pi-amq 0.1.1

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 (264) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -0
  4. package/THIRD_PARTY_NOTICES.md +33 -0
  5. package/dist/extension/guidance.d.ts +25 -0
  6. package/dist/extension/guidance.d.ts.map +1 -0
  7. package/dist/extension/guidance.js +121 -0
  8. package/dist/extension/guidance.js.map +1 -0
  9. package/dist/extension/guide.d.ts +30 -0
  10. package/dist/extension/guide.d.ts.map +1 -0
  11. package/dist/extension/guide.js +128 -0
  12. package/dist/extension/guide.js.map +1 -0
  13. package/dist/extension/index.d.ts +4 -0
  14. package/dist/extension/index.d.ts.map +1 -0
  15. package/dist/extension/index.js +6 -0
  16. package/dist/extension/index.js.map +1 -0
  17. package/dist/extension/runtime.d.ts +48 -0
  18. package/dist/extension/runtime.d.ts.map +1 -0
  19. package/dist/extension/runtime.js +1095 -0
  20. package/dist/extension/runtime.js.map +1 -0
  21. package/dist/extension/tools.d.ts +12 -0
  22. package/dist/extension/tools.d.ts.map +1 -0
  23. package/dist/extension/tools.js +198 -0
  24. package/dist/extension/tools.js.map +1 -0
  25. package/dist/extension/worker-runtime.d.ts +34 -0
  26. package/dist/extension/worker-runtime.d.ts.map +1 -0
  27. package/dist/extension/worker-runtime.js +974 -0
  28. package/dist/extension/worker-runtime.js.map +1 -0
  29. package/dist/extension/worker-tools.d.ts +140 -0
  30. package/dist/extension/worker-tools.d.ts.map +1 -0
  31. package/dist/extension/worker-tools.js +217 -0
  32. package/dist/extension/worker-tools.js.map +1 -0
  33. package/dist/integration/confirmed-bind-onboarding.d.ts +44 -0
  34. package/dist/integration/confirmed-bind-onboarding.d.ts.map +1 -0
  35. package/dist/integration/confirmed-bind-onboarding.js +199 -0
  36. package/dist/integration/confirmed-bind-onboarding.js.map +1 -0
  37. package/dist/integration/fleet-view.d.ts +66 -0
  38. package/dist/integration/fleet-view.d.ts.map +1 -0
  39. package/dist/integration/fleet-view.js +238 -0
  40. package/dist/integration/fleet-view.js.map +1 -0
  41. package/dist/integration/fresh-session-dispatcher.d.ts +66 -0
  42. package/dist/integration/fresh-session-dispatcher.d.ts.map +1 -0
  43. package/dist/integration/fresh-session-dispatcher.js +103 -0
  44. package/dist/integration/fresh-session-dispatcher.js.map +1 -0
  45. package/dist/integration/launch-preview.d.ts +31 -0
  46. package/dist/integration/launch-preview.d.ts.map +1 -0
  47. package/dist/integration/launch-preview.js +325 -0
  48. package/dist/integration/launch-preview.js.map +1 -0
  49. package/dist/integration/transport-port.d.ts +39 -0
  50. package/dist/integration/transport-port.d.ts.map +1 -0
  51. package/dist/integration/transport-port.js +2 -0
  52. package/dist/integration/transport-port.js.map +1 -0
  53. package/dist/integration/worker-completion-notifier.d.ts +22 -0
  54. package/dist/integration/worker-completion-notifier.d.ts.map +1 -0
  55. package/dist/integration/worker-completion-notifier.js +81 -0
  56. package/dist/integration/worker-completion-notifier.js.map +1 -0
  57. package/dist/transport/amq-cli-adapter.d.ts +41 -0
  58. package/dist/transport/amq-cli-adapter.d.ts.map +1 -0
  59. package/dist/transport/amq-cli-adapter.js +291 -0
  60. package/dist/transport/amq-cli-adapter.js.map +1 -0
  61. package/dist/transport/amq-message.d.ts +47 -0
  62. package/dist/transport/amq-message.d.ts.map +1 -0
  63. package/dist/transport/amq-message.js +173 -0
  64. package/dist/transport/amq-message.js.map +1 -0
  65. package/dist/transport/amq-operations.d.ts +105 -0
  66. package/dist/transport/amq-operations.d.ts.map +1 -0
  67. package/dist/transport/amq-operations.js +320 -0
  68. package/dist/transport/amq-operations.js.map +1 -0
  69. package/dist/transport/atomic-json.d.ts +18 -0
  70. package/dist/transport/atomic-json.d.ts.map +1 -0
  71. package/dist/transport/atomic-json.js +90 -0
  72. package/dist/transport/atomic-json.js.map +1 -0
  73. package/dist/transport/binding-resolver.d.ts +46 -0
  74. package/dist/transport/binding-resolver.d.ts.map +1 -0
  75. package/dist/transport/binding-resolver.js +389 -0
  76. package/dist/transport/binding-resolver.js.map +1 -0
  77. package/dist/transport/bridge-journal.d.ts +100 -0
  78. package/dist/transport/bridge-journal.d.ts.map +1 -0
  79. package/dist/transport/bridge-journal.js +459 -0
  80. package/dist/transport/bridge-journal.js.map +1 -0
  81. package/dist/transport/dlq-retry-audit.d.ts +28 -0
  82. package/dist/transport/dlq-retry-audit.d.ts.map +1 -0
  83. package/dist/transport/dlq-retry-audit.js +66 -0
  84. package/dist/transport/dlq-retry-audit.js.map +1 -0
  85. package/dist/transport/inbox-pump.d.ts +81 -0
  86. package/dist/transport/inbox-pump.d.ts.map +1 -0
  87. package/dist/transport/inbox-pump.js +558 -0
  88. package/dist/transport/inbox-pump.js.map +1 -0
  89. package/dist/transport/index.d.ts +12 -0
  90. package/dist/transport/index.d.ts.map +1 -0
  91. package/dist/transport/index.js +12 -0
  92. package/dist/transport/index.js.map +1 -0
  93. package/dist/transport/presence-projector.d.ts +29 -0
  94. package/dist/transport/presence-projector.d.ts.map +1 -0
  95. package/dist/transport/presence-projector.js +73 -0
  96. package/dist/transport/presence-projector.js.map +1 -0
  97. package/dist/transport/priority-delivery-scheduler.d.ts +85 -0
  98. package/dist/transport/priority-delivery-scheduler.d.ts.map +1 -0
  99. package/dist/transport/priority-delivery-scheduler.js +210 -0
  100. package/dist/transport/priority-delivery-scheduler.js.map +1 -0
  101. package/dist/transport/receiver-lease.d.ts +102 -0
  102. package/dist/transport/receiver-lease.d.ts.map +1 -0
  103. package/dist/transport/receiver-lease.js +778 -0
  104. package/dist/transport/receiver-lease.js.map +1 -0
  105. package/dist/transport/runtime-status.d.ts +48 -0
  106. package/dist/transport/runtime-status.d.ts.map +1 -0
  107. package/dist/transport/runtime-status.js +71 -0
  108. package/dist/transport/runtime-status.js.map +1 -0
  109. package/dist/workflow/atomic-storage.d.ts +12 -0
  110. package/dist/workflow/atomic-storage.d.ts.map +1 -0
  111. package/dist/workflow/atomic-storage.js +104 -0
  112. package/dist/workflow/atomic-storage.js.map +1 -0
  113. package/dist/workflow/budget.d.ts +18 -0
  114. package/dist/workflow/budget.d.ts.map +1 -0
  115. package/dist/workflow/budget.js +74 -0
  116. package/dist/workflow/budget.js.map +1 -0
  117. package/dist/workflow/index.d.ts +17 -0
  118. package/dist/workflow/index.d.ts.map +1 -0
  119. package/dist/workflow/index.js +17 -0
  120. package/dist/workflow/index.js.map +1 -0
  121. package/dist/workflow/lifecycle-projection.d.ts +18 -0
  122. package/dist/workflow/lifecycle-projection.d.ts.map +1 -0
  123. package/dist/workflow/lifecycle-projection.js +22 -0
  124. package/dist/workflow/lifecycle-projection.js.map +1 -0
  125. package/dist/workflow/model-fallback.d.ts +2 -0
  126. package/dist/workflow/model-fallback.d.ts.map +1 -0
  127. package/dist/workflow/model-fallback.js +17 -0
  128. package/dist/workflow/model-fallback.js.map +1 -0
  129. package/dist/workflow/pi-session-fork.d.ts +18 -0
  130. package/dist/workflow/pi-session-fork.d.ts.map +1 -0
  131. package/dist/workflow/pi-session-fork.js +80 -0
  132. package/dist/workflow/pi-session-fork.js.map +1 -0
  133. package/dist/workflow/run-artifacts.d.ts +5 -0
  134. package/dist/workflow/run-artifacts.d.ts.map +1 -0
  135. package/dist/workflow/run-artifacts.js +25 -0
  136. package/dist/workflow/run-artifacts.js.map +1 -0
  137. package/dist/workflow/run-cleanup.d.ts +70 -0
  138. package/dist/workflow/run-cleanup.d.ts.map +1 -0
  139. package/dist/workflow/run-cleanup.js +222 -0
  140. package/dist/workflow/run-cleanup.js.map +1 -0
  141. package/dist/workflow/run-gate.d.ts +43 -0
  142. package/dist/workflow/run-gate.d.ts.map +1 -0
  143. package/dist/workflow/run-gate.js +70 -0
  144. package/dist/workflow/run-gate.js.map +1 -0
  145. package/dist/workflow/run-store.d.ts +208 -0
  146. package/dist/workflow/run-store.d.ts.map +1 -0
  147. package/dist/workflow/run-store.js +2965 -0
  148. package/dist/workflow/run-store.js.map +1 -0
  149. package/dist/workflow/spawn-confirmation.d.ts +44 -0
  150. package/dist/workflow/spawn-confirmation.d.ts.map +1 -0
  151. package/dist/workflow/spawn-confirmation.js +228 -0
  152. package/dist/workflow/spawn-confirmation.js.map +1 -0
  153. package/dist/workflow/system-process-inspector.d.ts +9 -0
  154. package/dist/workflow/system-process-inspector.d.ts.map +1 -0
  155. package/dist/workflow/system-process-inspector.js +129 -0
  156. package/dist/workflow/system-process-inspector.js.map +1 -0
  157. package/dist/workflow/worker-child-extension.d.ts +3 -0
  158. package/dist/workflow/worker-child-extension.d.ts.map +1 -0
  159. package/dist/workflow/worker-child-extension.js +182 -0
  160. package/dist/workflow/worker-child-extension.js.map +1 -0
  161. package/dist/workflow/worker-gate.d.ts +74 -0
  162. package/dist/workflow/worker-gate.d.ts.map +1 -0
  163. package/dist/workflow/worker-gate.js +165 -0
  164. package/dist/workflow/worker-gate.js.map +1 -0
  165. package/dist/workflow/worker-launch.d.ts +65 -0
  166. package/dist/workflow/worker-launch.d.ts.map +1 -0
  167. package/dist/workflow/worker-launch.js +338 -0
  168. package/dist/workflow/worker-launch.js.map +1 -0
  169. package/dist/workflow/worker-model-catalog.d.ts +36 -0
  170. package/dist/workflow/worker-model-catalog.d.ts.map +1 -0
  171. package/dist/workflow/worker-model-catalog.js +105 -0
  172. package/dist/workflow/worker-model-catalog.js.map +1 -0
  173. package/dist/workflow/worker-process-supervisor.d.ts +110 -0
  174. package/dist/workflow/worker-process-supervisor.d.ts.map +1 -0
  175. package/dist/workflow/worker-process-supervisor.js +1188 -0
  176. package/dist/workflow/worker-process-supervisor.js.map +1 -0
  177. package/dist/workflow/worker-prompt-capsule.d.ts +32 -0
  178. package/dist/workflow/worker-prompt-capsule.d.ts.map +1 -0
  179. package/dist/workflow/worker-prompt-capsule.js +52 -0
  180. package/dist/workflow/worker-prompt-capsule.js.map +1 -0
  181. package/dist/workflow/worker-role-presets.d.ts +38 -0
  182. package/dist/workflow/worker-role-presets.d.ts.map +1 -0
  183. package/dist/workflow/worker-role-presets.js +201 -0
  184. package/dist/workflow/worker-role-presets.js.map +1 -0
  185. package/dist/workflow/workflow-doctor.d.ts +57 -0
  186. package/dist/workflow/workflow-doctor.d.ts.map +1 -0
  187. package/dist/workflow/workflow-doctor.js +289 -0
  188. package/dist/workflow/workflow-doctor.js.map +1 -0
  189. package/dist/workflow/workflow-lease.d.ts +125 -0
  190. package/dist/workflow/workflow-lease.d.ts.map +1 -0
  191. package/dist/workflow/workflow-lease.js +689 -0
  192. package/dist/workflow/workflow-lease.js.map +1 -0
  193. package/dist/workflow/workflow-types.d.ts +335 -0
  194. package/dist/workflow/workflow-types.d.ts.map +1 -0
  195. package/dist/workflow/workflow-types.js +11 -0
  196. package/dist/workflow/workflow-types.js.map +1 -0
  197. package/dist/workflow/workspace-paths.d.ts +4 -0
  198. package/dist/workflow/workspace-paths.d.ts.map +1 -0
  199. package/dist/workflow/workspace-paths.js +22 -0
  200. package/dist/workflow/workspace-paths.js.map +1 -0
  201. package/docs/conformance.md +116 -0
  202. package/docs/demo-transcript.md +34 -0
  203. package/docs/design.md +1481 -0
  204. package/docs/operator-guide.md +100 -0
  205. package/extension/guidance.ts +162 -0
  206. package/extension/guide.ts +155 -0
  207. package/extension/index.ts +8 -0
  208. package/extension/runtime.ts +1224 -0
  209. package/extension/tools.ts +221 -0
  210. package/extension/worker-runtime.ts +1150 -0
  211. package/extension/worker-tools.ts +276 -0
  212. package/integration/confirmed-bind-onboarding.ts +296 -0
  213. package/integration/fleet-view.ts +286 -0
  214. package/integration/fresh-session-dispatcher.ts +166 -0
  215. package/integration/launch-preview.ts +396 -0
  216. package/integration/transport-port.ts +29 -0
  217. package/integration/worker-completion-notifier.ts +98 -0
  218. package/package.json +55 -0
  219. package/roles/oracle.md +9 -0
  220. package/roles/researcher.md +8 -0
  221. package/roles/reviewer.md +9 -0
  222. package/roles/scout.md +8 -0
  223. package/roles/worker.md +10 -0
  224. package/scripts/demo-two-terminal.sh +59 -0
  225. package/skills/coordination-patterns/SKILL.md +41 -0
  226. package/skills/coordination-patterns/references/supervised-worker-fan-out.md +12 -0
  227. package/skills/coordination-patterns/references/today-mail-team.md +36 -0
  228. package/transport/amq-cli-adapter.ts +381 -0
  229. package/transport/amq-message.ts +218 -0
  230. package/transport/amq-operations.ts +482 -0
  231. package/transport/atomic-json.ts +115 -0
  232. package/transport/binding-resolver.ts +565 -0
  233. package/transport/bridge-journal.ts +588 -0
  234. package/transport/dlq-retry-audit.ts +97 -0
  235. package/transport/inbox-pump.ts +729 -0
  236. package/transport/index.ts +11 -0
  237. package/transport/presence-projector.ts +104 -0
  238. package/transport/priority-delivery-scheduler.ts +328 -0
  239. package/transport/receiver-lease.ts +945 -0
  240. package/transport/runtime-status.ts +112 -0
  241. package/workflow/.gitkeep +1 -0
  242. package/workflow/atomic-storage.ts +120 -0
  243. package/workflow/budget.ts +90 -0
  244. package/workflow/index.ts +16 -0
  245. package/workflow/lifecycle-projection.ts +37 -0
  246. package/workflow/model-fallback.ts +17 -0
  247. package/workflow/pi-session-fork.ts +96 -0
  248. package/workflow/run-artifacts.ts +36 -0
  249. package/workflow/run-cleanup.ts +315 -0
  250. package/workflow/run-gate.ts +122 -0
  251. package/workflow/run-store.ts +3890 -0
  252. package/workflow/spawn-confirmation.ts +305 -0
  253. package/workflow/system-process-inspector.ts +142 -0
  254. package/workflow/worker-child-extension.ts +193 -0
  255. package/workflow/worker-gate.ts +238 -0
  256. package/workflow/worker-launch.ts +485 -0
  257. package/workflow/worker-model-catalog.ts +165 -0
  258. package/workflow/worker-process-supervisor.ts +1504 -0
  259. package/workflow/worker-prompt-capsule.ts +81 -0
  260. package/workflow/worker-role-presets.ts +241 -0
  261. package/workflow/workflow-doctor.ts +400 -0
  262. package/workflow/workflow-lease.ts +905 -0
  263. package/workflow/workflow-types.ts +367 -0
  264. package/workflow/workspace-paths.ts +32 -0
