ushman-ledger 1.2.2 → 1.3.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 (61) hide show
  1. package/AGENTS.md +7 -5
  2. package/ARCHITECTURE.md +8 -2
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +37 -5
  5. package/TROUBLESHOOTING.md +17 -3
  6. package/dist/blobs.d.ts.map +1 -1
  7. package/dist/blobs.js +1 -1
  8. package/dist/builders.d.ts +33 -0
  9. package/dist/builders.d.ts.map +1 -1
  10. package/dist/builders.js +10 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +153 -43
  13. package/dist/doctor.d.ts +1 -1
  14. package/dist/doctor.d.ts.map +1 -1
  15. package/dist/doctor.js +45 -11
  16. package/dist/handle.d.ts.map +1 -1
  17. package/dist/handle.js +67 -30
  18. package/dist/helpers.d.ts.map +1 -1
  19. package/dist/helpers.js +2 -1
  20. package/dist/index.d.ts +3 -3
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +3 -3
  23. package/dist/lab-min.d.ts +1 -1
  24. package/dist/lab-min.d.ts.map +1 -1
  25. package/dist/lab-min.js +2 -1
  26. package/dist/list.d.ts +32 -0
  27. package/dist/list.d.ts.map +1 -1
  28. package/dist/list.js +1 -1
  29. package/dist/patch-resolver.d.ts.map +1 -1
  30. package/dist/patch-resolver.js +1 -1
  31. package/dist/process.d.ts +2 -0
  32. package/dist/process.d.ts.map +1 -0
  33. package/dist/process.js +16 -0
  34. package/dist/read-index.d.ts +7 -7
  35. package/dist/read-index.d.ts.map +1 -1
  36. package/dist/read-index.js +13 -9
  37. package/dist/record.d.ts.map +1 -1
  38. package/dist/record.js +1 -2
  39. package/dist/recovery.d.ts +8 -0
  40. package/dist/recovery.d.ts.map +1 -1
  41. package/dist/recovery.js +142 -30
  42. package/dist/render/retro.d.ts.map +1 -1
  43. package/dist/render/retro.js +4 -1
  44. package/dist/runtime-config.d.ts +2 -0
  45. package/dist/runtime-config.d.ts.map +1 -1
  46. package/dist/runtime-config.js +14 -0
  47. package/dist/schema/entry-core.d.ts +5 -2
  48. package/dist/schema/entry-core.d.ts.map +1 -1
  49. package/dist/schema/entry-core.js +3 -0
  50. package/dist/schema/entry-read.d.ts +57 -0
  51. package/dist/schema/entry-read.d.ts.map +1 -1
  52. package/dist/schema/entry-read.js +9 -1
  53. package/dist/schema/entry-write.d.ts +51 -0
  54. package/dist/schema/entry-write.d.ts.map +1 -1
  55. package/dist/schema/entry-write.js +9 -1
  56. package/dist/storage/filesystem.d.ts +14 -2
  57. package/dist/storage/filesystem.d.ts.map +1 -1
  58. package/dist/storage/filesystem.js +206 -39
  59. package/dist/storage/lock.d.ts.map +1 -1
  60. package/dist/storage/lock.js +38 -16
  61. package/package.json +2 -1
@@ -1 +1 @@
1
- {"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/storage/filesystem.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/storage/filesystem.ts"],"names":[],"mappings":"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"}
@@ -3,6 +3,7 @@ import { mkdir, open, readdir, readFile, rename, rm, stat } from 'node:fs/promis
3
3
  import path from 'node:path';
4
4
  import * as v from 'valibot';
5
5
  import { stableStringify } from "../json.js";
6
+ import { isProcessAlive } from "../process.js";
6
7
  import { readLabManifestMin } from "../lab-min.js";
7
8
  import { LEDGER_PHASES } from "../schema/entry.js";
8
9
  import { LedgerManifestSchema } from "../schema/manifest.js";
