@llblab/pi-kit 0.10.8 → 0.11.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 (29) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +14 -10
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +9 -2
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +52 -261
  7. package/node_modules/@llblab/pi-state-flow/docs/README.md +6 -1
  8. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +47 -26
  9. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +97 -0
  10. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +35 -0
  11. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +47 -0
  12. package/node_modules/@llblab/pi-state-flow/docs/performance.md +459 -0
  13. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +35 -3
  14. package/node_modules/@llblab/pi-state-flow/docs/usage.md +142 -0
  15. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +19 -3
  16. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +74 -0
  17. package/node_modules/@llblab/pi-state-flow/lib/context.ts +12 -12
  18. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +4 -2
  19. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +21 -5
  20. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +20 -5
  21. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +146 -37
  22. package/node_modules/@llblab/pi-state-flow/lib/git.ts +142 -42
  23. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +80 -27
  24. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +157 -20
  25. package/node_modules/@llblab/pi-state-flow/lib/status.ts +7 -12
  26. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +2 -1
  27. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -3
  28. package/node_modules/@llblab/pi-state-flow/package.json +5 -4
  29. package/package.json +2 -2
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { closeSync, lstatSync, mkdirSync, mkdtempSync, openSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, relative, resolve, sep } from "node:path";
6
- import { spawnSync } from "node:child_process";
6
+ import { spawn, spawnSync } from "node:child_process";
7
7
  import {
8
8
  assertOwnedFileUpdates,
9
9
  captureOwnedFileBases,
@@ -52,12 +52,13 @@ export interface GitPushResult {
52
52
  function git(
53
53
  repositoryRoot: string,
54
54
  args: readonly string[],
55
- options: { allowFailure?: boolean; env?: NodeJS.ProcessEnv; input?: string } = {},
55
+ options: { allowFailure?: boolean; env?: NodeJS.ProcessEnv; input?: string; maxBuffer?: number } = {},
56
56
  ): GitResult {
57
57
  const result = spawnSync("git", ["-C", repositoryRoot, ...args], {
58
58
  encoding: "utf8",
59
59
  timeout: GIT_TIMEOUT_MS,
60
60
  input: options.input,
61
+ ...(options.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }),
61
62
  env: {
62
63
  ...process.env,
63
64
  GIT_TERMINAL_PROMPT: "0",
@@ -142,26 +143,51 @@ function currentBranchRef(repositoryRoot: string): string {
142
143
  return result.stdout.trim();
143
144
  }
144
145
 
145
- function revisionFile(
146
- repositoryRoot: string,
147
- revision: string,
148
- path: string,
149
- ): DurableFileBase {
150
- const relativePath = relativeOwnedPath(path, repositoryRoot);
151
- const object = `${revision}:${relativePath}`;
152
- const entry = git(repositoryRoot, ["ls-tree", "-z", revision, "--", relativePath]).stdout;
153
- if (entry.length === 0) return { path, identity: "missing" };
154
- if (!/^100(?:644|755) blob [0-9a-f]+\t/.test(entry)) {
155
- throw new Error(`Historical State Flow file is not a regular blob: ${path}`);
146
+ /** One exact-path tree query per immutable revision; inspect only blobs the caller actually selects. */
147
+ function revisionFileReader(repositoryRoot: string, revision: string, paths: readonly string[]): (path: string) => DurableFileBase {
148
+ const relativePaths = new Map(paths.map((path) => [path, relativeOwnedPath(path, repositoryRoot)]));
149
+ if (relativePaths.size === 0) throw new Error("Git revision reader requires owned paths");
150
+ const selected = new Set(relativePaths.values());
151
+ const listing = git(repositoryRoot, ["ls-tree", "-z", revision, "--", ...selected], { env: {
152
+ GIT_LITERAL_PATHSPECS: "1", GIT_GLOB_PATHSPECS: "0", GIT_NOGLOB_PATHSPECS: "0", GIT_ICASE_PATHSPECS: "0",
153
+ } }).stdout;
154
+ if (listing.length > 0 && !listing.endsWith("\0")) throw new Error("Historical Git tree listing is incomplete");
155
+ const entries = new Map<string, string | null>();
156
+ for (const entry of listing.split("\0").filter(Boolean)) {
157
+ const separator = entry.indexOf("\t");
158
+ if (separator < 0) throw new Error("Historical Git tree listing is malformed");
159
+ const path = entry.slice(separator + 1);
160
+ if (!selected.has(path)) continue;
161
+ entries.set(path, entries.has(path) ? null : entry.slice(0, separator));
156
162
  }
157
- const content = git(repositoryRoot, ["show", object]).stdout;
158
- return {
159
- path,
160
- identity: `sha256:${createHash("sha256").update(content).digest("hex")}`,
161
- content,
163
+ const files = new Map<string, DurableFileBase>();
164
+ return (path) => {
165
+ const relativePath = relativePaths.get(path);
166
+ if (relativePath === undefined) throw new Error(`Git revision reader did not select path: ${path}`);
167
+ const cached = files.get(path);
168
+ if (cached) return cached;
169
+ const entry = entries.get(relativePath);
170
+ if (entry === null) throw new Error(`Historical Git tree has duplicate path: ${relativePath}`);
171
+ if (entry === undefined) {
172
+ const missing: DurableFileBase = { path, identity: "missing" };
173
+ files.set(path, missing);
174
+ return missing;
175
+ }
176
+ if (!/^100(?:644|755) blob [0-9a-f]+$/.test(entry)) {
177
+ throw new Error(`Historical State Flow file is not a regular blob: ${path}`);
178
+ }
179
+ // Selected state/runtime blobs have no semantic byte cap; Node's default pipe budget is only 1 MiB.
180
+ const content = git(repositoryRoot, ["show", `${revision}:${relativePath}`], { maxBuffer: Infinity }).stdout;
181
+ const file: DurableFileBase = { path, identity: `sha256:${createHash("sha256").update(content).digest("hex")}`, content };
182
+ files.set(path, file);
183
+ return file;
162
184
  };
163
185
  }
164
186
 
187
+ function revisionFile(repositoryRoot: string, revision: string, path: string): DurableFileBase {
188
+ return revisionFileReader(repositoryRoot, revision, [path])(path);
189
+ }
190
+
165
191
  function assertReadableRevision(root: string, revision: string): void {
166
192
  if (!/^[0-9a-f]{40,64}$/.test(revision)
167
193
  || git(root, ["cat-file", "-e", `${revision}^{commit}`], { allowFailure: true }).status !== 0) {
@@ -192,29 +218,41 @@ function captureTemporalBaseUnderLock(cwd: string, sessionId: string, root: stri
192
218
  return { head: currentHead(root), files: captureTemporalFileBases(cwd, sessionId, root, sessionKey) };
193
219
  }
194
220
 
195
- function revisionScopeFiles(root: string, revision: string, cwd: string, sessionId: string, sessionKey: string, scope: StateScope) {
196
- const read = (paths: ReturnType<typeof temporalScopePaths>) => ({
221
+ function revisionScopeFiles(read: (path: string) => DurableFileBase, paths: ReturnType<typeof temporalScopePaths>) {
222
+ return {
197
223
  paths,
198
- checkpoint: revisionFile(root, revision, paths.checkpoint),
199
- patches: revisionFile(root, revision, paths.patches),
200
- legacy: revisionFile(root, revision, resolve(paths.directory, "state.json")),
201
- meta: revisionFile(root, revision, paths.meta),
202
- });
203
- const canonical = read(temporalScopePaths(cwd, sessionId, scope, root, sessionKey));
204
- if (scope === "global" || [canonical.checkpoint, canonical.patches, canonical.legacy].some(({ identity }) => identity !== "missing")) return { ...canonical, legacyLayout: false };
205
- return { ...read(legacyTemporalScopePaths(cwd, sessionId, scope, root)), legacyLayout: true };
224
+ checkpoint: read(paths.checkpoint),
225
+ patches: read(paths.patches),
226
+ legacy: read(resolve(paths.directory, "state.json")),
227
+ meta: read(paths.meta),
228
+ };
206
229
  }
207
230
 
208
231
  /** Cold scope-stream reconstruction; pre-0.4 hashed paths remain read-only revision input. */
209
232
  export function loadTemporalRevision(cwd: string, sessionId: string, repositoryRoot: string, revision: string, sessionKey = sessionId): TemporalRevisionLoad {
210
233
  const root = assertRepositoryRoot(repositoryRoot);
211
234
  assertReadableRevision(root, revision);
235
+ const scopePaths = (["global", "cwd", "session"] as const).map((scope) => ({
236
+ scope,
237
+ canonical: temporalScopePaths(cwd, sessionId, scope, root, sessionKey),
238
+ legacy: scope === "global" ? undefined : legacyTemporalScopePaths(cwd, sessionId, scope, root),
239
+ }));
240
+ const canonicalRuntime = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
241
+ const legacyRuntime = legacySessionRuntimePaths(cwd, sessionId, root);
242
+ const readFile = revisionFileReader(root, revision, [
243
+ ...scopePaths.flatMap(({ canonical, legacy }) => legacy ? [canonical, legacy] : [canonical])
244
+ .flatMap((paths) => [paths.checkpoint, paths.patches, resolve(paths.directory, "state.json"), paths.meta]),
245
+ canonicalRuntime.config, canonicalRuntime.meta, legacyRuntime.config, legacyRuntime.meta,
246
+ ]);
212
247
  const files: DurableFileBase[] = [];
213
248
  const scopes = {} as Record<StateScope, ScopeStream | undefined>;
214
249
  const provenance: Record<StateScope, ArtifactProvenanceRegistry> = { global: {}, cwd: {}, session: {} };
215
250
  let legacyLayout = false;
216
- for (const scope of ["global", "cwd", "session"] as const) {
217
- const selected = revisionScopeFiles(root, revision, cwd, sessionId, sessionKey, scope);
251
+ for (const { scope, canonical, legacy } of scopePaths) {
252
+ let selected = { ...revisionScopeFiles(readFile, canonical), legacyLayout: false };
253
+ if (legacy && [selected.checkpoint, selected.patches, selected.legacy].every(({ identity }) => identity === "missing")) {
254
+ selected = { ...revisionScopeFiles(readFile, legacy), legacyLayout: true };
255
+ }
218
256
  if (selected.legacy.identity !== "missing") throw new Error("Historical legacy storage requires explicit migration interpretation");
219
257
  legacyLayout ||= selected.legacyLayout;
220
258
  files.push(selected.checkpoint, selected.patches, selected.legacy, ...(scope === "session" ? [] : [selected.meta]));
@@ -223,11 +261,11 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
223
261
  scope === "cwd" && !selected.legacyLayout ? cwd : undefined);
224
262
  }
225
263
  const readRuntime = (paths: ReturnType<typeof sessionRuntimePaths>) => ({
226
- paths, config: revisionFile(root, revision, paths.config), meta: revisionFile(root, revision, paths.meta),
264
+ paths, config: readFile(paths.config), meta: readFile(paths.meta),
227
265
  });
228
- let selectedRuntime = { ...readRuntime(sessionRuntimePaths(cwd, sessionId, root, sessionKey)), legacyLayout: false };
266
+ let selectedRuntime = { ...readRuntime(canonicalRuntime), legacyLayout: false };
229
267
  if (selectedRuntime.config.identity === "missing" && selectedRuntime.meta.identity === "missing") {
230
- selectedRuntime = { ...readRuntime(legacySessionRuntimePaths(cwd, sessionId, root)), legacyLayout: true };
268
+ selectedRuntime = { ...readRuntime(legacyRuntime), legacyLayout: true };
231
269
  }
232
270
  legacyLayout ||= selectedRuntime.legacyLayout;
233
271
  const { paths: runtimePaths, config, meta } = selectedRuntime;
@@ -247,6 +285,7 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
247
285
  }
248
286
  const selected = loadTemporalRevision(cwd, sessionId, root, temporalRevision, sessionKey);
249
287
  Object.assign(scopes, selected.scopes);
288
+ Object.assign(provenance, selected.provenance);
250
289
  }
251
290
  if (scopes.global === undefined || scopes.cwd === undefined || scopes.session === undefined) {
252
291
  throw new Error("Session runtime has incomplete temporal scope storage");
@@ -430,6 +469,7 @@ function commitOwnedFiles(
430
469
  // and manual deletions, while `.gitignore` stays authoritative for untracked files. The
431
470
  // transient publication lock is ours, not repository content.
432
471
  git(repositoryRoot, ["add", "-A", "--", ".", ":(exclude).state-flow-publication.lock"], { env });
472
+ const entries: string[] = [];
433
473
  for (const update of updates) {
434
474
  const relativePath = relativeOwnedPath(update.path, repositoryRoot);
435
475
  if (update.content === undefined) {
@@ -437,8 +477,10 @@ function commitOwnedFiles(
437
477
  continue;
438
478
  }
439
479
  const blob = git(repositoryRoot, ["hash-object", "-w", "--stdin"], { input: update.content }).stdout.trim();
440
- git(repositoryRoot, ["update-index", "--add", "--cacheinfo", `100644,${blob},${relativePath}`], { env });
480
+ entries.push(`100644 ${blob}\t${relativePath}\0`);
441
481
  }
482
+ // NUL framing preserves literal path characters while the blobs retain exact prepared bytes.
483
+ if (entries.length > 0) git(repositoryRoot, ["update-index", "-z", "--index-info"], { env, input: entries.join("") });
442
484
  const tree = git(repositoryRoot, ["write-tree"], { env }).stdout.trim();
443
485
  if (expectedHead !== undefined) {
444
486
  const previousTree = git(repositoryRoot, ["rev-parse", `${expectedHead}^{tree}`]).stdout.trim();
@@ -559,20 +601,22 @@ function includeUncommittedCohort(cwd: string, sessionId: string, root: string,
559
601
  updates: OwnedFileUpdate[], changedScopes: StateScope[], scopes: readonly StateScope[], runtime: boolean, sessionKey = sessionId): void {
560
602
  const targets = new Map(updates.map((update) => [update.path, update]));
561
603
  const desired = (paths: string[]) => paths.map((path) => targets.get(path) ?? { path, content: current.files.find((file) => file.path === path)!.content });
562
- const absentFromHead = (files: OwnedFileUpdate[]) => files.some(({ path, content }) => current.head === undefined || revisionFile(root, current.head, path).content !== content);
563
- for (const scope of ["global", "cwd", "session"] as const) {
604
+ const pairs = (["global", "cwd", "session"] as const).map((scope) => {
564
605
  const paths = temporalScopePaths(cwd, sessionId, scope, root, sessionKey);
565
- const pair = desired([paths.checkpoint, paths.patches]);
606
+ return { scope, pair: desired([paths.checkpoint, paths.patches]) };
607
+ });
608
+ const runtimePaths = runtime ? sessionRuntimePaths(cwd, sessionId, root, sessionKey) : undefined;
609
+ const runtimePair = runtimePaths ? desired([runtimePaths.config, runtimePaths.meta]) : [];
610
+ const readFile = current.head === undefined ? undefined : revisionFileReader(root, current.head,
611
+ [...pairs.flatMap(({ pair }) => pair), ...runtimePair].map(({ path }) => path));
612
+ const absentFromHead = (files: OwnedFileUpdate[]) => files.some(({ path, content }) => readFile === undefined || readFile(path).content !== content);
613
+ for (const { scope, pair } of pairs) {
566
614
  if (!absentFromHead(pair)) continue;
567
615
  if (!scopes.includes(scope)) throw new Error(`Temporal scope update omitted an uncommitted stream: ${scope}`);
568
616
  for (const update of pair) targets.set(update.path, update);
569
617
  if (!changedScopes.includes(scope)) changedScopes.push(scope);
570
618
  }
571
- if (runtime) {
572
- const paths = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
573
- const pair = desired([paths.config, paths.meta]);
574
- if (absentFromHead(pair)) for (const update of pair) targets.set(update.path, update);
575
- }
619
+ if (absentFromHead(runtimePair)) for (const update of runtimePair) targets.set(update.path, update);
576
620
  updates.splice(0, updates.length, ...targets.values());
577
621
  }
578
622
 
@@ -657,6 +701,62 @@ export function isGitCommitAncestor(repositoryRoot: string, ancestor: string, de
657
701
  throw new Error(`Cannot inspect Git commit ancestry: ${result.stderr || `exit ${result.status}`}`);
658
702
  }
659
703
 
704
+ /** Own one non-interactive exact-target push; cancellation is not confirmation of child exit. */
705
+ export function pushGitTarget(
706
+ repositoryRoot: string,
707
+ destination: { remote: string; ref: string },
708
+ commit: string,
709
+ signal?: AbortSignal,
710
+ ): Promise<void> {
711
+ if (typeof commit !== "string" || !/^[0-9a-f]{40,64}$/.test(commit)) return Promise.reject(new Error("Git push target must be an exact commit"));
712
+ if (signal?.aborted) return Promise.reject(new Error("Git push aborted"));
713
+ return new Promise<void>((resolve, reject) => {
714
+ const child = spawn("git", ["-C", repositoryRoot, "push", "--", destination.remote, `${commit}:${destination.ref}`], {
715
+ detached: process.platform !== "win32", // Own the POSIX group, not a detached daemon; retain the child handle.
716
+ stdio: ["ignore", "ignore", "pipe"],
717
+ windowsHide: true,
718
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" },
719
+ });
720
+ let stderr = "";
721
+ let failure: Error | undefined;
722
+ let settled = false;
723
+ function terminate(error: Error): void {
724
+ failure ??= error;
725
+ if (child.exitCode !== null || child.signalCode !== null) {
726
+ child.stderr?.destroy(); // Do not wait indefinitely for an outliving helper's inherited pipe.
727
+ return;
728
+ }
729
+ if (!child.pid) return;
730
+ try {
731
+ if (process.platform === "win32") child.kill("SIGKILL");
732
+ else process.kill(-child.pid, "SIGKILL");
733
+ } catch {
734
+ // No exit proof: keep the promise and caller's lease outstanding, even if signalling fails.
735
+ }
736
+ }
737
+ const abort = () => terminate(new Error("Git push aborted"));
738
+ const timeout = setTimeout(() => terminate(new Error(`Git push timed out after ${GIT_TIMEOUT_MS}ms`)), GIT_TIMEOUT_MS);
739
+ function finish(error?: Error): void {
740
+ if (settled) return;
741
+ settled = true;
742
+ clearTimeout(timeout);
743
+ signal?.removeEventListener("abort", abort);
744
+ if (error) reject(error);
745
+ else resolve();
746
+ }
747
+ child.stderr?.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString("utf8")).slice(-1000); });
748
+ child.on("error", (error) => {
749
+ failure ??= error;
750
+ if (!child.pid) finish(error); // Spawn failure owns no live process; kill errors do not prove exit.
751
+ });
752
+ child.once("exit", () => { if (failure) child.stderr?.destroy(); });
753
+ child.once("close", (code, endedBy) => finish(failure ?? (code === 0 ? undefined
754
+ : new Error(`Git push failed (${endedBy ?? code}): ${stderr.trim() || "no diagnostic output"}`))));
755
+ signal?.addEventListener("abort", abort, { once: true });
756
+ if (signal?.aborted) abort();
757
+ });
758
+ }
759
+
660
760
  export function pushGitCommit(repositoryRoot: string, commit: string): GitPushResult {
661
761
  try {
662
762
  const root = assertRepositoryRoot(repositoryRoot);
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname, parse, resolve, sep } from "node:path";
4
4
 
5
5
 
@@ -62,7 +62,9 @@ export function remotePublicationDestinationKey(destination: RemotePublicationDe
62
62
  return JSON.stringify([resolve(destination.gitCommonDir), destination.remote, destination.ref]);
63
63
  }
64
64
 
65
- const COMMIT = /^[0-9a-f]{40,64}$/;
65
+ function isCommit(value: unknown): value is string {
66
+ return typeof value === "string" && /^[0-9a-f]{40,64}$/.test(value);
67
+ }
66
68
  export type PublicationQueueStatus = "pending" | "pushing" | "failed";
67
69
  export interface PublicationQueueState {
68
70
  version: 1;
@@ -76,7 +78,7 @@ export interface PublicationQueueState {
76
78
  export type CommitAncestor = (ancestor: string, descendant: string) => boolean;
77
79
 
78
80
  export function createPublicationQueue(destination: RemotePublicationDestination, target: string): PublicationQueueState {
79
- if (!COMMIT.test(target)) throw new Error("Publication queue target must be an exact commit");
81
+ if (!isCommit(target)) throw new Error("Publication queue target must be an exact commit");
80
82
  remotePublicationDestinationKey(destination);
81
83
  return { version: 1, destination: structuredClone(destination), target, status: "pending", attempt: 0 };
82
84
  }
@@ -88,7 +90,7 @@ export interface PublicationCoalesceObserver {
88
90
 
89
91
  export function coalescePublicationTarget(state: PublicationQueueState, destination: RemotePublicationDestination, target: string, isAncestor: CommitAncestor, observer?: PublicationCoalesceObserver): PublicationQueueState {
90
92
  validatePublicationQueue(state);
91
- if (!COMMIT.test(target)) throw new Error("Publication queue target must be an exact commit");
93
+ if (!isCommit(target)) throw new Error("Publication queue target must be an exact commit");
92
94
  if (remotePublicationDestinationKey(state.destination) !== remotePublicationDestinationKey(destination)) throw new Error("Publication queue destination changed");
93
95
  if (target === state.target || isAncestor(target, state.target)) return structuredClone(state);
94
96
  const previous = structuredClone(state);
@@ -129,7 +131,7 @@ export function failPublicationAttempt(state: PublicationQueueState, error: stri
129
131
 
130
132
  export function confirmPublicationTarget(state: PublicationQueueState, pushed: string, isAncestor: CommitAncestor): PublicationQueueState | undefined {
131
133
  validatePublicationQueue(state);
132
- if (!COMMIT.test(pushed)) throw new Error("Confirmed publication target must be an exact commit");
134
+ if (!isCommit(pushed)) throw new Error("Confirmed publication target must be an exact commit");
133
135
  if (pushed === state.target) return undefined;
134
136
  if (!isAncestor(pushed, state.target)) throw new Error("Publication confirmation does not cover the queued lineage");
135
137
  return { ...structuredClone(state), confirmed: pushed, status: "pending", error: undefined };
@@ -146,11 +148,11 @@ export function recoverPublicationQueue(state: PublicationQueueState): Publicati
146
148
  export function validatePublicationQueue(value: unknown): asserts value is PublicationQueueState {
147
149
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid publication queue document");
148
150
  const v = value as Record<string, unknown>;
149
- if (v.version !== 1 || !COMMIT.test(String(v.target)) || !Number.isSafeInteger(v.attempt) || (v.attempt as number) < 0
151
+ if (v.version !== 1 || !isCommit(v.target) || !Number.isSafeInteger(v.attempt) || (v.attempt as number) < 0
150
152
  || (v.status !== "pending" && v.status !== "pushing" && v.status !== "failed")
151
153
  || typeof v.destination !== "object" || v.destination === null) throw new Error("Invalid publication queue document");
152
154
  remotePublicationDestinationKey(v.destination as unknown as RemotePublicationDestination);
153
- if (v.confirmed !== undefined && !COMMIT.test(String(v.confirmed))) throw new Error("Invalid publication queue document");
155
+ if (v.confirmed !== undefined && !isCommit(v.confirmed)) throw new Error("Invalid publication queue document");
154
156
  if (v.error !== undefined && (typeof v.error !== "string" || !v.error.trim())) throw new Error("Invalid publication queue document");
155
157
  const allowed = new Set(["version", "destination", "target", "confirmed", "status", "attempt", "error"]);
156
158
  if (Object.keys(v).some((key) => !allowed.has(key))) throw new Error("Invalid publication queue document");
@@ -181,6 +183,43 @@ export interface PublicationWorkerLease {
181
183
  release(): void;
182
184
  }
183
185
 
186
+ interface WorkerLeaseDocument {
187
+ version: 1;
188
+ pid: number;
189
+ token: string;
190
+ startedAt: string;
191
+ }
192
+
193
+ function readWorkerLease(path: string): WorkerLeaseDocument | undefined {
194
+ assertNoSymlinkAncestors(dirname(path));
195
+ const stat = lstatSync(path, { throwIfNoEntry: false });
196
+ if (!stat) return undefined;
197
+ if (!stat.isFile()) throw new Error("Publication worker lease path must be a regular file");
198
+ let descriptor: number;
199
+ try { descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
200
+ catch (error) {
201
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; // A live owner may have released it.
202
+ throw error;
203
+ }
204
+ try {
205
+ if (!fstatSync(descriptor).isFile()) throw new Error("Publication worker lease path must be a regular file");
206
+ const source = readFileSync(descriptor, "utf8");
207
+ let value: unknown;
208
+ try { value = JSON.parse(source); } catch { throw new Error("Publication worker lease is malformed"); }
209
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Publication worker lease is malformed");
210
+ const record = value as Record<string, unknown>;
211
+ if (Object.keys(record).sort().join(",") !== "pid,startedAt,token,version" || record.version !== 1
212
+ || !Number.isSafeInteger(record.pid) || (record.pid as number) <= 0 || (record.pid as number) > 2147483647
213
+ || typeof record.token !== "string" || !record.token.trim() || record.token !== record.token.trim()
214
+ || typeof record.startedAt !== "string" || !Number.isFinite(Date.parse(record.startedAt))) {
215
+ throw new Error("Publication worker lease is malformed");
216
+ }
217
+ return record as unknown as WorkerLeaseDocument;
218
+ } finally {
219
+ closeSync(descriptor);
220
+ }
221
+ }
222
+
184
223
  function processAlive(pid: number): boolean {
185
224
  try { process.kill(pid, 0); return true; }
186
225
  catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; }
@@ -191,28 +230,42 @@ export function acquirePublicationWorkerLease(queuePath: string): PublicationWor
191
230
  assertNoSymlinkAncestors(dirname(path));
192
231
  const token = randomUUID();
193
232
  const document = `${JSON.stringify({ version: 1, pid: process.pid, token, startedAt: new Date().toISOString() })}\n`;
194
- for (let attempt = 0; attempt < 2; attempt++) {
195
- try {
196
- writeFileSync(path, document, { flag: "wx", mode: 0o600 });
197
- return {
198
- path, token,
199
- release() {
200
- let current: unknown;
201
- try { current = JSON.parse(readFileSync(path, "utf8")); } catch { return; }
202
- if (typeof current === "object" && current !== null && (current as { token?: unknown }).token === token) rmSync(path, { force: true });
203
- },
204
- };
205
- } catch (error) {
206
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
207
- let current: unknown;
208
- try { current = JSON.parse(readFileSync(path, "utf8")); } catch { throw new Error("Publication worker lease is malformed"); }
209
- const pid = typeof current === "object" && current !== null ? (current as { pid?: unknown }).pid : undefined;
210
- if (!Number.isSafeInteger(pid) || (pid as number) <= 0) throw new Error("Publication worker lease is malformed");
211
- if (processAlive(pid as number)) return undefined;
212
- rmSync(path, { force: true });
233
+ const create = (): PublicationWorkerLease | undefined => {
234
+ try { writeFileSync(path, document, { flag: "wx", mode: 0o600 }); }
235
+ catch (error) {
236
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined;
237
+ throw error;
213
238
  }
239
+ return { path, token, release() {
240
+ let current: WorkerLeaseDocument | undefined;
241
+ try { current = readWorkerLease(path); } catch { return; }
242
+ // A live owner cannot be reclaimed, so release need not wait for queue writers.
243
+ if (current?.pid === process.pid && current.token === token) rmSync(path, { force: true });
244
+ } };
245
+ };
246
+ const lease = create();
247
+ if (lease) return lease;
248
+ const current = readWorkerLease(path);
249
+ if (!current) return create();
250
+ if (processAlive(current.pid)) return undefined;
251
+ // Only reclamation needs the existing queue writer gate; exclusive creation protects fresh claims.
252
+ const gate = `${resolve(queuePath)}.lock`;
253
+ assertNoSymlinkAncestors(dirname(gate));
254
+ let descriptor: number;
255
+ try { descriptor = openSync(gate, "wx", 0o600); }
256
+ catch (error) {
257
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined;
258
+ throw error;
259
+ }
260
+ try {
261
+ const owner = readWorkerLease(path);
262
+ if (owner && processAlive(owner.pid)) return undefined;
263
+ if (owner) rmSync(path, { force: true });
264
+ return create(); // A fresh claimant may win the gap; never remove its replacement.
265
+ } finally {
266
+ closeSync(descriptor);
267
+ rmSync(gate, { force: true });
214
268
  }
215
- return undefined;
216
269
  }
217
270
 
218
271
  export function loadPublicationQueue(path: string): PublicationQueueState | undefined {