@@ -0,0 +1,945 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID as nodeRandomUUID } from "node:crypto";
3
+ import {
4
+ chmod,
5
+ mkdir,
6
+ mkdtemp,
7
+ readFile,
8
+ readdir,
9
+ readlink,
10
+ rename,
11
+ rm,
12
+ stat,
13
+ unlink,
14
+ } from "node:fs/promises";
15
+ import { hostname as localHostname } from "node:os";
16
+ import { isAbsolute, join, normalize } from "node:path";
17
+ import { setTimeout as delay } from "node:timers/promises";
18
+
19
+ import {
20
+ readJson,
21
+ syncDirectory,
22
+ writeAtomicJson,
23
+ } from "./atomic-json.js";
24
+
25
+ const OWNER_KEYS = [
26
+ "schema",
27
+ "generation",
28
+ "piSessionId",
29
+ "piSessionFile",
30
+ "pid",
31
+ "processStartIdentity",
32
+ "bootIdentity",
33
+ "executable",
34
+ "hostname",
35
+ "rootId",
36
+ "createdAt",
37
+ ] as const;
38
+
39
+ const GUARD_KEYS = [
40
+ "schema",
41
+ "generation",
42
+ "pid",
43
+ "processStartIdentity",
44
+ "bootIdentity",
45
+ "executable",
46
+ "hostname",
47
+ "createdAt",
48
+ ] as const;
49
+
50
+ export interface ProcessIdentity {
51
+ readonly pid: number;
52
+ readonly processStartIdentity: string;
53
+ readonly bootIdentity: string | null;
54
+ readonly executable: string;
55
+ readonly hostname: string;
56
+ }
57
+
58
+ export type ProcessInspection =
59
+ | { readonly kind: "live"; readonly identity: ProcessIdentity }
60
+ | { readonly kind: "absent" }
61
+ | { readonly kind: "unknown"; readonly diagnostic: string };
62
+
63
+ export interface ProcessIdentityInspector {
64
+ current(): Promise<ProcessIdentity>;
65
+ inspect(pid: number): Promise<ProcessInspection>;
66
+ }
67
+
68
+ export interface ReceiverLeaseOwnerV1 {
69
+ readonly schema: 1;
70
+ readonly generation: string;
71
+ readonly piSessionId: string;
72
+ readonly piSessionFile: string;
73
+ readonly pid: number;
74
+ readonly processStartIdentity: string;
75
+ readonly bootIdentity: string | null;
76
+ readonly executable: string;
77
+ readonly hostname: string;
78
+ readonly rootId: string;
79
+ readonly createdAt: string;
80
+ }
81
+
82
+ interface LifecycleGuardV1 {
83
+ readonly schema: 1;
84
+ readonly generation: string;
85
+ readonly pid: number;
86
+ readonly processStartIdentity: string;
87
+ readonly bootIdentity: string | null;
88
+ readonly executable: string;
89
+ readonly hostname: string;
90
+ readonly createdAt: string;
91
+ }
92
+
93
+ export interface ReceiverLeaseManagerOptions {
94
+ readonly namespaceDirectory: string;
95
+ readonly rootId: string;
96
+ readonly inspector: ProcessIdentityInspector;
97
+ readonly randomUUID?: () => string;
98
+ readonly now?: () => Date;
99
+ readonly guardWaitMs?: number;
100
+ }
101
+
102
+ export type ReceiverLeaseRefusalReason =
103
+ | "remote-owner"
104
+ | "unverifiable-owner"
105
+ | "corrupt-owner"
106
+ | "lifecycle-busy"
107
+ | "lifecycle-unverifiable";
108
+
109
+ export type ReceiverLeaseAcquireResult =
110
+ | {
111
+ readonly kind: "acquired";
112
+ readonly owner: ReceiverLeaseOwnerV1;
113
+ readonly reclaimed?: {
114
+ readonly owner: ReceiverLeaseOwnerV1;
115
+ readonly quarantinePath: string;
116
+ };
117
+ }
118
+ | { readonly kind: "held"; readonly owner: ReceiverLeaseOwnerV1 }
119
+ | {
120
+ readonly kind: "refused";
121
+ readonly reason: ReceiverLeaseRefusalReason;
122
+ readonly diagnostic: string;
123
+ };
124
+
125
+ export type ReceiverLeaseReleaseResult =
126
+ | { readonly kind: "released" }
127
+ | { readonly kind: "not-owner"; readonly owner?: ReceiverLeaseOwnerV1 }
128
+ | { readonly kind: "refused"; readonly diagnostic: string };
129
+
130
+ export type ReceiverLeaseProjection =
131
+ | { readonly kind: "empty" }
132
+ | { readonly kind: "owned"; readonly owner: ReceiverLeaseOwnerV1 }
133
+ | { readonly kind: "corrupt"; readonly diagnostic: string };
134
+
135
+ export class LeaseFenceError extends Error {
136
+ constructor(message: string) {
137
+ super(message);
138
+ this.name = "LeaseFenceError";
139
+ }
140
+ }
141
+
142
+ interface AcquireRequest {
143
+ readonly piSessionId: string;
144
+ readonly piSessionFile: string;
145
+ }
146
+
147
+ type OwnerState =
148
+ | { readonly kind: "live" }
149
+ | { readonly kind: "stale"; readonly reason: string }
150
+ | { readonly kind: "remote" }
151
+ | { readonly kind: "unknown"; readonly diagnostic: string };
152
+
153
+ type GuardResult<T> =
154
+ | { readonly ok: true; readonly value: T }
155
+ | { readonly ok: false; readonly reason: ReceiverLeaseRefusalReason; readonly diagnostic: string };
156
+
157
+ /** Owns the generation-fenced receiver lifecycle inside one extension namespace. */
158
+ export class ReceiverLeaseManager {
159
+ readonly #namespaceDirectory: string;
160
+ readonly #rootId: string;
161
+ readonly #inspector: ProcessIdentityInspector;
162
+ readonly #randomUUID: () => string;
163
+ readonly #now: () => Date;
164
+ readonly #guardWaitMs: number;
165
+
166
+ constructor(options: ReceiverLeaseManagerOptions) {
167
+ if (!isAbsolute(options.namespaceDirectory)) {
168
+ throw new TypeError("Receiver lease namespace must be absolute");
169
+ }
170
+ if (options.rootId.length === 0) throw new TypeError("Receiver rootId is required");
171
+ this.#namespaceDirectory = normalize(options.namespaceDirectory);
172
+ this.#rootId = options.rootId;
173
+ this.#inspector = options.inspector;
174
+ this.#randomUUID = options.randomUUID ?? nodeRandomUUID;
175
+ this.#now = options.now ?? (() => new Date());
176
+ this.#guardWaitMs = options.guardWaitMs ?? 1_000;
177
+ }
178
+
179
+ async acquire(
180
+ request: AcquireRequest,
181
+ priorReclaim?: { readonly owner: ReceiverLeaseOwnerV1; readonly quarantinePath: string },
182
+ ): Promise<ReceiverLeaseAcquireResult> {
183
+ validateAcquireRequest(request);
184
+ await this.#initialize();
185
+ const guarded = await this.#withLifecycleGuard(async (current) => {
186
+ const projection = await this.inspectLease();
187
+ if (projection.kind === "corrupt") {
188
+ return {
189
+ kind: "refused" as const,
190
+ reason: "corrupt-owner" as const,
191
+ diagnostic: projection.diagnostic,
192
+ };
193
+ }
194
+ if (projection.kind === "empty") {
195
+ const owner = ownerFromRequest(
196
+ request,
197
+ current,
198
+ this.#rootId,
199
+ this.#randomUUID(),
200
+ this.#now(),
201
+ );
202
+ const created = await this.#createReceiver(owner);
203
+ return created
204
+ ? {
205
+ kind: "acquired" as const,
206
+ owner,
207
+ ...(priorReclaim ? { reclaimed: priorReclaim } : {}),
208
+ }
209
+ : { kind: "retry" as const };
210
+ }
211
+
212
+ const state = await classifyOwner(projection.owner, current, this.#inspector);
213
+ switch (state.kind) {
214
+ case "live":
215
+ return { kind: "held" as const, owner: projection.owner };
216
+ case "remote":
217
+ return {
218
+ kind: "refused" as const,
219
+ reason: "remote-owner" as const,
220
+ diagnostic: `Receiver owner is on remote host ${projection.owner.hostname}`,
221
+ };
222
+ case "unknown":
223
+ return {
224
+ kind: "refused" as const,
225
+ reason: "unverifiable-owner" as const,
226
+ diagnostic: state.diagnostic,
227
+ };
228
+ case "stale": {
229
+ const reread = await this.inspectLease();
230
+ if (reread.kind !== "owned" || !sameOwner(reread.owner, projection.owner)) {
231
+ return { kind: "retry" as const };
232
+ }
233
+ const quarantinePath = join(
234
+ this.#namespaceDirectory,
235
+ "quarantine",
236
+ `receiver.${safePathPart(projection.owner.generation)}.${this.#now().getTime()}.${safePathPart(this.#randomUUID())}`,
237
+ );
238
+ await rename(this.#receiverPath(), quarantinePath);
239
+ await syncDirectory(this.#namespaceDirectory);
240
+ return {
241
+ kind: "reclaimed" as const,
242
+ owner: projection.owner,
243
+ quarantinePath,
244
+ };
245
+ }
246
+ }
247
+ });
248
+
249
+ if (!guarded.ok) {
250
+ return { kind: "refused", reason: guarded.reason, diagnostic: guarded.diagnostic };
251
+ }
252
+ if (guarded.value.kind === "retry") return this.acquire(request, priorReclaim);
253
+ if (guarded.value.kind === "reclaimed") {
254
+ return this.acquire(request, {
255
+ owner: guarded.value.owner,
256
+ quarantinePath: guarded.value.quarantinePath,
257
+ });
258
+ }
259
+ return guarded.value;
260
+ }
261
+
262
+ async release(generation: string): Promise<ReceiverLeaseReleaseResult> {
263
+ requireNonEmpty(generation, "lease generation");
264
+ await this.#initialize();
265
+ const guarded = await this.#withLifecycleGuard(async () => {
266
+ const projection = await this.inspectLease();
267
+ if (projection.kind === "empty") return { kind: "not-owner" as const };
268
+ if (projection.kind === "corrupt") {
269
+ return { kind: "refused" as const, diagnostic: projection.diagnostic };
270
+ }
271
+ if (projection.owner.generation !== generation || projection.owner.rootId !== this.#rootId) {
272
+ return { kind: "not-owner" as const, owner: projection.owner };
273
+ }
274
+
275
+ const releasedPath = join(
276
+ this.#namespaceDirectory,
277
+ `.receiver.released-${safePathPart(generation)}-${safePathPart(this.#randomUUID())}`,
278
+ );
279
+ await rename(this.#receiverPath(), releasedPath);
280
+ await syncDirectory(this.#namespaceDirectory);
281
+ await rm(releasedPath, { recursive: true });
282
+ await syncDirectory(this.#namespaceDirectory);
283
+ return { kind: "released" as const };
284
+ });
285
+ return guarded.ok
286
+ ? guarded.value
287
+ : { kind: "refused", diagnostic: guarded.diagnostic };
288
+ }
289
+
290
+ async inspectLease(): Promise<ReceiverLeaseProjection> {
291
+ let raw: unknown;
292
+ try {
293
+ raw = await readJson(join(this.#receiverPath(), "owner.json"));
294
+ } catch (error) {
295
+ return { kind: "corrupt", diagnostic: `Receiver owner JSON is invalid: ${toError(error).message}` };
296
+ }
297
+ if (raw === undefined) {
298
+ try {
299
+ await stat(this.#receiverPath());
300
+ } catch (error) {
301
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "empty" };
302
+ throw error;
303
+ }
304
+ return { kind: "corrupt", diagnostic: "receiver.lock exists without a complete owner.json" };
305
+ }
306
+ try {
307
+ return { kind: "owned", owner: parseReceiverLeaseOwnerV1(raw) };
308
+ } catch (error) {
309
+ return { kind: "corrupt", diagnostic: toError(error).message };
310
+ }
311
+ }
312
+
313
+ async withCurrentLease<T>(generation: string, operation: () => Promise<T>): Promise<T> {
314
+ requireNonEmpty(generation, "lease generation");
315
+ await this.#initialize();
316
+ const guarded = await this.#withLifecycleGuard(async (current) => {
317
+ const projection = await this.inspectLease();
318
+ if (projection.kind !== "owned"
319
+ || projection.owner.generation !== generation
320
+ || projection.owner.rootId !== this.#rootId
321
+ || !ownerIsCurrentProcess(projection.owner, current)) {
322
+ throw new LeaseFenceError("Receiver lease generation is not current");
323
+ }
324
+ return operation();
325
+ });
326
+ if (!guarded.ok) throw new LeaseFenceError(guarded.diagnostic);
327
+ return guarded.value;
328
+ }
329
+
330
+ async #initialize(): Promise<void> {
331
+ await mkdir(this.#namespaceDirectory, { recursive: true, mode: 0o700 });
332
+ await mkdir(join(this.#namespaceDirectory, "quarantine"), { recursive: true, mode: 0o700 });
333
+ await mkdir(join(this.#namespaceDirectory, "lifecycle-reclaims"), { recursive: true, mode: 0o700 });
334
+ }
335
+
336
+ async #createReceiver(owner: ReceiverLeaseOwnerV1): Promise<boolean> {
337
+ const prepared = await mkdtemp(join(this.#namespaceDirectory, ".receiver.prepare-"));
338
+ await chmod(prepared, 0o700);
339
+ let installed = false;
340
+ try {
341
+ await writeAtomicJson(join(prepared, "owner.json"), owner, { createOnly: true });
342
+ await syncDirectory(prepared);
343
+ await rename(prepared, this.#receiverPath());
344
+ installed = true;
345
+ await syncDirectory(this.#namespaceDirectory);
346
+ return true;
347
+ } catch (error) {
348
+ if (isNodeError(error) && (error.code === "EEXIST" || error.code === "ENOTEMPTY")) {
349
+ return false;
350
+ }
351
+ throw error;
352
+ } finally {
353
+ if (!installed) await rm(prepared, { recursive: true, force: true });
354
+ }
355
+ }
356
+
357
+ async #withLifecycleGuard<T>(operation: (current: ProcessIdentity) => Promise<T>): Promise<GuardResult<T>> {
358
+ const current = await this.#inspector.current();
359
+ validateProcessIdentity(current, "current process identity");
360
+ const deadline = Date.now() + this.#guardWaitMs;
361
+
362
+ while (true) {
363
+ const beforeAcquire = await this.#waitForNoReclaimIntent(current, deadline);
364
+ if (!beforeAcquire.ok) return beforeAcquire;
365
+ const guard: LifecycleGuardV1 = {
366
+ schema: 1,
367
+ generation: this.#randomUUID(),
368
+ pid: current.pid,
369
+ processStartIdentity: current.processStartIdentity,
370
+ bootIdentity: current.bootIdentity,
371
+ executable: current.executable,
372
+ hostname: current.hostname,
373
+ createdAt: this.#now().toISOString(),
374
+ };
375
+ try {
376
+ await writeAtomicJson(this.#guardPath(), guard, { createOnly: true });
377
+ } catch (error) {
378
+ if (!isNodeError(error) || error.code !== "EEXIST") throw error;
379
+ const existing = await this.#readGuard();
380
+ if (existing.kind === "corrupt") {
381
+ return { ok: false, reason: "lifecycle-unverifiable", diagnostic: existing.diagnostic };
382
+ }
383
+ if (existing.kind === "empty") continue;
384
+ const state = await classifyProcessRecord(existing.guard, current, this.#inspector);
385
+ if (state.kind === "stale") {
386
+ const reclaimed = await this.#reclaimLifecycleGuard(current, deadline);
387
+ if (!reclaimed.ok) return reclaimed;
388
+ continue;
389
+ }
390
+ if (state.kind === "remote" || state.kind === "unknown") {
391
+ return {
392
+ ok: false,
393
+ reason: "lifecycle-unverifiable",
394
+ diagnostic: state.kind === "remote"
395
+ ? `Lifecycle guard belongs to remote host ${existing.guard.hostname}`
396
+ : state.diagnostic,
397
+ };
398
+ }
399
+ if (Date.now() >= deadline) {
400
+ return {
401
+ ok: false,
402
+ reason: "lifecycle-busy",
403
+ diagnostic: "Receiver lifecycle operation is busy; no owner was displaced",
404
+ };
405
+ }
406
+ await delay(5);
407
+ continue;
408
+ }
409
+
410
+ const afterAcquire = await this.#scanReclaimIntents(current);
411
+ if (!afterAcquire.ok) {
412
+ await this.#releaseGuard(guard.generation);
413
+ return afterAcquire;
414
+ }
415
+ if (!afterAcquire.clear) {
416
+ await this.#releaseGuard(guard.generation);
417
+ if (Date.now() >= deadline) {
418
+ return {
419
+ ok: false,
420
+ reason: "lifecycle-busy",
421
+ diagnostic: "Receiver lifecycle reclaim is active; no owner was displaced",
422
+ };
423
+ }
424
+ await delay(5);
425
+ continue;
426
+ }
427
+
428
+ let value: T | undefined;
429
+ let operationError: unknown;
430
+ try {
431
+ value = await operation(current);
432
+ } catch (error) {
433
+ operationError = error;
434
+ }
435
+
436
+ let releaseError: unknown;
437
+ try {
438
+ await this.#releaseGuard(guard.generation);
439
+ } catch (error) {
440
+ releaseError = error;
441
+ }
442
+ if (operationError !== undefined && releaseError !== undefined) {
443
+ throw new AggregateError(
444
+ [operationError, releaseError],
445
+ "Receiver lifecycle operation and guard release both failed",
446
+ );
447
+ }
448
+ if (operationError !== undefined) throw operationError;
449
+ if (releaseError !== undefined) throw releaseError;
450
+ return { ok: true, value: value as T };
451
+ }
452
+ }
453
+
454
+ async #readGuard(): Promise<
455
+ | { readonly kind: "empty" }
456
+ | { readonly kind: "owned"; readonly guard: LifecycleGuardV1 }
457
+ | { readonly kind: "corrupt"; readonly diagnostic: string }
458
+ > {
459
+ let raw: unknown;
460
+ try {
461
+ raw = await readJson(this.#guardPath());
462
+ } catch (error) {
463
+ return { kind: "corrupt", diagnostic: `Lifecycle guard JSON is invalid: ${toError(error).message}` };
464
+ }
465
+ if (raw === undefined) return { kind: "empty" };
466
+ try {
467
+ return { kind: "owned", guard: parseLifecycleGuardV1(raw) };
468
+ } catch (error) {
469
+ return { kind: "corrupt", diagnostic: toError(error).message };
470
+ }
471
+ }
472
+
473
+ async #releaseGuard(generation: string): Promise<void> {
474
+ const existing = await this.#readGuard();
475
+ if (existing.kind !== "owned" || existing.guard.generation !== generation) {
476
+ throw new LeaseFenceError("Receiver lifecycle guard generation changed before release");
477
+ }
478
+ await unlink(this.#guardPath());
479
+ await syncDirectory(this.#namespaceDirectory);
480
+ }
481
+
482
+ async #reclaimLifecycleGuard(
483
+ current: ProcessIdentity,
484
+ deadline: number,
485
+ ): Promise<GuardResult<true>> {
486
+ const intent: LifecycleGuardV1 = {
487
+ schema: 1,
488
+ generation: this.#randomUUID(),
489
+ pid: current.pid,
490
+ processStartIdentity: current.processStartIdentity,
491
+ bootIdentity: current.bootIdentity,
492
+ executable: current.executable,
493
+ hostname: current.hostname,
494
+ createdAt: this.#now().toISOString(),
495
+ };
496
+ const intentPath = join(
497
+ this.#reclaimPath(),
498
+ `reclaim.${safePathPart(intent.generation)}.json`,
499
+ );
500
+ await writeAtomicJson(intentPath, intent, { createOnly: true });
501
+ try {
502
+ while (true) {
503
+ const existing = await this.#readGuard();
504
+ if (existing.kind === "empty") return { ok: true, value: true };
505
+ if (existing.kind === "corrupt") {
506
+ return { ok: false, reason: "lifecycle-unverifiable", diagnostic: existing.diagnostic };
507
+ }
508
+ const state = await classifyProcessRecord(existing.guard, current, this.#inspector);
509
+ if (state.kind === "stale") {
510
+ const reread = await this.#readGuard();
511
+ if (reread.kind !== "owned" || !sameGuard(reread.guard, existing.guard)) continue;
512
+ const stalePath = join(
513
+ this.#namespaceDirectory,
514
+ "quarantine",
515
+ `lifecycle.${safePathPart(existing.guard.generation)}.${this.#now().getTime()}.${safePathPart(this.#randomUUID())}.json`,
516
+ );
517
+ try {
518
+ await rename(this.#guardPath(), stalePath);
519
+ await syncDirectory(this.#namespaceDirectory);
520
+ return { ok: true, value: true };
521
+ } catch (error) {
522
+ if (isNodeError(error) && error.code === "ENOENT") continue;
523
+ throw error;
524
+ }
525
+ }
526
+ if (state.kind === "remote" || state.kind === "unknown") {
527
+ return {
528
+ ok: false,
529
+ reason: "lifecycle-unverifiable",
530
+ diagnostic: state.kind === "remote"
531
+ ? `Lifecycle guard belongs to remote host ${existing.guard.hostname}`
532
+ : state.diagnostic,
533
+ };
534
+ }
535
+ if (Date.now() >= deadline) {
536
+ return {
537
+ ok: false,
538
+ reason: "lifecycle-busy",
539
+ diagnostic: "A live lifecycle guard appeared during stale reclaim; no owner was displaced",
540
+ };
541
+ }
542
+ await delay(5);
543
+ }
544
+ } finally {
545
+ await unlink(intentPath);
546
+ await syncDirectory(this.#reclaimPath());
547
+ }
548
+ }
549
+
550
+ async #waitForNoReclaimIntent(
551
+ current: ProcessIdentity,
552
+ deadline: number,
553
+ ): Promise<GuardResult<true>> {
554
+ while (true) {
555
+ const scan = await this.#scanReclaimIntents(current);
556
+ if (!scan.ok) return scan;
557
+ if (scan.clear) return { ok: true, value: true };
558
+ if (Date.now() >= deadline) {
559
+ return {
560
+ ok: false,
561
+ reason: "lifecycle-busy",
562
+ diagnostic: "Receiver lifecycle reclaim is active; no owner was displaced",
563
+ };
564
+ }
565
+ await delay(5);
566
+ }
567
+ }
568
+
569
+ async #scanReclaimIntents(current: ProcessIdentity): Promise<
570
+ | { readonly ok: true; readonly clear: boolean }
571
+ | { readonly ok: false; readonly reason: ReceiverLeaseRefusalReason; readonly diagnostic: string }
572
+ > {
573
+ const entries = await readdir(this.#reclaimPath());
574
+ let liveIntent = false;
575
+ for (const entry of entries) {
576
+ if (!entry.startsWith("reclaim.") || !entry.endsWith(".json")) continue;
577
+ const path = join(this.#reclaimPath(), entry);
578
+ let raw: unknown;
579
+ try {
580
+ raw = await readJson(path);
581
+ } catch (error) {
582
+ return {
583
+ ok: false,
584
+ reason: "lifecycle-unverifiable",
585
+ diagnostic: `Lifecycle reclaim intent is invalid: ${toError(error).message}`,
586
+ };
587
+ }
588
+ if (raw === undefined) continue;
589
+ let intent: LifecycleGuardV1;
590
+ try {
591
+ intent = parseLifecycleGuardV1(raw);
592
+ } catch (error) {
593
+ return {
594
+ ok: false,
595
+ reason: "lifecycle-unverifiable",
596
+ diagnostic: toError(error).message,
597
+ };
598
+ }
599
+ const state = await classifyProcessRecord(intent, current, this.#inspector);
600
+ if (state.kind === "stale") {
601
+ try {
602
+ await unlink(path);
603
+ await syncDirectory(this.#reclaimPath());
604
+ } catch (error) {
605
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
606
+ }
607
+ continue;
608
+ }
609
+ if (state.kind === "remote" || state.kind === "unknown") {
610
+ return {
611
+ ok: false,
612
+ reason: "lifecycle-unverifiable",
613
+ diagnostic: state.kind === "remote"
614
+ ? `Lifecycle reclaim intent belongs to remote host ${intent.hostname}`
615
+ : state.diagnostic,
616
+ };
617
+ }
618
+ liveIntent = true;
619
+ }
620
+ return { ok: true, clear: !liveIntent };
621
+ }
622
+
623
+ #receiverPath(): string {
624
+ return join(this.#namespaceDirectory, "receiver.lock");
625
+ }
626
+
627
+ #guardPath(): string {
628
+ return join(this.#namespaceDirectory, "receiver.lifecycle.guard.json");
629
+ }
630
+
631
+ #reclaimPath(): string {
632
+ return join(this.#namespaceDirectory, "lifecycle-reclaims");
633
+ }
634
+ }
635
+
636
+ export function parseReceiverLeaseOwnerV1(value: unknown): ReceiverLeaseOwnerV1 {
637
+ const object = strictRecord(value, OWNER_KEYS, "receiver owner");
638
+ if (number(object, "schema") !== 1) throw new Error("receiver owner schema must be 1");
639
+ const owner: ReceiverLeaseOwnerV1 = {
640
+ schema: 1,
641
+ generation: nonEmptyString(object, "generation"),
642
+ piSessionId: nonEmptyString(object, "piSessionId"),
643
+ piSessionFile: absolutePath(object, "piSessionFile"),
644
+ pid: positiveInteger(object, "pid"),
645
+ processStartIdentity: nonEmptyString(object, "processStartIdentity"),
646
+ bootIdentity: nullableString(object, "bootIdentity"),
647
+ executable: absolutePath(object, "executable"),
648
+ hostname: nonEmptyString(object, "hostname"),
649
+ rootId: nonEmptyString(object, "rootId"),
650
+ createdAt: isoTimestamp(object, "createdAt"),
651
+ };
652
+ return owner;
653
+ }
654
+
655
+ function parseLifecycleGuardV1(value: unknown): LifecycleGuardV1 {
656
+ const object = strictRecord(value, GUARD_KEYS, "receiver lifecycle guard");
657
+ if (number(object, "schema") !== 1) throw new Error("receiver lifecycle guard schema must be 1");
658
+ return {
659
+ schema: 1,
660
+ generation: nonEmptyString(object, "generation"),
661
+ pid: positiveInteger(object, "pid"),
662
+ processStartIdentity: nonEmptyString(object, "processStartIdentity"),
663
+ bootIdentity: nullableString(object, "bootIdentity"),
664
+ executable: absolutePath(object, "executable"),
665
+ hostname: nonEmptyString(object, "hostname"),
666
+ createdAt: isoTimestamp(object, "createdAt"),
667
+ };
668
+ }
669
+
670
+ function ownerFromRequest(
671
+ request: AcquireRequest,
672
+ identity: ProcessIdentity,
673
+ rootId: string,
674
+ generation: string,
675
+ now: Date,
676
+ ): ReceiverLeaseOwnerV1 {
677
+ return {
678
+ schema: 1,
679
+ generation: requireNonEmpty(generation, "lease generation"),
680
+ piSessionId: requireNonEmpty(request.piSessionId, "pi session ID"),
681
+ piSessionFile: requireAbsolute(request.piSessionFile, "pi session file"),
682
+ pid: identity.pid,
683
+ processStartIdentity: identity.processStartIdentity,
684
+ bootIdentity: identity.bootIdentity,
685
+ executable: identity.executable,
686
+ hostname: identity.hostname,
687
+ rootId,
688
+ createdAt: now.toISOString(),
689
+ };
690
+ }
691
+
692
+ async function classifyOwner(
693
+ owner: ReceiverLeaseOwnerV1,
694
+ current: ProcessIdentity,
695
+ inspector: ProcessIdentityInspector,
696
+ ): Promise<OwnerState> {
697
+ return classifyProcessRecord(owner, current, inspector);
698
+ }
699
+
700
+ async function classifyProcessRecord(
701
+ record: Pick<ReceiverLeaseOwnerV1, "hostname" | "pid" | "processStartIdentity" | "bootIdentity" | "executable">,
702
+ current: ProcessIdentity,
703
+ inspector: ProcessIdentityInspector,
704
+ ): Promise<OwnerState> {
705
+ if (record.hostname !== current.hostname) return { kind: "remote" };
706
+ const inspection = await inspector.inspect(record.pid);
707
+ if (inspection.kind === "absent") return { kind: "stale", reason: "PID is absent" };
708
+ if (inspection.kind === "unknown") return inspection;
709
+ const observed = inspection.identity;
710
+ if (record.processStartIdentity !== observed.processStartIdentity) {
711
+ return { kind: "stale", reason: "PID start identity changed" };
712
+ }
713
+ if (normalize(record.executable) !== normalize(observed.executable)) {
714
+ return { kind: "stale", reason: "PID executable changed" };
715
+ }
716
+ if (record.bootIdentity !== null
717
+ && observed.bootIdentity !== null
718
+ && record.bootIdentity !== observed.bootIdentity) {
719
+ return { kind: "stale", reason: "host boot identity changed" };
720
+ }
721
+ return { kind: "live" };
722
+ }
723
+
724
+ function ownerIsCurrentProcess(owner: ReceiverLeaseOwnerV1, current: ProcessIdentity): boolean {
725
+ return owner.hostname === current.hostname
726
+ && owner.pid === current.pid
727
+ && owner.processStartIdentity === current.processStartIdentity
728
+ && normalize(owner.executable) === normalize(current.executable)
729
+ && (owner.bootIdentity === null
730
+ || current.bootIdentity === null
731
+ || owner.bootIdentity === current.bootIdentity);
732
+ }
733
+
734
+ function sameOwner(left: ReceiverLeaseOwnerV1, right: ReceiverLeaseOwnerV1): boolean {
735
+ return JSON.stringify(left) === JSON.stringify(right);
736
+ }
737
+
738
+ function sameGuard(left: LifecycleGuardV1, right: LifecycleGuardV1): boolean {
739
+ return JSON.stringify(left) === JSON.stringify(right);
740
+ }
741
+
742
+ function validateAcquireRequest(request: AcquireRequest): void {
743
+ requireNonEmpty(request.piSessionId, "pi session ID");
744
+ requireAbsolute(request.piSessionFile, "pi session file");
745
+ }
746
+
747
+ function validateProcessIdentity(identity: ProcessIdentity, name: string): void {
748
+ if (!Number.isSafeInteger(identity.pid) || identity.pid <= 0) throw new Error(`${name} PID is invalid`);
749
+ requireNonEmpty(identity.processStartIdentity, `${name} start identity`);
750
+ requireAbsolute(identity.executable, `${name} executable`);
751
+ requireNonEmpty(identity.hostname, `${name} hostname`);
752
+ }
753
+
754
+ function strictRecord(
755
+ value: unknown,
756
+ allowed: readonly string[],
757
+ name: string,
758
+ ): Record<string, unknown> {
759
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
760
+ throw new Error(`${name} must be an object`);
761
+ }
762
+ const object = value as Record<string, unknown>;
763
+ const unknown = Object.keys(object).filter((key) => !allowed.includes(key));
764
+ if (unknown.length > 0) throw new Error(`${name} contains unknown fields: ${unknown.join(", ")}`);
765
+ return object;
766
+ }
767
+
768
+ function nonEmptyString(object: Record<string, unknown>, key: string): string {
769
+ const value = object[key];
770
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${key} must be a non-empty string`);
771
+ return value;
772
+ }
773
+
774
+ function nullableString(object: Record<string, unknown>, key: string): string | null {
775
+ const value = object[key];
776
+ if (value === null) return null;
777
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${key} must be null or a non-empty string`);
778
+ return value;
779
+ }
780
+
781
+ function absolutePath(object: Record<string, unknown>, key: string): string {
782
+ return requireAbsolute(nonEmptyString(object, key), key);
783
+ }
784
+
785
+ function positiveInteger(object: Record<string, unknown>, key: string): number {
786
+ const value = number(object, key);
787
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${key} must be a positive safe integer`);
788
+ return value;
789
+ }
790
+
791
+ function number(object: Record<string, unknown>, key: string): number {
792
+ const value = object[key];
793
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
794
+ return value;
795
+ }
796
+
797
+ function isoTimestamp(object: Record<string, unknown>, key: string): string {
798
+ const value = nonEmptyString(object, key);
799
+ if (!Number.isFinite(Date.parse(value))) throw new Error(`${key} must be an ISO timestamp`);
800
+ return value;
801
+ }
802
+
803
+ function requireNonEmpty(value: string, name: string): string {
804
+ if (value.length === 0) throw new TypeError(`${name} is required`);
805
+ return value;
806
+ }
807
+
808
+ function requireAbsolute(value: string, name: string): string {
809
+ if (!isAbsolute(value)) throw new TypeError(`${name} must be absolute`);
810
+ return normalize(value);
811
+ }
812
+
813
+ function safePathPart(value: string): string {
814
+ return value.replace(/[^A-Za-z0-9._-]/g, "_");
815
+ }
816
+
817
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
818
+ return error instanceof Error && "code" in error;
819
+ }
820
+
821
+ function toError(error: unknown): Error {
822
+ return error instanceof Error ? error : new Error(String(error));
823
+ }
824
+
825
+ /** Production process proof for Darwin and Linux. Unsupported platforms fail closed. */
826
+ export class LocalProcessIdentityInspector implements ProcessIdentityInspector {
827
+ async current(): Promise<ProcessIdentity> {
828
+ const inspected = await this.inspect(process.pid);
829
+ if (inspected.kind !== "live") {
830
+ throw new Error(inspected.kind === "unknown"
831
+ ? inspected.diagnostic
832
+ : "Current process identity is unexpectedly absent");
833
+ }
834
+ return inspected.identity;
835
+ }
836
+
837
+ async inspect(pid: number): Promise<ProcessInspection> {
838
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
839
+ return { kind: "unknown", diagnostic: "PID must be a positive safe integer" };
840
+ }
841
+ if (process.platform === "linux") return inspectLinuxProcess(pid);
842
+ if (process.platform === "darwin") return inspectDarwinProcess(pid);
843
+ return {
844
+ kind: "unknown",
845
+ diagnostic: `Process identity inspection is unsupported on ${process.platform}`,
846
+ };
847
+ }
848
+ }
849
+
850
+ async function inspectLinuxProcess(pid: number): Promise<ProcessInspection> {
851
+ try {
852
+ const stat = await readFile(`/proc/${pid}/stat`, "utf8");
853
+ const close = stat.lastIndexOf(")");
854
+ if (close < 0) return { kind: "unknown", diagnostic: `Malformed /proc/${pid}/stat` };
855
+ const fields = stat.slice(close + 2).trim().split(/\s+/);
856
+ const startIdentity = fields[19];
857
+ if (!startIdentity) return { kind: "unknown", diagnostic: `Missing start time in /proc/${pid}/stat` };
858
+ const executable = await readlink(`/proc/${pid}/exe`);
859
+ let bootIdentity: string | null = null;
860
+ try {
861
+ bootIdentity = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim() || null;
862
+ } catch {
863
+ bootIdentity = null;
864
+ }
865
+ return {
866
+ kind: "live",
867
+ identity: {
868
+ pid,
869
+ processStartIdentity: startIdentity,
870
+ bootIdentity,
871
+ executable: normalize(executable),
872
+ hostname: localHostname(),
873
+ },
874
+ };
875
+ } catch (error) {
876
+ if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ESRCH")) {
877
+ return { kind: "absent" };
878
+ }
879
+ return { kind: "unknown", diagnostic: `Linux process inspection failed: ${toError(error).message}` };
880
+ }
881
+ }
882
+
883
+ async function inspectDarwinProcess(pid: number): Promise<ProcessInspection> {
884
+ const processResult = await runBounded("/bin/ps", ["-p", String(pid), "-o", "lstart="]);
885
+ if (processResult.exitCode === 1 && processResult.stdout.trim().length === 0) return { kind: "absent" };
886
+ if (processResult.exitCode !== 0) {
887
+ return { kind: "unknown", diagnostic: `Darwin ps failed: ${processResult.stderr.trim()}` };
888
+ }
889
+ const startIdentity = processResult.stdout.trim();
890
+ if (startIdentity.length === 0) {
891
+ return { kind: "unknown", diagnostic: "Darwin ps returned an empty start identity" };
892
+ }
893
+ const files = await runBounded("/usr/sbin/lsof", ["-a", "-p", String(pid), "-d", "txt", "-Fn"]);
894
+ if (files.exitCode !== 0) {
895
+ return { kind: "unknown", diagnostic: `Darwin executable inspection failed: ${files.stderr.trim()}` };
896
+ }
897
+ const executable = files.stdout
898
+ .split("\n")
899
+ .find((line) => line.startsWith("n/") && line !== "n/usr/lib/dyld")
900
+ ?.slice(1);
901
+ if (!executable || !isAbsolute(executable)) {
902
+ return { kind: "unknown", diagnostic: "Darwin lsof did not return an absolute executable path" };
903
+ }
904
+ const bootResult = await runBounded("/usr/sbin/sysctl", ["-n", "kern.boottime"]);
905
+ const bootIdentity = bootResult.exitCode === 0 ? bootResult.stdout.trim() || null : null;
906
+ return {
907
+ kind: "live",
908
+ identity: {
909
+ pid,
910
+ processStartIdentity: startIdentity,
911
+ bootIdentity,
912
+ executable: normalize(executable),
913
+ hostname: localHostname(),
914
+ },
915
+ };
916
+ }
917
+
918
+ async function runBounded(
919
+ binary: string,
920
+ argv: readonly string[],
921
+ ): Promise<{ readonly exitCode: number | null; readonly stdout: string; readonly stderr: string }> {
922
+ return new Promise((resolve) => {
923
+ const child = spawn(binary, argv, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
924
+ const stdout: Buffer[] = [];
925
+ const stderr: Buffer[] = [];
926
+ let stdoutBytes = 0;
927
+ let stderrBytes = 0;
928
+ child.stdout.on("data", (chunk: Buffer) => {
929
+ stdoutBytes += chunk.length;
930
+ if (stdoutBytes <= 64 * 1024) stdout.push(Buffer.from(chunk));
931
+ else child.kill("SIGKILL");
932
+ });
933
+ child.stderr.on("data", (chunk: Buffer) => {
934
+ stderrBytes += chunk.length;
935
+ if (stderrBytes <= 64 * 1024) stderr.push(Buffer.from(chunk));
936
+ else child.kill("SIGKILL");
937
+ });
938
+ child.once("error", (error) => resolve({ exitCode: null, stdout: "", stderr: error.message }));
939
+ child.once("close", (exitCode) => resolve({
940
+ exitCode,
941
+ stdout: Buffer.concat(stdout).toString("utf8"),
942
+ stderr: Buffer.concat(stderr).toString("utf8"),
943
+ }));
944
+ });
945
+ }