@llblab/pi-kit 0.18.2 → 0.19.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 +11 -0
- package/README.md +3 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +35 -37
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +5 -3
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +23 -1
- package/node_modules/@llblab/pi-state-flow/README.md +102 -48
- package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +6 -9
- package/node_modules/@llblab/pi-state-flow/dist/index.js +6 -9
- package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +21 -8
- package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +40 -24
- package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +93 -63
- package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +4 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +9 -9
- package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +6 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +8 -7
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +56 -58
- package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +12 -24
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +7 -18
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +47 -78
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +4 -6
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +241 -437
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -72
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +120 -499
- package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +2 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +4 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +43 -22
- package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +1 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +0 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +1 -14
- package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +5 -37
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +1 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +13 -32
- package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +6 -6
- package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +25 -21
- package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +3 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +15 -12
- package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +2 -4
- package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +5 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +26 -88
- package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +160 -255
- package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +0 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +1 -47
- package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +16 -33
- package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +48 -137
- package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +16 -11
- package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +13 -14
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -9
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +15 -43
- package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +5 -9
- package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +13 -45
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +15 -7
- package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +104 -24
- package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +0 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +11 -14
- package/node_modules/@llblab/pi-state-flow/dist/package.json +9 -6
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +11 -17
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +8 -8
- package/node_modules/@llblab/pi-state-flow/docs/README.md +2 -2
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +64 -67
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +24 -6
- package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +12 -15
- package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +22 -27
- package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +25 -41
- package/node_modules/@llblab/pi-state-flow/docs/performance.md +38 -421
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +33 -46
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +37 -61
- package/node_modules/@llblab/pi-state-flow/index.ts +7 -71
- package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +26 -11
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +116 -88
- package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +12 -10
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -6
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +52 -59
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +11 -20
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +44 -86
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +237 -460
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +110 -552
- package/node_modules/@llblab/pi-state-flow/lib/history.ts +4 -3
- package/node_modules/@llblab/pi-state-flow/lib/json.ts +39 -23
- package/node_modules/@llblab/pi-state-flow/lib/logging.ts +1 -7
- package/node_modules/@llblab/pi-state-flow/lib/memory.ts +5 -44
- package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +14 -25
- package/node_modules/@llblab/pi-state-flow/lib/query.ts +26 -22
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +17 -11
- package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +7 -5
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +155 -254
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +1 -49
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +58 -142
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +27 -19
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +16 -54
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +12 -40
- package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +1 -1
- package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +104 -22
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +16 -26
- package/node_modules/@llblab/pi-state-flow/package.json +9 -6
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +11 -17
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +8 -8
- package/package.json +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.d.ts +0 -21
- package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.js +0 -125
- package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.d.ts +0 -36
- package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.js +0 -98
- package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +0 -13
- package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +0 -167
- package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +0 -86
- package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +0 -437
- package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +0 -133
- package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +0 -147
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +0 -171
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +0 -458
|
@@ -1,458 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, parse, resolve, sep } from "node:path";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export type RemotePublicationMode = "off" | "turn-end" | "transition";
|
|
7
|
-
|
|
8
|
-
export interface RemotePublicationDestination {
|
|
9
|
-
gitCommonDir: string;
|
|
10
|
-
remote: string;
|
|
11
|
-
ref: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface RemotePublicationPolicy {
|
|
15
|
-
mode: RemotePublicationMode;
|
|
16
|
-
migratedLegacyDefault: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface RemotePublicationPolicyDocument {
|
|
20
|
-
version: 1;
|
|
21
|
-
mode: RemotePublicationMode;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Missing policy on an existing runtime preserves 0.4 synchronous behavior. */
|
|
25
|
-
export function resolveRemotePublicationPolicy(
|
|
26
|
-
value: unknown,
|
|
27
|
-
options: { legacyRuntime: boolean },
|
|
28
|
-
): RemotePublicationPolicy {
|
|
29
|
-
if (value === undefined) {
|
|
30
|
-
return options.legacyRuntime
|
|
31
|
-
? { mode: "transition", migratedLegacyDefault: true }
|
|
32
|
-
: { mode: "turn-end", migratedLegacyDefault: false };
|
|
33
|
-
}
|
|
34
|
-
if (value !== "off" && value !== "turn-end" && value !== "transition") {
|
|
35
|
-
throw new Error("State Flow remote publication mode must be off, turn-end, or transition");
|
|
36
|
-
}
|
|
37
|
-
return { mode: value, migratedLegacyDefault: false };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function parseRemotePublicationPolicyDocument(
|
|
41
|
-
value: unknown,
|
|
42
|
-
options: { legacyRuntime: boolean },
|
|
43
|
-
): RemotePublicationPolicy {
|
|
44
|
-
if (value === undefined) return resolveRemotePublicationPolicy(undefined, options);
|
|
45
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid remote publication policy document");
|
|
46
|
-
const document = value as Record<string, unknown>;
|
|
47
|
-
if (Object.keys(document).sort().join(",") !== "mode,version" || document.version !== 1) {
|
|
48
|
-
throw new Error("Invalid remote publication policy document");
|
|
49
|
-
}
|
|
50
|
-
return resolveRemotePublicationPolicy(document.mode, { legacyRuntime: false });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function serializeRemotePublicationPolicyDocument(policy: RemotePublicationPolicy): RemotePublicationPolicyDocument {
|
|
54
|
-
return { version: 1, mode: policy.mode };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function remotePublicationDestinationKey(destination: RemotePublicationDestination): string {
|
|
58
|
-
if (!destination.remote.trim() || destination.remote !== destination.remote.trim()
|
|
59
|
-
|| !destination.ref.trim() || destination.ref !== destination.ref.trim()) {
|
|
60
|
-
throw new Error("Remote publication destination requires non-empty trimmed remote and ref");
|
|
61
|
-
}
|
|
62
|
-
return JSON.stringify([resolve(destination.gitCommonDir), destination.remote, destination.ref]);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function isCommit(value: unknown): value is string {
|
|
66
|
-
return typeof value === "string" && /^[0-9a-f]{40,64}$/.test(value);
|
|
67
|
-
}
|
|
68
|
-
export type PublicationQueueStatus = "pending" | "pushing" | "failed";
|
|
69
|
-
export interface PublicationQueueState {
|
|
70
|
-
version: 1;
|
|
71
|
-
destination: RemotePublicationDestination;
|
|
72
|
-
target: string;
|
|
73
|
-
confirmed?: string;
|
|
74
|
-
status: PublicationQueueStatus;
|
|
75
|
-
attempt: number;
|
|
76
|
-
error?: string;
|
|
77
|
-
}
|
|
78
|
-
export type CommitAncestor = (ancestor: string, descendant: string) => boolean;
|
|
79
|
-
|
|
80
|
-
export function createPublicationQueue(destination: RemotePublicationDestination, target: string): PublicationQueueState {
|
|
81
|
-
if (!isCommit(target)) throw new Error("Publication queue target must be an exact commit");
|
|
82
|
-
remotePublicationDestinationKey(destination);
|
|
83
|
-
return { version: 1, destination: structuredClone(destination), target, status: "pending", attempt: 0 };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface PublicationCoalesceObserver {
|
|
87
|
-
/** Called with the retired record when a journal lineage rewrite orphans its target. */
|
|
88
|
-
onDivergedLineage?: (previous: PublicationQueueState) => void;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function coalescePublicationTarget(state: PublicationQueueState, destination: RemotePublicationDestination, target: string, isAncestor: CommitAncestor, observer?: PublicationCoalesceObserver): PublicationQueueState {
|
|
92
|
-
validatePublicationQueue(state);
|
|
93
|
-
if (!isCommit(target)) throw new Error("Publication queue target must be an exact commit");
|
|
94
|
-
if (remotePublicationDestinationKey(state.destination) !== remotePublicationDestinationKey(destination)) throw new Error("Publication queue destination changed");
|
|
95
|
-
if (target === state.target || isAncestor(target, state.target)) return structuredClone(state);
|
|
96
|
-
const previous = structuredClone(state);
|
|
97
|
-
if (!isAncestor(state.target, target)) {
|
|
98
|
-
// A reset or re-initialized journal rewrites the lineage. The queued commit can never
|
|
99
|
-
// fast-forward the remote again, so retarget the live lineage instead of wedging every
|
|
100
|
-
// later turn-end; the retired commit stays in the local Git object store.
|
|
101
|
-
observer?.onDivergedLineage?.(previous);
|
|
102
|
-
const { confirmed: _confirmed, error: _error, ...live } = previous;
|
|
103
|
-
return { ...live, target, status: "pending", attempt: 0 };
|
|
104
|
-
}
|
|
105
|
-
return { ...previous, target, status: "pending", attempt: 0, error: undefined };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function parsePublicationQueue(content: string): PublicationQueueState {
|
|
109
|
-
let value: unknown;
|
|
110
|
-
try { value = JSON.parse(content); } catch { throw new Error("Invalid publication queue JSON"); }
|
|
111
|
-
validatePublicationQueue(value);
|
|
112
|
-
return structuredClone(value);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function serializePublicationQueue(state: PublicationQueueState): string {
|
|
116
|
-
validatePublicationQueue(state);
|
|
117
|
-
return `${JSON.stringify(state)}\n`;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function beginPublicationAttempt(state: PublicationQueueState): PublicationQueueState {
|
|
121
|
-
validatePublicationQueue(state);
|
|
122
|
-
const { error: _error, ...current } = structuredClone(state);
|
|
123
|
-
return { ...current, status: "pushing", attempt: state.attempt + 1 };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function failPublicationAttempt(state: PublicationQueueState, error: string): PublicationQueueState {
|
|
127
|
-
validatePublicationQueue(state);
|
|
128
|
-
if (state.status !== "pushing" || !error.trim()) throw new Error("Only an active publication attempt can fail with a bounded error");
|
|
129
|
-
return { ...structuredClone(state), status: "failed", error: error.trim().slice(0, 1000) };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function confirmPublicationTarget(state: PublicationQueueState, pushed: string, isAncestor: CommitAncestor): PublicationQueueState | undefined {
|
|
133
|
-
validatePublicationQueue(state);
|
|
134
|
-
if (!isCommit(pushed)) throw new Error("Confirmed publication target must be an exact commit");
|
|
135
|
-
if (pushed === state.target) return undefined;
|
|
136
|
-
if (!isAncestor(pushed, state.target)) throw new Error("Publication confirmation does not cover the queued lineage");
|
|
137
|
-
return { ...structuredClone(state), confirmed: pushed, status: "pending", error: undefined };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** A recovered pushing state is unconfirmed and safely retryable after restart. */
|
|
141
|
-
export function recoverPublicationQueue(state: PublicationQueueState): PublicationQueueState {
|
|
142
|
-
validatePublicationQueue(state);
|
|
143
|
-
return state.status === "pushing"
|
|
144
|
-
? { ...structuredClone(state), status: "pending", error: "previous publication attempt ended without confirmation" }
|
|
145
|
-
: structuredClone(state);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function validatePublicationQueue(value: unknown): asserts value is PublicationQueueState {
|
|
149
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid publication queue document");
|
|
150
|
-
const v = value as Record<string, unknown>;
|
|
151
|
-
if (v.version !== 1 || !isCommit(v.target) || !Number.isSafeInteger(v.attempt) || (v.attempt as number) < 0
|
|
152
|
-
|| (v.status !== "pending" && v.status !== "pushing" && v.status !== "failed")
|
|
153
|
-
|| typeof v.destination !== "object" || v.destination === null) throw new Error("Invalid publication queue document");
|
|
154
|
-
remotePublicationDestinationKey(v.destination as unknown as RemotePublicationDestination);
|
|
155
|
-
if (v.confirmed !== undefined && !isCommit(v.confirmed)) throw new Error("Invalid publication queue document");
|
|
156
|
-
if (v.error !== undefined && (typeof v.error !== "string" || !v.error.trim())) throw new Error("Invalid publication queue document");
|
|
157
|
-
const allowed = new Set(["version", "destination", "target", "confirmed", "status", "attempt", "error"]);
|
|
158
|
-
if (Object.keys(v).some((key) => !allowed.has(key))) throw new Error("Invalid publication queue document");
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export function publicationQueuePath(destination: RemotePublicationDestination): string {
|
|
162
|
-
const key = remotePublicationDestinationKey(destination);
|
|
163
|
-
const name = createHash("sha256").update(key).digest("hex");
|
|
164
|
-
return resolve(destination.gitCommonDir, "state-flow-publication", `${name}.json`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function assertNoSymlinkAncestors(path: string): void {
|
|
168
|
-
const absolute = resolve(path);
|
|
169
|
-
const root = parse(absolute).root;
|
|
170
|
-
let current = root;
|
|
171
|
-
for (const segment of absolute.slice(root.length).split(sep).filter(Boolean)) {
|
|
172
|
-
current = resolve(current, segment);
|
|
173
|
-
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
174
|
-
if (!stat) break;
|
|
175
|
-
if (stat.isSymbolicLink()) throw new Error("Publication queue path cannot traverse symlink ancestors");
|
|
176
|
-
if (!stat.isDirectory()) throw new Error("Publication queue ancestor must be a directory");
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export interface PublicationWorkerLease {
|
|
181
|
-
path: string;
|
|
182
|
-
token: string;
|
|
183
|
-
release(): void;
|
|
184
|
-
}
|
|
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
|
-
|
|
223
|
-
function processAlive(pid: number): boolean {
|
|
224
|
-
try { process.kill(pid, 0); return true; }
|
|
225
|
-
catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; }
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export function acquirePublicationWorkerLease(queuePath: string): PublicationWorkerLease | undefined {
|
|
229
|
-
const path = `${resolve(queuePath)}.worker.lock`;
|
|
230
|
-
assertNoSymlinkAncestors(dirname(path));
|
|
231
|
-
const token = randomUUID();
|
|
232
|
-
const document = `${JSON.stringify({ version: 1, pid: process.pid, token, startedAt: new Date().toISOString() })}\n`;
|
|
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;
|
|
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 });
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export function loadPublicationQueue(path: string): PublicationQueueState | undefined {
|
|
272
|
-
const target = resolve(path);
|
|
273
|
-
assertNoSymlinkAncestors(dirname(target));
|
|
274
|
-
const stat = lstatSync(target, { throwIfNoEntry: false });
|
|
275
|
-
if (!stat) return undefined;
|
|
276
|
-
if (!stat.isFile()) throw new Error("Publication queue path must be a regular file");
|
|
277
|
-
return parsePublicationQueue(readFileSync(target, "utf8"));
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export function removePublicationQueue(path: string, expected: PublicationQueueState): void {
|
|
281
|
-
const target = resolve(path);
|
|
282
|
-
assertNoSymlinkAncestors(dirname(target));
|
|
283
|
-
const lock = `${target}.lock`;
|
|
284
|
-
let lockFd: number | undefined;
|
|
285
|
-
try {
|
|
286
|
-
lockFd = openSync(lock, "wx", 0o600);
|
|
287
|
-
const current = loadPublicationQueue(target);
|
|
288
|
-
if (!current || serializePublicationQueue(current) !== serializePublicationQueue(expected)) {
|
|
289
|
-
throw new Error("Publication queue compare-and-swap conflict");
|
|
290
|
-
}
|
|
291
|
-
rmSync(target);
|
|
292
|
-
} finally {
|
|
293
|
-
if (lockFd !== undefined) {
|
|
294
|
-
closeSync(lockFd);
|
|
295
|
-
rmSync(lock, { force: true });
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export interface PublicationQueueReceipt {
|
|
301
|
-
previous?: PublicationQueueState;
|
|
302
|
-
current: PublicationQueueState;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
export function savePublicationQueue(path: string, state: PublicationQueueState, expected?: PublicationQueueState): PublicationQueueReceipt {
|
|
306
|
-
const target = resolve(path);
|
|
307
|
-
assertNoSymlinkAncestors(dirname(target));
|
|
308
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
309
|
-
const lock = `${target}.lock`;
|
|
310
|
-
let lockFd: number | undefined;
|
|
311
|
-
try {
|
|
312
|
-
lockFd = openSync(lock, "wx", 0o600);
|
|
313
|
-
const existing = lstatSync(target, { throwIfNoEntry: false });
|
|
314
|
-
if (existing && !existing.isFile()) throw new Error("Publication queue path must be a regular file");
|
|
315
|
-
const previous = existing ? parsePublicationQueue(readFileSync(target, "utf8")) : undefined;
|
|
316
|
-
if (expected !== undefined && (previous === undefined
|
|
317
|
-
|| serializePublicationQueue(previous) !== serializePublicationQueue(expected))) {
|
|
318
|
-
throw new Error("Publication queue compare-and-swap conflict");
|
|
319
|
-
}
|
|
320
|
-
if (expected === undefined && previous !== undefined) throw new Error("Publication queue compare-and-swap requires the current receipt");
|
|
321
|
-
const temporary = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
322
|
-
try {
|
|
323
|
-
writeFileSync(temporary, serializePublicationQueue(state), { flag: "wx", mode: 0o600 });
|
|
324
|
-
renameSync(temporary, target);
|
|
325
|
-
} finally {
|
|
326
|
-
rmSync(temporary, { force: true });
|
|
327
|
-
}
|
|
328
|
-
return { ...(previous === undefined ? {} : { previous }), current: structuredClone(state) };
|
|
329
|
-
} finally {
|
|
330
|
-
if (lockFd !== undefined) {
|
|
331
|
-
closeSync(lockFd);
|
|
332
|
-
rmSync(lock, { force: true });
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
export type PublicationPush = (state: Readonly<PublicationQueueState>) => Promise<void>;
|
|
338
|
-
|
|
339
|
-
export interface PublicationWorkerResult {
|
|
340
|
-
attempted: PublicationQueueState;
|
|
341
|
-
next?: PublicationQueueState;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** Execute one immutable target attempt; persistence/CAS remains the caller's responsibility. */
|
|
345
|
-
export async function runPublicationWorker(
|
|
346
|
-
state: PublicationQueueState,
|
|
347
|
-
push: PublicationPush,
|
|
348
|
-
current: () => PublicationQueueState,
|
|
349
|
-
isAncestor: CommitAncestor,
|
|
350
|
-
): Promise<PublicationWorkerResult> {
|
|
351
|
-
const attempted = beginPublicationAttempt(state);
|
|
352
|
-
try {
|
|
353
|
-
await push(Object.freeze(structuredClone(attempted)));
|
|
354
|
-
const next = confirmPublicationTarget(current(), attempted.target, isAncestor);
|
|
355
|
-
return { attempted, ...(next === undefined ? {} : { next }) };
|
|
356
|
-
} catch (error) {
|
|
357
|
-
return {
|
|
358
|
-
attempted,
|
|
359
|
-
next: failPublicationAttempt(attempted, error instanceof Error ? error.message : String(error)),
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
export interface PublicationWorkerControllerPorts {
|
|
365
|
-
resolveDestination(): RemotePublicationDestination | undefined;
|
|
366
|
-
isAncestor(ancestor: string, descendant: string): boolean;
|
|
367
|
-
push(destination: RemotePublicationDestination, target: string, signal: AbortSignal): Promise<void>;
|
|
368
|
-
onDiverged(previous: PublicationQueueState, target: string): void;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** Own durable queue coalescing, worker leases, retries, generation fencing, and bounded shutdown. */
|
|
372
|
-
export class PublicationWorkerController {
|
|
373
|
-
private readonly active = new Map<string, { controller: AbortController; done: Promise<void> }>();
|
|
374
|
-
private readonly ports: PublicationWorkerControllerPorts;
|
|
375
|
-
private stopped = false;
|
|
376
|
-
private shutdownPromise: Promise<boolean> | undefined;
|
|
377
|
-
|
|
378
|
-
constructor(ports: PublicationWorkerControllerPorts) {
|
|
379
|
-
this.ports = ports;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
enqueue(target: string): void {
|
|
383
|
-
const destination = this.ports.resolveDestination();
|
|
384
|
-
if (!destination) return;
|
|
385
|
-
const path = publicationQueuePath(destination);
|
|
386
|
-
const previous = loadPublicationQueue(path);
|
|
387
|
-
const next = previous
|
|
388
|
-
? coalescePublicationTarget(previous, destination, target, this.ports.isAncestor, {
|
|
389
|
-
onDivergedLineage: (dropped) => this.ports.onDiverged(dropped, target),
|
|
390
|
-
})
|
|
391
|
-
: createPublicationQueue(destination, target);
|
|
392
|
-
savePublicationQueue(path, next, previous);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
launch(): void {
|
|
396
|
-
if (this.stopped) return;
|
|
397
|
-
let destination: RemotePublicationDestination | undefined;
|
|
398
|
-
try { destination = this.ports.resolveDestination(); }
|
|
399
|
-
catch (error) {
|
|
400
|
-
if (error instanceof Error && /ENOENT/.test(error.message)) return;
|
|
401
|
-
throw error;
|
|
402
|
-
}
|
|
403
|
-
if (!destination) return;
|
|
404
|
-
const path = publicationQueuePath(destination);
|
|
405
|
-
if (this.active.has(path)) return;
|
|
406
|
-
let queued: PublicationQueueState | undefined;
|
|
407
|
-
let lease: PublicationWorkerLease | undefined;
|
|
408
|
-
try {
|
|
409
|
-
queued = loadPublicationQueue(path);
|
|
410
|
-
if (!queued) return;
|
|
411
|
-
lease = acquirePublicationWorkerLease(path);
|
|
412
|
-
} catch { return; }
|
|
413
|
-
if (!lease) return;
|
|
414
|
-
const controller = new AbortController();
|
|
415
|
-
const done = runPublicationWorker(
|
|
416
|
-
queued,
|
|
417
|
-
({ target }) => this.ports.push(destination, target, controller.signal),
|
|
418
|
-
() => loadPublicationQueue(path) ?? queued,
|
|
419
|
-
this.ports.isAncestor,
|
|
420
|
-
).then((result) => {
|
|
421
|
-
if (this.stopped) return;
|
|
422
|
-
const current = loadPublicationQueue(path);
|
|
423
|
-
if (!current) return;
|
|
424
|
-
if (result.next === undefined) {
|
|
425
|
-
if (current.target === result.attempted.target) removePublicationQueue(path, current);
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
if (current.target === result.attempted.target || result.next.target === current.target) savePublicationQueue(path, result.next, current);
|
|
429
|
-
}).catch(() => {
|
|
430
|
-
// Queue/CAS truth remains durable; status and a later activation expose retry.
|
|
431
|
-
}).finally(() => {
|
|
432
|
-
this.active.delete(path);
|
|
433
|
-
try {
|
|
434
|
-
lease.release();
|
|
435
|
-
if (!this.stopped && loadPublicationQueue(path)?.status === "pending") this.launch();
|
|
436
|
-
} catch {
|
|
437
|
-
// Failed lease cleanup or malformed persistence stays inert until retry or repair.
|
|
438
|
-
}
|
|
439
|
-
});
|
|
440
|
-
this.active.set(path, { controller, done });
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
shutdown(waitMs: number): Promise<boolean> {
|
|
444
|
-
this.stopped = true;
|
|
445
|
-
return this.shutdownPromise ??= (async () => {
|
|
446
|
-
const workers = [...this.active.values()];
|
|
447
|
-
for (const { controller } of workers) controller.abort();
|
|
448
|
-
if (workers.length === 0) return true;
|
|
449
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
450
|
-
try {
|
|
451
|
-
return await Promise.race([
|
|
452
|
-
Promise.all(workers.map(({ done }) => done)).then(() => true),
|
|
453
|
-
new Promise<false>((resolve) => { timeout = setTimeout(() => resolve(false), waitMs); }),
|
|
454
|
-
]);
|
|
455
|
-
} finally { clearTimeout(timeout); }
|
|
456
|
-
})();
|
|
457
|
-
}
|
|
458
|
-
}
|