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.
Files changed (87) hide show
  1. package/dist/commander.js +1 -0
  2. package/dist/commands/files.d.ts +2 -2
  3. package/dist/commands/files.d.ts.map +1 -1
  4. package/dist/commands/files.js +7 -4
  5. package/dist/commands/servers.d.ts.map +1 -1
  6. package/dist/commands/servers.js +11 -17
  7. package/dist/commands/tunnel.d.ts +15 -0
  8. package/dist/commands/tunnel.d.ts.map +1 -1
  9. package/dist/commands/tunnel.js +453 -153
  10. package/dist/commands/turn-rollback.d.ts +5 -0
  11. package/dist/commands/turn-rollback.d.ts.map +1 -0
  12. package/dist/commands/turn-rollback.js +76 -0
  13. package/dist/commands/workflow.d.ts.map +1 -1
  14. package/dist/commands/workflow.js +44 -0
  15. package/dist/core/agents/coord-client.d.ts.map +1 -1
  16. package/dist/core/agents/coord-client.js +18 -0
  17. package/dist/core/agents/state/proc-start.d.ts +5 -6
  18. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  19. package/dist/core/agents/state/proc-start.js +13 -9
  20. package/dist/core/agents/state/process-handle.d.ts +26 -0
  21. package/dist/core/agents/state/process-handle.d.ts.map +1 -0
  22. package/dist/core/agents/state/process-handle.js +121 -0
  23. package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
  24. package/dist/core/artifacts/delivery-card.js +2 -1
  25. package/dist/core/resources/service.d.ts +2 -16
  26. package/dist/core/resources/service.d.ts.map +1 -1
  27. package/dist/core/resources/service.js +1 -78
  28. package/dist/core/semantic/service.d.ts.map +1 -1
  29. package/dist/core/semantic/service.js +2 -9
  30. package/dist/core/servers/index.d.ts +12 -11
  31. package/dist/core/servers/index.d.ts.map +1 -1
  32. package/dist/core/servers/index.js +194 -81
  33. package/dist/core/servers/tunnels.d.ts +5 -3
  34. package/dist/core/servers/tunnels.d.ts.map +1 -1
  35. package/dist/core/servers/tunnels.js +45 -2
  36. package/dist/core/supervisor/service.d.ts +2 -4
  37. package/dist/core/supervisor/service.d.ts.map +1 -1
  38. package/dist/core/turn-rollback/content.d.ts +53 -0
  39. package/dist/core/turn-rollback/content.d.ts.map +1 -0
  40. package/dist/core/turn-rollback/content.js +396 -0
  41. package/dist/core/turn-rollback/index.d.ts +72 -0
  42. package/dist/core/turn-rollback/index.d.ts.map +1 -0
  43. package/dist/core/turn-rollback/index.js +443 -0
  44. package/dist/core/workflow/index.d.ts +4 -2
  45. package/dist/core/workflow/index.d.ts.map +1 -1
  46. package/dist/core/workflow/index.js +2 -1
  47. package/dist/core/workflow/recovery-audit.d.ts +30 -0
  48. package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
  49. package/dist/core/workflow/recovery-audit.js +116 -0
  50. package/dist/core/workflow/run-state.d.ts +19 -2
  51. package/dist/core/workflow/run-state.d.ts.map +1 -1
  52. package/dist/core/workflow/run-state.js +113 -8
  53. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  54. package/dist/core/workflow/workspaces/local-git.js +70 -14
  55. package/dist/core/workflow/workspaces/reclaim.d.ts +6 -1
  56. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  57. package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
  58. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  59. package/dist/core/workflow/workspaces/recovery.js +24 -0
  60. package/dist/lib/tunnel/state.d.ts +13 -6
  61. package/dist/lib/tunnel/state.d.ts.map +1 -1
  62. package/dist/lib/tunnel/state.js +58 -12
  63. package/package.json +6 -1
  64. package/src/commander.ts +10 -0
  65. package/src/commands/files.ts +13 -5
  66. package/src/commands/servers.ts +12 -23
  67. package/src/commands/tunnel.ts +533 -166
  68. package/src/commands/turn-rollback.ts +111 -0
  69. package/src/commands/workflow.ts +59 -0
  70. package/src/core/agents/coord-client.ts +20 -0
  71. package/src/core/agents/state/proc-start.ts +14 -11
  72. package/src/core/agents/state/process-handle.ts +165 -0
  73. package/src/core/artifacts/delivery-card.ts +7 -1
  74. package/src/core/resources/service.ts +3 -88
  75. package/src/core/semantic/service.ts +2 -8
  76. package/src/core/servers/index.ts +234 -85
  77. package/src/core/servers/tunnels.ts +55 -3
  78. package/src/core/supervisor/service.ts +2 -3
  79. package/src/core/turn-rollback/content.ts +499 -0
  80. package/src/core/turn-rollback/index.ts +580 -0
  81. package/src/core/workflow/index.ts +12 -1
  82. package/src/core/workflow/recovery-audit.ts +181 -0
  83. package/src/core/workflow/run-state.ts +137 -8
  84. package/src/core/workflow/workspaces/local-git.ts +86 -15
  85. package/src/core/workflow/workspaces/reclaim.ts +4 -0
  86. package/src/core/workflow/workspaces/recovery.ts +23 -0
  87. 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 = 1;
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
- const now = new Date().toISOString();
213
- const startToken = input.start_token ?? processStartToken(pid);
214
- const pids = [...new Set(input.pids?.filter((p) => Number.isSafeInteger(p) && p > 0))];
215
- const pidsStartTokens: Record<string, string> = {};
216
- for (const auxiliaryPid of pids) {
217
- const token = processStartToken(auxiliaryPid);
218
- if (token) pidsStartTokens[auxiliaryPid] = token;
219
- }
220
- const owner = input.owner === undefined ? callingOwner(coordRoot) : (input.owner ?? undefined);
221
- const record: ServerRecord = {
222
- schema_version: SERVER_RECORD_SCHEMA_VERSION,
223
- id,
224
- kind: input.kind,
225
- type: input.type,
226
- label: input.label ?? id,
227
- ...(input.url ? { url: input.url } : {}),
228
- ...(input.port ? { port: input.port } : {}),
229
- pid,
230
- ...(startToken ? { start_token: startToken } : {}),
231
- ...(pids.length ? { pids, pids_start_tokens: pidsStartTokens } : {}),
232
- host: hostname(),
233
- ...(input.scope ? { scope: resolve(input.scope) } : {}),
234
- ...(input.cwd ? { cwd: resolve(input.cwd) } : {}),
235
- ...(input.log ? { log: resolve(input.log) } : {}),
236
- ...(owner ? { owner } : {}),
237
- started_at: input.started_at ?? now,
238
- registered_at: now,
239
- last_active_at: now,
240
- };
241
- if (input.start_token !== undefined && verifyProcessGeneration(pid, input.start_token) !== "same")
242
- throw new Error(`pid ${pid} generation could not be verified before registration`);
243
- writePrivateJsonAtomic(recordPath(coordRoot, id), record);
244
- return record;
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
- * Remove a server's record. With `pid`, only a record still naming that pid
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
- const path = recordPath(coordRoot, id);
278
- if (options?.pid !== undefined) {
279
- const record = readRecord(path);
280
- if (record && record.pid !== options.pid) return false;
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
- if (!existsSync(path)) return false;
283
- rmSync(path, { force: true });
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
- return readRecord(recordPath(coordRoot, id));
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
- const path = recordPath(coordRoot, id);
298
- const record = readRecord(path);
299
- if (!record) return;
300
- writePrivateJsonAtomic(path, {
301
- ...record,
302
- last_active_at: (options?.now ?? new Date()).toISOString(),
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) => readRecord(join(dir, name)))
349
- .filter((record): record is ServerRecord => record !== null)
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.id, { coordRoot, pid: record.pid });
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
- * Each signal, including the process group and auxiliary processes, requires
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
- target.pid === record.pid
655
- ? signalServerLeader(target.pid, target.token, name, dependencies)
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. Older tunnels need an explicit adoption of an observed listener.
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 { gateLogFile, type TunnelState } from "../../lib/tunnel/state.ts";
10
- import type { RegisterServerInput } from "./index.ts";
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);