@llblab/pi-kit 0.18.1 → 0.19.0

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 (139) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +4 -2
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +35 -37
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +5 -3
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -1
  6. package/node_modules/@llblab/pi-state-flow/README.md +102 -48
  7. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +6 -9
  8. package/node_modules/@llblab/pi-state-flow/dist/index.js +6 -9
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +2 -2
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +21 -8
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +40 -24
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +93 -63
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +4 -3
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +9 -9
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +1 -1
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +6 -5
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +8 -7
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +56 -58
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +12 -24
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +7 -18
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +47 -78
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +4 -6
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +241 -437
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -72
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +120 -499
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +2 -1
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +4 -3
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +3 -0
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +43 -22
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +1 -5
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +0 -2
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +1 -14
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +5 -37
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +1 -2
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +13 -32
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +6 -6
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +25 -21
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +3 -3
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +15 -12
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +2 -4
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +5 -1
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +26 -88
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +159 -255
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +0 -3
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +1 -47
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +16 -33
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +48 -137
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +16 -11
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +13 -14
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -9
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +15 -43
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +5 -9
  53. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +13 -45
  54. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +1 -1
  55. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +15 -7
  56. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +104 -24
  57. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +0 -2
  58. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +11 -14
  59. package/node_modules/@llblab/pi-state-flow/dist/package.json +9 -6
  60. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +11 -17
  61. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +8 -8
  62. package/node_modules/@llblab/pi-state-flow/docs/README.md +2 -2
  63. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +64 -67
  64. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +24 -6
  65. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +12 -15
  66. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +22 -27
  67. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +25 -41
  68. package/node_modules/@llblab/pi-state-flow/docs/performance.md +38 -421
  69. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +33 -46
  70. package/node_modules/@llblab/pi-state-flow/docs/usage.md +37 -61
  71. package/node_modules/@llblab/pi-state-flow/index.ts +7 -71
  72. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +26 -11
  73. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +116 -88
  74. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +12 -10
  75. package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -6
  76. package/node_modules/@llblab/pi-state-flow/lib/context.ts +52 -59
  77. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +11 -20
  78. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +44 -86
  79. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +237 -460
  80. package/node_modules/@llblab/pi-state-flow/lib/git.ts +110 -552
  81. package/node_modules/@llblab/pi-state-flow/lib/history.ts +4 -3
  82. package/node_modules/@llblab/pi-state-flow/lib/json.ts +39 -23
  83. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +1 -7
  84. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +5 -44
  85. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +14 -25
  86. package/node_modules/@llblab/pi-state-flow/lib/query.ts +26 -22
  87. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +17 -11
  88. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +7 -5
  89. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +154 -254
  90. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +1 -49
  91. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +58 -142
  92. package/node_modules/@llblab/pi-state-flow/lib/state.ts +27 -19
  93. package/node_modules/@llblab/pi-state-flow/lib/status.ts +16 -54
  94. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +12 -40
  95. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +1 -1
  96. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +104 -22
  97. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +16 -26
  98. package/node_modules/@llblab/pi-state-flow/package.json +9 -6
  99. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +11 -17
  100. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +8 -8
  101. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +7 -0
  102. package/node_modules/@llblab/pi-telegram/README.md +1 -2
  103. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +1 -3
  104. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +1 -6
  105. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +1 -4
  106. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +13 -33
  107. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +1 -4
  108. package/node_modules/@llblab/pi-telegram/dist/lib/menu-queue.d.ts +1 -0
  109. package/node_modules/@llblab/pi-telegram/dist/lib/menu-queue.js +72 -1
  110. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +7 -0
  111. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +27 -0
  112. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +3 -0
  113. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +17 -0
  114. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +8 -1
  115. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  116. package/node_modules/@llblab/pi-telegram/docs/architecture.md +2 -0
  117. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
  118. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  119. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +0 -10
  120. package/node_modules/@llblab/pi-telegram/lib/commands.ts +18 -41
  121. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1 -8
  122. package/node_modules/@llblab/pi-telegram/lib/menu-queue.ts +126 -1
  123. package/node_modules/@llblab/pi-telegram/lib/queue.ts +46 -0
  124. package/node_modules/@llblab/pi-telegram/lib/replies.ts +19 -0
  125. package/node_modules/@llblab/pi-telegram/lib/sync.ts +8 -1
  126. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  127. package/package.json +3 -3
  128. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.d.ts +0 -21
  129. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.js +0 -125
  130. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.d.ts +0 -36
  131. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.js +0 -98
  132. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +0 -13
  133. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +0 -167
  134. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +0 -86
  135. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +0 -437
  136. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +0 -133
  137. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +0 -147
  138. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +0 -171
  139. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +0 -458
