11import { createHash } from 'node:crypto' ;
2- import { mkdirSync } from 'node:fs' ;
2+ import {
3+ existsSync ,
4+ mkdirSync ,
5+ renameSync ,
6+ } from 'node:fs' ;
37import { dirname , join , resolve } from 'node:path' ;
48// node:sqlite emits an ExperimentalWarning on load (documented in the README):
59// the module is Node's built-in SQLite binding, stable enough for Node >= 22.13
@@ -27,6 +31,7 @@ import type {
2731 AgentStateDefinition ,
2832 AgentStateDispatchOptions ,
2933 AgentStateDriver ,
34+ AgentStateEvent ,
3035 AgentStateEventSchemas ,
3136 AgentStateJournalRecord ,
3237 AgentStateReadOptions ,
@@ -197,6 +202,7 @@ interface JournalRow {
197202 readonly kind : string ;
198203 readonly name : string | null ;
199204 readonly payload : string | null ;
205+ readonly result_state : string | null ;
200206 readonly revision : number ;
201207 readonly state : string | null ;
202208 readonly to_version : number | null ;
@@ -230,6 +236,9 @@ const recordFromRow = (definitionId: string, row: JournalRow): AgentStateJournal
230236const sanitizedFileName = ( definitionId : string ) : string =>
231237 `${ definitionId . replace ( / [ ^ a - z A - Z 0 - 9 . _ - ] + / gu, '-' ) } -${ createHash ( 'sha256' ) . update ( definitionId , 'utf8' ) . digest ( 'hex' ) . slice ( 0 , 16 ) } .sqlite` ;
232238
239+ const legacySanitizedFileName = ( definitionId : string ) : string =>
240+ `${ definitionId . replace ( / [ ^ a - z A - Z 0 - 9 . _ - ] + / gu, '-' ) } -${ Buffer . from ( definitionId , 'utf8' ) . toString ( 'hex' ) . slice ( 0 , 12 ) } .sqlite` ;
241+
233242class SqliteConnection extends Context . Service < SqliteConnection , DatabaseSync > ( ) (
234243 '@agent-bundle/runtime/state/SqliteConnection' ,
235244) { }
@@ -341,11 +350,13 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
341350 #committedByKey(
342351 db : DatabaseSync ,
343352 key : string ,
344- ) : { readonly record : AgentStateJournalRecord ; readonly stateText : string | null } | undefined {
353+ ) : { readonly record : AgentStateJournalRecord ; readonly resultStateText : string | null } | undefined {
345354 const row = db . prepare ( 'SELECT * FROM agent_state_journal WHERE idempotency_key = ?' ) . get ( key ) as
346355 | JournalRow
347356 | undefined ;
348- return row === undefined ? undefined : { record : recordFromRow ( this . #definition. id , row ) , stateText : row . state } ;
357+ return row === undefined
358+ ? undefined
359+ : { record : recordFromRow ( this . #definition. id , row ) , resultStateText : row . result_state ?? row . state } ;
349360 }
350361
351362 /**
@@ -356,11 +367,11 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
356367 */
357368 #committedState(
358369 db : DatabaseSync ,
359- committed : { readonly record : AgentStateJournalRecord ; readonly stateText : string | null } ,
370+ committed : { readonly record : AgentStateJournalRecord ; readonly resultStateText : string | null } ,
360371 ) : TState {
361372 const raw =
362- committed . stateText !== null
363- ? parseStoredJson ( this . #definition. id , 'state' , committed . record . revision , committed . stateText )
373+ committed . resultStateText !== null
374+ ? parseStoredJson ( this . #definition. id , 'result state' , committed . record . revision , committed . resultStateText )
364375 : this . #replayTo( db , committed . record . revision ) ;
365376 const parsed = this . #definition. schema . safeParse ( raw ) ;
366377 if ( ! parsed . success ) {
@@ -387,7 +398,7 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
387398 const stateText = canonicalJson ( state ) ;
388399 db
389400 . prepare (
390- 'INSERT INTO agent_state_journal (revision, kind, name, payload, state, to_version, idempotency_key, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ,
401+ 'INSERT INTO agent_state_journal (revision, kind, name, payload, state, result_state, to_version, idempotency_key, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ,
391402 )
392403 . run (
393404 record . revision ,
@@ -397,6 +408,7 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
397408 // Event rows store their post-commit state too, so idempotent replay
398409 // survives migrations without exact-revision replay.
399410 stateText ,
411+ stateText ,
400412 record . kind === 'migrate' ? record . toVersion : null ,
401413 record . idempotencyKey ,
402414 record . committedAt ,
@@ -417,7 +429,7 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
417429 const committedByKey = ( db : DatabaseSync , key : string ) => this . #committedByKey( db , key ) ;
418430 const committedState = (
419431 db : DatabaseSync ,
420- committed : { readonly record : AgentStateJournalRecord ; readonly stateText : string | null } ,
432+ committed : { readonly record : AgentStateJournalRecord ; readonly resultStateText : string | null } ,
421433 ) => this . #committedState( db , committed ) ;
422434 const headState = ( db : DatabaseSync ) => this . #headState( db , 'commit' ) ;
423435 const now = this . #now;
@@ -601,6 +613,7 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
601613 name TEXT,
602614 payload TEXT,
603615 state TEXT,
616+ result_state TEXT,
604617 to_version INTEGER,
605618 idempotency_key TEXT NOT NULL UNIQUE,
606619 committed_at TEXT NOT NULL
@@ -611,6 +624,12 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
611624 state TEXT NOT NULL
612625 );
613626 ` ) ;
627+ const journalColumns = transactionDb . prepare ( 'PRAGMA table_info(agent_state_journal)' ) . all ( ) as unknown as {
628+ readonly name : string ;
629+ } [ ] ;
630+ if ( ! journalColumns . some ( ( column ) => column . name === 'result_state' ) ) {
631+ transactionDb . exec ( 'ALTER TABLE agent_state_journal ADD COLUMN result_state TEXT' ) ;
632+ }
614633 const definition = this . #definition;
615634 const meta = transactionDb
616635 . prepare ( 'SELECT definition_id, schema_version, kernel_format FROM agent_state_meta WHERE id = 1' )
@@ -678,14 +697,43 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
678697 }
679698 }
680699 const migrated = runStateMigrations ( definition , meta . schema_version , rawHead ) ;
681- // Stored post-commit states ride the same chain so committed
682- // idempotency keys keep replaying after the migration; every stored
683- // state sits at `meta.schema_version` (maintained inductively here).
684- const updateState = transactionDb . prepare ( 'UPDATE agent_state_journal SET state = ? WHERE revision = ?' ) ;
685- for ( const row of rows ) {
686- if ( row . state === null ) continue ;
687- const rawState = parseStoredJson ( definition . id , 'state' , row . revision , row . state ) ;
688- updateState . run ( canonicalJson ( runStateMigrations ( definition , meta . schema_version , rawState ) ) , row . revision ) ;
700+ // Journal records retain the original commit input for dedupe. Their
701+ // committed results migrate separately, matching the memory driver's
702+ // `{ record, state }` split. Legacy event rows without a result are
703+ // replayed before the new migration baseline makes old revisions
704+ // unavailable.
705+ const updateResult = transactionDb . prepare (
706+ 'UPDATE agent_state_journal SET result_state = ? WHERE revision = ?' ,
707+ ) ;
708+ let replayState : unknown = definition . initial ;
709+ for ( const [ index , row ] of rows . entries ( ) ) {
710+ const record = records [ index ] as AgentStateJournalRecord ;
711+ const storedResultText = row . result_state ?? row . state ;
712+ let migratedResult : TState ;
713+ if ( storedResultText !== null ) {
714+ replayState = parseStoredJson ( definition . id , 'result state' , row . revision , storedResultText ) ;
715+ migratedResult = runStateMigrations ( definition , meta . schema_version , replayState ) ;
716+ } else if ( record . kind === 'event' ) {
717+ try {
718+ replayState = definition . reduce (
719+ replayState as TState ,
720+ { name : record . name , payload : record . payload } as AgentStateEvent < TEvents > ,
721+ ) ;
722+ } catch ( error ) {
723+ throw new AgentStateError (
724+ 'migration-failure' ,
725+ `State '${ definition . id } ' could not recover legacy result at revision ${ String ( record . revision ) } ` ,
726+ { cause : error } ,
727+ ) ;
728+ }
729+ migratedResult = runStateMigrations ( definition , meta . schema_version , replayState ) ;
730+ } else {
731+ throw new AgentStateError (
732+ 'corrupt' ,
733+ `State '${ definition . id } ' journal row at revision ${ String ( record . revision ) } has no committed result` ,
734+ ) ;
735+ }
736+ updateResult . run ( canonicalJson ( migratedResult ) , row . revision ) ;
689737 }
690738 const record : AgentStateJournalRecord = {
691739 committedAt : this . #now( ) . toISOString ( ) ,
@@ -763,25 +811,48 @@ export const createSqliteStateDriver = (options: SqliteStateDriverOptions): Agen
763811 `State '${ definition . id } ' declares lifetime '${ definition . lifetime } ' but this driver provides 'workspace-durable'` ,
764812 ) ;
765813 }
766- return resolve (
767- options . file !== undefined ? options . file : join ( options . root as string , sanitizedFileName ( definition . id ) ) ,
768- ) ;
769- } ) ,
814+ if ( options . file !== undefined ) return resolve ( options . file ) ;
815+ const root = options . root as string ;
816+ const currentFile = resolve ( join ( root , sanitizedFileName ( definition . id ) ) ) ;
817+ const legacyFile = resolve ( join ( root , legacySanitizedFileName ( definition . id ) ) ) ;
818+ mkdirSync ( dirname ( currentFile ) , { recursive : true } ) ;
819+ if ( ! existsSync ( currentFile ) && existsSync ( legacyFile ) ) {
820+ for ( const suffix of [ '-wal' , '-shm' ] ) {
821+ const legacySidecar = `${ legacyFile } ${ suffix } ` ;
822+ if ( ! existsSync ( legacySidecar ) ) continue ;
823+ try {
824+ renameSync ( legacySidecar , `${ currentFile } ${ suffix } ` ) ;
825+ } catch ( error ) {
826+ // A concurrent adopter may have moved this sidecar after
827+ // the existence check. Other failures must remain visible.
828+ if ( ( error as SqliteErrorShape ) . code !== 'ENOENT' ) throw error ;
829+ }
830+ }
831+ try {
832+ renameSync ( legacyFile , currentFile ) ;
833+ } catch ( error ) {
834+ // Another opener may have atomically adopted the same
835+ // legacy file after both observed it. The winner's current
836+ // path is authoritative; otherwise preserve the failure.
837+ if ( ! existsSync ( currentFile ) ) throw error ;
838+ }
839+ }
840+ return currentFile ;
841+ } , true ) ,
770842 ) ;
771843 const connection = Effect . acquireRelease (
772844 sqliteEffect ( definition . id , 'open database' , ( ) => {
773845 mkdirSync ( dirname ( file ) , { recursive : true } ) ;
774846 return new DatabaseSync ( file ) ;
775847 } , true ) ,
776- ( db ) =>
777- Effect . sync ( ( ) => {
778- try {
779- db . close ( ) ;
780- } catch {
781- // Closing an already-broken connection must not mask the
782- // caller's path (the original failure carries the cause).
783- }
784- } ) ,
848+ ( db , exit ) => {
849+ const close = sqliteEffect ( definition . id , 'close database' , ( ) => {
850+ db . close ( ) ;
851+ } , true ) ;
852+ return Exit . isFailure ( exit )
853+ ? close . pipe ( Effect . catch ( ( ) => Effect . void ) )
854+ : close ;
855+ } ,
785856 ) ;
786857 const runtime = makeScopedEffectRuntime (
787858 Layer . effect ( SqliteConnection , connection ) ,
0 commit comments