@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.
- package/README.md +70 -0
- package/dist/client.d.ts +150 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +340 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/transport.d.ts +61 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +145 -0
- package/dist/transport.js.map +1 -0
- package/dist/view-models.d.ts +283 -0
- package/dist/view-models.d.ts.map +1 -0
- package/dist/view-models.js +579 -0
- package/dist/view-models.js.map +1 -0
- package/package.json +49 -0
- package/src/client.ts +465 -0
- package/src/index.ts +74 -0
- package/src/transport.ts +179 -0
- package/src/view-models.ts +919 -0
|
@@ -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
|
+
}
|