@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.
- package/LICENSE +21 -0
- package/README.md +373 -0
- package/README.zh-CN.md +359 -0
- package/index.ts +650 -0
- package/package.json +58 -0
- package/sessions/spawn.ts +15 -0
- package/src/agent/dispatch.ts +76 -0
- package/src/budget/caps.ts +42 -0
- package/src/budget/index.ts +8 -0
- package/src/budget/pool.ts +118 -0
- package/src/cache/index.ts +7 -0
- package/src/cache/journal.ts +184 -0
- package/src/cache/key.ts +97 -0
- package/src/determinism/ast-guard.ts +196 -0
- package/src/errors.ts +55 -0
- package/src/format.ts +27 -0
- package/src/index.ts +28 -0
- package/src/inspect.ts +237 -0
- package/src/lifecycle.ts +75 -0
- package/src/loader.ts +50 -0
- package/src/outcomes.ts +113 -0
- package/src/planner.ts +66 -0
- package/src/runner/index.ts +188 -0
- package/src/runner/stage-executor.ts +1078 -0
- package/src/state/index.ts +1 -0
- package/src/state/names.ts +33 -0
- package/src/types.ts +332 -0
- package/src/ui-groups.ts +43 -0
|
@@ -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
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -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
|
+
}
|