@tt-a1i/openpi 0.3.1 → 0.5.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.
Files changed (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -0,0 +1,593 @@
1
+ import type {
2
+ AgentRecord,
3
+ AgentUsage,
4
+ WorkflowDetails,
5
+ WorkflowMemoryProjection,
6
+ } from "./model.ts";
7
+ import { toSerializable } from "./serialization.ts";
8
+
9
+ /** Defaults apply only to settled session-memory projections. Disk is canonical. */
10
+ export const DEFAULT_WORKFLOW_SETTLED_MAX_RUNS = 32;
11
+ export const DEFAULT_WORKFLOW_SETTLED_MAX_BYTES = 2 * 1024 * 1024;
12
+
13
+ const MAX_PHASES = 32;
14
+ const MAX_LOGS = 8;
15
+ const MAX_NAME_BYTES = 512;
16
+ const MAX_DESCRIPTION_BYTES = 1_024;
17
+ const MAX_LABEL_BYTES = 256;
18
+ const MAX_PREVIEW_BYTES = 512;
19
+ const MAX_ERROR_BYTES = 1_024;
20
+
21
+ export interface WorkflowSettledRunRetentionOptions {
22
+ /** Maximum number of settled projections retained in this session. */
23
+ readonly maxRuns?: number;
24
+ /** Maximum serialized UTF-8 bytes retained by all projections. */
25
+ readonly maxBytes?: number;
26
+ }
27
+
28
+ export interface WorkflowRetentionStats {
29
+ /** These counters describe only the current process/session epoch. */
30
+ readonly scope: "current-session";
31
+ readonly retainedRuns: number;
32
+ readonly retainedBytes: number;
33
+ /** Cumulative runs removed by count/byte pressure in this session. */
34
+ readonly evictedRuns: number;
35
+ /** Compatibility alias for evictedRuns. */
36
+ readonly settledRunsEvicted: number;
37
+ /** Serialized bytes belonging to projections removed by pressure. */
38
+ readonly evictedBytes: number;
39
+ }
40
+
41
+ function utf8Head(value: string, maxBytes: number) {
42
+ if (maxBytes <= 0) return "";
43
+ const bytes = Buffer.from(value, "utf8");
44
+ if (bytes.length <= maxBytes) return value;
45
+ let end = maxBytes;
46
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
47
+ return bytes.subarray(0, end).toString("utf8");
48
+ }
49
+
50
+ function bounded(value: string | undefined, maxBytes: number) {
51
+ return value === undefined ? undefined : utf8Head(value, maxBytes);
52
+ }
53
+
54
+ function jsonBytes(value: unknown) {
55
+ const serialized = JSON.stringify(toSerializable(value));
56
+ if (serialized === undefined)
57
+ throw new Error("Workflow projection is not serializable");
58
+ return Buffer.byteLength(serialized, "utf8");
59
+ }
60
+
61
+ function validateLimit(value: number, name: string) {
62
+ if (!Number.isSafeInteger(value) || value < 0) {
63
+ throw new Error(`${name} must be a non-negative safe integer`);
64
+ }
65
+ return value;
66
+ }
67
+
68
+ function finite(value: number | undefined) {
69
+ return typeof value === "number" && Number.isFinite(value)
70
+ ? value
71
+ : undefined;
72
+ }
73
+
74
+ function compactUsage(usage: AgentUsage) {
75
+ return {
76
+ input: finite(usage.input) ?? 0,
77
+ output: finite(usage.output) ?? 0,
78
+ cacheRead: finite(usage.cacheRead) ?? 0,
79
+ cacheWrite: finite(usage.cacheWrite) ?? 0,
80
+ cost: finite(usage.cost) ?? 0,
81
+ ...(finite(usage.contextTokens) !== undefined
82
+ ? { contextTokens: usage.contextTokens }
83
+ : {}),
84
+ turns: finite(usage.turns) ?? 0,
85
+ };
86
+ }
87
+
88
+ function compactInvocation(agent: AgentRecord) {
89
+ const invocation = agent.invocation;
90
+ if (!invocation) return undefined;
91
+ return {
92
+ identity: {
93
+ runId: invocation.identity.runId,
94
+ callIndex: invocation.identity.callIndex,
95
+ },
96
+ intentState: invocation.intentState,
97
+ admissionState: invocation.admissionState,
98
+ executionState: invocation.executionState,
99
+ ...(invocation.outcome ? { outcome: invocation.outcome } : {}),
100
+ requestedAt: invocation.requestedAt,
101
+ ...(invocation.claimedAt !== undefined
102
+ ? { claimedAt: invocation.claimedAt }
103
+ : {}),
104
+ ...(invocation.runningAt !== undefined
105
+ ? { runningAt: invocation.runningAt }
106
+ : {}),
107
+ ...(invocation.terminalAt !== undefined
108
+ ? { terminalAt: invocation.terminalAt }
109
+ : {}),
110
+ };
111
+ }
112
+
113
+ function compactAgent(
114
+ agent: AgentRecord,
115
+ display: boolean,
116
+ ): Record<string, unknown> {
117
+ const result: Record<string, unknown> = {
118
+ index: agent.index,
119
+ ...(agent.callId ? { callId: agent.callId } : {}),
120
+ ...(agent.invocation ? { invocation: compactInvocation(agent) } : {}),
121
+ label: bounded(agent.label, MAX_LABEL_BYTES) ?? "agent",
122
+ ...(agent.phase ? { phase: bounded(agent.phase, MAX_LABEL_BYTES) } : {}),
123
+ state: agent.state,
124
+ ...(agent.model ? { model: bounded(agent.model, MAX_LABEL_BYTES) } : {}),
125
+ ...(agent.contextWindow !== undefined
126
+ ? { contextWindow: agent.contextWindow }
127
+ : {}),
128
+ startedAt: agent.startedAt,
129
+ ...(agent.finishedAt !== undefined ? { finishedAt: agent.finishedAt } : {}),
130
+ usage: compactUsage(agent.usage),
131
+ transcript: [],
132
+ };
133
+
134
+ // These are recovery references, not display text. Truncating them would
135
+ // turn an otherwise recoverable artifact into an unusable path.
136
+ if (agent.resultArtifact) result.resultArtifact = agent.resultArtifact;
137
+ if (agent.resultRef) result.resultRef = agent.resultRef;
138
+
139
+ if (!display) return result;
140
+ if (agent.preview) result.preview = bounded(agent.preview, MAX_PREVIEW_BYTES);
141
+ if (agent.error) result.error = bounded(agent.error, MAX_ERROR_BYTES);
142
+ if (agent.replayed) result.replayed = true;
143
+ if (agent.operatorKey) result.operatorKey = agent.operatorKey;
144
+ if (agent.inputCallIds?.length)
145
+ result.inputCallIds = agent.inputCallIds.slice(0, 32);
146
+ if (agent.worktreeBranch) result.worktreeBranch = agent.worktreeBranch;
147
+ if (agent.worktreePath) result.worktreePath = agent.worktreePath;
148
+ if (agent.worktreeHandoffArtifact)
149
+ result.worktreeHandoffArtifact = agent.worktreeHandoffArtifact;
150
+ if (agent.worktreeCleanup) {
151
+ result.worktreeCleanup = {
152
+ removed: agent.worktreeCleanup.removed,
153
+ branchDeleted: agent.worktreeCleanup.branchDeleted,
154
+ branch: agent.worktreeCleanup.branch,
155
+ detached: agent.worktreeCleanup.detached,
156
+ ...(agent.worktreeCleanup.reason
157
+ ? { reason: bounded(agent.worktreeCleanup.reason, MAX_ERROR_BYTES) }
158
+ : {}),
159
+ };
160
+ }
161
+ if (agent.acceptance) {
162
+ result.acceptance = {
163
+ status: agent.acceptance.status,
164
+ errors: agent.acceptance.errors
165
+ .slice(0, 4)
166
+ .map((error) => bounded(error, MAX_ERROR_BYTES)),
167
+ criteria: agent.acceptance.criteria.slice(0, 16).map((criterion) => ({
168
+ id: bounded(criterion.id, MAX_LABEL_BYTES),
169
+ status: criterion.status,
170
+ evidence: criterion.evidence
171
+ .slice(0, 4)
172
+ .map((evidence) => bounded(evidence, MAX_PREVIEW_BYTES)),
173
+ ...(criterion.note
174
+ ? { note: bounded(criterion.note, MAX_ERROR_BYTES) }
175
+ : {}),
176
+ })),
177
+ };
178
+ }
179
+ return result;
180
+ }
181
+
182
+ function omissionMetadata(
183
+ details: WorkflowDetails,
184
+ agentLimit: number,
185
+ logLimit: number,
186
+ ): WorkflowMemoryProjection["omitted"] {
187
+ return {
188
+ agents: Math.max(0, details.agents.length - agentLimit),
189
+ logs:
190
+ Math.max(0, (details.logs?.length ?? 0) - logLimit) +
191
+ (details.logsDropped ?? 0),
192
+ transcriptEntries: details.agents.reduce(
193
+ (total, agent) => total + agent.transcript.length,
194
+ 0,
195
+ ),
196
+ result: details.result !== undefined,
197
+ graph: details.graph !== undefined,
198
+ };
199
+ }
200
+
201
+ function makeProjection(
202
+ details: WorkflowDetails,
203
+ cap: number,
204
+ agents: readonly Record<string, unknown>[],
205
+ logs: readonly unknown[],
206
+ agentLimit: number,
207
+ logLimit: number,
208
+ ) {
209
+ const phases = details.phases.slice(0, MAX_PHASES).map((phase) => ({
210
+ title: bounded(phase.title, MAX_LABEL_BYTES) ?? "phase",
211
+ ...(phase.detail
212
+ ? { detail: bounded(phase.detail, MAX_DESCRIPTION_BYTES) }
213
+ : {}),
214
+ }));
215
+ const omitted = omissionMetadata(details, agentLimit, logLimit);
216
+ const candidate: Record<string, unknown> = {
217
+ runId: details.runId,
218
+ ...(details.sessionId ? { sessionId: details.sessionId } : {}),
219
+ ...(details.name ? { name: bounded(details.name, MAX_NAME_BYTES) } : {}),
220
+ ...(details.description
221
+ ? { description: bounded(details.description, MAX_DESCRIPTION_BYTES) }
222
+ : {}),
223
+ background: details.background,
224
+ status: details.status,
225
+ startedAt: details.startedAt,
226
+ ...(details.finishedAt !== undefined
227
+ ? { finishedAt: details.finishedAt }
228
+ : {}),
229
+ phases,
230
+ ...(details.currentPhase
231
+ ? { currentPhase: bounded(details.currentPhase, MAX_LABEL_BYTES) }
232
+ : {}),
233
+ agents,
234
+ ...(logs.length > 0 ? { logs } : {}),
235
+ ...(details.logsDropped ? { logsDropped: details.logsDropped } : {}),
236
+ ...(details.delivery
237
+ ? {
238
+ delivery: {
239
+ id: details.delivery.id,
240
+ state: details.delivery.state,
241
+ attempts: details.delivery.attempts,
242
+ updatedAt: details.delivery.updatedAt,
243
+ ...(details.delivery.deliveredAt !== undefined
244
+ ? { deliveredAt: details.delivery.deliveredAt }
245
+ : {}),
246
+ ...(details.delivery.lastError
247
+ ? {
248
+ lastError: bounded(
249
+ details.delivery.lastError,
250
+ MAX_ERROR_BYTES,
251
+ ),
252
+ }
253
+ : {}),
254
+ },
255
+ }
256
+ : {}),
257
+ ...(details.result !== undefined || details.resultArtifact
258
+ ? {
259
+ result: details.resultArtifact
260
+ ? "[stored in result.json]"
261
+ : "[result omitted from memory]",
262
+ ...(details.resultArtifact
263
+ ? { resultArtifact: details.resultArtifact }
264
+ : {}),
265
+ }
266
+ : {}),
267
+ ...(details.transcriptArtifact
268
+ ? { transcriptArtifact: details.transcriptArtifact }
269
+ : {}),
270
+ ...(details.resumedFrom ? { resumedFrom: details.resumedFrom } : {}),
271
+ ...(details.resumeNote
272
+ ? { resumeNote: bounded(details.resumeNote, MAX_ERROR_BYTES) }
273
+ : {}),
274
+ ...(details.error
275
+ ? { error: bounded(details.error, MAX_ERROR_BYTES) }
276
+ : {}),
277
+ ...(details.graph ? { graphOmitted: true } : {}),
278
+ };
279
+
280
+ const sourceBytes = jsonBytes(details);
281
+ const projectedBytes = jsonBytes(candidate);
282
+ const omittedBytes = Math.max(0, sourceBytes - projectedBytes);
283
+ const metadata = (bytes: number): WorkflowMemoryProjection => ({
284
+ kind: "settled",
285
+ maxBytes: cap,
286
+ bytes,
287
+ truncated:
288
+ omittedBytes > 0 ||
289
+ omitted.agents > 0 ||
290
+ omitted.logs > 0 ||
291
+ omitted.transcriptEntries > 0 ||
292
+ omitted.result ||
293
+ omitted.graph,
294
+ omitted,
295
+ });
296
+
297
+ return { candidate, metadata };
298
+ }
299
+
300
+ /**
301
+ * Build a detached settled-run projection. It intentionally never copies
302
+ * transcripts or arbitrary result values; those remain in the run artifacts.
303
+ */
304
+ export function projectWorkflowDetails(
305
+ details: WorkflowDetails,
306
+ maxBytes = DEFAULT_WORKFLOW_SETTLED_MAX_BYTES,
307
+ ): WorkflowDetails | undefined {
308
+ const cap = validateLimit(maxBytes, "maxBytes");
309
+ if (cap === 0) return undefined;
310
+
311
+ let display = true;
312
+ let agentLimit = details.agents.length;
313
+ let logLimit = Math.min(MAX_LOGS, details.logs?.length ?? 0);
314
+ let agents = details.agents.map((agent) => compactAgent(agent, display));
315
+ let logs = (details.logs ?? []).slice(-logLimit).map((log) => ({
316
+ at: log.at,
317
+ text: bounded(log.text, MAX_PREVIEW_BYTES) ?? "",
318
+ }));
319
+
320
+ const removeOptionalFields = () => {
321
+ display = false;
322
+ agents = details.agents.map((agent) => compactAgent(agent, false));
323
+ logs = [];
324
+ logLimit = 0;
325
+ };
326
+
327
+ const dropAgent = () => {
328
+ if (agents.length === 0) return false;
329
+ // Keep the oldest and newest identities preferentially while pressure
330
+ // removes middle display rows. Lifecycle facts for retained rows survive.
331
+ const index = agents.length > 1 ? Math.floor(agents.length / 2) : 0;
332
+ agents = [...agents.slice(0, index), ...agents.slice(index + 1)];
333
+ agentLimit--;
334
+ return true;
335
+ };
336
+
337
+ const dropRootOptional = (candidate: Record<string, unknown>) => {
338
+ for (const key of [
339
+ "description",
340
+ "resumeNote",
341
+ "error",
342
+ "currentPhase",
343
+ "name",
344
+ "sessionId",
345
+ "logsDropped",
346
+ "logs",
347
+ "graphOmitted",
348
+ "result",
349
+ ]) {
350
+ if (key in candidate) {
351
+ delete candidate[key];
352
+ return true;
353
+ }
354
+ }
355
+ return false;
356
+ };
357
+
358
+ let candidate: Record<string, unknown> | undefined;
359
+ let projection: WorkflowMemoryProjection | undefined;
360
+ for (let pass = 0; pass < details.agents.length + 32; pass++) {
361
+ const built = makeProjection(
362
+ details,
363
+ cap,
364
+ agents,
365
+ logs,
366
+ agentLimit,
367
+ logLimit,
368
+ );
369
+ candidate = built.candidate;
370
+ projection = built.metadata(0);
371
+ candidate.memoryProjection = projection;
372
+ let bytes = jsonBytes(candidate);
373
+ if (bytes <= cap) {
374
+ // The byte count is itself part of the projection. Iterate until the
375
+ // decimal width of that field stabilizes.
376
+ for (let i = 0; i < 4; i++) {
377
+ candidate.memoryProjection = built.metadata(bytes);
378
+ const next = jsonBytes(candidate);
379
+ if (next === bytes) break;
380
+ bytes = next;
381
+ }
382
+ candidate.memoryProjection = built.metadata(bytes);
383
+ bytes = jsonBytes(candidate);
384
+ if (bytes <= cap) return candidate as unknown as WorkflowDetails;
385
+ }
386
+
387
+ if (display) {
388
+ removeOptionalFields();
389
+ continue;
390
+ }
391
+ if (dropAgent()) continue;
392
+ if (dropRootOptional(candidate)) continue;
393
+
394
+ // The exact run id and terminal identity are non-negotiable. A caller
395
+ // using an impossibly small test budget gets an explicit non-retained run
396
+ // rather than a projection that cannot be addressed or measured safely.
397
+ const minimal: Record<string, unknown> = {
398
+ runId: details.runId,
399
+ background: details.background,
400
+ status: details.status,
401
+ startedAt: details.startedAt,
402
+ ...(details.finishedAt !== undefined
403
+ ? { finishedAt: details.finishedAt }
404
+ : {}),
405
+ phases: [],
406
+ agents: [],
407
+ ...(details.resultArtifact
408
+ ? { resultArtifact: details.resultArtifact }
409
+ : {}),
410
+ ...(details.transcriptArtifact
411
+ ? { transcriptArtifact: details.transcriptArtifact }
412
+ : {}),
413
+ };
414
+ const minimalProjection = makeProjection(
415
+ details,
416
+ cap,
417
+ [],
418
+ [],
419
+ 0,
420
+ 0,
421
+ ).metadata(0);
422
+ minimal.memoryProjection = minimalProjection;
423
+ const minimalBytes = jsonBytes(minimal);
424
+ if (minimalBytes <= cap) {
425
+ minimal.memoryProjection = { ...minimalProjection, bytes: minimalBytes };
426
+ if (jsonBytes(minimal) <= cap)
427
+ return minimal as unknown as WorkflowDetails;
428
+ }
429
+ return undefined;
430
+ }
431
+ return undefined;
432
+ }
433
+
434
+ export function measureWorkflowDetailsBytes(details: WorkflowDetails) {
435
+ return jsonBytes(details);
436
+ }
437
+
438
+ interface RetainedEntry {
439
+ readonly details: WorkflowDetails;
440
+ readonly bytes: number;
441
+ }
442
+
443
+ /** Count- and byte-bounded insertion-ordered store for settled projections. */
444
+ export class WorkflowSettledRunRetention {
445
+ readonly maxRuns: number;
446
+ readonly maxBytes: number;
447
+ private readonly entries = new Map<string, RetainedEntry>();
448
+ private totalBytes = 0;
449
+ private totalEvictedRuns = 0;
450
+ private totalEvictedBytes = 0;
451
+
452
+ constructor(options: WorkflowSettledRunRetentionOptions = {}) {
453
+ this.maxRuns = validateLimit(
454
+ options.maxRuns ?? DEFAULT_WORKFLOW_SETTLED_MAX_RUNS,
455
+ "maxRuns",
456
+ );
457
+ this.maxBytes = validateLimit(
458
+ options.maxBytes ?? DEFAULT_WORKFLOW_SETTLED_MAX_BYTES,
459
+ "maxBytes",
460
+ );
461
+ }
462
+
463
+ set(details: WorkflowDetails) {
464
+ const previous = this.entries.get(details.runId);
465
+ if (previous) {
466
+ this.entries.delete(details.runId);
467
+ this.totalBytes -= previous.bytes;
468
+ }
469
+ const projection = projectWorkflowDetails(details, this.maxBytes);
470
+ if (!projection) {
471
+ // Keep a previously valid projection addressable when an unusually small
472
+ // configured budget cannot represent a later update. The canonical file
473
+ // remains the recovery source either way.
474
+ if (previous) {
475
+ this.entries.set(details.runId, previous);
476
+ this.totalBytes += previous.bytes;
477
+ } else {
478
+ this.totalEvictedRuns++;
479
+ }
480
+ return undefined;
481
+ }
482
+ const bytes = measureWorkflowDetailsBytes(projection);
483
+ this.entries.set(details.runId, { details: projection, bytes });
484
+ this.totalBytes += bytes;
485
+ while (
486
+ this.entries.size > this.maxRuns ||
487
+ this.totalBytes > this.maxBytes
488
+ ) {
489
+ const oldest = this.entries.keys().next().value;
490
+ if (oldest === undefined) break;
491
+ const entry = this.entries.get(oldest);
492
+ this.entries.delete(oldest);
493
+ if (!entry) continue;
494
+ this.totalBytes -= entry.bytes;
495
+ this.totalEvictedRuns++;
496
+ this.totalEvictedBytes += entry.bytes;
497
+ }
498
+ return this.entries.get(details.runId)?.details;
499
+ }
500
+
501
+ get(runId: string) {
502
+ return this.entries.get(runId)?.details;
503
+ }
504
+
505
+ has(runId: string) {
506
+ return this.entries.has(runId);
507
+ }
508
+
509
+ delete(runId: string) {
510
+ const entry = this.entries.get(runId);
511
+ if (!entry) return false;
512
+ this.entries.delete(runId);
513
+ this.totalBytes -= entry.bytes;
514
+ return true;
515
+ }
516
+
517
+ clear() {
518
+ this.entries.clear();
519
+ this.totalBytes = 0;
520
+ }
521
+
522
+ /** Clear retained projections without changing current-session counters. */
523
+ reset() {
524
+ this.clear();
525
+ }
526
+
527
+ /** Start a new session accounting epoch. */
528
+ resetStats() {
529
+ this.totalEvictedRuns = 0;
530
+ this.totalEvictedBytes = 0;
531
+ }
532
+
533
+ /** Clear projections and start a new session accounting epoch. */
534
+ resetSession() {
535
+ this.clear();
536
+ this.resetStats();
537
+ }
538
+
539
+ keys() {
540
+ return this.entries.keys();
541
+ }
542
+
543
+ values() {
544
+ return [...this.entries.values()].map((entry) => entry.details).values();
545
+ }
546
+
547
+ entriesArray() {
548
+ return [...this.entries.entries()].map(
549
+ ([id, entry]) => [id, entry.details] as const,
550
+ );
551
+ }
552
+
553
+ get size() {
554
+ return this.entries.size;
555
+ }
556
+
557
+ get retainedBytes() {
558
+ return this.totalBytes;
559
+ }
560
+
561
+ get evictedRuns() {
562
+ return this.totalEvictedRuns;
563
+ }
564
+
565
+ get settledRunsEvicted() {
566
+ return this.totalEvictedRuns;
567
+ }
568
+
569
+ get evictedBytes() {
570
+ return this.totalEvictedBytes;
571
+ }
572
+
573
+ get stats(): WorkflowRetentionStats {
574
+ return {
575
+ scope: "current-session",
576
+ retainedRuns: this.entries.size,
577
+ retainedBytes: this.totalBytes,
578
+ evictedRuns: this.totalEvictedRuns,
579
+ settledRunsEvicted: this.totalEvictedRuns,
580
+ evictedBytes: this.totalEvictedBytes,
581
+ };
582
+ }
583
+
584
+ getStats() {
585
+ return this.stats;
586
+ }
587
+ }
588
+
589
+ export function createWorkflowSettledRunRetention(
590
+ options: WorkflowSettledRunRetentionOptions = {},
591
+ ) {
592
+ return new WorkflowSettledRunRetention(options);
593
+ }