@@ -16,6 +16,23 @@ export interface DaemonRecord {
1616 readonly scopeDir : string | null ;
1717}
1818
19+ export interface DaemonPointer {
20+ readonly version : 1 ;
21+ readonly hostname : string ;
22+ readonly port : number ;
23+ readonly pid : number ;
24+ readonly startedAt : string ;
25+ readonly scopeId : string ;
26+ readonly scopeDir : string | null ;
27+ readonly token : string ;
28+ }
29+
30+ export interface DaemonStartLock {
31+ readonly path : string ;
32+ readonly hostname : string ;
33+ readonly scopeId : string ;
34+ }
35+
1936// ---------------------------------------------------------------------------
2037// Host normalization
2138// ---------------------------------------------------------------------------
@@ -27,6 +44,14 @@ export const canonicalDaemonHost = (hostname: string): string => {
2744 return LOCAL_HOST_ALIASES . has ( normalized ) ? "localhost" : normalized ;
2845} ;
2946
47+ export const currentDaemonScopeId = ( ) : string => {
48+ const explicitScope = process . env . EXECUTOR_SCOPE_DIR ?. trim ( ) ;
49+ if ( explicitScope && explicitScope . length > 0 ) {
50+ return `scope:${ explicitScope } ` ;
51+ }
52+ return `cwd:${ process . cwd ( ) } ` ;
53+ } ;
54+
3055// ---------------------------------------------------------------------------
3156// Paths
3257// ---------------------------------------------------------------------------
@@ -35,12 +60,22 @@ const resolveDaemonDataDir = (path: Path.Path): string =>
3560 process . env . EXECUTOR_DATA_DIR ?? path . join ( homedir ( ) , ".executor" ) ;
3661
3762const sanitizeHostForPath = ( hostname : string ) : string => hostname . replaceAll ( / [ ^ a - z 0 - 9 . - ] + / gi, "_" ) ;
63+ const sanitizeScopeForPath = ( scopeId : string ) : string => scopeId . replaceAll ( / [ ^ a - z 0 - 9 . - ] + / gi, "_" ) ;
3864
3965const daemonRecordPath = ( path : Path . Path , input : { hostname : string ; port : number } ) : string => {
4066 const host = sanitizeHostForPath ( canonicalDaemonHost ( input . hostname ) ) ;
4167 return path . join ( resolveDaemonDataDir ( path ) , `daemon-${ host } -${ input . port } .json` ) ;
4268} ;
4369
70+ const daemonPointerPath = ( path : Path . Path , input : { hostname : string ; scopeId : string } ) : string => {
71+ const host = sanitizeHostForPath ( canonicalDaemonHost ( input . hostname ) ) ;
72+ const scope = sanitizeScopeForPath ( input . scopeId ) ;
73+ return path . join ( resolveDaemonDataDir ( path ) , `daemon-active-${ host } -${ scope } .json` ) ;
74+ } ;
75+
76+ const daemonStartLockPath = ( path : Path . Path , input : { hostname : string ; scopeId : string } ) : string =>
77+ `${ daemonPointerPath ( path , input ) } .lock` ;
78+
4479// ---------------------------------------------------------------------------
4580// Persistence
4681// ---------------------------------------------------------------------------
@@ -111,6 +146,48 @@ const parseRecord = (raw: string): DaemonRecord | null => {
111146 } ;
112147} ;
113148
149+ const parsePointer = ( raw : string ) : DaemonPointer | null => {
150+ let parsed : unknown ;
151+ try {
152+ parsed = JSON . parse ( raw ) ;
153+ } catch {
154+ return null ;
155+ }
156+
157+ if (
158+ typeof parsed !== "object" ||
159+ parsed === null ||
160+ ! ( "version" in parsed ) ||
161+ ( parsed as { version ?: unknown } ) . version !== 1
162+ ) {
163+ return null ;
164+ }
165+
166+ const r = parsed as Record < string , unknown > ;
167+ if (
168+ typeof r . hostname !== "string" ||
169+ typeof r . port !== "number" ||
170+ typeof r . pid !== "number" ||
171+ typeof r . startedAt !== "string" ||
172+ typeof r . scopeId !== "string" ||
173+ ! ( typeof r . scopeDir === "string" || r . scopeDir === null ) ||
174+ typeof r . token !== "string"
175+ ) {
176+ return null ;
177+ }
178+
179+ return {
180+ version : 1 ,
181+ hostname : canonicalDaemonHost ( r . hostname ) ,
182+ port : r . port ,
183+ pid : r . pid ,
184+ startedAt : r . startedAt ,
185+ scopeId : r . scopeId ,
186+ scopeDir : r . scopeDir ,
187+ token : r . token ,
188+ } ;
189+ } ;
190+
114191export const readDaemonRecord = ( input : {
115192 hostname : string ;
116193 port : number ;
@@ -135,6 +212,148 @@ export const removeDaemonRecord = (input: {
135212 yield * fs . remove ( daemonRecordPath ( path , input ) , { force : true } ) ;
136213 } ) ;
137214
215+ export const writeDaemonPointer = ( input : {
216+ hostname : string ;
217+ port : number ;
218+ pid : number ;
219+ scopeId : string ;
220+ scopeDir : string | null ;
221+ token : string ;
222+ } ) : Effect . Effect < void , PlatformError , FileSystem . FileSystem | Path . Path > =>
223+ Effect . gen ( function * ( ) {
224+ const fs = yield * FileSystem . FileSystem ;
225+ const path = yield * Path . Path ;
226+ const dataDir = resolveDaemonDataDir ( path ) ;
227+ yield * fs . makeDirectory ( dataDir , { recursive : true } ) ;
228+
229+ const payload : DaemonPointer = {
230+ version : 1 ,
231+ hostname : canonicalDaemonHost ( input . hostname ) ,
232+ port : input . port ,
233+ pid : input . pid ,
234+ startedAt : new Date ( ) . toISOString ( ) ,
235+ scopeId : input . scopeId ,
236+ scopeDir : input . scopeDir ,
237+ token : input . token ,
238+ } ;
239+
240+ yield * fs . writeFileString (
241+ daemonPointerPath ( path , { hostname : input . hostname , scopeId : input . scopeId } ) ,
242+ `${ JSON . stringify ( payload , null , 2 ) } \n` ,
243+ ) ;
244+ } ) ;
245+
246+ export const readDaemonPointer = ( input : {
247+ hostname : string ;
248+ scopeId : string ;
249+ } ) : Effect . Effect < DaemonPointer | null , never , FileSystem . FileSystem | Path . Path > =>
250+ Effect . gen ( function * ( ) {
251+ const fs = yield * FileSystem . FileSystem ;
252+ const path = yield * Path . Path ;
253+ const raw = yield * fs
254+ . readFileString ( daemonPointerPath ( path , input ) )
255+ . pipe ( Effect . catchAll ( ( ) => Effect . succeed ( null ) ) ) ;
256+ if ( raw === null ) return null ;
257+ return parsePointer ( raw ) ;
258+ } ) ;
259+
260+ export const removeDaemonPointer = ( input : {
261+ hostname : string ;
262+ scopeId : string ;
263+ } ) : Effect . Effect < void , PlatformError , FileSystem . FileSystem | Path . Path > =>
264+ Effect . gen ( function * ( ) {
265+ const fs = yield * FileSystem . FileSystem ;
266+ const path = yield * Path . Path ;
267+ yield * fs . remove ( daemonPointerPath ( path , input ) , { force : true } ) ;
268+ } ) ;
269+
270+ const parseLockPid = ( raw : string ) : number | null => {
271+ let parsed : unknown ;
272+ try {
273+ parsed = JSON . parse ( raw ) ;
274+ } catch {
275+ return null ;
276+ }
277+
278+ if (
279+ typeof parsed !== "object" ||
280+ parsed === null ||
281+ typeof ( parsed as Record < string , unknown > ) . pid !== "number"
282+ ) {
283+ return null ;
284+ }
285+
286+ return ( parsed as Record < string , number > ) . pid ;
287+ } ;
288+
289+ export const acquireDaemonStartLock = ( input : {
290+ hostname : string ;
291+ scopeId : string ;
292+ } ) : Effect . Effect < DaemonStartLock , Error , FileSystem . FileSystem | Path . Path > =>
293+ Effect . gen ( function * ( ) {
294+ const fs = yield * FileSystem . FileSystem ;
295+ const path = yield * Path . Path ;
296+ const dataDir = resolveDaemonDataDir ( path ) ;
297+ yield * fs . makeDirectory ( dataDir , { recursive : true } ) ;
298+
299+ const lockPath = daemonStartLockPath ( path , input ) ;
300+ const lockPayload = JSON . stringify (
301+ {
302+ pid : process . pid ,
303+ hostname : canonicalDaemonHost ( input . hostname ) ,
304+ scopeId : input . scopeId ,
305+ startedAt : new Date ( ) . toISOString ( ) ,
306+ } ,
307+ null ,
308+ 2 ,
309+ ) ;
310+
311+ const tryAcquire = ( ) =>
312+ fs . writeFileString ( lockPath , `${ lockPayload } \n` , { flag : "wx" } ) . pipe (
313+ Effect . as ( true ) ,
314+ Effect . catchAll ( ( ) => Effect . succeed ( false ) ) ,
315+ ) ;
316+
317+ if ( yield * tryAcquire ( ) ) {
318+ return {
319+ path : lockPath ,
320+ hostname : canonicalDaemonHost ( input . hostname ) ,
321+ scopeId : input . scopeId ,
322+ } ;
323+ }
324+
325+ const existingRaw = yield * fs . readFileString ( lockPath ) . pipe ( Effect . catchAll ( ( ) => Effect . succeed ( null ) ) ) ;
326+ if ( existingRaw !== null ) {
327+ const existingPid = parseLockPid ( existingRaw ) ;
328+ if ( existingPid !== null && ! isPidAlive ( existingPid ) ) {
329+ yield * fs . remove ( lockPath , { force : true } ) ;
330+ if ( yield * tryAcquire ( ) ) {
331+ return {
332+ path : lockPath ,
333+ hostname : canonicalDaemonHost ( input . hostname ) ,
334+ scopeId : input . scopeId ,
335+ } ;
336+ }
337+ }
338+ }
339+
340+ return yield * Effect . fail (
341+ new Error (
342+ `Another daemon startup is already in progress for ${ canonicalDaemonHost ( input . hostname ) } (${ input . scopeId } ).` ,
343+ ) ,
344+ ) ;
345+ } ) ;
346+
347+ export const releaseDaemonStartLock = ( input : DaemonStartLock ) : Effect . Effect <
348+ void ,
349+ PlatformError ,
350+ FileSystem . FileSystem | Path . Path
351+ > =>
352+ Effect . gen ( function * ( ) {
353+ const fs = yield * FileSystem . FileSystem ;
354+ yield * fs . remove ( input . path , { force : true } ) ;
355+ } ) ;
356+
138357// ---------------------------------------------------------------------------
139358// Process helpers
140359// ---------------------------------------------------------------------------
0 commit comments