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:
claude@clouddev1
2026-05-24 18:59:06 +00:00
parent 504c24c996
commit e4f2f5fa15
18 changed files with 730 additions and 76 deletions
+117 -7
View File
@@ -455,9 +455,14 @@ impl App {
command,
error,
facts,
source,
} => {
self.handle_dsl_failure(&command, error, facts);
Vec::new()
// ADR-0034 §1/§2: an execution failure is journalled
// `err` so it is recallable across sessions (the
// worker only journals successful commands). The App
// emits the intent; the runtime does the append.
vec![Action::JournalFailure { source }]
}
AppEvent::TablesRefreshed(tables) => {
trace!(count = tables.len(), "tables refreshed");
@@ -556,12 +561,22 @@ impl App {
self.note_error(crate::t!("project.export_failed", error = error));
Vec::new()
}
AppEvent::ReplayCompleted { path, count } => {
AppEvent::ReplayCompleted {
path,
count,
warnings,
} => {
self.note_system(crate::t!(
"replay.completed",
path = path,
count = count
));
// ADR-0034: surface `[skip]` warnings for app-lifecycle
// commands whose omission can leave the replayed state
// incomplete (`import`, nested `replay`).
for warning in warnings {
self.note_system(warning);
}
Vec::new()
}
AppEvent::ReplayFailed {
@@ -1207,7 +1222,14 @@ impl App {
if let ParseError::Invalid { .. } = &err {
self.note_error(render_usage_block(input));
}
Vec::new()
// ADR-0034 §1/§2: a submitted line that failed to
// parse is journalled `err` so it is recallable
// across sessions (the same `source` an `ok`
// command would record). The runtime does the
// append; the App only emits the intent.
vec![Action::JournalFailure {
source: input.to_string(),
}]
}
}
}
@@ -2308,7 +2330,12 @@ mod tests {
let mut app = App::new();
type_str(&mut app, "create table Customers");
let actions = submit(&mut app);
assert!(actions.is_empty());
// A definite parse error journals `err` (ADR-0034) and does
// not dispatch a command to the worker.
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected only a JournalFailure, no dispatch; got {actions:?}",
);
// Parse-error rendering is now multi-line (ADR-0021):
// caret + "parse error: …" + "usage: …" — the test
// checks that some error line mentions `with pk`.
@@ -2328,7 +2355,10 @@ mod tests {
let mut app = App::new();
type_str(&mut app, "frobulate widgets");
let actions = submit(&mut app);
assert!(actions.is_empty());
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"a definite parse error journals err without dispatching; got {actions:?}",
);
let has_parse_error = app
.output
.iter()
@@ -2351,7 +2381,10 @@ mod tests {
let mut app = App::new();
type_str(&mut app, "insert into T values (1, 2), (3, 4)");
let actions = submit(&mut app);
assert!(actions.is_empty(), "the bad line must not dispatch");
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"the bad line journals err but must not dispatch; got {actions:?}",
);
let has_pointer = app
.output
.iter()
@@ -2663,6 +2696,7 @@ mod tests {
app.update(AppEvent::ReplayCompleted {
path: "seed.commands".to_string(),
count: 4,
warnings: Vec::new(),
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::System);
@@ -2671,6 +2705,30 @@ mod tests {
assert!(last.text.contains("seed.commands"), "{}", last.text);
}
#[test]
fn replay_completed_event_renders_skip_warnings() {
// ADR-0034 Amendment 1: `[skip]` warnings (import / nested
// replay) surface in the output after the summary line.
let mut app = App::new();
app.update(AppEvent::ReplayCompleted {
path: "history.log".to_string(),
count: 2,
warnings: vec![
"[skip] line 3: `import a.zip` — replay does not re-import".to_string(),
"[skip] line 7: nested `replay x` — its commands were not replayed".to_string(),
],
});
let text: String = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("[ok] replay"), "summary present:\n{text}");
assert!(text.contains("import a.zip"), "import skip warning rendered:\n{text}");
assert!(text.contains("nested `replay x`"), "nested-replay skip warning rendered:\n{text}");
}
#[test]
fn replay_failed_event_renders_line_number_and_command_echo() {
let mut app = App::new();
@@ -2731,6 +2789,7 @@ mod tests {
kind: crate::db::SqliteErrorKind::NoSuchTable,
},
facts: crate::friendly::FailureContext::default(),
source: String::new(),
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
@@ -2788,6 +2847,7 @@ mod tests {
command: cmd,
error: err,
facts,
source: String::new(),
});
let body = app
.output
@@ -2836,6 +2896,7 @@ mod tests {
command: cmd,
error: err,
facts,
source: String::new(),
});
let body = app
.output
@@ -2867,6 +2928,7 @@ mod tests {
command: cmd.clone(),
error: err(),
facts: crate::friendly::FailureContext::default(),
source: String::new(),
});
let verbose_text = app
.output
@@ -2886,6 +2948,7 @@ mod tests {
command: cmd,
error: err(),
facts: crate::friendly::FailureContext::default(),
source: String::new(),
});
let short_text = app
.output
@@ -2920,7 +2983,10 @@ mod tests {
let mut app = App::new();
type_str(&mut app, "add column to table T: c (varchar)");
let actions = submit(&mut app);
assert!(actions.is_empty());
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected only a JournalFailure, no dispatch; got {actions:?}",
);
let mentions_varchar = app
.output
.iter()
@@ -3193,6 +3259,50 @@ mod tests {
);
}
#[test]
fn submitting_an_unparseable_line_emits_journal_failure() {
// ADR-0034 §1/§2: a submitted line that fails to parse is
// journalled `err` (recallable across sessions). The
// pure-sync App emits the intent; the runtime does the I/O.
let mut app = App::new();
type_str(&mut app, "florp glorp");
let actions = submit(&mut app);
assert!(
matches!(
actions.as_slice(),
[Action::JournalFailure { source }] if source == "florp glorp"
),
"expected JournalFailure for the typo'd line; got {actions:?}",
);
}
#[test]
fn dsl_failure_event_emits_journal_failure_carrying_the_source() {
// ADR-0034 §1/§2: an execution failure (the worker rejected
// a parsed command) is journalled `err` too. The runtime
// forwards the source on `DslFailed`; the App turns it into
// a `JournalFailure` action.
let mut app = App::new();
let actions = app.update(AppEvent::DslFailed {
command: Command::DropTable {
name: "Ghost".to_string(),
},
error: crate::db::DbError::Sqlite {
message: "no such table: Ghost".to_string(),
kind: crate::db::SqliteErrorKind::NoSuchTable,
},
facts: crate::friendly::FailureContext::default(),
source: "drop table Ghost".to_string(),
});
assert!(
matches!(
actions.as_slice(),
[Action::JournalFailure { source }] if source == "drop table Ghost"
),
"expected JournalFailure carrying the source; got {actions:?}",
);
}
#[test]
fn history_skips_consecutive_duplicates() {
let mut app = App::new();