@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,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Additive run-lifecycle contracts: content-free run snapshots, evidence-event envelopes, handoff
|
|
3
|
+
* snapshots, feature negotiation, and cursor resume. These layer on top of the orchestration value
|
|
4
|
+
* contracts without changing `AGENT_PROTOCOL_VERSION`, and - like the evidence contract - carry no
|
|
5
|
+
* payload content.
|
|
6
|
+
*
|
|
7
|
+
* Two decoder disciplines live here on purpose:
|
|
8
|
+
* - Transport decoders ({@link parseRunSnapshot}, {@link parseHandoffSnapshot},
|
|
9
|
+
* {@link parseRunPlanRef}, and the envelope of {@link parseRunEvidenceEvent}) are lenient about
|
|
10
|
+
* unknown additive fields so a newer host can add optional metadata without breaking an older
|
|
11
|
+
* client. They still reject any {@link FORBIDDEN_CONTENT_KEYS} so a payload can never ride along.
|
|
12
|
+
* - The evidence sink stays strict: the inner record of a {@link RunEvidenceEvent} is validated by
|
|
13
|
+
* {@link parseRunEvidence}, which accepts only the declared content-free keys.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
FORBIDDEN_CONTENT_KEYS,
|
|
18
|
+
parseRunEvidence,
|
|
19
|
+
RunContractError,
|
|
20
|
+
type RunEvidence,
|
|
21
|
+
} from "./orchestration.ts"
|
|
22
|
+
|
|
23
|
+
/** Run-lifecycle contract version. Additive to the session `AGENT_PROTOCOL_VERSION`. */
|
|
24
|
+
export const RUN_LIFECYCLE_VERSION = 1 as const
|
|
25
|
+
|
|
26
|
+
/** Terminal-or-transient state of a run as observed through evidence. */
|
|
27
|
+
export type RunLifecycleState =
|
|
28
|
+
| "submitted"
|
|
29
|
+
| "running"
|
|
30
|
+
| "paused"
|
|
31
|
+
| "succeeded"
|
|
32
|
+
| "failed"
|
|
33
|
+
| "cancelled"
|
|
34
|
+
|
|
35
|
+
const RUN_LIFECYCLE_STATES: readonly RunLifecycleState[] = [
|
|
36
|
+
"submitted",
|
|
37
|
+
"running",
|
|
38
|
+
"paused",
|
|
39
|
+
"succeeded",
|
|
40
|
+
"failed",
|
|
41
|
+
"cancelled",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** Handoff resolution state, surfaced content-free. */
|
|
45
|
+
export type HandoffStatus = "pending" | "accepted" | "declined"
|
|
46
|
+
|
|
47
|
+
const HANDOFF_STATUSES: readonly HandoffStatus[] = ["pending", "accepted", "declined"]
|
|
48
|
+
|
|
49
|
+
// ── Value contracts ───────────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** A content-free pointer to the plan a run is executing. */
|
|
52
|
+
export interface RunPlanRef {
|
|
53
|
+
readonly id: string
|
|
54
|
+
/** SHA-256 hex of the canonical plan. Content-free. */
|
|
55
|
+
readonly digest: string
|
|
56
|
+
readonly nodeCount: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Running tallies over a run's evidence stream. */
|
|
60
|
+
export interface RunCounters {
|
|
61
|
+
readonly total: number
|
|
62
|
+
readonly completed: number
|
|
63
|
+
readonly failed: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The bounded current view of a run: its plan pointer, lifecycle state, resume cursor, and counters.
|
|
68
|
+
* `cursor` is the highest evidence `seq` reflected here; a client resumes with everything after it.
|
|
69
|
+
*/
|
|
70
|
+
export interface RunSnapshot {
|
|
71
|
+
readonly version: typeof RUN_LIFECYCLE_VERSION
|
|
72
|
+
readonly runId: string
|
|
73
|
+
readonly plan: RunPlanRef
|
|
74
|
+
readonly state: RunLifecycleState
|
|
75
|
+
/** Highest evidence seq reflected in this snapshot, or -1 before any evidence. */
|
|
76
|
+
readonly cursor: number
|
|
77
|
+
readonly counters: RunCounters
|
|
78
|
+
readonly updatedAt: number
|
|
79
|
+
readonly failureCode?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A content-free pointer to one evidence record, with the stable dedupe identity. */
|
|
83
|
+
export interface EvidenceRef {
|
|
84
|
+
readonly runId: string
|
|
85
|
+
readonly seq: number
|
|
86
|
+
/** Stable per-record identity: `${runId}:${seq}`. */
|
|
87
|
+
readonly eventId: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A transport envelope wrapping one strict evidence record with its stable dedupe identity. */
|
|
91
|
+
export interface RunEvidenceEvent {
|
|
92
|
+
readonly version: typeof RUN_LIFECYCLE_VERSION
|
|
93
|
+
/** Stable per-record identity: `${runId}:${seq}` of the inner evidence. */
|
|
94
|
+
readonly eventId: string
|
|
95
|
+
readonly evidence: RunEvidence
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A content-free snapshot of one handoff between roles/agents within a run. */
|
|
99
|
+
export interface HandoffSnapshot {
|
|
100
|
+
readonly version: typeof RUN_LIFECYCLE_VERSION
|
|
101
|
+
readonly runId: string
|
|
102
|
+
readonly nodeId: string
|
|
103
|
+
readonly seq: number
|
|
104
|
+
/** Caller-opaque role identifiers. Non-secret structural coordinates. */
|
|
105
|
+
readonly from: string
|
|
106
|
+
readonly to: string
|
|
107
|
+
readonly status: HandoffStatus
|
|
108
|
+
readonly reason?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The stable dedupe identity for an evidence record. */
|
|
112
|
+
export function evidenceEventId(runId: string, seq: number): string {
|
|
113
|
+
return `${runId}:${seq}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Feature negotiation ───────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/** The result of reconciling a client's requested features against a host's offered set. */
|
|
119
|
+
export interface FeatureNegotiation {
|
|
120
|
+
/** Requested features the host offers, sorted and de-duplicated. */
|
|
121
|
+
readonly granted: readonly string[]
|
|
122
|
+
/** Requested features the host does not offer, sorted and de-duplicated. */
|
|
123
|
+
readonly unsupported: readonly string[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Reconcile requested features against the offered set. A requested feature the host does not offer
|
|
128
|
+
* is reported as unsupported rather than silently dropped, so a client can degrade deliberately.
|
|
129
|
+
*/
|
|
130
|
+
export function negotiateFeatures(
|
|
131
|
+
offered: readonly string[],
|
|
132
|
+
requested: readonly string[],
|
|
133
|
+
): FeatureNegotiation {
|
|
134
|
+
const offeredSet = new Set(offered)
|
|
135
|
+
const granted = new Set<string>()
|
|
136
|
+
const unsupported = new Set<string>()
|
|
137
|
+
for (const feature of requested) {
|
|
138
|
+
if (offeredSet.has(feature)) granted.add(feature)
|
|
139
|
+
else unsupported.add(feature)
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
granted: Object.freeze([...granted].sort()),
|
|
143
|
+
unsupported: Object.freeze([...unsupported].sort()),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Cursor resume ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
export type CursorResyncReason = "stale_cursor"
|
|
150
|
+
|
|
151
|
+
/** Events after the cursor were retained; deliver them and advance. */
|
|
152
|
+
export interface CursorResumeOk<T> {
|
|
153
|
+
readonly status: "ok"
|
|
154
|
+
readonly events: readonly T[]
|
|
155
|
+
/** Highest seq now delivered, or the input cursor when nothing followed it. */
|
|
156
|
+
readonly nextCursor: number
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The bounded window no longer contains the record after the cursor; a full resync is required. */
|
|
160
|
+
export interface CursorResyncRequired {
|
|
161
|
+
readonly status: "resync_required"
|
|
162
|
+
readonly reason: CursorResyncReason
|
|
163
|
+
readonly earliest: number
|
|
164
|
+
readonly latest: number
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type CursorResume<T> = CursorResumeOk<T> | CursorResyncRequired
|
|
168
|
+
|
|
169
|
+
/** Sentinel cursor meaning "before any record". A fresh subscription passes this or `undefined`. */
|
|
170
|
+
export const CURSOR_BEFORE_ALL = -1
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resume an ordered, seq-keyed window from a cursor.
|
|
174
|
+
*
|
|
175
|
+
* The window must be sorted by ascending, strictly increasing `seq`. `cursor` is the last seq the
|
|
176
|
+
* client already holds; `undefined` (or {@link CURSOR_BEFORE_ALL}) means the client holds nothing.
|
|
177
|
+
* If the record immediately after the cursor was already evicted from the bounded window, a gap
|
|
178
|
+
* exists and this returns `resync_required` with reason `stale_cursor` rather than silently skipping.
|
|
179
|
+
*/
|
|
180
|
+
export function resumeFromCursor<T extends { readonly seq: number }>(
|
|
181
|
+
window: readonly T[],
|
|
182
|
+
cursor?: number,
|
|
183
|
+
): CursorResume<T> {
|
|
184
|
+
let previous = Number.NEGATIVE_INFINITY
|
|
185
|
+
for (const entry of window) {
|
|
186
|
+
if (!Number.isSafeInteger(entry.seq) || entry.seq < 0)
|
|
187
|
+
throw new RunContractError("cursor window seq must be a non-negative integer")
|
|
188
|
+
if (entry.seq <= previous)
|
|
189
|
+
throw new RunContractError("cursor window must be sorted by strictly increasing seq")
|
|
190
|
+
previous = entry.seq
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const from = cursor === undefined ? CURSOR_BEFORE_ALL : cursor
|
|
194
|
+
if (!Number.isSafeInteger(from) || from < CURSOR_BEFORE_ALL)
|
|
195
|
+
throw new RunContractError("cursor must be a safe integer >= -1")
|
|
196
|
+
|
|
197
|
+
if (window.length === 0)
|
|
198
|
+
return {
|
|
199
|
+
status: "ok",
|
|
200
|
+
events: Object.freeze([]),
|
|
201
|
+
nextCursor: Math.max(from, CURSOR_BEFORE_ALL),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const earliest = (window[0] as T).seq
|
|
205
|
+
const latest = (window[window.length - 1] as T).seq
|
|
206
|
+
|
|
207
|
+
// A real cursor whose next record (from + 1) predates our window lost a gap to eviction. A fresh
|
|
208
|
+
// subscriber (CURSOR_BEFORE_ALL) has no continuity expectation and always takes the whole window.
|
|
209
|
+
if (from >= 0 && from + 1 < earliest)
|
|
210
|
+
return { status: "resync_required", reason: "stale_cursor", earliest, latest }
|
|
211
|
+
|
|
212
|
+
const events = window.filter((entry) => entry.seq > from)
|
|
213
|
+
const nextCursor = events.length > 0 ? (events[events.length - 1] as T).seq : from
|
|
214
|
+
return { status: "ok", events: Object.freeze(events), nextCursor }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Decoders ──────────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
const HEX64 = /^[0-9a-f]{64}$/
|
|
220
|
+
|
|
221
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
222
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function assertNoForbiddenKeys(record: Record<string, unknown>, where: string): void {
|
|
226
|
+
for (const key of FORBIDDEN_CONTENT_KEYS) {
|
|
227
|
+
if (key in record) throw new RunContractError(`${where} carries forbidden content key '${key}'`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function str(record: Record<string, unknown>, key: string, where: string): string {
|
|
232
|
+
const value = record[key]
|
|
233
|
+
if (typeof value !== "string" || value.length === 0)
|
|
234
|
+
throw new RunContractError(`${where}.${key} must be a non-empty string`)
|
|
235
|
+
return value
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function nonNegInt(record: Record<string, unknown>, key: string, where: string): number {
|
|
239
|
+
const value = record[key]
|
|
240
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
|
|
241
|
+
throw new RunContractError(`${where}.${key} must be a non-negative integer`)
|
|
242
|
+
return value
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Decode a content-free plan reference. Lenient about unknown additive keys, never content. */
|
|
246
|
+
export function parseRunPlanRef(value: unknown): RunPlanRef {
|
|
247
|
+
if (!isRecord(value)) throw new RunContractError("run plan ref must be an object")
|
|
248
|
+
assertNoForbiddenKeys(value, "run plan ref")
|
|
249
|
+
const digest = str(value, "digest", "run plan ref")
|
|
250
|
+
if (!HEX64.test(digest)) throw new RunContractError("run plan ref.digest must be sha256 hex")
|
|
251
|
+
return {
|
|
252
|
+
id: str(value, "id", "run plan ref"),
|
|
253
|
+
digest,
|
|
254
|
+
nodeCount: nonNegInt(value, "nodeCount", "run plan ref"),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseCounters(value: unknown): RunCounters {
|
|
259
|
+
if (!isRecord(value)) throw new RunContractError("run snapshot.counters must be an object")
|
|
260
|
+
assertNoForbiddenKeys(value, "run snapshot.counters")
|
|
261
|
+
return {
|
|
262
|
+
total: nonNegInt(value, "total", "run snapshot.counters"),
|
|
263
|
+
completed: nonNegInt(value, "completed", "run snapshot.counters"),
|
|
264
|
+
failed: nonNegInt(value, "failed", "run snapshot.counters"),
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Decode a bounded run snapshot. Forward-compatible: unknown additive fields are ignored so an
|
|
270
|
+
* older client tolerates a newer host. Forbidden content keys are still rejected.
|
|
271
|
+
*/
|
|
272
|
+
export function parseRunSnapshot(value: unknown): RunSnapshot {
|
|
273
|
+
if (!isRecord(value)) throw new RunContractError("run snapshot must be an object")
|
|
274
|
+
assertNoForbiddenKeys(value, "run snapshot")
|
|
275
|
+
if (value.version !== RUN_LIFECYCLE_VERSION)
|
|
276
|
+
throw new RunContractError(`run snapshot.version must be ${RUN_LIFECYCLE_VERSION}`)
|
|
277
|
+
const state = value.state
|
|
278
|
+
if (typeof state !== "string" || !RUN_LIFECYCLE_STATES.includes(state as RunLifecycleState))
|
|
279
|
+
throw new RunContractError("run snapshot.state is invalid")
|
|
280
|
+
const cursor = value.cursor
|
|
281
|
+
if (typeof cursor !== "number" || !Number.isSafeInteger(cursor) || cursor < CURSOR_BEFORE_ALL)
|
|
282
|
+
throw new RunContractError("run snapshot.cursor must be a safe integer >= -1")
|
|
283
|
+
const snapshot: RunSnapshot = {
|
|
284
|
+
version: RUN_LIFECYCLE_VERSION,
|
|
285
|
+
runId: str(value, "runId", "run snapshot"),
|
|
286
|
+
plan: parseRunPlanRef(value.plan),
|
|
287
|
+
state: state as RunLifecycleState,
|
|
288
|
+
cursor,
|
|
289
|
+
counters: parseCounters(value.counters),
|
|
290
|
+
updatedAt: nonNegInt(value, "updatedAt", "run snapshot"),
|
|
291
|
+
...(value.failureCode !== undefined
|
|
292
|
+
? { failureCode: str(value, "failureCode", "run snapshot") }
|
|
293
|
+
: {}),
|
|
294
|
+
}
|
|
295
|
+
return snapshot
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Decode an evidence-event envelope. The envelope is forward-compatible, but the inner record is
|
|
300
|
+
* validated strictly by {@link parseRunEvidence}, and its identity must match `${runId}:${seq}`.
|
|
301
|
+
*/
|
|
302
|
+
export function parseRunEvidenceEvent(value: unknown): RunEvidenceEvent {
|
|
303
|
+
if (!isRecord(value)) throw new RunContractError("run evidence event must be an object")
|
|
304
|
+
assertNoForbiddenKeys(value, "run evidence event")
|
|
305
|
+
if (value.version !== RUN_LIFECYCLE_VERSION)
|
|
306
|
+
throw new RunContractError(`run evidence event.version must be ${RUN_LIFECYCLE_VERSION}`)
|
|
307
|
+
const evidence = parseRunEvidence(value.evidence)
|
|
308
|
+
const expected = evidenceEventId(evidence.runId, evidence.seq)
|
|
309
|
+
if (value.eventId !== undefined && value.eventId !== expected)
|
|
310
|
+
throw new RunContractError("run evidence event.eventId must match the inner record identity")
|
|
311
|
+
return { version: RUN_LIFECYCLE_VERSION, eventId: expected, evidence }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Decode a handoff snapshot. Forward-compatible about unknown additive fields; forbidden content
|
|
316
|
+
* keys are rejected.
|
|
317
|
+
*/
|
|
318
|
+
export function parseHandoffSnapshot(value: unknown): HandoffSnapshot {
|
|
319
|
+
if (!isRecord(value)) throw new RunContractError("handoff snapshot must be an object")
|
|
320
|
+
assertNoForbiddenKeys(value, "handoff snapshot")
|
|
321
|
+
if (value.version !== RUN_LIFECYCLE_VERSION)
|
|
322
|
+
throw new RunContractError(`handoff snapshot.version must be ${RUN_LIFECYCLE_VERSION}`)
|
|
323
|
+
const status = value.status
|
|
324
|
+
if (typeof status !== "string" || !HANDOFF_STATUSES.includes(status as HandoffStatus))
|
|
325
|
+
throw new RunContractError("handoff snapshot.status is invalid")
|
|
326
|
+
const snapshot: HandoffSnapshot = {
|
|
327
|
+
version: RUN_LIFECYCLE_VERSION,
|
|
328
|
+
runId: str(value, "runId", "handoff snapshot"),
|
|
329
|
+
nodeId: str(value, "nodeId", "handoff snapshot"),
|
|
330
|
+
seq: nonNegInt(value, "seq", "handoff snapshot"),
|
|
331
|
+
from: str(value, "from", "handoff snapshot"),
|
|
332
|
+
to: str(value, "to", "handoff snapshot"),
|
|
333
|
+
status: status as HandoffStatus,
|
|
334
|
+
...(value.reason !== undefined ? { reason: str(value, "reason", "handoff snapshot") } : {}),
|
|
335
|
+
}
|
|
336
|
+
return snapshot
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Decision boundaries: approval and handoff lifecycles ────────────────────────────────────────
|
|
340
|
+
//
|
|
341
|
+
// A decision boundary is a paused point in a run awaiting a human (or delegated) decision: an
|
|
342
|
+
// approval to proceed, or a handoff between roles. Both are addressed by the same content-free
|
|
343
|
+
// {@link DecisionCoordinate} - run, node, capability, request id, a monotonic per-run child vector,
|
|
344
|
+
// and an absolute expiry - so a decision that does not match the live boundary, arrives after expiry,
|
|
345
|
+
// or replays a stale vector fails closed rather than resuming the wrong work. The lifecycles are
|
|
346
|
+
// modelled as explicit state machines below; a transition absent from the table is illegal.
|
|
347
|
+
|
|
348
|
+
/** The two boundary families a host pauses on. */
|
|
349
|
+
export type BoundaryKind = "approval" | "handoff"
|
|
350
|
+
|
|
351
|
+
/** Approval lifecycle. Terminal states accept no further transition. */
|
|
352
|
+
export type ApprovalLifecycleState = "pending" | "approved" | "denied" | "expired" | "cancelled"
|
|
353
|
+
|
|
354
|
+
/** Handoff lifecycle. `assigned` is the only non-pending live state; the rest are terminal. */
|
|
355
|
+
export type HandoffLifecycleState =
|
|
356
|
+
| "pending"
|
|
357
|
+
| "assigned"
|
|
358
|
+
| "accepted"
|
|
359
|
+
| "declined"
|
|
360
|
+
| "resolved"
|
|
361
|
+
| "expired"
|
|
362
|
+
| "cancelled"
|
|
363
|
+
|
|
364
|
+
/** The operations that drive a boundary. Not every op is legal from every state. */
|
|
365
|
+
export type BoundaryOp =
|
|
366
|
+
| "approve"
|
|
367
|
+
| "deny"
|
|
368
|
+
| "assign"
|
|
369
|
+
| "accept"
|
|
370
|
+
| "decline"
|
|
371
|
+
| "resolve"
|
|
372
|
+
| "expire"
|
|
373
|
+
| "cancel"
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The negotiated command surface for the inbox: a superset of {@link BoundaryOp} plus the read verbs
|
|
377
|
+
* `list` and `inspect`. A host advertises the subset it supports; a client drives only the granted
|
|
378
|
+
* commands. Order here is the canonical advertisement order.
|
|
379
|
+
*/
|
|
380
|
+
export const BOUNDARY_COMMANDS = [
|
|
381
|
+
"list",
|
|
382
|
+
"inspect",
|
|
383
|
+
"approve",
|
|
384
|
+
"deny",
|
|
385
|
+
"assign",
|
|
386
|
+
"resolve",
|
|
387
|
+
"expire",
|
|
388
|
+
"cancel",
|
|
389
|
+
] as const
|
|
390
|
+
|
|
391
|
+
export type BoundaryCommand = (typeof BOUNDARY_COMMANDS)[number]
|
|
392
|
+
|
|
393
|
+
/** Stable, content-free reasons a boundary decision is refused. */
|
|
394
|
+
export type BoundaryRejection =
|
|
395
|
+
| "unknown_boundary"
|
|
396
|
+
| "identity_mismatch"
|
|
397
|
+
| "stale_vector"
|
|
398
|
+
| "expired"
|
|
399
|
+
| "illegal_transition"
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The content-free identity of one decision boundary. `vector` is the run's monotonic child index at
|
|
403
|
+
* which the boundary opened; a decision must carry the boundary's exact vector, so a replayed or
|
|
404
|
+
* superseded decision (a lower or mismatched vector) is refused. `expiresAt` is an absolute epoch-ms
|
|
405
|
+
* deadline. No field carries a prompt, a reason-as-free-text, or a payload.
|
|
406
|
+
*/
|
|
407
|
+
export interface DecisionCoordinate {
|
|
408
|
+
readonly runId: string
|
|
409
|
+
readonly nodeId: string
|
|
410
|
+
readonly capability: string
|
|
411
|
+
readonly requestId: string
|
|
412
|
+
readonly vector: number
|
|
413
|
+
readonly expiresAt: number
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** True when both coordinates address the same boundary at the same child vector. */
|
|
417
|
+
export function coordinatesMatch(a: DecisionCoordinate, b: DecisionCoordinate): boolean {
|
|
418
|
+
return (
|
|
419
|
+
a.runId === b.runId &&
|
|
420
|
+
a.nodeId === b.nodeId &&
|
|
421
|
+
a.capability === b.capability &&
|
|
422
|
+
a.requestId === b.requestId &&
|
|
423
|
+
a.vector === b.vector
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** True while `now` is strictly before the coordinate's expiry. At or past expiry fails closed. */
|
|
428
|
+
export function coordinateIsFresh(coordinate: DecisionCoordinate, now: number): boolean {
|
|
429
|
+
return now < coordinate.expiresAt
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Monotonic child-vector check: a newly opened boundary must advance strictly past the run's last
|
|
434
|
+
* allocated vector. A non-advancing vector is a replay and is refused. `last` is `-1` before any
|
|
435
|
+
* boundary has opened for the run.
|
|
436
|
+
*/
|
|
437
|
+
export function vectorAdvances(last: number, next: number): boolean {
|
|
438
|
+
return Number.isSafeInteger(next) && next > last
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const APPROVAL_TRANSITIONS: Readonly<
|
|
442
|
+
Record<ApprovalLifecycleState, Partial<Record<BoundaryOp, ApprovalLifecycleState>>>
|
|
443
|
+
> = {
|
|
444
|
+
pending: { approve: "approved", deny: "denied", expire: "expired", cancel: "cancelled" },
|
|
445
|
+
approved: {},
|
|
446
|
+
denied: {},
|
|
447
|
+
expired: {},
|
|
448
|
+
cancelled: {},
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const HANDOFF_TRANSITIONS: Readonly<
|
|
452
|
+
Record<HandoffLifecycleState, Partial<Record<BoundaryOp, HandoffLifecycleState>>>
|
|
453
|
+
> = {
|
|
454
|
+
pending: {
|
|
455
|
+
assign: "assigned",
|
|
456
|
+
accept: "accepted",
|
|
457
|
+
decline: "declined",
|
|
458
|
+
expire: "expired",
|
|
459
|
+
cancel: "cancelled",
|
|
460
|
+
},
|
|
461
|
+
assigned: {
|
|
462
|
+
accept: "accepted",
|
|
463
|
+
decline: "declined",
|
|
464
|
+
resolve: "resolved",
|
|
465
|
+
expire: "expired",
|
|
466
|
+
cancel: "cancelled",
|
|
467
|
+
},
|
|
468
|
+
accepted: { resolve: "resolved", expire: "expired", cancel: "cancelled" },
|
|
469
|
+
declined: {},
|
|
470
|
+
resolved: {},
|
|
471
|
+
expired: {},
|
|
472
|
+
cancelled: {},
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The next approval state for an op, or `undefined` when the transition is illegal. */
|
|
476
|
+
export function nextApprovalState(
|
|
477
|
+
from: ApprovalLifecycleState,
|
|
478
|
+
op: BoundaryOp,
|
|
479
|
+
): ApprovalLifecycleState | undefined {
|
|
480
|
+
return APPROVAL_TRANSITIONS[from][op]
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** The next handoff state for an op, or `undefined` when the transition is illegal. */
|
|
484
|
+
export function nextHandoffState(
|
|
485
|
+
from: HandoffLifecycleState,
|
|
486
|
+
op: BoundaryOp,
|
|
487
|
+
): HandoffLifecycleState | undefined {
|
|
488
|
+
return HANDOFF_TRANSITIONS[from][op]
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** True once a boundary state accepts no further op. */
|
|
492
|
+
export function isTerminalApprovalState(state: ApprovalLifecycleState): boolean {
|
|
493
|
+
return Object.keys(APPROVAL_TRANSITIONS[state]).length === 0
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** True once a handoff state accepts no further op. */
|
|
497
|
+
export function isTerminalHandoffState(state: HandoffLifecycleState): boolean {
|
|
498
|
+
return Object.keys(HANDOFF_TRANSITIONS[state]).length === 0
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Decode a decision coordinate. Content-free: forbidden payload keys are rejected, and only the
|
|
503
|
+
* declared structural fields are read. `vector` and `expiresAt` must be non-negative safe integers.
|
|
504
|
+
*/
|
|
505
|
+
export function parseDecisionCoordinate(value: unknown): DecisionCoordinate {
|
|
506
|
+
if (!isRecord(value)) throw new RunContractError("decision coordinate must be an object")
|
|
507
|
+
assertNoForbiddenKeys(value, "decision coordinate")
|
|
508
|
+
return {
|
|
509
|
+
runId: str(value, "runId", "decision coordinate"),
|
|
510
|
+
nodeId: str(value, "nodeId", "decision coordinate"),
|
|
511
|
+
capability: str(value, "capability", "decision coordinate"),
|
|
512
|
+
requestId: str(value, "requestId", "decision coordinate"),
|
|
513
|
+
vector: nonNegInt(value, "vector", "decision coordinate"),
|
|
514
|
+
expiresAt: nonNegInt(value, "expiresAt", "decision coordinate"),
|
|
515
|
+
}
|
|
516
|
+
}
|