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
package/src/workflow/display.ts
CHANGED
|
@@ -3,28 +3,67 @@
|
|
|
3
3
|
* for streamed tool updates and the final tool result.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { safeDisplayText } from "./display-text.ts";
|
|
6
7
|
import type { WorkflowMeta } from "./parser.ts";
|
|
7
8
|
|
|
8
|
-
export type WorkflowAgentStatus = "running" | "done" | "error" | "skipped" | "cached";
|
|
9
|
+
export type WorkflowAgentStatus = "running" | "done" | "error" | "cancelled" | "skipped" | "cached";
|
|
10
|
+
|
|
11
|
+
export interface WorkflowActiveToolSnapshot {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
args?: string;
|
|
15
|
+
startedAt: number;
|
|
16
|
+
lastUpdateAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WorkflowAgentUsageSnapshot {
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
outputTokens: number;
|
|
22
|
+
cacheReadTokens: number;
|
|
23
|
+
cacheWriteTokens: number;
|
|
24
|
+
totalTokens: number;
|
|
25
|
+
cost: number;
|
|
26
|
+
turns: number;
|
|
27
|
+
toolUses: number;
|
|
28
|
+
retries: number;
|
|
29
|
+
compactions: number;
|
|
30
|
+
}
|
|
9
31
|
|
|
10
32
|
export interface WorkflowAgentSnapshot {
|
|
11
33
|
id: number;
|
|
12
34
|
label: string;
|
|
13
35
|
phase?: string;
|
|
36
|
+
workflowPath?: string[];
|
|
14
37
|
status: WorkflowAgentStatus;
|
|
15
38
|
resultPreview?: string;
|
|
16
39
|
error?: string;
|
|
40
|
+
/** Actual model selected by the child session. */
|
|
41
|
+
modelId?: string;
|
|
42
|
+
/** Actual effort applied by the child session. */
|
|
43
|
+
effort?: string;
|
|
44
|
+
/** Requested values are retained for diagnostics but never rendered as actual. */
|
|
45
|
+
requestedModelId?: string;
|
|
46
|
+
requestedEffort?: string;
|
|
47
|
+
agentType?: string;
|
|
48
|
+
isolation?: string;
|
|
49
|
+
structuredOutput?: boolean;
|
|
50
|
+
usage?: WorkflowAgentUsageSnapshot;
|
|
51
|
+
currentTurn?: number;
|
|
52
|
+
legacyCache?: boolean;
|
|
53
|
+
transcriptPath?: string;
|
|
17
54
|
/** Wall-clock ms when the agent started (host layer; set by the tool). */
|
|
18
55
|
startedAt?: number;
|
|
19
56
|
/** Wall-clock ms when the agent finished. */
|
|
20
57
|
endedAt?: number;
|
|
21
58
|
/** Runtime duration in ms (endedAt - startedAt). */
|
|
22
59
|
durationMs?: number;
|
|
23
|
-
/** Wall-clock ms of the last observed
|
|
60
|
+
/** Wall-clock ms of the last observed session event inside the subagent. */
|
|
24
61
|
lastActivityAt?: number;
|
|
25
|
-
/**
|
|
62
|
+
/** Last known factual state, e.g. "waiting for model" or "retry 2/3". */
|
|
26
63
|
activity?: string;
|
|
27
|
-
/**
|
|
64
|
+
/** Tool calls that have started but have not emitted tool_execution_end. */
|
|
65
|
+
activeTools?: WorkflowActiveToolSnapshot[];
|
|
66
|
+
/** @deprecated Raw assistant stream text is never captured or rendered. */
|
|
28
67
|
streamTail?: string;
|
|
29
68
|
}
|
|
30
69
|
|
|
@@ -40,8 +79,13 @@ export interface WorkflowSnapshot {
|
|
|
40
79
|
runningCount: number;
|
|
41
80
|
doneCount: number;
|
|
42
81
|
errorCount: number;
|
|
82
|
+
cancelledCount: number;
|
|
43
83
|
cachedCount: number;
|
|
44
84
|
spentTokens: number;
|
|
85
|
+
newTokens: number;
|
|
86
|
+
replayedTokens: number;
|
|
87
|
+
/** Sanitized session-scoped workflow detail manifest for headless consumers. */
|
|
88
|
+
detailsManifestPath?: string;
|
|
45
89
|
budgetTotal: number | null;
|
|
46
90
|
durationMs?: number;
|
|
47
91
|
result?: unknown;
|
|
@@ -52,26 +96,29 @@ export interface RenderOptions {
|
|
|
52
96
|
maxAgents?: number;
|
|
53
97
|
maxLogs?: number;
|
|
54
98
|
showResultPreviews?: boolean;
|
|
55
|
-
/**
|
|
99
|
+
/** @deprecated No-op retained for source compatibility. */
|
|
56
100
|
showStream?: boolean;
|
|
57
|
-
/** Override `Date.now()` for deterministic elapsed/
|
|
101
|
+
/** Override `Date.now()` for deterministic elapsed/activity rendering in tests. */
|
|
58
102
|
now?: number;
|
|
59
103
|
}
|
|
60
104
|
|
|
61
105
|
export function createSnapshot(meta: WorkflowMeta, runId: string, budgetTotal: number | null): WorkflowSnapshot {
|
|
62
106
|
return {
|
|
63
107
|
runId,
|
|
64
|
-
name: meta.name,
|
|
65
|
-
description: meta.description,
|
|
66
|
-
phases: meta.phases?.map((p) => p.title) ?? [],
|
|
108
|
+
name: safeDisplayText(meta.name, 120) || "workflow",
|
|
109
|
+
description: meta.description ? safeDisplayText(meta.description, 240) : undefined,
|
|
110
|
+
phases: meta.phases?.map((p) => safeDisplayText(p.title, 120)).filter(Boolean) ?? [],
|
|
67
111
|
logs: [],
|
|
68
112
|
agents: [],
|
|
69
113
|
agentCount: 0,
|
|
70
114
|
runningCount: 0,
|
|
71
115
|
doneCount: 0,
|
|
72
116
|
errorCount: 0,
|
|
117
|
+
cancelledCount: 0,
|
|
73
118
|
cachedCount: 0,
|
|
74
119
|
spentTokens: 0,
|
|
120
|
+
newTokens: 0,
|
|
121
|
+
replayedTokens: 0,
|
|
75
122
|
budgetTotal,
|
|
76
123
|
status: "running",
|
|
77
124
|
};
|
|
@@ -81,28 +128,34 @@ export function recompute(snapshot: WorkflowSnapshot): WorkflowSnapshot {
|
|
|
81
128
|
const runningCount = snapshot.agents.filter((a) => a.status === "running").length;
|
|
82
129
|
const doneCount = snapshot.agents.filter((a) => a.status === "done" || a.status === "cached").length;
|
|
83
130
|
const errorCount = snapshot.agents.filter((a) => a.status === "error").length;
|
|
131
|
+
const cancelledCount = snapshot.agents.filter((a) => a.status === "cancelled" || a.status === "skipped").length;
|
|
84
132
|
const cachedCount = snapshot.agents.filter((a) => a.status === "cached").length;
|
|
85
|
-
return { ...snapshot, agentCount: snapshot.agents.length, runningCount, doneCount, errorCount, cachedCount };
|
|
133
|
+
return { ...snapshot, agentCount: snapshot.agents.length, runningCount, doneCount, errorCount, cancelledCount, cachedCount };
|
|
86
134
|
}
|
|
87
135
|
|
|
88
136
|
export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderOptions = {}): string[] {
|
|
89
137
|
const maxAgents = options.maxAgents ?? 6;
|
|
90
138
|
const maxLogs = options.maxLogs ?? 2;
|
|
91
139
|
const showResultPreviews = options.showResultPreviews ?? false;
|
|
92
|
-
const showStream = options.showStream ?? false;
|
|
93
140
|
const now = options.now ?? Date.now();
|
|
94
141
|
|
|
95
|
-
const
|
|
96
|
-
|
|
142
|
+
const representedTokens = snapshot.newTokens + snapshot.replayedTokens;
|
|
143
|
+
const tokens = representedTokens
|
|
144
|
+
? ` · ${formatTokens(representedTokens)} token${snapshot.replayedTokens ? ` (${formatTokens(snapshot.newTokens)} new, ${formatTokens(snapshot.replayedTokens)} replayed)` : ""}`
|
|
145
|
+
: snapshot.spentTokens
|
|
146
|
+
? ` · ${formatTokens(snapshot.spentTokens)} output token`
|
|
147
|
+
: "";
|
|
148
|
+
const budget = snapshot.budgetTotal
|
|
149
|
+
? ` · ${formatTokens(snapshot.spentTokens)}/${formatTokens(snapshot.budgetTotal)} out`
|
|
97
150
|
: "";
|
|
98
|
-
const
|
|
99
|
-
snapshot.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
151
|
+
const stateParts = [
|
|
152
|
+
snapshot.runningCount > 0 ? `${snapshot.runningCount} running` : undefined,
|
|
153
|
+
snapshot.errorCount > 0 ? `${snapshot.errorCount} errors` : undefined,
|
|
154
|
+
snapshot.cancelledCount > 0 ? `${snapshot.cancelledCount} cancelled` : undefined,
|
|
155
|
+
].filter(Boolean);
|
|
156
|
+
const state = stateParts.length ? `, ${stateParts.join(", ")}` : "";
|
|
104
157
|
const cached = snapshot.cachedCount ? ` · ${snapshot.cachedCount} cached` : "";
|
|
105
|
-
const header = `◆ ${statusMark(snapshot.status)} ${snapshot.name} (${snapshot.doneCount}/${snapshot.agentCount} done${state})${cached}${tokens}`;
|
|
158
|
+
const header = `◆ ${statusMark(snapshot.status)} ${shorten(snapshot.name, 60)} (${snapshot.doneCount}/${snapshot.agentCount} done${state})${cached}${tokens}${budget}`;
|
|
106
159
|
const lines = [header];
|
|
107
160
|
|
|
108
161
|
const phaseNames = unique([
|
|
@@ -119,13 +172,22 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderO
|
|
|
119
172
|
const done = agents.filter((a) => a.status === "done" || a.status === "cached").length;
|
|
120
173
|
const running = agents.filter((a) => a.status === "running").length;
|
|
121
174
|
const errors = agents.filter((a) => a.status === "error").length;
|
|
122
|
-
const
|
|
123
|
-
const
|
|
175
|
+
const cancelled = agents.filter((a) => a.status === "cancelled" || a.status === "skipped").length;
|
|
176
|
+
const complete = agents.length > 0 && running === 0;
|
|
177
|
+
const marker = running > 0 || (!complete && snapshot.currentPhase === phase)
|
|
178
|
+
? "▶"
|
|
179
|
+
: errors
|
|
180
|
+
? "✗"
|
|
181
|
+
: cancelled
|
|
182
|
+
? "■"
|
|
183
|
+
: complete
|
|
184
|
+
? "✓"
|
|
185
|
+
: " ";
|
|
124
186
|
lines.push(
|
|
125
|
-
` ${marker} ${phase} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}`,
|
|
187
|
+
` ${marker} ${shorten(phase, 60)} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}${cancelled ? ` · ${cancelled} cancelled` : ""}`,
|
|
126
188
|
);
|
|
127
189
|
for (const agent of agents.slice(-maxAgents)) {
|
|
128
|
-
lines.push(
|
|
190
|
+
lines.push(...renderAgentLines(agent, { showResultPreviews, now }));
|
|
129
191
|
}
|
|
130
192
|
if (agents.length > maxAgents) lines.push(` … ${agents.length - maxAgents} earlier agents`);
|
|
131
193
|
}
|
|
@@ -134,8 +196,9 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderO
|
|
|
134
196
|
if (unphased.length) {
|
|
135
197
|
lines.push(" (unphased)");
|
|
136
198
|
for (const agent of unphased.slice(-maxAgents)) {
|
|
137
|
-
lines.push(
|
|
199
|
+
lines.push(...renderAgentLines(agent, { showResultPreviews, now }));
|
|
138
200
|
}
|
|
201
|
+
if (unphased.length > maxAgents) lines.push(` … ${unphased.length - maxAgents} earlier agents`);
|
|
139
202
|
}
|
|
140
203
|
|
|
141
204
|
for (const log of snapshot.logs.slice(-maxLogs)) lines.push(` log: ${shorten(log, 100)}`);
|
|
@@ -147,10 +210,8 @@ export function renderWorkflowText(snapshot: WorkflowSnapshot, options: RenderOp
|
|
|
147
210
|
}
|
|
148
211
|
|
|
149
212
|
export function preview(value: unknown, max = 80): string {
|
|
150
|
-
const text = typeof value === "string" ? value :
|
|
151
|
-
|
|
152
|
-
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
153
|
-
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
213
|
+
const text = typeof value === "string" ? value : boundedProjection(value, max);
|
|
214
|
+
return text ? safeDisplayText(text, max) : "";
|
|
154
215
|
}
|
|
155
216
|
|
|
156
217
|
function statusMark(status: WorkflowSnapshot["status"]): string {
|
|
@@ -176,6 +237,8 @@ function statusIcon(status: WorkflowAgentStatus): string {
|
|
|
176
237
|
return "⟲";
|
|
177
238
|
case "error":
|
|
178
239
|
return "✗";
|
|
240
|
+
case "cancelled":
|
|
241
|
+
return "■";
|
|
179
242
|
case "skipped":
|
|
180
243
|
return "-";
|
|
181
244
|
}
|
|
@@ -186,38 +249,88 @@ function unique(values: string[]): string[] {
|
|
|
186
249
|
}
|
|
187
250
|
|
|
188
251
|
function shorten(value: string, max: number): string {
|
|
189
|
-
|
|
190
|
-
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
252
|
+
return safeDisplayText(value, max);
|
|
191
253
|
}
|
|
192
254
|
|
|
193
255
|
function formatTokens(n: number): string {
|
|
194
256
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
195
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
257
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;
|
|
196
258
|
return String(n);
|
|
197
259
|
}
|
|
198
260
|
|
|
199
|
-
|
|
261
|
+
const PREVIEW_MAX_STRING = 256;
|
|
262
|
+
const UNINSPECTABLE_PREVIEW = "[Uninspectable]";
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Build a UI-only projection without invoking arbitrary object hooks. Objects
|
|
266
|
+
* and arrays intentionally remain opaque: JavaScript has no bounded own-key
|
|
267
|
+
* enumeration API, and Proxy/accessor traps must never affect agent success.
|
|
268
|
+
*/
|
|
269
|
+
function boundedProjection(value: unknown, _max: number): string {
|
|
200
270
|
try {
|
|
201
|
-
|
|
271
|
+
if (value === null) return "null";
|
|
272
|
+
if (typeof value === "string") {
|
|
273
|
+
const bounded = value.length > PREVIEW_MAX_STRING
|
|
274
|
+
? `${value.slice(0, PREVIEW_MAX_STRING)}…`
|
|
275
|
+
: value;
|
|
276
|
+
return JSON.stringify(bounded);
|
|
277
|
+
}
|
|
278
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "undefined") {
|
|
279
|
+
return String(value);
|
|
280
|
+
}
|
|
281
|
+
if (typeof value === "bigint") return "[BigInt]";
|
|
282
|
+
if (typeof value === "symbol") return "[Symbol]";
|
|
283
|
+
if (typeof value === "function") return "[Function]";
|
|
284
|
+
return Array.isArray(value) ? "[Array]" : "[Object]";
|
|
202
285
|
} catch {
|
|
203
|
-
return
|
|
286
|
+
return UNINSPECTABLE_PREVIEW;
|
|
204
287
|
}
|
|
205
288
|
}
|
|
206
289
|
|
|
207
|
-
/** Seconds
|
|
290
|
+
/** Seconds without an observable session event before visibility escalates. */
|
|
208
291
|
export const IDLE_THRESHOLD_S = 30;
|
|
209
292
|
|
|
210
|
-
function
|
|
293
|
+
function renderAgentLines(
|
|
211
294
|
agent: WorkflowAgentSnapshot,
|
|
212
|
-
opts: {
|
|
213
|
-
|
|
295
|
+
opts: {
|
|
296
|
+
showResultPreviews: boolean;
|
|
297
|
+
now: number;
|
|
298
|
+
},
|
|
299
|
+
): string[] {
|
|
214
300
|
const meta = agentMeta(agent, opts.now);
|
|
215
|
-
const result = opts.showResultPreviews && agent.resultPreview
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
301
|
+
const result = opts.showResultPreviews && agent.resultPreview
|
|
302
|
+
? ` — ${safeDisplayText(agent.resultPreview, 80)}`
|
|
303
|
+
: "";
|
|
304
|
+
return [
|
|
305
|
+
` #${agent.id} ${statusIcon(agent.status)} ${shorten(agent.label, 48)}${meta}${result}`,
|
|
306
|
+
` ${agentStats(agent)}`,
|
|
307
|
+
];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function agentStats(agent: WorkflowAgentSnapshot): string {
|
|
311
|
+
const running = agent.status === "running";
|
|
312
|
+
const model = agent.modelId
|
|
313
|
+
?? (running ? "resolving model…" : agent.legacyCache ? "model unavailable" : "model unresolved");
|
|
314
|
+
const effort = agent.effort
|
|
315
|
+
?? (running ? "resolving effort…" : agent.legacyCache ? "effort unavailable" : "effort unresolved");
|
|
316
|
+
if (agent.legacyCache) {
|
|
317
|
+
return `${shorten(model, 48)} • ${shorten(effort, 24)} · metrics unavailable · cached legacy entry`;
|
|
318
|
+
}
|
|
319
|
+
const usage = agent.usage ?? {
|
|
320
|
+
inputTokens: 0,
|
|
321
|
+
outputTokens: 0,
|
|
322
|
+
cacheReadTokens: 0,
|
|
323
|
+
cacheWriteTokens: 0,
|
|
324
|
+
totalTokens: 0,
|
|
325
|
+
cost: 0,
|
|
326
|
+
turns: 0,
|
|
327
|
+
toolUses: 0,
|
|
328
|
+
retries: 0,
|
|
329
|
+
compactions: 0,
|
|
330
|
+
};
|
|
331
|
+
const partial = agent.status === "cancelled" || agent.status === "error" || (running && agent.currentTurn != null);
|
|
332
|
+
const cache = agent.status === "cached" ? " · cached" : "";
|
|
333
|
+
return `${shorten(model, 48)} • ${shorten(effort, 24)} · ${usage.turns} turn${usage.turns === 1 ? "" : "s"} · ${usage.toolUses} tool use${usage.toolUses === 1 ? "" : "s"} · ${formatTokens(usage.totalTokens)}${partial ? "+" : ""} token${partial ? " · partial" : ""}${cache}`;
|
|
221
334
|
}
|
|
222
335
|
|
|
223
336
|
/** Compact per-agent timing/activity suffix for the live snapshot. */
|
|
@@ -226,20 +339,40 @@ function agentMeta(agent: WorkflowAgentSnapshot, now: number): string {
|
|
|
226
339
|
const startedAt = agent.startedAt ?? now;
|
|
227
340
|
const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));
|
|
228
341
|
const lastAct = agent.lastActivityAt ?? startedAt;
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
342
|
+
const silence = Math.max(0, Math.floor((now - lastAct) / 1000));
|
|
343
|
+
const activeTools = agent.activeTools ?? [];
|
|
344
|
+
|
|
345
|
+
if (activeTools.length > 0) {
|
|
346
|
+
const tool = visibleTool(activeTools);
|
|
347
|
+
const toolDuration = formatDuration(Math.max(0, now - tool.startedAt));
|
|
348
|
+
const toolName = shorten(tool.name, 30) || "tool";
|
|
349
|
+
const args = tool.args ? `: ${shorten(tool.args, 60)}` : "";
|
|
350
|
+
const others = activeTools.length > 1 ? ` +${activeTools.length - 1} more` : "";
|
|
351
|
+
const noToolEvents = Math.max(0, Math.floor((now - tool.lastUpdateAt) / 1000));
|
|
352
|
+
const warning = noToolEvents >= IDLE_THRESHOLD_S ? ` · ⚠ no tool events ${noToolEvents}s` : "";
|
|
353
|
+
return ` · ${elapsed}s · running ${toolName}${args} (${toolDuration})${others}${warning}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (silence >= IDLE_THRESHOLD_S) {
|
|
357
|
+
const last = agent.activity ? ` · last: ${shorten(agent.activity, 64)}` : "";
|
|
358
|
+
return ` · ${elapsed}s · ⚠ no events ${silence}s${last}`;
|
|
359
|
+
}
|
|
360
|
+
const activity = agent.activity ? ` · ${shorten(agent.activity, 64)}` : "";
|
|
361
|
+
return ` · ${elapsed}s${activity}`;
|
|
233
362
|
}
|
|
234
363
|
if (agent.durationMs != null && agent.durationMs > 0) return ` · ${formatDuration(agent.durationMs)}`;
|
|
235
364
|
return "";
|
|
236
365
|
}
|
|
237
366
|
|
|
367
|
+
function visibleTool(tools: WorkflowActiveToolSnapshot[]): WorkflowActiveToolSnapshot {
|
|
368
|
+
return tools.reduce((stalest, tool) => tool.lastUpdateAt < stalest.lastUpdateAt ? tool : stalest);
|
|
369
|
+
}
|
|
370
|
+
|
|
238
371
|
function formatDuration(ms: number): string {
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
if (
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
return `${
|
|
372
|
+
const totalSeconds = Math.round(ms / 1000);
|
|
373
|
+
if (totalSeconds < 1) return "<1s";
|
|
374
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
375
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
376
|
+
const seconds = totalSeconds % 60;
|
|
377
|
+
return `${minutes}m${seconds ? ` ${seconds}s` : ""}`;
|
|
245
378
|
}
|
package/src/workflow/journal.ts
CHANGED
|
Binary file
|
package/src/workflow/registry.ts
CHANGED
|
@@ -4,31 +4,114 @@
|
|
|
4
4
|
* by the workflow tool as a run progresses.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { recompute, type WorkflowSnapshot } from "./display.ts";
|
|
10
|
+
import { WorkflowRunDetails } from "./run-details.ts";
|
|
8
11
|
|
|
9
12
|
export interface RunHandle {
|
|
10
13
|
snapshot: WorkflowSnapshot;
|
|
11
14
|
abort: () => void;
|
|
12
15
|
startedAt: number;
|
|
16
|
+
details?: WorkflowRunDetails;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export class WorkflowRegistry {
|
|
16
20
|
private readonly runs = new Map<string, RunHandle>();
|
|
21
|
+
private readonly listeners = new Set<() => void>();
|
|
17
22
|
private order: string[] = [];
|
|
23
|
+
private scopeDir?: string;
|
|
18
24
|
|
|
19
|
-
register(
|
|
20
|
-
|
|
25
|
+
register(
|
|
26
|
+
runId: string,
|
|
27
|
+
snapshot: WorkflowSnapshot,
|
|
28
|
+
abort: () => void,
|
|
29
|
+
details?: WorkflowRunDetails,
|
|
30
|
+
): RunHandle {
|
|
31
|
+
const handle: RunHandle = { snapshot, abort, startedAt: Date.now(), details };
|
|
21
32
|
this.runs.set(runId, handle);
|
|
22
33
|
this.order = this.order.filter((id) => id !== runId);
|
|
23
34
|
this.order.push(runId);
|
|
24
|
-
// Keep at most the 50 most recent runs in memory.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (evict) this.runs.delete(evict);
|
|
28
|
-
}
|
|
35
|
+
// Keep at most the 50 most recent runs in memory without evicting active work.
|
|
36
|
+
this.trimRuns();
|
|
37
|
+
this.notify();
|
|
29
38
|
return handle;
|
|
30
39
|
}
|
|
31
40
|
|
|
41
|
+
subscribe(listener: () => void): () => void {
|
|
42
|
+
this.listeners.add(listener);
|
|
43
|
+
return () => this.listeners.delete(listener);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
notify(): void {
|
|
47
|
+
for (const listener of this.listeners) {
|
|
48
|
+
try {
|
|
49
|
+
listener();
|
|
50
|
+
} catch {
|
|
51
|
+
// UI observers must not affect workflow execution.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Restore the newest task-detail manifests for the current Pi session. */
|
|
57
|
+
restoreRuns(runsDir: string): number {
|
|
58
|
+
this.setScope(runsDir);
|
|
59
|
+
let files: string[];
|
|
60
|
+
try {
|
|
61
|
+
files = fs.readdirSync(runsDir)
|
|
62
|
+
.filter((name) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.details\.json$/.test(name))
|
|
63
|
+
.map((name) => path.join(runsDir, name))
|
|
64
|
+
.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs)
|
|
65
|
+
.slice(-50);
|
|
66
|
+
} catch {
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
let restored = 0;
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const loaded = WorkflowRunDetails.restore(file);
|
|
72
|
+
if (!loaded || this.runs.has(loaded.snapshot.runId ?? loaded.details.runId)) continue;
|
|
73
|
+
const runId = loaded.snapshot.runId ?? loaded.details.runId;
|
|
74
|
+
if (loaded.snapshot.status === "running") {
|
|
75
|
+
loaded.snapshot.status = "aborted";
|
|
76
|
+
for (const agent of loaded.snapshot.agents) {
|
|
77
|
+
if (agent.status !== "running") continue;
|
|
78
|
+
agent.status = "cancelled";
|
|
79
|
+
agent.currentTurn = undefined;
|
|
80
|
+
loaded.details.finishTask(agent.id, { status: "cancelled", error: "Session ended before the task completed." });
|
|
81
|
+
}
|
|
82
|
+
loaded.snapshot = recompute(loaded.snapshot);
|
|
83
|
+
loaded.details.persist(loaded.snapshot);
|
|
84
|
+
}
|
|
85
|
+
let startedAt = Date.now();
|
|
86
|
+
try {
|
|
87
|
+
startedAt = fs.statSync(file).mtimeMs;
|
|
88
|
+
} catch {
|
|
89
|
+
// Keep a deterministic in-memory fallback if the manifest disappears.
|
|
90
|
+
}
|
|
91
|
+
const handle: RunHandle = {
|
|
92
|
+
snapshot: loaded.snapshot,
|
|
93
|
+
abort: () => {},
|
|
94
|
+
startedAt,
|
|
95
|
+
details: loaded.details,
|
|
96
|
+
};
|
|
97
|
+
this.runs.set(runId, handle);
|
|
98
|
+
this.order.push(runId);
|
|
99
|
+
restored++;
|
|
100
|
+
}
|
|
101
|
+
this.trimRuns();
|
|
102
|
+
if (restored) this.notify();
|
|
103
|
+
return restored;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setScope(runsDir: string): void {
|
|
107
|
+
const resolved = path.resolve(runsDir);
|
|
108
|
+
if (this.scopeDir === resolved) return;
|
|
109
|
+
this.scopeDir = resolved;
|
|
110
|
+
this.runs.clear();
|
|
111
|
+
this.order = [];
|
|
112
|
+
this.notify();
|
|
113
|
+
}
|
|
114
|
+
|
|
32
115
|
get(runId: string): RunHandle | undefined {
|
|
33
116
|
return this.runs.get(runId);
|
|
34
117
|
}
|
|
@@ -36,8 +119,8 @@ export class WorkflowRegistry {
|
|
|
36
119
|
list(): RunHandle[] {
|
|
37
120
|
return this.order
|
|
38
121
|
.map((id) => this.runs.get(id))
|
|
39
|
-
.filter((
|
|
40
|
-
.
|
|
122
|
+
.filter((handle): handle is RunHandle => Boolean(handle))
|
|
123
|
+
.sort((left, right) => right.startedAt - left.startedAt);
|
|
41
124
|
}
|
|
42
125
|
|
|
43
126
|
active(): RunHandle[] {
|
|
@@ -55,6 +138,19 @@ export class WorkflowRegistry {
|
|
|
55
138
|
}
|
|
56
139
|
}
|
|
57
140
|
}
|
|
141
|
+
|
|
142
|
+
private trimRuns(): void {
|
|
143
|
+
while (this.runs.size > 50) {
|
|
144
|
+
const candidate = [...this.runs.entries()].sort((left, right) => {
|
|
145
|
+
const leftActive = left[1].snapshot.status === "running" ? 1 : 0;
|
|
146
|
+
const rightActive = right[1].snapshot.status === "running" ? 1 : 0;
|
|
147
|
+
return leftActive - rightActive || left[1].startedAt - right[1].startedAt;
|
|
148
|
+
})[0];
|
|
149
|
+
if (!candidate) break;
|
|
150
|
+
this.runs.delete(candidate[0]);
|
|
151
|
+
this.order = this.order.filter((id) => id !== candidate[0]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
58
154
|
}
|
|
59
155
|
|
|
60
156
|
let singleton: WorkflowRegistry | undefined;
|