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:
+138
-9
@@ -113,6 +113,123 @@ fn replay_three_lines_dispatches_three_commands() {
|
||||
assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() {
|
||||
// ADR-0034 §3 + Problem 3 (handoff-34 §4): `replay history.log`
|
||||
// must work. The journal is the pipe format
|
||||
// `<iso8601>|<status>|<source>`; replay extracts `<source>`, runs
|
||||
// `ok` records, and skips `err` ones (like blank / `#` lines — a
|
||||
// skipped failure is not a replay failure).
|
||||
//
|
||||
// This is the ADR-0034 headline reproduction. It is RED before the
|
||||
// fix: today `run_replay` feeds the whole `2026-…|ok|…` line to the
|
||||
// parser, which dies on line 1 (the timestamp is not a command).
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(
|
||||
project.path(),
|
||||
"history.log",
|
||||
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
|
||||
2026-05-24T10:00:01Z|ok|add column T: v (text)\n\
|
||||
2026-05-24T10:00:02Z|err|insert into T values (1, 2, 3, 4)\n\
|
||||
2026-05-24T10:00:03Z|ok|insert into T (id, v) values (1, 'alpha')\n",
|
||||
);
|
||||
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
// Three `ok` records replayed; the `err` record is skipped (not
|
||||
// counted, not a failure).
|
||||
assert_completed(&events, 3);
|
||||
|
||||
let data_result = rt()
|
||||
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
|
||||
.expect("query_data");
|
||||
assert_eq!(data_result.rows.len(), 1, "only the ok INSERT applied");
|
||||
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_skips_app_lifecycle_commands_silently() {
|
||||
// ADR-0034: a real `history.log` contains app-lifecycle commands
|
||||
// (`save as` / `load` / `new` / `export` / `mode` / `rebuild` …).
|
||||
// Replay skips them — they are session navigation, not schema/data
|
||||
// reconstruction, and the worker dispatch cannot run them (it would
|
||||
// panic on a parsed app command, or abort on the modal forms that
|
||||
// don't parse). These skip SILENTLY (no warning).
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
// Every silent-skip app-lifecycle form, including the modal forms
|
||||
// that don't parse on the command line (`save as` / `load` / `new`),
|
||||
// the bare incomplete form (`mode`), and the safety-critical `quit`
|
||||
// (a journalled quit must NOT quit during replay). None may abort;
|
||||
// none warns.
|
||||
write_script(
|
||||
project.path(),
|
||||
"history.log",
|
||||
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
|
||||
2026-05-24T10:00:01Z|ok|save as backup\n\
|
||||
2026-05-24T10:00:02Z|ok|load other\n\
|
||||
2026-05-24T10:00:03Z|ok|new scratch\n\
|
||||
2026-05-24T10:00:04Z|ok|mode advanced\n\
|
||||
2026-05-24T10:00:05Z|ok|mode\n\
|
||||
2026-05-24T10:00:06Z|ok|messages verbose\n\
|
||||
2026-05-24T10:00:07Z|ok|export out.zip\n\
|
||||
2026-05-24T10:00:08Z|ok|rebuild\n\
|
||||
2026-05-24T10:00:09Z|ok|help\n\
|
||||
2026-05-24T10:00:10Z|ok|quit\n\
|
||||
2026-05-24T10:00:11Z|ok|add column T: v (text)\n\
|
||||
2026-05-24T10:00:12Z|ok|insert into T (id, v) values (1, 'alpha')\n",
|
||||
);
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
// Three data/schema commands ran; every app-lifecycle line was
|
||||
// skipped silently (no panic, no abort, no warnings, no quit).
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
assert_eq!(*count, 3, "only the 3 write commands ran; events: {events:?}");
|
||||
assert!(warnings.is_empty(), "these skips are silent; got {warnings:?}");
|
||||
}
|
||||
other => panic!("expected ReplayCompleted, got {other:?}"),
|
||||
}
|
||||
let data_result = rt()
|
||||
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
|
||||
.expect("query_data");
|
||||
assert!(
|
||||
data_result.columns.iter().any(|c| c == "v"),
|
||||
"the add-column line applied; columns: {:?}",
|
||||
data_result.columns,
|
||||
);
|
||||
assert_eq!(data_result.rows.len(), 1, "the insert applied");
|
||||
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_skips_import_with_a_warning() {
|
||||
// ADR-0034: `import` is skipped like other app commands, but warns
|
||||
// — skipping it can leave the replayed state incomplete (the
|
||||
// imported data is not reconstructed).
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(
|
||||
project.path(),
|
||||
"history.log",
|
||||
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
|
||||
2026-05-24T10:00:01Z|ok|import shared.zip as Imported\n",
|
||||
);
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
assert_eq!(*count, 1, "only the create ran; events: {events:?}");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("import shared.zip")),
|
||||
"expected an import skip warning; got {warnings:?}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected ReplayCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_skips_blank_lines_and_comments() {
|
||||
let data = tempdir();
|
||||
@@ -307,7 +424,12 @@ fn replay_aborts_on_first_runtime_failure_and_reports_line() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_refuses_nested_replay() {
|
||||
fn replay_skips_nested_replay_with_a_warning() {
|
||||
// ADR-0034: a nested `replay` is no longer refused (which would
|
||||
// force a user to hand-edit a journal that happens to contain a
|
||||
// `replay` they once ran). It is skipped — sidestepping the
|
||||
// infinite-loop footgun by construction — and warned about,
|
||||
// because the nested file's commands are not reconstructed.
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(project.path(), "inner.commands", "create table T with pk id(int)\n");
|
||||
@@ -320,14 +442,21 @@ fn replay_refuses_nested_replay() {
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "outer.commands").await
|
||||
});
|
||||
let failed = assert_failed_at(&events, 2);
|
||||
let AppEvent::ReplayFailed { error, .. } = failed else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
error.contains("nested `replay`"),
|
||||
"expected nested-replay refusal: {error}"
|
||||
);
|
||||
// The outer `create table U` ran; the nested `replay` was
|
||||
// skipped (count 1), with a warning.
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
assert_eq!(*count, 1, "only the outer create ran; events: {events:?}");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("nested") && w.contains("replay inner.commands")),
|
||||
"expected a nested-replay skip warning; got {warnings:?}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected ReplayCompleted (nested replay skipped), got {other:?}"),
|
||||
}
|
||||
// The nested file's table was NOT created (the replay was skipped).
|
||||
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None, None).await });
|
||||
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user