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
+11
View File
@@ -24,6 +24,17 @@ pub enum Action {
command: Command,
source: String,
},
/// Record a *failed* submission to `history.log` as an `err`
/// record (ADR-0034 §1/§2). Emitted by the pure-sync `App`
/// for both failure kinds — a line that failed to parse (at
/// submit) and a command the worker rejected (on
/// `AppEvent::DslFailed`) — because the App does no I/O. The
/// runtime appends best-effort: a failure to record a failure
/// must never escalate a user error into a fatal (ADR-0034
/// §4). `source` is the original user-typed text.
JournalFailure {
source: String,
},
/// User issued the `rebuild` app-level command (ADR-0015
/// §7, §11). Runtime computes a summary from
/// `project.yaml` + `data/` and posts back as
+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();
+11
View File
@@ -76,6 +76,12 @@ pub enum AppEvent {
command: Command,
error: DbError,
facts: crate::friendly::FailureContext,
/// The original user-typed source line, retained so the
/// App can journal the failed command as an `err` record
/// (ADR-0034 §1/§2). The worker only journals successful
/// commands, so an execution failure would otherwise be
/// lost across sessions.
source: String,
},
/// Refreshed list of tables in the database.
TablesRefreshed(Vec<String>),
@@ -146,6 +152,11 @@ pub enum AppEvent {
ReplayCompleted {
path: String,
count: usize,
/// Pre-rendered `[skip]` warnings for app-lifecycle commands
/// whose omission can leave the replayed state incomplete —
/// `import` and a nested `replay` (ADR-0034). Other skipped
/// app commands are silent and do not appear here.
warnings: Vec<String>,
},
/// A `replay <path>` aborted at line `line_number`. `command`
/// is the line text as it appeared in the file (for the
+2 -1
View File
@@ -468,10 +468,11 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("replay.command_echo", &["command"]),
("replay.completed", &["path", "count"]),
("replay.error_could_not_open", &["path", "detail"]),
("replay.error_nested", &[]),
("replay.error_parse", &["detail"]),
("replay.failed_at_line", &["path", "line_number", "error"]),
("replay.failed_open", &["path", "error"]),
("replay.skipped_import", &["line", "command"]),
("replay.skipped_replay", &["line", "command"]),
// ---- UNIQUE violations (anchor: "already has the value") ----
(
"error.unique.insert.headline",
+7 -1
View File
@@ -842,4 +842,10 @@ replay:
# compose with `failed_at_line`'s `{error}` placeholder.
error_could_not_open: "could not open `{path}`: {detail}"
error_parse: "parse error: {detail}"
error_nested: "nested `replay` is not allowed inside a replay file"
# Skipped during replay (ADR-0034): app-lifecycle commands are
# not re-applied. Most skip silently; `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).
skipped_import: "[skip] line {line}: `{command}` — replay does not re-import; the imported data is not reconstructed"
skipped_replay: "[skip] line {line}: nested `{command}` — its commands were not replayed"
+136 -2
View File
@@ -22,10 +22,26 @@ use std::time::{SystemTime, UNIX_EPOCH};
use super::PersistenceError;
/// Format a single log record. Pure; no I/O.
/// Journal-record status tokens (ADR-0034 §1). Kept as named
/// constants so the writer and the readers (hydration + replay)
/// cannot drift on the spelling.
pub(super) const STATUS_OK: &str = "ok";
pub(super) const STATUS_ERR: &str = "err";
/// Format a successful-command record. Pure; no I/O.
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
format_record_with_status(command_text, timestamp_iso, STATUS_OK)
}
/// Format a record with an explicit status token (ADR-0034 §1).
/// Pure; no I/O.
pub(super) fn format_record_with_status(
command_text: &str,
timestamp_iso: String,
status: &str,
) -> String {
let escaped = escape_command(command_text);
format!("{timestamp_iso}|ok|{escaped}\n")
format!("{timestamp_iso}|{status}|{escaped}\n")
}
/// Read the most-recent `max_n` user-issued command sources
@@ -89,6 +105,54 @@ fn parse_record_source(line: &str) -> Option<String> {
Some(unescape_command(source))
}
/// A parsed journal record (ADR-0034 §3). `source` is already
/// unescaped.
pub(super) struct JournalRecord {
pub status_is_ok: bool,
pub source: String,
}
/// Classify `line` as a journal record or a bare command
/// (ADR-0034 §3). Returns `Some(JournalRecord)` only when the
/// line begins with a valid `<iso8601-timestamp>|<status>|`
/// prefix — so a bare command containing `|` (e.g.
/// `select 'a|b' from t`) is `None` (treated as bare by the
/// caller) because it does not start with a timestamp. A valid
/// timestamp prefix with a non-`ok` (or unrecognised) status is
/// still a journal record, reported with `status_is_ok = false`
/// so replay skips it rather than mis-running it as a command.
pub(super) fn parse_journal_record(line: &str) -> Option<JournalRecord> {
let mut parts = line.splitn(3, '|');
let ts = parts.next()?;
let status = parts.next()?;
let source = parts.next()?;
if !looks_like_iso8601(ts) {
return None;
}
Some(JournalRecord {
status_is_ok: status == STATUS_OK,
source: unescape_command(source),
})
}
/// True when `s` is exactly an `YYYY-MM-DDTHH:MM:SSZ` timestamp
/// — the shape `utc_iso8601_now` emits. Used to distinguish a
/// journal record's leading field from a bare command that
/// merely contains `|`.
fn looks_like_iso8601(s: &str) -> bool {
let b = s.as_bytes();
if b.len() != 20 {
return false;
}
let digit = |i: usize| b[i].is_ascii_digit();
digit(0) && digit(1) && digit(2) && digit(3) && b[4] == b'-'
&& digit(5) && digit(6) && b[7] == b'-'
&& digit(8) && digit(9) && b[10] == b'T'
&& digit(11) && digit(12) && b[13] == b':'
&& digit(14) && digit(15) && b[16] == b':'
&& digit(17) && digit(18) && b[19] == b'Z'
}
fn unescape_command(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
@@ -211,6 +275,76 @@ mod tests {
assert_eq!(line, "T|ok|foo\\nbar\n");
}
// ---- ADR-0034 §3 — journal-record detection for replay ----
#[test]
fn parse_journal_record_ok_extracts_unescaped_source() {
let rec = parse_journal_record(
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)",
)
.expect("valid ok journal record");
assert!(rec.status_is_ok);
assert_eq!(rec.source, "create table T with pk id(int)");
}
#[test]
fn parse_journal_record_err_is_record_but_not_ok() {
// A valid timestamp + `err` status is a journal record (so
// replay treats it as skippable), reported `status_is_ok =
// false`.
let rec = parse_journal_record("2026-05-24T10:00:02Z|err|insert into T values (1)")
.expect("valid err journal record");
assert!(!rec.status_is_ok);
}
#[test]
fn parse_journal_record_unknown_status_is_record_but_not_ok() {
// A valid timestamp + an unrecognised status is still a
// journal record (ADR-0034 §1 "readers ignore values they
// do not recognise"); replay skips it rather than running it.
let rec = parse_journal_record("2026-05-24T10:00:02Z|frobnicate|whatever")
.expect("valid-ts record with unknown status");
assert!(!rec.status_is_ok);
}
#[test]
fn parse_journal_record_rejects_bare_command_with_pipe() {
// A bare command that merely contains `|` must NOT be read
// as a journal record — its first field is not a timestamp.
assert!(parse_journal_record("select 'a|b' from t").is_none());
assert!(parse_journal_record("show data Orders").is_none());
}
#[test]
fn parse_journal_record_rejects_timestamp_ish_but_invalid_prefix() {
// Boundary: looks vaguely date-y but isn't the exact
// `YYYY-MM-DDTHH:MM:SSZ` shape → bare command.
assert!(parse_journal_record("2026-5-24|ok|x").is_none());
assert!(parse_journal_record("2026-05-24 10:00:00|ok|x").is_none());
assert!(parse_journal_record("notatimestamp|ok|x").is_none());
}
#[test]
fn parse_journal_record_preserves_pipe_in_source() {
// `|` is not escaped by the writer (it's a valid SQL char);
// `splitn(3, '|')` keeps everything after the second `|`.
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t")
.expect("ok record");
assert_eq!(rec.source, "select 'a|b' from t");
}
#[test]
fn parse_journal_record_round_trips_a_written_record() {
// What `format_record` writes, `parse_journal_record` reads
// back to the original command (escape→unescape lossless for
// the awkward cases).
let cmd = "update T set v = 'x\\y' where id = 1";
let line = format_record(cmd, "2026-05-24T10:00:00Z".to_string());
let rec = parse_journal_record(line.trim_end()).expect("round-trip record");
assert!(rec.status_is_ok);
assert_eq!(rec.source, cmd);
}
#[test]
fn backslash_is_escaped() {
let line = format_record("a\\b", "T".to_string());
+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.
+94 -37
View File
@@ -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