@@ -1,437 +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
- /** Missing policy on an existing runtime preserves 0.4 synchronous behavior. */
5
- export function resolveRemotePublicationPolicy(value, options) {
6
- if (value === undefined) {
7
- return options.legacyRuntime
8
- ? { mode: "transition", migratedLegacyDefault: true }
9
- : { mode: "turn-end", migratedLegacyDefault: false };
10
- }
11
- if (value !== "off" && value !== "turn-end" && value !== "transition") {
12
- throw new Error("State Flow remote publication mode must be off, turn-end, or transition");
13
- }
14
- return { mode: value, migratedLegacyDefault: false };
15
- }
16
- export function parseRemotePublicationPolicyDocument(value, options) {
17
- if (value === undefined)
18
- return resolveRemotePublicationPolicy(undefined, options);
19
- if (typeof value !== "object" || value === null || Array.isArray(value))
20
- throw new Error("Invalid remote publication policy document");
21
- const document = value;
22
- if (Object.keys(document).sort().join(",") !== "mode,version" || document.version !== 1) {
23
- throw new Error("Invalid remote publication policy document");
24
- }
25
- return resolveRemotePublicationPolicy(document.mode, { legacyRuntime: false });
26
- }
27
- export function serializeRemotePublicationPolicyDocument(policy) {
28
- return { version: 1, mode: policy.mode };
29
- }
30
- export function remotePublicationDestinationKey(destination) {
31
- if (!destination.remote.trim() || destination.remote !== destination.remote.trim()
32
- || !destination.ref.trim() || destination.ref !== destination.ref.trim()) {
33
- throw new Error("Remote publication destination requires non-empty trimmed remote and ref");
34
- }
35
- return JSON.stringify([resolve(destination.gitCommonDir), destination.remote, destination.ref]);
36
- }
37
- function isCommit(value) {
38
- return typeof value === "string" && /^[0-9a-f]{40,64}$/.test(value);
39
- }
40
- export function createPublicationQueue(destination, target) {
41
- if (!isCommit(target))
42
- throw new Error("Publication queue target must be an exact commit");
43
- remotePublicationDestinationKey(destination);
44
- return { version: 1, destination: structuredClone(destination), target, status: "pending", attempt: 0 };
45
- }
46
- export function coalescePublicationTarget(state, destination, target, isAncestor, observer) {
47
- validatePublicationQueue(state);
48
- if (!isCommit(target))
49
- throw new Error("Publication queue target must be an exact commit");
50
- if (remotePublicationDestinationKey(state.destination) !== remotePublicationDestinationKey(destination))
51
- throw new Error("Publication queue destination changed");
52
- if (target === state.target || isAncestor(target, state.target))
53
- return structuredClone(state);
54
- const previous = structuredClone(state);
55
- if (!isAncestor(state.target, target)) {
56
- // A reset or re-initialized journal rewrites the lineage. The queued commit can never
57
- // fast-forward the remote again, so retarget the live lineage instead of wedging every
58
- // later turn-end; the retired commit stays in the local Git object store.
59
- observer?.onDivergedLineage?.(previous);
60
- const { confirmed: _confirmed, error: _error, ...live } = previous;
61
- return { ...live, target, status: "pending", attempt: 0 };
62
- }
63
- return { ...previous, target, status: "pending", attempt: 0, error: undefined };
64
- }
65
- export function parsePublicationQueue(content) {
66
- let value;
67
- try {
68
- value = JSON.parse(content);
69
- }
70
- catch {
71
- throw new Error("Invalid publication queue JSON");
72
- }
73
- validatePublicationQueue(value);
74
- return structuredClone(value);
75
- }
76
- export function serializePublicationQueue(state) {
77
- validatePublicationQueue(state);
78
- return `${JSON.stringify(state)}\n`;
79
- }
80
- export function beginPublicationAttempt(state) {
81
- validatePublicationQueue(state);
82
- const { error: _error, ...current } = structuredClone(state);
83
- return { ...current, status: "pushing", attempt: state.attempt + 1 };
84
- }
85
- export function failPublicationAttempt(state, error) {
86
- validatePublicationQueue(state);
87
- if (state.status !== "pushing" || !error.trim())
88
- throw new Error("Only an active publication attempt can fail with a bounded error");
89
- return { ...structuredClone(state), status: "failed", error: error.trim().slice(0, 1000) };
90
- }
91
- export function confirmPublicationTarget(state, pushed, isAncestor) {
92
- validatePublicationQueue(state);
93
- if (!isCommit(pushed))
94
- throw new Error("Confirmed publication target must be an exact commit");
95
- if (pushed === state.target)
96
- return undefined;
97
- if (!isAncestor(pushed, state.target))
98
- throw new Error("Publication confirmation does not cover the queued lineage");
99
- return { ...structuredClone(state), confirmed: pushed, status: "pending", error: undefined };
100
- }
101
- /** A recovered pushing state is unconfirmed and safely retryable after restart. */
102
- export function recoverPublicationQueue(state) {
103
- validatePublicationQueue(state);
104
- return state.status === "pushing"
105
- ? { ...structuredClone(state), status: "pending", error: "previous publication attempt ended without confirmation" }
106
- : structuredClone(state);
107
- }
108
- export function validatePublicationQueue(value) {
109
- if (typeof value !== "object" || value === null || Array.isArray(value))
110
- throw new Error("Invalid publication queue document");
111
- const v = value;
112
- if (v.version !== 1 || !isCommit(v.target) || !Number.isSafeInteger(v.attempt) || v.attempt < 0
113
- || (v.status !== "pending" && v.status !== "pushing" && v.status !== "failed")
114
- || typeof v.destination !== "object" || v.destination === null)
115
- throw new Error("Invalid publication queue document");
116
- remotePublicationDestinationKey(v.destination);
117
- if (v.confirmed !== undefined && !isCommit(v.confirmed))
118
- throw new Error("Invalid publication queue document");
119
- if (v.error !== undefined && (typeof v.error !== "string" || !v.error.trim()))
120
- throw new Error("Invalid publication queue document");
121
- const allowed = new Set(["version", "destination", "target", "confirmed", "status", "attempt", "error"]);
122
- if (Object.keys(v).some((key) => !allowed.has(key)))
123
- throw new Error("Invalid publication queue document");
124
- }
125
- export function publicationQueuePath(destination) {
126
- const key = remotePublicationDestinationKey(destination);
127
- const name = createHash("sha256").update(key).digest("hex");
128
- return resolve(destination.gitCommonDir, "state-flow-publication", `${name}.json`);
129
- }
130
- function assertNoSymlinkAncestors(path) {
131
- const absolute = resolve(path);
132
- const root = parse(absolute).root;
133
- let current = root;
134
- for (const segment of absolute.slice(root.length).split(sep).filter(Boolean)) {
135
- current = resolve(current, segment);
136
- const stat = lstatSync(current, { throwIfNoEntry: false });
137
- if (!stat)
138
- break;
139
- if (stat.isSymbolicLink())
140
- throw new Error("Publication queue path cannot traverse symlink ancestors");
141
- if (!stat.isDirectory())
142
- throw new Error("Publication queue ancestor must be a directory");
143
- }
144
- }
145
- function readWorkerLease(path) {
146
- assertNoSymlinkAncestors(dirname(path));
147
- const stat = lstatSync(path, { throwIfNoEntry: false });
148
- if (!stat)
149
- return undefined;
150
- if (!stat.isFile())
151
- throw new Error("Publication worker lease path must be a regular file");
152
- let descriptor;
153
- try {
154
- descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
155
- }
156
- catch (error) {
157
- if (error.code === "ENOENT")
158
- return undefined; // A live owner may have released it.
159
- throw error;
160
- }
161
- try {
162
- if (!fstatSync(descriptor).isFile())
163
- throw new Error("Publication worker lease path must be a regular file");
164
- const source = readFileSync(descriptor, "utf8");
165
- let value;
166
- try {
167
- value = JSON.parse(source);
168
- }
169
- catch {
170
- throw new Error("Publication worker lease is malformed");
171
- }
172
- if (typeof value !== "object" || value === null || Array.isArray(value))
173
- throw new Error("Publication worker lease is malformed");
174
- const record = value;
175
- if (Object.keys(record).sort().join(",") !== "pid,startedAt,token,version" || record.version !== 1
176
- || !Number.isSafeInteger(record.pid) || record.pid <= 0 || record.pid > 2147483647
177
- || typeof record.token !== "string" || !record.token.trim() || record.token !== record.token.trim()
178
- || typeof record.startedAt !== "string" || !Number.isFinite(Date.parse(record.startedAt))) {
179
- throw new Error("Publication worker lease is malformed");
180
- }
181
- return record;
182
- }
183
- finally {
184
- closeSync(descriptor);
185
- }
186
- }
187
- function processAlive(pid) {
188
- try {
189
- process.kill(pid, 0);
190
- return true;
191
- }
192
- catch (error) {
193
- return error.code !== "ESRCH";
194
- }
195
- }
196
- export function acquirePublicationWorkerLease(queuePath) {
197
- const path = `${resolve(queuePath)}.worker.lock`;
198
- assertNoSymlinkAncestors(dirname(path));
199
- const token = randomUUID();
200
- const document = `${JSON.stringify({ version: 1, pid: process.pid, token, startedAt: new Date().toISOString() })}\n`;
201
- const create = () => {
202
- try {
203
- writeFileSync(path, document, { flag: "wx", mode: 0o600 });
204
- }
205
- catch (error) {
206
- if (error.code === "EEXIST")
207
- return undefined;
208
- throw error;
209
- }
210
- return { path, token, release() {
211
- let current;
212
- try {
213
- current = readWorkerLease(path);
214
- }
215
- catch {
216
- return;
217
- }
218
- // A live owner cannot be reclaimed, so release need not wait for queue writers.
219
- if (current?.pid === process.pid && current.token === token)
220
- rmSync(path, { force: true });
221
- } };
222
- };
223
- const lease = create();
224
- if (lease)
225
- return lease;
226
- const current = readWorkerLease(path);
227
- if (!current)
228
- return create();
229
- if (processAlive(current.pid))
230
- return undefined;
231
- // Only reclamation needs the existing queue writer gate; exclusive creation protects fresh claims.
232
- const gate = `${resolve(queuePath)}.lock`;
233
- assertNoSymlinkAncestors(dirname(gate));
234
- let descriptor;
235
- try {
236
- descriptor = openSync(gate, "wx", 0o600);
237
- }
238
- catch (error) {
239
- if (error.code === "EEXIST")
240
- return undefined;
241
- throw error;
242
- }
243
- try {
244
- const owner = readWorkerLease(path);
245
- if (owner && processAlive(owner.pid))
246
- return undefined;
247
- if (owner)
248
- rmSync(path, { force: true });
249
- return create(); // A fresh claimant may win the gap; never remove its replacement.
250
- }
251
- finally {
252
- closeSync(descriptor);
253
- rmSync(gate, { force: true });
254
- }
255
- }
256
- export function loadPublicationQueue(path) {
257
- const target = resolve(path);
258
- assertNoSymlinkAncestors(dirname(target));
259
- const stat = lstatSync(target, { throwIfNoEntry: false });
260
- if (!stat)
261
- return undefined;
262
- if (!stat.isFile())
263
- throw new Error("Publication queue path must be a regular file");
264
- return parsePublicationQueue(readFileSync(target, "utf8"));
265
- }
266
- export function removePublicationQueue(path, expected) {
267
- const target = resolve(path);
268
- assertNoSymlinkAncestors(dirname(target));
269
- const lock = `${target}.lock`;
270
- let lockFd;
271
- try {
272
- lockFd = openSync(lock, "wx", 0o600);
273
- const current = loadPublicationQueue(target);
274
- if (!current || serializePublicationQueue(current) !== serializePublicationQueue(expected)) {
275
- throw new Error("Publication queue compare-and-swap conflict");
276
- }
277
- rmSync(target);
278
- }
279
- finally {
280
- if (lockFd !== undefined) {
281
- closeSync(lockFd);
282
- rmSync(lock, { force: true });
283
- }
284
- }
285
- }
286
- export function savePublicationQueue(path, state, expected) {
287
- const target = resolve(path);
288
- assertNoSymlinkAncestors(dirname(target));
289
- mkdirSync(dirname(target), { recursive: true });
290
- const lock = `${target}.lock`;
291
- let lockFd;
292
- try {
293
- lockFd = openSync(lock, "wx", 0o600);
294
- const existing = lstatSync(target, { throwIfNoEntry: false });
295
- if (existing && !existing.isFile())
296
- throw new Error("Publication queue path must be a regular file");
297
- const previous = existing ? parsePublicationQueue(readFileSync(target, "utf8")) : undefined;
298
- if (expected !== undefined && (previous === undefined
299
- || serializePublicationQueue(previous) !== serializePublicationQueue(expected))) {
300
- throw new Error("Publication queue compare-and-swap conflict");
301
- }
302
- if (expected === undefined && previous !== undefined)
303
- throw new Error("Publication queue compare-and-swap requires the current receipt");
304
- const temporary = `${target}.tmp-${process.pid}-${Date.now()}`;
305
- try {
306
- writeFileSync(temporary, serializePublicationQueue(state), { flag: "wx", mode: 0o600 });
307
- renameSync(temporary, target);
308
- }
309
- finally {
310
- rmSync(temporary, { force: true });
311
- }
312
- return { ...(previous === undefined ? {} : { previous }), current: structuredClone(state) };
313
- }
314
- finally {
315
- if (lockFd !== undefined) {
316
- closeSync(lockFd);
317
- rmSync(lock, { force: true });
318
- }
319
- }
320
- }
321
- /** Execute one immutable target attempt; persistence/CAS remains the caller's responsibility. */
322
- export async function runPublicationWorker(state, push, current, isAncestor) {
323
- const attempted = beginPublicationAttempt(state);
324
- try {
325
- await push(Object.freeze(structuredClone(attempted)));
326
- const next = confirmPublicationTarget(current(), attempted.target, isAncestor);
327
- return { attempted, ...(next === undefined ? {} : { next }) };
328
- }
329
- catch (error) {
330
- return {
331
- attempted,
332
- next: failPublicationAttempt(attempted, error instanceof Error ? error.message : String(error)),
333
- };
334
- }
335
- }
336
- /** Own durable queue coalescing, worker leases, retries, generation fencing, and bounded shutdown. */
337
- export class PublicationWorkerController {
338
- active = new Map();
339
- ports;
340
- stopped = false;
341
- shutdownPromise;
342
- constructor(ports) {
343
- this.ports = ports;
344
- }
345
- enqueue(target) {
346
- const destination = this.ports.resolveDestination();
347
- if (!destination)
348
- return;
349
- const path = publicationQueuePath(destination);
350
- const previous = loadPublicationQueue(path);
351
- const next = previous
352
- ? coalescePublicationTarget(previous, destination, target, this.ports.isAncestor, {
353
- onDivergedLineage: (dropped) => this.ports.onDiverged(dropped, target),
354
- })
355
- : createPublicationQueue(destination, target);
356
- savePublicationQueue(path, next, previous);
357
- }
358
- launch() {
359
- if (this.stopped)
360
- return;
361
- let destination;
362
- try {
363
- destination = this.ports.resolveDestination();
364
- }
365
- catch (error) {
366
- if (error instanceof Error && /ENOENT/.test(error.message))
367
- return;
368
- throw error;
369
- }
370
- if (!destination)
371
- return;
372
- const path = publicationQueuePath(destination);
373
- if (this.active.has(path))
374
- return;
375
- let queued;
376
- let lease;
377
- try {
378
- queued = loadPublicationQueue(path);
379
- if (!queued)
380
- return;
381
- lease = acquirePublicationWorkerLease(path);
382
- }
383
- catch {
384
- return;
385
- }
386
- if (!lease)
387
- return;
388
- const controller = new AbortController();
389
- const done = runPublicationWorker(queued, ({ target }) => this.ports.push(destination, target, controller.signal), () => loadPublicationQueue(path) ?? queued, this.ports.isAncestor).then((result) => {
390
- if (this.stopped)
391
- return;
392
- const current = loadPublicationQueue(path);
393
- if (!current)
394
- return;
395
- if (result.next === undefined) {
396
- if (current.target === result.attempted.target)
397
- removePublicationQueue(path, current);
398
- return;
399
- }
400
- if (current.target === result.attempted.target || result.next.target === current.target)
401
- savePublicationQueue(path, result.next, current);
402
- }).catch(() => {
403
- // Queue/CAS truth remains durable; status and a later activation expose retry.
404
- }).finally(() => {
405
- this.active.delete(path);
406
- try {
407
- lease.release();
408
- if (!this.stopped && loadPublicationQueue(path)?.status === "pending")
409
- this.launch();
410
- }
411
- catch {
412
- // Failed lease cleanup or malformed persistence stays inert until retry or repair.
413
- }
414
- });
415
- this.active.set(path, { controller, done });
416
- }
417
- shutdown(waitMs) {
418
- this.stopped = true;
419
- return this.shutdownPromise ??= (async () => {
420
- const workers = [...this.active.values()];
421
- for (const { controller } of workers)
422
- controller.abort();
423
- if (workers.length === 0)
424
- return true;
425
- let timeout;
426
- try {
427
- return await Promise.race([
428
- Promise.all(workers.map(({ done }) => done)).then(() => true),
429
- new Promise((resolve) => { timeout = setTimeout(() => resolve(false), waitMs); }),
430
- ]);
431
- }
432
- finally {
433
- clearTimeout(timeout);
434
- }
435
- })();
436
- }
437
- }
@@ -1,133 +0,0 @@
1
- import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { hashArtifactSource, type ArtifactSourceIdentity } from "./artifact.ts";
5
-
6
- /** Opaque source metadata discovered without decoding or retaining Markdown bodies. */
7
- export interface ArtifactSourceCandidate extends ArtifactSourceIdentity {
8
- /** Raw byte count used as a conservative maintenance token-budget ceiling. */
9
- bytes: number;
10
- }
11
-
12
- export interface GlobalMarkdownDiscoveryResult {
13
- sources: ArtifactSourceCandidate[];
14
- removed: string[];
15
- unavailable?: string;
16
- }
17
-
18
- export function getKnowledgeRoot(agentDir = getAgentDir()): string {
19
- return resolve(agentDir, "knowledge");
20
- }
21
-
22
- function errorCode(error: unknown): string | undefined {
23
- return error instanceof Error && "code" in error
24
- ? (error as NodeJS.ErrnoException).code
25
- : undefined;
26
- }
27
-
28
- function isMissing(error: unknown): boolean {
29
- return errorCode(error) === "ENOENT";
30
- }
31
-
32
- function isInside(root: string, path: string): boolean {
33
- const child = relative(root, path);
34
- return child === "" || (!isAbsolute(child) && child !== ".." && !child.startsWith(`..${sep}`));
35
- }
36
-
37
- function canonicalExistingPath(path: string): string | undefined {
38
- try {
39
- return realpathSync.native(path);
40
- } catch (error) {
41
- if (isMissing(error)) return undefined;
42
- throw error;
43
- }
44
- }
45
-
46
- function readRegularFile(path: string): Buffer | undefined {
47
- let descriptor: number | undefined;
48
- try {
49
- descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
50
- if (!fstatSync(descriptor).isFile()) return undefined;
51
- return readFileSync(descriptor);
52
- } catch (error) {
53
- if (isMissing(error) || errorCode(error) === "ELOOP") return undefined;
54
- throw error;
55
- } finally {
56
- if (descriptor !== undefined) closeSync(descriptor);
57
- }
58
- }
59
-
60
- /** Discover regular `*.md` files beneath a Knowledge root without following directory or file symlinks. */
61
- export function discoverGlobalMarkdownSources(knowledgeRoot = getKnowledgeRoot()): ArtifactSourceCandidate[] {
62
- const configuredRoot = resolve(knowledgeRoot);
63
- const root = canonicalExistingPath(configuredRoot);
64
- if (root === undefined) return [];
65
- if (!statSync(root).isDirectory()) throw new Error(`Knowledge root is not a directory: ${configuredRoot}`);
66
-
67
- const sources: ArtifactSourceCandidate[] = [];
68
- const visit = (directory: string): void => {
69
- const entries = readdirSync(directory, { withFileTypes: true })
70
- .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
71
- for (const entry of entries) {
72
- const candidate = join(directory, entry.name);
73
- let metadata;
74
- try {
75
- metadata = lstatSync(candidate);
76
- } catch (error) {
77
- if (isMissing(error)) continue;
78
- throw error;
79
- }
80
- if (metadata.isSymbolicLink()) continue;
81
-
82
- const canonical = canonicalExistingPath(candidate);
83
- if (canonical === undefined || !isInside(root, canonical)) continue;
84
- if (metadata.isDirectory()) {
85
- visit(canonical);
86
- continue;
87
- }
88
- if (!metadata.isFile() || !entry.name.endsWith(".md")) continue;
89
-
90
- const body = readRegularFile(canonical);
91
- if (body === undefined) continue;
92
- sources.push({ path: canonical, hash: hashArtifactSource(body), bytes: body.byteLength });
93
- }
94
- };
95
-
96
- visit(root);
97
- return sources.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
98
- }
99
-
100
- /** Prove absence inside the current source root without following excluded symlink components. */
101
- function missingOwnedMarkdown(root: string, path: string): boolean {
102
- if (!isAbsolute(path) || path !== resolve(path) || path === root || !isInside(root, path) || !path.endsWith(".md")) return false;
103
- let candidate = root;
104
- for (const segment of relative(root, path).split(sep)) {
105
- candidate = join(candidate, segment);
106
- const metadata = lstatSync(candidate, { throwIfNoEntry: false });
107
- if (metadata === undefined) return true;
108
- if (metadata.isSymbolicLink() || !metadata.isDirectory()) return false;
109
- }
110
- return false;
111
- }
112
-
113
- /** Source-owner discovery; retained paths let restart/status re-observe removals until publication. */
114
- export class GlobalMarkdownDiscovery {
115
- readonly knowledgeRoot: string;
116
- #knownPaths = new Set<string>();
117
-
118
- constructor(knowledgeRoot = getKnowledgeRoot()) {
119
- this.knowledgeRoot = resolve(knowledgeRoot);
120
- }
121
-
122
- refresh(retainedPaths: Iterable<string> = this.#knownPaths): GlobalMarkdownDiscoveryResult {
123
- const root = canonicalExistingPath(this.knowledgeRoot);
124
- if (root === undefined) return { sources: [], removed: [], unavailable: `Knowledge root is unavailable: ${this.knowledgeRoot}` };
125
- const sources = discoverGlobalMarkdownSources(root);
126
- const currentPaths = new Set(sources.map((source) => source.path));
127
- const removed = [...new Set(retainedPaths)]
128
- .filter((path) => !currentPaths.has(path) && missingOwnedMarkdown(root, path))
129
- .sort();
130
- this.#knownPaths = currentPaths;
131
- return { sources, removed };
132
- }
133
- }