@llblab/pi-kit 0.6.0 → 0.7.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 (129) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-actors/README.md +1 -1
  6. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
  7. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
  9. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
  10. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
  12. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
  13. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
  14. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
  15. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
  17. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
  18. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
  19. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
  20. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
  21. package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
  22. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
  23. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
  24. package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
  25. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
  26. package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
  27. package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
  28. package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
  29. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
  30. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
  31. package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
  32. package/node_modules/@llblab/pi-actors/package.json +1 -1
  33. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
  34. package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
  35. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
  36. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
  37. package/node_modules/@llblab/pi-state-flow/README.md +203 -107
  38. package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
  39. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
  40. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
  41. package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
  42. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
  43. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
  44. package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
  45. package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
  46. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
  47. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
  50. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
  51. package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
  52. package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
  53. package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
  54. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
  55. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
  56. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
  58. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
  59. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
  60. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
  61. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
  62. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
  63. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
  64. package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
  65. package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
  66. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
  67. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
  68. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
  69. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
  70. package/node_modules/@llblab/pi-state-flow/package.json +8 -2
  71. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
  72. package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
  73. package/node_modules/@llblab/pi-telegram/BACKLOG.md +23 -5
  74. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
  75. package/node_modules/@llblab/pi-telegram/README.md +13 -9
  76. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
  77. package/node_modules/@llblab/pi-telegram/docs/architecture.md +227 -23
  78. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  79. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
  80. package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -6
  81. package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -6
  82. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
  83. package/node_modules/@llblab/pi-telegram/index.ts +4 -1418
  84. package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
  85. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +46 -1
  86. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
  87. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
  88. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
  89. package/node_modules/@llblab/pi-telegram/lib/bus.ts +355 -26
  90. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +718 -0
  91. package/node_modules/@llblab/pi-telegram/lib/commands.ts +237 -11
  92. package/node_modules/@llblab/pi-telegram/lib/config.ts +242 -26
  93. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1851 -0
  94. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
  95. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
  96. package/node_modules/@llblab/pi-telegram/lib/locks.ts +44 -2
  97. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +152 -13
  98. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +97 -10
  99. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
  100. package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
  101. package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
  102. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +6 -2
  103. package/node_modules/@llblab/pi-telegram/lib/queue.ts +118 -26
  104. package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
  105. package/node_modules/@llblab/pi-telegram/lib/replies.ts +21 -2
  106. package/node_modules/@llblab/pi-telegram/lib/routing.ts +344 -112
  107. package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
  108. package/node_modules/@llblab/pi-telegram/lib/status.ts +51 -4
  109. package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
  110. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +353 -22
  111. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
  112. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
  113. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
  114. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
  115. package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
  116. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
  117. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
  118. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
  119. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
  120. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  121. package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
  122. package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
  123. package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
  124. package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
  125. package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
  126. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
  127. package/node_modules/@llblab/skills/package.json +2 -3
  128. package/package.json +6 -5
  129. /package/node_modules/@llblab/pi-telegram/lib/{logs.ts → logging.ts} +0 -0
