@fyeeme/pi-dynamic-workflows 0.1.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/README.zh-CN.md +6 -4
- package/index.ts +10 -293
- package/package.json +4 -3
- package/src/format.ts +6 -15
- package/src/runner/stage-executor.ts +5 -0
- package/src/types.ts +0 -12
- package/src/inspect.ts +0 -237
- package/src/ui-groups.ts +0 -43
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ npm install --ignore-scripts # hydrate (the package is a workspace dep)
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
This resolves [`@fyeeme/pi-subagent-core`](https://www.npmjs.com/package/@fyeeme/pi-subagent-core)
|
|
32
|
-
(`^0.
|
|
32
|
+
(`^0.5.0`, from the npm registry — no sibling-repo layout requirement).
|
|
33
33
|
|
|
34
34
|
Then import the public API from the package root module (a TypeScript barrel; the package ships `.ts` source):
|
|
35
35
|
|
|
@@ -37,9 +37,11 @@ Then import the public API from the package root module (a TypeScript barrel; th
|
|
|
37
37
|
import { defineWorkflow, runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
> The package's `pi.extensions` entry
|
|
41
|
-
>
|
|
42
|
-
>
|
|
40
|
+
> The package's `pi.extensions` entry registers the `run_workflow` tool plus
|
|
41
|
+
> the shared sub-agent UI from pi-subagent-core (live agent widget above the
|
|
42
|
+
> editor, FleetView below it, and the `/agents` transcript viewer — every
|
|
43
|
+
> spawned workflow agent appears there under its step id). The engine is also
|
|
44
|
+
> fully usable via the imports shown here.
|
|
43
45
|
|
|
44
46
|
---
|
|
45
47
|
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**为 [pi](https://github.com/earendil-works/pi-mono) 打造的确定性 TypeScript 工作流编排。**
|
|
4
4
|
|
|
5
|
-
把工作流定义成一份类型化的声明式步骤列表,运行后即可获得**可恢复、受预算约束、可中止**的执行。融合 pi-dynamic-workflows 设计(
|
|
5
|
+
把工作流定义成一份类型化的声明式步骤列表,运行后即可获得**可恢复、受预算约束、可中止**的执行。融合 pi-dynamic-workflows 设计(10 个步骤原语 + 启发式 planner + outcome 收集器)与 Claude Code 工作流引擎的协调机制(确定性沙箱、缓存键恢复、按 agent 中止、动态预算、失控上限)。
|
|
6
6
|
|
|
7
7
|
语言:[English](README.md) | **中文**
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
## 为什么需要它
|
|
12
12
|
|
|
13
|
-
一次运行 = 一份步骤列表(`agent` / `code` / `fan_out` / `loop_until` / `adversarial` / `tournament` / `classify_route`)。引擎保证:
|
|
13
|
+
一次运行 = 一份步骤列表(`agent` / `code` / `log` / `fan_out` / `loop_until` / `loop_until_dry` / `adversarial` / `tournament` / `classify_route` / `sub_workflow`)。引擎保证:
|
|
14
14
|
|
|
15
15
|
- **确定性** —— workflow `.ts` 文件经 AST 守卫,禁止 `Date.now()` / `Math.random()` / `new Date()`;run id 是 `(timestamp, sequence)` 的纯函数。
|
|
16
16
|
- **恢复即不重派** —— 每个 agent 调用以 `sha256(workflow + prompt + signature)` 为键写入 journal;重跑同一 workflow 会回放缓存的 agent(零子进程派发)。
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
npm install --ignore-scripts # 水合(本包是 workspace 依赖)
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
这会解析 npm registry 上的 [`@fyeeme/pi-subagent-core`](https://www.npmjs.com/package/@fyeeme/pi-subagent-core)(`^0.
|
|
31
|
+
这会解析 npm registry 上的 [`@fyeeme/pi-subagent-core`](https://www.npmjs.com/package/@fyeeme/pi-subagent-core)(`^0.5.0`,无需保持同级仓库目录结构)。
|
|
32
32
|
|
|
33
33
|
随后从包根模块导入公共 API(TypeScript barrel,包直接以 `.ts` 源码分发):
|
|
34
34
|
|
|
@@ -36,7 +36,7 @@ npm install --ignore-scripts # 水合(本包是 workspace 依赖)
|
|
|
36
36
|
import { defineWorkflow, runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
> 包的 `pi.extensions`
|
|
39
|
+
> 包的 `pi.extensions` 入口注册 `run_workflow` 工具,并接入 pi-subagent-core 的共享子代理 UI(编辑器上方实时 agent widget、下方 FleetView、`/agents` 转录查看器——每个工作流 agent 以其 step id 出现在其中)。引擎本身也可经上述导入直接使用。
|
|
40
40
|
|
|
41
41
|
---
|
|
42
42
|
|
|
@@ -298,6 +298,8 @@ const result = await runWorkflow({ workflow: wf, cwd: tempDir, now: 1000, dispat
|
|
|
298
298
|
| `adversarial` | `produce`、`rubric[]`、`judges?`、`minPass?` | `{ candidate, passed, passCount, judges }` |
|
|
299
299
|
| `tournament` | `candidates`、`judges`、`produce` | `{ candidates, winner, judges }` |
|
|
300
300
|
| `classify_route` | `classifier`、`routes: Record<cat, Step[]>`、`fallback?` | `{ category, matched, route, routeStatus }` |
|
|
301
|
+
| `sub_workflow` | `workflow: WorkflowDefinition`、`input?`、`inheritBudget?` | `{ steps, status, workflowName, error }` |
|
|
302
|
+
| `loop_until_dry` | `agent(item, i)`、`keyOf?`、`merge?`、`maxRounds?`、`dryThreshold?` | 发现项组成的数组 |
|
|
301
303
|
|
|
302
304
|
每个步骤都接受 `id`、`retry?: { maxRetries }` 与
|
|
303
305
|
`onBudgetExhaust?: "throw" | "null"`——`"null"` 下预算耗尽时该步骤返回 `null`
|
package/index.ts
CHANGED
|
@@ -5,27 +5,20 @@
|
|
|
5
5
|
* workflow from within pi. The engine (src/runner) does the work; this entry
|
|
6
6
|
* only adapts the agent's JSON args into the code-form WorkflowDefinition and
|
|
7
7
|
* runs it with the default dispatch (real `pi --mode json` subprocesses).
|
|
8
|
+
*
|
|
9
|
+
* Live progress UI is delegated to the shared `@fyeeme/pi-subagent-core`
|
|
10
|
+
* extension (registered via the `pi.extensions` manifest alongside this
|
|
11
|
+
* entry): every spawned workflow agent notifies the process-global monitor
|
|
12
|
+
* through spawnAgent, rendering in the shared above-editor agent widget, the
|
|
13
|
+
* below-editor FleetView, and the `/agents` transcript viewer. This package
|
|
14
|
+
* ships no widget/command of its own — a former `wf:progress` widget and
|
|
15
|
+
* `/wf-inspect` command were removed in favor of that shared surface.
|
|
8
16
|
*/
|
|
9
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
18
|
import { Type } from "typebox";
|
|
11
|
-
import { buildRenderGroups, type PhaseDef } from "./src/ui-groups.ts";
|
|
12
|
-
import { BOLD, CYAN, DIM, GREEN, RED, YELLOW, fmtTokens, stepIdOf } from "./src/format.ts";
|
|
13
19
|
import { defineWorkflow, runWorkflow } from "./src/index.ts";
|
|
14
|
-
import type {
|
|
20
|
+
import type { Budget, StepContext, StepDefinition, WorkflowDefinition } from "./src/types.ts";
|
|
15
21
|
import { WorkflowError } from "./src/errors.ts";
|
|
16
|
-
import type { AgentLifecycleListeners } from "./src/lifecycle.ts";
|
|
17
|
-
import { WorkflowInspect } from "./src/inspect.ts";
|
|
18
|
-
|
|
19
|
-
/** Last completed run, exposed to /wf-inspect for interactive review. */
|
|
20
|
-
let lastRunResult: RunResult | null = null;
|
|
21
|
-
|
|
22
|
-
/** Phases of the most recent run — handed to /wf-inspect so the post-run view
|
|
23
|
-
* groups steps the same way the live widget did (C1 consistency). */
|
|
24
|
-
let lastPhases: readonly PhaseDef[] | undefined;
|
|
25
|
-
|
|
26
|
-
/** Active widget during a run — lets /wf-inspect show a live snapshot
|
|
27
|
-
* before the run completes (lastRunResult is only set post-run). */
|
|
28
|
-
let activeWidget: { snapshot(): RunResult } | null = null;
|
|
29
22
|
|
|
30
23
|
// ---------------------------------------------------------------------------
|
|
31
24
|
// Parameter schema (the JSON-serializable workflow subset)
|
|
@@ -49,7 +42,7 @@ const StepSchema = Type.Union([
|
|
|
49
42
|
Type.Object({
|
|
50
43
|
id: Type.String(),
|
|
51
44
|
type: Type.Literal("log"),
|
|
52
|
-
message: Type.String({ description: "Narrative line
|
|
45
|
+
message: Type.String({ description: "Narrative line fired via the onLog lifecycle listener (zero dispatch / zero tokens)" }),
|
|
53
46
|
onBudgetExhaust: BudgetExhaustPolicy,
|
|
54
47
|
}),
|
|
55
48
|
Type.Object({
|
|
@@ -102,18 +95,11 @@ const BudgetSchema = Type.Object({
|
|
|
102
95
|
maxDurationMs: Type.Optional(Type.Number()),
|
|
103
96
|
});
|
|
104
97
|
|
|
105
|
-
const PhaseSchema = Type.Object({
|
|
106
|
-
title: Type.String({ description: "Phase display name" }),
|
|
107
|
-
detail: Type.Optional(Type.String({ description: "Short detail shown next to the phase title" })),
|
|
108
|
-
stepIds: Type.Array(Type.String(), { description: "Step ids belonging to this phase" }),
|
|
109
|
-
});
|
|
110
|
-
|
|
111
98
|
const WorkflowSchema = Type.Object({
|
|
112
99
|
name: Type.String(),
|
|
113
100
|
description: Type.Optional(Type.String()),
|
|
114
101
|
steps: Type.Array(StepSchema),
|
|
115
102
|
budget: Type.Optional(BudgetSchema),
|
|
116
|
-
phases: Type.Optional(Type.Array(PhaseSchema, { description: "Group steps into phases for progress-tree UI" })),
|
|
117
103
|
});
|
|
118
104
|
|
|
119
105
|
const RunWorkflowParams = Type.Object({
|
|
@@ -287,234 +273,6 @@ type StepData =
|
|
|
287
273
|
onBudgetExhaust?: "throw" | "null";
|
|
288
274
|
};
|
|
289
275
|
|
|
290
|
-
// ---------------------------------------------------------------------------
|
|
291
|
-
// Progress widget — bridges lifecycle events → TUI setWidget
|
|
292
|
-
// ---------------------------------------------------------------------------
|
|
293
|
-
|
|
294
|
-
type CallStatus = "running" | "done" | "failed" | "skipped" | "retried" | "cached";
|
|
295
|
-
|
|
296
|
-
interface CallInfo {
|
|
297
|
-
readonly stepId: string;
|
|
298
|
-
readonly status: CallStatus;
|
|
299
|
-
readonly tokens: number;
|
|
300
|
-
readonly model?: string;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export function buildProgressWidget(
|
|
304
|
-
steps: readonly { id: string; type: string }[],
|
|
305
|
-
setWidget: (lines: string[] | undefined) => void,
|
|
306
|
-
setStatus: (text: string | undefined) => void,
|
|
307
|
-
phases?: readonly PhaseDef[],
|
|
308
|
-
): AgentLifecycleListeners & { cleanup(): void; snapshot(): RunResult } {
|
|
309
|
-
const start = Date.now();
|
|
310
|
-
let lastStreamRender = 0;
|
|
311
|
-
// Once cleanup() runs (run finished), late events from the abort window — the
|
|
312
|
-
// SIGTERM→SIGKILL grace period during which a child can still emit streamed
|
|
313
|
-
// deltas — must not re-create the panel via render()/setWidget.
|
|
314
|
-
let disposed = false;
|
|
315
|
-
const calls = new Map<AgentCallId, CallInfo>();
|
|
316
|
-
// C2: narrative lines emitted by `log` steps, keyed by step id.
|
|
317
|
-
const logLines = new Map<string, string>();
|
|
318
|
-
// C3: accumulated streaming text per in-flight call (delta chunks from onUpdate).
|
|
319
|
-
const streamText = new Map<string, string>();
|
|
320
|
-
// Live output capture: callId → the settled agent's final text (from onAgentEnd
|
|
321
|
-
// `output`), so a live snapshot shows REAL results, not fabricated progress text.
|
|
322
|
-
const callOutputs = new Map<string, string>();
|
|
323
|
-
// Every step starts at 0 expected agents; onAgentStart/onAgentCacheHit
|
|
324
|
-
// increment as calls fire (fan_out totals emerge at runtime). Pre-seeding
|
|
325
|
-
// non-fan_out steps to 1 double-counted (1→2 on start), leaving completed
|
|
326
|
-
// single-agent steps stuck showing [1/2].
|
|
327
|
-
const expected = new Map(steps.map((s) => [s.id, 0]));
|
|
328
|
-
|
|
329
|
-
// C1+D1: phase grouping — shared pure builder (also used by /wf-inspect).
|
|
330
|
-
const renderGroups = buildRenderGroups(steps, (s) => s.id, phases);
|
|
331
|
-
|
|
332
|
-
// callId format: `${stepId}#${n}` (e.g. "fan#2", "adv#produce") — see src/format.ts stepIdOf.
|
|
333
|
-
|
|
334
|
-
function render(): void {
|
|
335
|
-
const picons: Record<string, string> = {
|
|
336
|
-
done: GREEN("✓"),
|
|
337
|
-
failed: RED("✗"),
|
|
338
|
-
skipped: YELLOW("⏭"),
|
|
339
|
-
running: YELLOW("⏳"),
|
|
340
|
-
cached: GREEN("↻"),
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
const lines: string[] = [];
|
|
344
|
-
const renderStep = (s: { id: string; type: string }, indent: boolean): void => {
|
|
345
|
-
// C2: a `log` step renders as a distinct narrative line, not an agent row.
|
|
346
|
-
if (s.type === "log") {
|
|
347
|
-
const msg = logLines.get(s.id);
|
|
348
|
-
if (msg !== undefined) lines.push(`${indent ? " " : " "}${DIM(msg)}`);
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
const total = expected.get(s.id) ?? 0;
|
|
352
|
-
const entries = [...calls.values()].filter((c) => c.stepId === s.id);
|
|
353
|
-
const running = entries.some((c) => c.status === "running");
|
|
354
|
-
const failed = entries.filter((c) => c.status === "failed").length;
|
|
355
|
-
const skipped = entries.filter((c) => c.status === "skipped").length;
|
|
356
|
-
const done = entries.filter((c) => c.status === "done" || c.status === "cached").length;
|
|
357
|
-
const cached = entries.filter((c) => c.status === "cached").length;
|
|
358
|
-
const tokens = entries.reduce((sum, c) => sum + c.tokens, 0);
|
|
359
|
-
const models = new Set(entries.map((c) => c.model).filter((m): m is string => Boolean(m)));
|
|
360
|
-
|
|
361
|
-
const icon = failed > 0 ? picons.failed
|
|
362
|
-
: skipped > 0 && done === 0 ? picons.skipped
|
|
363
|
-
: total > 0 && done >= total ? (cached > 0 ? picons.cached : picons.done)
|
|
364
|
-
: running ? picons.running
|
|
365
|
-
: DIM("○");
|
|
366
|
-
|
|
367
|
-
const progress = total > 1 ? ` [${done}/${total}]` : "";
|
|
368
|
-
const tok = tokens > 0 ? ` · ${fmtTokens(tokens)} tok` : "";
|
|
369
|
-
// A8/C4: show the serving model when known (single model shown; mixed
|
|
370
|
-
// fan_out models collapse to a count to avoid a noisy line).
|
|
371
|
-
const modelTag = models.size === 1 ? ` · ${[...models][0]}` : models.size > 1 ? ` · ${models.size} models` : "";
|
|
372
|
-
const pad = indent ? " " : " ";
|
|
373
|
-
lines.push(`${pad}${icon} ${s.id}${progress}${tok}${modelTag}`);
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
// Every phase group renders its header — a phase interrupted by ungrouped
|
|
377
|
-
// items produces two groups; deduplicating the second header would leave
|
|
378
|
-
// an indented, header-less orphan row (review L2).
|
|
379
|
-
for (const g of renderGroups) {
|
|
380
|
-
if (g.kind === "phase" && g.title) {
|
|
381
|
-
lines.push(` ${BOLD(g.title)}${g.detail ? DIM(` — ${g.detail}`) : ""}`);
|
|
382
|
-
}
|
|
383
|
-
for (const s of g.items) renderStep(s, g.kind === "phase");
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// C3: streaming tail — the most-recently-started running call's accumulated
|
|
387
|
-
// text, truncated to the last 3 lines, so concurrent fan-out previews only
|
|
388
|
-
// the active call instead of flooding the widget.
|
|
389
|
-
const running = [...calls.entries()].reverse().find(([id, c]) => c.status === "running" && streamText.has(id));
|
|
390
|
-
if (running) {
|
|
391
|
-
const [id] = running;
|
|
392
|
-
const text = streamText.get(id) ?? "";
|
|
393
|
-
const tail = text.split("\n").slice(-3);
|
|
394
|
-
for (const ln of tail) {
|
|
395
|
-
const clipped = ln.length > 100 ? `${ln.slice(0, 99)}…` : ln;
|
|
396
|
-
if (clipped) lines.push(DIM(` ↳ ${clipped}`));
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
setWidget(lines.length > 0 ? lines : void 0);
|
|
402
|
-
|
|
403
|
-
const all = [...calls.values()];
|
|
404
|
-
const allDone = all.filter((c) => c.status !== "running").length;
|
|
405
|
-
const totalTokens = all.reduce((sum, c) => sum + c.tokens, 0);
|
|
406
|
-
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
|
407
|
-
setStatus(`wf ${allDone}/${calls.size} agents · ${fmtTokens(totalTokens)} tok · ${elapsed}s`);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const record = (callId: string, status: CallStatus, tokens = 0, model?: string): void => {
|
|
411
|
-
if (disposed) return; // late settle after cleanup — do not re-create the panel
|
|
412
|
-
calls.set(callId, { stepId: stepIdOf(callId), status, tokens, model });
|
|
413
|
-
render();
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
return {
|
|
417
|
-
cleanup() {
|
|
418
|
-
disposed = true;
|
|
419
|
-
calls.clear();
|
|
420
|
-
streamText.clear();
|
|
421
|
-
setWidget(void 0);
|
|
422
|
-
setStatus(void 0);
|
|
423
|
-
},
|
|
424
|
-
/** Build a synthetic RunResult from the live calls map, so /wf-inspect
|
|
425
|
-
* can show in-progress agents before the run finishes. */
|
|
426
|
-
snapshot(): RunResult {
|
|
427
|
-
const snapSteps: StepResult[] = steps.map((s) => {
|
|
428
|
-
const entries = [...calls.values()].filter((c) => c.stepId === s.id);
|
|
429
|
-
const total = expected.get(s.id) ?? 0;
|
|
430
|
-
const done = entries.filter((c) => c.status === "done" || c.status === "cached").length;
|
|
431
|
-
const failed = entries.filter((c) => c.status === "failed").length;
|
|
432
|
-
const running = entries.filter((c) => c.status === "running").length;
|
|
433
|
-
const cached = entries.filter((c) => c.status === "cached").length;
|
|
434
|
-
const tokens = entries.reduce((sum, c) => sum + c.tokens, 0);
|
|
435
|
-
const status: StepResult["status"] = s.type === "log"
|
|
436
|
-
? "done" // narrative line, no agent call — never "skipped"
|
|
437
|
-
: failed > 0 ? "failed" : done >= total && total > 0 ? "done" : running > 0 ? "running" : "skipped";
|
|
438
|
-
// Real outputs from settled calls — NOT the fabricated `[n/m] running`
|
|
439
|
-
// progress string (that leaked into the detail pane as fake results).
|
|
440
|
-
const settled = [...calls.entries()]
|
|
441
|
-
.filter(([callId, c]) => c.stepId === s.id)
|
|
442
|
-
.map(([callId]) => callOutputs.get(callId))
|
|
443
|
-
.filter((o): o is string => Boolean(o));
|
|
444
|
-
const results = settled.length > 0
|
|
445
|
-
? (s.type === "fan_out" ? settled : settled[0])
|
|
446
|
-
: undefined;
|
|
447
|
-
return {
|
|
448
|
-
id: s.id,
|
|
449
|
-
type: s.type as StageType,
|
|
450
|
-
status,
|
|
451
|
-
results,
|
|
452
|
-
stats: { tokens, cost: 0, durationMs: 0, agents: entries.length, failures: failed },
|
|
453
|
-
};
|
|
454
|
-
});
|
|
455
|
-
const all = [...calls.values()];
|
|
456
|
-
const stats: StepStats = {
|
|
457
|
-
tokens: all.reduce((sum, c) => sum + c.tokens, 0),
|
|
458
|
-
cost: 0,
|
|
459
|
-
durationMs: Date.now() - start,
|
|
460
|
-
agents: all.length,
|
|
461
|
-
failures: all.filter((c) => c.status === "failed").length,
|
|
462
|
-
};
|
|
463
|
-
return { runId: "live", status: "completed", steps: snapSteps, stats };
|
|
464
|
-
},
|
|
465
|
-
onAgentStart(callId) {
|
|
466
|
-
const stepId = stepIdOf(callId);
|
|
467
|
-
expected.set(stepId, (expected.get(stepId) ?? 0) + 1);
|
|
468
|
-
record(callId, "running");
|
|
469
|
-
},
|
|
470
|
-
onAgentEnd(callId, ok, stats, model, output) {
|
|
471
|
-
// A skipped/retried/cached call's subprocess still settles (abort →
|
|
472
|
-
// notifyEnd(false)); do not overwrite the already-recorded terminal
|
|
473
|
-
// state with a "failed" stamp — the run's own bookkeeping marks the
|
|
474
|
-
// step skipped/retried, so the widget must show the same.
|
|
475
|
-
const cur = calls.get(callId);
|
|
476
|
-
if (cur && cur.status !== "running") {
|
|
477
|
-
streamText.delete(callId);
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
record(callId, ok ? "done" : "failed", stats?.tokens ?? 0, model);
|
|
481
|
-
streamText.delete(callId); // free the accumulated tail once the call settles
|
|
482
|
-
if (output) callOutputs.set(callId, output);
|
|
483
|
-
},
|
|
484
|
-
onAgentSkip(callId) {
|
|
485
|
-
record(callId, "skipped");
|
|
486
|
-
},
|
|
487
|
-
onAgentRetry(callId) {
|
|
488
|
-
record(callId, "retried");
|
|
489
|
-
},
|
|
490
|
-
onAgentCacheHit(callId) {
|
|
491
|
-
const stepId = stepIdOf(callId);
|
|
492
|
-
expected.set(stepId, (expected.get(stepId) ?? 0) + 1);
|
|
493
|
-
record(callId, "cached");
|
|
494
|
-
},
|
|
495
|
-
onLog(stepId, message) {
|
|
496
|
-
if (disposed) return;
|
|
497
|
-
logLines.set(stepId, message);
|
|
498
|
-
render();
|
|
499
|
-
},
|
|
500
|
-
onUpdate(callId, partial) {
|
|
501
|
-
if (disposed) return;
|
|
502
|
-
// Bound the accumulated tail: the widget only ever renders the last 3
|
|
503
|
-
// lines (each clipped to ~100 chars), so keeping the full stream alive
|
|
504
|
-
// for the call's duration is pure memory growth on long generations.
|
|
505
|
-
streamText.set(callId, ((streamText.get(callId) ?? "") + partial).slice(-4096));
|
|
506
|
-
// Throttle: a high-frequency stream (fan_out × many deltas) would otherwise
|
|
507
|
-
// trigger a full O(steps×calls) render() per chunk. Bound to ~20fps; the
|
|
508
|
-
// final onAgentEnd render always fires, so the settled state is exact.
|
|
509
|
-
const nowMs = Date.now();
|
|
510
|
-
if (nowMs - lastStreamRender >= 50) {
|
|
511
|
-
lastStreamRender = nowMs;
|
|
512
|
-
render();
|
|
513
|
-
}
|
|
514
|
-
},
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
|
|
518
276
|
// ---------------------------------------------------------------------------
|
|
519
277
|
// Extension
|
|
520
278
|
// ---------------------------------------------------------------------------
|
|
@@ -573,34 +331,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
573
331
|
ctx.ui.notify(`Invalid model(s) dropped, using default: ${dropped.join(", ")}`, "warning");
|
|
574
332
|
}
|
|
575
333
|
const sanitizedWorkflow = { ...params.workflow, steps: sanitizedSteps };
|
|
576
|
-
const widget = buildProgressWidget(
|
|
577
|
-
sanitizedWorkflow.steps,
|
|
578
|
-
(lines) => ctx.ui.setWidget("wf:progress", lines),
|
|
579
|
-
(text) => ctx.ui.setStatus("wf:summary", text),
|
|
580
|
-
sanitizedWorkflow.phases,
|
|
581
|
-
);
|
|
582
|
-
activeWidget = widget;
|
|
583
|
-
lastPhases = sanitizedWorkflow.phases;
|
|
584
334
|
try {
|
|
585
335
|
const workflow = buildWorkflow(sanitizedWorkflow);
|
|
586
|
-
const listeners: AgentLifecycleListeners = {
|
|
587
|
-
onAgentStart: widget.onAgentStart,
|
|
588
|
-
onAgentEnd: widget.onAgentEnd,
|
|
589
|
-
onAgentSkip: widget.onAgentSkip,
|
|
590
|
-
onAgentRetry: widget.onAgentRetry,
|
|
591
|
-
onAgentCacheHit: widget.onAgentCacheHit,
|
|
592
|
-
onLog: widget.onLog,
|
|
593
|
-
onUpdate: widget.onUpdate,
|
|
594
|
-
};
|
|
595
336
|
const result = await runWorkflow({
|
|
596
337
|
workflow,
|
|
597
338
|
input: params.input,
|
|
598
339
|
cwd: params.cwd ?? ctx.cwd,
|
|
599
340
|
now: params.now ?? Date.now(),
|
|
600
341
|
signal,
|
|
601
|
-
listeners,
|
|
602
342
|
});
|
|
603
|
-
lastRunResult = result;
|
|
604
343
|
|
|
605
344
|
const lines = [
|
|
606
345
|
`workflow "${workflow.name}" → ${result.status} (run ${result.runId})`,
|
|
@@ -616,29 +355,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
616
355
|
} catch (e) {
|
|
617
356
|
const msg = e instanceof Error ? e.message : String(e);
|
|
618
357
|
return { content: [{ type: "text" as const, text: `run_workflow failed: ${msg}` }], details: { error: msg }, isError: true };
|
|
619
|
-
} finally {
|
|
620
|
-
activeWidget = null;
|
|
621
|
-
ctx.ui.setWidget("wf:progress", void 0);
|
|
622
|
-
widget.cleanup();
|
|
623
|
-
}
|
|
624
|
-
},
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
pi.registerCommand("wf-inspect", {
|
|
628
|
-
description: "Inspect the current/last workflow run (↑↓ select, enter detail, esc exit)",
|
|
629
|
-
handler: async (_args, ctx) => {
|
|
630
|
-
// Prefer a live snapshot while a run is in progress; fall back to
|
|
631
|
-
// the last completed result once the run has finished.
|
|
632
|
-
const r = activeWidget?.snapshot() ?? lastRunResult;
|
|
633
|
-
if (!r) {
|
|
634
|
-
ctx.ui.notify("No workflow run yet — run run_workflow first", "warning");
|
|
635
|
-
return;
|
|
636
358
|
}
|
|
637
|
-
await ctx.ui.custom(
|
|
638
|
-
(tui, _theme, _kb, done) =>
|
|
639
|
-
new WorkflowInspect(r, tui, () => done(undefined), lastPhases),
|
|
640
|
-
{ overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "80%" } },
|
|
641
|
-
);
|
|
642
359
|
},
|
|
643
360
|
});
|
|
644
361
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fyeeme/pi-dynamic-workflows",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Deterministic TypeScript workflow orchestration for pi. Fuses the pi-dynamic-workflows design (declarative graph, 10 step primitives, heuristic planner, outcome collectors) with Claude Code's workflow engine coordination mechanisms (deterministic sandbox, cache-key resume, per-agent abort map, dynamic budget, runaway caps).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
],
|
|
29
29
|
"pi": {
|
|
30
30
|
"extensions": [
|
|
31
|
-
"./index.ts"
|
|
31
|
+
"./index.ts",
|
|
32
|
+
"./node_modules/@fyeeme/pi-subagent-core/sub-agent.ts"
|
|
32
33
|
]
|
|
33
34
|
},
|
|
34
35
|
"scripts": {
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
"typecheck": "tsc"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
|
-
"@fyeeme/pi-subagent-core": "^0.
|
|
40
|
+
"@fyeeme/pi-subagent-core": "^0.5.0"
|
|
40
41
|
},
|
|
41
42
|
"peerDependencies": {
|
|
42
43
|
"@earendil-works/pi-ai": ">=0.84.1",
|
package/src/format.ts
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* src/format.ts — shared
|
|
2
|
+
* src/format.ts — shared parsing helper used by the runner.
|
|
3
3
|
*
|
|
4
|
-
* fmtTokens + ANSI color helpers: used by `buildProgressWidget` (index.ts) and
|
|
5
|
-
* `WorkflowInspect` (src/inspect.ts) — one copy so the two UIs cannot drift.
|
|
6
4
|
* stepIdOf: callId → step-id attribution, used by the runner (degraded-step
|
|
7
|
-
* accounting
|
|
8
|
-
*
|
|
5
|
+
* accounting, sibling abort scoping) and by the engine's dispatchOpts (the
|
|
6
|
+
* monitor `displayName` for the shared sub-agent UI). The former ANSI color
|
|
7
|
+
* helpers + fmtTokens were progress-widget/`/wf-inspect` rendering aids and
|
|
8
|
+
* were removed together with those surfaces (live progress is now rendered by
|
|
9
|
+
* the shared @fyeeme/pi-subagent-core extension).
|
|
9
10
|
*/
|
|
10
|
-
export const GREEN = (s: string): string => `\x1b[32m${s}\x1b[0m`;
|
|
11
|
-
export const RED = (s: string): string => `\x1b[31m${s}\x1b[0m`;
|
|
12
|
-
export const YELLOW = (s: string): string => `\x1b[33m${s}\x1b[0m`;
|
|
13
|
-
export const DIM = (s: string): string => `\x1b[2m${s}\x1b[0m`;
|
|
14
|
-
export const CYAN = (s: string): string => `\x1b[36m${s}\x1b[0m`;
|
|
15
|
-
export const BOLD = (s: string): string => `\x1b[1m${s}\x1b[0m`;
|
|
16
|
-
|
|
17
|
-
export function fmtTokens(n: number): string {
|
|
18
|
-
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
19
|
-
}
|
|
20
11
|
|
|
21
12
|
/** Extract the step id from a callId of the form `${stepId}#${n}` (e.g.
|
|
22
13
|
* "fan#2", "adv#produce", "cr#classify"). Falls back to the whole callId when
|
|
@@ -985,6 +985,11 @@ function dispatchOpts(
|
|
|
985
985
|
systemPrompt: spec.systemPrompt,
|
|
986
986
|
signal,
|
|
987
987
|
allowChildRecursion,
|
|
988
|
+
// UI display name for the shared sub-agent widget/FleetView (purely
|
|
989
|
+
// observational metadata consumed by the pi-subagent-core monitor): the
|
|
990
|
+
// step id ("fan", "adv"), so workflow rows are distinguishable from other
|
|
991
|
+
// agents. Cache hits never spawn, so they never appear — zero dispatch.
|
|
992
|
+
displayName: stepIdOf(callId),
|
|
988
993
|
// C3: bridge the spawn's streamed deltas to the lifecycle onUpdate listener,
|
|
989
994
|
// attributed to this callId. When no listener is registered, the subprocess
|
|
990
995
|
// drops the deltas (its onUpdate stays undefined — same as before).
|
package/src/types.ts
CHANGED
|
@@ -247,23 +247,11 @@ export interface StepRetry {
|
|
|
247
247
|
* spec revision; add `retryStage` back with implementation when ready. */
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
/** A phase groups related steps for UI progress-tree rendering.
|
|
251
|
-
* Steps not assigned to any phase render under an implicit default group. */
|
|
252
|
-
export interface PhaseDefinition {
|
|
253
|
-
readonly title: string;
|
|
254
|
-
readonly detail?: string;
|
|
255
|
-
readonly stepIds: readonly string[];
|
|
256
|
-
/** Optional model override for all agents in this phase. */
|
|
257
|
-
readonly model?: string;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
250
|
export interface WorkflowDefinition {
|
|
261
251
|
readonly name: string;
|
|
262
252
|
readonly description?: string;
|
|
263
253
|
readonly steps: readonly StepDefinition[];
|
|
264
254
|
readonly budget?: Budget;
|
|
265
|
-
/** Optional phase groupings for progress-tree UI rendering. */
|
|
266
|
-
readonly phases?: readonly PhaseDefinition[];
|
|
267
255
|
}
|
|
268
256
|
|
|
269
257
|
/** Typed identity helper: gives a workflow literal full union checking. */
|
package/src/inspect.ts
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WorkflowInspect — fullscreen overlay viewer with a master-detail split.
|
|
3
|
-
*
|
|
4
|
-
* Opened via `/wf-inspect` as an overlay (`ctx.ui.custom(..., { overlay: true })`)
|
|
5
|
-
* sized to ~90% × 80% of the terminal. The body is split into two panes that
|
|
6
|
-
* fill the wide overlay instead of wasting it on a single narrow column:
|
|
7
|
-
*
|
|
8
|
-
* ┌─ STEPS (left ~30%) ──────┬─ DETAIL (right ~70%) ──────────────┐
|
|
9
|
-
* │ ✓ gather · 1.2k tok │ collected 3 sources on topic X │
|
|
10
|
-
* │▸✓ fan · 8.4k tok │ [fan#1] Research alpha. │
|
|
11
|
-
* │ ○ refine · pending │ 8.4k tok · 3 agent(s) · 1240ms │
|
|
12
|
-
* └──────────────────────────┴────────────────────────────────────┘
|
|
13
|
-
*
|
|
14
|
-
* Left: compact step list (always visible, ↑↓/j/k selects, auto-scrolls).
|
|
15
|
-
* Right: the selected step's full results + stats (PgUp/PgDn/Shift+↑↓/Home/End
|
|
16
|
-
* scrolls the detail). No toggle — detail is always shown for the selection.
|
|
17
|
-
* esc/q exits.
|
|
18
|
-
*/
|
|
19
|
-
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
20
|
-
import type { RunResult, StepResult } from "./types.ts";
|
|
21
|
-
import { buildRenderGroups, type PhaseDef } from "./ui-groups.ts";
|
|
22
|
-
import { BOLD, CYAN, DIM, GREEN, RED, YELLOW, fmtTokens } from "./format.ts";
|
|
23
|
-
|
|
24
|
-
/** Overlay maxHeight percentage — keep in sync with the index.ts overlayOptions. */
|
|
25
|
-
const VIEWPORT_HEIGHT_PCT = 80;
|
|
26
|
-
/** header(1) + sep(1) + col-header(1) + sep(1) + [body] + sep(1) + footer(1). */
|
|
27
|
-
const CHROME_LINES = 6;
|
|
28
|
-
const MIN_VIEWPORT = 3;
|
|
29
|
-
/** Left pane share of the width (rest goes to the detail pane + separator). */
|
|
30
|
-
const LEFT_PCT = 0.3;
|
|
31
|
-
const MIN_LEFT = 16;
|
|
32
|
-
|
|
33
|
-
/** Minimal TUI surface WorkflowInspect needs. Narrower than full `TUI`. */
|
|
34
|
-
export interface InspectTUI {
|
|
35
|
-
requestRender(): void;
|
|
36
|
-
terminal: { readonly rows: number };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function statusIcon(status: StepResult["status"]): string {
|
|
40
|
-
switch (status) {
|
|
41
|
-
case "done":
|
|
42
|
-
return GREEN("✓");
|
|
43
|
-
case "failed":
|
|
44
|
-
return RED("✗");
|
|
45
|
-
case "skipped":
|
|
46
|
-
return YELLOW("⏭");
|
|
47
|
-
default:
|
|
48
|
-
return YELLOW("⏳");
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Fit a (possibly ANSI-colored) string into exactly `w` visible columns:
|
|
53
|
-
* truncate with … if too long, pad with spaces if too short. */
|
|
54
|
-
function field(s: string, w: number): string {
|
|
55
|
-
return truncateToWidth(s, w, "…", true);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export class WorkflowInspect {
|
|
59
|
-
private readonly result: RunResult;
|
|
60
|
-
private readonly tui: InspectTUI;
|
|
61
|
-
private readonly close: () => void;
|
|
62
|
-
private readonly phases?: readonly PhaseDef[];
|
|
63
|
-
private selected = 0;
|
|
64
|
-
/** Right-pane (detail) scroll offset; reset to 0 whenever selection changes. */
|
|
65
|
-
private detailScroll = 0;
|
|
66
|
-
/** Left-pane scroll offset; auto-adjusted to keep the selection visible. */
|
|
67
|
-
private leftScroll = 0;
|
|
68
|
-
/** Last known detail content height (set during render). Lets PgUp/PgDn
|
|
69
|
-
* clamp an Infinity detailScroll (set by End) before arithmetic, so
|
|
70
|
-
* End→PgUp is not swallowed by Infinity - vp = Infinity. */
|
|
71
|
-
private lastMaxDetailScroll = 0;
|
|
72
|
-
/** step index → row index within buildLeftLines() (phase title lines shift
|
|
73
|
-
* step rows past their raw array index — selection must scroll by row). */
|
|
74
|
-
private rowOfStep = new Map<number, number>();
|
|
75
|
-
|
|
76
|
-
constructor(result: RunResult, tui: InspectTUI, close: () => void, phases?: readonly PhaseDef[]) {
|
|
77
|
-
this.result = result;
|
|
78
|
-
this.tui = tui;
|
|
79
|
-
this.close = close;
|
|
80
|
-
this.phases = phases;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
private viewportHeight(): number {
|
|
84
|
-
const rows = this.tui.terminal.rows > 0 ? this.tui.terminal.rows : 24;
|
|
85
|
-
return Math.max(MIN_VIEWPORT, Math.floor((rows * VIEWPORT_HEIGHT_PCT) / 100) - CHROME_LINES);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
handleInput(data: string): void {
|
|
89
|
-
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
|
|
90
|
-
this.close();
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
const n = this.result.steps.length;
|
|
94
|
-
if (n === 0) return;
|
|
95
|
-
const vp = this.viewportHeight();
|
|
96
|
-
// Right-pane (detail) scroll.
|
|
97
|
-
if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
|
|
98
|
-
// End may have left detailScroll as the Infinity sentinel (clamped to a
|
|
99
|
-
// finite value only on the next render) — clamp before arithmetic so
|
|
100
|
-
// Infinity - vp stays Infinity and PgUp appears dead.
|
|
101
|
-
const cur = Math.min(this.detailScroll, this.lastMaxDetailScroll);
|
|
102
|
-
this.detailScroll = Math.max(0, cur - vp);
|
|
103
|
-
this.tui.requestRender();
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
|
|
107
|
-
const cur = Math.min(this.detailScroll, this.lastMaxDetailScroll);
|
|
108
|
-
this.detailScroll = cur + vp;
|
|
109
|
-
this.tui.requestRender();
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
if (matchesKey(data, "home")) {
|
|
113
|
-
this.detailScroll = 0;
|
|
114
|
-
this.tui.requestRender();
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
if (matchesKey(data, "end")) {
|
|
118
|
-
this.detailScroll = Number.POSITIVE_INFINITY;
|
|
119
|
-
this.tui.requestRender();
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
// Selection moves — reset the detail pane to the top of the new step.
|
|
123
|
-
if (matchesKey(data, "up") || data === "k") {
|
|
124
|
-
this.selected = (this.selected - 1 + n) % n;
|
|
125
|
-
this.detailScroll = 0;
|
|
126
|
-
this.tui.requestRender();
|
|
127
|
-
} else if (matchesKey(data, "down") || data === "j") {
|
|
128
|
-
this.selected = (this.selected + 1) % n;
|
|
129
|
-
this.detailScroll = 0;
|
|
130
|
-
this.tui.requestRender();
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
invalidate(): void {
|
|
135
|
-
// No cached render state.
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
render(width: number): string[] {
|
|
139
|
-
const r = this.result;
|
|
140
|
-
const w = Math.max(width, 40);
|
|
141
|
-
const leftW = Math.max(MIN_LEFT, Math.floor(w * LEFT_PCT));
|
|
142
|
-
const rightW = Math.max(20, w - leftW - 1); // -1 for the "│" separator
|
|
143
|
-
const sep = DIM("│");
|
|
144
|
-
const hr = DIM("─".repeat(w));
|
|
145
|
-
const out: string[] = [];
|
|
146
|
-
|
|
147
|
-
// --- Header (full width) ---
|
|
148
|
-
const degraded = r.degradedSteps?.length ? ` · ${r.degradedSteps.length} degraded` : "";
|
|
149
|
-
const errTag = r.errorCategory ? ` [${r.errorCategory}]` : "";
|
|
150
|
-
const head = `${BOLD(`workflow ${CYAN(r.runId)} → ${r.status}`)} ${DIM(`${r.stats.agents} agents · ${fmtTokens(r.stats.tokens)} tok · ${(r.stats.durationMs / 1000).toFixed(1)}s${degraded}`)}`;
|
|
151
|
-
out.push(field(r.error ? RED(`${head} ${r.error}${errTag}`) : head, w));
|
|
152
|
-
out.push(hr);
|
|
153
|
-
|
|
154
|
-
// --- Column header ---
|
|
155
|
-
const selStep = r.steps[this.selected];
|
|
156
|
-
const colLeft = BOLD("STEPS");
|
|
157
|
-
const colRight = DIM(`DETAIL${selStep ? ` · ${selStep.id} (${selStep.type})` : ""}`);
|
|
158
|
-
out.push(field(colLeft, leftW) + sep + field(colRight, rightW));
|
|
159
|
-
out.push(hr);
|
|
160
|
-
|
|
161
|
-
// --- Body: two panes zipped row-by-row ---
|
|
162
|
-
const vp = this.viewportHeight();
|
|
163
|
-
const leftLines = this.buildLeftLines(leftW);
|
|
164
|
-
// Keep the selected step visible in the left pane. leftLines may start
|
|
165
|
-
// with phase title rows, so the selection's ROW (not its raw step index)
|
|
166
|
-
// drives the window — otherwise phases push the selected step off-screen.
|
|
167
|
-
const selRow = this.rowOfStep.get(this.selected) ?? this.selected;
|
|
168
|
-
if (selRow < this.leftScroll) this.leftScroll = selRow;
|
|
169
|
-
else if (selRow >= this.leftScroll + vp) this.leftScroll = selRow - vp + 1;
|
|
170
|
-
this.leftScroll = Math.max(0, Math.min(this.leftScroll, Math.max(0, leftLines.length - vp)));
|
|
171
|
-
|
|
172
|
-
const rightLines = selStep ? this.buildRightLines(selStep) : [DIM("(no step)")];
|
|
173
|
-
const maxDetailScroll = Math.max(0, rightLines.length - vp);
|
|
174
|
-
this.lastMaxDetailScroll = maxDetailScroll;
|
|
175
|
-
this.detailScroll = Math.max(0, Math.min(this.detailScroll, maxDetailScroll));
|
|
176
|
-
|
|
177
|
-
for (let i = 0; i < vp; i++) {
|
|
178
|
-
const l = field(leftLines[this.leftScroll + i] ?? "", leftW);
|
|
179
|
-
const rr = field(rightLines[this.detailScroll + i] ?? "", rightW);
|
|
180
|
-
out.push(l + sep + rr);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// --- Footer ---
|
|
184
|
-
out.push(hr);
|
|
185
|
-
const footL = DIM("↑↓/j/k select · PgUp/PgDn/Shift+↑↓ scroll detail · Home/End · esc exit");
|
|
186
|
-
const dPct = rightLines.length <= vp ? "all" : `${Math.round(((this.detailScroll + vp) / rightLines.length) * 100)}%`;
|
|
187
|
-
const footR = DIM(`${this.detailScroll}/${rightLines.length} (${dPct}) · ${this.selected + 1}/${r.steps.length} steps`);
|
|
188
|
-
out.push(field(footL, leftW) + sep + field(footR, rightW));
|
|
189
|
-
return out;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Left pane: one compact line per step (status · id · type · tokens). */
|
|
193
|
-
private buildLeftLines(w: number): string[] {
|
|
194
|
-
const idxOf = new Map(this.result.steps.map((s, i) => [s.id, i]));
|
|
195
|
-
const groups = buildRenderGroups(this.result.steps, (s) => s.id, this.phases);
|
|
196
|
-
const lines: string[] = [];
|
|
197
|
-
const rowOf = new Map<number, number>();
|
|
198
|
-
// Every phase group renders its header (a phase interrupted by ungrouped
|
|
199
|
-
// items produces two groups; deduplicating the second header would leave
|
|
200
|
-
// an indented, header-less orphan row).
|
|
201
|
-
for (const g of groups) {
|
|
202
|
-
if (g.kind === "phase" && g.title) {
|
|
203
|
-
lines.push(field(BOLD(g.title), w));
|
|
204
|
-
}
|
|
205
|
-
for (const s of g.items) {
|
|
206
|
-
const i = idxOf.get(s.id) ?? 0;
|
|
207
|
-
rowOf.set(i, lines.length);
|
|
208
|
-
const sel = i === this.selected;
|
|
209
|
-
const tok = s.stats.tokens > 0 ? ` · ${fmtTokens(s.stats.tokens)} tok` : "";
|
|
210
|
-
const body = `${statusIcon(s.status)} ${s.id} ${DIM(`(${s.type})${tok}`)}`;
|
|
211
|
-
lines.push(field(sel ? `${CYAN("▸")}${BOLD(body)}` : ` ${body}`, w));
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
this.rowOfStep = rowOf;
|
|
215
|
-
return lines;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Right pane: the selected step's full results + a stats footer line.
|
|
219
|
-
* `results === undefined` means a live snapshot with nothing settled yet —
|
|
220
|
-
* show an honest "in progress" marker instead of JSON.stringify(undefined). */
|
|
221
|
-
private buildRightLines(s: StepResult): string[] {
|
|
222
|
-
const lines: string[] = [];
|
|
223
|
-
if (s.results === undefined) {
|
|
224
|
-
lines.push(DIM("(in progress — no agent output settled yet)"));
|
|
225
|
-
} else {
|
|
226
|
-
const body = typeof s.results === "string" ? s.results : JSON.stringify(s.results, null, 2);
|
|
227
|
-
for (const ln of body.split("\n")) lines.push(DIM(ln));
|
|
228
|
-
}
|
|
229
|
-
lines.push("");
|
|
230
|
-
lines.push(DIM(`${fmtTokens(s.stats.tokens)} tok · ${s.stats.agents} agent(s) · ${s.stats.durationMs}ms · ${s.stats.failures} fail`));
|
|
231
|
-
return lines;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
dispose(): void {
|
|
235
|
-
// Nothing to release.
|
|
236
|
-
}
|
|
237
|
-
}
|
package/src/ui-groups.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared phase-grouping logic (C1+D1) — used by the live progress widget
|
|
3
|
-
* (index.ts) and the `/wf-inspect` view (src/inspect.ts) so both render with
|
|
4
|
-
* identical grouping. Extracted as a pure function so it is unit-testable.
|
|
5
|
-
*
|
|
6
|
-
* Declared phases carry a header; ungrouped items collect into a header-less
|
|
7
|
-
* default group interleaved at declaration order. When no phases are declared
|
|
8
|
-
* (or none match), the whole item list is one default group (flat render).
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export interface PhaseDef {
|
|
12
|
-
readonly title: string;
|
|
13
|
-
readonly detail?: string;
|
|
14
|
-
readonly stepIds: readonly string[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface RenderGroup<T> {
|
|
18
|
-
readonly kind: "phase" | "default";
|
|
19
|
-
readonly title?: string;
|
|
20
|
-
readonly detail?: string;
|
|
21
|
-
readonly items: T[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function buildRenderGroups<T>(
|
|
25
|
-
items: readonly T[],
|
|
26
|
-
getId: (item: T) => string,
|
|
27
|
-
phases?: readonly PhaseDef[],
|
|
28
|
-
): RenderGroup<T>[] {
|
|
29
|
-
const stepPhase = new Map<string, { title: string; detail?: string }>();
|
|
30
|
-
if (phases && phases.length > 0) {
|
|
31
|
-
for (const ph of phases) for (const sid of ph.stepIds) stepPhase.set(sid, { title: ph.title, detail: ph.detail });
|
|
32
|
-
}
|
|
33
|
-
const groups: RenderGroup<T>[] = [];
|
|
34
|
-
for (const item of items) {
|
|
35
|
-
const ph = stepPhase.get(getId(item));
|
|
36
|
-
const key = ph ? `phase:${ph.title}` : "default";
|
|
37
|
-
const last = groups[groups.length - 1];
|
|
38
|
-
const lastKey = last ? (last.kind === "phase" ? `phase:${last.title}` : "default") : "";
|
|
39
|
-
if (last && lastKey === key) last.items.push(item);
|
|
40
|
-
else groups.push(ph ? { kind: "phase", title: ph.title, detail: ph.detail, items: [item] } : { kind: "default", items: [item] });
|
|
41
|
-
}
|
|
42
|
-
return groups;
|
|
43
|
-
}
|