@@ -46,9 +47,11 @@ export const resolveLedgerPaths = (workspaceRoot) => {
46
47
  const root = path.join(workspaceRoot, ...LEDGER_ROOT_SEGMENTS);
47
48
  return {
48
49
  blobsDir: path.join(root, 'blobs'),
50
+ externalTempFilesDir: path.join(root, 'external-temp-files'),
49
51
  lockFile: (phase) => path.join(root, phase, '.lock'),
50
52
  manifestFile: path.join(root, 'manifest.json'),
51
53
  manifestLockFile: path.join(root, '.manifest.lock'),
54
+ pendingCommitQuarantineDir: path.join(root, 'pending-quarantine'),
52
55
  pendingArchivesDir: path.join(root, 'pending-archives'),
53
56
  pendingCommitsDir: path.join(root, 'pending'),
54
57
  phaseDir: (phase) => path.join(root, phase),
@@ -65,7 +68,9 @@ export const ensureLedgerDirectories = async (workspaceRoot) => {
65
68
  const paths = resolveLedgerPaths(workspaceRoot);
66
69
  await mkdir(paths.root, { recursive: true });
67
70
  await mkdir(paths.blobsDir, { recursive: true });
71
+ await mkdir(paths.externalTempFilesDir, { recursive: true });
68
72
  await mkdir(paths.pendingCommitsDir, { recursive: true });
73
+ await mkdir(paths.pendingCommitQuarantineDir, { recursive: true });
69
74
  await mkdir(paths.pendingArchivesDir, { recursive: true });
70
75
  await Promise.all(LEDGER_PHASES.map(async (phase) => {
71
76
  await mkdir(paths.phaseDir(phase), { recursive: true });
@@ -80,6 +85,36 @@ export const setAtomicWriteTestHook = (filePath, hook) => {
80
85
  }
81
86
  atomicWriteTestHooks.set(key, hook);
82
87
  };
88
+ const withPrimaryErrorPreserved = (error, cleanupError) => {
89
+ if (error instanceof Error) {
90
+ const errorWithCause = error;
91
+ if (errorWithCause.cause === undefined) {
92
+ Object.defineProperty(errorWithCause, 'cause', {
93
+ configurable: true,
94
+ enumerable: false,
95
+ value: cleanupError,
96
+ writable: true,
97
+ });
98
+ }
99
+ return error;
100
+ }
101
+ return new Error(String(error), { cause: cleanupError });
102
+ };
103
+ const removeTempRegistration = async (tempRegistration) => {
104
+ if (!tempRegistration) {
105
+ return;
106
+ }
107
+ await tempRegistration.remove();
108
+ };
109
+ const throwWithTempRegistrationCleanup = async ({ error, tempRegistration, }) => {
110
+ try {
111
+ await removeTempRegistration(tempRegistration);
112
+ }
113
+ catch (cleanupError) {
114
+ throw withPrimaryErrorPreserved(error, cleanupError);
115
+ }
116
+ throw error;
117
+ };
83
118
  const createManifest = async (workspaceRoot) => {
84
119
  const labManifest = await readLabManifestMin(workspaceRoot);
85
120
  const now = new Date().toISOString();
@@ -97,30 +132,73 @@ const createManifest = async (workspaceRoot) => {
97
132
  workspaceId: labManifest.workspaceId,
98
133
  });
99
134
  };
100
- export const writeAtomicTextFile = async (filePath, text) => {
135
+ export const writeAtomicTextFile = async (filePath, text, options = {}) => {
101
136
  await mkdir(path.dirname(filePath), { recursive: true });
102
137
  const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`;
103
- const handle = await open(tempPath, 'w');
138
+ const tempRegistration = await options.registerTempFile?.({ finalPath: filePath, tempPath });
139
+ let handle;
104
140
  try {
105
- await handle.writeFile(text, 'utf8');
106
- await handle.sync();
141
+ handle = await open(tempPath, 'w');
142
+ }
143
+ catch (error) {
144
+ await throwWithTempRegistrationCleanup({
145
+ error,
146
+ tempRegistration,
147
+ });
148
+ }
149
+ const fileHandle = handle;
150
+ if (!fileHandle) {
151
+ throw new Error(`Failed to open atomic temp file: ${tempPath}`);
152
+ }
153
+ let writeError;
154
+ try {
155
+ await fileHandle.writeFile(text, 'utf8');
156
+ await fileHandle.sync();
157
+ }
158
+ catch (error) {
159
+ writeError = error;
107
160
  }
108
161
  finally {
109
- await handle.close();
162
+ await fileHandle.close();
163
+ }
164
+ if (writeError) {
165
+ await rm(tempPath, { force: true });
166
+ await throwWithTempRegistrationCleanup({
167
+ error: writeError,
168
+ tempRegistration,
169
+ });
110
170
  }
111
171
  await runAtomicWriteTestHook(filePath);
112
172
  try {
113
173
  await rename(tempPath, filePath);
174
+ await removeTempRegistration(tempRegistration);
114
175
  }
115
176
  catch (error) {
116
177
  await rm(tempPath, { force: true });
117
- throw error;
178
+ await throwWithTempRegistrationCleanup({
179
+ error,
180
+ tempRegistration,
181
+ });
118
182
  }
119
183
  };
120
- export const createAtomicTextFileWriter = async (filePath) => {
184
+ export const createAtomicTextFileWriter = async (filePath, options = {}) => {
121
185
  await mkdir(path.dirname(filePath), { recursive: true });
122
186
  const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`;
123
- const handle = await open(tempPath, 'w');
187
+ const tempRegistration = await options.registerTempFile?.({ finalPath: filePath, tempPath });
188
+ let handle;
189
+ try {
190
+ handle = await open(tempPath, 'w');
191
+ }
192
+ catch (error) {
193
+ await throwWithTempRegistrationCleanup({
194
+ error,
195
+ tempRegistration,
196
+ });
197
+ }
198
+ const fileHandle = handle;
199
+ if (!fileHandle) {
200
+ throw new Error(`Failed to open atomic temp file: ${tempPath}`);
201
+ }
124
202
  let completed = false;
125
203
  let pendingWriteError;
126
204
  let writeChain = Promise.resolve();
@@ -149,11 +227,12 @@ export const createAtomicTextFileWriter = async (filePath) => {
149
227
  catch (error) {
150
228
  writeError = error;
151
229
  }
152
- await handle.close();
230
+ await fileHandle.close();
153
231
  await rm(tempPath, { force: true });
154
- if (writeError) {
155
- throw writeError;
156
- }
232
+ await throwWithTempRegistrationCleanup({
233
+ error: writeError,
234
+ tempRegistration,
235
+ });
157
236
  },
158
237
  close: async () => {
159
238
  if (!(await finalizeHandle())) {
@@ -163,23 +242,30 @@ export const createAtomicTextFileWriter = async (filePath) => {
163
242
  await waitForWrites();
164
243
  }
165
244
  catch (error) {
166
- await handle.close();
245
+ await fileHandle.close();
167
246
  await rm(tempPath, { force: true });
168
- throw error;
247
+ await throwWithTempRegistrationCleanup({
248
+ error,
249
+ tempRegistration,
250
+ });
169
251
  }
170
252
  try {
171
- await handle.sync();
253
+ await fileHandle.sync();
172
254
  }
173
255
  finally {
174
- await handle.close();
256
+ await fileHandle.close();
175
257
  }
176
258
  await runAtomicWriteTestHook(filePath);
177
259
  try {
178
260
  await rename(tempPath, filePath);
261
+ await removeTempRegistration(tempRegistration);
179
262
  }
180
263
  catch (error) {
181
264
  await rm(tempPath, { force: true });
182
- throw error;
265
+ await throwWithTempRegistrationCleanup({
266
+ error,
267
+ tempRegistration,
268
+ });
183
269
  }
184
270
  },
185
271
  write: async (chunk) => {
@@ -188,7 +274,7 @@ export const createAtomicTextFileWriter = async (filePath) => {
188
274
  return;
189
275
  }
190
276
  await runAtomicWriteWriteTestHook(filePath);
191
- await handle.writeFile(chunk, 'utf8');
277
+ await fileHandle.write(chunk, undefined, 'utf8');
192
278
  });
193
279
  writeChain = nextWrite.catch((error) => {
194
280
  pendingWriteError ??= error;
@@ -200,6 +286,26 @@ export const createAtomicTextFileWriter = async (filePath) => {
200
286
  export const writeAtomicJsonFile = async (filePath, value) => {
201
287
  await writeAtomicTextFile(filePath, `${stableStringify(value, true)}\n`);
202
288
  };
289
+ const EXTERNAL_TEMP_FILE_SCHEMA_VERSION = 'ushman-ledger-external-temp-file/v1';
290
+ const ExternalTempFileRecordSchema = v.object({
291
+ createdAt: v.pipe(v.string(), v.isoTimestamp()),
292
+ finalPath: v.pipe(v.string(), v.minLength(1)),
293
+ schemaVersion: v.literal(EXTERNAL_TEMP_FILE_SCHEMA_VERSION),
294
+ tempPath: v.pipe(v.string(), v.minLength(1)),
295
+ });
296
+ export const createExternalTempFileRegistrar = (workspaceRoot) => async ({ finalPath, tempPath }) => {
297
+ const paths = await ensureLedgerDirectories(workspaceRoot);
298
+ const recordPath = path.join(paths.externalTempFilesDir, `${process.pid}.${Date.now()}.${randomUUID()}.json`);
299
+ await writeAtomicJsonFile(recordPath, v.parse(ExternalTempFileRecordSchema, {
300
+ createdAt: new Date().toISOString(),
301
+ finalPath: path.resolve(finalPath),
302
+ schemaVersion: EXTERNAL_TEMP_FILE_SCHEMA_VERSION,
303
+ tempPath: path.resolve(tempPath),
304
+ }));
305
+ return {
306
+ remove: async () => rm(recordPath, { force: true }),
307
+ };
308
+ };
203
309
  const parseManifestText = (filePath, text) => {
204
310
  try {
205
311
  return v.parse(LedgerManifestSchema, JSON.parse(text));
@@ -268,22 +374,6 @@ const parseTempOwnerPid = (fileName) => {
268
374
  }
269
375
  return Number.parseInt(match[1], 10);
270
376
  };
271
- const isProcessAlive = (pid) => {
272
- try {
273
- process.kill(pid, 0);
274
- return true;
275
- }
276
- catch (error) {
277
- const code = error.code;
278
- if (code === 'EPERM') {
279
- return true;
280
- }
281
- if (code === 'ESRCH') {
282
- return false;
283
- }
284
- throw error;
285
- }
286
- };
287
377
  const resolveFinalPathFromTemp = (filePath) => {
288
378
  const fileName = path.basename(filePath);
289
379
  const tempMarkerIndex = fileName.lastIndexOf('.tmp.');
@@ -304,10 +394,12 @@ const cleanupStaleTempFile = async (fullPath) => {
304
394
  throw error;
305
395
  }
306
396
  const ownerPid = parseTempOwnerPid(path.basename(fullPath));
307
- if (Date.now() - fileStat.mtimeMs < LEDGER_STALE_TEMP_MS) {
308
- return;
397
+ if (ownerPid !== null) {
398
+ if (isProcessAlive(ownerPid)) {
399
+ return;
400
+ }
309
401
  }
310
- if (ownerPid !== null && isProcessAlive(ownerPid)) {
402
+ else if (Date.now() - fileStat.mtimeMs < LEDGER_STALE_TEMP_MS) {
311
403
  return;
312
404
  }
313
405
  const finalPath = resolveFinalPathFromTemp(fullPath);
@@ -325,6 +417,73 @@ const cleanupStaleTempFile = async (fullPath) => {
325
417
  }
326
418
  await rm(fullPath, { force: true });
327
419
  };
420
+ const readExternalTempFileRecord = async (recordPath) => {
421
+ try {
422
+ const text = await readFile(recordPath, 'utf8');
423
+ return {
424
+ kind: 'valid',
425
+ record: v.parse(ExternalTempFileRecordSchema, JSON.parse(text)),
426
+ };
427
+ }
428
+ catch (error) {
429
+ if (error.code === 'ENOENT') {
430
+ return {
431
+ kind: 'missing',
432
+ };
433
+ }
434
+ return {
435
+ kind: 'invalid',
436
+ };
437
+ }
438
+ };
439
+ const pathExists = async (filePath) => {
440
+ try {
441
+ await stat(filePath);
442
+ return true;
443
+ }
444
+ catch (error) {
445
+ if (error.code === 'ENOENT') {
446
+ return false;
447
+ }
448
+ throw error;
449
+ }
450
+ };
451
+ const isTrackedAtomicTempRecord = (record) => path.dirname(record.tempPath) === path.dirname(record.finalPath) &&
452
+ path.basename(record.tempPath).startsWith(`${path.basename(record.finalPath)}.tmp.`);
453
+ const quarantineInvalidExternalTempRecord = async (recordPath) => {
454
+ const quarantinedPath = `${recordPath}.invalid.${Date.now()}.${randomUUID()}`;
455
+ try {
456
+ await rename(recordPath, quarantinedPath);
457
+ }
458
+ catch (error) {
459
+ if (error.code === 'ENOENT') {
460
+ return;
461
+ }
462
+ throw error;
463
+ }
464
+ };
465
+ const cleanupTrackedExternalTempFile = async (recordPath) => {
466
+ const recordResult = await readExternalTempFileRecord(recordPath);
467
+ if (recordResult.kind === 'missing') {
468
+ return;
469
+ }
470
+ if (recordResult.kind === 'invalid') {
471
+ await quarantineInvalidExternalTempRecord(recordPath);
472
+ return;
473
+ }
474
+ if (!isTrackedAtomicTempRecord(recordResult.record)) {
475
+ await quarantineInvalidExternalTempRecord(recordPath);
476
+ return;
477
+ }
478
+ if (!(await pathExists(recordResult.record.tempPath))) {
479
+ await rm(recordPath, { force: true });
480
+ return;
481
+ }
482
+ await cleanupStaleTempFile(recordResult.record.tempPath);
483
+ if (!(await pathExists(recordResult.record.tempPath))) {
484
+ await rm(recordPath, { force: true });
485
+ }
486
+ };
328
487
  const listLedgerTempFiles = async (root) => {
329
488
  const entries = await readdir(root, { withFileTypes: true });
330
489
  const tempFiles = [];
@@ -342,8 +501,16 @@ const listLedgerTempFiles = async (root) => {
342
501
  };
343
502
  export const cleanupStaleTempFiles = async (workspaceRoot) => {
344
503
  const paths = await ensureLedgerDirectories(workspaceRoot);
345
- const tempFiles = await listLedgerTempFiles(paths.root);
346
- await Promise.all(tempFiles.map(async (filePath) => cleanupStaleTempFile(filePath)));
504
+ const [tempFiles, externalTempRecords] = await Promise.all([
505
+ listLedgerTempFiles(paths.root),
506
+ readdir(paths.externalTempFilesDir, { withFileTypes: true }),
507
+ ]);
508
+ await Promise.all([
509
+ ...tempFiles.map(async (filePath) => cleanupStaleTempFile(filePath)),
510
+ ...externalTempRecords
511
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
512
+ .map(async (entry) => cleanupTrackedExternalTempFile(path.join(paths.externalTempFilesDir, entry.name))),
513
+ ]);
347
514
  };
348
515
  export const readPhaseEntryFileNames = async (workspaceRoot, phase) => {
349
516
  const paths = await ensureLedgerDirectories(workspaceRoot);
@@ -1 +1 @@
1
- {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../src/storage/lock.ts"],"names":[],"mappings":"AAGA,KAAK,WAAW,GAAG;IACf,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE;QACjB,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE;YAAE,QAAQ,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;KACtG,CAAC;IACF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC9B,CAAC;AAqLF,MAAM,MAAM,UAAU,GAAG;IACrB,QAAQ,CAAC,eAAe,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC,CAAC;AA+BF,eAAO,MAAM,WAAW,GAAU,UAAU,MAAM,EAAE,UAAS,OAAO,CAAC,WAAW,CAAM,KAAG,OAAO,CAAC,UAAU,CAgD1G,CAAC"}
1
+ {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../src/storage/lock.ts"],"names":[],"mappings":"AAIA,KAAK,WAAW,GAAG;IACf,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE;QACjB,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE;YAAE,QAAQ,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;KACtG,CAAC;IACF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC9B,CAAC;AAyMF,MAAM,MAAM,UAAU,GAAG;IACrB,QAAQ,CAAC,eAAe,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC,CAAC;AAkCF,eAAO,MAAM,WAAW,GAAU,UAAU,MAAM,EAAE,UAAS,OAAO,CAAC,WAAW,CAAM,KAAG,OAAO,CAAC,UAAU,CAgD1G,CAAC"}
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { open, readFile, rename, rm, stat } from 'node:fs/promises';
2
+ import { open, readFile, rename, rm } from 'node:fs/promises';
3
+ import { isProcessAlive } from "../process.js";
3
4
  const DEFAULT_LOCK_OPTIONS = {
4
5
  // Give writers enough time to detect and reclaim a stale lock before timing out.
5
6
  retryDelayMs: 10,
@@ -7,6 +8,12 @@ const DEFAULT_LOCK_OPTIONS = {
7
8
  timeoutMs: 35_000,
8
9
  };
9
10
  const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
+ const parseOwnerPid = (value) => {
12
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
13
+ return null;
14
+ }
15
+ return value;
16
+ };
10
17
  const readLockState = async (lockPath) => {
11
18
  try {
12
19
  const rawText = await readFile(lockPath, 'utf8');
@@ -14,6 +21,7 @@ const readLockState = async (lockPath) => {
14
21
  const parsed = JSON.parse(rawText);
15
22
  const startedAt = typeof parsed.startedAt === 'string' ? Date.parse(parsed.startedAt) : Number.NaN;
16
23
  return {
24
+ ownerPid: parseOwnerPid(parsed.pid),
17
25
  rawText,
18
26
  startedAtMs: Number.isNaN(startedAt) ? null : startedAt,
19
27
  token: typeof parsed.token === 'string' && parsed.token.length > 0 ? parsed.token : null,
@@ -21,6 +29,7 @@ const readLockState = async (lockPath) => {
21
29
  }
22
30
  catch {
23
31
  return {
32
+ ownerPid: null,
24
33
  rawText,
25
34
  startedAtMs: null,
26
35
  token: null,
@@ -35,6 +44,9 @@ const readLockState = async (lockPath) => {
35
44
  }
36
45
  };
37
46
  const isFreshLockState = (lockState, staleMs) => {
47
+ if (lockState.ownerPid !== null && !isProcessAlive(lockState.ownerPid)) {
48
+ return false;
49
+ }
38
50
  if (lockState.startedAtMs === null) {
39
51
  return false;
40
52
  }
@@ -62,17 +74,17 @@ const writeExclusiveTextFile = async (filePath, text) => {
62
74
  await handle.close();
63
75
  }
64
76
  };
65
- const reclaimMarkerExists = async (lockPath) => {
66
- try {
67
- await stat(buildReclaimMarkerPath(lockPath));
68
- return true;
77
+ const hasActiveReclaimMarker = async (lockPath, staleMs) => {
78
+ const reclaimMarkerPath = buildReclaimMarkerPath(lockPath);
79
+ const reclaimMarkerState = await readLockState(reclaimMarkerPath);
80
+ if (!reclaimMarkerState) {
81
+ return false;
69
82
  }
70
- catch (error) {
71
- if (error.code === 'ENOENT') {
72
- return false;
73
- }
74
- throw error;
83
+ if (isFreshLockState(reclaimMarkerState, staleMs)) {
84
+ return true;
75
85
  }
86
+ await rm(reclaimMarkerPath, { force: true });
87
+ return false;
76
88
  };
77
89
  const acquireReclaimMarker = async (lockPath) => {
78
90
  const reclaimMarkerPath = buildReclaimMarkerPath(lockPath);
@@ -126,17 +138,24 @@ const reclaimIfObservedStateMatches = async ({ lockPath, observedState, testHook
126
138
  }
127
139
  throw error;
128
140
  }
141
+ let shouldRemoveQuarantine = false;
129
142
  try {
130
143
  const quarantinedState = await readLockState(quarantinePath);
131
144
  if (!quarantinedState || quarantinedState.rawText !== observedState.rawText) {
132
145
  await restoreQuarantinedLock(lockPath, quarantinePath);
133
146
  return false;
134
147
  }
135
- await rm(quarantinePath, { force: true });
148
+ shouldRemoveQuarantine = true;
136
149
  return true;
137
150
  }
151
+ catch (error) {
152
+ await restoreQuarantinedLock(lockPath, quarantinePath);
153
+ throw error;
154
+ }
138
155
  finally {
139
- await rm(quarantinePath, { force: true });
156
+ if (shouldRemoveQuarantine) {
157
+ await rm(quarantinePath, { force: true });
158
+ }
140
159
  }
141
160
  }
142
161
  finally {
@@ -155,7 +174,10 @@ const reclaimIfStale = async (lockPath, options) => {
155
174
  });
156
175
  };
157
176
  const describeCurrentLockState = async (lockPath) => {
158
- const [currentState, reclaiming] = await Promise.all([readLockState(lockPath), reclaimMarkerExists(lockPath)]);
177
+ const [currentState, reclaiming] = await Promise.all([
178
+ readLockState(lockPath),
179
+ hasActiveReclaimMarker(lockPath, DEFAULT_LOCK_OPTIONS.staleMs),
180
+ ]);
159
181
  if (!currentState) {
160
182
  return reclaiming ? 'lock missing while stale reclamation marker is present' : 'lock file is missing';
161
183
  }
@@ -163,7 +185,7 @@ const describeCurrentLockState = async (lockPath) => {
163
185
  return `${ageMs}, token=${currentState.token ?? 'missing'}, reclaiming=${reclaiming ? 'yes' : 'no'}`;
164
186
  };
165
187
  const assertOwnership = async (lockPath, token) => {
166
- if (await reclaimMarkerExists(lockPath)) {
188
+ if (await hasActiveReclaimMarker(lockPath, DEFAULT_LOCK_OPTIONS.staleMs)) {
167
189
  throw new Error(`Lost ledger lock ownership for ${lockPath}: stale reclamation is in progress.`);
168
190
  }
169
191
  const currentState = await readLockState(lockPath);
@@ -182,7 +204,7 @@ export const acquireLock = async (lockPath, options = {}) => {
182
204
  const mergedOptions = { ...DEFAULT_LOCK_OPTIONS, ...options };
183
205
  const deadline = Date.now() + mergedOptions.timeoutMs;
184
206
  while (Date.now() <= deadline) {
185
- if (await reclaimMarkerExists(lockPath)) {
207
+ if (await hasActiveReclaimMarker(lockPath, mergedOptions.staleMs)) {
186
208
  await sleep(mergedOptions.retryDelayMs);
187
209
  continue;
188
210
  }
@@ -201,7 +223,7 @@ export const acquireLock = async (lockPath, options = {}) => {
201
223
  finally {
202
224
  await handle.close();
203
225
  }
204
- if (await reclaimMarkerExists(lockPath)) {
226
+ if (await hasActiveReclaimMarker(lockPath, mergedOptions.staleMs)) {
205
227
  await releaseIfOwned(lockPath, token);
206
228
  await sleep(mergedOptions.retryDelayMs);
207
229
  continue;
package/package.json CHANGED
@@ -12,6 +12,7 @@
12
12
  "dependencies": {
13
13
  "safe-stable-stringify": "^2.5.0",
14
14
  "tar": "^7.5.15",
15
+ "ushman-lab-types": "^0.2.0",
15
16
  "valibot": "^1.4.1"
16
17
  },
17
18
  "description": "Append-only workspace ledger library and CLI for Ushman v4.",
@@ -70,5 +71,5 @@
70
71
  },
71
72
  "type": "module",
72
73
  "types": "dist/index.d.ts",
73
- "version": "1.2.2"
74
+ "version": "1.3.1"
74
75
  }