pi-ultracode 0.1.0 → 0.1.2
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 +401 -148
- package/extensions/ultracode.ts +134 -16
- package/package.json +22 -1
- package/src/commands.ts +32 -84
- package/src/index.ts +20 -0
- package/src/mode.ts +445 -34
- package/src/prompts.ts +2 -2
- package/src/thinking.ts +27 -0
- package/src/workflow/agent-runner.ts +903 -53
- package/src/workflow/agent-types.ts +3 -6
- package/src/workflow/display-text.ts +461 -0
- package/src/workflow/display.ts +188 -55
- package/src/workflow/journal.ts +0 -0
- package/src/workflow/registry.ts +106 -10
- package/src/workflow/run-details.ts +1276 -0
- package/src/workflow/runtime.ts +189 -38
- package/src/workflow/tool.ts +285 -47
- package/src/workflow/workflow-overlay.ts +719 -0
- package/types/workflow.d.ts +1 -1
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AgentTelemetryEvent, AgentUsage, ThinkingLevel } from "./agent-runner.ts";
|
|
4
|
+
import { safeDisplayText, safeTranscriptText } from "./display-text.ts";
|
|
5
|
+
import { preview, recompute, type WorkflowAgentStatus, type WorkflowSnapshot } from "./display.ts";
|
|
6
|
+
|
|
7
|
+
export const RUN_DETAILS_VERSION = 1;
|
|
8
|
+
export const MAX_LIVE_TASK_BYTES = 1024 * 1024;
|
|
9
|
+
export const MAX_LIVE_TASK_LINES = 5_000;
|
|
10
|
+
export const MAX_LIVE_RUN_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
export const MAX_TASK_TRANSCRIPT_BYTES = 10 * 1024 * 1024;
|
|
12
|
+
export const MAX_RUN_TRANSCRIPT_BYTES = 128 * 1024 * 1024;
|
|
13
|
+
const MAX_DETAILS_MANIFEST_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
const MAX_RUN_TRANSCRIPT_PAYLOAD_BYTES = MAX_RUN_TRANSCRIPT_BYTES - MAX_DETAILS_MANIFEST_BYTES;
|
|
15
|
+
const TRANSCRIPT_HEAD_BYTES = 1024 * 1024;
|
|
16
|
+
const TRANSCRIPT_MARKER_BYTES = 1024;
|
|
17
|
+
const TRANSCRIPT_TAIL_BYTES = MAX_TASK_TRANSCRIPT_BYTES - TRANSCRIPT_HEAD_BYTES - TRANSCRIPT_MARKER_BYTES;
|
|
18
|
+
const TRANSCRIPT_TEXT_CHUNK_CHARS = 48 * 1024;
|
|
19
|
+
const MAX_PERSISTED_EVENT_TEXT = 32 * 1024 * 1024;
|
|
20
|
+
const MAX_RESULT_PREVIEW = 64 * 1024;
|
|
21
|
+
const MAX_LIVE_PROMPT_BYTES = 128 * 1024;
|
|
22
|
+
const MAX_LIVE_PROMPT_LINES = 500;
|
|
23
|
+
const ESTIMATED_RENDER_WIDTH = 80;
|
|
24
|
+
const LIVE_OMISSION_TEXT = "… earlier live output omitted …";
|
|
25
|
+
const PROMPT_OMISSION_TEXT = "… prompt truncated in live view; full prompt remains in the transcript …";
|
|
26
|
+
|
|
27
|
+
export interface WorkflowTaskUsage {
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
cacheReadTokens: number;
|
|
31
|
+
cacheWriteTokens: number;
|
|
32
|
+
totalTokens: number;
|
|
33
|
+
cost: number;
|
|
34
|
+
turns: number;
|
|
35
|
+
toolUses: number;
|
|
36
|
+
retries: number;
|
|
37
|
+
compactions: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type WorkflowTimelineKind =
|
|
41
|
+
| "turn"
|
|
42
|
+
| "text"
|
|
43
|
+
| "thinking"
|
|
44
|
+
| "tool"
|
|
45
|
+
| "retry"
|
|
46
|
+
| "compaction"
|
|
47
|
+
| "error"
|
|
48
|
+
| "omitted";
|
|
49
|
+
|
|
50
|
+
export interface WorkflowTimelineEvent {
|
|
51
|
+
seq: number;
|
|
52
|
+
ts: number;
|
|
53
|
+
kind: WorkflowTimelineKind;
|
|
54
|
+
turn?: number;
|
|
55
|
+
text?: string;
|
|
56
|
+
streaming?: boolean;
|
|
57
|
+
startedAt?: number;
|
|
58
|
+
endedAt?: number;
|
|
59
|
+
toolCallId?: string;
|
|
60
|
+
toolName?: string;
|
|
61
|
+
toolArgs?: string;
|
|
62
|
+
resultPreview?: string;
|
|
63
|
+
isError?: boolean;
|
|
64
|
+
state?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface WorkflowTaskSummary {
|
|
68
|
+
id: number;
|
|
69
|
+
label: string;
|
|
70
|
+
phase?: string;
|
|
71
|
+
workflowPath: string[];
|
|
72
|
+
status: WorkflowAgentStatus;
|
|
73
|
+
promptPreview: string;
|
|
74
|
+
requestedModelId?: string;
|
|
75
|
+
requestedEffort?: ThinkingLevel;
|
|
76
|
+
modelId?: string;
|
|
77
|
+
effort?: ThinkingLevel;
|
|
78
|
+
usage: WorkflowTaskUsage;
|
|
79
|
+
currentTurn?: number;
|
|
80
|
+
startedAt?: number;
|
|
81
|
+
endedAt?: number;
|
|
82
|
+
durationMs?: number;
|
|
83
|
+
resultPreview?: string;
|
|
84
|
+
error?: string;
|
|
85
|
+
agentType?: string;
|
|
86
|
+
isolation?: string;
|
|
87
|
+
structuredOutput?: boolean;
|
|
88
|
+
cached?: boolean;
|
|
89
|
+
legacyCache?: boolean;
|
|
90
|
+
transcriptPath?: string;
|
|
91
|
+
transcriptOmittedBytes?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface WorkflowTaskDetail extends WorkflowTaskSummary {
|
|
95
|
+
prompt: string;
|
|
96
|
+
events: WorkflowTimelineEvent[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface StartWorkflowTaskInput {
|
|
100
|
+
id: number;
|
|
101
|
+
label: string;
|
|
102
|
+
phase?: string;
|
|
103
|
+
workflowPath?: string[];
|
|
104
|
+
prompt: string;
|
|
105
|
+
modelPattern?: string;
|
|
106
|
+
requestedEffort?: ThinkingLevel;
|
|
107
|
+
agentType?: string;
|
|
108
|
+
isolation?: string;
|
|
109
|
+
structuredOutput?: boolean;
|
|
110
|
+
cached?: boolean;
|
|
111
|
+
cachedRecord?: Partial<WorkflowTaskSummary>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface FinishWorkflowTaskInput {
|
|
115
|
+
status: "done" | "error" | "cancelled" | "cached";
|
|
116
|
+
result?: unknown;
|
|
117
|
+
error?: string;
|
|
118
|
+
usage?: AgentUsage;
|
|
119
|
+
modelId?: string;
|
|
120
|
+
effort?: ThinkingLevel;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface WorkflowRunDetailsManifest {
|
|
124
|
+
version: number;
|
|
125
|
+
runId: string;
|
|
126
|
+
name: string;
|
|
127
|
+
updatedAt: number;
|
|
128
|
+
snapshot: WorkflowSnapshot;
|
|
129
|
+
tasks: WorkflowTaskSummary[];
|
|
130
|
+
transcriptBytes: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface InternalTask extends WorkflowTaskDetail {
|
|
134
|
+
eventsLoaded: boolean;
|
|
135
|
+
writer?: BoundedTaskTranscriptWriter;
|
|
136
|
+
promptBytes: number;
|
|
137
|
+
promptLines: number;
|
|
138
|
+
liveBytes: number;
|
|
139
|
+
liveLines: number;
|
|
140
|
+
omittedLiveBytes: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function emptyUsage(): WorkflowTaskUsage {
|
|
144
|
+
return {
|
|
145
|
+
inputTokens: 0,
|
|
146
|
+
outputTokens: 0,
|
|
147
|
+
cacheReadTokens: 0,
|
|
148
|
+
cacheWriteTokens: 0,
|
|
149
|
+
totalTokens: 0,
|
|
150
|
+
cost: 0,
|
|
151
|
+
turns: 0,
|
|
152
|
+
toolUses: 0,
|
|
153
|
+
retries: 0,
|
|
154
|
+
compactions: 0,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function normalizeTaskUsage(value: AgentUsage | Partial<WorkflowTaskUsage> | undefined): WorkflowTaskUsage {
|
|
159
|
+
const inputTokens = finite(value?.inputTokens);
|
|
160
|
+
const outputTokens = finite(value?.outputTokens);
|
|
161
|
+
return {
|
|
162
|
+
inputTokens,
|
|
163
|
+
outputTokens,
|
|
164
|
+
cacheReadTokens: finite(value?.cacheReadTokens),
|
|
165
|
+
cacheWriteTokens: finite(value?.cacheWriteTokens),
|
|
166
|
+
totalTokens: inputTokens + outputTokens,
|
|
167
|
+
cost: finite(value?.cost),
|
|
168
|
+
turns: finite(value?.turns),
|
|
169
|
+
toolUses: finite(value?.toolUses),
|
|
170
|
+
retries: finite(value?.retries),
|
|
171
|
+
compactions: finite(value?.compactions),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function finite(value: unknown): number {
|
|
176
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function displayActualModelId(value: string | undefined): string | undefined {
|
|
180
|
+
if (!value) return undefined;
|
|
181
|
+
const withoutThinking = value.replace(/:(?:off|minimal|low|medium|high|xhigh|max)$/i, "");
|
|
182
|
+
return safeDisplayText(withoutThinking, 120) || undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function displayRequestedModelId(value: string | undefined): string | undefined {
|
|
186
|
+
if (!value) return undefined;
|
|
187
|
+
const withoutThinking = value.replace(/:(?:off|minimal|low|medium|high|xhigh|max)$/i, "");
|
|
188
|
+
const providerSeparator = withoutThinking.indexOf("/");
|
|
189
|
+
return safeDisplayText(providerSeparator >= 0 ? withoutThinking.slice(providerSeparator + 1) : withoutThinking, 120) || undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function safeMultiline(value: string, max = MAX_PERSISTED_EVENT_TEXT): string {
|
|
193
|
+
const bounded = value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
194
|
+
return safeTranscriptText(bounded, max);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function chunkText(value: string, maxChars: number): string[] {
|
|
198
|
+
if (!value) return [""];
|
|
199
|
+
const chunks: string[] = [];
|
|
200
|
+
for (let offset = 0; offset < value.length; offset += maxChars) {
|
|
201
|
+
chunks.push(value.slice(offset, offset + maxChars));
|
|
202
|
+
}
|
|
203
|
+
return chunks;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function joinTranscriptChunks(chunks: Map<number, string>, expectedCount: number): string {
|
|
207
|
+
const output: string[] = [];
|
|
208
|
+
let previous = -1;
|
|
209
|
+
for (const [index, text] of [...chunks.entries()].sort((left, right) => left[0] - right[0])) {
|
|
210
|
+
if (index > previous + 1) output.push("\n… middle transcript chunks omitted …\n");
|
|
211
|
+
output.push(text);
|
|
212
|
+
previous = index;
|
|
213
|
+
}
|
|
214
|
+
if (previous + 1 < expectedCount) output.push("\n… trailing transcript chunks omitted …");
|
|
215
|
+
return output.join("");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function safeResult(value: unknown): string | undefined {
|
|
219
|
+
if (typeof value === "string") {
|
|
220
|
+
const text = safeMultiline(value, MAX_RESULT_PREVIEW).trim();
|
|
221
|
+
return text || undefined;
|
|
222
|
+
}
|
|
223
|
+
const text = preview(value, MAX_RESULT_PREVIEW).trim();
|
|
224
|
+
return text || undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function advanceRenderedLines(value: string, initialColumn = 0): { added: number; column: number } {
|
|
228
|
+
let added = 0;
|
|
229
|
+
let column = initialColumn;
|
|
230
|
+
for (let index = 0; index < value.length; index++) {
|
|
231
|
+
if (value.charCodeAt(index) === 10) {
|
|
232
|
+
added++;
|
|
233
|
+
column = 0;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
column++;
|
|
237
|
+
if (column >= ESTIMATED_RENDER_WIDTH) {
|
|
238
|
+
added++;
|
|
239
|
+
column = 0;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { added, column };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function estimatedRenderedLines(value: string): number {
|
|
246
|
+
return 1 + advanceRenderedLines(value).added;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function eventWeight(event: WorkflowTimelineEvent): { bytes: number; lines: number } {
|
|
250
|
+
const text = [event.text, event.resultPreview, event.toolArgs].filter(Boolean).join("\n");
|
|
251
|
+
return {
|
|
252
|
+
bytes: Buffer.byteLength(text, "utf8") + 128,
|
|
253
|
+
lines: estimatedRenderedLines(text),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function cloneEvent(event: WorkflowTimelineEvent): WorkflowTimelineEvent {
|
|
258
|
+
return { ...event };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function cloneSummary(task: InternalTask): WorkflowTaskSummary {
|
|
262
|
+
return {
|
|
263
|
+
id: task.id,
|
|
264
|
+
label: task.label,
|
|
265
|
+
phase: task.phase,
|
|
266
|
+
workflowPath: [...task.workflowPath],
|
|
267
|
+
status: task.status,
|
|
268
|
+
promptPreview: task.promptPreview,
|
|
269
|
+
requestedModelId: task.requestedModelId,
|
|
270
|
+
requestedEffort: task.requestedEffort,
|
|
271
|
+
modelId: task.modelId,
|
|
272
|
+
effort: task.effort,
|
|
273
|
+
usage: { ...task.usage },
|
|
274
|
+
currentTurn: task.currentTurn,
|
|
275
|
+
startedAt: task.startedAt,
|
|
276
|
+
endedAt: task.endedAt,
|
|
277
|
+
durationMs: task.durationMs,
|
|
278
|
+
resultPreview: task.resultPreview,
|
|
279
|
+
error: task.error,
|
|
280
|
+
agentType: task.agentType,
|
|
281
|
+
isolation: task.isolation,
|
|
282
|
+
structuredOutput: task.structuredOutput,
|
|
283
|
+
cached: task.cached,
|
|
284
|
+
legacyCache: task.legacyCache,
|
|
285
|
+
transcriptPath: task.transcriptPath,
|
|
286
|
+
transcriptOmittedBytes: task.transcriptOmittedBytes,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Deep module for one run's task details. The workflow tool only forwards
|
|
292
|
+
* lifecycle/telemetry events; buffering, redaction, persistence, and restore
|
|
293
|
+
* stay local to this implementation.
|
|
294
|
+
*/
|
|
295
|
+
export class WorkflowRunDetails {
|
|
296
|
+
readonly runId: string;
|
|
297
|
+
readonly name: string;
|
|
298
|
+
readonly runsDir: string;
|
|
299
|
+
readonly manifestPath: string;
|
|
300
|
+
|
|
301
|
+
private readonly tasks = new Map<number, InternalTask>();
|
|
302
|
+
private readonly listeners = new Set<() => void>();
|
|
303
|
+
private readonly streamingLineColumns = new Map<string, number>();
|
|
304
|
+
private readonly artifactBudget: RunArtifactBudget;
|
|
305
|
+
private sequence = 0;
|
|
306
|
+
private viewedTaskId?: number;
|
|
307
|
+
|
|
308
|
+
constructor(input: { runId: string; name: string; runsDir: string }) {
|
|
309
|
+
this.runId = input.runId;
|
|
310
|
+
this.name = safeDisplayText(input.name, 120) || "workflow";
|
|
311
|
+
this.runsDir = input.runsDir;
|
|
312
|
+
this.manifestPath = path.join(input.runsDir, `${input.runId}.details.json`);
|
|
313
|
+
const taskDir = this.taskDir();
|
|
314
|
+
try {
|
|
315
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
316
|
+
} catch {
|
|
317
|
+
// Artifact persistence is best-effort; live details remain available.
|
|
318
|
+
}
|
|
319
|
+
this.artifactBudget = new RunArtifactBudget(taskDir, MAX_RUN_TRANSCRIPT_PAYLOAD_BYTES);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
static restore(manifestPath: string): { details: WorkflowRunDetails; snapshot: WorkflowSnapshot } | undefined {
|
|
323
|
+
let manifest: WorkflowRunDetailsManifest;
|
|
324
|
+
try {
|
|
325
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as WorkflowRunDetailsManifest;
|
|
326
|
+
} catch {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
if (
|
|
330
|
+
manifest.version !== RUN_DETAILS_VERSION
|
|
331
|
+
|| !/^wf_[a-z0-9_-]{1,120}$/i.test(manifest.runId ?? "")
|
|
332
|
+
|| typeof manifest.name !== "string"
|
|
333
|
+
|| !manifest.snapshot
|
|
334
|
+
) return undefined;
|
|
335
|
+
const details = new WorkflowRunDetails({
|
|
336
|
+
runId: manifest.runId,
|
|
337
|
+
name: manifest.name,
|
|
338
|
+
runsDir: path.dirname(manifestPath),
|
|
339
|
+
});
|
|
340
|
+
for (const rawSummary of Array.isArray(manifest.tasks) ? manifest.tasks : []) {
|
|
341
|
+
const summary = restoreTaskSummary(rawSummary, details.name);
|
|
342
|
+
if (!summary) continue;
|
|
343
|
+
const task: InternalTask = {
|
|
344
|
+
...summary,
|
|
345
|
+
transcriptPath: isPathInside(summary.transcriptPath, details.taskDir()) ? summary.transcriptPath : undefined,
|
|
346
|
+
workflowPath: [...(summary.workflowPath ?? [details.name])],
|
|
347
|
+
usage: normalizeTaskUsage(summary.usage),
|
|
348
|
+
prompt: summary.promptPreview ?? "",
|
|
349
|
+
events: [],
|
|
350
|
+
eventsLoaded: false,
|
|
351
|
+
promptBytes: Buffer.byteLength(summary.promptPreview ?? "", "utf8"),
|
|
352
|
+
promptLines: estimatedRenderedLines(summary.promptPreview ?? ""),
|
|
353
|
+
liveBytes: 0,
|
|
354
|
+
liveLines: 0,
|
|
355
|
+
omittedLiveBytes: 0,
|
|
356
|
+
};
|
|
357
|
+
details.tasks.set(task.id, task);
|
|
358
|
+
details.sequence = Math.max(details.sequence, task.id);
|
|
359
|
+
}
|
|
360
|
+
let snapshot: WorkflowSnapshot;
|
|
361
|
+
try {
|
|
362
|
+
snapshot = recompute(snapshotForManifest(manifest.snapshot));
|
|
363
|
+
} catch {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
snapshot.runId = manifest.runId;
|
|
367
|
+
snapshot.detailsManifestPath = manifestPath;
|
|
368
|
+
snapshot.agents = snapshot.agents.map((agent) => ({
|
|
369
|
+
...agent,
|
|
370
|
+
transcriptPath: isPathInside(agent.transcriptPath, details.taskDir()) ? agent.transcriptPath : undefined,
|
|
371
|
+
}));
|
|
372
|
+
return { details, snapshot };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
subscribe(listener: () => void): () => void {
|
|
376
|
+
this.listeners.add(listener);
|
|
377
|
+
return () => this.listeners.delete(listener);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
setViewedTask(id: number | undefined): void {
|
|
381
|
+
this.viewedTaskId = id;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
startTask(input: StartWorkflowTaskInput): WorkflowTaskSummary {
|
|
385
|
+
const restored = this.tasks.get(input.id);
|
|
386
|
+
if (input.cached && restored) {
|
|
387
|
+
restored.status = "cached";
|
|
388
|
+
restored.cached = true;
|
|
389
|
+
restored.currentTurn = undefined;
|
|
390
|
+
this.notify();
|
|
391
|
+
return cloneSummary(restored);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const prompt = safeTranscriptText(input.prompt, MAX_TASK_TRANSCRIPT_BYTES);
|
|
395
|
+
const cached = input.cachedRecord;
|
|
396
|
+
const task: InternalTask = {
|
|
397
|
+
id: input.id,
|
|
398
|
+
label: safeDisplayText(input.label, 120) || `agent ${input.id}`,
|
|
399
|
+
phase: input.phase ? safeDisplayText(input.phase, 120) : undefined,
|
|
400
|
+
workflowPath: (input.workflowPath?.length ? input.workflowPath : [this.name])
|
|
401
|
+
.map((part) => safeDisplayText(part, 120))
|
|
402
|
+
.filter(Boolean),
|
|
403
|
+
status: input.cached ? "cached" : "running",
|
|
404
|
+
prompt,
|
|
405
|
+
promptPreview: safeDisplayText(prompt, 240),
|
|
406
|
+
requestedModelId: displayRequestedModelId(input.cached ? cached?.requestedModelId ?? input.modelPattern : input.modelPattern),
|
|
407
|
+
requestedEffort: input.cached ? cached?.requestedEffort ?? cached?.effort : input.requestedEffort,
|
|
408
|
+
modelId: displayActualModelId(cached?.modelId),
|
|
409
|
+
effort: cached?.effort,
|
|
410
|
+
usage: normalizeTaskUsage(cached?.usage),
|
|
411
|
+
currentTurn: undefined,
|
|
412
|
+
startedAt: cached?.startedAt ?? Date.now(),
|
|
413
|
+
endedAt: cached?.endedAt,
|
|
414
|
+
durationMs: cached?.durationMs,
|
|
415
|
+
resultPreview: cached?.resultPreview,
|
|
416
|
+
error: cached?.error,
|
|
417
|
+
agentType: input.agentType ? safeDisplayText(input.agentType, 80) : cached?.agentType,
|
|
418
|
+
isolation: input.isolation ? safeDisplayText(input.isolation, 40) : cached?.isolation,
|
|
419
|
+
structuredOutput: input.structuredOutput ?? cached?.structuredOutput,
|
|
420
|
+
cached: Boolean(input.cached),
|
|
421
|
+
legacyCache: cached?.legacyCache ?? Boolean(input.cached && (!cached?.modelId || !cached?.effort)),
|
|
422
|
+
transcriptPath: cached?.transcriptPath,
|
|
423
|
+
transcriptOmittedBytes: cached?.transcriptOmittedBytes,
|
|
424
|
+
events: [],
|
|
425
|
+
eventsLoaded: true,
|
|
426
|
+
promptBytes: Buffer.byteLength(prompt, "utf8"),
|
|
427
|
+
promptLines: estimatedRenderedLines(prompt),
|
|
428
|
+
liveBytes: Buffer.byteLength(prompt, "utf8"),
|
|
429
|
+
liveLines: estimatedRenderedLines(prompt),
|
|
430
|
+
omittedLiveBytes: 0,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
if (!input.cached) {
|
|
434
|
+
const writer = new BoundedTaskTranscriptWriter({
|
|
435
|
+
dir: this.taskDir(),
|
|
436
|
+
taskId: input.id,
|
|
437
|
+
budget: this.artifactBudget,
|
|
438
|
+
});
|
|
439
|
+
writer.reset();
|
|
440
|
+
this.persistPrompt(writer, input.id, prompt);
|
|
441
|
+
task.writer = writer;
|
|
442
|
+
task.transcriptPath = writer.finalPath;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
this.tasks.set(input.id, task);
|
|
446
|
+
this.sequence = Math.max(this.sequence, input.id);
|
|
447
|
+
this.trimMemory();
|
|
448
|
+
this.notify();
|
|
449
|
+
return cloneSummary(task);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
record(id: number, event: AgentTelemetryEvent): WorkflowTaskSummary | undefined {
|
|
453
|
+
const task = this.tasks.get(id);
|
|
454
|
+
if (!task || task.status !== "running") return task ? cloneSummary(task) : undefined;
|
|
455
|
+
const now = Date.now();
|
|
456
|
+
|
|
457
|
+
switch (event.kind) {
|
|
458
|
+
case "model_requested":
|
|
459
|
+
task.requestedModelId = displayActualModelId(event.modelId) ?? task.requestedModelId;
|
|
460
|
+
task.requestedEffort = event.effort ?? task.requestedEffort;
|
|
461
|
+
break;
|
|
462
|
+
case "model_resolved":
|
|
463
|
+
task.modelId = displayActualModelId(event.modelId) ?? task.modelId;
|
|
464
|
+
task.effort = event.effort;
|
|
465
|
+
break;
|
|
466
|
+
case "turn_start": {
|
|
467
|
+
const turn = task.usage.turns + 1;
|
|
468
|
+
task.currentTurn = turn;
|
|
469
|
+
this.pushEvent(task, { seq: ++this.sequence, ts: now, kind: "turn", turn, state: "streaming" }, true);
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
case "text_delta": {
|
|
473
|
+
let textEvent = [...task.events].reverse().find((candidate) => candidate.kind === "text" && candidate.streaming);
|
|
474
|
+
if (!textEvent) {
|
|
475
|
+
textEvent = {
|
|
476
|
+
seq: ++this.sequence,
|
|
477
|
+
ts: now,
|
|
478
|
+
kind: "text",
|
|
479
|
+
turn: task.currentTurn ?? task.usage.turns + 1,
|
|
480
|
+
text: "",
|
|
481
|
+
streaming: true,
|
|
482
|
+
};
|
|
483
|
+
this.pushEvent(task, textEvent, false);
|
|
484
|
+
}
|
|
485
|
+
const delta = event.delta.length > MAX_LIVE_TASK_BYTES
|
|
486
|
+
? safeTranscriptText(event.delta, MAX_LIVE_TASK_BYTES)
|
|
487
|
+
: event.delta;
|
|
488
|
+
const previousText = textEvent.text ?? "";
|
|
489
|
+
const lineKey = `${task.id}:${textEvent.seq}`;
|
|
490
|
+
const previousColumn = this.streamingLineColumns.get(lineKey)
|
|
491
|
+
?? advanceRenderedLines(previousText).column;
|
|
492
|
+
const lineAdvance = advanceRenderedLines(delta, previousColumn);
|
|
493
|
+
this.streamingLineColumns.set(lineKey, lineAdvance.column);
|
|
494
|
+
textEvent.text = `${previousText}${delta}`;
|
|
495
|
+
task.liveBytes += Buffer.byteLength(delta, "utf8");
|
|
496
|
+
task.liveLines += lineAdvance.added;
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
case "message_end": {
|
|
500
|
+
task.usage.turns++;
|
|
501
|
+
if (event.usage) {
|
|
502
|
+
task.usage.inputTokens += event.usage.inputTokens;
|
|
503
|
+
task.usage.outputTokens += event.usage.outputTokens;
|
|
504
|
+
task.usage.cacheReadTokens += event.usage.cacheReadTokens;
|
|
505
|
+
task.usage.cacheWriteTokens += event.usage.cacheWriteTokens;
|
|
506
|
+
task.usage.totalTokens = task.usage.inputTokens + task.usage.outputTokens;
|
|
507
|
+
task.usage.cost += event.usage.cost;
|
|
508
|
+
}
|
|
509
|
+
const text = safeMultiline(event.text);
|
|
510
|
+
let textEvent = [...task.events].reverse().find((candidate) => candidate.kind === "text" && candidate.streaming);
|
|
511
|
+
if (!textEvent && text) {
|
|
512
|
+
textEvent = {
|
|
513
|
+
seq: ++this.sequence,
|
|
514
|
+
ts: now,
|
|
515
|
+
kind: "text",
|
|
516
|
+
turn: task.currentTurn ?? task.usage.turns,
|
|
517
|
+
};
|
|
518
|
+
task.events.push(textEvent);
|
|
519
|
+
}
|
|
520
|
+
if (textEvent) {
|
|
521
|
+
textEvent.text = text;
|
|
522
|
+
textEvent.streaming = false;
|
|
523
|
+
textEvent.endedAt = now;
|
|
524
|
+
this.streamingLineColumns.delete(`${task.id}:${textEvent.seq}`);
|
|
525
|
+
this.persistTimelineEvent(task, textEvent);
|
|
526
|
+
}
|
|
527
|
+
const thinking = [...task.events].reverse().find(
|
|
528
|
+
(candidate) => candidate.kind === "thinking" && candidate.streaming,
|
|
529
|
+
);
|
|
530
|
+
if (thinking) {
|
|
531
|
+
thinking.streaming = false;
|
|
532
|
+
thinking.state = "done";
|
|
533
|
+
thinking.endedAt = now;
|
|
534
|
+
this.persistTimelineEvent(task, thinking);
|
|
535
|
+
}
|
|
536
|
+
const turnEvent = [...task.events].reverse().find(
|
|
537
|
+
(candidate) => candidate.kind === "turn" && candidate.state === "streaming",
|
|
538
|
+
);
|
|
539
|
+
if (turnEvent) {
|
|
540
|
+
turnEvent.state = "done";
|
|
541
|
+
turnEvent.endedAt = now;
|
|
542
|
+
this.persistTimelineEvent(task, turnEvent);
|
|
543
|
+
}
|
|
544
|
+
task.currentTurn = undefined;
|
|
545
|
+
if (event.error) this.pushError(task, event.error, now);
|
|
546
|
+
this.recomputeTaskWeight(task);
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
case "thinking_start":
|
|
550
|
+
this.pushEvent(task, {
|
|
551
|
+
seq: ++this.sequence,
|
|
552
|
+
ts: now,
|
|
553
|
+
kind: "thinking",
|
|
554
|
+
turn: task.currentTurn,
|
|
555
|
+
state: "streaming",
|
|
556
|
+
streaming: true,
|
|
557
|
+
startedAt: now,
|
|
558
|
+
}, true);
|
|
559
|
+
break;
|
|
560
|
+
case "thinking_end": {
|
|
561
|
+
const thinking = [...task.events].reverse().find((candidate) => candidate.kind === "thinking" && candidate.streaming);
|
|
562
|
+
if (thinking) {
|
|
563
|
+
thinking.streaming = false;
|
|
564
|
+
thinking.state = "done";
|
|
565
|
+
thinking.endedAt = now;
|
|
566
|
+
this.persistTimelineEvent(task, thinking);
|
|
567
|
+
}
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
case "tool_start":
|
|
571
|
+
task.usage.toolUses++;
|
|
572
|
+
this.pushEvent(task, {
|
|
573
|
+
seq: ++this.sequence,
|
|
574
|
+
ts: now,
|
|
575
|
+
kind: "tool",
|
|
576
|
+
turn: task.currentTurn ?? task.usage.turns,
|
|
577
|
+
state: "running",
|
|
578
|
+
streaming: true,
|
|
579
|
+
startedAt: now,
|
|
580
|
+
toolCallId: event.toolCallId,
|
|
581
|
+
toolName: safeDisplayText(event.toolName, 80) || "tool",
|
|
582
|
+
toolArgs: event.toolArgs ? safeDisplayText(event.toolArgs, 240) : undefined,
|
|
583
|
+
}, true);
|
|
584
|
+
break;
|
|
585
|
+
case "tool_end": {
|
|
586
|
+
let tool = [...task.events].reverse().find(
|
|
587
|
+
(candidate) => candidate.kind === "tool" && candidate.toolCallId === event.toolCallId && candidate.streaming,
|
|
588
|
+
);
|
|
589
|
+
if (!tool) {
|
|
590
|
+
tool = {
|
|
591
|
+
seq: ++this.sequence,
|
|
592
|
+
ts: now,
|
|
593
|
+
kind: "tool",
|
|
594
|
+
toolCallId: event.toolCallId,
|
|
595
|
+
toolName: safeDisplayText(event.toolName, 80) || "tool",
|
|
596
|
+
startedAt: now,
|
|
597
|
+
};
|
|
598
|
+
task.events.push(tool);
|
|
599
|
+
}
|
|
600
|
+
tool.streaming = false;
|
|
601
|
+
tool.state = event.isError ? "failed" : "done";
|
|
602
|
+
tool.isError = event.isError;
|
|
603
|
+
tool.endedAt = now;
|
|
604
|
+
tool.resultPreview = event.resultPreview ? safeMultiline(event.resultPreview, 8 * 1024) : undefined;
|
|
605
|
+
this.persistTimelineEvent(task, tool);
|
|
606
|
+
this.recomputeTaskWeight(task);
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
case "retry":
|
|
610
|
+
if (event.state === "start") task.usage.retries++;
|
|
611
|
+
this.pushEvent(task, {
|
|
612
|
+
seq: ++this.sequence,
|
|
613
|
+
ts: now,
|
|
614
|
+
kind: "retry",
|
|
615
|
+
state: event.state,
|
|
616
|
+
text: safeDisplayText(event.detail, 240),
|
|
617
|
+
}, true);
|
|
618
|
+
break;
|
|
619
|
+
case "compaction":
|
|
620
|
+
if (event.state === "start") task.usage.compactions++;
|
|
621
|
+
this.pushEvent(task, {
|
|
622
|
+
seq: ++this.sequence,
|
|
623
|
+
ts: now,
|
|
624
|
+
kind: "compaction",
|
|
625
|
+
state: event.state,
|
|
626
|
+
text: safeDisplayText(event.detail, 240),
|
|
627
|
+
}, true);
|
|
628
|
+
break;
|
|
629
|
+
case "run_error":
|
|
630
|
+
task.modelId = displayActualModelId(event.modelId) ?? task.modelId;
|
|
631
|
+
task.effort = event.effort ?? task.effort;
|
|
632
|
+
task.usage = normalizeTaskUsage(event.usage);
|
|
633
|
+
this.pushError(task, event.error, now);
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
this.trimMemory();
|
|
638
|
+
this.notify();
|
|
639
|
+
return cloneSummary(task);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
finishTask(id: number, input: FinishWorkflowTaskInput): WorkflowTaskSummary | undefined {
|
|
643
|
+
const task = this.tasks.get(id);
|
|
644
|
+
if (!task) return undefined;
|
|
645
|
+
const now = Date.now();
|
|
646
|
+
task.status = input.status === "cancelled" ? "cancelled" : input.status;
|
|
647
|
+
task.cached = input.status === "cached" || task.cached;
|
|
648
|
+
task.currentTurn = undefined;
|
|
649
|
+
if (input.status !== "cached") {
|
|
650
|
+
task.endedAt = now;
|
|
651
|
+
if (task.startedAt != null) task.durationMs = Math.max(0, now - task.startedAt);
|
|
652
|
+
}
|
|
653
|
+
if (input.usage) task.usage = normalizeTaskUsage(input.usage);
|
|
654
|
+
task.modelId = displayActualModelId(input.modelId) ?? task.modelId;
|
|
655
|
+
task.effort = input.effort ?? task.effort;
|
|
656
|
+
task.resultPreview = safeResult(input.result) ?? task.resultPreview;
|
|
657
|
+
if (input.error) {
|
|
658
|
+
task.error = safeDisplayText(input.error, 512);
|
|
659
|
+
this.pushError(task, input.error, now);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
for (const event of task.events) {
|
|
663
|
+
if (!event.streaming) continue;
|
|
664
|
+
event.streaming = false;
|
|
665
|
+
event.endedAt = now;
|
|
666
|
+
if (event.kind === "text") {
|
|
667
|
+
event.text = safeMultiline(event.text ?? "");
|
|
668
|
+
this.streamingLineColumns.delete(`${task.id}:${event.seq}`);
|
|
669
|
+
}
|
|
670
|
+
if (event.kind === "turn") event.state = input.status === "cancelled" ? "cancelled" : "incomplete";
|
|
671
|
+
if (event.kind === "thinking") event.state = input.status === "cancelled" ? "cancelled" : "incomplete";
|
|
672
|
+
if (event.kind === "tool") event.state = input.status === "cancelled" ? "cancelled" : "incomplete";
|
|
673
|
+
this.persistTimelineEvent(task, event);
|
|
674
|
+
}
|
|
675
|
+
this.recomputeTaskWeight(task);
|
|
676
|
+
if (task.writer) {
|
|
677
|
+
task.transcriptOmittedBytes = task.writer.omittedBytes;
|
|
678
|
+
const finalized = task.writer.finalize();
|
|
679
|
+
task.transcriptOmittedBytes = task.writer.omittedBytes;
|
|
680
|
+
if (finalized) task.writer = undefined;
|
|
681
|
+
}
|
|
682
|
+
this.trimMemory();
|
|
683
|
+
this.notify();
|
|
684
|
+
return cloneSummary(task);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
listTasks(): WorkflowTaskSummary[] {
|
|
688
|
+
return [...this.tasks.values()].sort((a, b) => a.id - b.id).map(cloneSummary);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
getTask(id: number): WorkflowTaskDetail | undefined {
|
|
692
|
+
const task = this.tasks.get(id);
|
|
693
|
+
if (!task) return undefined;
|
|
694
|
+
this.loadPersistedEvents(task);
|
|
695
|
+
return {
|
|
696
|
+
...cloneSummary(task),
|
|
697
|
+
prompt: task.prompt,
|
|
698
|
+
events: task.events.map((event) => ({
|
|
699
|
+
...cloneEvent(event),
|
|
700
|
+
text: event.text ? safeMultiline(event.text, MAX_LIVE_TASK_BYTES) : undefined,
|
|
701
|
+
resultPreview: event.resultPreview ? safeMultiline(event.resultPreview, 8 * 1024) : undefined,
|
|
702
|
+
})),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
getTaskSummary(id: number): WorkflowTaskSummary | undefined {
|
|
707
|
+
const task = this.tasks.get(id);
|
|
708
|
+
return task ? cloneSummary(task) : undefined;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
persist(snapshot: WorkflowSnapshot): void {
|
|
712
|
+
const manifest: WorkflowRunDetailsManifest = {
|
|
713
|
+
version: RUN_DETAILS_VERSION,
|
|
714
|
+
runId: this.runId,
|
|
715
|
+
name: this.name,
|
|
716
|
+
updatedAt: Date.now(),
|
|
717
|
+
snapshot: snapshotForManifest(snapshot),
|
|
718
|
+
tasks: this.listTasks().map(taskForManifest),
|
|
719
|
+
transcriptBytes: this.artifactBudget.usedBytes,
|
|
720
|
+
};
|
|
721
|
+
try {
|
|
722
|
+
fs.mkdirSync(this.runsDir, { recursive: true });
|
|
723
|
+
let serialized = JSON.stringify(manifest, null, 2);
|
|
724
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_DETAILS_MANIFEST_BYTES) {
|
|
725
|
+
manifest.snapshot.logs = [];
|
|
726
|
+
manifest.snapshot.agents = manifest.snapshot.agents.map((agent) => ({
|
|
727
|
+
...agent,
|
|
728
|
+
resultPreview: undefined,
|
|
729
|
+
error: undefined,
|
|
730
|
+
activity: undefined,
|
|
731
|
+
}));
|
|
732
|
+
manifest.tasks = manifest.tasks.map((task) => ({
|
|
733
|
+
...task,
|
|
734
|
+
promptPreview: "",
|
|
735
|
+
resultPreview: undefined,
|
|
736
|
+
error: undefined,
|
|
737
|
+
}));
|
|
738
|
+
serialized = JSON.stringify(manifest);
|
|
739
|
+
}
|
|
740
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_DETAILS_MANIFEST_BYTES) return;
|
|
741
|
+
const temp = `${this.manifestPath}.tmp-${process.pid}`;
|
|
742
|
+
fs.writeFileSync(temp, serialized, "utf8");
|
|
743
|
+
fs.renameSync(temp, this.manifestPath);
|
|
744
|
+
} catch {
|
|
745
|
+
// Persistence failure does not affect the workflow result.
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
close(snapshot: WorkflowSnapshot): void {
|
|
750
|
+
for (const task of this.tasks.values()) {
|
|
751
|
+
if (!task.writer) continue;
|
|
752
|
+
task.transcriptOmittedBytes = task.writer.omittedBytes;
|
|
753
|
+
const finalized = task.writer.finalize();
|
|
754
|
+
task.transcriptOmittedBytes = task.writer.omittedBytes;
|
|
755
|
+
if (finalized) task.writer = undefined;
|
|
756
|
+
}
|
|
757
|
+
this.persist(snapshot);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private taskDir(): string {
|
|
761
|
+
return path.join(this.runsDir, `${this.runId}.tasks`);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private pushError(task: InternalTask, error: string, now: number): void {
|
|
765
|
+
task.error = safeDisplayText(error, 512);
|
|
766
|
+
const previous = [...task.events].reverse().find((event) => event.kind === "error");
|
|
767
|
+
if (previous?.text === task.error) return;
|
|
768
|
+
this.pushEvent(task, {
|
|
769
|
+
seq: ++this.sequence,
|
|
770
|
+
ts: now,
|
|
771
|
+
kind: "error",
|
|
772
|
+
text: task.error,
|
|
773
|
+
isError: true,
|
|
774
|
+
}, true);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private pushEvent(task: InternalTask, event: WorkflowTimelineEvent, persist: boolean): void {
|
|
778
|
+
task.events.push(event);
|
|
779
|
+
const weight = eventWeight(event);
|
|
780
|
+
task.liveBytes += weight.bytes;
|
|
781
|
+
task.liveLines += weight.lines;
|
|
782
|
+
if (persist) this.persistTimelineEvent(task, event);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private persistPrompt(writer: BoundedTaskTranscriptWriter, taskId: number, prompt: string): void {
|
|
786
|
+
const chunks = chunkText(safeMultiline(prompt), TRANSCRIPT_TEXT_CHUNK_CHARS);
|
|
787
|
+
for (const [chunkIndex, text] of chunks.entries()) {
|
|
788
|
+
writer.append({
|
|
789
|
+
version: RUN_DETAILS_VERSION,
|
|
790
|
+
recordType: "prompt",
|
|
791
|
+
runId: this.runId,
|
|
792
|
+
taskId,
|
|
793
|
+
ts: Date.now(),
|
|
794
|
+
chunkIndex,
|
|
795
|
+
chunkCount: chunks.length,
|
|
796
|
+
text,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private persistTimelineEvent(task: InternalTask, event: WorkflowTimelineEvent): void {
|
|
802
|
+
if (!task.writer) return;
|
|
803
|
+
const safeEvent: WorkflowTimelineEvent = {
|
|
804
|
+
...event,
|
|
805
|
+
text: event.text ? safeMultiline(event.text) : undefined,
|
|
806
|
+
resultPreview: event.resultPreview ? safeMultiline(event.resultPreview, 8 * 1024) : undefined,
|
|
807
|
+
toolArgs: event.toolArgs ? safeDisplayText(event.toolArgs, 240) : undefined,
|
|
808
|
+
streaming: event.streaming,
|
|
809
|
+
};
|
|
810
|
+
const chunks = safeEvent.kind === "text" ? chunkText(safeEvent.text ?? "", TRANSCRIPT_TEXT_CHUNK_CHARS) : [safeEvent.text];
|
|
811
|
+
for (const [chunkIndex, text] of chunks.entries()) {
|
|
812
|
+
task.writer.append({
|
|
813
|
+
version: RUN_DETAILS_VERSION,
|
|
814
|
+
recordType: "event",
|
|
815
|
+
runId: this.runId,
|
|
816
|
+
taskId: task.id,
|
|
817
|
+
ts: Date.now(),
|
|
818
|
+
baseSeq: safeEvent.seq,
|
|
819
|
+
chunkIndex,
|
|
820
|
+
chunkCount: chunks.length,
|
|
821
|
+
event: safeEvent.kind === "text" ? { ...safeEvent, text } : safeEvent,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
task.transcriptOmittedBytes = task.writer.omittedBytes;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
private recomputeTaskWeight(task: InternalTask): void {
|
|
828
|
+
task.promptBytes = Buffer.byteLength(task.prompt, "utf8");
|
|
829
|
+
task.promptLines = estimatedRenderedLines(task.prompt);
|
|
830
|
+
task.liveBytes = task.promptBytes;
|
|
831
|
+
task.liveLines = task.promptLines;
|
|
832
|
+
for (const event of task.events) {
|
|
833
|
+
const weight = eventWeight(event);
|
|
834
|
+
task.liveBytes += weight.bytes;
|
|
835
|
+
task.liveLines += weight.lines;
|
|
836
|
+
if (event.kind === "text" && event.streaming) {
|
|
837
|
+
this.streamingLineColumns.set(`${task.id}:${event.seq}`, advanceRenderedLines(event.text ?? "").column);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private trimMemory(): void {
|
|
843
|
+
for (const task of this.tasks.values()) this.trimTask(task);
|
|
844
|
+
let total = [...this.tasks.values()].reduce((sum, task) => sum + task.liveBytes, 0);
|
|
845
|
+
if (total <= MAX_LIVE_RUN_BYTES) return;
|
|
846
|
+
|
|
847
|
+
const candidates = [...this.tasks.values()].sort((a, b) => {
|
|
848
|
+
if (a.id === this.viewedTaskId) return 1;
|
|
849
|
+
if (b.id === this.viewedTaskId) return -1;
|
|
850
|
+
const aDone = a.status === "running" ? 1 : 0;
|
|
851
|
+
const bDone = b.status === "running" ? 1 : 0;
|
|
852
|
+
return aDone - bDone || a.id - b.id;
|
|
853
|
+
});
|
|
854
|
+
for (const task of candidates) {
|
|
855
|
+
while (total > MAX_LIVE_RUN_BYTES) {
|
|
856
|
+
let removed = this.removeOldestEvent(task);
|
|
857
|
+
if (!removed && task.id !== this.viewedTaskId && task.prompt.length > 4 * 1024) {
|
|
858
|
+
const before = Buffer.byteLength(task.prompt, "utf8");
|
|
859
|
+
task.prompt = `${task.prompt.slice(0, 4 * 1024 - 1)}…`;
|
|
860
|
+
this.recomputeTaskWeight(task);
|
|
861
|
+
removed = Math.max(0, before - Buffer.byteLength(task.prompt, "utf8"));
|
|
862
|
+
}
|
|
863
|
+
if (!removed) break;
|
|
864
|
+
total -= removed;
|
|
865
|
+
}
|
|
866
|
+
if (total <= MAX_LIVE_RUN_BYTES) break;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
private trimTask(task: InternalTask): void {
|
|
871
|
+
if (task.promptBytes > MAX_LIVE_PROMPT_BYTES || task.promptLines > MAX_LIVE_PROMPT_LINES) {
|
|
872
|
+
this.truncateLivePrompt(task);
|
|
873
|
+
}
|
|
874
|
+
while (task.liveBytes > MAX_LIVE_TASK_BYTES || task.liveLines > MAX_LIVE_TASK_LINES) {
|
|
875
|
+
if (this.removeOldestEvent(task)) continue;
|
|
876
|
+
if (!this.truncateLivePrompt(task)) break;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private truncateLivePrompt(task: InternalTask): number {
|
|
881
|
+
const before = Buffer.byteLength(task.prompt, "utf8");
|
|
882
|
+
const lines = task.prompt.split(/\r?\n/);
|
|
883
|
+
let text = lines.slice(0, MAX_LIVE_PROMPT_LINES).join("\n");
|
|
884
|
+
while (Buffer.byteLength(text, "utf8") > MAX_LIVE_PROMPT_BYTES && text.length > 1) {
|
|
885
|
+
text = text.slice(0, Math.floor(text.length * 0.75));
|
|
886
|
+
}
|
|
887
|
+
if (text === task.prompt) return 0;
|
|
888
|
+
task.prompt = `${text}\n${PROMPT_OMISSION_TEXT}`;
|
|
889
|
+
this.recomputeTaskWeight(task);
|
|
890
|
+
return Math.max(1, before - Buffer.byteLength(task.prompt, "utf8"));
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private removeOldestEvent(task: InternalTask): number {
|
|
894
|
+
const index = task.events.findIndex((event) => event.kind !== "omitted" && !event.streaming);
|
|
895
|
+
if (index < 0) {
|
|
896
|
+
const streaming = task.events.find((event) => event.kind === "text" && event.streaming && (event.text?.length ?? 0) > 1024);
|
|
897
|
+
return streaming ? this.shrinkTextEvent(task, streaming) : 0;
|
|
898
|
+
}
|
|
899
|
+
const candidate = task.events[index];
|
|
900
|
+
if (candidate?.kind === "text" && (candidate.text?.length ?? 0) > 1024) {
|
|
901
|
+
return this.shrinkTextEvent(task, candidate);
|
|
902
|
+
}
|
|
903
|
+
const [removed] = task.events.splice(index, 1);
|
|
904
|
+
if (!removed) return 0;
|
|
905
|
+
this.streamingLineColumns.delete(`${task.id}:${removed.seq}`);
|
|
906
|
+
const weight = eventWeight(removed);
|
|
907
|
+
task.liveBytes = Math.max(0, task.liveBytes - weight.bytes);
|
|
908
|
+
task.liveLines = Math.max(0, task.liveLines - weight.lines);
|
|
909
|
+
task.omittedLiveBytes += weight.bytes;
|
|
910
|
+
this.ensureLiveOmissionMarker(task);
|
|
911
|
+
return weight.bytes;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private shrinkTextEvent(task: InternalTask, event: WorkflowTimelineEvent): number {
|
|
915
|
+
if (!event.text) return 0;
|
|
916
|
+
const before = eventWeight(event).bytes;
|
|
917
|
+
const safeText = event.streaming
|
|
918
|
+
? safeTranscriptText(event.text, MAX_PERSISTED_EVENT_TEXT)
|
|
919
|
+
: event.text;
|
|
920
|
+
event.text = `…${safeText.slice(-Math.floor(safeText.length / 2))}`;
|
|
921
|
+
this.recomputeTaskWeight(task);
|
|
922
|
+
const removed = Math.max(0, before - eventWeight(event).bytes);
|
|
923
|
+
task.omittedLiveBytes += removed;
|
|
924
|
+
this.ensureLiveOmissionMarker(task);
|
|
925
|
+
return Math.max(1, removed);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
private ensureLiveOmissionMarker(task: InternalTask): void {
|
|
929
|
+
let marker = task.events.find((event) => event.kind === "omitted");
|
|
930
|
+
if (!marker) {
|
|
931
|
+
marker = { seq: -1, ts: Date.now(), kind: "omitted", text: LIVE_OMISSION_TEXT };
|
|
932
|
+
task.events.unshift(marker);
|
|
933
|
+
}
|
|
934
|
+
marker.text = `${LIVE_OMISSION_TEXT} (${formatBytes(task.omittedLiveBytes)})`;
|
|
935
|
+
this.recomputeTaskWeight(task);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private loadPersistedEvents(task: InternalTask): void {
|
|
939
|
+
if (task.eventsLoaded) return;
|
|
940
|
+
task.eventsLoaded = true;
|
|
941
|
+
if (!task.transcriptPath) return;
|
|
942
|
+
let content: string;
|
|
943
|
+
try {
|
|
944
|
+
const resolved = path.resolve(task.transcriptPath);
|
|
945
|
+
const root = path.resolve(this.taskDir()) + path.sep;
|
|
946
|
+
if (!resolved.startsWith(root)) return;
|
|
947
|
+
content = fs.readFileSync(resolved, "utf8");
|
|
948
|
+
} catch {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const loadedEvents = new Map<number, WorkflowTimelineEvent>();
|
|
952
|
+
const eventChunks = new Map<number, { event: WorkflowTimelineEvent; chunks: Map<number, string>; count: number }>();
|
|
953
|
+
const promptChunks = new Map<number, string>();
|
|
954
|
+
let promptChunkCount = 0;
|
|
955
|
+
const omissionEvents: WorkflowTimelineEvent[] = [];
|
|
956
|
+
for (const line of content.split("\n")) {
|
|
957
|
+
if (!line.trim()) continue;
|
|
958
|
+
try {
|
|
959
|
+
const record = JSON.parse(line) as any;
|
|
960
|
+
if (record.recordType === "prompt" && typeof record.text === "string") {
|
|
961
|
+
const index = Number.isFinite(record.chunkIndex) ? record.chunkIndex : 0;
|
|
962
|
+
promptChunks.set(index, record.text);
|
|
963
|
+
promptChunkCount = Math.max(promptChunkCount, Number.isFinite(record.chunkCount) ? record.chunkCount : 1);
|
|
964
|
+
} else if (record.recordType === "event" && record.event?.kind && Number.isFinite(record.event.seq)) {
|
|
965
|
+
const event = record.event as WorkflowTimelineEvent;
|
|
966
|
+
const count = Number.isFinite(record.chunkCount) ? Math.max(1, record.chunkCount) : 1;
|
|
967
|
+
if (event.kind === "text" && count > 1) {
|
|
968
|
+
const seq = Number.isFinite(record.baseSeq) ? record.baseSeq : event.seq;
|
|
969
|
+
const group = eventChunks.get(seq) ?? { event, chunks: new Map<number, string>(), count };
|
|
970
|
+
group.event = event;
|
|
971
|
+
group.count = Math.max(group.count, count);
|
|
972
|
+
group.chunks.set(Number.isFinite(record.chunkIndex) ? record.chunkIndex : 0, event.text ?? "");
|
|
973
|
+
eventChunks.set(seq, group);
|
|
974
|
+
} else {
|
|
975
|
+
loadedEvents.set(event.seq, event);
|
|
976
|
+
}
|
|
977
|
+
} else if (record.recordType === "omitted") {
|
|
978
|
+
omissionEvents.push({
|
|
979
|
+
seq: -1,
|
|
980
|
+
ts: finite(record.ts),
|
|
981
|
+
kind: "omitted",
|
|
982
|
+
text: typeof record.message === "string" ? record.message : LIVE_OMISSION_TEXT,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
} catch {
|
|
986
|
+
// Ignore a partial/corrupt line; later records remain readable.
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
if (promptChunks.size > 0) {
|
|
990
|
+
task.prompt = joinTranscriptChunks(promptChunks, promptChunkCount);
|
|
991
|
+
task.promptPreview = safeDisplayText(task.prompt, 240);
|
|
992
|
+
}
|
|
993
|
+
for (const [seq, group] of eventChunks) {
|
|
994
|
+
loadedEvents.set(seq, { ...group.event, seq, text: joinTranscriptChunks(group.chunks, group.count) });
|
|
995
|
+
}
|
|
996
|
+
task.events.push(...omissionEvents, ...[...loadedEvents.values()].sort((left, right) => left.seq - right.seq));
|
|
997
|
+
this.recomputeTaskWeight(task);
|
|
998
|
+
this.trimMemory();
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private notify(): void {
|
|
1002
|
+
for (const listener of this.listeners) {
|
|
1003
|
+
try {
|
|
1004
|
+
listener();
|
|
1005
|
+
} catch {
|
|
1006
|
+
// UI listeners are isolated from execution.
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function restoreTaskSummary(value: unknown, fallbackName: string): WorkflowTaskSummary | undefined {
|
|
1013
|
+
if (!value || typeof value !== "object") return undefined;
|
|
1014
|
+
const raw = value as Record<string, unknown>;
|
|
1015
|
+
const id = Number.isInteger(raw.id) ? Number(raw.id) : 0;
|
|
1016
|
+
if (id < 1 || id > 1000) return undefined;
|
|
1017
|
+
const statuses: WorkflowAgentStatus[] = ["running", "done", "error", "cancelled", "skipped", "cached"];
|
|
1018
|
+
const status = statuses.includes(raw.status as WorkflowAgentStatus) ? raw.status as WorkflowAgentStatus : "cancelled";
|
|
1019
|
+
const requestedEffort = normalizeThinkingLevel(raw.requestedEffort);
|
|
1020
|
+
const effort = normalizeThinkingLevel(raw.effort);
|
|
1021
|
+
const workflowPath = Array.isArray(raw.workflowPath)
|
|
1022
|
+
? raw.workflowPath.filter((part): part is string => typeof part === "string")
|
|
1023
|
+
: [fallbackName];
|
|
1024
|
+
return taskForManifest({
|
|
1025
|
+
id,
|
|
1026
|
+
label: typeof raw.label === "string" ? raw.label : `agent ${id}`,
|
|
1027
|
+
phase: typeof raw.phase === "string" ? raw.phase : undefined,
|
|
1028
|
+
workflowPath,
|
|
1029
|
+
status,
|
|
1030
|
+
promptPreview: typeof raw.promptPreview === "string" ? raw.promptPreview : "",
|
|
1031
|
+
requestedModelId: typeof raw.requestedModelId === "string" ? raw.requestedModelId : undefined,
|
|
1032
|
+
requestedEffort,
|
|
1033
|
+
modelId: typeof raw.modelId === "string" ? raw.modelId : undefined,
|
|
1034
|
+
effort,
|
|
1035
|
+
usage: normalizeTaskUsage(raw.usage as Partial<WorkflowTaskUsage> | undefined),
|
|
1036
|
+
currentTurn: typeof raw.currentTurn === "number" ? finite(raw.currentTurn) : undefined,
|
|
1037
|
+
startedAt: typeof raw.startedAt === "number" ? finite(raw.startedAt) : undefined,
|
|
1038
|
+
endedAt: typeof raw.endedAt === "number" ? finite(raw.endedAt) : undefined,
|
|
1039
|
+
durationMs: typeof raw.durationMs === "number" ? finite(raw.durationMs) : undefined,
|
|
1040
|
+
resultPreview: typeof raw.resultPreview === "string" ? raw.resultPreview : undefined,
|
|
1041
|
+
error: typeof raw.error === "string" ? raw.error : undefined,
|
|
1042
|
+
agentType: typeof raw.agentType === "string" ? raw.agentType : undefined,
|
|
1043
|
+
isolation: typeof raw.isolation === "string" ? raw.isolation : undefined,
|
|
1044
|
+
structuredOutput: raw.structuredOutput === true,
|
|
1045
|
+
cached: raw.cached === true,
|
|
1046
|
+
legacyCache: raw.legacyCache === true,
|
|
1047
|
+
transcriptPath: typeof raw.transcriptPath === "string" ? raw.transcriptPath : undefined,
|
|
1048
|
+
transcriptOmittedBytes: typeof raw.transcriptOmittedBytes === "number" ? finite(raw.transcriptOmittedBytes) : undefined,
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function normalizeThinkingLevel(value: unknown): ThinkingLevel | undefined {
|
|
1053
|
+
return ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(String(value))
|
|
1054
|
+
? value as ThinkingLevel
|
|
1055
|
+
: undefined;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function taskForManifest(task: WorkflowTaskSummary): WorkflowTaskSummary {
|
|
1059
|
+
return {
|
|
1060
|
+
...task,
|
|
1061
|
+
label: safeDisplayText(task.label, 120),
|
|
1062
|
+
phase: task.phase ? safeDisplayText(task.phase, 120) : undefined,
|
|
1063
|
+
workflowPath: task.workflowPath.map((part) => safeDisplayText(part, 120)),
|
|
1064
|
+
promptPreview: safeDisplayText(task.promptPreview, 240),
|
|
1065
|
+
requestedModelId: task.requestedModelId ? safeDisplayText(task.requestedModelId, 120) : undefined,
|
|
1066
|
+
modelId: task.modelId ? safeDisplayText(task.modelId, 120) : undefined,
|
|
1067
|
+
resultPreview: task.resultPreview ? safeDisplayText(task.resultPreview, 512) : undefined,
|
|
1068
|
+
error: task.error ? safeDisplayText(task.error, 512) : undefined,
|
|
1069
|
+
agentType: task.agentType ? safeDisplayText(task.agentType, 80) : undefined,
|
|
1070
|
+
isolation: task.isolation ? safeDisplayText(task.isolation, 40) : undefined,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function snapshotForManifest(snapshot: WorkflowSnapshot): WorkflowSnapshot {
|
|
1075
|
+
return {
|
|
1076
|
+
...snapshot,
|
|
1077
|
+
name: safeDisplayText(snapshot.name, 120),
|
|
1078
|
+
description: snapshot.description ? safeDisplayText(snapshot.description, 240) : undefined,
|
|
1079
|
+
phases: snapshot.phases.map((phase) => safeDisplayText(phase, 120)),
|
|
1080
|
+
currentPhase: snapshot.currentPhase ? safeDisplayText(snapshot.currentPhase, 120) : undefined,
|
|
1081
|
+
logs: snapshot.logs.map((line) => safeDisplayText(line, 512)),
|
|
1082
|
+
agents: snapshot.agents.map((agent) => ({
|
|
1083
|
+
...agent,
|
|
1084
|
+
label: safeDisplayText(agent.label, 120),
|
|
1085
|
+
phase: agent.phase ? safeDisplayText(agent.phase, 120) : undefined,
|
|
1086
|
+
workflowPath: agent.workflowPath?.map((part) => safeDisplayText(part, 120)),
|
|
1087
|
+
resultPreview: agent.resultPreview ? safeDisplayText(agent.resultPreview, 512) : undefined,
|
|
1088
|
+
error: agent.error ? safeDisplayText(agent.error, 512) : undefined,
|
|
1089
|
+
requestedModelId: agent.requestedModelId ? safeDisplayText(agent.requestedModelId, 120) : undefined,
|
|
1090
|
+
modelId: agent.modelId ? safeDisplayText(agent.modelId, 120) : undefined,
|
|
1091
|
+
agentType: agent.agentType ? safeDisplayText(agent.agentType, 80) : undefined,
|
|
1092
|
+
isolation: agent.isolation ? safeDisplayText(agent.isolation, 40) : undefined,
|
|
1093
|
+
activity: agent.activity ? safeDisplayText(agent.activity, 160) : undefined,
|
|
1094
|
+
activeTools: undefined,
|
|
1095
|
+
streamTail: undefined,
|
|
1096
|
+
})),
|
|
1097
|
+
// The result already lives in the resume journal/tool result. Avoid a second
|
|
1098
|
+
// potentially huge or hostile serialization in the UI manifest.
|
|
1099
|
+
result: undefined,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
class RunArtifactBudget {
|
|
1104
|
+
usedBytes = 0;
|
|
1105
|
+
private readonly dir: string;
|
|
1106
|
+
private readonly maxBytes: number;
|
|
1107
|
+
|
|
1108
|
+
constructor(dir: string, maxBytes: number) {
|
|
1109
|
+
this.dir = dir;
|
|
1110
|
+
this.maxBytes = maxBytes;
|
|
1111
|
+
try {
|
|
1112
|
+
this.usedBytes = fs.readdirSync(dir)
|
|
1113
|
+
.filter((name) => /\.(?:jsonl|head|tail)$/.test(name))
|
|
1114
|
+
.reduce((sum, name) => sum + fileSize(path.join(dir, name)), 0);
|
|
1115
|
+
} catch {
|
|
1116
|
+
this.usedBytes = 0;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
reserve(bytes: number): boolean {
|
|
1121
|
+
if (this.usedBytes + bytes > this.maxBytes) return false;
|
|
1122
|
+
this.usedBytes += bytes;
|
|
1123
|
+
return true;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
release(bytes: number): void {
|
|
1127
|
+
this.usedBytes = Math.max(0, this.usedBytes - Math.max(0, bytes));
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
class BoundedTaskTranscriptWriter {
|
|
1132
|
+
readonly finalPath: string;
|
|
1133
|
+
readonly headPath: string;
|
|
1134
|
+
readonly tailPath: string;
|
|
1135
|
+
omittedBytes = 0;
|
|
1136
|
+
private headBytes = 0;
|
|
1137
|
+
private tailBytes = 0;
|
|
1138
|
+
|
|
1139
|
+
constructor(input: { dir: string; taskId: number; budget: RunArtifactBudget }) {
|
|
1140
|
+
this.budget = input.budget;
|
|
1141
|
+
this.finalPath = path.join(input.dir, `${input.taskId}.transcript.jsonl`);
|
|
1142
|
+
this.headPath = `${this.finalPath}.head`;
|
|
1143
|
+
this.tailPath = `${this.finalPath}.tail`;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
private readonly budget: RunArtifactBudget;
|
|
1147
|
+
|
|
1148
|
+
reset(): void {
|
|
1149
|
+
for (const target of [this.finalPath, this.headPath, this.tailPath]) {
|
|
1150
|
+
const bytes = fileSize(target);
|
|
1151
|
+
try {
|
|
1152
|
+
fs.rmSync(target, { force: true });
|
|
1153
|
+
} catch {
|
|
1154
|
+
// best-effort
|
|
1155
|
+
}
|
|
1156
|
+
this.budget.release(bytes);
|
|
1157
|
+
}
|
|
1158
|
+
this.headBytes = 0;
|
|
1159
|
+
this.tailBytes = 0;
|
|
1160
|
+
this.omittedBytes = 0;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
append(record: Record<string, unknown>): void {
|
|
1164
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
1165
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
1166
|
+
if (!this.budget.reserve(bytes)) {
|
|
1167
|
+
this.omittedBytes += bytes;
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
try {
|
|
1171
|
+
if (this.headBytes + bytes <= TRANSCRIPT_HEAD_BYTES) {
|
|
1172
|
+
fs.appendFileSync(this.headPath, line, "utf8");
|
|
1173
|
+
this.headBytes += bytes;
|
|
1174
|
+
} else {
|
|
1175
|
+
fs.appendFileSync(this.tailPath, line, "utf8");
|
|
1176
|
+
this.tailBytes += bytes;
|
|
1177
|
+
if (this.tailBytes > TRANSCRIPT_TAIL_BYTES) this.compactTail();
|
|
1178
|
+
}
|
|
1179
|
+
} catch {
|
|
1180
|
+
this.budget.release(bytes);
|
|
1181
|
+
this.omittedBytes += bytes;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
finalize(): boolean {
|
|
1186
|
+
const head = readText(this.headPath);
|
|
1187
|
+
const tail = readText(this.tailPath);
|
|
1188
|
+
if (!head && !tail && fs.existsSync(this.finalPath)) return true;
|
|
1189
|
+
const markerCandidate = this.omittedBytes > 0
|
|
1190
|
+
? `${JSON.stringify({
|
|
1191
|
+
version: RUN_DETAILS_VERSION,
|
|
1192
|
+
recordType: "omitted",
|
|
1193
|
+
ts: Date.now(),
|
|
1194
|
+
omittedBytes: this.omittedBytes,
|
|
1195
|
+
message: `… ${formatBytes(this.omittedBytes)} of middle transcript omitted …`,
|
|
1196
|
+
})}\n`
|
|
1197
|
+
: "";
|
|
1198
|
+
const markerBytes = Buffer.byteLength(markerCandidate, "utf8");
|
|
1199
|
+
const marker = markerBytes <= TRANSCRIPT_MARKER_BYTES && this.budget.reserve(markerBytes)
|
|
1200
|
+
? markerCandidate
|
|
1201
|
+
: "";
|
|
1202
|
+
const combined = `${head}${marker}${tail}`;
|
|
1203
|
+
try {
|
|
1204
|
+
fs.mkdirSync(path.dirname(this.finalPath), { recursive: true });
|
|
1205
|
+
const temp = `${this.finalPath}.tmp-${process.pid}`;
|
|
1206
|
+
fs.writeFileSync(temp, combined, "utf8");
|
|
1207
|
+
fs.renameSync(temp, this.finalPath);
|
|
1208
|
+
fs.rmSync(this.headPath, { force: true });
|
|
1209
|
+
fs.rmSync(this.tailPath, { force: true });
|
|
1210
|
+
this.headBytes = 0;
|
|
1211
|
+
this.tailBytes = 0;
|
|
1212
|
+
return true;
|
|
1213
|
+
} catch {
|
|
1214
|
+
if (marker) this.budget.release(markerBytes);
|
|
1215
|
+
// Keep the head/tail files for recovery when final compaction fails.
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
private compactTail(): void {
|
|
1221
|
+
const content = readText(this.tailPath);
|
|
1222
|
+
if (!content) return;
|
|
1223
|
+
const lines = content.split("\n").filter(Boolean);
|
|
1224
|
+
const kept: string[] = [];
|
|
1225
|
+
let keptBytes = 0;
|
|
1226
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
1227
|
+
const line = `${lines[index]}\n`;
|
|
1228
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
1229
|
+
if (keptBytes + bytes > TRANSCRIPT_TAIL_BYTES) break;
|
|
1230
|
+
kept.unshift(line);
|
|
1231
|
+
keptBytes += bytes;
|
|
1232
|
+
}
|
|
1233
|
+
const removed = Math.max(0, this.tailBytes - keptBytes);
|
|
1234
|
+
try {
|
|
1235
|
+
fs.writeFileSync(this.tailPath, kept.join(""), "utf8");
|
|
1236
|
+
this.tailBytes = keptBytes;
|
|
1237
|
+
this.omittedBytes += removed;
|
|
1238
|
+
this.budget.release(removed);
|
|
1239
|
+
} catch {
|
|
1240
|
+
// Leave the original tail in place; a later append can retry compaction.
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function isPathInside(candidate: string | undefined, rootDir: string): boolean {
|
|
1246
|
+
if (!candidate) return false;
|
|
1247
|
+
try {
|
|
1248
|
+
const resolved = fs.realpathSync(candidate);
|
|
1249
|
+
const root = fs.realpathSync(rootDir) + path.sep;
|
|
1250
|
+
return resolved.startsWith(root);
|
|
1251
|
+
} catch {
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function readText(filePath: string): string {
|
|
1257
|
+
try {
|
|
1258
|
+
return fs.readFileSync(filePath, "utf8");
|
|
1259
|
+
} catch {
|
|
1260
|
+
return "";
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function fileSize(filePath: string): number {
|
|
1265
|
+
try {
|
|
1266
|
+
return fs.statSync(filePath).size;
|
|
1267
|
+
} catch {
|
|
1268
|
+
return 0;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function formatBytes(bytes: number): string {
|
|
1273
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1274
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1275
|
+
return `${Math.round(bytes)}B`;
|
|
1276
|
+
}
|