@@ -0,0 +1,296 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, 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
+ const COMMIT = /^[0-9a-f]{40,64}$/;
66
+ export type PublicationQueueStatus = "pending" | "pushing" | "failed";
67
+ export interface PublicationQueueState {
68
+ version: 1;
69
+ destination: RemotePublicationDestination;
70
+ target: string;
71
+ confirmed?: string;
72
+ status: PublicationQueueStatus;
73
+ attempt: number;
74
+ error?: string;
75
+ }
76
+ export type CommitAncestor = (ancestor: string, descendant: string) => boolean;
77
+
78
+ export function createPublicationQueue(destination: RemotePublicationDestination, target: string): PublicationQueueState {
79
+ if (!COMMIT.test(target)) throw new Error("Publication queue target must be an exact commit");
80
+ remotePublicationDestinationKey(destination);
81
+ return { version: 1, destination: structuredClone(destination), target, status: "pending", attempt: 0 };
82
+ }
83
+
84
+ export function coalescePublicationTarget(state: PublicationQueueState, destination: RemotePublicationDestination, target: string, isAncestor: CommitAncestor): PublicationQueueState {
85
+ validatePublicationQueue(state);
86
+ if (!COMMIT.test(target)) throw new Error("Publication queue target must be an exact commit");
87
+ if (remotePublicationDestinationKey(state.destination) !== remotePublicationDestinationKey(destination)) throw new Error("Publication queue destination changed");
88
+ if (target === state.target || isAncestor(target, state.target)) return structuredClone(state);
89
+ if (!isAncestor(state.target, target)) throw new Error("Publication queue targets diverge");
90
+ return { ...structuredClone(state), target, status: "pending", attempt: 0, error: undefined };
91
+ }
92
+
93
+ export function parsePublicationQueue(content: string): PublicationQueueState {
94
+ let value: unknown;
95
+ try { value = JSON.parse(content); } catch { throw new Error("Invalid publication queue JSON"); }
96
+ validatePublicationQueue(value);
97
+ return structuredClone(value);
98
+ }
99
+
100
+ export function serializePublicationQueue(state: PublicationQueueState): string {
101
+ validatePublicationQueue(state);
102
+ return `${JSON.stringify(state)}\n`;
103
+ }
104
+
105
+ export function beginPublicationAttempt(state: PublicationQueueState): PublicationQueueState {
106
+ validatePublicationQueue(state);
107
+ const { error: _error, ...current } = structuredClone(state);
108
+ return { ...current, status: "pushing", attempt: state.attempt + 1 };
109
+ }
110
+
111
+ export function failPublicationAttempt(state: PublicationQueueState, error: string): PublicationQueueState {
112
+ validatePublicationQueue(state);
113
+ if (state.status !== "pushing" || !error.trim()) throw new Error("Only an active publication attempt can fail with a bounded error");
114
+ return { ...structuredClone(state), status: "failed", error: error.trim().slice(0, 1000) };
115
+ }
116
+
117
+ export function confirmPublicationTarget(state: PublicationQueueState, pushed: string, isAncestor: CommitAncestor): PublicationQueueState | undefined {
118
+ validatePublicationQueue(state);
119
+ if (!COMMIT.test(pushed)) throw new Error("Confirmed publication target must be an exact commit");
120
+ if (pushed === state.target) return undefined;
121
+ if (!isAncestor(pushed, state.target)) throw new Error("Publication confirmation does not cover the queued lineage");
122
+ return { ...structuredClone(state), confirmed: pushed, status: "pending", error: undefined };
123
+ }
124
+
125
+ /** A recovered pushing state is unconfirmed and safely retryable after restart. */
126
+ export function recoverPublicationQueue(state: PublicationQueueState): PublicationQueueState {
127
+ validatePublicationQueue(state);
128
+ return state.status === "pushing"
129
+ ? { ...structuredClone(state), status: "pending", error: "previous publication attempt ended without confirmation" }
130
+ : structuredClone(state);
131
+ }
132
+
133
+ export function validatePublicationQueue(value: unknown): asserts value is PublicationQueueState {
134
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid publication queue document");
135
+ const v = value as Record<string, unknown>;
136
+ if (v.version !== 1 || !COMMIT.test(String(v.target)) || !Number.isSafeInteger(v.attempt) || (v.attempt as number) < 0
137
+ || (v.status !== "pending" && v.status !== "pushing" && v.status !== "failed")
138
+ || typeof v.destination !== "object" || v.destination === null) throw new Error("Invalid publication queue document");
139
+ remotePublicationDestinationKey(v.destination as unknown as RemotePublicationDestination);
140
+ if (v.confirmed !== undefined && !COMMIT.test(String(v.confirmed))) throw new Error("Invalid publication queue document");
141
+ if (v.error !== undefined && (typeof v.error !== "string" || !v.error.trim())) throw new Error("Invalid publication queue document");
142
+ const allowed = new Set(["version", "destination", "target", "confirmed", "status", "attempt", "error"]);
143
+ if (Object.keys(v).some((key) => !allowed.has(key))) throw new Error("Invalid publication queue document");
144
+ }
145
+
146
+ export function publicationQueuePath(destination: RemotePublicationDestination): string {
147
+ const key = remotePublicationDestinationKey(destination);
148
+ const name = createHash("sha256").update(key).digest("hex");
149
+ return resolve(destination.gitCommonDir, "state-flow-publication", `${name}.json`);
150
+ }
151
+
152
+ function assertNoSymlinkAncestors(path: string): void {
153
+ const absolute = resolve(path);
154
+ const root = parse(absolute).root;
155
+ let current = root;
156
+ for (const segment of absolute.slice(root.length).split(sep).filter(Boolean)) {
157
+ current = resolve(current, segment);
158
+ const stat = lstatSync(current, { throwIfNoEntry: false });
159
+ if (!stat) break;
160
+ if (stat.isSymbolicLink()) throw new Error("Publication queue path cannot traverse symlink ancestors");
161
+ if (!stat.isDirectory()) throw new Error("Publication queue ancestor must be a directory");
162
+ }
163
+ }
164
+
165
+ export interface PublicationWorkerLease {
166
+ path: string;
167
+ token: string;
168
+ release(): void;
169
+ }
170
+
171
+ function processAlive(pid: number): boolean {
172
+ try { process.kill(pid, 0); return true; }
173
+ catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; }
174
+ }
175
+
176
+ export function acquirePublicationWorkerLease(queuePath: string): PublicationWorkerLease | undefined {
177
+ const path = `${resolve(queuePath)}.worker.lock`;
178
+ assertNoSymlinkAncestors(dirname(path));
179
+ const token = randomUUID();
180
+ const document = `${JSON.stringify({ version: 1, pid: process.pid, token, startedAt: new Date().toISOString() })}\n`;
181
+ for (let attempt = 0; attempt < 2; attempt++) {
182
+ try {
183
+ writeFileSync(path, document, { flag: "wx", mode: 0o600 });
184
+ return {
185
+ path, token,
186
+ release() {
187
+ let current: unknown;
188
+ try { current = JSON.parse(readFileSync(path, "utf8")); } catch { return; }
189
+ if (typeof current === "object" && current !== null && (current as { token?: unknown }).token === token) rmSync(path, { force: true });
190
+ },
191
+ };
192
+ } catch (error) {
193
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
194
+ let current: unknown;
195
+ try { current = JSON.parse(readFileSync(path, "utf8")); } catch { throw new Error("Publication worker lease is malformed"); }
196
+ const pid = typeof current === "object" && current !== null ? (current as { pid?: unknown }).pid : undefined;
197
+ if (!Number.isSafeInteger(pid) || (pid as number) <= 0) throw new Error("Publication worker lease is malformed");
198
+ if (processAlive(pid as number)) return undefined;
199
+ rmSync(path, { force: true });
200
+ }
201
+ }
202
+ return undefined;
203
+ }
204
+
205
+ export function loadPublicationQueue(path: string): PublicationQueueState | undefined {
206
+ const target = resolve(path);
207
+ assertNoSymlinkAncestors(dirname(target));
208
+ const stat = lstatSync(target, { throwIfNoEntry: false });
209
+ if (!stat) return undefined;
210
+ if (!stat.isFile()) throw new Error("Publication queue path must be a regular file");
211
+ return parsePublicationQueue(readFileSync(target, "utf8"));
212
+ }
213
+
214
+ export function removePublicationQueue(path: string, expected: PublicationQueueState): void {
215
+ const target = resolve(path);
216
+ assertNoSymlinkAncestors(dirname(target));
217
+ const lock = `${target}.lock`;
218
+ let lockFd: number | undefined;
219
+ try {
220
+ lockFd = openSync(lock, "wx", 0o600);
221
+ const current = loadPublicationQueue(target);
222
+ if (!current || serializePublicationQueue(current) !== serializePublicationQueue(expected)) {
223
+ throw new Error("Publication queue compare-and-swap conflict");
224
+ }
225
+ rmSync(target);
226
+ } finally {
227
+ if (lockFd !== undefined) {
228
+ closeSync(lockFd);
229
+ rmSync(lock, { force: true });
230
+ }
231
+ }
232
+ }
233
+
234
+ export interface PublicationQueueReceipt {
235
+ previous?: PublicationQueueState;
236
+ current: PublicationQueueState;
237
+ }
238
+
239
+ export function savePublicationQueue(path: string, state: PublicationQueueState, expected?: PublicationQueueState): PublicationQueueReceipt {
240
+ const target = resolve(path);
241
+ assertNoSymlinkAncestors(dirname(target));
242
+ mkdirSync(dirname(target), { recursive: true });
243
+ const lock = `${target}.lock`;
244
+ let lockFd: number | undefined;
245
+ try {
246
+ lockFd = openSync(lock, "wx", 0o600);
247
+ const existing = lstatSync(target, { throwIfNoEntry: false });
248
+ if (existing && !existing.isFile()) throw new Error("Publication queue path must be a regular file");
249
+ const previous = existing ? parsePublicationQueue(readFileSync(target, "utf8")) : undefined;
250
+ if (expected !== undefined && (previous === undefined
251
+ || serializePublicationQueue(previous) !== serializePublicationQueue(expected))) {
252
+ throw new Error("Publication queue compare-and-swap conflict");
253
+ }
254
+ if (expected === undefined && previous !== undefined) throw new Error("Publication queue compare-and-swap requires the current receipt");
255
+ const temporary = `${target}.tmp-${process.pid}-${Date.now()}`;
256
+ try {
257
+ writeFileSync(temporary, serializePublicationQueue(state), { flag: "wx", mode: 0o600 });
258
+ renameSync(temporary, target);
259
+ } finally {
260
+ rmSync(temporary, { force: true });
261
+ }
262
+ return { ...(previous === undefined ? {} : { previous }), current: structuredClone(state) };
263
+ } finally {
264
+ if (lockFd !== undefined) {
265
+ closeSync(lockFd);
266
+ rmSync(lock, { force: true });
267
+ }
268
+ }
269
+ }
270
+
271
+ export type PublicationPush = (state: Readonly<PublicationQueueState>) => Promise<void>;
272
+
273
+ export interface PublicationWorkerResult {
274
+ attempted: PublicationQueueState;
275
+ next?: PublicationQueueState;
276
+ }
277
+
278
+ /** Execute one immutable target attempt; persistence/CAS remains the caller's responsibility. */
279
+ export async function runPublicationWorker(
280
+ state: PublicationQueueState,
281
+ push: PublicationPush,
282
+ current: () => PublicationQueueState,
283
+ isAncestor: CommitAncestor,
284
+ ): Promise<PublicationWorkerResult> {
285
+ const attempted = beginPublicationAttempt(state);
286
+ try {
287
+ await push(Object.freeze(structuredClone(attempted)));
288
+ const next = confirmPublicationTarget(current(), attempted.target, isAncestor);
289
+ return { attempted, ...(next === undefined ? {} : { next }) };
290
+ } catch (error) {
291
+ return {
292
+ attempted,
293
+ next: failPublicationAttempt(attempted, error instanceof Error ? error.message : String(error)),
294
+ };
295
+ }
296
+ }
@@ -1,26 +1,42 @@
1
- import { emptyState } from "./state.ts";
2
- import { migrateSnapshot, migrationFailure, type Snapshot } from "./snapshot.ts";
1
+ import { RevisionUnavailableError, emptySnapshot, parsePiCheckpoint, migrationFailure, type Snapshot } from "./snapshot.ts";
3
2
 
