Skip to content

Commit 151ff69

Browse files
authored
fix(journal): v0.6.26 — one-time SQLite->plugin import so re-enabling the journal preserves run history (#293)
* feat(journal): one-time local SQLite -> workflow_journal plugin import (BU-1H) Re-enabling the durable workflow_journal plugin blanked the portal's run-history view: the summary readers route to the (empty) plugin store while 391 existing runs lived only in local SQLite. Add a one-time, idempotent boot migration that copies every run + its checkpoints from the local SQLite engine into the active plugin via journal/save + journal/checkpoint_save, then writes a marker (.journal-imported-v1, next to workflow.db) to skip re-scanning. - journal_client::import_local_sqlite_into_plugin: resolves the backend; no-op (skipped) for the SQLite path (no plugin / kill-switch) and when the marker exists. Reads the LOCAL SQLite store directly via a Sqlite-pinned WorkflowStateManager (new_sqlite) + sqlite_all_run_ids, iterating one run at a time (bounded memory) and upserting into the plugin through an internal JournalImportSink seam. - Idempotent + safe: saves are upserts; the marker is written ONLY after a clean full pass, so a partial import retries next boot. Reads never mutate SQLite (no data loss). - Daemon hook in run_daemon.rs, right after the journal sink is wired and before reconcile reads the journal. Best-effort: a failure is logged and never blocks startup (kill-switch remains the escape hatch). - Tests: copy N runs+checkpoints (marker written), marker-present skip, empty store (marker written, 0 saves), SQLite backend no-op. * chore(release): v0.6.26 — one-time SQLite->journal-plugin import (fixes run-history blank on journal cutover)
1 parent 988e281 commit 151ff69

6 files changed

Lines changed: 323 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/orchestrator-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "orchestrator-cli"
3-
version = "0.6.25"
3+
version = "0.6.26"
44
edition = "2021"
55
license = "Elastic-2.0"
66
default-run = "animus"

crates/orchestrator-core/src/workflow.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ mod state_machine;
66
mod state_manager;
77

88
pub use journal_client::{
9-
record_wire_event as journal_record_wire_event, shutdown_resident_hosts as shutdown_journal_hosts,
9+
import_local_sqlite_into_plugin, record_wire_event as journal_record_wire_event,
10+
shutdown_resident_hosts as shutdown_journal_hosts, JournalImportStats,
1011
};
1112

1213
pub use lifecycle_executor::WorkflowLifecycleExecutor;

crates/orchestrator-core/src/workflow/journal_client.rs

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,179 @@ pub(crate) fn reset_backend_cache_for_tests() {
137137
backend_cache().lock().unwrap_or_else(|p| p.into_inner()).clear();
138138
}
139139

140+
// ---------------------------------------------------------------------------
141+
// BU-1H: one-time local SQLite -> plugin import.
142+
// ---------------------------------------------------------------------------
143+
144+
/// Marker file (next to the local `workflow.db`) recording that the one-time
145+
/// import of the local SQLite run history into the `workflow_journal` plugin has
146+
/// completed for this project root. Its presence skips the (otherwise idempotent)
147+
/// re-scan on every boot.
148+
const JOURNAL_IMPORT_MARKER_FILE: &str = ".journal-imported-v1";
149+
150+
/// Outcome of [`import_local_sqlite_into_plugin`].
151+
#[derive(Debug, Default, Clone, PartialEq, Eq)]
152+
pub struct JournalImportStats {
153+
/// Runs upserted into the plugin via `journal/save`.
154+
pub runs_imported: usize,
155+
/// Checkpoints upserted via `journal/checkpoint_save`.
156+
pub checkpoints_imported: usize,
157+
/// `true` when the import did not run because there was nothing to do at the
158+
/// gate: no plugin backend active (SQLite / kill-switch) or the marker already
159+
/// existed. `false` means the scan ran (possibly importing 0 rows from an
160+
/// empty/absent local store, in which case the marker is now written).
161+
pub skipped: bool,
162+
}
163+
164+
impl JournalImportStats {
165+
fn skipped() -> Self {
166+
Self { skipped: true, ..Default::default() }
167+
}
168+
}
169+
170+
/// Sink the import writes scanned runs/checkpoints to. The production impl
171+
/// forwards to the resident `workflow_journal` plugin; tests use an in-memory
172+
/// recorder so the scan + marker logic is exercised without spawning a plugin.
173+
trait JournalImportSink {
174+
fn save_run(&mut self, workflow: &OrchestratorWorkflow) -> Result<()>;
175+
fn save_checkpoint(&mut self, workflow_id: &str, checkpoint_num: usize, blob: serde_json::Value) -> Result<()>;
176+
}
177+
178+
struct PluginImportSink {
179+
plugin: DiscoveredPlugin,
180+
project_root: PathBuf,
181+
}
182+
183+
impl JournalImportSink for PluginImportSink {
184+
fn save_run(&mut self, workflow: &OrchestratorWorkflow) -> Result<()> {
185+
save(&self.plugin, &self.project_root, workflow)
186+
}
187+
fn save_checkpoint(&mut self, workflow_id: &str, checkpoint_num: usize, blob: serde_json::Value) -> Result<()> {
188+
checkpoint_save(&self.plugin, &self.project_root, workflow_id, checkpoint_num, blob)
189+
}
190+
}
191+
192+
fn import_marker_path(project_root: &Path) -> PathBuf {
193+
super::state_manager::db_path_for_project(project_root).with_file_name(JOURNAL_IMPORT_MARKER_FILE)
194+
}
195+
196+
fn write_import_marker(marker: &Path) -> Result<()> {
197+
if let Some(parent) = marker.parent() {
198+
std::fs::create_dir_all(parent).ok();
199+
}
200+
std::fs::File::create(marker).with_context(|| format!("creating journal import marker at {}", marker.display()))?;
201+
Ok(())
202+
}
203+
204+
/// One-time migration: copy every run + its checkpoints from the LOCAL SQLite
205+
/// store into the active `workflow_journal` PLUGIN, so re-enabling the durable
206+
/// backend does not blank the run-history view (the summary readers route to the
207+
/// plugin store; without this, an installed-but-empty plugin shows no history).
208+
///
209+
/// No-op (returns `skipped`) when the SQLite backend is active (no plugin
210+
/// installed / kill-switch set) — the local path never imports. No-op when the
211+
/// marker already exists.
212+
///
213+
/// IDEMPOTENT + SAFE: `journal/save` and `journal/checkpoint_save` are upserts,
214+
/// so a re-run (e.g. after a mid-import failure left the marker unwritten) merely
215+
/// rewrites the same rows. The marker is written ONLY after a clean full pass, so
216+
/// a partial import is retried on the next boot rather than silently truncated.
217+
pub fn import_local_sqlite_into_plugin(project_root: &Path) -> Result<JournalImportStats> {
218+
let plugin = match resolve_backend(project_root) {
219+
JournalBackend::Sqlite => return Ok(JournalImportStats::skipped()),
220+
JournalBackend::Plugin(plugin) => *plugin,
221+
};
222+
let mut sink = PluginImportSink { plugin, project_root: project_root.to_path_buf() };
223+
import_local_sqlite_into_sink(project_root, &mut sink)
224+
}
225+
226+
/// Backend-agnostic core of the import: scan the LOCAL SQLite store, forward each
227+
/// run + checkpoints to `sink`, and write the marker on a clean pass. Separated
228+
/// from [`import_local_sqlite_into_plugin`] so the scan + marker logic is unit
229+
/// testable with an in-memory sink (the plugin RPC path needs a live plugin).
230+
fn import_local_sqlite_into_sink<S: JournalImportSink>(
231+
project_root: &Path,
232+
sink: &mut S,
233+
) -> Result<JournalImportStats> {
234+
let marker = import_marker_path(project_root);
235+
if marker.exists() {
236+
return Ok(JournalImportStats::skipped());
237+
}
238+
239+
// Read the LOCAL SQLite engine directly, regardless of the active backend.
240+
let sqlite = super::state_manager::WorkflowStateManager::new_sqlite(project_root);
241+
let ids = super::state_manager::sqlite_all_run_ids(project_root)?;
242+
243+
if ids.is_empty() {
244+
// Nothing to import (empty or absent local store): mark done so we never
245+
// re-scan, and report a non-skipped 0/0 pass.
246+
write_import_marker(&marker)?;
247+
return Ok(JournalImportStats::default());
248+
}
249+
250+
let total = ids.len();
251+
let mut stats = JournalImportStats::default();
252+
for (idx, id) in ids.iter().enumerate() {
253+
// Load + forward one run at a time so the full set never resides in memory.
254+
let workflow = match sqlite.load(id) {
255+
Ok(workflow) => workflow,
256+
Err(err) => {
257+
tracing::warn!(
258+
target: "animus.workflow.journal",
259+
workflow_id = %id,
260+
error = %err,
261+
"skipping unreadable local run during workflow_journal import"
262+
);
263+
continue;
264+
}
265+
};
266+
sink.save_run(&workflow).with_context(|| format!("importing run {id} into workflow_journal plugin"))?;
267+
stats.runs_imported += 1;
268+
269+
let checkpoint_nums = sqlite.list_checkpoints(id).unwrap_or_default();
270+
for checkpoint_num in checkpoint_nums {
271+
match sqlite.load_checkpoint(id, checkpoint_num) {
272+
Ok(snapshot) => {
273+
let blob = serde_json::to_value(&snapshot)
274+
.with_context(|| format!("serializing checkpoint snapshot {id}#{checkpoint_num}"))?;
275+
sink.save_checkpoint(id, checkpoint_num, blob)
276+
.with_context(|| format!("importing checkpoint {id}#{checkpoint_num}"))?;
277+
stats.checkpoints_imported += 1;
278+
}
279+
Err(err) => {
280+
tracing::warn!(
281+
target: "animus.workflow.journal",
282+
workflow_id = %id,
283+
checkpoint = checkpoint_num,
284+
error = %err,
285+
"skipping unreadable local checkpoint during workflow_journal import"
286+
);
287+
}
288+
}
289+
}
290+
291+
if (idx + 1) % 50 == 0 {
292+
tracing::info!(
293+
target: "animus.workflow.journal",
294+
imported = idx + 1,
295+
total,
296+
"workflow_journal import progress"
297+
);
298+
}
299+
}
300+
301+
// Marker written only after a clean full pass: a mid-import failure (the `?`
302+
// above) leaves it absent so the next boot retries (saves are upserts).
303+
write_import_marker(&marker)?;
304+
tracing::info!(
305+
target: "animus.workflow.journal",
306+
runs = stats.runs_imported,
307+
checkpoints = stats.checkpoints_imported,
308+
"workflow_journal local SQLite import complete"
309+
);
310+
Ok(stats)
311+
}
312+
140313
// ---------------------------------------------------------------------------
141314
// Blob <-> OrchestratorWorkflow conversions
142315
// ---------------------------------------------------------------------------
@@ -706,4 +879,104 @@ mod tests {
706879
fn env_flag_enabled_value(v: &str) -> bool {
707880
matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on")
708881
}
882+
883+
// --- BU-1H import migration ---------------------------------------------
884+
885+
use crate::types::CheckpointReason;
886+
use crate::workflow::state_manager::WorkflowStateManager;
887+
888+
#[derive(Default)]
889+
struct RecordingSink {
890+
runs: Vec<String>,
891+
checkpoints: Vec<(String, usize)>,
892+
}
893+
894+
impl JournalImportSink for RecordingSink {
895+
fn save_run(&mut self, workflow: &OrchestratorWorkflow) -> Result<()> {
896+
self.runs.push(workflow.id.clone());
897+
Ok(())
898+
}
899+
fn save_checkpoint(
900+
&mut self,
901+
workflow_id: &str,
902+
checkpoint_num: usize,
903+
_blob: serde_json::Value,
904+
) -> Result<()> {
905+
self.checkpoints.push((workflow_id.to_string(), checkpoint_num));
906+
Ok(())
907+
}
908+
}
909+
910+
#[test]
911+
fn import_copies_runs_and_checkpoints_and_writes_marker() {
912+
crate::test_env::stable_test_home();
913+
let dir = tempfile::tempdir().expect("tempdir");
914+
let sqlite = WorkflowStateManager::new_sqlite(dir.path());
915+
916+
let wf_a = sample_workflow("import-a");
917+
let wf_b = sample_workflow("import-b");
918+
sqlite.save(&wf_a).expect("save a");
919+
sqlite.save(&wf_b).expect("save b");
920+
// Two checkpoints on one run, none on the other.
921+
sqlite.save_checkpoint(&wf_a, CheckpointReason::Start).expect("cp1");
922+
sqlite.save_checkpoint(&wf_a, CheckpointReason::StatusChange).expect("cp2");
923+
924+
let mut sink = RecordingSink::default();
925+
let stats = import_local_sqlite_into_sink(dir.path(), &mut sink).expect("import");
926+
927+
assert!(!stats.skipped);
928+
assert_eq!(stats.runs_imported, 2);
929+
assert_eq!(stats.checkpoints_imported, 2);
930+
assert_eq!(sink.runs.len(), 2);
931+
assert!(sink.runs.contains(&"import-a".to_string()));
932+
assert!(sink.runs.contains(&"import-b".to_string()));
933+
assert_eq!(sink.checkpoints, vec![("import-a".to_string(), 1), ("import-a".to_string(), 2)]);
934+
assert!(import_marker_path(dir.path()).exists(), "marker written after a clean pass");
935+
}
936+
937+
#[test]
938+
fn import_skips_when_marker_present() {
939+
crate::test_env::stable_test_home();
940+
let dir = tempfile::tempdir().expect("tempdir");
941+
let sqlite = WorkflowStateManager::new_sqlite(dir.path());
942+
sqlite.save(&sample_workflow("import-c")).expect("save");
943+
944+
// Pre-write the marker: the scan must not run.
945+
write_import_marker(&import_marker_path(dir.path())).expect("write marker");
946+
947+
let mut sink = RecordingSink::default();
948+
let stats = import_local_sqlite_into_sink(dir.path(), &mut sink).expect("import");
949+
950+
assert!(stats.skipped);
951+
assert_eq!(stats.runs_imported, 0);
952+
assert!(sink.runs.is_empty(), "no saves when marker present");
953+
}
954+
955+
#[test]
956+
fn import_empty_sqlite_writes_marker_and_imports_nothing() {
957+
crate::test_env::stable_test_home();
958+
let dir = tempfile::tempdir().expect("tempdir");
959+
// No runs saved: the local store is empty/absent.
960+
961+
let mut sink = RecordingSink::default();
962+
let stats = import_local_sqlite_into_sink(dir.path(), &mut sink).expect("import");
963+
964+
assert!(!stats.skipped, "an empty store still completes a (0-row) pass");
965+
assert_eq!(stats.runs_imported, 0);
966+
assert!(sink.runs.is_empty());
967+
assert!(import_marker_path(dir.path()).exists(), "marker written so we never re-scan");
968+
}
969+
970+
#[test]
971+
fn import_is_a_noop_for_sqlite_backend() {
972+
crate::test_env::stable_test_home();
973+
reset_backend_cache_for_tests();
974+
let dir = tempfile::tempdir().expect("tempdir");
975+
// No plugin installed => SQLite backend => the public entrypoint must not
976+
// touch SQLite and must report skipped (no marker written).
977+
let stats = import_local_sqlite_into_plugin(dir.path()).expect("import");
978+
assert!(stats.skipped);
979+
assert_eq!(stats.runs_imported, 0);
980+
assert!(!import_marker_path(dir.path()).exists(), "no marker for the SQLite path");
981+
}
709982
}

crates/orchestrator-core/src/workflow/state_manager.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ impl WorkflowStateManager {
215215
Self { project_root, backend }
216216
}
217217

218+
/// Construct a manager PINNED to the in-tree SQLite backend, ignoring any
219+
/// installed `workflow_journal` plugin. Used by the one-time journal import
220+
/// migration ([`super::journal_client::import_local_sqlite_into_plugin`]) so it
221+
/// can read the LOCAL store directly while the active backend is the plugin.
222+
pub(crate) fn new_sqlite(project_root: impl Into<PathBuf>) -> Self {
223+
Self { project_root: project_root.into(), backend: super::journal_client::JournalBackend::Sqlite }
224+
}
225+
218226
/// The installed `workflow_journal` plugin, if the backend resolved to one.
219227
/// `None` => SQLite (run every existing code path unchanged).
220228
fn journal_plugin(&self) -> Option<&orchestrator_plugin_host::DiscoveredPlugin> {
@@ -1136,6 +1144,17 @@ pub fn load_workflow_ref_index(project_root: &std::path::Path) -> Result<std::co
11361144
Ok(out)
11371145
}
11381146

1147+
/// All run ids in the LOCAL SQLite store, regardless of status (terminal +
1148+
/// non-terminal). Reads the SQLite engine directly (never the plugin backend);
1149+
/// used by the one-time journal import migration to enumerate runs cheaply
1150+
/// (ids only) so each run is loaded + forwarded individually, bounding memory.
1151+
pub(crate) fn sqlite_all_run_ids(project_root: &std::path::Path) -> Result<Vec<String>> {
1152+
let conn = open_project_db(project_root)?;
1153+
let mut stmt = conn.prepare("SELECT id FROM workflows")?;
1154+
let ids = stmt.query_map([], |row| row.get::<_, String>(0))?.filter_map(|row| row.ok()).collect();
1155+
Ok(ids)
1156+
}
1157+
11391158
pub fn db_path_for_project(project_root: &std::path::Path) -> PathBuf {
11401159
protocol::scoped_state_root(project_root).expect("scoped_state_root requires a home directory").join("workflow.db")
11411160
}

crates/orchestrator-daemon-runtime/src/daemon/run_daemon.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,33 @@ where
287287
// BU-3: tee phase/run lifecycle events into an installed workflow_journal
288288
// plugin (no-op when none is installed — the SQLite backend has no events).
289289
workflow_event_broadcaster.set_journal_sink(std::path::PathBuf::from(project_root));
290+
291+
// BU-1H: one-time migration of the local SQLite run history into the durable
292+
// workflow_journal plugin, BEFORE the scheduler/reconcile reads the journal.
293+
// No-op when the SQLite backend is active (no plugin / kill-switch) or when
294+
// the marker already exists. Best-effort: a failure is logged and never
295+
// blocks daemon startup (the kill-switch remains the escape hatch). Runs once
296+
// per boot (this startup path executes once).
297+
match orchestrator_core::workflow::import_local_sqlite_into_plugin(std::path::Path::new(project_root)) {
298+
Ok(stats) if !stats.skipped => {
299+
tracing::info!(
300+
target: "animus.workflow.journal",
301+
runs = stats.runs_imported,
302+
checkpoints = stats.checkpoints_imported,
303+
"workflow_journal local SQLite import pass"
304+
);
305+
}
306+
Ok(_) => {}
307+
Err(err) => {
308+
tracing::warn!(
309+
target: "animus.workflow.journal",
310+
error = %err,
311+
"workflow_journal local SQLite import failed (run history may be incomplete until next boot; \
312+
set ANIMUS_DISABLE_WORKFLOW_JOURNAL_PLUGIN=1 to fall back to local SQLite)"
313+
);
314+
}
315+
}
316+
290317
install_workflow_event_emitter(BroadcastWorkflowEventEmitter::new(workflow_event_broadcaster.clone()));
291318
install_workflow_event_broadcaster(workflow_event_broadcaster.clone());
292319

0 commit comments

Comments
 (0)