harnery 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commander.js +1 -0
- package/dist/commands/files.d.ts +2 -2
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +7 -4
- package/dist/commands/servers.d.ts.map +1 -1
- package/dist/commands/servers.js +11 -17
- package/dist/commands/tunnel.d.ts +15 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +453 -153
- package/dist/commands/turn-rollback.d.ts +5 -0
- package/dist/commands/turn-rollback.d.ts.map +1 -0
- package/dist/commands/turn-rollback.js +76 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +44 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +18 -0
- package/dist/core/agents/state/proc-start.d.ts +5 -6
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +13 -9
- package/dist/core/agents/state/process-handle.d.ts +26 -0
- package/dist/core/agents/state/process-handle.d.ts.map +1 -0
- package/dist/core/agents/state/process-handle.js +121 -0
- package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
- package/dist/core/artifacts/delivery-card.js +2 -1
- package/dist/core/resources/service.d.ts +2 -16
- package/dist/core/resources/service.d.ts.map +1 -1
- package/dist/core/resources/service.js +1 -78
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +2 -9
- package/dist/core/servers/index.d.ts +12 -11
- package/dist/core/servers/index.d.ts.map +1 -1
- package/dist/core/servers/index.js +194 -81
- package/dist/core/servers/tunnels.d.ts +5 -3
- package/dist/core/servers/tunnels.d.ts.map +1 -1
- package/dist/core/servers/tunnels.js +45 -2
- package/dist/core/supervisor/service.d.ts +2 -4
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/turn-rollback/content.d.ts +53 -0
- package/dist/core/turn-rollback/content.d.ts.map +1 -0
- package/dist/core/turn-rollback/content.js +396 -0
- package/dist/core/turn-rollback/index.d.ts +72 -0
- package/dist/core/turn-rollback/index.d.ts.map +1 -0
- package/dist/core/turn-rollback/index.js +443 -0
- package/dist/core/workflow/index.d.ts +4 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -1
- package/dist/core/workflow/recovery-audit.d.ts +30 -0
- package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
- package/dist/core/workflow/recovery-audit.js +116 -0
- package/dist/core/workflow/run-state.d.ts +19 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +113 -8
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +70 -14
- package/dist/core/workflow/workspaces/reclaim.d.ts +6 -1
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.js +24 -0
- package/dist/lib/tunnel/state.d.ts +13 -6
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +58 -12
- package/package.json +6 -1
- package/src/commander.ts +10 -0
- package/src/commands/files.ts +13 -5
- package/src/commands/servers.ts +12 -23
- package/src/commands/tunnel.ts +533 -166
- package/src/commands/turn-rollback.ts +111 -0
- package/src/commands/workflow.ts +59 -0
- package/src/core/agents/coord-client.ts +20 -0
- package/src/core/agents/state/proc-start.ts +14 -11
- package/src/core/agents/state/process-handle.ts +165 -0
- package/src/core/artifacts/delivery-card.ts +7 -1
- package/src/core/resources/service.ts +3 -88
- package/src/core/semantic/service.ts +2 -8
- package/src/core/servers/index.ts +234 -85
- package/src/core/servers/tunnels.ts +55 -3
- package/src/core/supervisor/service.ts +2 -3
- package/src/core/turn-rollback/content.ts +499 -0
- package/src/core/turn-rollback/index.ts +580 -0
- package/src/core/workflow/index.ts +12 -1
- package/src/core/workflow/recovery-audit.ts +181 -0
- package/src/core/workflow/run-state.ts +137 -8
- package/src/core/workflow/workspaces/local-git.ts +86 -15
- package/src/core/workflow/workspaces/reclaim.ts +4 -0
- package/src/core/workflow/workspaces/recovery.ts +23 -0
- package/src/lib/tunnel/state.ts +70 -15
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* automatically.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { createHash } from "node:crypto";
|
|
23
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
24
24
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
25
25
|
import { hostname } from "node:os";
|
|
26
26
|
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
import { liveInstanceIdV3 } from "../events/v3/live-route-observer.ts";
|
|
42
42
|
import { resolveOwner } from "../hooks/resolve/owner.ts";
|
|
43
43
|
import { writePrivateJsonAtomic } from "../storage/atomic-json.ts";
|
|
44
|
+
import { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
|
|
44
45
|
import {
|
|
45
46
|
ancestry,
|
|
46
47
|
establishedConnectionCounts,
|
|
@@ -52,6 +53,12 @@ import {
|
|
|
52
53
|
scanSupport,
|
|
53
54
|
} from "./net.ts";
|
|
54
55
|
|
|
56
|
+
export {
|
|
57
|
+
processStartToken,
|
|
58
|
+
signalIfSameGeneration,
|
|
59
|
+
verifyProcessGeneration,
|
|
60
|
+
} from "../agents/state/proc-start.ts";
|
|
61
|
+
export { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
|
|
55
62
|
export {
|
|
56
63
|
ancestry,
|
|
57
64
|
establishedConnectionCounts,
|
|
@@ -67,9 +74,9 @@ export {
|
|
|
67
74
|
readProcessInfo,
|
|
68
75
|
scanSupport,
|
|
69
76
|
} from "./net.ts";
|
|
70
|
-
export { tunnelServerId, tunnelServerInput } from "./tunnels.ts";
|
|
77
|
+
export { createTunnelReadinessVerifier, tunnelServerId, tunnelServerInput } from "./tunnels.ts";
|
|
71
78
|
|
|
72
|
-
export const SERVER_RECORD_SCHEMA_VERSION =
|
|
79
|
+
export const SERVER_RECORD_SCHEMA_VERSION = 2;
|
|
73
80
|
export const SERVER_DEFAULT_IDLE_HOURS = 2;
|
|
74
81
|
export const SERVER_DEFAULT_OWNER_STALE_HOURS = 24;
|
|
75
82
|
const TOUCH_INTERVAL_MS = 60_000;
|
|
@@ -79,6 +86,7 @@ export type ServerKind = "service" | "session";
|
|
|
79
86
|
export interface ServerRecord {
|
|
80
87
|
schema_version: typeof SERVER_RECORD_SCHEMA_VERSION;
|
|
81
88
|
id: string;
|
|
89
|
+
registration_id: string;
|
|
82
90
|
kind: ServerKind;
|
|
83
91
|
/** Free-form family name: `preview`, `tunnel`, `dashboard`, ... */
|
|
84
92
|
type: string;
|
|
@@ -209,39 +217,57 @@ export function registerServer(
|
|
|
209
217
|
)
|
|
210
218
|
throw new Error(`invalid server port: ${input.port}`);
|
|
211
219
|
const id = input.id ?? serverId(input.type, input.scope);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
220
|
+
return withServerMutation(coordRoot, id, () => {
|
|
221
|
+
const path = recordPath(coordRoot, id);
|
|
222
|
+
const now = new Date().toISOString();
|
|
223
|
+
const startToken = input.start_token ?? processStartToken(pid);
|
|
224
|
+
const pids = [...new Set(input.pids?.filter((p) => Number.isSafeInteger(p) && p > 0))];
|
|
225
|
+
const pidsStartTokens: Record<string, string> = {};
|
|
226
|
+
for (const auxiliaryPid of pids) {
|
|
227
|
+
const token = processStartToken(auxiliaryPid);
|
|
228
|
+
if (token) pidsStartTokens[auxiliaryPid] = token;
|
|
229
|
+
}
|
|
230
|
+
const owner = input.owner === undefined ? callingOwner(coordRoot) : (input.owner ?? undefined);
|
|
231
|
+
const record: ServerRecord = {
|
|
232
|
+
schema_version: SERVER_RECORD_SCHEMA_VERSION,
|
|
233
|
+
id,
|
|
234
|
+
registration_id: randomUUID(),
|
|
235
|
+
kind: input.kind,
|
|
236
|
+
type: input.type,
|
|
237
|
+
label: input.label ?? id,
|
|
238
|
+
...(input.url ? { url: input.url } : {}),
|
|
239
|
+
...(input.port ? { port: input.port } : {}),
|
|
240
|
+
pid,
|
|
241
|
+
...(startToken ? { start_token: startToken } : {}),
|
|
242
|
+
...(pids.length ? { pids, pids_start_tokens: pidsStartTokens } : {}),
|
|
243
|
+
host: hostname(),
|
|
244
|
+
...(input.scope ? { scope: resolve(input.scope) } : {}),
|
|
245
|
+
...(input.cwd ? { cwd: resolve(input.cwd) } : {}),
|
|
246
|
+
...(input.log ? { log: resolve(input.log) } : {}),
|
|
247
|
+
...(owner ? { owner } : {}),
|
|
248
|
+
started_at: input.started_at ?? now,
|
|
249
|
+
registered_at: now,
|
|
250
|
+
last_active_at: now,
|
|
251
|
+
};
|
|
252
|
+
if (
|
|
253
|
+
input.start_token !== undefined &&
|
|
254
|
+
verifyProcessGeneration(pid, input.start_token) !== "same"
|
|
255
|
+
)
|
|
256
|
+
throw new Error(`pid ${pid} generation could not be verified before registration`);
|
|
257
|
+
if (existsSync(path)) {
|
|
258
|
+
const existing = readRecord(path);
|
|
259
|
+
if (!existing)
|
|
260
|
+
throw new Error(
|
|
261
|
+
`registration ${id} custody is unknown: invalid or older server record retained`,
|
|
262
|
+
);
|
|
263
|
+
if (sameProcessCustody(existing, record) && serverState(existing) === "running")
|
|
264
|
+
return existing;
|
|
265
|
+
if (existing.host !== hostname() || !allProcessesAbsent(existing))
|
|
266
|
+
throw new Error(`registration ${id} custody is held by a live or unverifiable server`);
|
|
267
|
+
}
|
|
268
|
+
writePrivateJsonAtomic(path, record);
|
|
269
|
+
return record;
|
|
270
|
+
});
|
|
245
271
|
}
|
|
246
272
|
|
|
247
273
|
/** Best-effort registration that never throws (for server start paths). */
|
|
@@ -267,39 +293,132 @@ function callingOwner(coordRoot: string): ServerRecord["owner"] {
|
|
|
267
293
|
}
|
|
268
294
|
}
|
|
269
295
|
|
|
270
|
-
/**
|
|
271
|
-
|
|
272
|
-
* is removed, so a replacement that re-registered the same id survives.
|
|
273
|
-
*/
|
|
274
|
-
export function unregisterServer(id: string, options?: ServerOptions & { pid?: number }): boolean {
|
|
296
|
+
/** Remove only the exact registration receipt observed by the caller. */
|
|
297
|
+
export function unregisterServer(record: ServerRecord, options?: ServerOptions): boolean {
|
|
275
298
|
const coordRoot = rootOf(options);
|
|
276
|
-
if (!coordRoot || !isValidServerId(id)) return false;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
299
|
+
if (!coordRoot || !record || !isValidServerId(record.id)) return false;
|
|
300
|
+
return withServerMutation(coordRoot, record.id, () => removeServerReceipt(coordRoot, record));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Hold the exact registration receipt until a synchronous or asynchronous effect settles. */
|
|
304
|
+
export function withServerRegistrationCustody<T>(
|
|
305
|
+
record: ServerRecord,
|
|
306
|
+
options: ServerOptions,
|
|
307
|
+
effect: () => PromiseLike<T>,
|
|
308
|
+
): Promise<T>;
|
|
309
|
+
export function withServerRegistrationCustody<T>(
|
|
310
|
+
record: ServerRecord,
|
|
311
|
+
options: ServerOptions,
|
|
312
|
+
effect: () => T,
|
|
313
|
+
): T;
|
|
314
|
+
export function withServerRegistrationCustody<T>(
|
|
315
|
+
record: ServerRecord,
|
|
316
|
+
options: ServerOptions,
|
|
317
|
+
effect: () => T | PromiseLike<T>,
|
|
318
|
+
): T | Promise<T> {
|
|
319
|
+
const coordRoot = rootOf(options);
|
|
320
|
+
if (!coordRoot) throw new Error("registration custody root is unavailable");
|
|
321
|
+
const lease = serverMutationLease(coordRoot, record.id);
|
|
322
|
+
try {
|
|
323
|
+
const current = readServer(record.id, { coordRoot });
|
|
324
|
+
if (
|
|
325
|
+
!current ||
|
|
326
|
+
current.registration_id !== record.registration_id ||
|
|
327
|
+
!sameProcessCustody(current, record)
|
|
328
|
+
)
|
|
329
|
+
throw new Error(`registration ${record.id} custody changed`);
|
|
330
|
+
const result = effect();
|
|
331
|
+
if (
|
|
332
|
+
result !== null &&
|
|
333
|
+
(typeof result === "object" || typeof result === "function") &&
|
|
334
|
+
typeof (result as PromiseLike<T>).then === "function"
|
|
335
|
+
)
|
|
336
|
+
return Promise.resolve(result).finally(() => lease.release());
|
|
337
|
+
lease.release();
|
|
338
|
+
return result as T;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
lease.release();
|
|
341
|
+
throw error;
|
|
281
342
|
}
|
|
282
|
-
|
|
283
|
-
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function removeServerReceipt(coordRoot: string, record: ServerRecord): boolean {
|
|
346
|
+
const path = recordPath(coordRoot, record.id);
|
|
347
|
+
const current = readRecord(path);
|
|
348
|
+
if (
|
|
349
|
+
!current ||
|
|
350
|
+
current.registration_id !== record.registration_id ||
|
|
351
|
+
!sameProcessCustody(current, record)
|
|
352
|
+
)
|
|
353
|
+
return false;
|
|
354
|
+
rmSync(path);
|
|
284
355
|
return true;
|
|
285
356
|
}
|
|
286
357
|
|
|
358
|
+
function serverMutationLease(coordRoot: string, id: string) {
|
|
359
|
+
recordPath(coordRoot, id);
|
|
360
|
+
const scope = `server-registration-${id}`;
|
|
361
|
+
return acquireNoClobberLease({
|
|
362
|
+
path: join(serversDir(coordRoot), ".custody", id),
|
|
363
|
+
scope,
|
|
364
|
+
authoritySha256: createHash("sha256").update(scope).digest("hex"),
|
|
365
|
+
staleAfterMs: 1,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function withServerMutation<T>(coordRoot: string, id: string, operation: () => T): T {
|
|
370
|
+
const lease = serverMutationLease(coordRoot, id);
|
|
371
|
+
try {
|
|
372
|
+
return operation();
|
|
373
|
+
} finally {
|
|
374
|
+
lease.release();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function sameProcessCustody(a: ServerRecord, b: ServerRecord): boolean {
|
|
379
|
+
const helpers = (record: ServerRecord) =>
|
|
380
|
+
[...new Set(record.pids ?? [])]
|
|
381
|
+
.sort((x, y) => x - y)
|
|
382
|
+
.map((pid) => [pid, record.pids_start_tokens?.[pid] ?? null]);
|
|
383
|
+
return (
|
|
384
|
+
a.schema_version === SERVER_RECORD_SCHEMA_VERSION &&
|
|
385
|
+
b.schema_version === SERVER_RECORD_SCHEMA_VERSION &&
|
|
386
|
+
a.id === b.id &&
|
|
387
|
+
a.pid === b.pid &&
|
|
388
|
+
a.start_token === b.start_token &&
|
|
389
|
+
a.host === b.host &&
|
|
390
|
+
a.kind === b.kind &&
|
|
391
|
+
a.type === b.type &&
|
|
392
|
+
a.scope === b.scope &&
|
|
393
|
+
a.owner?.instance_id === b.owner?.instance_id &&
|
|
394
|
+
JSON.stringify(helpers(a)) === JSON.stringify(helpers(b))
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
287
398
|
export function readServer(id: string, options?: ServerOptions): ServerRecord | null {
|
|
288
399
|
const coordRoot = rootOf(options);
|
|
289
400
|
if (!coordRoot || !isValidServerId(id)) return null;
|
|
290
|
-
|
|
401
|
+
const path = recordPath(coordRoot, id);
|
|
402
|
+
const record = readRecord(path);
|
|
403
|
+
if (!record && existsSync(path))
|
|
404
|
+
throw new Error(
|
|
405
|
+
`server record ${id} is invalid or uses an older schema; retained without custody`,
|
|
406
|
+
);
|
|
407
|
+
return record;
|
|
291
408
|
}
|
|
292
409
|
|
|
293
410
|
/** Mark a server as used now. */
|
|
294
411
|
export function touchServer(id: string, options?: ServerOptions & { now?: Date }): void {
|
|
295
412
|
const coordRoot = rootOf(options);
|
|
296
413
|
if (!coordRoot || !isValidServerId(id)) return;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
414
|
+
withServerMutation(coordRoot, id, () => {
|
|
415
|
+
const path = recordPath(coordRoot, id);
|
|
416
|
+
const record = readRecord(path);
|
|
417
|
+
if (!record) return;
|
|
418
|
+
writePrivateJsonAtomic(path, {
|
|
419
|
+
...record,
|
|
420
|
+
last_active_at: (options?.now ?? new Date()).toISOString(),
|
|
421
|
+
});
|
|
303
422
|
});
|
|
304
423
|
}
|
|
305
424
|
|
|
@@ -328,6 +447,8 @@ function readRecord(path: string): ServerRecord | null {
|
|
|
328
447
|
if (
|
|
329
448
|
value?.schema_version !== SERVER_RECORD_SCHEMA_VERSION ||
|
|
330
449
|
typeof value.id !== "string" ||
|
|
450
|
+
typeof value.registration_id !== "string" ||
|
|
451
|
+
!/^[0-9a-f-]{36}$/.test(value.registration_id) ||
|
|
331
452
|
!Number.isSafeInteger(value.pid) ||
|
|
332
453
|
(value.kind !== "service" && value.kind !== "session")
|
|
333
454
|
)
|
|
@@ -345,8 +466,14 @@ export function readServerRecords(options?: ServerOptions): ServerRecord[] {
|
|
|
345
466
|
if (!existsSync(dir)) return [];
|
|
346
467
|
return readdirSync(dir)
|
|
347
468
|
.filter((name) => name.endsWith(".json"))
|
|
348
|
-
.map((name) =>
|
|
349
|
-
|
|
469
|
+
.map((name) => {
|
|
470
|
+
const record = readRecord(join(dir, name));
|
|
471
|
+
if (!record)
|
|
472
|
+
throw new Error(
|
|
473
|
+
`server record ${name} is invalid or uses an older schema; retained without custody`,
|
|
474
|
+
);
|
|
475
|
+
return record;
|
|
476
|
+
})
|
|
350
477
|
.sort(
|
|
351
478
|
(a, b) =>
|
|
352
479
|
a.kind.localeCompare(b.kind) || a.type.localeCompare(b.type) || a.id.localeCompare(b.id),
|
|
@@ -440,7 +567,7 @@ export function listServers(options?: ListServersOptions): ServersReport {
|
|
|
440
567
|
records.forEach((record, i) => {
|
|
441
568
|
const state = states[i]!;
|
|
442
569
|
if (state === "dead" && allProcessesAbsent(record) && options?.prune !== false) {
|
|
443
|
-
unregisterServer(record
|
|
570
|
+
unregisterServer(record, { coordRoot });
|
|
444
571
|
pruned.push(record.id);
|
|
445
572
|
return;
|
|
446
573
|
}
|
|
@@ -595,8 +722,8 @@ export interface ServerProcessDependencies extends ProcessGenerationSignalDepend
|
|
|
595
722
|
|
|
596
723
|
/**
|
|
597
724
|
* Stop a verified server with SIGTERM, then SIGKILL after the grace period.
|
|
598
|
-
*
|
|
599
|
-
* a fresh generation check. Unknown or different generations retain the record.
|
|
725
|
+
* Every signal targets one retained process handle, including auxiliary processes.
|
|
726
|
+
* Each target requires a fresh generation check. Unknown or different generations retain the record.
|
|
600
727
|
*/
|
|
601
728
|
export async function stopServer(
|
|
602
729
|
record: ServerRecord,
|
|
@@ -606,6 +733,47 @@ export async function stopServer(
|
|
|
606
733
|
},
|
|
607
734
|
): Promise<StopResult> {
|
|
608
735
|
const coordRoot = rootOf(options);
|
|
736
|
+
if (!coordRoot)
|
|
737
|
+
return {
|
|
738
|
+
id: record.id,
|
|
739
|
+
stopped: false,
|
|
740
|
+
method: "refused",
|
|
741
|
+
generation: "unknown",
|
|
742
|
+
detail: "registration custody root is unavailable",
|
|
743
|
+
};
|
|
744
|
+
let lease: ReturnType<typeof serverMutationLease>;
|
|
745
|
+
try {
|
|
746
|
+
lease = serverMutationLease(coordRoot, record.id);
|
|
747
|
+
} catch {
|
|
748
|
+
return {
|
|
749
|
+
id: record.id,
|
|
750
|
+
stopped: false,
|
|
751
|
+
method: "refused",
|
|
752
|
+
generation: "unknown",
|
|
753
|
+
detail: "registration custody is unavailable",
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
const current = readServer(record.id, { coordRoot });
|
|
758
|
+
if (!current || current.registration_id !== record.registration_id)
|
|
759
|
+
return {
|
|
760
|
+
id: record.id,
|
|
761
|
+
stopped: false,
|
|
762
|
+
method: "refused",
|
|
763
|
+
generation: "unknown",
|
|
764
|
+
detail: "registration custody changed; retained current server",
|
|
765
|
+
};
|
|
766
|
+
return await stopServerWithCustody(record, coordRoot, options);
|
|
767
|
+
} finally {
|
|
768
|
+
lease.release();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function stopServerWithCustody(
|
|
773
|
+
record: ServerRecord,
|
|
774
|
+
coordRoot: string,
|
|
775
|
+
options?: ServerOptions & { graceMs?: number; signalDependencies?: ServerProcessDependencies },
|
|
776
|
+
): Promise<StopResult> {
|
|
609
777
|
const graceMs = options?.graceMs ?? 5_000;
|
|
610
778
|
const dependencies = options?.signalDependencies ?? {};
|
|
611
779
|
const targets = [
|
|
@@ -622,8 +790,7 @@ export async function stopServer(
|
|
|
622
790
|
detail: `pid ${pid} generation is ${generation}; left the server record in place`,
|
|
623
791
|
});
|
|
624
792
|
const finish = (result: StopResult): StopResult => {
|
|
625
|
-
if (result.stopped || result.method === "not-running")
|
|
626
|
-
unregisterServer(record.id, { coordRoot, pid: record.pid });
|
|
793
|
+
if (result.stopped || result.method === "not-running") removeServerReceipt(coordRoot, record);
|
|
627
794
|
return result;
|
|
628
795
|
};
|
|
629
796
|
if (record.host !== hostname())
|
|
@@ -645,16 +812,16 @@ export async function stopServer(
|
|
|
645
812
|
if (targets.every((target) => processPresence(target.pid, dependencies.probePid) === "absent"))
|
|
646
813
|
return finish({ id: record.id, stopped: false, method: "not-running" });
|
|
647
814
|
|
|
815
|
+
const current = readServer(record.id, { coordRoot });
|
|
816
|
+
if (!current || !sameProcessCustody(current, record)) return refuse(record.pid, "unknown");
|
|
648
817
|
for (const name of ["SIGTERM", "SIGKILL"] as const) {
|
|
649
818
|
for (const target of targets) {
|
|
650
819
|
const presence = processPresence(target.pid, dependencies.probePid);
|
|
651
820
|
if (presence === "absent") continue;
|
|
652
821
|
if (presence === "unknown") return refuse(target.pid, "unknown");
|
|
653
|
-
const result =
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
: signalIfSameGeneration(target.pid, target.token, name, dependencies);
|
|
657
|
-
if (result.verdict !== "same") return refuse(target.pid, result.verdict);
|
|
822
|
+
const result = signalIfSameGeneration(target.pid, target.token, name, dependencies);
|
|
823
|
+
if (!result.sent || result.verdict !== "same")
|
|
824
|
+
return refuse(target.pid, result.verdict === "same" ? "unknown" : result.verdict);
|
|
658
825
|
}
|
|
659
826
|
const observed = await waitForExit(targets, name === "SIGTERM" ? graceMs : 2_000, dependencies);
|
|
660
827
|
if (observed.refused) return refuse(observed.refused.pid, observed.refused.verdict);
|
|
@@ -670,24 +837,6 @@ export async function stopServer(
|
|
|
670
837
|
};
|
|
671
838
|
}
|
|
672
839
|
|
|
673
|
-
function signalServerLeader(
|
|
674
|
-
pid: number,
|
|
675
|
-
token: string | undefined,
|
|
676
|
-
name: NodeJS.Signals,
|
|
677
|
-
dependencies: ServerProcessDependencies,
|
|
678
|
-
): { sent: boolean; verdict: ProcessGenerationVerdict } {
|
|
679
|
-
// The leader's generation authorizes its group. A failed group signal must
|
|
680
|
-
// not reuse that observation for the direct-PID fallback.
|
|
681
|
-
const kill = dependencies.kill ?? process.kill;
|
|
682
|
-
const group = signalIfSameGeneration(pid, token, name, {
|
|
683
|
-
readToken: dependencies.readToken,
|
|
684
|
-
kill: (leader, signal) => kill(-leader, signal),
|
|
685
|
-
});
|
|
686
|
-
return group.sent || group.verdict !== "same"
|
|
687
|
-
? group
|
|
688
|
-
: signalIfSameGeneration(pid, token, name, dependencies);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
840
|
async function waitForExit(
|
|
692
841
|
targets: Array<{ pid: number; token: string | undefined }>,
|
|
693
842
|
waitMs: number,
|
|
@@ -2,12 +2,59 @@
|
|
|
2
2
|
* Tunnels as registry services.
|
|
3
3
|
*
|
|
4
4
|
* `tunnel up` and `tunnel reload` register the new gate; `tunnel down` removes
|
|
5
|
-
* its record.
|
|
5
|
+
* its record. Missing original witnesses remain unknown and cannot authorize actions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { hostname } from "node:os";
|
|
8
9
|
import { resolve } from "node:path";
|
|
9
|
-
import {
|
|
10
|
-
|
|
10
|
+
import {
|
|
11
|
+
gateLogFile,
|
|
12
|
+
type TunnelReadinessVerifier,
|
|
13
|
+
type TunnelState,
|
|
14
|
+
} from "../../lib/tunnel/state.ts";
|
|
15
|
+
import {
|
|
16
|
+
type RegisterServerInput,
|
|
17
|
+
readServer,
|
|
18
|
+
type ServerOptions,
|
|
19
|
+
verifyProcessGeneration,
|
|
20
|
+
withServerRegistrationCustody,
|
|
21
|
+
} from "./index.ts";
|
|
22
|
+
|
|
23
|
+
/** Verify only witnesses saved by the original tunnel registration. */
|
|
24
|
+
export function createTunnelReadinessVerifier(options: ServerOptions): TunnelReadinessVerifier {
|
|
25
|
+
return {
|
|
26
|
+
observeProcess(pid, originalToken) {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
31
|
+
if (code === "ESRCH") return "absent";
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
return verifyProcessGeneration(pid, originalToken);
|
|
35
|
+
},
|
|
36
|
+
withRegistration(state, inspect) {
|
|
37
|
+
try {
|
|
38
|
+
const original = readServer(tunnelServerId(state.name), options);
|
|
39
|
+
if (
|
|
40
|
+
!original ||
|
|
41
|
+
original.host !== hostname() ||
|
|
42
|
+
original.kind !== "service" ||
|
|
43
|
+
original.type !== "tunnel" ||
|
|
44
|
+
original.registration_id !== state.registration_id ||
|
|
45
|
+
original.pid !== state.gate_pid ||
|
|
46
|
+
original.start_token !== state.gate_start_token ||
|
|
47
|
+
original.port !== state.gate_port ||
|
|
48
|
+
original.url !== state.url
|
|
49
|
+
)
|
|
50
|
+
return "unknown";
|
|
51
|
+
return withServerRegistrationCustody(original, options, inspect);
|
|
52
|
+
} catch {
|
|
53
|
+
return "unknown";
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
11
58
|
|
|
12
59
|
export function tunnelServerId(name: string): string {
|
|
13
60
|
return `tunnel-${name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-")}`;
|
|
@@ -15,6 +62,10 @@ export function tunnelServerId(name: string): string {
|
|
|
15
62
|
|
|
16
63
|
/** Register the new gate; saved helper PIDs have no generation witnesses. */
|
|
17
64
|
export function tunnelServerInput(state: TunnelState, coordRoot: string): RegisterServerInput {
|
|
65
|
+
if (Object.hasOwn(state, "provider_pid") || Object.hasOwn(state, "provider_start_token"))
|
|
66
|
+
throw new Error("obsolete tunnel provider authority is unknown; state retained");
|
|
67
|
+
if (!state.gate_start_token)
|
|
68
|
+
throw new Error("tunnel gate generation witness is unavailable; state retained");
|
|
18
69
|
return {
|
|
19
70
|
id: tunnelServerId(state.name),
|
|
20
71
|
kind: "service",
|
|
@@ -23,6 +74,7 @@ export function tunnelServerInput(state: TunnelState, coordRoot: string): Regist
|
|
|
23
74
|
url: state.url,
|
|
24
75
|
port: state.gate_port,
|
|
25
76
|
pid: state.gate_pid,
|
|
77
|
+
start_token: state.gate_start_token,
|
|
26
78
|
log: resolve(coordRoot, ".cache", "tunnel", gateLogFile(state.name)),
|
|
27
79
|
cwd: coordRoot,
|
|
28
80
|
started_at: state.started_at,
|
|
@@ -17,6 +17,7 @@ import { performance } from "node:perf_hooks";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { collectCoordinationHealthSnapshot } from "../agents/health.ts";
|
|
19
19
|
import {
|
|
20
|
+
type ProcessGenerationSignalDependencies,
|
|
20
21
|
type ProcessGenerationVerdict,
|
|
21
22
|
processStartToken,
|
|
22
23
|
signalIfSameGeneration,
|
|
@@ -185,9 +186,7 @@ export interface SupervisorStopResult extends SupervisorStatus {
|
|
|
185
186
|
|
|
186
187
|
export function requestSupervisorStop(
|
|
187
188
|
coordRootRaw: string,
|
|
188
|
-
dependencies: SupervisorProcessDependencies & {
|
|
189
|
-
kill?: (pid: number, signal: NodeJS.Signals | number) => unknown;
|
|
190
|
-
} = {},
|
|
189
|
+
dependencies: SupervisorProcessDependencies & ProcessGenerationSignalDependencies = {},
|
|
191
190
|
): SupervisorStopResult {
|
|
192
191
|
const coordRoot = resolve(coordRootRaw);
|
|
193
192
|
const paths = supervisorPaths(coordRoot);
|