@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.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +14 -10
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +9 -2
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -0
- package/node_modules/@llblab/pi-state-flow/README.md +52 -261
- package/node_modules/@llblab/pi-state-flow/docs/README.md +6 -1
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +47 -26
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +97 -0
- package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +35 -0
- package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +47 -0
- package/node_modules/@llblab/pi-state-flow/docs/performance.md +459 -0
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +35 -3
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +142 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +19 -3
- package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +74 -0
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +12 -12
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +4 -2
- package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +21 -5
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +20 -5
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +146 -37
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +142 -42
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +80 -27
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +157 -20
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +7 -12
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +2 -1
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -3
- package/node_modules/@llblab/pi-state-flow/package.json +5 -4
- 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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
158
|
-
return {
|
|
159
|
-
path
|
|
160
|
-
|
|
161
|
-
|
|
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(
|
|
196
|
-
|
|
221
|
+
function revisionScopeFiles(read: (path: string) => DurableFileBase, paths: ReturnType<typeof temporalScopePaths>) {
|
|
222
|
+
return {
|
|
197
223
|
paths,
|
|
198
|
-
checkpoint:
|
|
199
|
-
patches:
|
|
200
|
-
legacy:
|
|
201
|
-
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
|
|
217
|
-
|
|
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:
|
|
264
|
+
paths, config: readFile(paths.config), meta: readFile(paths.meta),
|
|
227
265
|
});
|
|
228
|
-
let selectedRuntime = { ...readRuntime(
|
|
266
|
+
let selectedRuntime = { ...readRuntime(canonicalRuntime), legacyLayout: false };
|
|
229
267
|
if (selectedRuntime.config.identity === "missing" && selectedRuntime.meta.identity === "missing") {
|
|
230
|
-
selectedRuntime = { ...readRuntime(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 || !
|
|
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 && !
|
|
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
|
-
|
|
195
|
-
try {
|
|
196
|
-
|
|
197
|
-
return
|
|
198
|
-
|
|
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 {
|