55 * going in the user's tmux server, and the next launch would otherwise know nothing about it.
66 * Each record names the run's tag, its pane and its tmux server's socket, and nothing else: no
77 * command line and no output, since it lives outside the account's encrypted data. A record is
8- * written once the pane is tagged and removed once the run has ended, its pane is gone, or Sim has
9- * stopped it.
8+ * saved before the run's command may start, and removed once the run has ended, its pane is gone,
9+ * or Sim has stopped it.
1010 */
1111
12- import { mkdirSync , readdirSync , readFileSync , rmSync , writeFileSync } from 'node:fs'
12+ import { readdirSync , readFileSync , rmSync } from 'node:fs'
1313import { join } from 'node:path'
1414import { createLogger } from '@sim/logger'
1515import { getErrorMessage } from '@sim/utils/errors'
16+ import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
17+ import type { RecordedRun } from '@/main/terminal/tmux'
1618
1719const logger = createLogger ( 'DesktopTerminalRunLedger' )
1820
19- /** One recorded run: what it takes to find its pane again, on the server it ran on. */
20- export interface RunRecord {
21- /** The tag on the run's pane; only a pane carrying it is ever acted on. */
22- runId : string
23- pane : string
24- /** The tmux server's socket, so a different server is never asked about this pane. */
25- socket : string
26- }
27-
2821export interface RunLedger {
29- record ( run : RunRecord ) : void
22+ /** Saves a run's record; false when it could not be saved, so the run must not start. */
23+ record ( run : RecordedRun ) : boolean
3024 forget ( runId : string ) : void
3125 /** Every recorded run; `excludeLive` leaves out runs this process recorded. */
32- list ( options ?: { excludeLive ?: boolean } ) : RunRecord [ ]
26+ list ( options ?: { excludeLive ?: boolean } ) : RecordedRun [ ]
3327}
3428
3529/** Run tags are generated ids; anything else in the directory is not a record. */
3630const RUN_ID = / ^ [ A - Z a - z 0 - 9 _ - ] { 1 , 128 } $ /
3731
38- function parseRecord ( text : string ) : RunRecord | null {
32+ function parseRecord ( text : string ) : RecordedRun | null {
3933 try {
40- const parsed = JSON . parse ( text ) as Partial < RunRecord >
34+ const parsed = JSON . parse ( text ) as Partial < RecordedRun >
4135 if (
4236 typeof parsed . runId === 'string' &&
4337 RUN_ID . test ( parsed . runId ) &&
@@ -54,26 +48,35 @@ function parseRecord(text: string): RunRecord | null {
5448 return null
5549}
5650
51+ /** Removes a file the ledger no longer needs; a failure is logged, never thrown at a caller. */
52+ function remove ( path : string ) : void {
53+ try {
54+ rmSync ( path , { force : true } )
55+ } catch ( error ) {
56+ logger . warn ( 'Could not remove a tmux run record' , { error : getErrorMessage ( error ) } )
57+ }
58+ }
59+
5760export function createRunLedger ( dir : string ) : RunLedger {
5861 /** Runs recorded by this process, still going as far as it knows. */
5962 const live = new Set < string > ( )
6063 const pathFor = ( runId : string ) => join ( dir , `${ runId } .json` )
6164
6265 return {
6366 record ( run ) {
64- if ( ! RUN_ID . test ( run . runId ) ) return
67+ if ( ! RUN_ID . test ( run . runId ) ) return false
6568 try {
66- mkdirSync ( dir , { recursive : true , mode : 0o700 } )
67- writeFileSync ( pathFor ( run . runId ) , JSON . stringify ( run ) , { mode : 0o600 } )
69+ writeJsonFileAtomicallySync ( pathFor ( run . runId ) , run )
6870 live . add ( run . runId )
71+ return true
6972 } catch ( error ) {
7073 logger . warn ( 'Could not record a tmux run' , { error : getErrorMessage ( error ) } )
74+ return false
7175 }
7276 } ,
7377 forget ( runId ) {
7478 live . delete ( runId )
75- if ( ! RUN_ID . test ( runId ) ) return
76- rmSync ( pathFor ( runId ) , { force : true } )
79+ if ( RUN_ID . test ( runId ) ) remove ( pathFor ( runId ) )
7780 } ,
7881 list ( options = { } ) {
7982 let names : string [ ]
@@ -82,18 +85,23 @@ export function createRunLedger(dir: string): RunLedger {
8285 } catch {
8386 return [ ]
8487 }
85- const runs : RunRecord [ ] = [ ]
88+ const runs : RecordedRun [ ] = [ ]
8689 for ( const name of names ) {
90+ // A write that never finished leaves only its temporary file behind.
91+ if ( name . endsWith ( '.tmp' ) ) {
92+ remove ( join ( dir , name ) )
93+ continue
94+ }
8795 if ( ! name . endsWith ( '.json' ) ) continue
88- let record : RunRecord | null = null
96+ let record : RecordedRun | null = null
8997 try {
9098 record = parseRecord ( readFileSync ( join ( dir , name ) , 'utf8' ) )
9199 } catch {
92100 record = null
93101 }
94102 if ( ! record || `${ record . runId } .json` !== name ) {
95103 // Nothing could act on it safely; it only takes up space.
96- rmSync ( join ( dir , name ) , { force : true } )
104+ remove ( join ( dir , name ) )
97105 continue
98106 }
99107 if ( options . excludeLive && live . has ( record . runId ) ) continue
0 commit comments