@fyeeme/pi-dynamic-workflows 0.1.1

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.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Determinism AST guard — the load-time counterpart of CC.s vm-sandbox determinism ban.
3
+ *
4
+ * Workflow `.ts` files are loaded via jiti (a real module, not a vm-sandboxed
5
+ * script string), so we cannot sandbox at eval time the way CC does. Instead
6
+ * we parse the source BEFORE jiti loads it and reject any reference to the
7
+ * non-deterministic APIs: `Date.now`, `Math.random`, `new Date()`, `Date()`, and
8
+ * the Web Crypto randomness surface (`crypto.randomUUID`/`randomBytes`/
9
+ * `getRandomValues`), `performance.now`, and `process.hrtime`. Reading "now" or
10
+ * randomness inside a workflow body would make its output — and therefore the
11
+ * resume cache key derived from it — unstable across runs.
12
+ *
13
+ * Uses the typescript compiler's `createSourceFile` (pure parse, no type-check
14
+ * program) — the only reliable way to walk TypeScript syntax without a custom
15
+ * parser. Same patterns CC.s vm-sandbox flags (Date.now/Math.random), plus the `Date()`
16
+ * no-arg call (merged in from the alternative loader.ts implementation during
17
+ * Task 5 unification — that version caught this case, the original did not).
18
+ */
19
+ import {
20
+ type CallExpression,
21
+ type ElementAccessExpression,
22
+ type Identifier,
23
+ type NewExpression,
24
+ type Node,
25
+ type NoSubstitutionTemplateLiteral,
26
+ type ParenthesizedExpression,
27
+ type PropertyAccessExpression,
28
+ type StringLiteral,
29
+ ScriptTarget,
30
+ SyntaxKind,
31
+ createSourceFile,
32
+ } from "typescript";
33
+ import { WorkflowError } from "../errors.ts";
34
+
35
+ export type DeterminismViolationKind =
36
+ | "date_now"
37
+ | "math_random"
38
+ | "new_date"
39
+ | "date_call"
40
+ | "crypto_random"
41
+ | "performance_now"
42
+ | "process_hrtime";
43
+
44
+ export interface DeterminismViolation {
45
+ readonly kind: DeterminismViolationKind;
46
+ readonly line: number;
47
+ readonly column: number;
48
+ readonly source: string;
49
+ }
50
+
51
+ export const BANNED_API_MESSAGE: Record<DeterminismViolationKind, string> = {
52
+ date_now: "Date.now() is non-deterministic — pass a timestamp into the runtime instead",
53
+ math_random: "Math.random() is non-deterministic — use a sequence counter or pass a seed in",
54
+ new_date: "new Date() with no args is non-deterministic — pass a timestamp explicitly",
55
+ date_call: "Date() with no args is non-deterministic — pass an explicit format or timestamp",
56
+ crypto_random:
57
+ "crypto.randomUUID/randomBytes/getRandomValues is non-deterministic — use a seeded RNG or pass randomness in",
58
+ performance_now: "performance.now() is non-deterministic — use the run's deterministic inception time",
59
+ process_hrtime: "process.hrtime() is non-deterministic — use the run's deterministic inception time",
60
+ };
61
+
62
+ /**
63
+ * Scan TypeScript source for non-deterministic API references.
64
+ * Returns one entry per offending node, with 1-based line:column.
65
+ */
66
+ export function findDeterminismViolations(source: string, filename = "<workflow>"): DeterminismViolation[] {
67
+ const sf = createSourceFile(filename, source, ScriptTarget.Latest, /*setParentNodes*/ false);
68
+ const violations: DeterminismViolation[] = [];
69
+ walk(sf, (node) => {
70
+ const kind = classify(node);
71
+ if (kind !== null) {
72
+ const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
73
+ violations.push({ kind, line: line + 1, column: character + 1, source: node.getText(sf) });
74
+ }
75
+ });
76
+ return violations;
77
+ }
78
+
79
+ /**
80
+ * Assert source is deterministic; throw DeterminismError listing every
81
+ * violation. Loader calls this immediately before jiti-imports a workflow.
82
+ *
83
+ * Scope limitation (P1-3): scans ONLY the entry source passed in. Static
84
+ * imports (`import { now } from "./time-util.ts"`) whose helper calls
85
+ * Date.now / Math.random are NOT transitively scanned — keep workflows
86
+ * single-file, or guard imported helpers separately. Both dot and bracket
87
+ * access (`Date.now()` / `Date["now"]()`) are caught; destructuring aliases
88
+ * (`const {now} = Date; now()`) and `globalThis.Math.random()` still bypass.
89
+ * This is a single-file lint, not a full determinism proof.
90
+ */
91
+ export function assertDeterministic(source: string, filename?: string): void {
92
+ const violations = findDeterminismViolations(source, filename);
93
+ if (violations.length === 0) return;
94
+ const label = filename ?? "<workflow>";
95
+ const detail = violations
96
+ .map((v) => ` ${label}:${v.line}:${v.column} ${v.source} — ${BANNED_API_MESSAGE[v.kind]}`)
97
+ .join("\n");
98
+ throw new DeterminismError(
99
+ `Workflow source is non-deterministic (${violations.length} violation(s)):\n${detail}`,
100
+ );
101
+ }
102
+
103
+ export class DeterminismError extends WorkflowError {
104
+ constructor(message: string) {
105
+ super(message, { category: "determinism" });
106
+ this.name = "DeterminismError";
107
+ }
108
+ }
109
+
110
+ function classify(node: Node): DeterminismViolationKind | null {
111
+ // Unwrap `(...)` so `(Date).now()` and `(crypto)['randomUUID']` cannot bypass
112
+ // the identifier checks below.
113
+ function unwrap(n: Node): Node {
114
+ let cur = n;
115
+ while (cur.kind === SyntaxKind.ParenthesizedExpression) cur = (cur as ParenthesizedExpression).expression;
116
+ return cur;
117
+ }
118
+
119
+ // Date.now / Math.random / crypto.* / performance.now / process.hrtime — a
120
+ // property access off the global identifier (covers `crypto.randomUUID()`,
121
+ // `performance.now()`, and `process.hrtime.bigint()` via its `process.hrtime`
122
+ // base access).
123
+ if (node.kind === SyntaxKind.PropertyAccessExpression) {
124
+ const pae = node as PropertyAccessExpression;
125
+ const base = unwrap(pae.expression);
126
+ if (base.kind === SyntaxKind.Identifier) {
127
+ return matchGlobal((base as Identifier).text, pae.name.text);
128
+ }
129
+ return null;
130
+ }
131
+ // Bracket access off a known non-deterministic global: Date["now"],
132
+ // crypto["randomUUID"], performance["now"], process["hrtime"]. Closes the
133
+ // bracket-aliasing bypass (review m3) and the template-literal subscript
134
+ // bypass Date[`now`] (a NoSubstitutionTemplateLiteral, not a StringLiteral).
135
+ // Destructuring aliases (`const {now} = Date; now()`) and local shadowing
136
+ // (`const Date = {now: () => 42}`) still bypass / false-positive: the former
137
+ // needs cross-scope alias tracking, the latter scope analysis — both out of
138
+ // scope for this guard, which targets direct global-API use.
139
+ if (node.kind === SyntaxKind.ElementAccessExpression) {
140
+ const eae = node as ElementAccessExpression;
141
+ const base = unwrap(eae.expression);
142
+ const arg = eae.argumentExpression;
143
+ if (base.kind === SyntaxKind.Identifier) {
144
+ const obj = (base as Identifier).text;
145
+ if (arg?.kind === SyntaxKind.StringLiteral) {
146
+ return matchGlobal(obj, (arg as StringLiteral).text);
147
+ }
148
+ if (arg?.kind === SyntaxKind.NoSubstitutionTemplateLiteral) {
149
+ return matchGlobal(obj, (arg as NoSubstitutionTemplateLiteral).text);
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+ // Date() — a no-arg call expression returns the current-time string.
155
+ // Date.now() is a CallExpression too, but its callee is a PropertyAccess,
156
+ // not a bare Identifier, so it falls through here without matching.
157
+ if (node.kind === SyntaxKind.CallExpression) {
158
+ const ce = node as CallExpression;
159
+ if (
160
+ ce.expression.kind === SyntaxKind.Identifier &&
161
+ (ce.expression as Identifier).text === "Date" &&
162
+ (ce.arguments === undefined || ce.arguments.length === 0)
163
+ ) {
164
+ return "date_call";
165
+ }
166
+ return null;
167
+ }
168
+ // new Date() with zero arguments. new Date(ts) / new Date(y, m, d) are allowed.
169
+ if (node.kind === SyntaxKind.NewExpression) {
170
+ const ne = node as NewExpression;
171
+ if (
172
+ ne.expression.kind === SyntaxKind.Identifier &&
173
+ (ne.expression as Identifier).text === "Date" &&
174
+ (ne.arguments === undefined || ne.arguments.length === 0)
175
+ ) {
176
+ return "new_date";
177
+ }
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /** Map a (globalObject, property) pair from a property/element access to a
183
+ * violation kind, or null if neither side is a known non-deterministic API. */
184
+ function matchGlobal(obj: string, prop: string): DeterminismViolationKind | null {
185
+ if (obj === "Date" && prop === "now") return "date_now";
186
+ if (obj === "Math" && prop === "random") return "math_random";
187
+ if (obj === "crypto" && (prop === "randomUUID" || prop === "randomBytes" || prop === "getRandomValues")) return "crypto_random";
188
+ if (obj === "performance" && prop === "now") return "performance_now";
189
+ if (obj === "process" && prop === "hrtime") return "process_hrtime";
190
+ return null;
191
+ }
192
+
193
+ function walk(node: Node, visit: (n: Node) => void): void {
194
+ visit(node);
195
+ node.forEachChild((child) => walk(child, visit));
196
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Categorized workflow errors (A5 taxonomy).
3
+ *
4
+ * A single `WorkflowError` class carries a `category` discriminator so retry
5
+ * policy and callers can distinguish recoverable from terminal failures
6
+ * without a subclass per category (design D6). The pre-existing
7
+ * `BudgetExceededError` and `DeterminismError` are retrofitted to extend this
8
+ * class so they gain the discriminator without breaking their exported identity.
9
+ *
10
+ * Retry policy keys off `category`: only `dispatch-error` and `unexpected-state`
11
+ * auto-retry by default; the rest are terminal (size-limit, determinism,
12
+ * control-chars, policy-gate, compile, killed, budget-exceeded).
13
+ */
14
+
15
+ export type ErrorCategory =
16
+ | "budget-exceeded"
17
+ | "determinism"
18
+ | "size-limit"
19
+ | "control-chars"
20
+ | "compile"
21
+ | "policy-gate"
22
+ | "killed"
23
+ | "dispatch-error"
24
+ | "unexpected-state";
25
+
26
+ /** Structured detail attached to a categorized error (limit hit, byte count,
27
+ * offending source location, underlying message, …). */
28
+ export type ErrorDetail = Readonly<Record<string, unknown>>;
29
+
30
+ export interface WorkflowErrorOptions {
31
+ readonly category?: ErrorCategory;
32
+ readonly detail?: ErrorDetail;
33
+ readonly cause?: unknown;
34
+ }
35
+
36
+ export class WorkflowError extends Error {
37
+ readonly category: ErrorCategory;
38
+ readonly detail?: ErrorDetail;
39
+
40
+ constructor(message: string, opts: WorkflowErrorOptions = {}) {
41
+ super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
42
+ this.name = "WorkflowError";
43
+ this.category = opts.category ?? "unexpected-state";
44
+ this.detail = opts.detail;
45
+ }
46
+ }
47
+
48
+ /** Categories that are eligible for automatic retry by default. All others are
49
+ * treated as terminal (retrying a size-limit or determinism violation just
50
+ * wastes budget). */
51
+ export const RETRYABLE_CATEGORIES: readonly ErrorCategory[] = ["dispatch-error", "unexpected-state"];
52
+
53
+ export function isRetryable(err: unknown): boolean {
54
+ return err instanceof WorkflowError && RETRYABLE_CATEGORIES.includes(err.category);
55
+ }
package/src/format.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * src/format.ts — shared display/parsing helpers (progress widget + /wf-inspect + runner).
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
+ * stepIdOf: callId → step-id attribution, used by the runner (degraded-step
7
+ * accounting) and the widget (grouping by step). Extracted from the verbatim
8
+ * duplicates that used to live in each file.
9
+ */
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
+
21
+ /** Extract the step id from a callId of the form `${stepId}#${n}` (e.g.
22
+ * "fan#2", "adv#produce", "cr#classify"). Falls back to the whole callId when
23
+ * there is no '#'. Used to attribute null-degraded calls to their step. */
24
+ export function stepIdOf(callId: string): string {
25
+ const sep = callId.lastIndexOf("#");
26
+ return sep >= 0 ? callId.slice(0, sep) : callId;
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Public API barrel for @fyeeme/pi-dynamic-workflows.
3
+ *
4
+ * Import the workflow engine from here:
5
+ * import { defineWorkflow, runWorkflow, collect, heuristicallyPlan } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
6
+ */
7
+ export * from "./types.ts";
8
+ export { runWorkflow, type RunWorkflowOptions } from "./runner/index.ts";
9
+ export { type AgentDispatch, type StepExecContext } from "./runner/stage-executor.ts";
10
+ export { loadWorkflowModule, type LoadWorkflowOptions } from "./loader.ts";
11
+ export {
12
+ collect,
13
+ parseFirstJson,
14
+ urlCollector,
15
+ filePathCollector,
16
+ jsonCollector,
17
+ type OutputSpec,
18
+ type Collector,
19
+ } from "./outcomes.ts";
20
+ export { heuristicallyPlan, type HeuristicPlanOptions } from "./planner.ts";
21
+ export {
22
+ WorkflowError,
23
+ isRetryable,
24
+ RETRYABLE_CATEGORIES,
25
+ type ErrorCategory,
26
+ type ErrorDetail,
27
+ type WorkflowErrorOptions,
28
+ } from "./errors.ts";
package/src/inspect.ts ADDED
@@ -0,0 +1,237 @@
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
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Agent-level lifecycle events (Task 5 surface).
3
+ *
4
+ * Minimal: the four agent events. `retryAgent`/`skipAgent` fire onAgentRetry/
5
+ * onAgentSkip. onAgentStart/onAgentEnd are reserved for the spawn path to wire
6
+ * later — onAgentEnd takes a boolean `ok` rather than the full AgentSpawnResult
7
+ * so this module has no `src/agent/dispatch` import (avoids a circular type
8
+ * dependency: dispatch imports lifecycle for the listener type).
9
+ *
10
+ * Workflow/stage-level events (onWorkflowStart/onStageStart/...) join here
11
+ * when the runner module lands. Mirrors CC's LifecycleListeners shape: a
12
+ * per-call optional bundle; a throwing listener is caught and warned, never
13
+ * blocks the abort/retry path.
14
+ */
15
+
16
+ import type { StepStats } from "./types.ts";
17
+
18
+ export interface AgentLifecycleListeners {
19
+ onAgentStart?(callId: string): void;
20
+ /** `stats` carries per-call tokens/cost/duration so progress UIs can show spend live. */
21
+ onAgentEnd?(callId: string, ok: boolean, stats?: StepStats, model?: string, output?: string): void;
22
+ onAgentSkip?(callId: string): void;
23
+ onAgentRetry?(callId: string): void;
24
+ /** Fired when an agent call hits the journal cache (zero-dispatch resume). */
25
+ onAgentCacheHit?(callId: string): void;
26
+ /** Fired when a `log` step runs, with the step id and resolved message. */
27
+ onLog?(stepId: string, message: string): void;
28
+ /** Fired on each streamed `message_update` partial for an in-flight agent call. */
29
+ onUpdate?(callId: string, partial: string): void;
30
+ }
31
+
32
+ export function notifySkip(listeners: AgentLifecycleListeners | undefined, callId: string): void {
33
+ try {
34
+ listeners?.onAgentSkip?.(callId);
35
+ } catch (e) {
36
+ warn("onAgentSkip", e);
37
+ }
38
+ }
39
+
40
+ export function notifyRetry(listeners: AgentLifecycleListeners | undefined, callId: string): void {
41
+ try {
42
+ listeners?.onAgentRetry?.(callId);
43
+ } catch (e) {
44
+ warn("onAgentRetry", e);
45
+ }
46
+ }
47
+
48
+ export function notifyCacheHit(listeners: AgentLifecycleListeners | undefined, callId: string): void {
49
+ try {
50
+ listeners?.onAgentCacheHit?.(callId);
51
+ } catch (e) {
52
+ warn("onAgentCacheHit", e);
53
+ }
54
+ }
55
+
56
+ export function notifyLog(listeners: AgentLifecycleListeners | undefined, stepId: string, message: string): void {
57
+ try {
58
+ listeners?.onLog?.(stepId, message);
59
+ } catch (e) {
60
+ warn("onLog", e);
61
+ }
62
+ }
63
+
64
+ export function notifyUpdate(listeners: AgentLifecycleListeners | undefined, callId: string, partial: string): void {
65
+ try {
66
+ listeners?.onUpdate?.(callId, partial);
67
+ } catch (e) {
68
+ warn("onUpdate", e);
69
+ }
70
+ }
71
+
72
+ function warn(event: string, e: unknown): void {
73
+ const msg = e instanceof Error ? e.message : String(e);
74
+ console.warn(`[pi-dynamic-workflows] lifecycle ${event} listener threw: ${msg}`);
75
+ }
package/src/loader.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Workflow-file loader — the bridge from the deterministic sandbox (Task 3) to
3
+ * runtime execution: read a `.ts` workflow file, reject it via the ast-guard if
4
+ * it smuggles in non-deterministic APIs (Date.now / Math.random / new Date()),
5
+ * then load it as a module via jiti (the same loader pi uses for extensions).
6
+ *
7
+ * Guarding BEFORE jiti-import is what makes cache-key resume (Task 4) sound: a
8
+ * workflow body that read "now" would change its cache key every run, so resume
9
+ * could never hit. Rejecting such source at load time makes determinism
10
+ * enforceable rather than hoped-for. jiti loads a real module (not a vm-sandboxed
11
+ * string), so the guard runs on the raw source text first.
12
+ *
13
+ * Scope limit: the guard scans ONLY the entry file's source. A helper module
14
+ * imported by the workflow that calls Date.now()/Math.random() is not caught
15
+ * here, and would silently destabilize cache keys. For full determinism keep
16
+ * workflows single-file, or guard imported helpers separately. (Walking jiti's
17
+ * transitive imports pre-load is not exposed by the loader API.)
18
+ */
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { createJiti } from "jiti/static";
23
+ import { assertDeterministic } from "./determinism/ast-guard.ts";
24
+
25
+ export interface LoadWorkflowOptions {
26
+ /** Absolute (or baseUrl-relative) path to the `.ts` workflow file. */
27
+ readonly filePath: string;
28
+ /** jiti base URL; defaults to this module so relative imports resolve from here. */
29
+ readonly baseUrl?: string;
30
+ }
31
+
32
+ /**
33
+ * Read `filePath`, reject it if non-deterministic, then load it via jiti.
34
+ * Returns the module namespace — callers extract the workflow export
35
+ * (e.g. `mod.workflow` or `mod.default`, a WorkflowDefinition).
36
+ */
37
+ export async function loadWorkflowModule<T = unknown>(opts: LoadWorkflowOptions): Promise<T> {
38
+ const baseUrl = opts.baseUrl ?? import.meta.url;
39
+ // Resolve the path ONCE so readFile and jiti.import load the same file. A
40
+ // relative path is baseUrl-relative (per LoadWorkflowOptions); previously
41
+ // readFile resolved it against cwd while jiti resolved it against baseUrl,
42
+ // so the guard could scan one file while jiti executed another.
43
+ const resolved = path.isAbsolute(opts.filePath)
44
+ ? opts.filePath
45
+ : path.resolve(path.dirname(fileURLToPath(baseUrl)), opts.filePath);
46
+ const source = await fs.promises.readFile(resolved, "utf-8");
47
+ assertDeterministic(source, opts.filePath);
48
+ const jiti = createJiti(baseUrl, { moduleCache: false });
49
+ return (await jiti.import(resolved)) as T;
50
+ }