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
+43 -1
View File
@@ -272,13 +272,30 @@ impl Persistence {
}
}
/// Append one record to `history.log`.
/// Append one successful-command record to `history.log`.
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let line = history::format_record(command_text, history::utc_iso8601_now());
history::append(&path, &line)
}
/// Append a failed-command record to `history.log`, tagged
/// `err` (ADR-0034 §1). Used by the runtime's error path so a
/// command that failed to parse or to execute is still
/// recallable across sessions (it never reaches the worker's
/// transactional `ok` journal). Best-effort at the call site:
/// a failure to record a failure must never escalate a user
/// error into a fatal (ADR-0034 §4).
pub fn append_history_failure(&self, command_text: &str) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
history::STATUS_ERR,
);
history::append(&path, &line)
}
/// Read the most-recent `max_n` sources out of
/// `history.log` for input-history hydration on project
/// open (ADR-0015 §12). Returned in chronological order
@@ -289,6 +306,31 @@ impl Persistence {
}
}
/// How `run_replay` should treat one already-trimmed,
/// non-blank, non-`#` line (ADR-0034 §3).
pub(crate) enum ReplayLine {
/// Run this command text — either a journal `ok` record's
/// extracted source, or a bare command verbatim.
Run(String),
/// A journal record whose status is not `ok` — skip it
/// silently (a skipped failure is not a replay failure).
Skip,
}
/// Classify one replay input line (ADR-0034 §3). A journal
/// record (`<ts>|<status>|<source>`) runs its source only when
/// `ok` and is skipped otherwise; any other line is a bare
/// command run verbatim. Detection is by the leading
/// timestamp+status prefix, so a bare command that merely
/// contains `|` (e.g. `select 'a|b' from t`) is run as-is.
pub(crate) fn classify_replay_line(line: &str) -> ReplayLine {
match history::parse_journal_record(line) {
Some(rec) if rec.status_is_ok => ReplayLine::Run(rec.source),
Some(_) => ReplayLine::Skip,
None => ReplayLine::Run(line.to_string()),
}
}
/// Write `body` to `path` atomically via temp file + fsync +
/// rename. The temp file is named `<final>.tmp` in the same
/// directory so the rename stays on the same filesystem.