@nifrajs/agent-protocol 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/README.md +33 -0
- package/dist/index.d.ts +221 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +112 -0
- package/dist/index.js.map +1 -0
- package/dist/orchestration.d.ts +150 -0
- package/dist/orchestration.d.ts.map +1 -0
- package/dist/orchestration.js +278 -0
- package/dist/orchestration.js.map +1 -0
- package/dist/run-lifecycle.d.ts +190 -0
- package/dist/run-lifecycle.d.ts.map +1 -0
- package/dist/run-lifecycle.js +302 -0
- package/dist/run-lifecycle.js.map +1 -0
- package/package.json +45 -0
- package/src/index.ts +405 -0
- package/src/orchestration.ts +447 -0
- package/src/run-lifecycle.ts +516 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestration value contracts: declarative run plans, content-free run evidence, artifact
|
|
3
|
+
* references, and the effect-key identity. Additive to the session protocol; carries no behavior,
|
|
4
|
+
* no runtime dependency, and - by construction - no payload content.
|
|
5
|
+
*
|
|
6
|
+
* The privacy invariant lives in the parsers here: {@link parseRunEvidence} and
|
|
7
|
+
* {@link parseArtifactRef} accept only the declared content-free keys and reject any record over
|
|
8
|
+
* {@link EVIDENCE_MAX_BYTES}. A prompt, model output, or tool payload cannot round-trip through
|
|
9
|
+
* these contracts. Raw payloads travel only through an {@link ArtifactPort} the caller owns.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Orchestration contract version. Additive to the session `AGENT_PROTOCOL_VERSION`. */
|
|
13
|
+
export const RUN_PLAN_VERSION = 1 as const
|
|
14
|
+
|
|
15
|
+
/** Hard cap on a single serialized evidence record. A content-free record never approaches this. */
|
|
16
|
+
export const EVIDENCE_MAX_BYTES = 4096
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Keys that would carry payload content. They are never valid on evidence or artifact records and
|
|
20
|
+
* are rejected by the strict parsers even if they would otherwise fit the size cap.
|
|
21
|
+
*/
|
|
22
|
+
export const FORBIDDEN_CONTENT_KEYS: readonly string[] = Object.freeze([
|
|
23
|
+
"prompt",
|
|
24
|
+
"text",
|
|
25
|
+
"input",
|
|
26
|
+
"output",
|
|
27
|
+
"report",
|
|
28
|
+
"message",
|
|
29
|
+
"payload",
|
|
30
|
+
"body",
|
|
31
|
+
"args",
|
|
32
|
+
"content",
|
|
33
|
+
"response",
|
|
34
|
+
"completion",
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
// ── Artifact references ─────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** A content-free pointer to a payload the caller chose to retain out of band. */
|
|
40
|
+
export interface ArtifactRef {
|
|
41
|
+
/** Caller-namespaced id. Opaque to nifra. */
|
|
42
|
+
readonly id: string
|
|
43
|
+
/** SHA-256 hex of the payload, for integrity and correlation. Content-free. */
|
|
44
|
+
readonly digest: string
|
|
45
|
+
readonly bytes: number
|
|
46
|
+
readonly mediaType: string
|
|
47
|
+
/** Optional caller-opaque locator (e.g. a private URI). nifra never dereferences it. */
|
|
48
|
+
readonly locator?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What a payload is and where it came from, handed to the port at `put` time. */
|
|
52
|
+
export interface ArtifactContext {
|
|
53
|
+
readonly planDigest: string
|
|
54
|
+
readonly nodeId: string
|
|
55
|
+
/** e.g. "prompt" | "model_output" | "tool_input" | "tool_output". */
|
|
56
|
+
readonly kind: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Caller-owned sink for raw payloads - the ONLY place payload bytes leave transient execution.
|
|
61
|
+
* The public repo ships only a discarding no-op and a disposable in-memory test port. No public
|
|
62
|
+
* implementation persists.
|
|
63
|
+
*/
|
|
64
|
+
export interface ArtifactPort {
|
|
65
|
+
put(payload: Uint8Array, ctx: ArtifactContext): ArtifactRef | Promise<ArtifactRef>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Effect key ──────────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** The stable, content-free identity of one side-effecting node attempt-boundary. */
|
|
71
|
+
export interface NodeEffectKey {
|
|
72
|
+
/** SHA-256 hex over the canonical effect material. 64 lowercase hex chars. */
|
|
73
|
+
readonly digest: string
|
|
74
|
+
/** Non-secret structural coordinates, safe to log. */
|
|
75
|
+
readonly planDigest: string
|
|
76
|
+
readonly nodeId: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Run plan ────────────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/** Leaf kinds resolve a StepCatalog handler; structural kinds compose child nodes. */
|
|
82
|
+
export type RunLeafKind = "task" | "verify" | "approve" | "checkpoint" | "handoff" | "subagent"
|
|
83
|
+
export type RunStructuralKind = "sequence" | "parallel" | "retry" | "branch"
|
|
84
|
+
|
|
85
|
+
/** Declarative node kinds. Each maps to an existing workflow primitive at compile time. */
|
|
86
|
+
export type RunNodeKind = RunLeafKind | RunStructuralKind
|
|
87
|
+
|
|
88
|
+
/** Bounded retry policy for a leaf node. Attempts map to the kernel's retry step. */
|
|
89
|
+
export interface RunRetryPolicy {
|
|
90
|
+
readonly attempts: number
|
|
91
|
+
readonly backoffMs?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A leaf plan node. `step` names a handler resolved locally through a StepCatalog. */
|
|
95
|
+
export interface RunLeafNode {
|
|
96
|
+
readonly id: string
|
|
97
|
+
readonly kind: RunLeafKind
|
|
98
|
+
/** StepCatalog key. The handler is registered locally and never persisted in the plan. */
|
|
99
|
+
readonly step: string
|
|
100
|
+
/** Only meaningful on top-level nodes; nested children may not declare it. */
|
|
101
|
+
readonly dependsOn?: readonly string[]
|
|
102
|
+
/** Required for `approve`; ignored otherwise. */
|
|
103
|
+
readonly reason?: string
|
|
104
|
+
/** When set, the compiler asserts the resolved catalog step declares this exact version. */
|
|
105
|
+
readonly stepVersion?: number
|
|
106
|
+
/** Wraps this leaf's compiled step in a bounded retry. */
|
|
107
|
+
readonly retry?: RunRetryPolicy
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An ordered composite. Children run in declaration order. */
|
|
111
|
+
export interface RunSequenceNode {
|
|
112
|
+
readonly id: string
|
|
113
|
+
readonly kind: "sequence"
|
|
114
|
+
readonly children: readonly RunNode[]
|
|
115
|
+
readonly dependsOn?: readonly string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A bounded fan-out. Effective concurrency is the lower of this, the host, and the child count. */
|
|
119
|
+
export interface RunParallelNode {
|
|
120
|
+
readonly id: string
|
|
121
|
+
readonly kind: "parallel"
|
|
122
|
+
readonly children: readonly RunNode[]
|
|
123
|
+
readonly maxConcurrency?: number
|
|
124
|
+
readonly dependsOn?: readonly string[]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A bounded retry wrapping a single child node. */
|
|
128
|
+
export interface RunRetryNode {
|
|
129
|
+
readonly id: string
|
|
130
|
+
readonly kind: "retry"
|
|
131
|
+
readonly child: RunNode
|
|
132
|
+
readonly attempts: number
|
|
133
|
+
readonly backoffMs?: number
|
|
134
|
+
readonly dependsOn?: readonly string[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A conditional. `step` names a predicate catalog handler selecting `then` or `otherwise`. */
|
|
138
|
+
export interface RunBranchNode {
|
|
139
|
+
readonly id: string
|
|
140
|
+
readonly kind: "branch"
|
|
141
|
+
readonly step: string
|
|
142
|
+
readonly then: RunNode
|
|
143
|
+
readonly otherwise?: RunNode
|
|
144
|
+
readonly dependsOn?: readonly string[]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A serializable, closure-free plan node. Structural kinds nest into a tree. */
|
|
148
|
+
export type RunNode = RunLeafNode | RunSequenceNode | RunParallelNode | RunRetryNode | RunBranchNode
|
|
149
|
+
|
|
150
|
+
/** A serializable, closure-free run plan. Top-level nodes form a DAG; each node may nest a tree. */
|
|
151
|
+
export interface RunPlan {
|
|
152
|
+
readonly version: typeof RUN_PLAN_VERSION
|
|
153
|
+
readonly id: string
|
|
154
|
+
readonly nodes: readonly RunNode[]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Run evidence ────────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export type RunEvidenceStatus = "started" | "completed" | "failed"
|
|
160
|
+
|
|
161
|
+
/** The content-free projection of one run transition. Every field is id/hash/counter/status/timing. */
|
|
162
|
+
export interface RunEvidence {
|
|
163
|
+
readonly version: typeof RUN_PLAN_VERSION
|
|
164
|
+
readonly runId: string
|
|
165
|
+
readonly planDigest: string
|
|
166
|
+
readonly nodeId: string
|
|
167
|
+
readonly status: RunEvidenceStatus
|
|
168
|
+
readonly seq: number
|
|
169
|
+
/** NodeEffectKey.digest when the node declared an effect selector; absent otherwise. */
|
|
170
|
+
readonly effectKey?: string
|
|
171
|
+
/** Whether the step declared an idempotency selector. */
|
|
172
|
+
readonly idempotent: boolean
|
|
173
|
+
readonly artifacts?: readonly ArtifactRef[]
|
|
174
|
+
readonly errorCode?: string
|
|
175
|
+
readonly durationMs?: number
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Strict parsers ──────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/** Thrown by every parser in this module on malformed or content-bearing input. Fails closed. */
|
|
181
|
+
export class RunContractError extends Error {
|
|
182
|
+
constructor(readonly reason: string) {
|
|
183
|
+
super(`orchestration contract: ${reason}`)
|
|
184
|
+
this.name = "RunContractError"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
189
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assertNoForbiddenKeys(record: Record<string, unknown>, where: string): void {
|
|
193
|
+
for (const key of FORBIDDEN_CONTENT_KEYS) {
|
|
194
|
+
if (key in record) throw new RunContractError(`${where} carries forbidden content key '${key}'`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function assertOnlyKeys(
|
|
199
|
+
record: Record<string, unknown>,
|
|
200
|
+
allowed: readonly string[],
|
|
201
|
+
where: string,
|
|
202
|
+
): void {
|
|
203
|
+
for (const key of Object.keys(record)) {
|
|
204
|
+
if (!allowed.includes(key)) throw new RunContractError(`${where} has unknown key '${key}'`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const HEX64 = /^[0-9a-f]{64}$/
|
|
209
|
+
|
|
210
|
+
function str(record: Record<string, unknown>, key: string, where: string): string {
|
|
211
|
+
const value = record[key]
|
|
212
|
+
if (typeof value !== "string" || value.length === 0)
|
|
213
|
+
throw new RunContractError(`${where}.${key} must be a non-empty string`)
|
|
214
|
+
return value
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function int(record: Record<string, unknown>, key: string, where: string): number {
|
|
218
|
+
const value = record[key]
|
|
219
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
|
|
220
|
+
throw new RunContractError(`${where}.${key} must be a non-negative integer`)
|
|
221
|
+
return value
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Parse a content-free artifact reference. Rejects any non-schema or content key. */
|
|
225
|
+
export function parseArtifactRef(value: unknown): ArtifactRef {
|
|
226
|
+
if (!isRecord(value)) throw new RunContractError("artifact ref must be an object")
|
|
227
|
+
assertOnlyKeys(value, ["id", "digest", "bytes", "mediaType", "locator"], "artifact ref")
|
|
228
|
+
assertNoForbiddenKeys(value, "artifact ref")
|
|
229
|
+
const digest = str(value, "digest", "artifact ref")
|
|
230
|
+
if (!HEX64.test(digest)) throw new RunContractError("artifact ref.digest must be sha256 hex")
|
|
231
|
+
const ref: ArtifactRef = {
|
|
232
|
+
id: str(value, "id", "artifact ref"),
|
|
233
|
+
digest,
|
|
234
|
+
bytes: int(value, "bytes", "artifact ref"),
|
|
235
|
+
mediaType: str(value, "mediaType", "artifact ref"),
|
|
236
|
+
}
|
|
237
|
+
if (value.locator !== undefined) {
|
|
238
|
+
if (typeof value.locator !== "string")
|
|
239
|
+
throw new RunContractError("artifact ref.locator must be a string")
|
|
240
|
+
return { ...ref, locator: value.locator }
|
|
241
|
+
}
|
|
242
|
+
return ref
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const LEAF_KINDS: readonly RunLeafKind[] = [
|
|
246
|
+
"task",
|
|
247
|
+
"verify",
|
|
248
|
+
"approve",
|
|
249
|
+
"checkpoint",
|
|
250
|
+
"handoff",
|
|
251
|
+
"subagent",
|
|
252
|
+
]
|
|
253
|
+
const STRUCTURAL_KINDS: readonly RunStructuralKind[] = ["sequence", "parallel", "retry", "branch"]
|
|
254
|
+
|
|
255
|
+
function posInt(record: Record<string, unknown>, key: string, where: string): number {
|
|
256
|
+
const value = record[key]
|
|
257
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
|
|
258
|
+
throw new RunContractError(`${where}.${key} must be a positive integer`)
|
|
259
|
+
return value
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseDependsOn(
|
|
263
|
+
raw: Record<string, unknown>,
|
|
264
|
+
where: string,
|
|
265
|
+
allowDepends: boolean,
|
|
266
|
+
): readonly string[] | undefined {
|
|
267
|
+
if (raw.dependsOn === undefined) return undefined
|
|
268
|
+
if (!allowDepends) throw new RunContractError(`${where} is nested and may not declare dependsOn`)
|
|
269
|
+
if (!Array.isArray(raw.dependsOn) || raw.dependsOn.some((d) => typeof d !== "string"))
|
|
270
|
+
throw new RunContractError(`${where}.dependsOn must be a string array`)
|
|
271
|
+
return [...(raw.dependsOn as readonly string[])]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseRetryPolicy(value: unknown, where: string): RunRetryPolicy {
|
|
275
|
+
if (!isRecord(value)) throw new RunContractError(`${where}.retry must be an object`)
|
|
276
|
+
assertOnlyKeys(value, ["attempts", "backoffMs"], `${where}.retry`)
|
|
277
|
+
const attempts = posInt(value, "attempts", `${where}.retry`)
|
|
278
|
+
if (attempts > 16) throw new RunContractError(`${where}.retry.attempts must be 1..16`)
|
|
279
|
+
if (value.backoffMs === undefined) return { attempts }
|
|
280
|
+
return { attempts, backoffMs: int(value, "backoffMs", `${where}.retry`) }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Parse one node (recursively). `allowDepends` is true only for top-level nodes. */
|
|
284
|
+
function parseNode(raw: unknown, where: string, allowDepends: boolean, ids: Set<string>): RunNode {
|
|
285
|
+
if (!isRecord(raw)) throw new RunContractError(`${where} must be an object`)
|
|
286
|
+
const kind = raw.kind
|
|
287
|
+
if (typeof kind !== "string") throw new RunContractError(`${where}.kind must be a string`)
|
|
288
|
+
const isLeaf = LEAF_KINDS.includes(kind as RunLeafKind)
|
|
289
|
+
if (!isLeaf && !STRUCTURAL_KINDS.includes(kind as RunStructuralKind))
|
|
290
|
+
throw new RunContractError(`${where}.kind '${kind}' is invalid`)
|
|
291
|
+
|
|
292
|
+
const allowed =
|
|
293
|
+
kind === "sequence"
|
|
294
|
+
? ["id", "kind", "children", "dependsOn"]
|
|
295
|
+
: kind === "parallel"
|
|
296
|
+
? ["id", "kind", "children", "maxConcurrency", "dependsOn"]
|
|
297
|
+
: kind === "retry"
|
|
298
|
+
? ["id", "kind", "child", "attempts", "backoffMs", "dependsOn"]
|
|
299
|
+
: kind === "branch"
|
|
300
|
+
? ["id", "kind", "step", "then", "otherwise", "dependsOn"]
|
|
301
|
+
: ["id", "kind", "step", "dependsOn", "reason", "stepVersion", "retry"]
|
|
302
|
+
assertOnlyKeys(raw, allowed, where)
|
|
303
|
+
assertNoForbiddenKeys(raw, where)
|
|
304
|
+
|
|
305
|
+
const id = str(raw, "id", where)
|
|
306
|
+
if (ids.has(id)) throw new RunContractError(`duplicate node id '${id}'`)
|
|
307
|
+
ids.add(id)
|
|
308
|
+
const dependsOn = parseDependsOn(raw, where, allowDepends)
|
|
309
|
+
const withDeps = <T extends { readonly kind: RunNodeKind }>(node: T): T =>
|
|
310
|
+
dependsOn === undefined ? node : { ...node, dependsOn }
|
|
311
|
+
|
|
312
|
+
if (isLeaf) {
|
|
313
|
+
const leaf: RunLeafNode = { id, kind: kind as RunLeafKind, step: str(raw, "step", where) }
|
|
314
|
+
let out = leaf
|
|
315
|
+
if (raw.reason !== undefined) {
|
|
316
|
+
if (typeof raw.reason !== "string")
|
|
317
|
+
throw new RunContractError(`${where}.reason must be a string`)
|
|
318
|
+
out = { ...out, reason: raw.reason }
|
|
319
|
+
}
|
|
320
|
+
if (kind === "approve" && out.reason === undefined)
|
|
321
|
+
throw new RunContractError(`${where} of kind 'approve' requires a reason`)
|
|
322
|
+
if (raw.stepVersion !== undefined)
|
|
323
|
+
out = { ...out, stepVersion: posInt(raw, "stepVersion", where) }
|
|
324
|
+
if (raw.retry !== undefined) out = { ...out, retry: parseRetryPolicy(raw.retry, where) }
|
|
325
|
+
return withDeps(out)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (kind === "sequence" || kind === "parallel") {
|
|
329
|
+
if (!Array.isArray(raw.children) || raw.children.length === 0)
|
|
330
|
+
throw new RunContractError(`${where}.children must be a non-empty array`)
|
|
331
|
+
const children = raw.children.map((child, i) =>
|
|
332
|
+
parseNode(child, `${where}.children[${i}]`, false, ids),
|
|
333
|
+
)
|
|
334
|
+
if (kind === "sequence") return withDeps({ id, kind, children })
|
|
335
|
+
const node: RunParallelNode = { id, kind, children }
|
|
336
|
+
return withDeps(
|
|
337
|
+
raw.maxConcurrency === undefined
|
|
338
|
+
? node
|
|
339
|
+
: { ...node, maxConcurrency: posInt(raw, "maxConcurrency", where) },
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (kind === "retry") {
|
|
344
|
+
const attempts = posInt(raw, "attempts", where)
|
|
345
|
+
if (attempts > 16) throw new RunContractError(`${where}.attempts must be 1..16`)
|
|
346
|
+
const child = parseNode(raw.child, `${where}.child`, false, ids)
|
|
347
|
+
const node: RunRetryNode = { id, kind, child, attempts }
|
|
348
|
+
return withDeps(
|
|
349
|
+
raw.backoffMs === undefined ? node : { ...node, backoffMs: int(raw, "backoffMs", where) },
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// branch
|
|
354
|
+
const then = parseNode(raw.then, `${where}.then`, false, ids)
|
|
355
|
+
const node: RunBranchNode = { id, kind: "branch", step: str(raw, "step", where), then }
|
|
356
|
+
return withDeps(
|
|
357
|
+
raw.otherwise === undefined
|
|
358
|
+
? node
|
|
359
|
+
: { ...node, otherwise: parseNode(raw.otherwise, `${where}.otherwise`, false, ids) },
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Parse a declarative run plan. Rejects closures, unknown keys, and structural errors. */
|
|
364
|
+
export function parseRunPlan(value: unknown): RunPlan {
|
|
365
|
+
if (!isRecord(value)) throw new RunContractError("run plan must be an object")
|
|
366
|
+
assertOnlyKeys(value, ["version", "id", "nodes"], "run plan")
|
|
367
|
+
if (value.version !== RUN_PLAN_VERSION)
|
|
368
|
+
throw new RunContractError(`run plan.version must be ${RUN_PLAN_VERSION}`)
|
|
369
|
+
if (!Array.isArray(value.nodes) || value.nodes.length === 0)
|
|
370
|
+
throw new RunContractError("run plan.nodes must be a non-empty array")
|
|
371
|
+
const ids = new Set<string>()
|
|
372
|
+
const nodes = value.nodes.map((raw, index) => parseNode(raw, `node[${index}]`, true, ids))
|
|
373
|
+
const topLevel = new Set(nodes.map((node) => node.id))
|
|
374
|
+
for (const node of nodes) {
|
|
375
|
+
for (const dep of node.dependsOn ?? []) {
|
|
376
|
+
if (dep === node.id) throw new RunContractError(`node '${node.id}' depends on itself`)
|
|
377
|
+
if (!topLevel.has(dep))
|
|
378
|
+
throw new RunContractError(`node '${node.id}' depends on unknown '${dep}'`)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { version: RUN_PLAN_VERSION, id: str(value, "id", "run plan"), nodes }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Parse a content-free evidence record. Rejects content keys and any record over the size cap. */
|
|
385
|
+
export function parseRunEvidence(value: unknown): RunEvidence {
|
|
386
|
+
if (!isRecord(value)) throw new RunContractError("evidence must be an object")
|
|
387
|
+
assertOnlyKeys(
|
|
388
|
+
value,
|
|
389
|
+
[
|
|
390
|
+
"version",
|
|
391
|
+
"runId",
|
|
392
|
+
"planDigest",
|
|
393
|
+
"nodeId",
|
|
394
|
+
"status",
|
|
395
|
+
"seq",
|
|
396
|
+
"effectKey",
|
|
397
|
+
"idempotent",
|
|
398
|
+
"artifacts",
|
|
399
|
+
"errorCode",
|
|
400
|
+
"durationMs",
|
|
401
|
+
],
|
|
402
|
+
"evidence",
|
|
403
|
+
)
|
|
404
|
+
assertNoForbiddenKeys(value, "evidence")
|
|
405
|
+
if (value.version !== RUN_PLAN_VERSION)
|
|
406
|
+
throw new RunContractError(`evidence.version must be ${RUN_PLAN_VERSION}`)
|
|
407
|
+
const status = value.status
|
|
408
|
+
if (status !== "started" && status !== "completed" && status !== "failed")
|
|
409
|
+
throw new RunContractError("evidence.status is invalid")
|
|
410
|
+
if (typeof value.idempotent !== "boolean")
|
|
411
|
+
throw new RunContractError("evidence.idempotent must be a boolean")
|
|
412
|
+
const evidence: RunEvidence = {
|
|
413
|
+
version: RUN_PLAN_VERSION,
|
|
414
|
+
runId: str(value, "runId", "evidence"),
|
|
415
|
+
planDigest: str(value, "planDigest", "evidence"),
|
|
416
|
+
nodeId: str(value, "nodeId", "evidence"),
|
|
417
|
+
status,
|
|
418
|
+
seq: int(value, "seq", "evidence"),
|
|
419
|
+
idempotent: value.idempotent,
|
|
420
|
+
...(value.effectKey !== undefined
|
|
421
|
+
? { effectKey: parseDigest(value.effectKey, "effectKey") }
|
|
422
|
+
: {}),
|
|
423
|
+
...(value.errorCode !== undefined ? { errorCode: str(value, "errorCode", "evidence") } : {}),
|
|
424
|
+
...(value.durationMs !== undefined ? { durationMs: int(value, "durationMs", "evidence") } : {}),
|
|
425
|
+
...(value.artifacts !== undefined ? { artifacts: parseArtifacts(value.artifacts) } : {}),
|
|
426
|
+
}
|
|
427
|
+
assertEvidenceSize(evidence)
|
|
428
|
+
return evidence
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function parseDigest(value: unknown, field: string): string {
|
|
432
|
+
if (typeof value !== "string" || !HEX64.test(value))
|
|
433
|
+
throw new RunContractError(`evidence.${field} must be sha256 hex`)
|
|
434
|
+
return value
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function parseArtifacts(value: unknown): readonly ArtifactRef[] {
|
|
438
|
+
if (!Array.isArray(value)) throw new RunContractError("evidence.artifacts must be an array")
|
|
439
|
+
return value.map(parseArtifactRef)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Assert a record serializes within the hard cap. Public reference records are content-free. */
|
|
443
|
+
export function assertEvidenceSize(evidence: RunEvidence): void {
|
|
444
|
+
const bytes = new TextEncoder().encode(JSON.stringify(evidence)).length
|
|
445
|
+
if (bytes > EVIDENCE_MAX_BYTES)
|
|
446
|
+
throw new RunContractError(`evidence record ${bytes}B exceeds ${EVIDENCE_MAX_BYTES}B cap`)
|
|
447
|
+
}
|