@signalridge/pi-workflows 1.2.0 → 1.3.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/CHANGELOG.md +45 -0
- package/README.md +142 -17
- package/package.json +14 -8
- package/skills/workflow-authoring/SKILL.md +30 -0
- package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
- package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
- package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
- package/skills/workflow-authoring/examples/defensive-json-parsing.js +63 -0
- package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
- package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
- package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
- package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
- package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
- package/skills/workflow-authoring/examples/structured-output.js +37 -0
- package/skills/workflow-authoring/examples/tournament.js +81 -0
- package/skills/workflow-authoring/examples/validated-gate.js +63 -0
- package/skills/workflow-authoring/references/capabilities.md +44 -0
- package/skills/workflow-authoring/references/capability-details.md +294 -0
- package/skills/workflow-authoring/references/common-helpers.md +4 -0
- package/skills/workflow-authoring/references/debugging.md +29 -0
- package/skills/workflow-authoring/references/focused-recipes.md +12 -0
- package/skills/workflow-authoring/references/helpers.md +9 -0
- package/skills/workflow-authoring/references/lifecycle.md +37 -0
- package/skills/workflow-authoring/references/pattern-selection.md +15 -0
- package/skills/workflow-authoring/references/quality-helpers.md +8 -0
- package/skills/workflow-authoring/references/registry-ownership.md +17 -0
- package/skills/workflow-authoring/references/retry-helper.md +5 -0
- package/skills/workflow-authoring/references/review.md +43 -0
- package/skills/workflow-authoring/references/runtime.md +29 -0
- package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
- package/skills/workflow-authoring/references/versions.md +13 -0
- package/skills/workflow-patterns/SKILL.md +51 -0
- package/src/arming.ts +69 -0
- package/src/builtins.ts +331 -0
- package/src/capabilities.ts +318 -0
- package/src/code-review-scope.ts +144 -0
- package/src/engine.ts +1158 -2561
- package/src/errors.ts +136 -0
- package/src/index.ts +750 -103
- package/src/journal.ts +594 -1001
- package/src/rpc-client.ts +95 -24
- package/src/runtime.ts +1708 -0
- package/src/saved-workflows.ts +121 -0
- package/src/shared-store.ts +132 -0
- package/src/ui.ts +77 -40
- package/src/workflow-settings.ts +100 -0
- package/src/schema.ts +0 -215
- package/src/state-machine.ts +0 -79
- package/src/synthesis.ts +0 -140
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* saved-workflows.ts — persisted user workflows (A7).
|
|
3
|
+
*
|
|
4
|
+
* Layout: `~/.pi/workflows/projects/<basename>-<sha256(cwd)[0:12]>/saved/*.js`
|
|
5
|
+
* with a `settings.json` next to the run directory. Writes are atomic
|
|
6
|
+
* (temp file + rename) with a `.bak` backup kept before replacement. Names are
|
|
7
|
+
* validated against a bounded identifier pattern so a saved name can never
|
|
8
|
+
* escape the saved directory or collide with command registration.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
14
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
/** Names a saved workflow; the same pattern feeds slash-command registration. */
|
|
17
|
+
const SAVED_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
18
|
+
|
|
19
|
+
export function isValidSavedName(name: string): boolean {
|
|
20
|
+
return SAVED_NAME_PATTERN.test(name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function projectDir(cwd: string): string {
|
|
24
|
+
const encoded = basename(resolve(cwd)) || "root";
|
|
25
|
+
const digest = createHash("sha256").update(resolve(cwd)).digest("hex").slice(0, 12);
|
|
26
|
+
return join(getAgentDir(), "workflows", "projects", `${encoded}-${digest}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function savedDir(cwd: string): string {
|
|
30
|
+
return join(projectDir(cwd), "saved");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function savedPath(name: string, cwd: string): string {
|
|
34
|
+
return join(savedDir(cwd), `${name}.js`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function userSavedDir(): string {
|
|
38
|
+
return join(getAgentDir(), "workflows", "saved");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function userSavedPath(name: string): string {
|
|
42
|
+
return join(userSavedDir(), `${name}.js`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Atomic write with backup: temp file + rename, keeping a .bak of the prior content. */
|
|
46
|
+
function atomicWrite(path: string, content: string): void {
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
48
|
+
const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
49
|
+
if (existsSync(path)) {
|
|
50
|
+
try {
|
|
51
|
+
renameSync(path, `${path}.bak`);
|
|
52
|
+
} catch {
|
|
53
|
+
// Best-effort backup; the rename below still replaces the target.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
writeFileSync(temporary, content, { encoding: "utf8", flag: "wx" });
|
|
57
|
+
renameSync(temporary, path);
|
|
58
|
+
try {
|
|
59
|
+
rmSync(`${path}.bak`, { force: true });
|
|
60
|
+
} catch {
|
|
61
|
+
// best-effort
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Save a workflow script under `name`. Throws on invalid names. */
|
|
66
|
+
export function saveWorkflow(name: string, script: string, cwd: string = process.cwd()): void {
|
|
67
|
+
if (!isValidSavedName(name)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Invalid workflow name "${name}": use letters, digits, and ._- only, at most 64 characters, no leading separator.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
atomicWrite(savedPath(name, cwd), script);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Load a saved workflow script by name; undefined when absent or invalid. */
|
|
76
|
+
export function loadSavedWorkflow(name: string, cwd: string = process.cwd()): string | undefined {
|
|
77
|
+
if (!isValidSavedName(name)) return undefined;
|
|
78
|
+
for (const path of [savedPath(name, cwd), userSavedPath(name)]) {
|
|
79
|
+
if (!existsSync(path)) continue;
|
|
80
|
+
try {
|
|
81
|
+
const content = readFileSync(path, "utf8");
|
|
82
|
+
if (content.length > 0 && content.length <= 200_000) return content;
|
|
83
|
+
} catch {
|
|
84
|
+
// Project errors do not hide a valid user-scope fallback.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** List saved workflow names (bounded). */
|
|
91
|
+
export function listSavedWorkflows(cwd: string = process.cwd()): string[] {
|
|
92
|
+
const names = new Set<string>();
|
|
93
|
+
for (const dir of [savedDir(cwd), userSavedDir()]) {
|
|
94
|
+
if (!existsSync(dir)) continue;
|
|
95
|
+
try {
|
|
96
|
+
for (const file of readdirSync(dir)) {
|
|
97
|
+
const name = file.endsWith(".js") ? basename(file, ".js") : "";
|
|
98
|
+
if (isValidSavedName(name)) names.add(name);
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// An unreadable scope is simply unavailable; the other scope still works.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...names].sort();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Remove a saved workflow; returns false when it did not exist. */
|
|
108
|
+
export function removeSavedWorkflow(name: string, cwd: string = process.cwd()): boolean {
|
|
109
|
+
if (!isValidSavedName(name)) return false;
|
|
110
|
+
let removed = false;
|
|
111
|
+
for (const path of [savedPath(name, cwd), userSavedPath(name)]) {
|
|
112
|
+
if (!existsSync(path)) continue;
|
|
113
|
+
try {
|
|
114
|
+
rmSync(path, { force: true });
|
|
115
|
+
removed = true;
|
|
116
|
+
} catch {
|
|
117
|
+
// Keep trying the other scope; report whether anything was removed.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return removed;
|
|
121
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared-store.ts — in-memory key-value store scoped to a single workflow run.
|
|
3
|
+
*
|
|
4
|
+
* One `SharedStore` instance is created at run start and disposed when the run
|
|
5
|
+
* ends. Two MCP-compatible tool definitions (`store_put` / `store_get`) are
|
|
6
|
+
* injected into every agent's tool list so parallel agents can share
|
|
7
|
+
* intermediate state without coordinating through the script itself.
|
|
8
|
+
*
|
|
9
|
+
* Journal integration: the runtime captures `store.commitDelta(deltaKey)`
|
|
10
|
+
* alongside each agent result in the journal. On resume,
|
|
11
|
+
* `store.applyDelta(delta)` rebuilds the store state additively in callSeq
|
|
12
|
+
* order, so parallel-agent writes are replayed correctly without the
|
|
13
|
+
* last-complete-wins ordering bug that a whole-Map restore() would cause.
|
|
14
|
+
*
|
|
15
|
+
* `deltaKey` must be unique across every run that shares this store instance,
|
|
16
|
+
* not just within one run's callSeq. A nested `workflow()` call restarts its
|
|
17
|
+
* own callSeq at 0 while inheriting the parent's store, so a bare callIndex
|
|
18
|
+
* would collide. Callers compose `deltaKey` as `${runId}:${callIndex}`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export class SharedStore {
|
|
22
|
+
private readonly map = new Map<string, unknown>();
|
|
23
|
+
// Per-agent write deltas; keyed by a run-unique `${runId}:${callIndex}` string.
|
|
24
|
+
private readonly agentDeltas = new Map<string, Record<string, unknown>>();
|
|
25
|
+
// Pre-write shadow values for the CURRENT delta-key's in-progress writes, so a
|
|
26
|
+
// failed retry attempt's mutations can be rolled back (see `discardDelta`)
|
|
27
|
+
// instead of leaking into the live store or a later successful attempt's
|
|
28
|
+
// recorded delta. Populated lazily on first write to a given key within the
|
|
29
|
+
// current delta window; cleared whenever the delta is finalized either way.
|
|
30
|
+
private readonly priorValues = new Map<string, Map<string, { existed: boolean; value: unknown }>>();
|
|
31
|
+
|
|
32
|
+
/** Store a value under `key`. Overwrites any existing value. */
|
|
33
|
+
put(key: string, value: unknown): void {
|
|
34
|
+
this.map.set(key, value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Store a value and record the write in the per-agent delta for `deltaKey`. */
|
|
38
|
+
trackPut(key: string, value: unknown, deltaKey: string): void {
|
|
39
|
+
let priors = this.priorValues.get(deltaKey);
|
|
40
|
+
if (!priors) {
|
|
41
|
+
priors = new Map();
|
|
42
|
+
this.priorValues.set(deltaKey, priors);
|
|
43
|
+
}
|
|
44
|
+
// Only shadow the value from BEFORE this delta window started writing to
|
|
45
|
+
// this key — a second write within the same attempt must not overwrite the
|
|
46
|
+
// shadow with its own (already-in-window) value.
|
|
47
|
+
if (!priors.has(key)) {
|
|
48
|
+
priors.set(
|
|
49
|
+
key,
|
|
50
|
+
this.map.has(key) ? { existed: true, value: this.map.get(key) } : { existed: false, value: undefined },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
this.map.set(key, value);
|
|
54
|
+
let delta = this.agentDeltas.get(deltaKey);
|
|
55
|
+
if (!delta) {
|
|
56
|
+
delta = {};
|
|
57
|
+
this.agentDeltas.set(deltaKey, delta);
|
|
58
|
+
}
|
|
59
|
+
delta[key] = value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Retrieve the value for `key`, or `undefined` when absent. */
|
|
63
|
+
get(key: string): unknown {
|
|
64
|
+
return this.map.get(key);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether `key` is present in the store. */
|
|
68
|
+
has(key: string): boolean {
|
|
69
|
+
return this.map.has(key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Return a deep-copied plain-object snapshot of all entries. */
|
|
73
|
+
snapshot(): Record<string, unknown> {
|
|
74
|
+
return structuredClone(Object.fromEntries(this.map));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extract and clear the write delta accumulated for `deltaKey`.
|
|
79
|
+
* Called after an agent completes to get the set of keys it wrote.
|
|
80
|
+
*/
|
|
81
|
+
commitDelta(deltaKey: string): Record<string, unknown> {
|
|
82
|
+
const delta = this.agentDeltas.get(deltaKey) ?? {};
|
|
83
|
+
this.agentDeltas.delete(deltaKey);
|
|
84
|
+
this.priorValues.delete(deltaKey);
|
|
85
|
+
return delta;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Undo the writes recorded for `deltaKey` and discard its bookkeeping,
|
|
90
|
+
* without touching any other key. Used when a retry attempt fails: that
|
|
91
|
+
* attempt's writes must not remain visible in the live store nor merge into
|
|
92
|
+
* the delta eventually recorded by a later successful attempt of the SAME
|
|
93
|
+
* call. Per-key guard: a key is only rolled back if the store STILL holds
|
|
94
|
+
* this attempt's own last write to it (`Object.is`); a concurrent sibling's
|
|
95
|
+
* overwrite is left untouched.
|
|
96
|
+
*/
|
|
97
|
+
discardDelta(deltaKey: string): void {
|
|
98
|
+
const delta = this.agentDeltas.get(deltaKey);
|
|
99
|
+
if (!delta) return;
|
|
100
|
+
const priors = this.priorValues.get(deltaKey);
|
|
101
|
+
for (const key of Object.keys(delta)) {
|
|
102
|
+
if (!Object.is(this.map.get(key), delta[key])) continue;
|
|
103
|
+
const prior = priors?.get(key);
|
|
104
|
+
if (prior?.existed) this.map.set(key, prior.value);
|
|
105
|
+
else this.map.delete(key);
|
|
106
|
+
}
|
|
107
|
+
this.agentDeltas.delete(deltaKey);
|
|
108
|
+
this.priorValues.delete(deltaKey);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Apply a write delta additively — sets each key without clearing others. */
|
|
112
|
+
applyDelta(delta: Record<string, unknown>): void {
|
|
113
|
+
for (const [k, v] of Object.entries(delta)) {
|
|
114
|
+
this.map.set(k, v);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Replace all entries with a snapshot (full reset). */
|
|
119
|
+
restore(snap: Record<string, unknown>): void {
|
|
120
|
+
this.map.clear();
|
|
121
|
+
for (const [k, v] of Object.entries(snap)) {
|
|
122
|
+
this.map.set(k, v);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Clear all entries (called when the run ends). */
|
|
127
|
+
dispose(): void {
|
|
128
|
+
this.map.clear();
|
|
129
|
+
this.agentDeltas.clear();
|
|
130
|
+
this.priorValues.clear();
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/ui.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
4
|
-
import type { WorkflowEngine
|
|
4
|
+
import type { WorkflowEngine } from "./engine.js";
|
|
5
|
+
import type { ScriptRun } from "./journal.js";
|
|
5
6
|
import { sanitizeDisplayText } from "./safe-text.js";
|
|
6
|
-
import { isTerminalWorkflow, type WorkflowStatus } from "./state-machine.js";
|
|
7
7
|
|
|
8
8
|
/** Bounds, in code points, for untrusted strings rendered into the navigator. */
|
|
9
9
|
const MAX_NAME_CHARS = 200;
|
|
@@ -12,6 +12,38 @@ const MAX_RESULT_CHARS = 300;
|
|
|
12
12
|
const MAX_RUN_ERROR_CHARS = 500;
|
|
13
13
|
const MAX_DESCRIPTION_CHARS = 120;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Pure snapshot render layer shared by the navigator, the live widget, and any
|
|
17
|
+
* inline render site. All child-agent output passes through sanitizeDisplayText
|
|
18
|
+
* BEFORE truncation so a live escape introducer is never cut mid-sequence.
|
|
19
|
+
*/
|
|
20
|
+
export function snapshotRunLine(run: ScriptRun): string {
|
|
21
|
+
return formatWorkflowRunLabel(run);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Live-widget lines for all non-terminal runs (A10). */
|
|
25
|
+
export function liveWidgetLines(
|
|
26
|
+
runs: readonly ScriptRun[],
|
|
27
|
+
mode: "compact" | "detailed" = "compact",
|
|
28
|
+
maxAgentsShown = 6,
|
|
29
|
+
): string[] {
|
|
30
|
+
const active = runs.filter((run) => !isTerminal(run.status));
|
|
31
|
+
if (active.length === 0) return [];
|
|
32
|
+
const boundedMax = Math.max(1, Math.min(32, Math.floor(maxAgentsShown)));
|
|
33
|
+
const lines = ["Workflows"];
|
|
34
|
+
for (const run of active.slice(0, boundedMax)) {
|
|
35
|
+
lines.push(` ${snapshotRunLine(run)}`);
|
|
36
|
+
if (mode === "detailed") {
|
|
37
|
+
for (const [nodeId, status] of Object.entries(run.callStatus).slice(0, boundedMax)) {
|
|
38
|
+
lines.push(` ${nodeId}: ${status}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return lines.slice(0, 1 + boundedMax * (mode === "detailed" ? boundedMax + 1 : 1));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RunStatus = ScriptRun["status"];
|
|
46
|
+
|
|
15
47
|
function elapsed(ms: number): string {
|
|
16
48
|
const seconds = Math.floor(ms / 1_000);
|
|
17
49
|
if (seconds < 60) return `${seconds}s`;
|
|
@@ -19,47 +51,51 @@ function elapsed(ms: number): string {
|
|
|
19
51
|
return `${minutes}m ${seconds % 60}s`;
|
|
20
52
|
}
|
|
21
53
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
54
|
+
function isTerminal(status: RunStatus): boolean {
|
|
55
|
+
return status === "completed" || status === "failed" || status === "stopped";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatWorkflowRunLabel(run: ScriptRun): string {
|
|
59
|
+
const phases = run.meta.phases && run.meta.phases.length > 0 ? ` · phases ${run.meta.phases.length}` : "";
|
|
60
|
+
const calls = Object.keys(run.callResults).length;
|
|
61
|
+
const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
|
|
62
|
+
return `${name} · ${run.status}${phases} · ${calls} calls · ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`;
|
|
27
63
|
}
|
|
28
64
|
|
|
29
65
|
export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): string {
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
32
|
-
const
|
|
66
|
+
const state = engine.getState(runId);
|
|
67
|
+
if (!state) return "Workflow run is no longer available.";
|
|
68
|
+
const run = state.run;
|
|
69
|
+
const tokenValues = Object.values(run.callResults)
|
|
33
70
|
.map((result) => result.tokenCount)
|
|
34
71
|
.filter((value): value is number => value !== undefined);
|
|
35
72
|
const tokenCount = tokenValues.length > 0 ? tokenValues.reduce((total, value) => total + value, 0) : undefined;
|
|
36
73
|
const lines = [
|
|
37
|
-
`${sanitizeDisplayText(run.
|
|
74
|
+
`${sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS)} [${run.status}]`,
|
|
38
75
|
`run: ${sanitizeDisplayText(run.runId, MAX_ID_CHARS)}`,
|
|
39
|
-
`elapsed: ${elapsed(Math.max(0, (
|
|
76
|
+
`elapsed: ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`,
|
|
40
77
|
"",
|
|
41
78
|
...(tokenCount === undefined ? [] : [`tokens: ${tokenCount}`]),
|
|
42
79
|
];
|
|
43
|
-
for (const phase of run.
|
|
80
|
+
for (const phase of run.meta.phases ?? []) {
|
|
44
81
|
lines.push(`Phase: ${sanitizeDisplayText(phase.title, MAX_NAME_CHARS)}`);
|
|
45
|
-
for (const task of run.definition.tasks.filter((candidate) => candidate.phase === phase.id)) {
|
|
46
|
-
const status = run.taskStatus[task.id] ?? "pending";
|
|
47
|
-
const agent = run.agentIds[task.id] ? ` agent=${sanitizeDisplayText(run.agentIds[task.id], MAX_ID_CHARS)}` : "";
|
|
48
|
-
const compact = run.compactions[task.id] ? ` compactions=${run.compactions[task.id]}` : "";
|
|
49
|
-
lines.push(` ${status.padEnd(11)} ${sanitizeDisplayText(task.id, MAX_ID_CHARS)}${agent}${compact}`);
|
|
50
|
-
const result = run.taskResults[task.id];
|
|
51
|
-
if (result?.error) lines.push(` error: ${sanitizeDisplayText(result.error, MAX_RESULT_CHARS)}`);
|
|
52
|
-
if (result?.text) lines.push(` result: ${sanitizeDisplayText(result.text, MAX_RESULT_CHARS)}`);
|
|
53
|
-
}
|
|
54
82
|
}
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
}
|
|
83
|
+
const calls = Object.entries(run.callStatus)
|
|
84
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
85
|
+
.map(([nodeId, status]) => {
|
|
86
|
+
const result = run.callResults[nodeId];
|
|
87
|
+
const agent = run.agentIds[nodeId] ? ` agent=${sanitizeDisplayText(run.agentIds[nodeId], MAX_ID_CHARS)}` : "";
|
|
88
|
+
const compact = run.compactions[nodeId] ? ` compactions=${run.compactions[nodeId]}` : "";
|
|
89
|
+
const parts = [` ${status.padEnd(11)} call ${sanitizeDisplayText(nodeId, MAX_ID_CHARS)}${agent}${compact}`];
|
|
90
|
+
if (result?.error) parts.push(` error: ${sanitizeDisplayText(result.error, MAX_RESULT_CHARS)}`);
|
|
91
|
+
if (result?.result !== undefined && result.status === "completed") {
|
|
92
|
+
parts.push(` result: ${sanitizeDisplayText(String(result.result), MAX_RESULT_CHARS)}`);
|
|
93
|
+
}
|
|
94
|
+
return parts.join("\n");
|
|
95
|
+
});
|
|
96
|
+
if (calls.length > 0) {
|
|
97
|
+
lines.push("Calls:");
|
|
98
|
+
lines.push(...calls);
|
|
63
99
|
}
|
|
64
100
|
lines.push("", "Live agent conversations remain available through pi-subagents FleetView.");
|
|
65
101
|
if (run.error) lines.push(`Error: ${sanitizeDisplayText(run.error, MAX_RUN_ERROR_CHARS)}`);
|
|
@@ -81,15 +117,12 @@ const STOP: WorkflowActionItem = { value: "stop", label: "Stop" };
|
|
|
81
117
|
const BACK: WorkflowActionItem = { value: "back", label: "Back" };
|
|
82
118
|
|
|
83
119
|
/**
|
|
84
|
-
* Menu actions legal for a run in `status
|
|
85
|
-
* Exhaustive by construction: there is no `default` arm, so adding a `WorkflowStatus` member
|
|
86
|
-
* is a compile error here instead of a silently wrong menu.
|
|
120
|
+
* Menu actions legal for a run in `status`. Exhaustive by construction.
|
|
87
121
|
*/
|
|
88
|
-
export function workflowActionsFor(status:
|
|
122
|
+
export function workflowActionsFor(status: RunStatus): readonly WorkflowActionItem[] {
|
|
89
123
|
switch (status) {
|
|
90
124
|
case "pending":
|
|
91
125
|
case "pausing":
|
|
92
|
-
case "synthesizing":
|
|
93
126
|
case "stopping":
|
|
94
127
|
return [REFRESH, STOP, BACK];
|
|
95
128
|
case "running":
|
|
@@ -145,25 +178,29 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
|
|
|
145
178
|
return;
|
|
146
179
|
}
|
|
147
180
|
while (true) {
|
|
148
|
-
const
|
|
181
|
+
const states = engine
|
|
182
|
+
.list()
|
|
183
|
+
.map((summary) => engine.getRun(String(summary.runId)))
|
|
184
|
+
.filter((run): run is ScriptRun => run !== undefined)
|
|
185
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
149
186
|
const selected = await selectList<string>(
|
|
150
187
|
ctx,
|
|
151
188
|
"Workflow runs",
|
|
152
|
-
|
|
189
|
+
states.map((run) => ({
|
|
153
190
|
value: run.runId,
|
|
154
191
|
label: formatWorkflowRunLabel(run),
|
|
155
192
|
description: run.error === undefined ? undefined : sanitizeDisplayText(run.error, MAX_DESCRIPTION_CHARS),
|
|
156
193
|
})),
|
|
157
194
|
(theme) =>
|
|
158
|
-
|
|
195
|
+
states.length > 0
|
|
159
196
|
? theme.fg("dim", "Select a run to inspect.")
|
|
160
197
|
: theme.fg("muted", "No workflow runs in this session."),
|
|
161
198
|
);
|
|
162
199
|
if (!selected) return;
|
|
163
200
|
while (true) {
|
|
164
|
-
const run = engine.
|
|
201
|
+
const run = engine.getRun(selected);
|
|
165
202
|
if (!run) break;
|
|
166
|
-
const name = sanitizeDisplayText(run.name, MAX_NAME_CHARS);
|
|
203
|
+
const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
|
|
167
204
|
const action = await selectList<WorkflowAction>(
|
|
168
205
|
ctx,
|
|
169
206
|
`${name} · ${run.status}`,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Small, package-owned workflow presentation settings. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
export type WorkflowProgressMode = "compact" | "detailed";
|
|
8
|
+
|
|
9
|
+
export interface WorkflowSettings {
|
|
10
|
+
progressMode?: WorkflowProgressMode;
|
|
11
|
+
maxAgentsShown?: number;
|
|
12
|
+
effort?: "off" | "high" | "ultra";
|
|
13
|
+
keywordTriggerWord?: string;
|
|
14
|
+
keywordTriggerEnabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_SETTINGS: Required<
|
|
18
|
+
Pick<WorkflowSettings, "progressMode" | "maxAgentsShown" | "effort" | "keywordTriggerEnabled">
|
|
19
|
+
> = {
|
|
20
|
+
progressMode: "compact",
|
|
21
|
+
maxAgentsShown: 6,
|
|
22
|
+
effort: "off",
|
|
23
|
+
keywordTriggerEnabled: true,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function projectDirectory(cwd: string): string {
|
|
27
|
+
const absolute = resolve(cwd);
|
|
28
|
+
const digest = createHash("sha256").update(absolute).digest("hex").slice(0, 12);
|
|
29
|
+
return join(getAgentDir(), "workflows", "projects", `${basename(absolute) || "root"}-${digest}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readSettings(path: string): WorkflowSettings {
|
|
33
|
+
if (!existsSync(path)) return {};
|
|
34
|
+
try {
|
|
35
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
36
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
37
|
+
const value = parsed as Record<string, unknown>;
|
|
38
|
+
return {
|
|
39
|
+
...(value.progressMode === "compact" || value.progressMode === "detailed"
|
|
40
|
+
? { progressMode: value.progressMode }
|
|
41
|
+
: {}),
|
|
42
|
+
...(typeof value.maxAgentsShown === "number" &&
|
|
43
|
+
Number.isInteger(value.maxAgentsShown) &&
|
|
44
|
+
value.maxAgentsShown >= 1 &&
|
|
45
|
+
value.maxAgentsShown <= 32
|
|
46
|
+
? { maxAgentsShown: value.maxAgentsShown }
|
|
47
|
+
: {}),
|
|
48
|
+
...(value.effort === "off" || value.effort === "high" || value.effort === "ultra"
|
|
49
|
+
? { effort: value.effort }
|
|
50
|
+
: {}),
|
|
51
|
+
...(typeof value.keywordTriggerWord === "string" &&
|
|
52
|
+
/^[A-Za-z][A-Za-z0-9_-]{0,31}$/u.test(value.keywordTriggerWord)
|
|
53
|
+
? { keywordTriggerWord: value.keywordTriggerWord.toLowerCase() }
|
|
54
|
+
: {}),
|
|
55
|
+
...(typeof value.keywordTriggerEnabled === "boolean"
|
|
56
|
+
? { keywordTriggerEnabled: value.keywordTriggerEnabled }
|
|
57
|
+
: {}),
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function loadWorkflowSettings(
|
|
65
|
+
cwd: string = process.cwd(),
|
|
66
|
+
): WorkflowSettings & Required<Pick<WorkflowSettings, "progressMode" | "maxAgentsShown" | "effort">> {
|
|
67
|
+
const globalPath = join(getAgentDir(), "workflows", "settings.json");
|
|
68
|
+
const projectPath = join(projectDirectory(cwd), "settings.json");
|
|
69
|
+
return { ...DEFAULT_SETTINGS, ...readSettings(globalPath), ...readSettings(projectPath) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function saveWorkflowSettings(settings: WorkflowSettings, cwd: string = process.cwd()): void {
|
|
73
|
+
const safe: WorkflowSettings = {
|
|
74
|
+
...(settings.progressMode === "compact" || settings.progressMode === "detailed"
|
|
75
|
+
? { progressMode: settings.progressMode }
|
|
76
|
+
: {}),
|
|
77
|
+
...(typeof settings.maxAgentsShown === "number" &&
|
|
78
|
+
Number.isInteger(settings.maxAgentsShown) &&
|
|
79
|
+
settings.maxAgentsShown >= 1 &&
|
|
80
|
+
settings.maxAgentsShown <= 32
|
|
81
|
+
? { maxAgentsShown: settings.maxAgentsShown }
|
|
82
|
+
: {}),
|
|
83
|
+
...(settings.effort === "off" || settings.effort === "high" || settings.effort === "ultra"
|
|
84
|
+
? { effort: settings.effort }
|
|
85
|
+
: {}),
|
|
86
|
+
...(typeof settings.keywordTriggerWord === "string" &&
|
|
87
|
+
/^[A-Za-z][A-Za-z0-9_-]{0,31}$/u.test(settings.keywordTriggerWord)
|
|
88
|
+
? { keywordTriggerWord: settings.keywordTriggerWord.toLowerCase() }
|
|
89
|
+
: {}),
|
|
90
|
+
...(typeof settings.keywordTriggerEnabled === "boolean"
|
|
91
|
+
? { keywordTriggerEnabled: settings.keywordTriggerEnabled }
|
|
92
|
+
: {}),
|
|
93
|
+
};
|
|
94
|
+
const directory = projectDirectory(cwd);
|
|
95
|
+
mkdirSync(directory, { recursive: true });
|
|
96
|
+
const path = join(directory, "settings.json");
|
|
97
|
+
const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
98
|
+
writeFileSync(temporary, `${JSON.stringify(safe, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
99
|
+
renameSync(temporary, path);
|
|
100
|
+
}
|