4
3
  export interface SnapshotRecovery {
5
4
  snapshot: Snapshot;
6
5
  skipped: string[];
6
+ disabledMarker?: true;
7
7
  }
8
8
 
9
9
  function failureMessage(snapshot: Snapshot): string | undefined {
10
- return !snapshot.enabled && snapshot.validation?.attempt === 0
11
- ? snapshot.validation.error
10
+ return !snapshot.config.enabled && snapshot.meta.validation?.attempt === 0
11
+ ? snapshot.meta.validation.error
12
12
  : undefined;
13
13
  }
14
14
 
15
15
  /** Recover the newest valid snapshot, falling back through the active branch. */
16
- export function recoverSnapshot(candidates: readonly unknown[]): SnapshotRecovery {
16
+ export function recoverSnapshot(candidates: readonly unknown[], resolveRevision?: (revision: string, legacy?: Snapshot) => Snapshot): SnapshotRecovery {
17
17
  const skipped: string[] = [];
18
18
  let newestFailure: Snapshot | undefined;
19
19
  for (const candidate of candidates) {
20
20
  let migrated: Snapshot;
21
+ let selectedRevision: string | undefined;
21
22
  try {
22
- migrated = migrateSnapshot(candidate);
23
+ const parsed = parsePiCheckpoint(candidate);
24
+ if ("revision" in parsed) {
25
+ selectedRevision = parsed.revision;
26
+ if (!resolveRevision) throw new Error("Checkpoint pointer requires immutable runtime resolution");
27
+ return { snapshot: resolveRevision(parsed.revision), skipped };
28
+ }
29
+ if ("disabled" in parsed) return { snapshot: emptySnapshot(), skipped, disabledMarker: true };
30
+ migrated = parsed;
31
+ if (failureMessage(migrated) === undefined && migrated.meta.durableBase) {
32
+ selectedRevision = migrated.meta.durableBase;
33
+ if (!resolveRevision) throw new Error("Legacy checkpoint requires immutable runtime resolution");
34
+ return { snapshot: resolveRevision(migrated.meta.durableBase, migrated), skipped };
35
+ }
23
36
  } catch (error) {
37
+ if (selectedRevision && error instanceof RevisionUnavailableError) {
38
+ return { snapshot: migrationFailure({ meta: { durableBase: selectedRevision } }, `Snapshot restoration failed: ${error.message}`), skipped };
39
+ }
24
40
  migrated = migrationFailure(
25
41
  {},
26
42
  `Snapshot restoration failed: ${error instanceof Error ? error.message : String(error)}`,
@@ -32,7 +48,7 @@ export function recoverSnapshot(candidates: readonly unknown[]): SnapshotRecover
32
48
  skipped.push(failure);
33
49
  }
34
50
  return {
35
- snapshot: newestFailure ?? { enabled: false, state: emptyState(), step: 0 },
51
+ snapshot: newestFailure ?? emptySnapshot(),
36
52
  skipped,
37
53
  };
38
54
  }
@@ -0,0 +1,79 @@
1
+ import {
2
+ decideArtifactAcquisition,
3
+ type ArtifactAcquisitionIntent,
4
+ type ArtifactAcquisitionReason,
5
+ } from "./acquisition.ts";
6
+ import type { ArtifactSourceIdentity } from "./artifact.ts";
7
+ import type { StateScope } from "./state.ts";
8
+
9
+ export type RehydrationPhase = "resume-bootstrap" | "new-bootstrap" | "step";
10
+
11
+ export interface RehydrationRoute {
12
+ scope: StateScope;
13
+ source: ArtifactSourceIdentity;
14
+ metadata: unknown;
15
+ compiler: string;
16
+ intent: ArtifactAcquisitionIntent;
17
+ materializedSufficient?: boolean;
18
+ explicitRefresh?: boolean;
19
+ sourceBytes?: number;
20
+ }
21
+
22
+ export interface RehydrationRead {
23
+ scope: StateScope;
24
+ path: string;
25
+ hash: string;
26
+ reason: ArtifactAcquisitionReason;
27
+ }
28
+
29
+ export interface RehydrationPlan {
30
+ reads: RehydrationRead[];
31
+ materialized: string[];
32
+ deferred: Array<{ path: string; reason: "new-session-scope" | "read-count-limit" | "source-byte-limit" }>;
33
+ }
34
+
35
+ export interface RehydrationOptions {
36
+ maxReads?: number;
37
+ maxSourceBytes?: number;
38
+ }
39
+
40
+ /** Plan visible reads only; this function never reads, compiles, mutates, or publishes sources. */
41
+ export function planKnowledgeRehydration(
42
+ phase: RehydrationPhase,
43
+ routes: readonly RehydrationRoute[],
44
+ options: RehydrationOptions = {},
45
+ ): RehydrationPlan {
46
+ const maxReads = Math.max(0, Math.floor(options.maxReads ?? 1));
47
+ const maxSourceBytes = Math.max(0, Math.floor(options.maxSourceBytes ?? 64 * 1024));
48
+ const reads: RehydrationRead[] = [];
49
+ const materialized: string[] = [];
50
+ const deferred: RehydrationPlan["deferred"] = [];
51
+ let sourceBytes = 0;
52
+ for (const route of [...routes].sort((left, right) => left.source.path.localeCompare(right.source.path))) {
53
+ if (phase === "new-bootstrap" && route.scope === "session") {
54
+ deferred.push({ path: route.source.path, reason: "new-session-scope" });
55
+ continue;
56
+ }
57
+ const decision = decideArtifactAcquisition(route.source, route.metadata, route.compiler, {
58
+ intent: route.intent,
59
+ ...(route.materializedSufficient === undefined ? {} : { materializedSufficient: route.materializedSufficient }),
60
+ ...(route.explicitRefresh === undefined ? {} : { explicitRefresh: route.explicitRefresh }),
61
+ });
62
+ if (decision.kind === "use-materialized") {
63
+ materialized.push(route.source.path);
64
+ continue;
65
+ }
66
+ if (reads.length >= maxReads) {
67
+ deferred.push({ path: route.source.path, reason: "read-count-limit" });
68
+ continue;
69
+ }
70
+ const bytes = route.sourceBytes ?? 0;
71
+ if (sourceBytes + bytes > maxSourceBytes) {
72
+ deferred.push({ path: route.source.path, reason: "source-byte-limit" });
73
+ continue;
74
+ }
75
+ sourceBytes += bytes;
76
+ reads.push({ scope: route.scope, path: route.source.path, hash: route.source.hash, reason: decision.reason });
77
+ }
78
+ return { reads, materialized, deferred };
79
+ }