harnery 0.45.1 → 0.45.2

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 (119) hide show
  1. package/dist/commands/workflow.d.ts.map +1 -1
  2. package/dist/commands/workflow.js +6 -45
  3. package/dist/core/agents/coord-client.d.ts.map +1 -1
  4. package/dist/core/agents/coord-client.js +0 -18
  5. package/dist/core/agents/state/pidmap.js +2 -2
  6. package/dist/core/agents/state/proc-start.d.ts +20 -31
  7. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  8. package/dist/core/agents/state/proc-start.js +37 -70
  9. package/dist/core/agents/state/service-process.d.ts +61 -0
  10. package/dist/core/agents/state/service-process.d.ts.map +1 -0
  11. package/dist/core/agents/state/service-process.js +143 -0
  12. package/dist/core/context/index.d.ts +6 -4
  13. package/dist/core/context/index.d.ts.map +1 -1
  14. package/dist/core/context/index.js +194 -256
  15. package/dist/core/context/observation.d.ts +9 -9
  16. package/dist/core/context/observation.d.ts.map +1 -1
  17. package/dist/core/context/observation.js +37 -6
  18. package/dist/core/events/v3/runtime-build.d.ts +14 -0
  19. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  20. package/dist/core/events/v3/runtime-build.js +44 -0
  21. package/dist/core/governor/index.d.ts +2 -1
  22. package/dist/core/governor/index.d.ts.map +1 -1
  23. package/dist/core/governor/index.js +2 -1
  24. package/dist/core/governor/service-read.d.ts +3 -10
  25. package/dist/core/governor/service-read.d.ts.map +1 -1
  26. package/dist/core/governor/service-read.js +10 -94
  27. package/dist/core/governor/service.d.ts +3 -7
  28. package/dist/core/governor/service.d.ts.map +1 -1
  29. package/dist/core/governor/service.js +17 -182
  30. package/dist/core/hooks/resolve/owner.js +2 -2
  31. package/dist/core/resources/sampler.js +2 -2
  32. package/dist/core/resources/service-status.d.ts +2 -14
  33. package/dist/core/resources/service-status.d.ts.map +1 -1
  34. package/dist/core/resources/service-status.js +3 -23
  35. package/dist/core/resources/service.d.ts +3 -7
  36. package/dist/core/resources/service.d.ts.map +1 -1
  37. package/dist/core/resources/service.js +12 -101
  38. package/dist/core/resources/status.d.ts.map +1 -1
  39. package/dist/core/resources/status.js +3 -2
  40. package/dist/core/semantic/service.d.ts.map +1 -1
  41. package/dist/core/semantic/service.js +9 -2
  42. package/dist/core/servers/index.d.ts +0 -2
  43. package/dist/core/servers/index.d.ts.map +1 -1
  44. package/dist/core/servers/index.js +3 -18
  45. package/dist/core/supervisor/service.d.ts +4 -7
  46. package/dist/core/supervisor/service.d.ts.map +1 -1
  47. package/dist/core/supervisor/service.js +23 -122
  48. package/dist/core/supervisor/services.js +2 -2
  49. package/dist/core/supervisor/status.d.ts +3 -17
  50. package/dist/core/supervisor/status.d.ts.map +1 -1
  51. package/dist/core/supervisor/status.js +7 -70
  52. package/dist/core/workflow/engine.js +2 -1
  53. package/dist/core/workflow/index.d.ts +3 -4
  54. package/dist/core/workflow/index.d.ts.map +1 -1
  55. package/dist/core/workflow/index.js +2 -2
  56. package/dist/core/workflow/proof.d.ts.map +1 -1
  57. package/dist/core/workflow/proof.js +30 -74
  58. package/dist/core/workflow/resume-lock.d.ts +17 -0
  59. package/dist/core/workflow/resume-lock.d.ts.map +1 -0
  60. package/dist/core/workflow/resume-lock.js +158 -0
  61. package/dist/core/workflow/run-state.d.ts +0 -21
  62. package/dist/core/workflow/run-state.d.ts.map +1 -1
  63. package/dist/core/workflow/run-state.js +3 -174
  64. package/dist/core/workflow/workspaces/leases.d.ts +4 -0
  65. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  66. package/dist/core/workflow/workspaces/leases.js +1 -1
  67. package/dist/core/workflow/workspaces/local-git.js +91 -210
  68. package/dist/core/workflow/workspaces/ownership.d.ts +0 -7
  69. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -1
  70. package/dist/core/workflow/workspaces/ownership.js +3 -130
  71. package/dist/core/workflow/workspaces/reclaim.d.ts +4 -7
  72. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  73. package/dist/core/workflow/workspaces/reclaim.js +0 -4
  74. package/dist/core/workflow/workspaces/recovery.d.ts +1 -1
  75. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  76. package/dist/core/workflow/workspaces/recovery.js +1 -1
  77. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  78. package/dist/core/workflow/workspaces/state.js +1 -4
  79. package/package.json +1 -1
  80. package/src/commands/workflow.ts +8 -58
  81. package/src/core/agents/coord-client.ts +0 -20
  82. package/src/core/agents/state/pidmap.ts +2 -2
  83. package/src/core/agents/state/proc-start.ts +41 -69
  84. package/src/core/agents/state/service-process.ts +217 -0
  85. package/src/core/context/index.ts +207 -272
  86. package/src/core/context/observation.ts +40 -9
  87. package/src/core/events/v3/runtime-build.ts +62 -0
  88. package/src/core/governor/index.ts +1 -2
  89. package/src/core/governor/service-read.ts +15 -114
  90. package/src/core/governor/service.ts +36 -220
  91. package/src/core/hooks/resolve/owner.ts +2 -2
  92. package/src/core/resources/sampler.ts +3 -2
  93. package/src/core/resources/service-status.ts +6 -35
  94. package/src/core/resources/service.ts +23 -133
  95. package/src/core/resources/status.ts +3 -2
  96. package/src/core/semantic/service.ts +8 -2
  97. package/src/core/servers/index.ts +3 -23
  98. package/src/core/supervisor/service.ts +33 -155
  99. package/src/core/supervisor/services.ts +2 -2
  100. package/src/core/supervisor/status.ts +10 -85
  101. package/src/core/workflow/engine.ts +1 -1
  102. package/src/core/workflow/index.ts +2 -13
  103. package/src/core/workflow/proof.ts +35 -83
  104. package/src/core/workflow/resume-lock.ts +179 -0
  105. package/src/core/workflow/run-state.ts +3 -207
  106. package/src/core/workflow/workspaces/leases.ts +1 -1
  107. package/src/core/workflow/workspaces/local-git.ts +120 -239
  108. package/src/core/workflow/workspaces/ownership.ts +3 -158
  109. package/src/core/workflow/workspaces/reclaim.ts +3 -9
  110. package/src/core/workflow/workspaces/recovery.ts +1 -1
  111. package/src/core/workflow/workspaces/state.ts +1 -5
  112. package/dist/core/agents/state/process-handle.d.ts +0 -26
  113. package/dist/core/agents/state/process-handle.d.ts.map +0 -1
  114. package/dist/core/agents/state/process-handle.js +0 -121
  115. package/dist/core/workflow/recovery-audit.d.ts +0 -30
  116. package/dist/core/workflow/recovery-audit.d.ts.map +0 -1
  117. package/dist/core/workflow/recovery-audit.js +0 -116
  118. package/src/core/agents/state/process-handle.ts +0 -165
  119. package/src/core/workflow/recovery-audit.ts +0 -181
