@nifrajs/coding-agent 3.2.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 (211) hide show
  1. package/EXTENSIONS.md +25 -0
  2. package/PERFORMANCE.md +19 -0
  3. package/README.md +69 -0
  4. package/REGISTRY.md +15 -0
  5. package/SECURITY.md +32 -0
  6. package/dist/approvals.d.ts +70 -0
  7. package/dist/approvals.d.ts.map +1 -0
  8. package/dist/approvals.js +174 -0
  9. package/dist/approvals.js.map +1 -0
  10. package/dist/capabilities.d.ts +20 -0
  11. package/dist/capabilities.d.ts.map +1 -0
  12. package/dist/capabilities.js +49 -0
  13. package/dist/capabilities.js.map +1 -0
  14. package/dist/cli.d.ts +24 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +380 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/context.d.ts +17 -0
  19. package/dist/context.d.ts.map +1 -0
  20. package/dist/context.js +66 -0
  21. package/dist/context.js.map +1 -0
  22. package/dist/deployment-adapters.d.ts +40 -0
  23. package/dist/deployment-adapters.d.ts.map +1 -0
  24. package/dist/deployment-adapters.js +147 -0
  25. package/dist/deployment-adapters.js.map +1 -0
  26. package/dist/diff.d.ts +17 -0
  27. package/dist/diff.d.ts.map +1 -0
  28. package/dist/diff.js +61 -0
  29. package/dist/diff.js.map +1 -0
  30. package/dist/errors.d.ts +9 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +35 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/extensions.d.ts +104 -0
  35. package/dist/extensions.d.ts.map +1 -0
  36. package/dist/extensions.js +284 -0
  37. package/dist/extensions.js.map +1 -0
  38. package/dist/handoffs.d.ts +97 -0
  39. package/dist/handoffs.d.ts.map +1 -0
  40. package/dist/handoffs.js +199 -0
  41. package/dist/handoffs.js.map +1 -0
  42. package/dist/healing.d.ts +56 -0
  43. package/dist/healing.d.ts.map +1 -0
  44. package/dist/healing.js +54 -0
  45. package/dist/healing.js.map +1 -0
  46. package/dist/host.d.ts +61 -0
  47. package/dist/host.d.ts.map +1 -0
  48. package/dist/host.js +369 -0
  49. package/dist/host.js.map +1 -0
  50. package/dist/index.d.ts +26 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +26 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/isolated-worker.d.ts +2 -0
  55. package/dist/isolated-worker.d.ts.map +1 -0
  56. package/dist/isolated-worker.js +155 -0
  57. package/dist/isolated-worker.js.map +1 -0
  58. package/dist/isolated.d.ts +52 -0
  59. package/dist/isolated.d.ts.map +1 -0
  60. package/dist/isolated.js +293 -0
  61. package/dist/isolated.js.map +1 -0
  62. package/dist/native.d.ts +92 -0
  63. package/dist/native.d.ts.map +1 -0
  64. package/dist/native.js +339 -0
  65. package/dist/native.js.map +1 -0
  66. package/dist/orchestration/artifact-port.d.ts +30 -0
  67. package/dist/orchestration/artifact-port.d.ts.map +1 -0
  68. package/dist/orchestration/artifact-port.js +59 -0
  69. package/dist/orchestration/artifact-port.js.map +1 -0
  70. package/dist/orchestration/catalog.d.ts +71 -0
  71. package/dist/orchestration/catalog.d.ts.map +1 -0
  72. package/dist/orchestration/catalog.js +46 -0
  73. package/dist/orchestration/catalog.js.map +1 -0
  74. package/dist/orchestration/compile.d.ts +59 -0
  75. package/dist/orchestration/compile.d.ts.map +1 -0
  76. package/dist/orchestration/compile.js +268 -0
  77. package/dist/orchestration/compile.js.map +1 -0
  78. package/dist/orchestration/dispatch.d.ts +115 -0
  79. package/dist/orchestration/dispatch.d.ts.map +1 -0
  80. package/dist/orchestration/dispatch.js +220 -0
  81. package/dist/orchestration/dispatch.js.map +1 -0
  82. package/dist/orchestration/durable-jobs.d.ts +35 -0
  83. package/dist/orchestration/durable-jobs.d.ts.map +1 -0
  84. package/dist/orchestration/durable-jobs.js +273 -0
  85. package/dist/orchestration/durable-jobs.js.map +1 -0
  86. package/dist/orchestration/effect-key.d.ts +23 -0
  87. package/dist/orchestration/effect-key.d.ts.map +1 -0
  88. package/dist/orchestration/effect-key.js +24 -0
  89. package/dist/orchestration/effect-key.js.map +1 -0
  90. package/dist/orchestration/evidence-store.d.ts +75 -0
  91. package/dist/orchestration/evidence-store.d.ts.map +1 -0
  92. package/dist/orchestration/evidence-store.js +183 -0
  93. package/dist/orchestration/evidence-store.js.map +1 -0
  94. package/dist/orchestration/hash.d.ts +12 -0
  95. package/dist/orchestration/hash.d.ts.map +1 -0
  96. package/dist/orchestration/hash.js +43 -0
  97. package/dist/orchestration/hash.js.map +1 -0
  98. package/dist/orchestration/host.d.ts +121 -0
  99. package/dist/orchestration/host.d.ts.map +1 -0
  100. package/dist/orchestration/host.js +334 -0
  101. package/dist/orchestration/host.js.map +1 -0
  102. package/dist/orchestration/index.d.ts +20 -0
  103. package/dist/orchestration/index.d.ts.map +1 -0
  104. package/dist/orchestration/index.js +19 -0
  105. package/dist/orchestration/index.js.map +1 -0
  106. package/dist/orchestration/policy.d.ts +63 -0
  107. package/dist/orchestration/policy.d.ts.map +1 -0
  108. package/dist/orchestration/policy.js +80 -0
  109. package/dist/orchestration/policy.js.map +1 -0
  110. package/dist/orchestration/recovery.d.ts +87 -0
  111. package/dist/orchestration/recovery.d.ts.map +1 -0
  112. package/dist/orchestration/recovery.js +197 -0
  113. package/dist/orchestration/recovery.js.map +1 -0
  114. package/dist/orchestration/tracer.d.ts +31 -0
  115. package/dist/orchestration/tracer.d.ts.map +1 -0
  116. package/dist/orchestration/tracer.js +141 -0
  117. package/dist/orchestration/tracer.js.map +1 -0
  118. package/dist/plans.d.ts +35 -0
  119. package/dist/plans.d.ts.map +1 -0
  120. package/dist/plans.js +63 -0
  121. package/dist/plans.js.map +1 -0
  122. package/dist/presets.d.ts +10 -0
  123. package/dist/presets.d.ts.map +1 -0
  124. package/dist/presets.js +70 -0
  125. package/dist/presets.js.map +1 -0
  126. package/dist/process.d.ts +6 -0
  127. package/dist/process.d.ts.map +1 -0
  128. package/dist/process.js +42 -0
  129. package/dist/process.js.map +1 -0
  130. package/dist/registry.d.ts +37 -0
  131. package/dist/registry.d.ts.map +1 -0
  132. package/dist/registry.js +51 -0
  133. package/dist/registry.js.map +1 -0
  134. package/dist/replay.d.ts +24 -0
  135. package/dist/replay.d.ts.map +1 -0
  136. package/dist/replay.js +154 -0
  137. package/dist/replay.js.map +1 -0
  138. package/dist/rpc.d.ts +51 -0
  139. package/dist/rpc.d.ts.map +1 -0
  140. package/dist/rpc.js +412 -0
  141. package/dist/rpc.js.map +1 -0
  142. package/dist/session-migration.d.ts +59 -0
  143. package/dist/session-migration.d.ts.map +1 -0
  144. package/dist/session-migration.js +315 -0
  145. package/dist/session-migration.js.map +1 -0
  146. package/dist/sessions.d.ts +74 -0
  147. package/dist/sessions.d.ts.map +1 -0
  148. package/dist/sessions.js +222 -0
  149. package/dist/sessions.js.map +1 -0
  150. package/dist/subagents.d.ts +51 -0
  151. package/dist/subagents.d.ts.map +1 -0
  152. package/dist/subagents.js +141 -0
  153. package/dist/subagents.js.map +1 -0
  154. package/dist/tools.d.ts +13 -0
  155. package/dist/tools.d.ts.map +1 -0
  156. package/dist/tools.js +37 -0
  157. package/dist/tools.js.map +1 -0
  158. package/dist/ui.d.ts +42 -0
  159. package/dist/ui.d.ts.map +1 -0
  160. package/dist/ui.js +93 -0
  161. package/dist/ui.js.map +1 -0
  162. package/dist/verification.d.ts +34 -0
  163. package/dist/verification.d.ts.map +1 -0
  164. package/dist/verification.js +91 -0
  165. package/dist/verification.js.map +1 -0
  166. package/dist/workflows.d.ts +98 -0
  167. package/dist/workflows.d.ts.map +1 -0
  168. package/dist/workflows.js +174 -0
  169. package/dist/workflows.js.map +1 -0
  170. package/package.json +84 -0
  171. package/src/approvals.ts +249 -0
  172. package/src/capabilities.ts +74 -0
  173. package/src/cli.ts +405 -0
  174. package/src/context.ts +81 -0
  175. package/src/deployment-adapters.ts +181 -0
  176. package/src/diff.ts +80 -0
  177. package/src/errors.ts +46 -0
  178. package/src/extensions.ts +402 -0
  179. package/src/handoffs.ts +299 -0
  180. package/src/healing.ts +93 -0
  181. package/src/host.ts +465 -0
  182. package/src/index.ts +165 -0
  183. package/src/isolated-worker.ts +177 -0
  184. package/src/isolated.ts +344 -0
  185. package/src/native.ts +478 -0
  186. package/src/orchestration/artifact-port.ts +75 -0
  187. package/src/orchestration/catalog.ts +102 -0
  188. package/src/orchestration/compile.ts +373 -0
  189. package/src/orchestration/dispatch.ts +383 -0
  190. package/src/orchestration/durable-jobs.ts +374 -0
  191. package/src/orchestration/effect-key.ts +35 -0
  192. package/src/orchestration/evidence-store.ts +233 -0
  193. package/src/orchestration/hash.ts +43 -0
  194. package/src/orchestration/host.ts +445 -0
  195. package/src/orchestration/index.ts +131 -0
  196. package/src/orchestration/policy.ts +116 -0
  197. package/src/orchestration/recovery.ts +271 -0
  198. package/src/orchestration/tracer.ts +177 -0
  199. package/src/plans.ts +105 -0
  200. package/src/presets.ts +90 -0
  201. package/src/process.ts +47 -0
  202. package/src/registry.ts +80 -0
  203. package/src/replay.ts +190 -0
  204. package/src/rpc.ts +598 -0
  205. package/src/session-migration.ts +401 -0
  206. package/src/sessions.ts +303 -0
  207. package/src/subagents.ts +210 -0
  208. package/src/tools.ts +52 -0
  209. package/src/ui.ts +137 -0
  210. package/src/verification.ts +133 -0
  211. package/src/workflows.ts +279 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Adapter from the dependency-free jobs lease contract to run-node dispatch.
