harnery 0.22.1 → 0.24.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 (103) hide show
  1. package/README.md +1 -0
  2. package/dist/commands/workflow.d.ts.map +1 -1
  3. package/dist/commands/workflow.js +302 -5
  4. package/dist/core/work/state.d.ts +7 -0
  5. package/dist/core/work/state.d.ts.map +1 -1
  6. package/dist/core/work/state.js +112 -23
  7. package/dist/core/workflow/child-env.d.ts +10 -6
  8. package/dist/core/workflow/child-env.d.ts.map +1 -1
  9. package/dist/core/workflow/child-env.js +15 -7
  10. package/dist/core/workflow/durable-record.d.ts +7 -0
  11. package/dist/core/workflow/durable-record.d.ts.map +1 -0
  12. package/dist/core/workflow/durable-record.js +83 -0
  13. package/dist/core/workflow/engine.d.ts.map +1 -1
  14. package/dist/core/workflow/engine.js +208 -29
  15. package/dist/core/workflow/index.d.ts +2 -0
  16. package/dist/core/workflow/index.d.ts.map +1 -1
  17. package/dist/core/workflow/index.js +1 -0
  18. package/dist/core/workflow/proof.d.ts +4 -0
  19. package/dist/core/workflow/proof.d.ts.map +1 -1
  20. package/dist/core/workflow/proof.js +50 -15
  21. package/dist/core/workflow/run-state.d.ts +3 -0
  22. package/dist/core/workflow/run-state.d.ts.map +1 -1
  23. package/dist/core/workflow/run-state.js +25 -13
  24. package/dist/core/workflow/spawn-cursor.d.ts +3 -0
  25. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  26. package/dist/core/workflow/spawn-cursor.js +8 -4
  27. package/dist/core/workflow/types.d.ts +10 -0
  28. package/dist/core/workflow/types.d.ts.map +1 -1
  29. package/dist/core/workflow/workspaces/attestation-error.d.ts +7 -0
  30. package/dist/core/workflow/workspaces/attestation-error.d.ts.map +1 -0
  31. package/dist/core/workflow/workspaces/attestation-error.js +25 -0
  32. package/dist/core/workflow/workspaces/cancellation.d.ts +8 -0
  33. package/dist/core/workflow/workspaces/cancellation.d.ts.map +1 -0
  34. package/dist/core/workflow/workspaces/cancellation.js +95 -0
  35. package/dist/core/workflow/workspaces/cleanup.d.ts +9 -0
  36. package/dist/core/workflow/workspaces/cleanup.d.ts.map +1 -0
  37. package/dist/core/workflow/workspaces/cleanup.js +223 -0
  38. package/dist/core/workflow/workspaces/execution.d.ts +25 -0
  39. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -0
  40. package/dist/core/workflow/workspaces/execution.js +316 -0
  41. package/dist/core/workflow/workspaces/git.d.ts +25 -0
  42. package/dist/core/workflow/workspaces/git.d.ts.map +1 -0
  43. package/dist/core/workflow/workspaces/git.js +102 -0
  44. package/dist/core/workflow/workspaces/index.d.ts +15 -0
  45. package/dist/core/workflow/workspaces/index.d.ts.map +1 -0
  46. package/dist/core/workflow/workspaces/index.js +7 -0
  47. package/dist/core/workflow/workspaces/inspect.d.ts +85 -0
  48. package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -0
  49. package/dist/core/workflow/workspaces/inspect.js +255 -0
  50. package/dist/core/workflow/workspaces/integration.d.ts +32 -0
  51. package/dist/core/workflow/workspaces/integration.d.ts.map +1 -0
  52. package/dist/core/workflow/workspaces/integration.js +631 -0
  53. package/dist/core/workflow/workspaces/leases.d.ts +37 -0
  54. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -0
  55. package/dist/core/workflow/workspaces/leases.js +397 -0
  56. package/dist/core/workflow/workspaces/lifecycle.d.ts +43 -0
  57. package/dist/core/workflow/workspaces/lifecycle.d.ts.map +1 -0
  58. package/dist/core/workflow/workspaces/lifecycle.js +622 -0
  59. package/dist/core/workflow/workspaces/local-git.d.ts +8 -0
  60. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -0
  61. package/dist/core/workflow/workspaces/local-git.js +1474 -0
  62. package/dist/core/workflow/workspaces/paths.d.ts +23 -0
  63. package/dist/core/workflow/workspaces/paths.d.ts.map +1 -0
  64. package/dist/core/workflow/workspaces/paths.js +191 -0
  65. package/dist/core/workflow/workspaces/recovery.d.ts +3 -0
  66. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -0
  67. package/dist/core/workflow/workspaces/recovery.js +50 -0
  68. package/dist/core/workflow/workspaces/state.d.ts +60 -0
  69. package/dist/core/workflow/workspaces/state.d.ts.map +1 -0
  70. package/dist/core/workflow/workspaces/state.js +296 -0
  71. package/dist/core/workflow/workspaces/types.d.ts +445 -0
  72. package/dist/core/workflow/workspaces/types.d.ts.map +1 -0
  73. package/dist/core/workflow/workspaces/types.js +2 -0
  74. package/dist/core/workflow/workspaces/validate.d.ts +14 -0
  75. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -0
  76. package/dist/core/workflow/workspaces/validate.js +476 -0
  77. package/package.json +5 -3
  78. package/src/commands/workflow.ts +366 -8
  79. package/src/core/work/state.ts +156 -28
  80. package/src/core/workflow/child-env.ts +18 -7
  81. package/src/core/workflow/durable-record.ts +94 -0
  82. package/src/core/workflow/engine.ts +230 -30
  83. package/src/core/workflow/index.ts +74 -0
  84. package/src/core/workflow/proof.ts +70 -22
  85. package/src/core/workflow/run-state.ts +46 -11
  86. package/src/core/workflow/spawn-cursor.ts +8 -4
  87. package/src/core/workflow/types.ts +14 -0
  88. package/src/core/workflow/workspaces/attestation-error.ts +30 -0
  89. package/src/core/workflow/workspaces/cancellation.ts +137 -0
  90. package/src/core/workflow/workspaces/cleanup.ts +308 -0
  91. package/src/core/workflow/workspaces/execution.ts +412 -0
  92. package/src/core/workflow/workspaces/git.ts +125 -0
  93. package/src/core/workflow/workspaces/index.ts +89 -0
  94. package/src/core/workflow/workspaces/inspect.ts +421 -0
  95. package/src/core/workflow/workspaces/integration.ts +845 -0
  96. package/src/core/workflow/workspaces/leases.ts +517 -0
  97. package/src/core/workflow/workspaces/lifecycle.ts +792 -0
  98. package/src/core/workflow/workspaces/local-git.ts +1892 -0
  99. package/src/core/workflow/workspaces/paths.ts +247 -0
  100. package/src/core/workflow/workspaces/recovery.ts +56 -0
  101. package/src/core/workflow/workspaces/state.ts +481 -0
  102. package/src/core/workflow/workspaces/types.ts +517 -0
  103. package/src/core/workflow/workspaces/validate.ts +595 -0
