@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.
- package/EXTENSIONS.md +25 -0
- package/PERFORMANCE.md +19 -0
- package/README.md +69 -0
- package/REGISTRY.md +15 -0
- package/SECURITY.md +32 -0
- package/dist/approvals.d.ts +70 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +174 -0
- package/dist/approvals.js.map +1 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +49 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +380 -0
- package/dist/cli.js.map +1 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +66 -0
- package/dist/context.js.map +1 -0
- package/dist/deployment-adapters.d.ts +40 -0
- package/dist/deployment-adapters.d.ts.map +1 -0
- package/dist/deployment-adapters.js +147 -0
- package/dist/deployment-adapters.js.map +1 -0
- package/dist/diff.d.ts +17 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +61 -0
- package/dist/diff.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/extensions.d.ts +104 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +284 -0
- package/dist/extensions.js.map +1 -0
- package/dist/handoffs.d.ts +97 -0
- package/dist/handoffs.d.ts.map +1 -0
- package/dist/handoffs.js +199 -0
- package/dist/handoffs.js.map +1 -0
- package/dist/healing.d.ts +56 -0
- package/dist/healing.d.ts.map +1 -0
- package/dist/healing.js +54 -0
- package/dist/healing.js.map +1 -0
- package/dist/host.d.ts +61 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +369 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/isolated-worker.d.ts +2 -0
- package/dist/isolated-worker.d.ts.map +1 -0
- package/dist/isolated-worker.js +155 -0
- package/dist/isolated-worker.js.map +1 -0
- package/dist/isolated.d.ts +52 -0
- package/dist/isolated.d.ts.map +1 -0
- package/dist/isolated.js +293 -0
- package/dist/isolated.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +339 -0
- package/dist/native.js.map +1 -0
- package/dist/orchestration/artifact-port.d.ts +30 -0
- package/dist/orchestration/artifact-port.d.ts.map +1 -0
- package/dist/orchestration/artifact-port.js +59 -0
- package/dist/orchestration/artifact-port.js.map +1 -0
- package/dist/orchestration/catalog.d.ts +71 -0
- package/dist/orchestration/catalog.d.ts.map +1 -0
- package/dist/orchestration/catalog.js +46 -0
- package/dist/orchestration/catalog.js.map +1 -0
- package/dist/orchestration/compile.d.ts +59 -0
- package/dist/orchestration/compile.d.ts.map +1 -0
- package/dist/orchestration/compile.js +268 -0
- package/dist/orchestration/compile.js.map +1 -0
- package/dist/orchestration/dispatch.d.ts +115 -0
- package/dist/orchestration/dispatch.d.ts.map +1 -0
- package/dist/orchestration/dispatch.js +220 -0
- package/dist/orchestration/dispatch.js.map +1 -0
- package/dist/orchestration/durable-jobs.d.ts +35 -0
- package/dist/orchestration/durable-jobs.d.ts.map +1 -0
- package/dist/orchestration/durable-jobs.js +273 -0
- package/dist/orchestration/durable-jobs.js.map +1 -0
- package/dist/orchestration/effect-key.d.ts +23 -0
- package/dist/orchestration/effect-key.d.ts.map +1 -0
- package/dist/orchestration/effect-key.js +24 -0
- package/dist/orchestration/effect-key.js.map +1 -0
- package/dist/orchestration/evidence-store.d.ts +75 -0
- package/dist/orchestration/evidence-store.d.ts.map +1 -0
- package/dist/orchestration/evidence-store.js +183 -0
- package/dist/orchestration/evidence-store.js.map +1 -0
- package/dist/orchestration/hash.d.ts +12 -0
- package/dist/orchestration/hash.d.ts.map +1 -0
- package/dist/orchestration/hash.js +43 -0
- package/dist/orchestration/hash.js.map +1 -0
- package/dist/orchestration/host.d.ts +121 -0
- package/dist/orchestration/host.d.ts.map +1 -0
- package/dist/orchestration/host.js +334 -0
- package/dist/orchestration/host.js.map +1 -0
- package/dist/orchestration/index.d.ts +20 -0
- package/dist/orchestration/index.d.ts.map +1 -0
- package/dist/orchestration/index.js +19 -0
- package/dist/orchestration/index.js.map +1 -0
- package/dist/orchestration/policy.d.ts +63 -0
- package/dist/orchestration/policy.d.ts.map +1 -0
- package/dist/orchestration/policy.js +80 -0
- package/dist/orchestration/policy.js.map +1 -0
- package/dist/orchestration/recovery.d.ts +87 -0
- package/dist/orchestration/recovery.d.ts.map +1 -0
- package/dist/orchestration/recovery.js +197 -0
- package/dist/orchestration/recovery.js.map +1 -0
- package/dist/orchestration/tracer.d.ts +31 -0
- package/dist/orchestration/tracer.d.ts.map +1 -0
- package/dist/orchestration/tracer.js +141 -0
- package/dist/orchestration/tracer.js.map +1 -0
- package/dist/plans.d.ts +35 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +63 -0
- package/dist/plans.js.map +1 -0
- package/dist/presets.d.ts +10 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +70 -0
- package/dist/presets.js.map +1 -0
- package/dist/process.d.ts +6 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +42 -0
- package/dist/process.js.map +1 -0
- package/dist/registry.d.ts +37 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +51 -0
- package/dist/registry.js.map +1 -0
- package/dist/replay.d.ts +24 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +154 -0
- package/dist/replay.js.map +1 -0
- package/dist/rpc.d.ts +51 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +412 -0
- package/dist/rpc.js.map +1 -0
- package/dist/session-migration.d.ts +59 -0
- package/dist/session-migration.d.ts.map +1 -0
- package/dist/session-migration.js +315 -0
- package/dist/session-migration.js.map +1 -0
- package/dist/sessions.d.ts +74 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +222 -0
- package/dist/sessions.js.map +1 -0
- package/dist/subagents.d.ts +51 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +141 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +13 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +37 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui.d.ts +42 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +93 -0
- package/dist/ui.js.map +1 -0
- package/dist/verification.d.ts +34 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +91 -0
- package/dist/verification.js.map +1 -0
- package/dist/workflows.d.ts +98 -0
- package/dist/workflows.d.ts.map +1 -0
- package/dist/workflows.js +174 -0
- package/dist/workflows.js.map +1 -0
- package/package.json +84 -0
- package/src/approvals.ts +249 -0
- package/src/capabilities.ts +74 -0
- package/src/cli.ts +405 -0
- package/src/context.ts +81 -0
- package/src/deployment-adapters.ts +181 -0
- package/src/diff.ts +80 -0
- package/src/errors.ts +46 -0
- package/src/extensions.ts +402 -0
- package/src/handoffs.ts +299 -0
- package/src/healing.ts +93 -0
- package/src/host.ts +465 -0
- package/src/index.ts +165 -0
- package/src/isolated-worker.ts +177 -0
- package/src/isolated.ts +344 -0
- package/src/native.ts +478 -0
- package/src/orchestration/artifact-port.ts +75 -0
- package/src/orchestration/catalog.ts +102 -0
- package/src/orchestration/compile.ts +373 -0
- package/src/orchestration/dispatch.ts +383 -0
- package/src/orchestration/durable-jobs.ts +374 -0
- package/src/orchestration/effect-key.ts +35 -0
- package/src/orchestration/evidence-store.ts +233 -0
- package/src/orchestration/hash.ts +43 -0
- package/src/orchestration/host.ts +445 -0
- package/src/orchestration/index.ts +131 -0
- package/src/orchestration/policy.ts +116 -0
- package/src/orchestration/recovery.ts +271 -0
- package/src/orchestration/tracer.ts +177 -0
- package/src/plans.ts +105 -0
- package/src/presets.ts +90 -0
- package/src/process.ts +47 -0
- package/src/registry.ts +80 -0
- package/src/replay.ts +190 -0
- package/src/rpc.ts +598 -0
- package/src/session-migration.ts +401 -0
- package/src/sessions.ts +303 -0
- package/src/subagents.ts +210 -0
- package/src/tools.ts +52 -0
- package/src/ui.ts +137 -0
- package/src/verification.ts +133 -0
- 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
|
+
}
|