@@ -0,0 +1,179 @@
1
+ // Kept apart from run-state.ts: the resume lock reads process identity, and
2
+ // run-state.ts is also reached from read-only dashboard modules.
3
+ import { randomUUID } from "node:crypto";
4
+ import {
5
+ chmodSync,
6
+ closeSync,
7
+ linkSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ openSync,
11
+ readFileSync,
12
+ renameSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { hostname } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { processStartToken, verifyProcessGeneration } from "../agents/state/proc-start.ts";
19
+ import { stateFileMode } from "../storage/modes.ts";
20
+ import { assertWorkflowRunId } from "./approvals.ts";
21
+
22
+ export type WorkflowResumeOwnerState = "dead" | "live" | "unknown";
23
+
24
+ export interface WorkflowResumeLockOwner {
25
+ pid: number;
26
+ host: string;
27
+ created_at: string;
28
+ start_token?: string | null;
29
+ }
30
+
31
+ /** Acquire the exclusive lease shared by resume and reclaim. A lock whose
32
+ * same-host owner has exited, or whose PID now names a different process, is
33
+ * retired automatically. Every other existing lock refuses. */
34
+ export function acquireWorkflowResumeLease(coordRoot: string, runId: string): () => void {
35
+ const path = workflowResumeLockPath(coordRoot, runId);
36
+ const owner = {
37
+ owner_id: randomUUID(),
38
+ pid: process.pid,
39
+ host: hostname(),
40
+ created_at: new Date().toISOString(),
41
+ start_token: processStartToken(process.pid),
42
+ };
43
+ const ownerBytes = `${JSON.stringify(owner)}\n`;
44
+ mkdirSync(dirname(path), { recursive: true });
45
+
46
+ const acquire = (): { device: number; inode: number } | false => {
47
+ try {
48
+ const fd = openSync(path, "wx", stateFileMode());
49
+ try {
50
+ writeFileSync(fd, ownerBytes, "utf8");
51
+ } finally {
52
+ closeSync(fd);
53
+ }
54
+ chmodSync(path, stateFileMode());
55
+ const identity = lstatSync(path);
56
+ return { device: identity.dev, inode: identity.ino };
57
+ } catch (error) {
58
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
59
+ return false;
60
+ }
61
+ };
62
+
63
+ const acquired = acquire() || (retireDeadResumeLock(path) && acquire());
64
+ if (!acquired) {
65
+ const existing = readWorkflowResumeLock(coordRoot, runId);
66
+ const holder = existing
67
+ ? ` by pid ${existing.pid} on ${existing.host} (owner ${workflowResumeOwnerState(existing)})`
68
+ : "; its owner could not be read";
69
+ throw new Error(`workflow run ${runId} resume lease is already held${holder}`);
70
+ }
71
+
72
+ return () => {
73
+ try {
74
+ // Pin identity and bytes, so a stale closure never removes a replacement.
75
+ const identity = lstatSync(path);
76
+ if (
77
+ identity.isFile() &&
78
+ identity.dev === acquired.device &&
79
+ identity.ino === acquired.inode &&
80
+ readFileSync(path, "utf8") === ownerBytes
81
+ ) {
82
+ unlinkSync(path);
83
+ }
84
+ } catch {
85
+ // Leave an unreadable or replaced lock untouched.
86
+ }
87
+ };
88
+ }
89
+
90
+ export function workflowResumeLockPath(coordRoot: string, runId: string): string {
91
+ assertWorkflowRunId(runId);
92
+ return join(coordRoot, ".harnery", "workflows", runId, "resume.lock");
93
+ }
94
+
95
+ /** Only a same-host owner can be proven gone: by ESRCH, or by a recorded start
96
+ * token that no longer matches its PID. Foreign and unreadable owners stay live. */
97
+ export function workflowResumeOwnerState(owner: WorkflowResumeLockOwner): WorkflowResumeOwnerState {
98
+ if (owner.host !== hostname()) return "unknown";
99
+ try {
100
+ process.kill(owner.pid, 0);
101
+ } catch (error) {
102
+ const code = (error as NodeJS.ErrnoException).code;
103
+ if (code === "ESRCH") return "dead";
104
+ if (code !== "EPERM") return "unknown";
105
+ }
106
+ return verifyProcessGeneration(owner.pid, owner.start_token) === "different" ? "dead" : "live";
107
+ }
108
+
109
+ /** Move a dead owner's exact lock aside before deleting it. If a contender
110
+ * replaced it in between, the moved bytes (a fresh owner_id) differ, and that
111
+ * live lock is linked back. */
112
+ function retireDeadResumeLock(path: string): boolean {
113
+ let bytes: string;
114
+ try {
115
+ bytes = readFileSync(path, "utf8");
116
+ } catch (error) {
117
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
118
+ }
119
+ const owner = parseResumeLock(bytes);
120
+ if (!owner || workflowResumeOwnerState(owner) !== "dead") return false;
121
+ const aside = `${path}.dead-${randomUUID()}`;
122
+ try {
123
+ renameSync(path, aside);
124
+ } catch (error) {
125
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return true;
126
+ throw error;
127
+ }
128
+ const exact = readFileSync(aside, "utf8") === bytes;
129
+ if (!exact) {
130
+ try {
131
+ linkSync(aside, path);
132
+ } catch {
133
+ // A third contender already holds the path; the moved owner's release
134
+ // finds a different inode and leaves that lock alone.
135
+ }
136
+ }
137
+ unlinkSync(aside);
138
+ return exact;
139
+ }
140
+
141
+ export function readWorkflowResumeLock(
142
+ coordRoot: string,
143
+ runId: string,
144
+ ): WorkflowResumeLockOwner | null {
145
+ return readResumeLease(workflowResumeLockPath(coordRoot, runId));
146
+ }
147
+
148
+ function readResumeLease(path: string): WorkflowResumeLockOwner | null {
149
+ try {
150
+ return parseResumeLock(readFileSync(path, "utf8"));
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function parseResumeLock(bytes: string): WorkflowResumeLockOwner | null {
157
+ try {
158
+ const value = JSON.parse(bytes) as Record<string, unknown>;
159
+ return typeof value.pid === "number" &&
160
+ Number.isSafeInteger(value.pid) &&
161
+ value.pid > 0 &&
162
+ typeof value.host === "string" &&
163
+ value.host.length > 0 &&
164
+ validTimestamp(value.created_at)
165
+ ? {
166
+ pid: value.pid,
167
+ host: value.host,
168
+ created_at: value.created_at,
169
+ start_token: typeof value.start_token === "string" ? value.start_token : null,
170
+ }
171
+ : null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ function validTimestamp(value: unknown): value is string {
178
+ return typeof value === "string" && value.length <= 40 && Number.isFinite(Date.parse(value));
179
+ }
@@ -1,18 +1,5 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import {
3
- chmodSync,
4
- closeSync,
5
- existsSync,
6
- fsyncSync,
7
- lstatSync,
8
- mkdirSync,
9
- openSync,
10
- readFileSync,
11
- statSync,
12
- unlinkSync,
13
- writeFileSync,
14
- } from "node:fs";
15
- import { hostname } from "node:os";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
16
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
17
4
  import type { RepoSnapshot } from "../context/index.ts";
18
5
  import {
@@ -22,16 +9,9 @@ import {
22
9
  type PolicyNetworkAccess,
23
10
  policyDigest,
24
11
  } from "../policy/index.ts";
25
- import { stateFileMode } from "../storage/modes.ts";
26
12
  import { assertWorkflowRunId, readWorkflowApproval } from "./approvals.ts";
27
13
  import { isCanonicalWorkflowAttemptContext } from "./attempt-context.ts";
28
- import {
29
- fsyncParentDirectory,
30
- readJsonRecord,
31
- stableDigest,
32
- writeImmutableJson,
33
- } from "./durable-record.ts";
34
- import { beginWorkflowRecovery, type WorkflowRecoveryAuthorization } from "./recovery-audit.ts";
14
+ import { readJsonRecord, writeImmutableJson } from "./durable-record.ts";
35
15
  import { normalizeWorkflowSpecialists } from "./specialists.ts";
36
16
  import type {
37
17
  WorkflowAttemptContext,
@@ -46,7 +26,6 @@ import type {
46
26
  WorkspaceBinding,
47
27
  WorkspaceCompatibilityExecutionEvidence,
48
28
  } from "./workspaces/index.ts";
49
- import { acquireNoClobberLease } from "./workspaces/leases.ts";
50
29
  import {
51
30
  assertWorkspaceManifestAuthority,
52
31
  isWorkspaceBinding,
@@ -212,189 +191,6 @@ export function workflowRunManifestPath(coordRoot: string, runId: string): strin
212
191
  return join(coordRoot, ".harnery", "workflows", runId, "run.json");
213
192
  }
214
193
 
215
- /** Acquire an exclusive lease shared by resume and reclaim. Existing locks
216
- * always refuse; explicit audited recovery is a separate operation. */
217
- export function acquireWorkflowResumeLease(coordRoot: string, runId: string): () => void {
218
- assertWorkflowRunId(runId);
219
- const path = join(coordRoot, ".harnery", "workflows", runId, "resume.lock");
220
- const owner = {
221
- owner_id: randomUUID(),
222
- pid: process.pid,
223
- host: hostname(),
224
- created_at: new Date().toISOString(),
225
- };
226
- const ownerBytes = `${JSON.stringify(owner)}\n`;
227
- mkdirSync(dirname(path), { recursive: true });
228
-
229
- const acquire = (): { device: number; inode: number } | false => {
230
- try {
231
- const fd = openSync(path, "wx", stateFileMode());
232
- try {
233
- writeFileSync(fd, ownerBytes, "utf8");
234
- fsyncSync(fd);
235
- } finally {
236
- closeSync(fd);
237
- }
238
- chmodSync(path, stateFileMode());
239
- fsyncParentDirectory(path);
240
- const identity = lstatSync(path);
241
- return { device: identity.dev, inode: identity.ino };
242
- } catch (error) {
243
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
244
- return false;
245
- }
246
- };
247
-
248
- const acquired = withResumeLeaseMutation(coordRoot, runId, acquire);
249
- if (!acquired) {
250
- const existing = readResumeLease(path);
251
- const holder = existing
252
- ? ` by pid ${existing.pid} on ${existing.host}; its liveness has not been verified`
253
- : "; its owner could not be verified";
254
- throw new Error(`workflow run ${runId} resume lease is already held${holder}`);
255
- }
256
-
257
- return () => {
258
- try {
259
- // Only this live process can release this nonce. Acquisition never
260
- // replaces a present lock, and unlock requires ESRCH for its owner, so
261
- // neither supported writer can replace this lock during synchronous
262
- // release. Taking their mutation gate would strand a live owner's lock
263
- // on ordinary contention. Pin both identity and bytes for stale closures.
264
- const identity = lstatSync(path);
265
- if (
266
- identity.isFile() &&
267
- identity.dev === acquired.device &&
268
- identity.ino === acquired.inode &&
269
- readFileSync(path, "utf8") === ownerBytes
270
- ) {
271
- unlinkSync(path);
272
- fsyncParentDirectory(path);
273
- }
274
- } catch {
275
- // Leave an unreadable or replaced lock untouched.
276
- }
277
- };
278
- }
279
-
280
- export interface WorkflowResumeRecoveryOptions extends Partial<WorkflowRecoveryAuthorization> {
281
- yes?: boolean;
282
- lockSha256?: string;
283
- }
284
-
285
- export interface WorkflowResumeRecoveryResult {
286
- action: "preview" | "unlocked";
287
- run_id: string;
288
- lock_sha256: string | null;
289
- owner_state: "dead" | "live" | "unknown" | "absent";
290
- owner: { pid: number; host: string; created_at: string } | null;
291
- recovery_id?: string;
292
- }
293
-
294
- export function recoverWorkflowResumeLease(
295
- coordRoot: string,
296
- runId: string,
297
- options: WorkflowResumeRecoveryOptions,
298
- ): WorkflowResumeRecoveryResult {
299
- assertWorkflowRunId(runId);
300
- const path = join(coordRoot, ".harnery", "workflows", runId, "resume.lock");
301
- const inspect = (): WorkflowResumeRecoveryResult => {
302
- let lockSha256: string;
303
- try {
304
- const stat = lstatSync(path);
305
- if (!stat.isFile() || stat.size > 8 * 1024)
306
- throw new Error("resume lock is not a bounded regular file");
307
- lockSha256 = stableDigest({
308
- bytes: readFileSync(path, "utf8"),
309
- device: stat.dev,
310
- inode: stat.ino,
311
- });
312
- } catch (error) {
313
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
314
- return {
315
- action: "preview",
316
- run_id: runId,
317
- lock_sha256: null,
318
- owner_state: "absent",
319
- owner: null,
320
- };
321
- }
322
- const owner = readResumeLease(path);
323
- let ownerState: WorkflowResumeRecoveryResult["owner_state"] = "unknown";
324
- if (owner?.host === hostname()) {
325
- try {
326
- process.kill(owner.pid, 0);
327
- ownerState = "live";
328
- } catch (error) {
329
- if ((error as NodeJS.ErrnoException).code === "ESRCH") ownerState = "dead";
330
- }
331
- }
332
- return {
333
- action: "preview",
334
- run_id: runId,
335
- lock_sha256: lockSha256,
336
- owner_state: ownerState,
337
- owner,
338
- };
339
- };
340
- if (!options.yes) return inspect();
341
- return withResumeLeaseMutation(coordRoot, runId, () => {
342
- const observed = inspect();
343
- if (observed.owner_state !== "dead")
344
- throw new Error(`workflow resume unlock refused: owner is ${observed.owner_state}`);
345
- if (!options.lockSha256 || options.lockSha256 !== observed.lock_sha256)
346
- throw new Error("workflow resume unlock requires the exact current preview digest");
347
- const audit = beginWorkflowRecovery(coordRoot, {
348
- run_id: runId,
349
- action: "unlock_resume",
350
- target_sha256: options.lockSha256,
351
- target: { lock: "resume.lock", owner: observed.owner },
352
- actor: options.actor!,
353
- reason: options.reason!,
354
- });
355
- const current = inspect();
356
- if (current.lock_sha256 !== observed.lock_sha256 || current.owner_state !== "dead")
357
- throw new Error("workflow resume lock authority changed before unlock");
358
- audit.assertCurrent();
359
- unlinkSync(path);
360
- fsyncParentDirectory(path);
361
- audit.complete();
362
- return { ...observed, action: "unlocked", recovery_id: audit.intent.operation_id };
363
- });
364
- }
365
-
366
- function withResumeLeaseMutation<T>(coordRoot: string, runId: string, action: () => T): T {
367
- const authority = stableDigest({ run_id: runId, lock: "resume.lock" });
368
- const lease = acquireNoClobberLease({
369
- path: join(coordRoot, ".harnery", "workflows", runId, "resume-mutation.lease"),
370
- scope: "workflow-resume-mutation",
371
- authoritySha256: authority,
372
- staleAfterMs: 300_000,
373
- validateStaleOwner: (owner) => owner.authority_sha256 === authority,
374
- });
375
- try {
376
- return action();
377
- } finally {
378
- lease.release();
379
- }
380
- }
381
-
382
- function readResumeLease(path: string): { pid: number; host: string; created_at: string } | null {
383
- try {
384
- const value = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
385
- return typeof value.pid === "number" &&
386
- Number.isSafeInteger(value.pid) &&
387
- value.pid > 0 &&
388
- typeof value.host === "string" &&
389
- value.host.length > 0 &&
390
- validTimestamp(value.created_at)
391
- ? { pid: value.pid, host: value.host, created_at: value.created_at }
392
- : null;
393
- } catch {
394
- return null;
395
- }
396
- }
397
-
398
194
  function validExecution(value: WorkflowRunManifest["execution"]): boolean {
399
195
  if (!value || typeof value !== "object") return false;
400
196
  return (
@@ -417,7 +417,7 @@ function writeExclusive(path: string, bytes: string): void {
417
417
  }
418
418
  }
419
419
 
420
- function readOwner(path: string): { owner: NoClobberLeaseOwner; bytes: string } {
420
+ export function readOwner(path: string): { owner: NoClobberLeaseOwner; bytes: string } {
421
421
  let size: number;
422
422
  try {
423
423
  const stat = lstatSync(path);