@nifrajs/agent-app 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.
@@ -0,0 +1,919 @@
1
+ /**
2
+ * Presentation-safe projections of the agent protocol.
3
+ *
4
+ * A backend event or session snapshot carries prompt text, tool inputs and outputs, model
5
+ * completions, diagnostic reports, and filesystem paths. None of that may reach browser-facing code.
6
+ * These view models expose only stable identifiers, lifecycle statuses, counters, and opaque
7
+ * references - never the content itself. A prompt becomes a character count; a tool result becomes an
8
+ * `ok` flag and an error *code*; a compaction becomes before/after token counts. The projection is the
9
+ * boundary: an upstream consumer sees enough to render progress and resolve interactions, and nothing
10
+ * it could use to reconstruct a payload.
11
+ */
12
+
13
+ import {
14
+ type AgentEvent,
15
+ type AgentSessionSnapshot,
16
+ type AgentSessionStatus,
17
+ type ApprovalLifecycleState,
18
+ type BoundaryCommand,
19
+ type HandoffLifecycleState,
20
+ type HandoffSnapshot,
21
+ nextApprovalState,
22
+ nextHandoffState,
23
+ type RunSnapshot,
24
+ } from "@nifrajs/agent-protocol"
25
+
26
+ /** A session reduced to lifecycle and capability facts. The working directory is deliberately omitted. */
27
+ export interface SessionView {
28
+ readonly id: string
29
+ readonly backend: string
30
+ readonly status: AgentSessionStatus
31
+ readonly createdAt: number
32
+ readonly updatedAt: number
33
+ readonly lastSeq: number
34
+ readonly activeTurnId?: string
35
+ readonly extensionRevision?: string
36
+ readonly capabilities: readonly string[]
37
+ }
38
+
39
+ export function toSessionView(snapshot: AgentSessionSnapshot): SessionView {
40
+ return Object.freeze({
41
+ id: snapshot.id,
42
+ backend: snapshot.backend,
43
+ status: snapshot.status,
44
+ createdAt: snapshot.createdAt,
45
+ updatedAt: snapshot.updatedAt,
46
+ lastSeq: snapshot.lastSeq,
47
+ ...(snapshot.activeTurnId === undefined ? {} : { activeTurnId: snapshot.activeTurnId }),
48
+ ...(snapshot.extensionRevision === undefined
49
+ ? {}
50
+ : { extensionRevision: snapshot.extensionRevision }),
51
+ capabilities: [...snapshot.capabilities],
52
+ })
53
+ }
54
+
55
+ interface EventViewBase {
56
+ readonly kind: AgentEvent["type"]
57
+ readonly seq: number
58
+ readonly at: number
59
+ }
60
+
61
+ export interface SessionLifecycleView extends EventViewBase {
62
+ readonly kind: "session.started" | "session.updated" | "session.completed"
63
+ readonly session: SessionView
64
+ }
65
+
66
+ export interface TurnStartedView extends EventViewBase {
67
+ readonly kind: "turn.started"
68
+ readonly turnId: string
69
+ }
70
+
71
+ export interface AssistantChunkView extends EventViewBase {
72
+ readonly kind: "assistant.delta" | "assistant.message"
73
+ readonly turnId: string
74
+ /** Length of the assistant text. The text itself is never carried. */
75
+ readonly chars: number
76
+ }
77
+
78
+ export interface ToolStartedView extends EventViewBase {
79
+ readonly kind: "tool.started"
80
+ readonly turnId: string
81
+ readonly callId: string
82
+ readonly name: string
83
+ readonly hasInput: boolean
84
+ }
85
+
86
+ export interface ToolDeltaView extends EventViewBase {
87
+ readonly kind: "tool.delta"
88
+ readonly turnId: string
89
+ readonly callId: string
90
+ readonly chars: number
91
+ }
92
+
93
+ export interface ToolCompletedView extends EventViewBase {
94
+ readonly kind: "tool.completed"
95
+ readonly turnId: string
96
+ readonly callId: string
97
+ readonly name: string
98
+ readonly ok: boolean
99
+ /** Error *code* only; the human-readable message is content and is dropped. */
100
+ readonly errorCode?: string
101
+ }
102
+
103
+ export interface ApprovalRequiredView extends EventViewBase {
104
+ readonly kind: "approval.required"
105
+ readonly turnId: string
106
+ readonly approvalId: string
107
+ readonly action: string
108
+ readonly capability: string
109
+ }
110
+
111
+ export interface ApprovalResolvedView extends EventViewBase {
112
+ readonly kind: "approval.resolved"
113
+ readonly turnId?: string
114
+ readonly approvalId: string
115
+ readonly approved: boolean
116
+ }
117
+
118
+ export interface RepairRequiredView extends EventViewBase {
119
+ readonly kind: "repair.required"
120
+ readonly turnId?: string
121
+ readonly taskId: string
122
+ readonly verification: "check" | "assure" | "test"
123
+ readonly capabilities: readonly string[]
124
+ }
125
+
126
+ export interface VerificationCompletedView extends EventViewBase {
127
+ readonly kind: "verification.completed"
128
+ readonly name: string
129
+ readonly ok: boolean
130
+ }
131
+
132
+ export interface MemoryCompactedView extends EventViewBase {
133
+ readonly kind: "memory.compacted"
134
+ readonly before: number
135
+ readonly after: number
136
+ readonly reason: "manual" | "threshold" | "overflow" | "workflow"
137
+ }
138
+
139
+ export interface ExtensionReloadedView extends EventViewBase {
140
+ readonly kind: "extension.reloaded"
141
+ readonly revision: string
142
+ readonly loadedCount: number
143
+ readonly disabledCount: number
144
+ readonly rolledBack: boolean
145
+ }
146
+
147
+ export interface SessionFailedView extends EventViewBase {
148
+ readonly kind: "session.failed"
149
+ readonly errorCode: string
150
+ readonly recoverable: boolean
151
+ }
152
+
153
+ export interface SessionStoppedView extends EventViewBase {
154
+ readonly kind: "session.stopped"
155
+ }
156
+
157
+ export type AgentEventView =
158
+ | SessionLifecycleView
159
+ | TurnStartedView
160
+ | AssistantChunkView
161
+ | ToolStartedView
162
+ | ToolDeltaView
163
+ | ToolCompletedView
164
+ | ApprovalRequiredView
165
+ | ApprovalResolvedView
166
+ | RepairRequiredView
167
+ | VerificationCompletedView
168
+ | MemoryCompactedView
169
+ | ExtensionReloadedView
170
+ | SessionFailedView
171
+ | SessionStoppedView
172
+
173
+ /** Project one protocol event to its content-free view. Total over the event union - never returns undefined. */
174
+ export function toEventView(event: AgentEvent): AgentEventView {
175
+ const base = { seq: event.seq, at: event.at }
176
+ switch (event.type) {
177
+ case "session.started":
178
+ case "session.updated":
179
+ case "session.completed":
180
+ return Object.freeze({ ...base, kind: event.type, session: toSessionView(event.snapshot) })
181
+ case "turn.started":
182
+ return Object.freeze({ ...base, kind: event.type, turnId: event.turnId })
183
+ case "assistant.delta":
184
+ case "assistant.message":
185
+ return Object.freeze({
186
+ ...base,
187
+ kind: event.type,
188
+ turnId: event.turnId,
189
+ chars: event.text.length,
190
+ })
191
+ case "tool.started":
192
+ return Object.freeze({
193
+ ...base,
194
+ kind: event.type,
195
+ turnId: event.turnId,
196
+ callId: event.callId,
197
+ name: event.name,
198
+ hasInput: event.input !== undefined,
199
+ })
200
+ case "tool.delta":
201
+ return Object.freeze({
202
+ ...base,
203
+ kind: event.type,
204
+ turnId: event.turnId,
205
+ callId: event.callId,
206
+ chars: event.text.length,
207
+ })
208
+ case "tool.completed":
209
+ return Object.freeze({
210
+ ...base,
211
+ kind: event.type,
212
+ turnId: event.turnId,
213
+ callId: event.callId,
214
+ name: event.name,
215
+ ok: event.ok,
216
+ ...(event.error === undefined ? {} : { errorCode: event.error.code }),
217
+ })
218
+ case "approval.required":
219
+ return Object.freeze({
220
+ ...base,
221
+ kind: event.type,
222
+ turnId: event.turnId,
223
+ approvalId: event.approvalId,
224
+ action: event.action,
225
+ capability: event.capability,
226
+ })
227
+ case "approval.resolved":
228
+ return Object.freeze({
229
+ ...base,
230
+ kind: event.type,
231
+ approvalId: event.approvalId,
232
+ approved: event.approved,
233
+ ...(event.turnId === undefined ? {} : { turnId: event.turnId }),
234
+ })
235
+ case "repair.required":
236
+ return Object.freeze({
237
+ ...base,
238
+ kind: event.type,
239
+ taskId: event.task.id,
240
+ verification: event.task.verification,
241
+ capabilities: [...event.task.capabilities],
242
+ ...(event.turnId === undefined ? {} : { turnId: event.turnId }),
243
+ })
244
+ case "verification.completed":
245
+ return Object.freeze({ ...base, kind: event.type, name: event.name, ok: event.ok })
246
+ case "memory.compacted":
247
+ return Object.freeze({
248
+ ...base,
249
+ kind: event.type,
250
+ before: event.before,
251
+ after: event.after,
252
+ reason: event.reason,
253
+ })
254
+ case "extension.reloaded":
255
+ return Object.freeze({
256
+ ...base,
257
+ kind: event.type,
258
+ revision: event.revision,
259
+ loadedCount: event.loaded.length,
260
+ disabledCount: event.disabled.length,
261
+ rolledBack: event.rolledBack,
262
+ })
263
+ case "session.failed":
264
+ return Object.freeze({
265
+ ...base,
266
+ kind: event.type,
267
+ errorCode: event.error.code,
268
+ recoverable: event.recoverable,
269
+ })
270
+ case "session.stopped":
271
+ return Object.freeze({ ...base, kind: event.type })
272
+ }
273
+ }
274
+
275
+ /** A run reduced to plan reference, lifecycle state, and progress counters - no node payloads. */
276
+ export interface RunView {
277
+ readonly runId: string
278
+ readonly planId: string
279
+ readonly planDigest: string
280
+ readonly nodeCount: number
281
+ readonly state: RunSnapshot["state"]
282
+ readonly cursor: number
283
+ readonly total: number
284
+ readonly completed: number
285
+ readonly failed: number
286
+ readonly updatedAt: number
287
+ readonly failureCode?: string
288
+ }
289
+
290
+ export function toRunView(snapshot: RunSnapshot): RunView {
291
+ return Object.freeze({
292
+ runId: snapshot.runId,
293
+ planId: snapshot.plan.id,
294
+ planDigest: snapshot.plan.digest,
295
+ nodeCount: snapshot.plan.nodeCount,
296
+ state: snapshot.state,
297
+ cursor: snapshot.cursor,
298
+ total: snapshot.counters.total,
299
+ completed: snapshot.counters.completed,
300
+ failed: snapshot.counters.failed,
301
+ updatedAt: snapshot.updatedAt,
302
+ ...(snapshot.failureCode === undefined ? {} : { failureCode: snapshot.failureCode }),
303
+ })
304
+ }
305
+
306
+ /** A handoff reduced to routing identifiers and status - the reason string is content and is dropped. */
307
+ export interface HandoffView {
308
+ readonly runId: string
309
+ readonly nodeId: string
310
+ readonly seq: number
311
+ readonly from: string
312
+ readonly to: string
313
+ readonly status: HandoffSnapshot["status"]
314
+ }
315
+
316
+ export function toHandoffView(snapshot: HandoffSnapshot): HandoffView {
317
+ return Object.freeze({
318
+ runId: snapshot.runId,
319
+ nodeId: snapshot.nodeId,
320
+ seq: snapshot.seq,
321
+ from: snapshot.from,
322
+ to: snapshot.to,
323
+ status: snapshot.status,
324
+ })
325
+ }
326
+
327
+ /**
328
+ * A registry capability projected to its content-free identity card.
329
+ *
330
+ * The registry lists the invokable capabilities a host admits. A view carries only structural facts -
331
+ * kind, identifier, version, the schema *digest* (a hash, never the schema), the required capability
332
+ * tokens, and the host classes (approval, retry, idempotency, isolation). It never carries an input
333
+ * schema, a description, an instruction, a prompt, or any other content field; any such field on the
334
+ * raw record is simply dropped by the projection rather than surfaced.
335
+ */
336
+ export interface RegistryCapabilityView {
337
+ readonly kind: "tool" | "mcp-tool" | "extension" | "model-adapter" | "deployment-adapter"
338
+ readonly name: string
339
+ readonly version: string
340
+ readonly schemaDigest: string
341
+ readonly requiredCapabilities: readonly string[]
342
+ readonly approval: "none" | "required" | "threshold"
343
+ /** Present only for a `threshold` approval class. A numeric level is a bound, not content. */
344
+ readonly approvalLevel?: number
345
+ readonly retry: "none" | "idempotent"
346
+ readonly idempotency: "none" | "request" | "durable"
347
+ readonly isolation: "inherit" | "process" | "sandbox"
348
+ }
349
+
350
+ const REGISTRY_KINDS: ReadonlySet<string> = new Set([
351
+ "tool",
352
+ "mcp-tool",
353
+ "extension",
354
+ "model-adapter",
355
+ "deployment-adapter",
356
+ ])
357
+ const REGISTRY_RETRY: ReadonlySet<string> = new Set(["none", "idempotent"])
358
+ const REGISTRY_IDEMPOTENCY: ReadonlySet<string> = new Set(["none", "request", "durable"])
359
+ const REGISTRY_ISOLATION: ReadonlySet<string> = new Set(["inherit", "process", "sandbox"])
360
+ const HEX64 = /^[0-9a-f]{64}$/
361
+
362
+ function isRecordValue(value: unknown): value is Record<string, unknown> {
363
+ return typeof value === "object" && value !== null && !Array.isArray(value)
364
+ }
365
+
366
+ function readApproval(
367
+ value: unknown,
368
+ ): { readonly approval: RegistryCapabilityView["approval"]; readonly level?: number } | undefined {
369
+ if (!isRecordValue(value)) return undefined
370
+ const kind = value.kind
371
+ if (kind === "none" || kind === "required") return { approval: kind }
372
+ if (kind === "threshold" && typeof value.level === "number" && Number.isSafeInteger(value.level))
373
+ return { approval: "threshold", level: value.level }
374
+ return undefined
375
+ }
376
+
377
+ /**
378
+ * Project one raw registry descriptor to a content-free {@link RegistryCapabilityView}, or `undefined`
379
+ * when a required identifier is missing or malformed. Only whitelisted structural fields are read, so
380
+ * an unexpected content field on the record can never reach the returned view.
381
+ */
382
+ export function toRegistryCapabilityView(value: unknown): RegistryCapabilityView | undefined {
383
+ if (!isRecordValue(value)) return undefined
384
+ const { kind, name, version, schemaDigest, requiredCapabilities, retry, idempotency, isolation } =
385
+ value
386
+ if (
387
+ typeof kind !== "string" ||
388
+ !REGISTRY_KINDS.has(kind) ||
389
+ typeof name !== "string" ||
390
+ name.length === 0 ||
391
+ typeof version !== "string" ||
392
+ version.length === 0 ||
393
+ typeof schemaDigest !== "string" ||
394
+ !HEX64.test(schemaDigest) ||
395
+ !Array.isArray(requiredCapabilities) ||
396
+ !requiredCapabilities.every((entry) => typeof entry === "string") ||
397
+ typeof retry !== "string" ||
398
+ !REGISTRY_RETRY.has(retry) ||
399
+ typeof idempotency !== "string" ||
400
+ !REGISTRY_IDEMPOTENCY.has(idempotency) ||
401
+ typeof isolation !== "string" ||
402
+ !REGISTRY_ISOLATION.has(isolation)
403
+ )
404
+ return undefined
405
+ const approval = readApproval(value.approval)
406
+ if (approval === undefined) return undefined
407
+ return Object.freeze({
408
+ kind: kind as RegistryCapabilityView["kind"],
409
+ name,
410
+ version,
411
+ schemaDigest,
412
+ requiredCapabilities: Object.freeze([...requiredCapabilities]),
413
+ approval: approval.approval,
414
+ ...(approval.level === undefined ? {} : { approvalLevel: approval.level }),
415
+ retry: retry as RegistryCapabilityView["retry"],
416
+ idempotency: idempotency as RegistryCapabilityView["idempotency"],
417
+ isolation: isolation as RegistryCapabilityView["isolation"],
418
+ })
419
+ }
420
+
421
+ /** The minimal boundary facts a decision needs. A `BoundaryItemView` from the client satisfies it. */
422
+ export interface BoundaryStateView {
423
+ readonly kind: "approval" | "handoff"
424
+ readonly state: string
425
+ /** Absolute epoch-ms expiry. At or past it, the boundary is stale and offers no command. */
426
+ readonly expiresAt: number
427
+ }
428
+
429
+ const APPROVAL_STATES: ReadonlySet<string> = new Set([
430
+ "pending",
431
+ "approved",
432
+ "denied",
433
+ "expired",
434
+ "cancelled",
435
+ ])
436
+ const HANDOFF_STATES: ReadonlySet<string> = new Set([
437
+ "pending",
438
+ "assigned",
439
+ "accepted",
440
+ "declined",
441
+ "resolved",
442
+ "expired",
443
+ "cancelled",
444
+ ])
445
+ /** UI verbs per boundary kind, in display order. `cancel` is shared; a numeric level never appears. */
446
+ const APPROVAL_UI_OPS = ["approve", "deny", "cancel"] as const
447
+ const HANDOFF_UI_OPS = ["assign", "resolve", "cancel"] as const
448
+
449
+ /** True once `now` reaches or passes the boundary's expiry. A stale boundary fails every command closed. */
450
+ export function boundaryIsStale(item: BoundaryStateView, now: number): boolean {
451
+ return now >= item.expiresAt
452
+ }
453
+
454
+ /**
455
+ * The boundary commands a UI may currently offer for one item. A command appears only when the host
456
+ * has negotiated the `inbox` feature, the boundary has not expired, and the op is a legal transition
457
+ * from the boundary's live state. An unknown or terminal state yields no commands, so a stale,
458
+ * unsupported, or already-settled boundary can never present an actionable control.
459
+ */
460
+ export function boundaryCommands(
461
+ item: BoundaryStateView,
462
+ options: { readonly inbox: boolean; readonly now: number },
463
+ ): readonly BoundaryCommand[] {
464
+ if (!options.inbox || boundaryIsStale(item, options.now)) return []
465
+ if (item.kind === "approval") {
466
+ if (!APPROVAL_STATES.has(item.state)) return []
467
+ const state = item.state as ApprovalLifecycleState
468
+ return Object.freeze(APPROVAL_UI_OPS.filter((op) => nextApprovalState(state, op) !== undefined))
469
+ }
470
+ if (!HANDOFF_STATES.has(item.state)) return []
471
+ const state = item.state as HandoffLifecycleState
472
+ return Object.freeze(HANDOFF_UI_OPS.filter((op) => nextHandoffState(state, op) !== undefined))
473
+ }
474
+
475
+ /**
476
+ * Orders events by their sequence number and suppresses duplicates before they reach the UI.
477
+ *
478
+ * A live SSE stream can redeliver an event after a reconnect, or hand two frames to a consumer out of
479
+ * order. {@link offer} returns only the events that are newly deliverable, in sequence order. Events
480
+ * whose seq was already delivered, or is already buffered, are dropped as duplicates. A bounded
481
+ * `maxPending` guards against an unbounded hole: once more than `maxPending` out-of-order events are
482
+ * held waiting for a missing seq, the buffer skips the gap to the lowest pending seq and records the
483
+ * skipped count on {@link dropped}, so a permanently lost event cannot stall the view forever.
484
+ */
485
+ export class OrderedEventBuffer {
486
+ private next: number | undefined
487
+ private readonly pending = new Map<number, AgentEventView>()
488
+ private readonly maxPending: number
489
+ private droppedCount = 0
490
+
491
+ /**
492
+ * @param options.from Last already-delivered seq; the next expected event is `from + 1`. Omit for a
493
+ * fresh buffer that adopts the first offered event's seq as its starting point.
494
+ * @param options.maxPending Out-of-order events held before the buffer skips a gap. Default 256.
495
+ */
496
+ constructor(options?: { readonly from?: number; readonly maxPending?: number }) {
497
+ if (options?.from !== undefined) {
498
+ if (!Number.isSafeInteger(options.from))
499
+ throw new TypeError("OrderedEventBuffer: from must be a safe integer")
500
+ this.next = options.from + 1
501
+ }
502
+ this.maxPending = options?.maxPending ?? 256
503
+ if (!Number.isSafeInteger(this.maxPending) || this.maxPending < 1)
504
+ throw new TypeError("OrderedEventBuffer: maxPending must be a positive integer")
505
+ }
506
+
507
+ /** Count of events skipped to recover from an unfilled gap. */
508
+ get dropped(): number {
509
+ return this.droppedCount
510
+ }
511
+
512
+ /** Seq the buffer will emit next, or `undefined` before the first event is seen. */
513
+ get expected(): number | undefined {
514
+ return this.next
515
+ }
516
+
517
+ offer(view: AgentEventView): readonly AgentEventView[] {
518
+ if (this.next === undefined) this.next = view.seq
519
+ if (view.seq < this.next) return [] // already delivered
520
+ if (this.pending.has(view.seq)) return [] // duplicate still waiting
521
+ this.pending.set(view.seq, view)
522
+ return this.drain()
523
+ }
524
+
525
+ private drain(): readonly AgentEventView[] {
526
+ const out: AgentEventView[] = []
527
+ const emitContiguous = () => {
528
+ for (let held = this.pending.get(this.next as number); held !== undefined; ) {
529
+ out.push(held)
530
+ this.pending.delete(this.next as number)
531
+ this.next = (this.next as number) + 1
532
+ held = this.pending.get(this.next as number)
533
+ }
534
+ }
535
+ emitContiguous()
536
+ if (this.pending.size > this.maxPending) {
537
+ const lowest = Math.min(...this.pending.keys())
538
+ this.droppedCount += lowest - (this.next as number)
539
+ this.next = lowest
540
+ emitContiguous()
541
+ }
542
+ return out
543
+ }
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // Run studio projections
548
+ // ---------------------------------------------------------------------------
549
+
550
+ export type RunStudioNodeState =
551
+ | "pending"
552
+ | "running"
553
+ | "paused"
554
+ | "succeeded"
555
+ | "failed"
556
+ | "cancelled"
557
+ | "recovered"
558
+
559
+ export interface RunStudioNodeView {
560
+ readonly nodeId: string
561
+ readonly dependsOn: readonly string[]
562
+ readonly state: RunStudioNodeState
563
+ readonly attempt: number
564
+ readonly retryCount: number
565
+ readonly checkpointed: boolean
566
+ readonly cancelled: boolean
567
+ readonly recovered: boolean
568
+ }
569
+
570
+ export interface RunStudioView {
571
+ readonly runId: string
572
+ readonly planId: string
573
+ readonly planDigest: string
574
+ readonly cursor: number
575
+ readonly state: RunStudioNodeState
576
+ readonly nodes: readonly RunStudioNodeView[]
577
+ readonly activeNodes: number
578
+ readonly terminalNodes: number
579
+ readonly traceRef?: string
580
+ readonly replayRef?: string
581
+ }
582
+
583
+ export interface EvidenceTimelineView {
584
+ readonly seq: number
585
+ readonly eventId: string
586
+ readonly runId: string
587
+ readonly nodeId: string
588
+ readonly status:
589
+ | "started"
590
+ | "checkpointed"
591
+ | "retrying"
592
+ | "recovered"
593
+ | "completed"
594
+ | "failed"
595
+ | "cancelled"
596
+ | "dead-lettered"
597
+ readonly attempt: number
598
+ readonly traceRef?: string
599
+ readonly replayRef?: string
600
+ readonly scheduleToken?: string
601
+ }
602
+
603
+ export type EvalComparisonViewCode =
604
+ | "equal"
605
+ | "improved"
606
+ | "tolerated"
607
+ | "regressed"
608
+ | "missing"
609
+ | "incomparable"
610
+
611
+ export interface EvalComparisonView {
612
+ readonly suiteId: string
613
+ readonly comparisons: readonly {
614
+ readonly caseId: string
615
+ readonly rubricId: string
616
+ readonly code: EvalComparisonViewCode
617
+ readonly regressionId: string
618
+ }[]
619
+ readonly regressionIds: readonly string[]
620
+ }
621
+
622
+ export interface FaultInjectionView {
623
+ readonly id: string
624
+ readonly kind: string
625
+ readonly scheduleToken: string
626
+ readonly regressionId: string
627
+ readonly ok: boolean
628
+ }
629
+
630
+ const STUDIO_TOKEN = /^[-A-Za-z0-9._:/]{1,128}$/
631
+ const STUDIO_DIGEST = /^[0-9a-f]{64}$/
632
+ const STUDIO_STATES: ReadonlySet<string> = new Set([
633
+ "pending",
634
+ "running",
635
+ "paused",
636
+ "succeeded",
637
+ "failed",
638
+ "cancelled",
639
+ "recovered",
640
+ ])
641
+ const STUDIO_STATUSES: ReadonlySet<string> = new Set([
642
+ "started",
643
+ "checkpointed",
644
+ "retrying",
645
+ "recovered",
646
+ "completed",
647
+ "failed",
648
+ "cancelled",
649
+ "dead-lettered",
650
+ ])
651
+ const STUDIO_CODES: ReadonlySet<string> = new Set([
652
+ "equal",
653
+ "improved",
654
+ "tolerated",
655
+ "regressed",
656
+ "missing",
657
+ "incomparable",
658
+ ])
659
+ const STUDIO_FORBIDDEN: ReadonlySet<string> = new Set([
660
+ "prompt",
661
+ "message",
662
+ "text",
663
+ "input",
664
+ "output",
665
+ "arguments",
666
+ "body",
667
+ "response",
668
+ "secret",
669
+ "credential",
670
+ "diagnostic",
671
+ "stack",
672
+ "content",
673
+ "transcript",
674
+ "artifact",
675
+ "path",
676
+ ])
677
+
678
+ function studioRecord(value: unknown): value is Record<string, unknown> {
679
+ return typeof value === "object" && value !== null && !Array.isArray(value)
680
+ }
681
+
682
+ function studioToken(value: unknown): value is string {
683
+ return typeof value === "string" && STUDIO_TOKEN.test(value)
684
+ }
685
+
686
+ function studioInt(value: unknown, min = 0): value is number {
687
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= min
688
+ }
689
+
690
+ function studioSafeKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): boolean {
691
+ return Object.keys(value).every(
692
+ (key) => !STUDIO_FORBIDDEN.has(key.toLowerCase()) && allowed.has(key),
693
+ )
694
+ }
695
+
696
+ /** Project an evidence-only run graph; malformed or content-bearing input is rejected. */
697
+ export function toRunStudioView(value: unknown): RunStudioView | undefined {
698
+ if (!studioRecord(value)) return undefined
699
+ const allowed = new Set([
700
+ "runId",
701
+ "planId",
702
+ "planDigest",
703
+ "cursor",
704
+ "state",
705
+ "nodes",
706
+ "traceRef",
707
+ "replayRef",
708
+ ])
709
+ if (
710
+ !studioSafeKeys(value, allowed) ||
711
+ !studioToken(value.runId) ||
712
+ !studioToken(value.planId) ||
713
+ typeof value.planDigest !== "string" ||
714
+ !STUDIO_DIGEST.test(value.planDigest) ||
715
+ !studioInt(value.cursor) ||
716
+ typeof value.state !== "string" ||
717
+ !STUDIO_STATES.has(value.state) ||
718
+ !Array.isArray(value.nodes)
719
+ )
720
+ return undefined
721
+ if (value.traceRef !== undefined && !studioToken(value.traceRef)) return undefined
722
+ if (value.replayRef !== undefined && !studioToken(value.replayRef)) return undefined
723
+ const nodes: RunStudioNodeView[] = []
724
+ for (const raw of value.nodes) {
725
+ if (
726
+ !studioRecord(raw) ||
727
+ !studioSafeKeys(
728
+ raw,
729
+ new Set([
730
+ "nodeId",
731
+ "dependsOn",
732
+ "state",
733
+ "attempt",
734
+ "retryCount",
735
+ "checkpointed",
736
+ "cancelled",
737
+ "recovered",
738
+ ]),
739
+ )
740
+ )
741
+ return undefined
742
+ if (
743
+ !studioToken(raw.nodeId) ||
744
+ !Array.isArray(raw.dependsOn) ||
745
+ !raw.dependsOn.every(studioToken) ||
746
+ typeof raw.state !== "string" ||
747
+ !STUDIO_STATES.has(raw.state) ||
748
+ !studioInt(raw.attempt, 1) ||
749
+ !studioInt(raw.retryCount) ||
750
+ typeof raw.checkpointed !== "boolean" ||
751
+ typeof raw.cancelled !== "boolean" ||
752
+ typeof raw.recovered !== "boolean"
753
+ )
754
+ return undefined
755
+ nodes.push(
756
+ Object.freeze({
757
+ nodeId: raw.nodeId,
758
+ dependsOn: Object.freeze([...raw.dependsOn].sort()),
759
+ state: raw.state as RunStudioNodeState,
760
+ attempt: raw.attempt,
761
+ retryCount: raw.retryCount,
762
+ checkpointed: raw.checkpointed,
763
+ cancelled: raw.cancelled,
764
+ recovered: raw.recovered,
765
+ }),
766
+ )
767
+ }
768
+ nodes.sort((a, b) => a.nodeId.localeCompare(b.nodeId))
769
+ return Object.freeze({
770
+ runId: value.runId,
771
+ planId: value.planId,
772
+ planDigest: value.planDigest,
773
+ cursor: value.cursor,
774
+ state: value.state as RunStudioNodeState,
775
+ nodes: Object.freeze(nodes),
776
+ activeNodes: nodes.filter((node) => node.state === "running" || node.state === "paused").length,
777
+ terminalNodes: nodes.filter(
778
+ (node) => node.state === "succeeded" || node.state === "failed" || node.state === "cancelled",
779
+ ).length,
780
+ ...(value.traceRef === undefined ? {} : { traceRef: value.traceRef }),
781
+ ...(value.replayRef === undefined ? {} : { replayRef: value.replayRef }),
782
+ })
783
+ }
784
+
785
+ /** Drop every field except bounded timeline evidence and sort by sequence. */
786
+ export function toEvidenceTimelineView(value: unknown): readonly EvidenceTimelineView[] {
787
+ if (!Array.isArray(value)) return []
788
+ const rows: EvidenceTimelineView[] = []
789
+ for (const raw of value) {
790
+ if (
791
+ !studioRecord(raw) ||
792
+ !studioSafeKeys(
793
+ raw,
794
+ new Set([
795
+ "seq",
796
+ "eventId",
797
+ "runId",
798
+ "nodeId",
799
+ "status",
800
+ "attempt",
801
+ "traceRef",
802
+ "replayRef",
803
+ "scheduleToken",
804
+ ]),
805
+ )
806
+ )
807
+ continue
808
+ if (
809
+ !studioInt(raw.seq) ||
810
+ !studioToken(raw.eventId) ||
811
+ !studioToken(raw.runId) ||
812
+ !studioToken(raw.nodeId) ||
813
+ typeof raw.status !== "string" ||
814
+ !STUDIO_STATUSES.has(raw.status) ||
815
+ !studioInt(raw.attempt, 1)
816
+ )
817
+ continue
818
+ if (raw.traceRef !== undefined && !studioToken(raw.traceRef)) continue
819
+ if (raw.replayRef !== undefined && !studioToken(raw.replayRef)) continue
820
+ if (raw.scheduleToken !== undefined && !studioToken(raw.scheduleToken)) continue
821
+ rows.push(
822
+ Object.freeze({
823
+ seq: raw.seq,
824
+ eventId: raw.eventId,
825
+ runId: raw.runId,
826
+ nodeId: raw.nodeId,
827
+ status: raw.status as EvidenceTimelineView["status"],
828
+ attempt: raw.attempt,
829
+ ...(raw.traceRef === undefined ? {} : { traceRef: raw.traceRef }),
830
+ ...(raw.replayRef === undefined ? {} : { replayRef: raw.replayRef }),
831
+ ...(raw.scheduleToken === undefined ? {} : { scheduleToken: raw.scheduleToken }),
832
+ }),
833
+ )
834
+ }
835
+ rows.sort((a, b) => a.seq - b.seq)
836
+ return Object.freeze(rows)
837
+ }
838
+
839
+ export function toEvalComparisonView(value: unknown): EvalComparisonView | undefined {
840
+ if (
841
+ !studioRecord(value) ||
842
+ !studioSafeKeys(value, new Set(["suiteId", "comparisons", "regressions"])) ||
843
+ !studioToken(value.suiteId) ||
844
+ !Array.isArray(value.comparisons) ||
845
+ !Array.isArray(value.regressions) ||
846
+ !value.regressions.every(studioToken)
847
+ )
848
+ return undefined
849
+ const comparisons: Array<EvalComparisonView["comparisons"][number]> = []
850
+ for (const raw of value.comparisons) {
851
+ if (
852
+ !studioRecord(raw) ||
853
+ !studioSafeKeys(raw, new Set(["caseId", "rubricId", "code", "regressionId"])) ||
854
+ !studioToken(raw.caseId) ||
855
+ !studioToken(raw.rubricId) ||
856
+ typeof raw.code !== "string" ||
857
+ !STUDIO_CODES.has(raw.code) ||
858
+ !studioToken(raw.regressionId)
859
+ )
860
+ return undefined
861
+ comparisons.push(
862
+ Object.freeze({
863
+ caseId: raw.caseId,
864
+ rubricId: raw.rubricId,
865
+ code: raw.code as EvalComparisonViewCode,
866
+ regressionId: raw.regressionId,
867
+ }),
868
+ )
869
+ }
870
+ return Object.freeze({
871
+ suiteId: value.suiteId,
872
+ comparisons: Object.freeze(comparisons),
873
+ regressionIds: Object.freeze([...value.regressions]),
874
+ })
875
+ }
876
+
877
+ export function toFaultInjectionViews(value: unknown): readonly FaultInjectionView[] {
878
+ if (!Array.isArray(value)) return []
879
+ const views: FaultInjectionView[] = []
880
+ for (const raw of value) {
881
+ if (
882
+ !studioRecord(raw) ||
883
+ !studioSafeKeys(raw, new Set(["id", "kind", "scheduleToken", "regressionId", "ok"])) ||
884
+ !studioToken(raw.id) ||
885
+ !studioToken(raw.kind) ||
886
+ !studioToken(raw.scheduleToken) ||
887
+ !studioToken(raw.regressionId) ||
888
+ typeof raw.ok !== "boolean"
889
+ )
890
+ continue
891
+ views.push(
892
+ Object.freeze({
893
+ id: raw.id,
894
+ kind: raw.kind,
895
+ scheduleToken: raw.scheduleToken,
896
+ regressionId: raw.regressionId,
897
+ ok: raw.ok,
898
+ }),
899
+ )
900
+ }
901
+ return Object.freeze(views)
902
+ }
903
+
904
+ /** Keep browser work bounded when a run has more than 1,000 evidence rows. */
905
+ export function virtualizeEvidenceRows<T>(
906
+ rows: readonly T[],
907
+ cursor: number,
908
+ windowSize = 100,
909
+ ): { readonly offset: number; readonly rows: readonly T[] } {
910
+ if (!Number.isSafeInteger(cursor) || cursor < 0)
911
+ throw new RangeError("evidence cursor must be non-negative")
912
+ if (!Number.isSafeInteger(windowSize) || windowSize < 1 || windowSize > 256)
913
+ throw new RangeError("evidence window size is invalid")
914
+ const offset = Math.min(
915
+ Math.max(0, cursor - Math.floor(windowSize / 2)),
916
+ Math.max(0, rows.length - windowSize),
917
+ )
918
+ return Object.freeze({ offset, rows: Object.freeze(rows.slice(offset, offset + windowSize)) })
919
+ }