@@ -0,0 +1,517 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ linkSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ rmdirSync,
13
+ statSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { hostname } from "node:os";
18
+ import { basename, dirname, join, resolve } from "node:path";
19
+
20
+ const MAX_LEASE_BYTES = 8 * 1024;
21
+ const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
22
+
23
+ export interface NoClobberLeaseOwner {
24
+ schema_version: 1;
25
+ owner_id: string;
26
+ owner_file: string;
27
+ scope: string;
28
+ authority_sha256: string;
29
+ metadata?: Record<string, string>;
30
+ pid: number;
31
+ host: string;
32
+ created_at: string;
33
+ expires_at: string;
34
+ }
35
+
36
+ export type LeaseRecoveryStep =
37
+ | "claim_created"
38
+ | "stale_pinned"
39
+ | "current_removed"
40
+ | "new_current_linked";
41
+
42
+ export interface AcquireNoClobberLeaseInput {
43
+ path: string;
44
+ scope: string;
45
+ authoritySha256: string;
46
+ metadata?: Readonly<Record<string, string>>;
47
+ staleAfterMs: number;
48
+ validateStaleOwner?: (owner: NoClobberLeaseOwner) => boolean;
49
+ now?: () => number;
50
+ pid?: number;
51
+ host?: string;
52
+ onRecoveryStep?: (step: LeaseRecoveryStep) => void;
53
+ }
54
+
55
+ export interface NoClobberLease {
56
+ owner: NoClobberLeaseOwner;
57
+ recovered_owner?: NoClobberLeaseOwner;
58
+ release(): void;
59
+ }
60
+
61
+ interface RecoveryClaim {
62
+ schema_version: 1;
63
+ claimant: NoClobberLeaseOwner;
64
+ observed_owner: NoClobberLeaseOwner;
65
+ observed_sha256: string;
66
+ created_at: string;
67
+ }
68
+
69
+ /**
70
+ * Acquires a crash-safe lease whose active name is always a hard link to one
71
+ * immutable owner file. Stale takeover is serialized by an atomic recovery
72
+ * directory; only its claimant may pin and replace the observed `current`.
73
+ */
74
+ export function acquireNoClobberLease(input: AcquireNoClobberLeaseInput): NoClobberLease {
75
+ validateInput(input);
76
+ const leaseDir = resolve(input.path);
77
+ ensureLeaseDirectory(leaseDir);
78
+
79
+ const now = input.now ?? Date.now;
80
+ const created = now();
81
+ const ownerId = randomUUID();
82
+ const ownerFile = `owner-${ownerId}.json`;
83
+ const owner: NoClobberLeaseOwner = {
84
+ schema_version: 1,
85
+ owner_id: ownerId,
86
+ owner_file: ownerFile,
87
+ scope: input.scope,
88
+ authority_sha256: input.authoritySha256,
89
+ metadata: input.metadata ? { ...input.metadata } : undefined,
90
+ pid: input.pid ?? process.pid,
91
+ host: input.host ?? hostname(),
92
+ created_at: new Date(created).toISOString(),
93
+ expires_at: new Date(created + input.staleAfterMs).toISOString(),
94
+ };
95
+ const ownerPath = join(leaseDir, ownerFile);
96
+ const ownerBytes = `${JSON.stringify(owner)}\n`;
97
+ writeExclusive(ownerPath, ownerBytes);
98
+ fsyncDirectory(leaseDir);
99
+
100
+ const currentPath = join(leaseDir, "current");
101
+ const recoveryPath = join(leaseDir, "recovery");
102
+ let recoveryClaimed = false;
103
+ try {
104
+ if (existsSync(recoveryPath)) {
105
+ reclaimAbandonedRecovery(leaseDir, recoveryPath, input, now());
106
+ }
107
+
108
+ try {
109
+ linkSync(ownerPath, currentPath);
110
+ fsyncDirectory(leaseDir);
111
+ assertExactOwner(currentPath, ownerPath, ownerBytes, owner);
112
+ if (existsSync(recoveryPath)) {
113
+ unlinkExactCurrent(currentPath, ownerPath);
114
+ throw new Error(`lease ${input.scope} recovery is already in progress`);
115
+ }
116
+ return leaseHandle(leaseDir, ownerPath, ownerBytes, owner);
117
+ } catch (error) {
118
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
119
+ }
120
+
121
+ const observed = readOwner(currentPath);
122
+ if (leaseIsLive(observed.owner, now(), input.staleAfterMs)) {
123
+ throw new Error(`lease ${input.scope} is held by a live or unexpired owner`);
124
+ }
125
+ if (
126
+ observed.owner.scope !== input.scope ||
127
+ (input.validateStaleOwner !== undefined && !input.validateStaleOwner(observed.owner))
128
+ ) {
129
+ throw new Error(`stale lease ${input.scope} has mismatched authority`);
130
+ }
131
+
132
+ createRecoveryClaim(leaseDir, recoveryPath, owner, observed);
133
+ recoveryClaimed = true;
134
+ input.onRecoveryStep?.("claim_created");
135
+
136
+ const pinPath = join(recoveryPath, "stale-pin");
137
+ try {
138
+ linkSync(currentPath, pinPath);
139
+ assertSameInodeAndBytes(currentPath, pinPath, observed.bytes);
140
+ } catch {
141
+ abortUnchangedRecoveryClaim(leaseDir, recoveryPath, pinPath, owner);
142
+ recoveryClaimed = false;
143
+ throw new Error(`lease ${input.scope} recovery raced with another contender`);
144
+ }
145
+ fsyncDirectory(recoveryPath);
146
+ input.onRecoveryStep?.("stale_pinned");
147
+
148
+ assertSameInodeAndBytes(currentPath, pinPath, observed.bytes);
149
+ unlinkSync(currentPath);
150
+ fsyncDirectory(leaseDir);
151
+ input.onRecoveryStep?.("current_removed");
152
+
153
+ linkSync(ownerPath, currentPath);
154
+ assertExactOwner(currentPath, ownerPath, ownerBytes, owner);
155
+ fsyncDirectory(leaseDir);
156
+ input.onRecoveryStep?.("new_current_linked");
157
+
158
+ removePinnedOwner(leaseDir, pinPath, observed.owner);
159
+ unlinkSync(pinPath);
160
+ unlinkSync(join(recoveryPath, "claim.json"));
161
+ rmdirSync(recoveryPath);
162
+ fsyncDirectory(leaseDir);
163
+ return {
164
+ ...leaseHandle(leaseDir, ownerPath, ownerBytes, owner),
165
+ recovered_owner: observed.owner,
166
+ };
167
+ } catch (error) {
168
+ // Once the recovery directory exists, leave every boundary intact. A
169
+ // later owner can quarantine that exact abandoned claim and reconcile it.
170
+ if (!recoveryClaimed) safeUnlink(ownerPath);
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ function validateInput(input: AcquireNoClobberLeaseInput): void {
176
+ if (
177
+ !SAFE_COMPONENT.test(basename(resolve(input.path))) ||
178
+ !SAFE_COMPONENT.test(input.scope) ||
179
+ !/^[a-f0-9]{64}$/.test(input.authoritySha256) ||
180
+ !Number.isSafeInteger(input.staleAfterMs) ||
181
+ input.staleAfterMs < 1
182
+ ) {
183
+ throw new Error("no-clobber lease input is invalid");
184
+ }
185
+ }
186
+
187
+ function ensureLeaseDirectory(leaseDir: string): void {
188
+ mkdirSync(dirname(leaseDir), { recursive: true, mode: 0o700 });
189
+ if (existsSync(leaseDir) && !lstatSync(leaseDir).isDirectory()) {
190
+ throw new Error(`lease path is not a directory: ${leaseDir}`);
191
+ }
192
+ if (!existsSync(leaseDir)) {
193
+ mkdirSync(leaseDir, { mode: 0o700 });
194
+ fsyncDirectory(dirname(leaseDir));
195
+ }
196
+ }
197
+
198
+ function createRecoveryClaim(
199
+ leaseDir: string,
200
+ recoveryPath: string,
201
+ claimant: NoClobberLeaseOwner,
202
+ observed: { owner: NoClobberLeaseOwner; bytes: string },
203
+ ): void {
204
+ const temporary = join(leaseDir, `recovery-claim-${claimant.owner_id}`);
205
+ mkdirSync(temporary, { mode: 0o700 });
206
+ const claim: RecoveryClaim = {
207
+ schema_version: 1,
208
+ claimant,
209
+ observed_owner: observed.owner,
210
+ observed_sha256: digestBytes(observed.bytes),
211
+ created_at: claimant.created_at,
212
+ };
213
+ try {
214
+ writeExclusive(join(temporary, "claim.json"), `${JSON.stringify(claim)}\n`);
215
+ fsyncDirectory(temporary);
216
+ try {
217
+ renameSync(temporary, recoveryPath);
218
+ fsyncDirectory(leaseDir);
219
+ } catch (error) {
220
+ if (
221
+ (error as NodeJS.ErrnoException).code === "EEXIST" ||
222
+ (error as NodeJS.ErrnoException).code === "ENOTEMPTY"
223
+ ) {
224
+ unlinkSync(join(temporary, "claim.json"));
225
+ rmdirSync(temporary);
226
+ throw new Error(`lease ${claimant.scope} recovery is already in progress`);
227
+ }
228
+ throw error;
229
+ }
230
+ } catch (error) {
231
+ if (existsSync(join(temporary, "claim.json"))) unlinkSync(join(temporary, "claim.json"));
232
+ if (existsSync(temporary)) rmdirSync(temporary);
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ function reclaimAbandonedRecovery(
238
+ leaseDir: string,
239
+ recoveryPath: string,
240
+ input: AcquireNoClobberLeaseInput,
241
+ now: number,
242
+ ): void {
243
+ const claim = readRecoveryClaim(recoveryPath);
244
+ if (leaseIsLive(claim.claimant, now, input.staleAfterMs)) {
245
+ throw new Error(`lease ${input.scope} recovery is already in progress`);
246
+ }
247
+ if (
248
+ claim.claimant.scope !== input.scope ||
249
+ claim.observed_owner.scope !== input.scope ||
250
+ (input.validateStaleOwner !== undefined && !input.validateStaleOwner(claim.observed_owner))
251
+ ) {
252
+ throw new Error(`stale lease ${input.scope} recovery has mismatched authority`);
253
+ }
254
+ const recoveryPin = join(recoveryPath, "stale-pin");
255
+ if (existsSync(recoveryPin)) {
256
+ const pinned = readOwner(recoveryPin);
257
+ if (
258
+ pinned.owner.owner_id !== claim.observed_owner.owner_id ||
259
+ digestBytes(pinned.bytes) !== claim.observed_sha256
260
+ ) {
261
+ throw new Error(`stale lease ${input.scope} recovery pin is corrupt`);
262
+ }
263
+ }
264
+
265
+ const quarantine = join(leaseDir, `quarantine-${randomUUID()}`);
266
+ try {
267
+ renameSync(recoveryPath, quarantine);
268
+ } catch (error) {
269
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
270
+ throw new Error(`lease ${input.scope} recovery raced with another contender`);
271
+ }
272
+ throw error;
273
+ }
274
+ fsyncDirectory(leaseDir);
275
+
276
+ const pinPath = join(quarantine, "stale-pin");
277
+ if (existsSync(pinPath)) {
278
+ const pinned = readOwner(pinPath);
279
+ if (
280
+ pinned.owner.owner_id !== claim.observed_owner.owner_id ||
281
+ digestBytes(pinned.bytes) !== claim.observed_sha256
282
+ ) {
283
+ throw new Error(`stale lease ${input.scope} recovery pin is corrupt`);
284
+ }
285
+ removePinnedOwner(leaseDir, pinPath, pinned.owner);
286
+ unlinkSync(pinPath);
287
+ }
288
+ const claimantPath = join(leaseDir, claim.claimant.owner_file);
289
+ const currentPath = join(leaseDir, "current");
290
+ if (
291
+ existsSync(claimantPath) &&
292
+ (!existsSync(currentPath) || !sameInode(claimantPath, currentPath))
293
+ ) {
294
+ unlinkSync(claimantPath);
295
+ }
296
+ unlinkSync(join(quarantine, "claim.json"));
297
+ rmdirSync(quarantine);
298
+ fsyncDirectory(leaseDir);
299
+ }
300
+
301
+ function abortUnchangedRecoveryClaim(
302
+ leaseDir: string,
303
+ recoveryPath: string,
304
+ pinPath: string,
305
+ claimant: NoClobberLeaseOwner,
306
+ ): void {
307
+ const claim = readRecoveryClaim(recoveryPath);
308
+ if (claim.claimant.owner_id !== claimant.owner_id) {
309
+ throw new Error(`lease ${claimant.scope} recovery ownership changed`);
310
+ }
311
+ safeUnlink(pinPath);
312
+ unlinkSync(join(recoveryPath, "claim.json"));
313
+ rmdirSync(recoveryPath);
314
+ fsyncDirectory(leaseDir);
315
+ }
316
+
317
+ function readRecoveryClaim(recoveryPath: string): RecoveryClaim {
318
+ const path = join(recoveryPath, "claim.json");
319
+ const size = lstatSync(path).size;
320
+ if (size <= 0 || size > MAX_LEASE_BYTES * 2) {
321
+ throw new Error("lease recovery claim bytes are invalid");
322
+ }
323
+ let claim: RecoveryClaim;
324
+ try {
325
+ claim = JSON.parse(readFileSync(path, "utf8")) as RecoveryClaim;
326
+ } catch (error) {
327
+ throw new Error(`lease recovery claim is corrupt: ${(error as Error).message}`);
328
+ }
329
+ if (
330
+ claim.schema_version !== 1 ||
331
+ !validOwner(claim.claimant) ||
332
+ !validOwner(claim.observed_owner) ||
333
+ !/^[a-f0-9]{64}$/.test(claim.observed_sha256) ||
334
+ typeof claim.created_at !== "string" ||
335
+ claim.created_at !== claim.claimant.created_at
336
+ ) {
337
+ throw new Error("lease recovery claim has an unsupported schema");
338
+ }
339
+ return claim;
340
+ }
341
+
342
+ function leaseHandle(
343
+ leaseDir: string,
344
+ ownerPath: string,
345
+ ownerBytes: string,
346
+ owner: NoClobberLeaseOwner,
347
+ ): NoClobberLease {
348
+ let released = false;
349
+ return {
350
+ owner,
351
+ release() {
352
+ if (released) return;
353
+ const currentPath = join(leaseDir, "current");
354
+ assertExactOwner(currentPath, ownerPath, ownerBytes, owner);
355
+ unlinkSync(currentPath);
356
+ unlinkSync(ownerPath);
357
+ fsyncDirectory(leaseDir);
358
+ released = true;
359
+ },
360
+ };
361
+ }
362
+
363
+ function writeExclusive(path: string, bytes: string): void {
364
+ const fd = openSync(path, "wx", 0o600);
365
+ try {
366
+ writeFileSync(fd, bytes, "utf8");
367
+ fsyncSync(fd);
368
+ } finally {
369
+ closeSync(fd);
370
+ }
371
+ }
372
+
373
+ function readOwner(path: string): { owner: NoClobberLeaseOwner; bytes: string } {
374
+ let size: number;
375
+ try {
376
+ const stat = lstatSync(path);
377
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("lease owner is not a plain file");
378
+ size = stat.size;
379
+ } catch (error) {
380
+ throw new Error(`lease owner cannot be inspected: ${(error as Error).message}`);
381
+ }
382
+ if (size <= 0 || size > MAX_LEASE_BYTES) throw new Error("lease owner bytes are invalid");
383
+ const bytes = readFileSync(path, "utf8");
384
+ let value: unknown;
385
+ try {
386
+ value = JSON.parse(bytes);
387
+ } catch (error) {
388
+ throw new Error(`lease owner is corrupt: ${(error as Error).message}`);
389
+ }
390
+ if (!validOwner(value)) throw new Error("lease owner has an unsupported schema");
391
+ return { owner: value, bytes };
392
+ }
393
+
394
+ function validOwner(value: unknown): value is NoClobberLeaseOwner {
395
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
396
+ const owner = value as Record<string, unknown>;
397
+ return (
398
+ owner.schema_version === 1 &&
399
+ typeof owner.owner_id === "string" &&
400
+ typeof owner.owner_file === "string" &&
401
+ owner.owner_file === `owner-${owner.owner_id}.json` &&
402
+ SAFE_COMPONENT.test(owner.owner_file) &&
403
+ typeof owner.scope === "string" &&
404
+ SAFE_COMPONENT.test(owner.scope) &&
405
+ typeof owner.authority_sha256 === "string" &&
406
+ /^[a-f0-9]{64}$/.test(owner.authority_sha256) &&
407
+ (owner.metadata === undefined ||
408
+ (isStringRecord(owner.metadata) && Object.keys(owner.metadata).length <= 20)) &&
409
+ Number.isSafeInteger(owner.pid) &&
410
+ (owner.pid as number) > 0 &&
411
+ typeof owner.host === "string" &&
412
+ owner.host.length > 0 &&
413
+ typeof owner.created_at === "string" &&
414
+ typeof owner.expires_at === "string" &&
415
+ Number.isFinite(Date.parse(owner.created_at)) &&
416
+ Number.isFinite(Date.parse(owner.expires_at))
417
+ );
418
+ }
419
+
420
+ function isStringRecord(value: unknown): value is Record<string, string> {
421
+ return (
422
+ typeof value === "object" &&
423
+ value !== null &&
424
+ !Array.isArray(value) &&
425
+ Object.entries(value).every(
426
+ ([key, item]) => SAFE_COMPONENT.test(key) && typeof item === "string" && item.length <= 1_000,
427
+ )
428
+ );
429
+ }
430
+
431
+ function leaseIsLive(owner: NoClobberLeaseOwner, now: number, staleAfterMs: number): boolean {
432
+ const created = Date.parse(owner.created_at);
433
+ const expires = Date.parse(owner.expires_at);
434
+ if (
435
+ !Number.isFinite(created) ||
436
+ !Number.isFinite(expires) ||
437
+ expires !== created + staleAfterMs ||
438
+ now < created ||
439
+ now < expires
440
+ ) {
441
+ return true;
442
+ }
443
+ if (owner.host !== hostname()) return false;
444
+ try {
445
+ process.kill(owner.pid, 0);
446
+ return true;
447
+ } catch (error) {
448
+ return (error as NodeJS.ErrnoException).code === "EPERM";
449
+ }
450
+ }
451
+
452
+ function assertExactOwner(
453
+ currentPath: string,
454
+ ownerPath: string,
455
+ expectedBytes: string,
456
+ owner: NoClobberLeaseOwner,
457
+ ): void {
458
+ const current = readOwner(currentPath);
459
+ if (
460
+ current.bytes !== expectedBytes ||
461
+ current.owner.owner_id !== owner.owner_id ||
462
+ !sameInode(currentPath, ownerPath)
463
+ ) {
464
+ throw new Error(`lease ${owner.scope} ownership changed`);
465
+ }
466
+ }
467
+
468
+ function unlinkExactCurrent(currentPath: string, ownerPath: string): void {
469
+ if (existsSync(currentPath) && sameInode(currentPath, ownerPath)) unlinkSync(currentPath);
470
+ }
471
+
472
+ function removePinnedOwner(leaseDir: string, pinPath: string, owner: NoClobberLeaseOwner): void {
473
+ const staleOwnerPath = join(leaseDir, owner.owner_file);
474
+ if (
475
+ basename(staleOwnerPath) === owner.owner_file &&
476
+ existsSync(staleOwnerPath) &&
477
+ sameInode(staleOwnerPath, pinPath)
478
+ ) {
479
+ unlinkSync(staleOwnerPath);
480
+ }
481
+ }
482
+
483
+ function assertSameInodeAndBytes(left: string, right: string, expectedBytes: string): void {
484
+ if (!sameInode(left, right)) throw new Error("lease recovery owner inode changed");
485
+ const leftBytes = readFileSync(left, "utf8");
486
+ const rightBytes = readFileSync(right, "utf8");
487
+ if (leftBytes !== expectedBytes || rightBytes !== expectedBytes) {
488
+ throw new Error("lease recovery owner bytes changed");
489
+ }
490
+ }
491
+
492
+ function sameInode(left: string, right: string): boolean {
493
+ const a = statSync(left, { bigint: true });
494
+ const b = statSync(right, { bigint: true });
495
+ return a.dev === b.dev && a.ino === b.ino;
496
+ }
497
+
498
+ function digestBytes(bytes: string): string {
499
+ return createHash("sha256").update(bytes).digest("hex");
500
+ }
501
+
502
+ function fsyncDirectory(path: string): void {
503
+ const fd = openSync(path, "r");
504
+ try {
505
+ fsyncSync(fd);
506
+ } finally {
507
+ closeSync(fd);
508
+ }
509
+ }
510
+
511
+ function safeUnlink(path: string): void {
512
+ try {
513
+ unlinkSync(path);
514
+ } catch (error) {
515
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
516
+ }
517
+ }