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
+7 -2
View File
@@ -182,8 +182,13 @@ fn import_with_empty_target_after_as_errors() {
let actions = submit(&mut app);
// "as " trailing whitespace is trimmed by .split_once + .trim,
// making the as-target empty. We surface this as a usage
// error rather than silently importing without a target.
assert!(actions.is_empty());
// error rather than silently importing without a target. The
// failed line is journalled `err` (ADR-0034) but no import
// dispatches.
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected only a JournalFailure, no import dispatch; got {actions:?}",
);
// The friendly parse-error rendering produces multiple
// output lines (caret, message, usage). Scan for the anchor
// phrase rather than asserting on the final line. The
+25
View File
@@ -155,6 +155,31 @@ fn read_recent_history_returns_appended_entries_in_order() {
);
}
#[test]
fn hydration_reads_both_ok_and_err_records() {
// ADR-0034 §1/§2: failed commands are journalled `err`, and
// input-history hydration reads ALL records (ok + err) so a
// typo'd / rejected command from a previous session is
// recallable after restart — matching the in-session ring's
// "record everything" behaviour.
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk").unwrap();
p.append_history_failure("insert into A (1, 2, 3)").unwrap();
p.append_history("show data A").unwrap();
let entries = p.read_recent_history(10).unwrap();
assert_eq!(
entries,
vec![
"create table A with pk".to_string(),
"insert into A (1, 2, 3)".to_string(), // the err record is recalled
"show data A".to_string(),
],
"hydration includes the err record",
);
}
#[test]
fn seed_history_replaces_in_memory_history() {
let mut app = App::new();
+5 -4
View File
@@ -34,15 +34,16 @@ fn submit(app: &mut App) -> Vec<Action> {
}
/// Run `input` through the app and return every error-kind
/// output line. Asserts the submission produced no actions
/// (i.e. the parse failed).
/// output line. Asserts the submission parse-failed — which now
/// emits exactly a `JournalFailure` (ADR-0034: the failed line is
/// journalled `err`) and dispatches no command to the worker.
fn error_lines_for(input: &str) -> Vec<String> {
let mut app = App::new();
type_str(&mut app, input);
let actions = submit(&mut app);
assert!(
actions.is_empty(),
"expected parse failure (no actions) for {input:?}, got {actions:?}",
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected parse failure (only a JournalFailure) for {input:?}, got {actions:?}",
);
app.output
.iter()
+138 -9
View File
@@ -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]
+6 -4
View File
@@ -81,9 +81,11 @@ fn simple_mode_select_yields_sql_hint_and_does_not_dispatch() {
assert_eq!(app.mode, Mode::Simple);
type_str(&mut app, "select * from anywhere");
let actions = submit(&mut app);
// The failed simple-mode submission is journalled `err`
// (ADR-0034) but dispatches no command.
assert!(
actions.is_empty(),
"simple-mode `select` must not produce a dispatch action; got {actions:?}",
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"simple-mode `select` must not dispatch (only journal err); got {actions:?}",
);
// The error output spans multiple lines (the message and a
// caret pointer). The hint catalog key
@@ -135,8 +137,8 @@ fn advanced_mode_select_from_internal_table_is_rejected() {
type_str(&mut app, "select * from __rdbms_playground_columns");
let actions = submit(&mut app);
assert!(
actions.is_empty(),
"internal-table reference must not dispatch; got {actions:?}",
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"internal-table reference must not dispatch (only journal err); got {actions:?}",
);
let error_text: String = app
.output
+7 -1
View File
@@ -108,7 +108,12 @@ fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() {
let theme = Theme::dark();
type_str(&mut app, "hello world");
let actions = submit(&mut app);
assert!(actions.is_empty());
// The failed line journals `err` (ADR-0034) but does not echo
// or dispatch a command.
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected only a JournalFailure; got {actions:?}",
);
let rendered = rendered_text(&mut app, &theme, 80, 24);
assert!(
rendered.contains("parse error"),
@@ -605,6 +610,7 @@ fn dsl_failure_shows_friendly_error_in_output() {
kind: rdbms_playground::db::SqliteErrorKind::NoSuchTable,
},
facts: rdbms_playground::friendly::FailureContext::default(),
source: String::new(),
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(