3
+ *
4
+ * The supplied JobStore should be dedicated to this adapter (JobStore has no claim-by-name
5
+ * operation). Its persisted payload is the parsed, content-free RunDispatch identity. The adapter
6
+ * adds lease generations and rejects stale completions in-process; a durable operated implementation
7
+ * must enforce the same compare-and-commit rule in its data layer.
8
+ */
9
+
10
+ import type { JobStore, StoredJob } from "@nifrajs/jobs"
11
+ import { MemoryJobStore } from "@nifrajs/jobs"
12
+ import {
13
+ DISPATCH_JOB_NAME,
14
+ type DispatchInspection,
15
+ type DispatchResultCode,
16
+ type DispatchState,
17
+ type DispatchWriteResult,
18
+ deriveRunIdempotencyKey,
19
+ parseRunCheckpoint,
20
+ parseRunDispatch,
21
+ parseRunDispatchEvidence,
22
+ parseRunLease,
23
+ RUN_DISPATCH_VERSION,
24
+ type RunCheckpoint,
25
+ type RunDispatch,
26
+ type RunDispatchEvidence,
27
+ type RunDispatchStore,
28
+ type RunLease,
29
+ } from "./dispatch.ts"
30
+
31
+ export interface DurableJobsStoreOptions {
32
+ /** A dedicated at-least-once JobStore. Defaults to disposable in-memory storage. */
33
+ readonly store?: JobStore
34
+ /** Injected time source used for lease and cancellation decisions. */
35
+ readonly now?: () => number
36
+ }
37
+
38
+ interface Entry {
39
+ readonly dispatch: RunDispatch
40
+ readonly jobId: string
41
+ state: DispatchState
42
+ attempt: number
43
+ generation: number
44
+ leaseId: string | undefined
45
+ leaseUntil: number
46
+ lease: RunLease | undefined
47
+ checkpoint: RunCheckpoint | undefined
48
+ readonly evidence: RunDispatchEvidence[]
49
+ }
50
+
51
+ function code(value: string): string {
52
+ if (!/^[A-Za-z0-9_.:-]{1,256}$/.test(value))
53
+ throw new TypeError("dispatch code must be a stable token")
54
+ return value
55
+ }
56
+
57
+ function result(codeValue: DispatchResultCode): DispatchWriteResult {
58
+ return { ok: codeValue === "accepted", code: codeValue }
59
+ }
60
+
61
+ function leaseMatches(entry: Entry | undefined, lease: RunLease): entry is Entry {
62
+ return (
63
+ entry !== undefined &&
64
+ entry.leaseId === lease.leaseId &&
65
+ entry.generation === lease.generation &&
66
+ entry.attempt === lease.attempt &&
67
+ entry.dispatch.dispatchId === lease.dispatch.dispatchId &&
68
+ entry.dispatch.planDigest === lease.dispatch.planDigest &&
69
+ entry.dispatch.nodeId === lease.dispatch.nodeId &&
70
+ entry.lease?.idempotencyKey === lease.idempotencyKey
71
+ )
72
+ }
73
+
74
+ function isTerminal(state: DispatchState): boolean {
75
+ return (
76
+ state === "succeeded" ||
77
+ state === "failed" ||
78
+ state === "cancelled" ||
79
+ state === "dead-lettered"
80
+ )
81
+ }
82
+
83
+ function evidenceFor(
84
+ entry: Entry,
85
+ state: DispatchState,
86
+ at: number,
87
+ scheduleToken: string,
88
+ idempotencyKey: string,
89
+ codeValue?: string,
90
+ ): RunDispatchEvidence {
91
+ return parseRunDispatchEvidence({
92
+ version: RUN_DISPATCH_VERSION,
93
+ dispatchId: entry.dispatch.dispatchId,
94
+ runId: entry.dispatch.runId,
95
+ nodeId: entry.dispatch.nodeId,
96
+ state,
97
+ attempt: entry.attempt,
98
+ generation: entry.generation,
99
+ at,
100
+ scheduleToken,
101
+ idempotencyKey,
102
+ ...(codeValue === undefined ? {} : { code: code(codeValue) }),
103
+ })
104
+ }
105
+
106
+ function appendEvidence(entry: Entry, item: RunDispatchEvidence): void {
107
+ entry.evidence.push(parseRunDispatchEvidence(item))
108
+ }
109
+
110
+ function dispatchFromJob(job: StoredJob): RunDispatch {
111
+ if (job.name !== DISPATCH_JOB_NAME) throw new TypeError("jobs adapter received an unrelated job")
112
+ return parseRunDispatch(job.payload)
113
+ }
114
+
115
+ /**
116
+ * Create an at-least-once run dispatch store over an existing JobStore.
117
+ *
118
+ * JobStore's `attempt` increments only when a caller explicitly retries; an expired lease is
119
+ * redelivered at the same logical attempt, which is what makes the idempotency key stable across a
120
+ * duplicate delivery. A retry intentionally advances the logical attempt and gets a new key.
121
+ */
122
+ export function createDurableJobsStore(options: DurableJobsStoreOptions = {}): RunDispatchStore & {
123
+ readonly jobs: JobStore
124
+ deadLetters(): readonly RunDispatchEvidence[]
125
+ } {
126
+ const jobs = options.store ?? new MemoryJobStore()
127
+ const now = options.now ?? (() => Date.now())
128
+ const byJobId = new Map<string, Entry>()
129
+ const byDispatchId = new Map<string, Entry>()
130
+ let generation = 0
131
+ const dead: RunDispatchEvidence[] = []
132
+
133
+ async function enqueue(source: RunDispatch): Promise<void> {
134
+ const dispatch = parseRunDispatch(source)
135
+ if (byDispatchId.has(dispatch.dispatchId)) throw new Error("duplicate dispatch id")
136
+ const jobId = await jobs.enqueue({
137
+ name: DISPATCH_JOB_NAME,
138
+ payload: dispatch,
139
+ runAt: dispatch.notBefore,
140
+ maxAttempts: dispatch.maxAttempts,
141
+ })
142
+ const entry: Entry = {
143
+ dispatch,
144
+ jobId,
145
+ state: "queued",
146
+ attempt: 1,
147
+ generation: 0,
148
+ leaseId: undefined,
149
+ leaseUntil: 0,
150
+ lease: undefined,
151
+ checkpoint: undefined,
152
+ evidence: [],
153
+ }
154
+ byJobId.set(jobId, entry)
155
+ byDispatchId.set(dispatch.dispatchId, entry)
156
+ }
157
+
158
+ async function lease(at: number, limit: number, leaseMs: number): Promise<RunLease[]> {
159
+ if (!Number.isSafeInteger(at) || at < 0) throw new RangeError("dispatch lease time is invalid")
160
+ if (!Number.isSafeInteger(limit) || limit < 1)
161
+ throw new RangeError("dispatch lease limit is invalid")
162
+ if (!Number.isFinite(leaseMs) || leaseMs <= 0)
163
+ throw new RangeError("dispatch lease duration is invalid")
164
+ const jobsLeased = await jobs.lease(at, limit, leaseMs)
165
+ const resultLeases: RunLease[] = []
166
+ for (const job of jobsLeased) {
167
+ const dispatch = dispatchFromJob(job)
168
+ const entry = byJobId.get(job.id) ?? byDispatchId.get(dispatch.dispatchId)
169
+ if (entry === undefined) throw new Error("jobs adapter lost dispatch identity")
170
+ if (isTerminal(entry.state)) continue
171
+ entry.state = "leased"
172
+ entry.attempt = job.attempt + 1
173
+ entry.generation = ++generation
174
+ entry.leaseUntil = at + leaseMs
175
+ entry.leaseId = `${job.id}:${entry.generation}`
176
+ const idempotencyKey = await deriveRunIdempotencyKey(
177
+ dispatch.planDigest,
178
+ dispatch.runId,
179
+ dispatch.nodeId,
180
+ entry.attempt,
181
+ )
182
+ const value = parseRunLease({
183
+ version: RUN_DISPATCH_VERSION,
184
+ leaseId: entry.leaseId,
185
+ dispatch,
186
+ attempt: entry.attempt,
187
+ generation: entry.generation,
188
+ leasedAt: at,
189
+ leaseUntil: entry.leaseUntil,
190
+ scheduleToken: `${job.id}:${entry.generation}`,
191
+ idempotencyKey,
192
+ })
193
+ entry.lease = value
194
+ appendEvidence(
195
+ entry,
196
+ evidenceFor(entry, "leased", at, value.scheduleToken, value.idempotencyKey),
197
+ )
198
+ resultLeases.push(value)
199
+ }
200
+ return resultLeases
201
+ }
202
+
203
+ function current(lease: RunLease): Entry | undefined {
204
+ return byDispatchId.get(lease.dispatch.dispatchId)
205
+ }
206
+
207
+ async function checkpoint(lease: RunLease, source: RunCheckpoint): Promise<DispatchWriteResult> {
208
+ const value = parseRunCheckpoint(source)
209
+ const entry = current(lease)
210
+ if (!leaseMatches(entry, lease)) return result("stale_lease")
211
+ if (entry.state === "cancelled") return result("cancelled")
212
+ if (isTerminal(entry.state)) return result("terminal")
213
+ if (now() >= entry.leaseUntil) return result("stale_lease")
214
+ if (
215
+ value.dispatchId !== lease.dispatch.dispatchId ||
216
+ value.runId !== lease.dispatch.runId ||
217
+ value.nodeId !== lease.dispatch.nodeId ||
218
+ value.attempt !== lease.attempt ||
219
+ value.generation !== lease.generation ||
220
+ value.idempotencyKey !== lease.idempotencyKey
221
+ )
222
+ return result("invalid_checkpoint")
223
+ entry.checkpoint = value
224
+ entry.state = "checkpointed"
225
+ appendEvidence(
226
+ entry,
227
+ evidenceFor(entry, "checkpointed", value.at, value.scheduleToken, value.idempotencyKey),
228
+ )
229
+ return result("accepted")
230
+ }
231
+
232
+ async function complete(
233
+ lease: RunLease,
234
+ source?: RunDispatchEvidence,
235
+ ): Promise<DispatchWriteResult> {
236
+ const entry = current(lease)
237
+ if (!leaseMatches(entry, lease)) return result("stale_lease")
238
+ if (entry.state === "cancelled") return result("cancelled")
239
+ if (isTerminal(entry.state)) return result("terminal")
240
+ if (now() >= entry.leaseUntil) return result("stale_lease")
241
+ if (source !== undefined) {
242
+ const evidence = parseRunDispatchEvidence(source)
243
+ if (
244
+ evidence.dispatchId !== lease.dispatch.dispatchId ||
245
+ evidence.attempt !== lease.attempt ||
246
+ evidence.generation !== lease.generation ||
247
+ evidence.idempotencyKey !== lease.idempotencyKey
248
+ )
249
+ return result("invalid_checkpoint")
250
+ }
251
+ await jobs.complete(entry.jobId)
252
+ entry.state = "succeeded"
253
+ appendEvidence(
254
+ entry,
255
+ source ?? evidenceFor(entry, "succeeded", now(), lease.scheduleToken, lease.idempotencyKey),
256
+ )
257
+ entry.lease = undefined
258
+ return result("accepted")
259
+ }
260
+
261
+ async function retry(
262
+ lease: RunLease,
263
+ runAt: number,
264
+ failureCode: string,
265
+ ): Promise<DispatchWriteResult> {
266
+ const entry = current(lease)
267
+ if (!leaseMatches(entry, lease)) return result("stale_lease")
268
+ if (entry.state === "cancelled") return result("cancelled")
269
+ if (isTerminal(entry.state)) return result("terminal")
270
+ if (now() >= entry.leaseUntil) return result("stale_lease")
271
+ if (!Number.isSafeInteger(runAt) || runAt < 0)
272
+ throw new RangeError("dispatch retry time is invalid")
273
+ await jobs.retry(entry.jobId, runAt)
274
+ entry.state = "retrying"
275
+ entry.checkpoint = undefined
276
+ appendEvidence(
277
+ entry,
278
+ evidenceFor(entry, "retrying", now(), lease.scheduleToken, lease.idempotencyKey, failureCode),
279
+ )
280
+ entry.lease = undefined
281
+ return result("accepted")
282
+ }
283
+
284
+ async function deadLetter(lease: RunLease, failureCode: string): Promise<DispatchWriteResult> {
285
+ const entry = current(lease)
286
+ if (!leaseMatches(entry, lease)) return result("stale_lease")
287
+ if (entry.state === "cancelled") return result("cancelled")
288
+ if (isTerminal(entry.state)) return result("terminal")
289
+ if (now() >= entry.leaseUntil) return result("stale_lease")
290
+ const stableCode = code(failureCode)
291
+ await jobs.deadLetter(entry.jobId, stableCode)
292
+ entry.state = "dead-lettered"
293
+ const evidence = evidenceFor(
294
+ entry,
295
+ "dead-lettered",
296
+ now(),
297
+ lease.scheduleToken,
298
+ lease.idempotencyKey,
299
+ stableCode,
300
+ )
301
+ appendEvidence(entry, evidence)
302
+ dead.push(evidence)
303
+ entry.lease = undefined
304
+ return result("accepted")
305
+ }
306
+
307
+ async function cancel(runId: string, cancellationCode = "cancelled"): Promise<number> {
308
+ const stableCode = code(cancellationCode)
309
+ let changed = 0
310
+ for (const entry of byDispatchId.values()) {
311
+ if (entry.dispatch.runId !== runId || isTerminal(entry.state)) continue
312
+ entry.state = "cancelled"
313
+ changed++
314
+ const lease = entry.lease
315
+ appendEvidence(
316
+ entry,
317
+ evidenceFor(
318
+ entry,
319
+ "cancelled",
320
+ now(),
321
+ lease?.scheduleToken ?? `${entry.jobId}:cancel`,
322
+ lease?.idempotencyKey ??
323
+ (await deriveRunIdempotencyKey(
324
+ entry.dispatch.planDigest,
325
+ entry.dispatch.runId,
326
+ entry.dispatch.nodeId,
327
+ entry.attempt,
328
+ )),
329
+ stableCode,
330
+ ),
331
+ )
332
+ if (lease !== undefined) await jobs.deadLetter(entry.jobId, stableCode)
333
+ else await jobs.deadLetter(entry.jobId, stableCode)
334
+ entry.lease = undefined
335
+ }
336
+ return changed
337
+ }
338
+
339
+ function inspect(dispatchId: string): DispatchInspection | undefined {
340
+ const entry = byDispatchId.get(dispatchId)
341
+ if (entry === undefined) return undefined
342
+ return Object.freeze({
343
+ dispatch: entry.dispatch,
344
+ state: entry.state,
345
+ attempt: entry.attempt,
346
+ generation: entry.generation,
347
+ ...(entry.checkpoint === undefined ? {} : { checkpoint: entry.checkpoint }),
348
+ evidence: Object.freeze([...entry.evidence]),
349
+ })
350
+ }
351
+
352
+ return {
353
+ jobs,
354
+ enqueue,
355
+ lease,
356
+ checkpoint,
357
+ complete,
358
+ retry,
359
+ deadLetter,
360
+ cancel,
361
+ inspect,
362
+ deadLetters: () => Object.freeze([...dead]),
363
+ }
364
+ }
365
+
366
+ /** Disposable local reference adapter; it makes no durability or exactly-once guarantee. */
367
+ export function createMemoryRunDispatchStore(
368
+ options: { readonly now?: () => number } = {},
369
+ ): RunDispatchStore & {
370
+ readonly jobs: JobStore
371
+ deadLetters(): readonly RunDispatchEvidence[]
372
+ } {
373
+ return createDurableJobsStore({ store: new MemoryJobStore(), ...options })
374
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Content-free idempotency identity for one side-effecting node attempt-boundary.
3
+ *
4
+ * The step author projects only the identity-bearing subset of its input into a byte `selector`
5
+ * (via `CatalogStep.selectEffect`). {@link deriveNodeEffectKey} frames that selector with the
6
+ * non-secret plan/node coordinates and hashes the whole to a digest. The digest is what crosses a
7
+ * boundary or lands in evidence; the selector bytes never leave this function. This mirrors
8
+ * `@nifrajs/core`'s `computeIdempotencyFingerprint`: the hash is the key, the material is consumed.
9
+ */
10
+
11
+ import type { NodeEffectKey } from "@nifrajs/agent-protocol"
12
+ import { sha256Hex } from "./hash.ts"
13
+
14
+ const encoder = new TextEncoder()
15
+
16
+ /** Inputs to the effect-key derivation. `selector` is the step's content-free identity projection. */
17
+ export interface EffectKeyMaterial {
18
+ readonly planDigest: string
19
+ readonly nodeId: string
20
+ /** Identity-bearing bytes chosen by the step. Consumed into the hash, never retained. */
21
+ readonly selector: Uint8Array
22
+ }
23
+
24
+ /**
25
+ * Derive the stable, content-free key for a node attempt. The returned digest is safe to log and to
26
+ * cross a boundary; two attempts with the same identity converge, distinct identities diverge.
27
+ */
28
+ export async function deriveNodeEffectKey(material: EffectKeyMaterial): Promise<NodeEffectKey> {
29
+ const header = encoder.encode(`${material.planDigest}\n${material.nodeId}\n`)
30
+ const framed = new Uint8Array(header.length + material.selector.length)
31
+ framed.set(header, 0)
32
+ framed.set(material.selector, header.length)
33
+ const digest = await sha256Hex(framed)
34
+ return { digest, planDigest: material.planDigest, nodeId: material.nodeId }
35
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Evidence-only run stores. A store keeps a bounded live window of the most recent
3
+ * {@link RunEvidence} records, a set of order-independent aggregates (completed node ids, artifact
4
+ * refs, counters), and an order-independent terminal digest - never the whole stream, so a 100k-event
5
+ * run stays bounded in memory. Every record is re-parsed through {@link parseRunEvidence} on the way
6
+ * in, so a payload-bearing or oversized record cannot be written through either store: the parser is
7
+ * the privacy gate, not the caller's discipline.
8
+ *
9
+ * There is no dependency on `FileSessionStore` or any session machinery: these stores hold run
10
+ * evidence only, and the file store persists exactly the content-free record it parsed.
11
+ */
12
+
13
+ import { appendFile } from "node:fs/promises"
14
+ import type { ArtifactRef, RunEvidence } from "@nifrajs/agent-protocol"
15
+ import { assertEvidenceSize, parseRunEvidence } from "@nifrajs/agent-protocol"
16
+ import { canonicalJson } from "./hash.ts"
17
+
18
+ /** Terminal tally of a run's evidence stream. Counts only; no payloads. */
19
+ export interface EvidenceCounters {
20
+ readonly total: number
21
+ readonly started: number
22
+ readonly completed: number
23
+ readonly failed: number
24
+ }
25
+
26
+ /** A bounded, evidence-only run store. Append is fail-closed; reads are content-free aggregates. */
27
+ export interface EvidenceStore {
28
+ /** Parse, tally, and persist one record. Throws on a payload-bearing or oversized record. */
29
+ append(record: RunEvidence): Promise<void>
30
+ /** Total records appended (not the live-window size). */
31
+ readonly count: number
32
+ /** The most recent records, oldest first, capped at the configured window. */
33
+ live(): readonly RunEvidence[]
34
+ counters(): EvidenceCounters
35
+ /** Distinct node ids that reached `completed`, sorted for determinism. */
36
+ completedNodeIds(): readonly string[]
37
+ /** Every artifact ref referenced by the stream, in append order. */
38
+ artifacts(): readonly ArtifactRef[]
39
+ /** Order-independent SHA-256 of the run's outcome multiset (parallel order cannot change it). */
40
+ digest(): Promise<string>
41
+ }
42
+
43
+ const DEFAULT_MAX_LIVE = 1024
44
+ const encoder = new TextEncoder()
45
+
46
+ async function sha256Bytes(text: string): Promise<Uint8Array> {
47
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(text))
48
+ return new Uint8Array(digest)
49
+ }
50
+
51
+ /**
52
+ * The identity of one record for the terminal digest: node outcome only. `seq`, `durationMs`, and
53
+ * `runId` are excluded so two runs of the same plan (or the same plan under a different parallel
54
+ * schedule) produce an identical digest - the deterministic-eval anchor. Each node emits at most one
55
+ * record per status, so XOR accumulation never cancels a distinct outcome.
56
+ */
57
+ function digestKey(record: RunEvidence): string {
58
+ return canonicalJson({
59
+ planDigest: record.planDigest,
60
+ nodeId: record.nodeId,
61
+ status: record.status,
62
+ idempotent: record.idempotent,
63
+ ...(record.errorCode !== undefined ? { errorCode: record.errorCode } : {}),
64
+ ...(record.effectKey !== undefined ? { effectKey: record.effectKey } : {}),
65
+ ...(record.artifacts !== undefined ? { artifacts: record.artifacts } : {}),
66
+ })
67
+ }
68
+
69
+ /** Shared accumulator: bounded ring window plus order-independent aggregates. No payload retained. */
70
+ class EvidenceAccumulator {
71
+ private readonly max: number
72
+ private readonly buf: RunEvidence[] = []
73
+ private head = 0
74
+ private filled = false
75
+ private readonly acc = new Uint8Array(32)
76
+ private readonly completed = new Set<string>()
77
+ private readonly artifactRefs: ArtifactRef[] = []
78
+ private totalCount = 0
79
+ private startedCount = 0
80
+ private completedCount = 0
81
+ private failedCount = 0
82
+
83
+ constructor(maxLive: number) {
84
+ if (!Number.isSafeInteger(maxLive) || maxLive < 0)
85
+ throw new RangeError("evidence store: maxLive must be a non-negative integer")
86
+ this.max = maxLive
87
+ }
88
+
89
+ /** `record` is already parsed and within the size cap. */
90
+ async add(record: RunEvidence): Promise<void> {
91
+ this.totalCount++
92
+ if (record.status === "started") this.startedCount++
93
+ else if (record.status === "completed") {
94
+ this.completedCount++
95
+ this.completed.add(record.nodeId)
96
+ } else this.failedCount++
97
+ if (record.artifacts !== undefined)
98
+ for (const ref of record.artifacts) this.artifactRefs.push(ref)
99
+ const hash = await sha256Bytes(digestKey(record))
100
+ for (let i = 0; i < 32; i++) this.acc[i] = (this.acc[i] as number) ^ (hash[i] as number)
101
+ this.pushLive(record)
102
+ }
103
+
104
+ private pushLive(record: RunEvidence): void {
105
+ if (this.max === 0) return
106
+ this.buf[this.head] = record
107
+ this.head = (this.head + 1) % this.max
108
+ if (this.head === 0) this.filled = true
109
+ }
110
+
111
+ get count(): number {
112
+ return this.totalCount
113
+ }
114
+
115
+ live(): readonly RunEvidence[] {
116
+ if (!this.filled) return this.buf.slice(0, this.head)
117
+ return [...this.buf.slice(this.head), ...this.buf.slice(0, this.head)]
118
+ }
119
+
120
+ counters(): EvidenceCounters {
121
+ return {
122
+ total: this.totalCount,
123
+ started: this.startedCount,
124
+ completed: this.completedCount,
125
+ failed: this.failedCount,
126
+ }
127
+ }
128
+
129
+ completedNodeIds(): readonly string[] {
130
+ return [...this.completed].sort()
131
+ }
132
+
133
+ artifacts(): readonly ArtifactRef[] {
134
+ return [...this.artifactRefs]
135
+ }
136
+
137
+ digest(): string {
138
+ let hex = ""
139
+ for (let i = 0; i < 32; i++) hex += (this.acc[i] as number).toString(16).padStart(2, "0")
140
+ return hex
141
+ }
142
+ }
143
+
144
+ /** Normalize on the way in: re-parse (content-free gate) then re-check the size cap. */
145
+ function normalize(record: RunEvidence): RunEvidence {
146
+ const parsed = parseRunEvidence(record)
147
+ assertEvidenceSize(parsed)
148
+ return parsed
149
+ }
150
+
151
+ export interface MemoryEvidenceStoreOptions {
152
+ /** Records retained in the live window. Older records fall out; aggregates still count them. */
153
+ readonly maxLive?: number
154
+ }
155
+
156
+ /** A fully in-memory bounded store. Keeps a window plus aggregates; never the whole stream. */
157
+ export class MemoryEvidenceStore implements EvidenceStore {
158
+ private readonly accumulator: EvidenceAccumulator
159
+
160
+ constructor(options: MemoryEvidenceStoreOptions = {}) {
161
+ this.accumulator = new EvidenceAccumulator(options.maxLive ?? DEFAULT_MAX_LIVE)
162
+ }
163
+
164
+ async append(record: RunEvidence): Promise<void> {
165
+ await this.accumulator.add(normalize(record))
166
+ }
167
+
168
+ get count(): number {
169
+ return this.accumulator.count
170
+ }
171
+ live(): readonly RunEvidence[] {
172
+ return this.accumulator.live()
173
+ }
174
+ counters(): EvidenceCounters {
175
+ return this.accumulator.counters()
176
+ }
177
+ completedNodeIds(): readonly string[] {
178
+ return this.accumulator.completedNodeIds()
179
+ }
180
+ artifacts(): readonly ArtifactRef[] {
181
+ return this.accumulator.artifacts()
182
+ }
183
+ async digest(): Promise<string> {
184
+ return this.accumulator.digest()
185
+ }
186
+ }
187
+
188
+ export interface FileEvidenceStoreOptions {
189
+ /** Append-only newline-delimited file. One canonical JSON record per line, in append order. */
190
+ readonly path: string
191
+ readonly maxLive?: number
192
+ }
193
+
194
+ /**
195
+ * A bounded store that also appends each parsed record as one canonical-JSON line to a file, in
196
+ * deterministic append order. The record is normalized (and so a forbidden-content record is
197
+ * rejected) BEFORE the line is written, so the file never receives a payload. The append uses the
198
+ * `a` flag, so each record is one O_APPEND write.
199
+ */
200
+ export class FileEvidenceStore implements EvidenceStore {
201
+ private readonly accumulator: EvidenceAccumulator
202
+ private readonly path: string
203
+
204
+ constructor(options: FileEvidenceStoreOptions) {
205
+ this.path = options.path
206
+ this.accumulator = new EvidenceAccumulator(options.maxLive ?? DEFAULT_MAX_LIVE)
207
+ }
208
+
209
+ async append(record: RunEvidence): Promise<void> {
210
+ const parsed = normalize(record)
211
+ await this.accumulator.add(parsed)
212
+ await appendFile(this.path, `${canonicalJson(parsed)}\n`, { flag: "a" })
213
+ }
214
+
215
+ get count(): number {
216
+ return this.accumulator.count
217
+ }
218
+ live(): readonly RunEvidence[] {
219
+ return this.accumulator.live()
220
+ }
221
+ counters(): EvidenceCounters {
222
+ return this.accumulator.counters()
223
+ }
224
+ completedNodeIds(): readonly string[] {
225
+ return this.accumulator.completedNodeIds()
226
+ }
227
+ artifacts(): readonly ArtifactRef[] {
228
+ return this.accumulator.artifacts()
229
+ }
230
+ async digest(): Promise<string> {
231
+ return this.accumulator.digest()
232
+ }
233
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Content-addressing helpers shared by the orchestration module: a SHA-256 hex digest over bytes
3
+ * and a canonical JSON encoding with sorted object keys so a digest is stable across key order.
4
+ * Mirrors `@nifrajs/core`'s idempotency fingerprint (the content-free key precedent).
5
+ */
6
+
7
+ const encoder = new TextEncoder()
8
+
9
+ /** SHA-256 of `bytes` as lowercase hex. Collision-resistant so a digest cannot be forged from content. */
10
+ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
11
+ // Copy into a fresh ArrayBuffer-backed view so the digest input is a concrete BufferSource
12
+ // regardless of the caller's backing buffer (SharedArrayBuffer-typed views are rejected).
13
+ const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes))
14
+ const view = new Uint8Array(digest)
15
+ let hex = ""
16
+ for (let i = 0; i < view.length; i++) hex += (view[i] as number).toString(16).padStart(2, "0")
17
+ return hex
18
+ }
19
+
20
+ /** SHA-256 hex of a UTF-8 string. */
21
+ export function sha256HexOf(text: string): Promise<string> {
22
+ return sha256Hex(encoder.encode(text))
23
+ }
24
+
25
+ /** Deterministic JSON with recursively sorted object keys. Arrays keep order. Rejects non-finite numbers. */
26
+ export function canonicalJson(value: unknown): string {
27
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
28
+ return JSON.stringify(value)
29
+ }
30
+ if (typeof value === "number") {
31
+ if (!Number.isFinite(value)) throw new TypeError("non-finite JSON number")
32
+ return JSON.stringify(value)
33
+ }
34
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`
35
+ if (typeof value === "object") {
36
+ const record = value as Record<string, unknown>
37
+ const keys = Object.keys(record)
38
+ .filter((key) => record[key] !== undefined)
39
+ .sort()
40
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`
41
+ }
42
+ throw new TypeError(`cannot canonicalize ${typeof value}`)
43
+ }