@@ -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}
0 commit comments