feat: ADR-0034 — history journal records err + replay parses/filters the journal
Replay (§3): run_replay parses <ts>|<status>|<source> journal records — runs ok, skips non-ok — while still accepting bare .commands scripts (prefix-detected so a | inside a bare command isn't misread). Fixes replay history.log, which died on line 1. Journal failures (§1/§2): failed commands are recorded err via a new Action::JournalFailure, emitted by the pure-sync App for both parse failures and worker-execution failures (runtime appends best-effort, never fatal). Hydration reads all records so typo'd/rejected commands are recallable across sessions. Amendment 1 — replay filters app-lifecycle commands: a working replay history.log exposed that the journal also records save as/load/new/export/import/rebuild/mode (which would panic the worker dispatch or abort replay). Replay now re-applies only schema/data writes and skips every app-lifecycle command + nested replay, classified by entry word so modal/incomplete forms (save as, bare mode) and quit skip uniformly rather than aborting. All skips continue (reversing the nested-replay refusal); import and nested replay warn. replay.error_nested removed; replay.skipped_import/_replay added; ReplayCompleted carries warnings. requirements.md U3/U4 updated; app-command runtime-failure journalling tracked as a follow-up. 1659 passing / 0 failing / 0 skipped / 1 ignored. Clippy clean.
This commit is contained in:
+94
-37
@@ -384,6 +384,19 @@ async fn run_loop(
|
||||
source,
|
||||
);
|
||||
}
|
||||
Action::JournalFailure { source } => {
|
||||
// ADR-0034 §1/§4: record a failed command as an
|
||||
// `err` record. Best-effort — a failure to record
|
||||
// a failure must never escalate a user error into
|
||||
// a fatal, so the result is logged and ignored.
|
||||
if let Err(e) = crate::persistence::Persistence::new(
|
||||
session.project().path().to_path_buf(),
|
||||
)
|
||||
.append_history_failure(&source)
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to journal err record (ignored)");
|
||||
}
|
||||
}
|
||||
Action::PrepareRebuild => {
|
||||
spawn_prepare_rebuild(
|
||||
session.project().path().to_path_buf(),
|
||||
@@ -1143,6 +1156,9 @@ fn spawn_dsl_dispatch(
|
||||
source: String,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
// Retain the source for `DslFailed` so the App can journal a
|
||||
// rejected command as `err` (ADR-0034 §1/§2).
|
||||
let source_for_journal = source.clone();
|
||||
let outcome = execute_command_typed(&database, command.clone(), source).await;
|
||||
let event = match outcome {
|
||||
Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded {
|
||||
@@ -1199,6 +1215,7 @@ fn spawn_dsl_dispatch(
|
||||
command: command.clone(),
|
||||
error,
|
||||
facts,
|
||||
source: source_for_journal,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1627,65 +1644,88 @@ pub async fn run_replay(
|
||||
};
|
||||
|
||||
let mut count: usize = 0;
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
for (idx, raw) in body.lines().enumerate() {
|
||||
let line_number = idx + 1;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// Parse the line through the same DSL parser the
|
||||
// interactive path uses. The schema is re-snapshotted
|
||||
// every line because earlier replayed commands
|
||||
// (`create table`, `add column`, …) mutate it — so
|
||||
// Phase D typed-slot rejections (wrong-count value
|
||||
// lists, wrong-type column values) fire at replay
|
||||
// parse time, matching the interactive path, rather
|
||||
// than only at bind time. A failure here is structural
|
||||
// (bad syntax / typed-slot reject) — report and stop
|
||||
// without dispatching.
|
||||
// ADR-0034 §3: a journal record (`<ts>|<status>|<source>`)
|
||||
// contributes its extracted source when `ok` and is skipped
|
||||
// otherwise; any other line is a bare command run verbatim.
|
||||
// This is what makes `replay history.log` work without
|
||||
// breaking hand-written `.commands` scripts.
|
||||
let command_text = match crate::persistence::classify_replay_line(trimmed) {
|
||||
crate::persistence::ReplayLine::Skip => continue,
|
||||
crate::persistence::ReplayLine::Run(text) => text,
|
||||
};
|
||||
// ADR-0034 Amendment 1: classify by entry word BEFORE parsing.
|
||||
// App-lifecycle commands and a nested `replay` are skipped
|
||||
// during replay — they are session navigation, not schema/data
|
||||
// reconstruction (and the worker dispatch cannot run them).
|
||||
// Classifying by the leading word handles the modal forms
|
||||
// (`save as` / `load` / `new`) and any incomplete app form
|
||||
// uniformly: ALL such skips continue, never aborting the
|
||||
// replay. `import` and a nested `replay` warn, because skipping
|
||||
// them can leave the replayed state incomplete (imported data /
|
||||
// the nested file's commands are not reconstructed); the rest
|
||||
// skip silently. Skipping a nested `replay` also closes the
|
||||
// infinite-loop footgun by construction.
|
||||
let entry = command_text
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if entry == "import" {
|
||||
warnings.push(crate::t!(
|
||||
"replay.skipped_import",
|
||||
line = line_number,
|
||||
command = command_text
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if entry == "replay" {
|
||||
warnings.push(crate::t!(
|
||||
"replay.skipped_replay",
|
||||
line = line_number,
|
||||
command = command_text
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if is_app_lifecycle_entry_word(&entry) {
|
||||
continue;
|
||||
}
|
||||
// A schema/data write (or read, or a genuine typo). Parse with
|
||||
// the live schema — re-snapshotted every line because earlier
|
||||
// replayed commands mutate it (so Phase D typed-slot rejections
|
||||
// fire at parse time, matching the interactive path). A parse
|
||||
// failure here is a genuine malformed command (not an app
|
||||
// command, which was skipped above) — report it with the line
|
||||
// number and stop.
|
||||
let schema = build_schema_cache(database).await;
|
||||
// Replay parses each line like an interactive submission and
|
||||
// executes the resulting command — in advanced mode (the full
|
||||
// surface). A bad value in a shared-entry-word DML line is
|
||||
// caught either at parse time (a DSL form's typed slot) or at
|
||||
// execute time (the engine's column-type enforcement); either
|
||||
// way the offending line fails and replay stops without
|
||||
// applying it.
|
||||
let command = match crate::dsl::parser::parse_command_with_schema(
|
||||
trimmed, &schema,
|
||||
&command_text, &schema,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
events.push(AppEvent::ReplayFailed {
|
||||
path: path.to_string(),
|
||||
line_number,
|
||||
command: trimmed.to_string(),
|
||||
command: command_text.clone(),
|
||||
error: crate::t!("replay.error_parse", detail = e),
|
||||
});
|
||||
return events;
|
||||
}
|
||||
};
|
||||
// Nested replay is intentionally refused. Allowing it
|
||||
// would invite easy infinite-loop footguns (a script
|
||||
// replaying itself, two scripts replaying each other);
|
||||
// the DSL is small enough that we'd rather close that
|
||||
// door than design fences around it. A real
|
||||
// composition story lands later if the need is proven.
|
||||
if matches!(command, Command::Replay { .. }) {
|
||||
events.push(AppEvent::ReplayFailed {
|
||||
path: path.to_string(),
|
||||
line_number,
|
||||
command: trimmed.to_string(),
|
||||
error: crate::t!("replay.error_nested"),
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
// Dispatch through the same path as interactive input so
|
||||
// per-command persistence (history.log, project.yaml,
|
||||
// CSVs) fires as if the user had typed each line.
|
||||
// CSVs) fires as if the user had typed each line. The
|
||||
// source re-journalled is the *extracted* command, not the
|
||||
// raw `<ts>|ok|…` record (ADR-0034 §3).
|
||||
let outcome =
|
||||
execute_command_typed(database, command, trimmed.to_string()).await;
|
||||
execute_command_typed(database, command, command_text.clone()).await;
|
||||
match outcome {
|
||||
Ok(_) => {
|
||||
count += 1;
|
||||
@@ -1709,7 +1749,7 @@ pub async fn run_replay(
|
||||
events.push(AppEvent::ReplayFailed {
|
||||
path: path.to_string(),
|
||||
line_number,
|
||||
command: trimmed.to_string(),
|
||||
command: command_text.clone(),
|
||||
error: e.friendly_message(),
|
||||
});
|
||||
return events;
|
||||
@@ -1720,10 +1760,27 @@ pub async fn run_replay(
|
||||
events.push(AppEvent::ReplayCompleted {
|
||||
path: path.to_string(),
|
||||
count,
|
||||
warnings,
|
||||
});
|
||||
events
|
||||
}
|
||||
|
||||
/// True when `entry` (a lowercased leading command word) is an
|
||||
/// app-lifecycle command that replay skips (ADR-0034 Amendment 1).
|
||||
/// `import` and `replay` are handled separately by the caller
|
||||
/// (they warn); this is the silent-skip set. Mirrors the
|
||||
/// `AppCommand` entry words (see `src/completion.rs`'s
|
||||
/// `empty_input_offers_app_command_entry_keywords`); both must list
|
||||
/// the same words. A new `AppCommand` must be added here so replay
|
||||
/// skips it rather than aborting. `q` is intentionally absent: it is
|
||||
/// not a recognised command, so a `q` line is a genuine error.
|
||||
fn is_app_lifecycle_entry_word(entry: &str) -> bool {
|
||||
matches!(
|
||||
entry,
|
||||
"save" | "load" | "new" | "export" | "mode" | "messages" | "rebuild" | "help" | "quit"
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve a `replay <path>` argument: absolute paths pass
|
||||
/// through unchanged; relative paths are joined under the active
|
||||
/// project's root so `replay history.log` works without ceremony
|
||||
|
||||
Reference in New Issue
Block a user