@signalridge/pi-workflows 0.2.0 → 1.2.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 +40 -0
- package/README.md +5 -3
- package/package.json +3 -3
- package/src/engine.ts +55 -3
- package/src/index.ts +2 -0
- package/src/safe-text.ts +44 -0
- package/src/schema.ts +30 -1
- package/src/state-machine.ts +6 -0
- package/src/synthesis.ts +77 -32
- package/src/ui.ts +73 -25
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.1
|
|
4
|
+
### Patch Changes
|
|
5
|
+
|
|
6
|
+
- 24a8af4: Remove references to the maintainer's personal dotfile setup from shipped docs
|
|
7
|
+
and source comments. Each constraint is restated as a property of the package
|
|
8
|
+
itself: which manager owns a settings file, whether a project permits git
|
|
9
|
+
worktrees, and installing a package from a single source. No runtime behavior
|
|
10
|
+
changes.
|
|
11
|
+
|
|
12
|
+
## 1.0.0
|
|
13
|
+
### Major Changes
|
|
14
|
+
|
|
15
|
+
- Adopt a unified 1.0.0 across every published package.
|
|
16
|
+
|
|
17
|
+
The version numbers no longer track their upstream forks individually; from this release each package
|
|
18
|
+
is versioned on its own merit against the Signalridge line, and 1.0.0 is the shared starting point.
|
|
19
|
+
Packages whose behavior changed in this release document that change in their own changeset entries;
|
|
20
|
+
the remainder are re-released unchanged so the whole set shares one baseline.
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- Chain dependency results into downstream prompts, sanitize child-agent output in the navigator, and make the detail-menu action mapping exhaustive.
|
|
25
|
+
|
|
26
|
+
- `inputs`: a task may declare `inputs`, a duplicate-free subset of its own `depends_on`, so those dependencies' journaled results are appended to its dispatch prompt. `depends_on` on its own stays a pure scheduling barrier, so the DAG can now chain and not only fan out. The field is opt-in: a task without `inputs` dispatches a byte-identical prompt to before. Composition is a pure function of the journaled prefix — the declared `inputs` array is iterated in order and only journaled result fields are read — so a replay recomposes the same prompt and pi-subagents' managed spawn fingerprint stays stable across a branch rewind. Each result is capped at 6,000 characters and the appended section at 24,000, budgeted against the managed protocol's 100,000-character prompt ceiling, which rejects rather than truncates; when the authored prompt leaves too little headroom, nothing is appended.
|
|
27
|
+
- Forward-only journal compatibility: a `run_created` definition carrying `inputs` fails validation on an older `pi-workflows` build, which quarantines and reports that run rather than replaying it. Downgrading after authoring a workflow with `inputs` is not supported.
|
|
28
|
+
- Terminal safety: task results, task errors, and run errors are child-agent output and reached the `/workflows` navigator unsanitized, so a child could emit escape sequences that clear the screen, plant an OSC 8 hyperlink, or reorder the line with a bidi override. Every untrusted string is now neutralized before it is truncated — truncating first could cut through a sequence and leave a live introducer behind.
|
|
29
|
+
- `result_truncated=` in synthesis context now reflects the journaled `truncated` flag, which was previously always reported as `false` because results are already capped when they are journaled.
|
|
30
|
+
- Regression-proofing, not a behavior change: the `/workflows` detail menu built its action list from inline status literals. That mapping is now an exhaustively-typed `workflowActionsFor`, so adding a workflow status is a compile error instead of a silently wrong menu. Every status offers the same actions it did before.
|
|
31
|
+
|
|
32
|
+
### Patch Changes
|
|
33
|
+
|
|
34
|
+
- Updated dependencies
|
|
35
|
+
- Updated dependencies
|
|
36
|
+
- Updated dependencies
|
|
37
|
+
- Updated dependencies
|
|
38
|
+
- Updated dependencies
|
|
39
|
+
- Updated dependencies
|
|
40
|
+
- @signalridge/pi-subagents@1.0.0
|
|
41
|
+
- @signalridge/pi-subagents-protocol@1.0.0
|
|
42
|
+
|
|
3
43
|
## Unreleased
|
|
4
44
|
- **Semantic workflow tiers:** tasks, synthesis, and an optional run default can request only `small`, `medium`, or `large`; model and thinking policy stays inside pi-subagents. The release requires `@signalridge/pi-subagents >=0.16.0-signalridge.1` and the v3 `workflowTiers` capability.
|
|
5
45
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Declarative DAG orchestration for Pi. The `workflow` tool validates phases, task
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
-
`@signalridge/pi-workflows` requires `@signalridge/pi-subagents` `>=0.
|
|
7
|
+
`@signalridge/pi-workflows` requires `@signalridge/pi-subagents` `>=1.0.0` with protocol v3 managed-spawn support. They are separate Pi packages: the peer dependency documents the requirement but intentionally does not auto-load or duplicate the subagent extension.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
pi install npm:@signalridge/pi-subagents
|
|
@@ -20,16 +20,18 @@ pi -e ./packages/pi-subagents
|
|
|
20
20
|
pi -e ./packages/pi-workflows
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Pi activates each package from its own `pi.extensions` manifest.
|
|
23
|
+
Pi activates each package from its own `pi.extensions` manifest. Install `pi-workflows` and `pi-subagents` from the same source — an npm copy of one alongside a local checkout of the other loads both, and the duplicate registers its tools twice.
|
|
24
24
|
|
|
25
25
|
At `session_start`, this extension sends `subagents:rpc:ping` and verifies protocol version 3 plus the `managedSpawn`, `lifecycleOwner`, owner-scoped `ownedStop`, `ownedQuiescence`, and `workflowTiers` capabilities. The package floor and capability check prevent older v3 peers from receiving tiered requests they cannot validate. If the fork is missing, old, or duplicated incorrectly, workflow execution returns a diagnostic naming the required packages instead of dispatching agents. The legacy unowned stop RPC remains available to older external callers, but workflow cleanup never falls back to it.
|
|
26
26
|
|
|
27
27
|
## Tools and command
|
|
28
28
|
|
|
29
|
-
- `workflow`: run a foreground or background workflow.
|
|
29
|
+
- `workflow`: run a foreground or background workflow. `depends_on` is a scheduling barrier only. A task may additionally declare `inputs`, a duplicate-free subset of its own `depends_on`, to have those dependencies' journaled results appended to its dispatch prompt: each result is capped at 6,000 characters, the appended section at 24,000, and the whole prompt at the protocol's 100,000-character ceiling. When the task prompt leaves less than 512 characters of headroom, nothing is appended and the prompt dispatches unchanged. Omitting `inputs` dispatches exactly the authored prompt.
|
|
30
30
|
- `workflow_control`: `list`, `get`, `pause`, `resume`, or `stop` a run. `get` returns bounded task and synthesis details including status, agent ID, compactions, and result/error previews.
|
|
31
31
|
- `/workflows`: TUI run list/detail navigator. Live agent conversation navigation remains in pi-subagents FleetView.
|
|
32
32
|
|
|
33
|
+
Composed input sections are a pure function of the journaled prefix — declared `inputs` order, journaled result fields only — so a replay reproduces the same prompt and the managed spawn fingerprint stays stable across a branch rewind.
|
|
34
|
+
|
|
33
35
|
State is persisted as `pi.appendEntry("pi-workflows:journal", ...)` custom entries, which do not enter model context. Results and synthesis instructions are capped before journaling or dispatch. Interrupted runs reset unreconciled dispatches to a recoverable state and reuse their managed spawn keys; completed task facts are not dispatched again. A task failure stops remaining owned agents, journals their terminal callbacks, blocks dependents, and settles the workflow as failed.
|
|
34
36
|
Restore rotation is journaled atomically per run (`run_recovery`) so a failed append leaves no partial generation; older multi-entry recovery prefixes remain replayable and are migrated with independent `attempt_recovery` entries.
|
|
35
37
|
Each atomic recovery also carries a deterministic bounded `recoveryId` derived from its branch/run and attempt rotations. Replay treats an exact normalized duplicate as a no-op, quarantines same-ID conflicts, and accepts older schema-v2 atomic entries without an ID only through deterministic legacy normalization.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-workflows",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Declarative DAG workflows backed by pi-subagents managed spawning.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
"peerDependencies": {
|
|
18
18
|
"@earendil-works/pi-coding-agent": "*",
|
|
19
19
|
"@earendil-works/pi-tui": "*",
|
|
20
|
-
"@signalridge/pi-subagents": ">=0.
|
|
20
|
+
"@signalridge/pi-subagents": ">=1.0.0"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@sinclair/typebox": "^0.34.49",
|
|
24
|
-
"@signalridge/pi-subagents-protocol": "^
|
|
24
|
+
"@signalridge/pi-subagents-protocol": "^1.0.0"
|
|
25
25
|
},
|
|
26
26
|
"pi": {
|
|
27
27
|
"extensions": [
|
package/src/engine.ts
CHANGED
|
@@ -36,7 +36,18 @@ import {
|
|
|
36
36
|
transitionWorkflow,
|
|
37
37
|
type WorkflowStatus,
|
|
38
38
|
} from "./state-machine.js";
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
buildBoundedResultSection,
|
|
41
|
+
buildSynthesisPrompt,
|
|
42
|
+
cap,
|
|
43
|
+
MAX_DISPATCH_PROMPT_CHARS,
|
|
44
|
+
MAX_TASK_INPUT_CHARS,
|
|
45
|
+
MAX_TASK_RESULT_CHARS,
|
|
46
|
+
MIN_TASK_INPUT_CHARS,
|
|
47
|
+
type ResultEntry,
|
|
48
|
+
resultFromLifecycle,
|
|
49
|
+
TASK_INPUT_HEADER,
|
|
50
|
+
} from "./synthesis.js";
|
|
40
51
|
|
|
41
52
|
const SYNTHESIS_NODE_ID = "__synthesis__";
|
|
42
53
|
const MAX_WAITERS = 256;
|
|
@@ -1767,6 +1778,41 @@ export class WorkflowEngine {
|
|
|
1767
1778
|
}
|
|
1768
1779
|
}
|
|
1769
1780
|
|
|
1781
|
+
/**
|
|
1782
|
+
* Append the journaled results of `task.inputs` to its prompt.
|
|
1783
|
+
*
|
|
1784
|
+
* Must be a pure function of the journaled prefix: the composed prompt feeds pi-subagents'
|
|
1785
|
+
* managed fingerprint, and a replay that recomposes a different prompt is reported as a spawn
|
|
1786
|
+
* key conflict and fails the run non-retryably. So: iterate the declared `inputs` array in
|
|
1787
|
+
* order (never `Object.keys(run.taskResults)`, whose order differs between a live run and a
|
|
1788
|
+
* replay), read only journaled result fields (never `run.taskStatus`, live agent state, or the
|
|
1789
|
+
* clock), and never throw — `dispatchTask` has no error path around composition.
|
|
1790
|
+
*/
|
|
1791
|
+
private composeTaskPrompt(run: WorkflowRun, task: WorkflowTask): string {
|
|
1792
|
+
const inputs = task.inputs ?? [];
|
|
1793
|
+
if (inputs.length === 0) return task.prompt;
|
|
1794
|
+
const separator = "\n\n";
|
|
1795
|
+
// pi-subagents-protocol rejects an oversized prompt by throwing, so budget against the
|
|
1796
|
+
// remaining headroom rather than adding a fixed section and hoping it fits.
|
|
1797
|
+
const headroom = MAX_DISPATCH_PROMPT_CHARS - task.prompt.length - separator.length;
|
|
1798
|
+
const budget = Math.min(MAX_TASK_INPUT_CHARS, headroom);
|
|
1799
|
+
if (budget < MIN_TASK_INPUT_CHARS) return task.prompt;
|
|
1800
|
+
const entries: ResultEntry[] = inputs.map((id) => {
|
|
1801
|
+
const dependency = run.definition.tasks.find((candidate) => candidate.id === id);
|
|
1802
|
+
return {
|
|
1803
|
+
task: dependency ?? { id, description: id },
|
|
1804
|
+
result: run.taskResults[id],
|
|
1805
|
+
fallbackStatus: "missing",
|
|
1806
|
+
};
|
|
1807
|
+
});
|
|
1808
|
+
const section = buildBoundedResultSection([TASK_INPUT_HEADER], entries, {
|
|
1809
|
+
maxPerResult: MAX_TASK_RESULT_CHARS,
|
|
1810
|
+
maxTotal: budget,
|
|
1811
|
+
overflowMarker: "\n\n[dependency input truncated: additional results omitted]",
|
|
1812
|
+
});
|
|
1813
|
+
return cap(`${task.prompt}${separator}${section}`, MAX_DISPATCH_PROMPT_CHARS).text;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1770
1816
|
private async dispatchTask(run: WorkflowRun, nodeId: string, branchEpoch: number): Promise<void> {
|
|
1771
1817
|
const task = run.definition.tasks.find((candidate) => candidate.id === nodeId);
|
|
1772
1818
|
if (
|
|
@@ -1778,8 +1824,14 @@ export class WorkflowEngine {
|
|
|
1778
1824
|
)
|
|
1779
1825
|
return;
|
|
1780
1826
|
const attemptId = run.attemptIds[nodeId];
|
|
1781
|
-
const
|
|
1782
|
-
|
|
1827
|
+
const prompt = this.composeTaskPrompt(run, task);
|
|
1828
|
+
const tierOverride = task.tier === undefined ? run.definition.tier : undefined;
|
|
1829
|
+
// Keeping the `task` identity when nothing changes guarantees a byte-identical dispatch
|
|
1830
|
+
// for every workflow that does not declare `inputs`.
|
|
1831
|
+
const dispatchTaskDefinition: WorkflowTask =
|
|
1832
|
+
tierOverride === undefined && prompt === task.prompt
|
|
1833
|
+
? task
|
|
1834
|
+
: { ...task, prompt, ...(tierOverride === undefined ? {} : { tier: tierOverride }) };
|
|
1783
1835
|
let response: ManagedSpawnResponse;
|
|
1784
1836
|
try {
|
|
1785
1837
|
response = normalizeManagedSpawnResponse(
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ const TaskSchema = Type.Object(
|
|
|
27
27
|
description: Type.String({ minLength: 1, maxLength: 512 }),
|
|
28
28
|
prompt: Type.String({ minLength: 1, maxLength: 100_000 }),
|
|
29
29
|
depends_on: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 128 })),
|
|
30
|
+
inputs: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 128 })),
|
|
30
31
|
},
|
|
31
32
|
{ additionalProperties: false },
|
|
32
33
|
);
|
|
@@ -117,6 +118,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
117
118
|
promptSnippet: "Run independent tasks as a dependency-aware workflow",
|
|
118
119
|
promptGuidelines: [
|
|
119
120
|
"Use workflow for explicit multi-task DAG orchestration; an optional tier must be small, medium, or large. Do not include model, thinking, concurrency, retry, timeout, or turn-limit settings.",
|
|
121
|
+
"depends_on controls ordering only. To pass a dependency's result into a task, also list it in inputs, which must be a subset of that task's depends_on; those results are appended to the task prompt under a bounded budget.",
|
|
120
122
|
"Use workflow_control to inspect, pause, resume, or stop a background workflow.",
|
|
121
123
|
],
|
|
122
124
|
parameters: WorkflowSchema,
|
package/src/safe-text.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-safety for untrusted text rendered by the `/workflows` navigator.
|
|
3
|
+
*
|
|
4
|
+
* Task results, task errors, and run errors originate in child agent sessions, so they are
|
|
5
|
+
* attacker-influenced from this package's point of view. Duplicated rather than imported:
|
|
6
|
+
* extensions must not depend on another extension's source (see docs/package-boundaries.md).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Control, escape-introducing, and bidi-override code points a terminal would act on. */
|
|
10
|
+
function isUnsafeTerminalCodePoint(codePoint: number): boolean {
|
|
11
|
+
return (
|
|
12
|
+
codePoint <= 0x1f ||
|
|
13
|
+
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
14
|
+
codePoint === 0x061c ||
|
|
15
|
+
codePoint === 0x200e ||
|
|
16
|
+
codePoint === 0x200f ||
|
|
17
|
+
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
|
18
|
+
(codePoint >= 0x2066 && codePoint <= 0x2069)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Replace every code point a terminal could interpret as control input with a space.
|
|
24
|
+
* Neutralizing the introducer (ESC, and the 8-bit C1 forms such as `0x9b` CSI) is what makes
|
|
25
|
+
* the remainder of a sequence inert payload text rather than a command.
|
|
26
|
+
*/
|
|
27
|
+
export function sanitizeTerminalText(value: string): string {
|
|
28
|
+
return [...value]
|
|
29
|
+
.map((character) => (isUnsafeTerminalCodePoint(character.codePointAt(0) ?? 0) ? " " : character))
|
|
30
|
+
.join("");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Single-line, bounded rendering of untrusted text.
|
|
35
|
+
*
|
|
36
|
+
* Sanitizing strictly before truncating is load-bearing: slicing first can cut through an
|
|
37
|
+
* escape sequence and leave a dangling introducer that swallows whatever the terminal draws
|
|
38
|
+
* next. Slicing by code point additionally avoids splitting a surrogate pair.
|
|
39
|
+
*/
|
|
40
|
+
export function sanitizeDisplayText(value: string, maximumLength: number): string {
|
|
41
|
+
if (maximumLength < 1) return "";
|
|
42
|
+
const safe = sanitizeTerminalText(value).replace(/\s+/gu, " ").trim();
|
|
43
|
+
return [...safe].slice(0, maximumLength).join("");
|
|
44
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -19,6 +19,11 @@ export interface WorkflowTask {
|
|
|
19
19
|
prompt: string;
|
|
20
20
|
tier?: WorkflowTier;
|
|
21
21
|
depends_on: string[];
|
|
22
|
+
/**
|
|
23
|
+
* Subset of `depends_on` whose journaled results are appended to this task's dispatch prompt.
|
|
24
|
+
* Opt-in: absent means `depends_on` stays a pure ordering barrier, as it always has been.
|
|
25
|
+
*/
|
|
26
|
+
inputs?: string[];
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export interface WorkflowSynthesis {
|
|
@@ -77,6 +82,25 @@ function stringArray(value: unknown, label: string, maxItems: number, maxText: n
|
|
|
77
82
|
return value.map((item, index) => stringField(item, `${label}[${index}]`, maxText));
|
|
78
83
|
}
|
|
79
84
|
|
|
85
|
+
/**
|
|
86
|
+
* `inputs` must be a duplicate-free subset of the same task's `depends_on`, so it can never
|
|
87
|
+
* introduce an edge the cycle checker has not already seen.
|
|
88
|
+
*/
|
|
89
|
+
function validateTaskInputs(tasks: WorkflowTask[]): void {
|
|
90
|
+
for (const task of tasks) {
|
|
91
|
+
if (!task.inputs) continue;
|
|
92
|
+
const dependencies = new Set(task.depends_on);
|
|
93
|
+
const seen = new Set<string>();
|
|
94
|
+
for (const input of task.inputs) {
|
|
95
|
+
if (!dependencies.has(input)) {
|
|
96
|
+
throw new Error(`task "${task.id}" input "${input}" is not one of its depends_on`);
|
|
97
|
+
}
|
|
98
|
+
if (seen.has(input)) throw new Error(`task "${task.id}" lists duplicate input "${input}"`);
|
|
99
|
+
seen.add(input);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
80
104
|
function validateDag(tasks: WorkflowTask[]): void {
|
|
81
105
|
const ids = new Set(tasks.map((task) => task.id));
|
|
82
106
|
const visiting = new Set<string>();
|
|
@@ -132,7 +156,7 @@ export function validateWorkflow(input: unknown): WorkflowDefinition {
|
|
|
132
156
|
if (!isRecord(raw)) throw new Error(`tasks[${index}] must be an object`);
|
|
133
157
|
assertObjectKeys(
|
|
134
158
|
raw,
|
|
135
|
-
["id", "phase", "tier", "subagent_type", "description", "prompt", "depends_on"],
|
|
159
|
+
["id", "phase", "tier", "subagent_type", "description", "prompt", "depends_on", "inputs"],
|
|
136
160
|
`tasks[${index}]`,
|
|
137
161
|
);
|
|
138
162
|
const task: WorkflowTask = {
|
|
@@ -143,6 +167,10 @@ export function validateWorkflow(input: unknown): WorkflowDefinition {
|
|
|
143
167
|
description: stringField(raw.description, `tasks[${index}].description`, MAX_SHORT_TEXT),
|
|
144
168
|
prompt: stringField(raw.prompt, `tasks[${index}].prompt`, MAX_TEXT),
|
|
145
169
|
depends_on: stringArray(raw.depends_on, `tasks[${index}].depends_on`, MAX_TASKS, 128),
|
|
170
|
+
// Spread so an absent field stays absent and journaled definitions remain byte-stable.
|
|
171
|
+
...(raw.inputs === undefined
|
|
172
|
+
? {}
|
|
173
|
+
: { inputs: stringArray(raw.inputs, `tasks[${index}].inputs`, MAX_TASKS, 128) }),
|
|
146
174
|
};
|
|
147
175
|
if (taskIds.has(task.id)) throw new Error(`duplicate task id "${task.id}"`);
|
|
148
176
|
taskIds.add(task.id);
|
|
@@ -151,6 +179,7 @@ export function validateWorkflow(input: unknown): WorkflowDefinition {
|
|
|
151
179
|
}
|
|
152
180
|
return task;
|
|
153
181
|
});
|
|
182
|
+
validateTaskInputs(tasks);
|
|
154
183
|
validateDag(tasks);
|
|
155
184
|
|
|
156
185
|
let synthesis: WorkflowSynthesis | undefined;
|
package/src/state-machine.ts
CHANGED
|
@@ -34,6 +34,12 @@ const workflowTransitions: Record<WorkflowStatus, readonly WorkflowStatus[]> = {
|
|
|
34
34
|
interrupted: ["running", "stopping", "stopped"],
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Every `WorkflowStatus`, derived from the exhaustive transition table rather than re-listed,
|
|
39
|
+
* so a new union member cannot silently be omitted from status-driven enumerations.
|
|
40
|
+
*/
|
|
41
|
+
export const WORKFLOW_STATUSES: readonly WorkflowStatus[] = Object.keys(workflowTransitions) as WorkflowStatus[];
|
|
42
|
+
|
|
37
43
|
const taskTransitions: Record<TaskStatus, readonly TaskStatus[]> = {
|
|
38
44
|
pending: ["ready", "blocked", "stopped"],
|
|
39
45
|
ready: ["dispatching", "blocked", "stopped"],
|
package/src/synthesis.ts
CHANGED
|
@@ -4,45 +4,69 @@ import type { WorkflowDefinition, WorkflowTask } from "./schema.js";
|
|
|
4
4
|
export const MAX_TASK_RESULT_CHARS = 6_000;
|
|
5
5
|
export const MAX_SYNTHESIS_INSTRUCTION_CHARS = 8_000;
|
|
6
6
|
export const MAX_SYNTHESIS_INPUT_CHARS = 48_000;
|
|
7
|
+
/** Aggregate budget for a task's `inputs` section; deliberately smaller than synthesis's. */
|
|
8
|
+
export const MAX_TASK_INPUT_CHARS = 24_000;
|
|
9
|
+
/** Must equal `boundedString`'s prompt cap in pi-subagents-protocol, which throws rather than truncating. */
|
|
10
|
+
export const MAX_DISPATCH_PROMPT_CHARS = 100_000;
|
|
11
|
+
/** Below this much headroom an input section carries no useful content, so none is injected. */
|
|
12
|
+
export const MIN_TASK_INPUT_CHARS = 512;
|
|
13
|
+
export const TASK_INPUT_HEADER = "Dependency results (pi-workflows inputs v1):";
|
|
7
14
|
|
|
8
|
-
function cap(value: string, limit: number): { text: string; truncated: boolean } {
|
|
15
|
+
export function cap(value: string, limit: number): { text: string; truncated: boolean } {
|
|
9
16
|
if (value.length <= limit) return { text: value, truncated: false };
|
|
10
17
|
const marker = "\n…[truncated]";
|
|
11
18
|
const contentLimit = Math.max(0, limit - marker.length);
|
|
12
19
|
return { text: `${value.slice(0, contentLimit)}${marker}`, truncated: true };
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (!definition.synthesis) throw new Error("workflow has no synthesis definition");
|
|
22
|
+
export interface ResultEntry {
|
|
23
|
+
task: Pick<WorkflowTask, "id" | "description">;
|
|
24
|
+
result: WorkflowTaskResult | undefined;
|
|
25
|
+
/** Reported as `status=` only when no journaled result exists. */
|
|
26
|
+
fallbackStatus: string;
|
|
27
|
+
}
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
export interface BoundedSectionOptions {
|
|
30
|
+
maxPerResult: number;
|
|
31
|
+
maxTotal: number;
|
|
32
|
+
overflowMarker: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One labeled, bounded result block. Reads only journaled fields so replays reproduce it exactly. */
|
|
36
|
+
export function buildResultBlock(entry: ResultEntry, maxResultChars = MAX_TASK_RESULT_CHARS): string {
|
|
37
|
+
const { task, result } = entry;
|
|
38
|
+
const bounded = cap(result?.text ?? "(no result)", maxResultChars);
|
|
39
|
+
const pointers = [
|
|
40
|
+
result?.agentId ? `agent_id=${result.agentId}` : "agent_id=unknown",
|
|
41
|
+
result?.outputFile ? `transcript=${result.outputFile}` : undefined,
|
|
42
|
+
result?.tokenCount !== undefined ? `token_count=${result.tokenCount}` : undefined,
|
|
43
|
+
]
|
|
44
|
+
.filter((value): value is string => value !== undefined)
|
|
45
|
+
.join(" ");
|
|
46
|
+
// `result.truncated` matters because `resultFromLifecycle` already caps text to
|
|
47
|
+
// MAX_TASK_RESULT_CHARS, so a re-cap here can never observe the original overflow.
|
|
48
|
+
const truncated = bounded.truncated || result?.truncated === true;
|
|
49
|
+
return [
|
|
50
|
+
`### ${task.id} — ${task.description}`,
|
|
51
|
+
`status=${result?.status ?? entry.fallbackStatus} ${pointers}`,
|
|
52
|
+
truncated ? "result_truncated=true" : "result_truncated=false",
|
|
53
|
+
bounded.text,
|
|
54
|
+
].join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Append result blocks after `preamble` until the aggregate budget is reached. */
|
|
58
|
+
export function buildBoundedResultSection(
|
|
59
|
+
preamble: readonly string[],
|
|
60
|
+
entries: readonly ResultEntry[],
|
|
61
|
+
options: BoundedSectionOptions,
|
|
62
|
+
): string {
|
|
63
|
+
const chunks: string[] = [...preamble];
|
|
25
64
|
let total = chunks.join("\n").length;
|
|
26
65
|
let aggregateTruncated = false;
|
|
27
66
|
|
|
28
|
-
for (const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const bounded = cap(text, MAX_TASK_RESULT_CHARS);
|
|
32
|
-
const pointers = [
|
|
33
|
-
result?.agentId ? `agent_id=${result.agentId}` : "agent_id=unknown",
|
|
34
|
-
result?.outputFile ? `transcript=${result.outputFile}` : undefined,
|
|
35
|
-
result?.tokenCount !== undefined ? `token_count=${result.tokenCount}` : undefined,
|
|
36
|
-
]
|
|
37
|
-
.filter((value): value is string => value !== undefined)
|
|
38
|
-
.join(" ");
|
|
39
|
-
const block = [
|
|
40
|
-
`### ${task.id} — ${task.description}`,
|
|
41
|
-
`status=${result?.status ?? run.taskStatus[task.id]} ${pointers}`,
|
|
42
|
-
bounded.truncated ? "result_truncated=true" : "result_truncated=false",
|
|
43
|
-
bounded.text,
|
|
44
|
-
].join("\n");
|
|
45
|
-
if (total + block.length + 2 > MAX_SYNTHESIS_INPUT_CHARS) {
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const block = buildResultBlock(entry, options.maxPerResult);
|
|
69
|
+
if (total + block.length + 2 > options.maxTotal) {
|
|
46
70
|
aggregateTruncated = true;
|
|
47
71
|
break;
|
|
48
72
|
}
|
|
@@ -52,13 +76,34 @@ export function buildSynthesisPrompt(
|
|
|
52
76
|
|
|
53
77
|
let output = chunks.join("\n");
|
|
54
78
|
if (aggregateTruncated) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
output += marker;
|
|
79
|
+
if (output.length + options.overflowMarker.length <= options.maxTotal) {
|
|
80
|
+
output += options.overflowMarker;
|
|
58
81
|
} else {
|
|
59
|
-
output = `${output.slice(0,
|
|
82
|
+
output = `${output.slice(0, options.maxTotal - options.overflowMarker.length)}${options.overflowMarker}`;
|
|
60
83
|
}
|
|
61
84
|
}
|
|
85
|
+
return output;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build bounded, labeled synthesis context without concatenating full transcripts. */
|
|
89
|
+
export function buildSynthesisPrompt(
|
|
90
|
+
definition: WorkflowDefinition,
|
|
91
|
+
run: WorkflowRun,
|
|
92
|
+
tasks: readonly WorkflowTask[] = definition.tasks,
|
|
93
|
+
): string {
|
|
94
|
+
if (!definition.synthesis) throw new Error("workflow has no synthesis definition");
|
|
95
|
+
|
|
96
|
+
const instruction = cap(definition.synthesis.prompt, MAX_SYNTHESIS_INSTRUCTION_CHARS);
|
|
97
|
+
const entries = tasks.map((task) => ({
|
|
98
|
+
task,
|
|
99
|
+
result: run.taskResults[task.id],
|
|
100
|
+
fallbackStatus: String(run.taskStatus[task.id]),
|
|
101
|
+
}));
|
|
102
|
+
const output = buildBoundedResultSection([instruction.text, "", "Workflow task results:"], entries, {
|
|
103
|
+
maxPerResult: MAX_TASK_RESULT_CHARS,
|
|
104
|
+
maxTotal: MAX_SYNTHESIS_INPUT_CHARS,
|
|
105
|
+
overflowMarker: "\n\n[aggregate synthesis input truncated: additional task results omitted]",
|
|
106
|
+
});
|
|
62
107
|
return cap(output, MAX_SYNTHESIS_INPUT_CHARS).text;
|
|
63
108
|
}
|
|
64
109
|
|
package/src/ui.ts
CHANGED
|
@@ -2,6 +2,15 @@ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import type { WorkflowEngine, WorkflowSummary } from "./engine.js";
|
|
5
|
+
import { sanitizeDisplayText } from "./safe-text.js";
|
|
6
|
+
import { isTerminalWorkflow, type WorkflowStatus } from "./state-machine.js";
|
|
7
|
+
|
|
8
|
+
/** Bounds, in code points, for untrusted strings rendered into the navigator. */
|
|
9
|
+
const MAX_NAME_CHARS = 200;
|
|
10
|
+
const MAX_ID_CHARS = 128;
|
|
11
|
+
const MAX_RESULT_CHARS = 300;
|
|
12
|
+
const MAX_RUN_ERROR_CHARS = 500;
|
|
13
|
+
const MAX_DESCRIPTION_CHARS = 120;
|
|
5
14
|
|
|
6
15
|
function elapsed(ms: number): string {
|
|
7
16
|
const seconds = Math.floor(ms / 1_000);
|
|
@@ -13,7 +22,8 @@ function elapsed(ms: number): string {
|
|
|
13
22
|
export function formatWorkflowRunLabel(run: WorkflowSummary): string {
|
|
14
23
|
const phases = run.phaseCount > 0 ? ` · phases ${run.completedPhases}/${run.phaseCount}` : "";
|
|
15
24
|
const tokens = run.tokenCount === undefined ? "" : ` · ${run.tokenCount} tokens`;
|
|
16
|
-
|
|
25
|
+
const name = sanitizeDisplayText(run.name, MAX_NAME_CHARS);
|
|
26
|
+
return `${name} · ${run.status}${phases} · ${run.completedTasks}/${run.taskCount} tasks · ${elapsed(run.elapsedMs)}${tokens}`;
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): string {
|
|
@@ -24,22 +34,22 @@ export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): str
|
|
|
24
34
|
.filter((value): value is number => value !== undefined);
|
|
25
35
|
const tokenCount = tokenValues.length > 0 ? tokenValues.reduce((total, value) => total + value, 0) : undefined;
|
|
26
36
|
const lines = [
|
|
27
|
-
`${run.definition.name} [${run.status}]`,
|
|
28
|
-
`run: ${run.runId}`,
|
|
29
|
-
`elapsed: ${elapsed(Math.max(0, (run.status
|
|
37
|
+
`${sanitizeDisplayText(run.definition.name, MAX_NAME_CHARS)} [${run.status}]`,
|
|
38
|
+
`run: ${sanitizeDisplayText(run.runId, MAX_ID_CHARS)}`,
|
|
39
|
+
`elapsed: ${elapsed(Math.max(0, (isTerminalWorkflow(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`,
|
|
30
40
|
"",
|
|
31
41
|
...(tokenCount === undefined ? [] : [`tokens: ${tokenCount}`]),
|
|
32
42
|
];
|
|
33
43
|
for (const phase of run.definition.phases) {
|
|
34
|
-
lines.push(`Phase: ${phase.title}`);
|
|
44
|
+
lines.push(`Phase: ${sanitizeDisplayText(phase.title, MAX_NAME_CHARS)}`);
|
|
35
45
|
for (const task of run.definition.tasks.filter((candidate) => candidate.phase === phase.id)) {
|
|
36
46
|
const status = run.taskStatus[task.id] ?? "pending";
|
|
37
|
-
const agent = run.agentIds[task.id] ? ` agent=${run.agentIds[task.id]}` : "";
|
|
47
|
+
const agent = run.agentIds[task.id] ? ` agent=${sanitizeDisplayText(run.agentIds[task.id], MAX_ID_CHARS)}` : "";
|
|
38
48
|
const compact = run.compactions[task.id] ? ` compactions=${run.compactions[task.id]}` : "";
|
|
39
|
-
lines.push(` ${status.padEnd(11)} ${task.id}${agent}${compact}`);
|
|
49
|
+
lines.push(` ${status.padEnd(11)} ${sanitizeDisplayText(task.id, MAX_ID_CHARS)}${agent}${compact}`);
|
|
40
50
|
const result = run.taskResults[task.id];
|
|
41
|
-
if (result?.error) lines.push(` error: ${result.error
|
|
42
|
-
if (result?.text) lines.push(` result: ${result.text
|
|
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)}`);
|
|
43
53
|
}
|
|
44
54
|
}
|
|
45
55
|
const unphased = run.definition.tasks.filter((task) => task.phase === undefined);
|
|
@@ -47,15 +57,55 @@ export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): str
|
|
|
47
57
|
lines.push("Tasks:");
|
|
48
58
|
for (const task of unphased) {
|
|
49
59
|
const status = run.taskStatus[task.id] ?? "pending";
|
|
50
|
-
const agent = run.agentIds[task.id] ? ` agent=${run.agentIds[task.id]}` : "";
|
|
51
|
-
lines.push(` ${status.padEnd(11)} ${task.id}${agent}`);
|
|
60
|
+
const agent = run.agentIds[task.id] ? ` agent=${sanitizeDisplayText(run.agentIds[task.id], MAX_ID_CHARS)}` : "";
|
|
61
|
+
lines.push(` ${status.padEnd(11)} ${sanitizeDisplayText(task.id, MAX_ID_CHARS)}${agent}`);
|
|
52
62
|
}
|
|
53
63
|
}
|
|
54
64
|
lines.push("", "Live agent conversations remain available through pi-subagents FleetView.");
|
|
55
|
-
if (run.error) lines.push(`Error: ${run.error
|
|
65
|
+
if (run.error) lines.push(`Error: ${sanitizeDisplayText(run.error, MAX_RUN_ERROR_CHARS)}`);
|
|
56
66
|
return lines.join("\n");
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
/** Actions the `/workflows` detail menu can offer, in display order. */
|
|
70
|
+
export type WorkflowAction = "refresh" | "pause" | "resume" | "stop" | "back";
|
|
71
|
+
|
|
72
|
+
export interface WorkflowActionItem {
|
|
73
|
+
value: WorkflowAction;
|
|
74
|
+
label: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const REFRESH: WorkflowActionItem = { value: "refresh", label: "Refresh" };
|
|
78
|
+
const PAUSE: WorkflowActionItem = { value: "pause", label: "Pause" };
|
|
79
|
+
const RESUME: WorkflowActionItem = { value: "resume", label: "Resume" };
|
|
80
|
+
const STOP: WorkflowActionItem = { value: "stop", label: "Stop" };
|
|
81
|
+
const BACK: WorkflowActionItem = { value: "back", label: "Back" };
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Menu actions legal for a run in `status`, mirroring what `WorkflowEngine.control` accepts.
|
|
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.
|
|
87
|
+
*/
|
|
88
|
+
export function workflowActionsFor(status: WorkflowStatus): readonly WorkflowActionItem[] {
|
|
89
|
+
switch (status) {
|
|
90
|
+
case "pending":
|
|
91
|
+
case "pausing":
|
|
92
|
+
case "synthesizing":
|
|
93
|
+
case "stopping":
|
|
94
|
+
return [REFRESH, STOP, BACK];
|
|
95
|
+
case "running":
|
|
96
|
+
return [REFRESH, PAUSE, STOP, BACK];
|
|
97
|
+
case "paused":
|
|
98
|
+
case "interrupted":
|
|
99
|
+
return [REFRESH, RESUME, STOP, BACK];
|
|
100
|
+
case "completed":
|
|
101
|
+
case "failed":
|
|
102
|
+
case "stopped":
|
|
103
|
+
return [REFRESH, BACK];
|
|
104
|
+
}
|
|
105
|
+
const unreachable: never = status;
|
|
106
|
+
return unreachable;
|
|
107
|
+
}
|
|
108
|
+
|
|
59
109
|
async function selectList<T>(
|
|
60
110
|
ctx: ExtensionCommandContext,
|
|
61
111
|
title: string,
|
|
@@ -102,7 +152,7 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
|
|
|
102
152
|
runs.map((run) => ({
|
|
103
153
|
value: run.runId,
|
|
104
154
|
label: formatWorkflowRunLabel(run),
|
|
105
|
-
description: run.error
|
|
155
|
+
description: run.error === undefined ? undefined : sanitizeDisplayText(run.error, MAX_DESCRIPTION_CHARS),
|
|
106
156
|
})),
|
|
107
157
|
(theme) =>
|
|
108
158
|
runs.length > 0
|
|
@@ -113,26 +163,24 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
|
|
|
113
163
|
while (true) {
|
|
114
164
|
const run = engine.get(selected);
|
|
115
165
|
if (!run) break;
|
|
116
|
-
const
|
|
166
|
+
const name = sanitizeDisplayText(run.name, MAX_NAME_CHARS);
|
|
167
|
+
const action = await selectList<WorkflowAction>(
|
|
117
168
|
ctx,
|
|
118
|
-
`${
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
...(!["completed", "failed", "stopped"].includes(run.status) ? [{ value: "stop", label: "Stop" }] : []),
|
|
124
|
-
{ value: "back", label: "Back" },
|
|
125
|
-
],
|
|
169
|
+
`${name} · ${run.status}`,
|
|
170
|
+
workflowActionsFor(run.status).map((item) => ({
|
|
171
|
+
value: item.value,
|
|
172
|
+
label: item.label,
|
|
173
|
+
})),
|
|
126
174
|
() => formatWorkflowDetail(engine, selected),
|
|
127
175
|
);
|
|
128
|
-
if (
|
|
176
|
+
if (action === undefined || action === "back") break;
|
|
129
177
|
if (action === "refresh") continue;
|
|
130
178
|
if (action === "stop") {
|
|
131
|
-
const confirmed = await ctx.ui.confirm("Stop workflow", `Stop ${
|
|
179
|
+
const confirmed = await ctx.ui.confirm("Stop workflow", `Stop ${name}? The run will be non-resumable.`);
|
|
132
180
|
if (!confirmed) continue;
|
|
133
181
|
}
|
|
134
182
|
try {
|
|
135
|
-
await engine.control(action
|
|
183
|
+
await engine.control(action, selected);
|
|
136
184
|
ctx.ui.notify(`Workflow ${action} requested.`, "info");
|
|
137
185
|
} catch (error: unknown) {
|
|
138
186
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|