pi-subagents 0.58.0 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/docs/agents.md +5 -3
- package/docs/configuration.md +4 -4
- package/docs/extension-api.md +1 -1
- package/docs/models.md +24 -1
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +93 -5
- package/docs/workflows.md +94 -3
- package/package.json +3 -1
- package/prompts/review-loop.md +2 -2
- package/skills/council-mode/SKILL.md +48 -243
- package/skills/council-mode/references/pass-contracts.md +150 -0
- package/skills/pi-subagents/SKILL.md +87 -37
- package/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
- package/skills/pi-subagents/references/execution-controls.md +52 -7
- package/skills/pi-subagents/references/management-authoring-rpc.md +3 -4
- package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +54 -26
- package/skills/pi-subagents/references/review-and-validation.md +73 -0
- package/src/agents/agent-management.ts +159 -42
- package/src/agents/agent-serializer.ts +4 -2
- package/src/agents/agents.ts +67 -26
- package/src/agents/runtime-agent-registry.ts +9 -16
- package/src/api/background-work.ts +5 -1
- package/src/api/delegation.ts +0 -7
- package/src/api/preflight.ts +6 -9
- package/src/api/shared-types.ts +2 -0
- package/src/extension/fanout-child.ts +5 -3
- package/src/extension/index.ts +51 -23
- package/src/extension/public-execution.ts +16 -2
- package/src/extension/schemas.ts +36 -10
- package/src/extension/tool-description.ts +24 -5
- package/src/intercom/result-intercom.ts +2 -0
- package/src/profiles/profiles.ts +5 -6
- package/src/runs/background/active-async-capacity.ts +2 -2
- package/src/runs/background/async-execution.ts +52 -37
- package/src/runs/background/async-job-tracker.ts +17 -12
- package/src/runs/background/async-resume.ts +23 -25
- package/src/runs/background/async-status-snapshot.ts +23 -261
- package/src/runs/background/async-status.ts +110 -8
- package/src/runs/background/chain-append.ts +6 -3
- package/src/runs/background/chain-root-attachment.ts +60 -8
- package/src/runs/background/control-channel.ts +3 -2
- package/src/runs/background/fleet-view.ts +21 -11
- package/src/runs/background/notify.ts +158 -6
- package/src/runs/background/result-files.ts +2 -1
- package/src/runs/background/result-watcher.ts +2 -0
- package/src/runs/background/resume-guidance.ts +1 -1
- package/src/runs/background/retained-children.ts +1 -1
- package/src/runs/background/run-status.ts +30 -9
- package/src/runs/background/scheduled-runs.ts +86 -7
- package/src/runs/background/stale-run-reconciler.ts +10 -4
- package/src/runs/background/steering.ts +4 -14
- package/src/runs/background/subagent-runner.ts +351 -358
- package/src/runs/background/subagent-wait.ts +68 -12
- package/src/runs/background/terminal-run-index.ts +1 -1
- package/src/runs/background/wait-completions.ts +25 -1
- package/src/runs/background/wait-config.ts +23 -9
- package/src/runs/background/wait-tool.ts +9 -2
- package/src/runs/foreground/async-steering-action.ts +2 -2
- package/src/runs/foreground/execution.ts +125 -122
- package/src/runs/foreground/foreground-control.ts +3 -0
- package/src/runs/foreground/foreground-history.ts +1 -0
- package/src/runs/foreground/subagent-executor.ts +576 -240
- package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
- package/src/runs/shared/abort-recovery.ts +119 -0
- package/src/runs/shared/async-status-projection.ts +597 -0
- package/src/runs/shared/background-process-options.ts +9 -0
- package/src/runs/shared/child-identity.ts +19 -4
- package/src/runs/shared/child-launch-plan.ts +151 -0
- package/src/runs/shared/completion-evidence.ts +89 -0
- package/src/runs/shared/completion-guard.ts +1 -1
- package/src/runs/shared/dynamic-fanout.ts +2 -2
- package/src/runs/shared/host-step-status.ts +230 -0
- package/src/runs/shared/lane-metadata.ts +105 -0
- package/src/runs/shared/mcp-config-sources.ts +42 -6
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
- package/src/runs/shared/mcp-direct-tool-grant.ts +194 -0
- package/src/runs/shared/model-fallback.ts +9 -2
- package/src/runs/shared/mutation-evidence.ts +52 -3
- package/src/runs/shared/nested-events.ts +6 -2
- package/src/runs/shared/nested-render.ts +7 -3
- package/src/runs/shared/parallel-handoff.ts +419 -7
- package/src/runs/shared/parallel-utils.ts +7 -0
- package/src/runs/shared/pi-args.ts +67 -3
- package/src/runs/shared/single-output.ts +72 -22
- package/src/runs/shared/subagent-prompt-runtime.ts +33 -6
- package/src/runs/shared/workflow-graph.ts +15 -0
- package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
- package/src/runs/shared/worktree.ts +18 -0
- package/src/shared/child-session-name.ts +46 -0
- package/src/shared/extension-context.ts +24 -0
- package/src/shared/formatters.ts +5 -2
- package/src/shared/launch-contract.ts +1 -1
- package/src/shared/settings.ts +9 -103
- package/src/shared/types.ts +232 -30
- package/src/shared/utils.ts +35 -55
- package/src/slash/delegation-adapters.ts +1 -8
- package/src/slash/delegation-request.ts +0 -4
- package/src/slash/slash-bridge.ts +1 -2
- package/src/slash/slash-commands.ts +369 -90
- package/src/slash/slash-live-state.ts +22 -11
- package/src/tui/fleet-status.ts +134 -75
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render-helpers.ts +31 -0
- package/src/tui/render.ts +895 -121
- package/src/watchdog/change-signature.ts +40 -1
- package/src/watchdog/turn-delta.ts +1 -1
- package/src/workflows/chat-progress.ts +6 -3
- package/src/workflows/host-command.ts +235 -0
- package/src/workflows/scripted-workflow.ts +503 -38
- package/src/workflows/workflow-child-summary.ts +9 -5
- package/src/workflows/workflow-preflight.ts +270 -0
- package/src/workflows/workflow-receipt.ts +43 -4
- package/src/workflows/workflow-settlement.ts +246 -0
- package/src/runs/shared/turn-budget.ts +0 -98
|
@@ -3,6 +3,7 @@ import type { WorkflowScriptChildResult, WorkflowScriptTraceEntry } from "./scri
|
|
|
3
3
|
|
|
4
4
|
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
5
5
|
const TERMINAL_STATES = new Set(["completed", "failed", "paused", "stopped", "rejected", "detached"]);
|
|
6
|
+
const MAX_REQUIRED_ID_BYTES = 4_096;
|
|
6
7
|
|
|
7
8
|
function bounded(value: unknown, maxBytes: number): string | undefined {
|
|
8
9
|
if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value, "utf8") > maxBytes) return undefined;
|
|
@@ -10,8 +11,8 @@ function bounded(value: unknown, maxBytes: number): string | undefined {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
function requiredId(value: string, label: string): string {
|
|
13
|
-
const id = bounded(value,
|
|
14
|
-
if (!id) throw new Error(`${label} must be a non-empty identifier of at most
|
|
14
|
+
const id = bounded(value, MAX_REQUIRED_ID_BYTES);
|
|
15
|
+
if (!id) throw new Error(`${label} must be a non-empty identifier of at most ${MAX_REQUIRED_ID_BYTES} UTF-8 bytes.`);
|
|
15
16
|
return id;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -38,6 +39,7 @@ export function workflowChildSummary(input: {
|
|
|
38
39
|
state,
|
|
39
40
|
...(bounded(entry.runId, 256) ? { runId: bounded(entry.runId, 256) } : {}),
|
|
40
41
|
...(previous?.agent ? { agent: previous.agent } : {}),
|
|
42
|
+
...(previous?.sessionName ? { sessionName: previous.sessionName } : {}),
|
|
41
43
|
...(previous?.model ? { model: previous.model } : {}),
|
|
42
44
|
...(previous?.thinking ? { thinking: previous.thinking } : {}),
|
|
43
45
|
});
|
|
@@ -57,6 +59,7 @@ export function workflowChildSummary(input: {
|
|
|
57
59
|
state,
|
|
58
60
|
...(bounded(step.runId, 256) ? { runId: bounded(step.runId, 256) } : {}),
|
|
59
61
|
...(launchResolved && bounded(step.agent, 256) ? { agent: bounded(step.agent, 256) } : {}),
|
|
62
|
+
...(bounded(step.sessionName, 256) ? { sessionName: bounded(step.sessionName, 256) } : {}),
|
|
60
63
|
...(bounded(step.model, 256) ? { model: bounded(step.model, 256) } : {}),
|
|
61
64
|
...(bounded(step.thinking, 32) ? { thinking: bounded(step.thinking, 32) } : {}),
|
|
62
65
|
});
|
|
@@ -70,6 +73,7 @@ export function workflowChildSummary(input: {
|
|
|
70
73
|
state,
|
|
71
74
|
...(bounded(child.runId, 256) ? { runId: bounded(child.runId, 256) } : {}),
|
|
72
75
|
...(bounded(child.agent, 256) ? { agent: bounded(child.agent, 256) } : {}),
|
|
76
|
+
...(bounded(result?.sessionName, 256) ? { sessionName: bounded(result?.sessionName, 256) } : {}),
|
|
73
77
|
...(bounded(result?.model, 256) ? { model: bounded(result?.model, 256) } : {}),
|
|
74
78
|
...(bounded(result?.thinking, 32) ? { thinking: bounded(result?.thinking, 32) } : {}),
|
|
75
79
|
});
|
|
@@ -101,14 +105,14 @@ export function parseWorkflowChildSummary(value: unknown): WorkflowChildSummaryV
|
|
|
101
105
|
const children = input.children.map((row): WorkflowChildSummaryV1["children"][number] => {
|
|
102
106
|
if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error("workflowChildren child row is invalid.");
|
|
103
107
|
const child = row as Record<string, unknown>;
|
|
104
|
-
if (Object.keys(child).some((key) => !["childId", "runId", "agent", "model", "thinking", "state"].includes(key))) throw new Error("workflowChildren child row has unsupported fields.");
|
|
108
|
+
if (Object.keys(child).some((key) => !["childId", "runId", "agent", "sessionName", "model", "thinking", "state"].includes(key))) throw new Error("workflowChildren child row has unsupported fields.");
|
|
105
109
|
if (typeof child.childId !== "string" || !KEY_PATTERN.test(child.childId)) throw new Error("workflowChildren childId is invalid.");
|
|
106
110
|
const state = child.state;
|
|
107
111
|
if (state !== "pending" && state !== "running" && state !== "completed" && state !== "failed" && state !== "paused" && state !== "stopped" && state !== "rejected" && state !== "detached") throw new Error("workflowChildren child state is invalid.");
|
|
108
|
-
for (const [field, maxBytes] of [["runId", 256], ["agent", 256], ["model", 256], ["thinking", 32]] as const) {
|
|
112
|
+
for (const [field, maxBytes] of [["runId", 256], ["agent", 256], ["sessionName", 256], ["model", 256], ["thinking", 32]] as const) {
|
|
109
113
|
if (child[field] !== undefined && bounded(child[field], maxBytes) === undefined) throw new Error(`workflowChildren child ${field} is invalid.`);
|
|
110
114
|
}
|
|
111
|
-
return { childId: child.childId, state, ...(child.runId ? { runId: child.runId as string } : {}), ...(child.agent ? { agent: child.agent as string } : {}), ...(child.model ? { model: child.model as string } : {}), ...(child.thinking ? { thinking: child.thinking as string } : {}) };
|
|
115
|
+
return { childId: child.childId, state, ...(child.runId ? { runId: child.runId as string } : {}), ...(child.agent ? { agent: child.agent as string } : {}), ...(child.sessionName ? { sessionName: child.sessionName as string } : {}), ...(child.model ? { model: child.model as string } : {}), ...(child.thinking ? { thinking: child.thinking as string } : {}) };
|
|
112
116
|
});
|
|
113
117
|
if (new Set(children.map((child) => child.childId)).size !== children.length) throw new Error("workflowChildren has duplicate childId values.");
|
|
114
118
|
if (typeof input.parentToolCallId !== "string") throw new Error("workflowChildren.parentToolCallId is invalid.");
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { WorkflowPreflightLaneV1, WorkflowPreflightV1 } from "../shared/types.ts";
|
|
2
|
+
|
|
3
|
+
export const WORKFLOW_PREFLIGHT_VERSION = 1 as const;
|
|
4
|
+
export const WORKFLOW_PREFLIGHT_MAX_LANES = 64;
|
|
5
|
+
export const WORKFLOW_PREFLIGHT_MAX_STRING_LENGTH = 256;
|
|
6
|
+
export const WORKFLOW_PREFLIGHT_MAX_CLAIMS = 16;
|
|
7
|
+
export const WORKFLOW_PREFLIGHT_MAX_DEPTH = 3;
|
|
8
|
+
export const WORKFLOW_PREFLIGHT_MAX_BYTES = 16 * 1024;
|
|
9
|
+
export const WORKFLOW_PREFLIGHT_MAX_WARNINGS = 16;
|
|
10
|
+
|
|
11
|
+
const WORKFLOW_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
12
|
+
const PREFLIGHT_FIELDS = new Set(["version", "coverage", "lanes"]);
|
|
13
|
+
const LANE_FIELDS = new Set(["key", "mode", "decision", "claims", "expectedOutput", "independence"]);
|
|
14
|
+
const PREFLIGHT_MODES = new Set<WorkflowPreflightLaneV1["mode"]>(["mutation", "review", "scout", "gate"]);
|
|
15
|
+
|
|
16
|
+
type WorkflowTraceLike = {
|
|
17
|
+
operation: string;
|
|
18
|
+
key: string;
|
|
19
|
+
phase?: string;
|
|
20
|
+
generatedLaneKey?: string;
|
|
21
|
+
warning?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface WorkflowPreflightValidationResult {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
preflight?: WorkflowPreflightV1;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
32
|
+
const prototype = Object.getPrototypeOf(value);
|
|
33
|
+
return prototype === Object.prototype || prototype === null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertPlainObject(value: unknown, path: string): asserts value is Record<string, unknown> {
|
|
37
|
+
if (!isPlainObject(value)) throw new Error(`${path} must be a plain JSON object.`);
|
|
38
|
+
if (Object.getOwnPropertySymbols(value).length > 0) throw new Error(`${path} must not contain symbol keys.`);
|
|
39
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
40
|
+
if (!descriptor.enumerable || !("value" in descriptor)) throw new Error(`${path}.${key} must be an enumerable data property.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertKnownFields(value: Record<string, unknown>, allowed: ReadonlySet<string>, path: string): void {
|
|
45
|
+
for (const key of Object.keys(value)) {
|
|
46
|
+
if (!allowed.has(key)) throw new Error(`${path} contains unsupported field '${key}'.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeDisplayString(value: unknown, path: string): string {
|
|
51
|
+
if (typeof value !== "string") throw new Error(`${path} must be a string.`);
|
|
52
|
+
const normalized = value
|
|
53
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
54
|
+
.replace(/\s+/g, " ")
|
|
55
|
+
.trim();
|
|
56
|
+
if (!normalized) throw new Error(`${path} must be a non-empty string.`);
|
|
57
|
+
if (normalized.length > WORKFLOW_PREFLIGHT_MAX_STRING_LENGTH) {
|
|
58
|
+
throw new Error(`${path} exceeds the maximum length of ${WORKFLOW_PREFLIGHT_MAX_STRING_LENGTH}.`);
|
|
59
|
+
}
|
|
60
|
+
return normalized;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeLane(value: unknown, index: number): WorkflowPreflightLaneV1 {
|
|
64
|
+
const path = `preflight.lanes[${index}]`;
|
|
65
|
+
assertPlainObject(value, path);
|
|
66
|
+
assertKnownFields(value, LANE_FIELDS, path);
|
|
67
|
+
const key = normalizeDisplayString(value.key, `${path}.key`);
|
|
68
|
+
if (!WORKFLOW_KEY_PATTERN.test(key)) throw new Error(`${path}.key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.`);
|
|
69
|
+
let mode: WorkflowPreflightLaneV1["mode"];
|
|
70
|
+
if (value.mode !== undefined) {
|
|
71
|
+
if (typeof value.mode !== "string" || !PREFLIGHT_MODES.has(value.mode as WorkflowPreflightLaneV1["mode"])) {
|
|
72
|
+
throw new Error(`${path}.mode must be one of: mutation, review, scout, gate.`);
|
|
73
|
+
}
|
|
74
|
+
mode = value.mode as WorkflowPreflightLaneV1["mode"];
|
|
75
|
+
}
|
|
76
|
+
let claims: string[] | undefined;
|
|
77
|
+
if (value.claims !== undefined) {
|
|
78
|
+
if (!Array.isArray(value.claims)) throw new Error(`${path}.claims must be an array of strings.`);
|
|
79
|
+
if (value.claims.length > WORKFLOW_PREFLIGHT_MAX_CLAIMS) throw new Error(`${path}.claims supports at most ${WORKFLOW_PREFLIGHT_MAX_CLAIMS} entries.`);
|
|
80
|
+
claims = value.claims.map((claim, claimIndex) => normalizeDisplayString(claim, `${path}.claims[${claimIndex}]`));
|
|
81
|
+
for (let claimIndex = 0; claimIndex < value.claims.length; claimIndex += 1) {
|
|
82
|
+
if (!Object.prototype.hasOwnProperty.call(value.claims, claimIndex)) throw new Error(`${path}.claims must not contain sparse arrays.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
key,
|
|
87
|
+
...(mode ? { mode } : {}),
|
|
88
|
+
...(value.decision !== undefined ? { decision: normalizeDisplayString(value.decision, `${path}.decision`) } : {}),
|
|
89
|
+
...(claims ? { claims } : {}),
|
|
90
|
+
...(value.expectedOutput !== undefined ? { expectedOutput: normalizeDisplayString(value.expectedOutput, `${path}.expectedOutput`) } : {}),
|
|
91
|
+
...(value.independence !== undefined ? { independence: normalizeDisplayString(value.independence, `${path}.independence`) } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate and canonicalize the explicit preflight input. This is intentionally
|
|
97
|
+
* independent of workflowScript parsing: callers may describe dynamic fanout
|
|
98
|
+
* without making the metadata a second execution graph.
|
|
99
|
+
*/
|
|
100
|
+
export function normalizeWorkflowPreflight(input: unknown): WorkflowPreflightV1 | undefined {
|
|
101
|
+
if (input === undefined) return undefined;
|
|
102
|
+
assertPlainObject(input, "preflight");
|
|
103
|
+
assertKnownFields(input, PREFLIGHT_FIELDS, "preflight");
|
|
104
|
+
if (input.version !== WORKFLOW_PREFLIGHT_VERSION) throw new Error(`preflight.version must be ${WORKFLOW_PREFLIGHT_VERSION}.`);
|
|
105
|
+
const coverage = input.coverage === undefined ? "partial" : input.coverage;
|
|
106
|
+
if (coverage !== "complete" && coverage !== "partial") throw new Error("preflight.coverage must be 'complete' or 'partial'.");
|
|
107
|
+
if (!Array.isArray(input.lanes)) throw new Error("preflight.lanes must be an array.");
|
|
108
|
+
if (input.lanes.length > WORKFLOW_PREFLIGHT_MAX_LANES) throw new Error(`preflight.lanes supports at most ${WORKFLOW_PREFLIGHT_MAX_LANES} lanes.`);
|
|
109
|
+
for (let index = 0; index < input.lanes.length; index += 1) {
|
|
110
|
+
if (!Object.prototype.hasOwnProperty.call(input.lanes, index)) throw new Error("preflight.lanes must not contain sparse arrays.");
|
|
111
|
+
}
|
|
112
|
+
const lanes = input.lanes.map(normalizeLane);
|
|
113
|
+
const seenKeys = new Set<string>();
|
|
114
|
+
for (const lane of lanes) {
|
|
115
|
+
if (seenKeys.has(lane.key)) throw new Error(`preflight.lanes contains duplicate key '${lane.key}'.`);
|
|
116
|
+
seenKeys.add(lane.key);
|
|
117
|
+
}
|
|
118
|
+
const normalized = { version: WORKFLOW_PREFLIGHT_VERSION, coverage, lanes } satisfies WorkflowPreflightV1;
|
|
119
|
+
const depth = 1 + (lanes.length > 0 ? 1 : 0) + (lanes.some((lane) => lane.claims !== undefined) ? 1 : 0);
|
|
120
|
+
if (depth > WORKFLOW_PREFLIGHT_MAX_DEPTH) throw new Error(`preflight exceeds the maximum nesting depth of ${WORKFLOW_PREFLIGHT_MAX_DEPTH}.`);
|
|
121
|
+
const bytes = Buffer.byteLength(JSON.stringify(normalized), "utf8");
|
|
122
|
+
if (bytes > WORKFLOW_PREFLIGHT_MAX_BYTES) throw new Error(`preflight canonical JSON is ${bytes} bytes; maximum is ${WORKFLOW_PREFLIGHT_MAX_BYTES}.`);
|
|
123
|
+
return normalized;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function validateWorkflowPreflight(input: unknown): WorkflowPreflightValidationResult {
|
|
127
|
+
try {
|
|
128
|
+
const preflight = normalizeWorkflowPreflight(input);
|
|
129
|
+
return { ok: true, ...(preflight ? { preflight } : {}) };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function warningLimit(messages: string[]): string[] {
|
|
136
|
+
if (messages.length <= WORKFLOW_PREFLIGHT_MAX_WARNINGS) return messages;
|
|
137
|
+
const omitted = messages.length - WORKFLOW_PREFLIGHT_MAX_WARNINGS + 1;
|
|
138
|
+
return [...messages.slice(0, WORKFLOW_PREFLIGHT_MAX_WARNINGS - 1), `Preflight advisory: ${omitted} additional mismatch warning(s) omitted.`];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function declaredKeys(preflight: WorkflowPreflightV1): Set<string> {
|
|
142
|
+
return new Set(preflight.lanes.map((lane) => lane.key));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Treat declared lane keys as plan roots: `lane` is the lane itself and
|
|
147
|
+
* `lane.stage` is a stage by convention, even without generated provenance.
|
|
148
|
+
*/
|
|
149
|
+
function declaredLaneCoversWorkflowKey(entry: WorkflowTraceLike, laneKey: string): boolean {
|
|
150
|
+
return entry.key === laneKey || entry.key.startsWith(`${laneKey}.`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function keyCoveredByDeclaredLane(entry: WorkflowTraceLike, declared: ReadonlySet<string>): boolean {
|
|
154
|
+
for (const laneKey of declared) if (declaredLaneCoversWorkflowKey(entry, laneKey)) return true;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function launchedEntries(trace: readonly WorkflowTraceLike[]): WorkflowTraceLike[] {
|
|
159
|
+
const entries: WorkflowTraceLike[] = [];
|
|
160
|
+
const seen = new Set<string>();
|
|
161
|
+
for (const entry of trace) {
|
|
162
|
+
if (entry.operation !== "run" || entry.phase === "auto-resume" || seen.has(entry.key)) continue;
|
|
163
|
+
seen.add(entry.key);
|
|
164
|
+
entries.push(entry);
|
|
165
|
+
}
|
|
166
|
+
return entries;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function undeclaredWarning(key: string): string {
|
|
170
|
+
return `Preflight advisory: workflow key '${key}' launched without a declared lane.`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function unlaunchedWarning(key: string): string {
|
|
174
|
+
return `Preflight advisory: declared lane '${key}' was not launched.`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Return bounded advisory mismatch warnings; these never reject a child launch. */
|
|
178
|
+
export function workflowPreflightWarnings(
|
|
179
|
+
preflight: WorkflowPreflightV1 | undefined,
|
|
180
|
+
trace: readonly WorkflowTraceLike[],
|
|
181
|
+
options: { settled?: boolean } = {},
|
|
182
|
+
): string[] {
|
|
183
|
+
if (!preflight) return [];
|
|
184
|
+
const declared = declaredKeys(preflight);
|
|
185
|
+
const launched = launchedEntries(trace);
|
|
186
|
+
const messages = launched.filter((entry) => !keyCoveredByDeclaredLane(entry, declared)).map((entry) => undeclaredWarning(entry.key));
|
|
187
|
+
if (options.settled === true) {
|
|
188
|
+
for (const lane of preflight.lanes) if (!launched.some((entry) => declaredLaneCoversWorkflowKey(entry, lane.key))) messages.push(unlaunchedWarning(lane.key));
|
|
189
|
+
}
|
|
190
|
+
return warningLimit(messages);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Attach the first undeclared-key warning to its first trace row for status/debug views. */
|
|
194
|
+
export function annotateWorkflowPreflightTrace<T extends WorkflowTraceLike>(trace: readonly T[], preflight: WorkflowPreflightV1 | undefined): T[] {
|
|
195
|
+
if (!preflight) return [...trace];
|
|
196
|
+
const declared = declaredKeys(preflight);
|
|
197
|
+
const warned = new Set<string>();
|
|
198
|
+
return trace.map((entry) => {
|
|
199
|
+
if (entry.operation !== "run" || entry.phase === "auto-resume" || keyCoveredByDeclaredLane(entry, declared) || warned.has(entry.key)) return entry;
|
|
200
|
+
warned.add(entry.key);
|
|
201
|
+
return { ...entry, warning: entry.warning ?? undeclaredWarning(entry.key) };
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function laneCell(value: string | undefined): string {
|
|
206
|
+
return value ?? "—";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const WORKFLOW_PREFLIGHT_PLAN_LABEL_LENGTH = 96;
|
|
210
|
+
|
|
211
|
+
function planLaneLabel(lane: WorkflowPreflightLaneV1): string {
|
|
212
|
+
const value = lane.decision?.trim() || lane.key;
|
|
213
|
+
if (value.length <= WORKFLOW_PREFLIGHT_PLAN_LABEL_LENGTH) return value;
|
|
214
|
+
return `${value.slice(0, WORKFLOW_PREFLIGHT_PLAN_LABEL_LENGTH - 1).trimEnd()}…`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Render the detailed bounded table reserved for tool output and expanded/debug views. */
|
|
218
|
+
export function formatWorkflowPreflight(preflight: WorkflowPreflightV1 | undefined, options: { indent?: string } = {}): string {
|
|
219
|
+
if (!preflight) return "";
|
|
220
|
+
const indent = options.indent ?? "";
|
|
221
|
+
const count = preflight.lanes.length;
|
|
222
|
+
const lines = [
|
|
223
|
+
`${indent}Preflight: v${preflight.version} · ${preflight.coverage} · ${count} lane${count === 1 ? "" : "s"}`,
|
|
224
|
+
`${indent} key | mode | decision | claims | expected output | independence`,
|
|
225
|
+
];
|
|
226
|
+
for (const lane of preflight.lanes) {
|
|
227
|
+
lines.push(`${indent} ${[
|
|
228
|
+
lane.key,
|
|
229
|
+
laneCell(lane.mode),
|
|
230
|
+
laneCell(lane.decision),
|
|
231
|
+
lane.claims?.join(", ") ?? "—",
|
|
232
|
+
laneCell(lane.expectedOutput),
|
|
233
|
+
laneCell(lane.independence),
|
|
234
|
+
].join(" | ")}`);
|
|
235
|
+
}
|
|
236
|
+
return lines.join("\n");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatWorkflowPreflightSummary(preflight: WorkflowPreflightV1 | undefined): string {
|
|
240
|
+
if (!preflight) return "";
|
|
241
|
+
const keys = preflight.lanes.slice(0, 4).map((lane) => lane.key).join(", ");
|
|
242
|
+
const remainder = preflight.lanes.length > 4 ? `, +${preflight.lanes.length - 4}` : "";
|
|
243
|
+
return `preflight · ${preflight.coverage} · ${preflight.lanes.length} lane${preflight.lanes.length === 1 ? "" : "s"}${keys ? `: ${keys}${remainder}` : ""}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Render the operator-facing one-line plan shown in routine status views. */
|
|
247
|
+
export function formatWorkflowPreflightPlanSummary(preflight: WorkflowPreflightV1 | undefined, options: { indent?: string } = {}): string {
|
|
248
|
+
if (!preflight) return "";
|
|
249
|
+
const indent = options.indent ?? "";
|
|
250
|
+
const count = preflight.lanes.length;
|
|
251
|
+
const labels = count === 1
|
|
252
|
+
? planLaneLabel(preflight.lanes[0]!)
|
|
253
|
+
: preflight.lanes.slice(0, 4).map((lane) => lane.key).join(", ") + (count > 4 ? `, +${count - 4}` : "");
|
|
254
|
+
return `${indent}Plan: ${count} lane${count === 1 ? "" : "s"}${labels ? ` · ${labels}` : ""}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Render a bounded, non-alarming warning for routine status views. */
|
|
258
|
+
export function formatWorkflowPreflightWarningSummary(warnings: readonly string[] | undefined, options: { indent?: string; hint?: string } = {}): string {
|
|
259
|
+
if (!warnings?.length) return "";
|
|
260
|
+
const indent = options.indent ?? "";
|
|
261
|
+
const hint = options.hint ?? "details available for debug";
|
|
262
|
+
const count = warnings.length;
|
|
263
|
+
return `${indent}Plan note: ${count} preflight mismatch${count === 1 ? "" : "es"} · ${hint}.`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function formatWorkflowPreflightWarnings(warnings: readonly string[] | undefined, options: { indent?: string } = {}): string {
|
|
267
|
+
if (!warnings?.length) return "";
|
|
268
|
+
const indent = options.indent ?? "";
|
|
269
|
+
return [`${indent}Preflight warnings:`, ...warnings.map((warning) => `${indent} - ${warning.replace(/\s+/g, " ").trim()}`)].join("\n");
|
|
270
|
+
}
|
|
@@ -4,11 +4,14 @@ import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
|
|
|
4
4
|
import type { ExternalCliReceiptMetadata, WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState, WorkflowRecoveryAction, WorkflowTerminalOutcome, WorkflowTerminalResolution } from "../shared/types.ts";
|
|
5
5
|
import type { WorkflowReceiptResumeReference, WorkflowScriptChildResult } from "./scripted-workflow.ts";
|
|
6
6
|
import { parseWorkflowChildSummary } from "./workflow-child-summary.ts";
|
|
7
|
+
import { HOST_STEP_MAX_COUNT, assertUniqueHostStepIds, parseHostStepNode } from "../runs/shared/host-step-status.ts";
|
|
8
|
+
import { assertWorkflowLaneKey, normalizeWorkflowLaneMetadata } from "../runs/shared/lane-metadata.ts";
|
|
7
9
|
|
|
8
10
|
export type { WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState } from "../shared/types.ts";
|
|
9
11
|
|
|
10
12
|
export const WORKFLOW_RECEIPT_VERSION = 1;
|
|
11
13
|
export const WORKFLOW_RECEIPT_FILE = "workflow-receipt.json";
|
|
14
|
+
const MAX_WORKFLOW_RECEIPT_BYTES = 2 * 1024 * 1024;
|
|
12
15
|
|
|
13
16
|
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
14
17
|
|
|
@@ -33,6 +36,7 @@ export function buildWorkflowReceipt(input: {
|
|
|
33
36
|
workflowRunId: string;
|
|
34
37
|
state: WorkflowReceiptState;
|
|
35
38
|
children: WorkflowScriptChildResult[];
|
|
39
|
+
hostSteps?: WorkflowReceipt["hostSteps"];
|
|
36
40
|
workflowChildren?: WorkflowReceipt["workflowChildren"];
|
|
37
41
|
terminalOutcome?: WorkflowTerminalOutcome;
|
|
38
42
|
createdAt?: number;
|
|
@@ -43,12 +47,15 @@ export function buildWorkflowReceipt(input: {
|
|
|
43
47
|
for (const child of input.children) {
|
|
44
48
|
const key = assertKey(child.key, "workflow receipt child key");
|
|
45
49
|
if (entries[key]) throw new Error(`Workflow receipt has duplicate child key '${key}'.`);
|
|
50
|
+
const lane = normalizeWorkflowLaneMetadata(child.lane, `workflow receipt child '${key}'.lane`);
|
|
51
|
+
assertWorkflowLaneKey(lane, key, `workflow receipt child '${key}'.lane`);
|
|
46
52
|
const runIds = [...new Set((child.continuation?.runIds ?? (child.runId ? [child.runId] : [])).filter((runId) => typeof runId === "string" && runId.trim()).map((runId) => runId.trim()))];
|
|
47
53
|
const latestRunId = runIds.at(-1);
|
|
48
54
|
const resumability = child.resumability ?? { state: "not-resumable", reason: child.runId ? "resumability was not recorded" : "child produced no run id" };
|
|
49
55
|
if (resumability.state === "resumable" && !latestRunId) throw new Error(`Workflow receipt child '${key}' is resumable but has no retained run id.`);
|
|
50
56
|
const base = {
|
|
51
57
|
key,
|
|
58
|
+
...(lane ? { lane } : {}),
|
|
52
59
|
...(child.terminalOutcome ? { terminalOutcome: child.terminalOutcome } : {}),
|
|
53
60
|
...(child.agent ? { agent: child.agent } : {}),
|
|
54
61
|
...(child.requestedContext ? { requestedContext: child.requestedContext } : {}),
|
|
@@ -61,7 +68,10 @@ export function buildWorkflowReceipt(input: {
|
|
|
61
68
|
? { ...base, latestRunId: latestRunId!, resumability }
|
|
62
69
|
: { ...base, ...(latestRunId ? { latestRunId } : {}), resumability };
|
|
63
70
|
}
|
|
64
|
-
|
|
71
|
+
if (input.hostSteps && input.hostSteps.length > HOST_STEP_MAX_COUNT) throw new Error(`Workflow receipt has more than ${HOST_STEP_MAX_COUNT} host steps.`);
|
|
72
|
+
const hostSteps = input.hostSteps?.map((hostStep, index) => parseHostStepNode(hostStep, `workflow receipt hostSteps[${index}]`));
|
|
73
|
+
if (hostSteps) assertUniqueHostStepIds(hostSteps, "workflow receipt");
|
|
74
|
+
return { version: WORKFLOW_RECEIPT_VERSION, workflowRunId, state: input.state, createdAt: input.createdAt ?? Date.now(), entries, ...(hostSteps?.length ? { hostSteps } : {}), ...(input.workflowChildren ? { workflowChildren: input.workflowChildren } : {}), ...(input.terminalOutcome ? { terminalOutcome: input.terminalOutcome } : {}) };
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
export function writeWorkflowReceipt(asyncDir: string, receipt: WorkflowReceipt): string {
|
|
@@ -70,6 +80,26 @@ export function writeWorkflowReceipt(asyncDir: string, receipt: WorkflowReceipt)
|
|
|
70
80
|
return receiptPath;
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
function readWorkflowReceiptFile(receiptPath: string): unknown {
|
|
84
|
+
let fd: number | undefined;
|
|
85
|
+
try {
|
|
86
|
+
fd = fs.openSync(receiptPath, "r");
|
|
87
|
+
const stat = fs.fstatSync(fd);
|
|
88
|
+
if (!stat.isFile()) throw new Error("workflow receipt is not a regular file.");
|
|
89
|
+
if (stat.size > MAX_WORKFLOW_RECEIPT_BYTES) throw new Error(`workflow receipt exceeds the ${MAX_WORKFLOW_RECEIPT_BYTES}-byte limit.`);
|
|
90
|
+
const buffer = Buffer.allocUnsafe(stat.size);
|
|
91
|
+
let offset = 0;
|
|
92
|
+
while (offset < buffer.length) {
|
|
93
|
+
const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, null);
|
|
94
|
+
if (bytesRead <= 0) throw new Error("workflow receipt ended before its declared size.");
|
|
95
|
+
offset += bytesRead;
|
|
96
|
+
}
|
|
97
|
+
return JSON.parse(buffer.toString("utf-8")) as unknown;
|
|
98
|
+
} finally {
|
|
99
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
const EXTERNAL_CLI_CAPABILITIES = {
|
|
74
104
|
stop: true,
|
|
75
105
|
steer: false,
|
|
@@ -202,7 +232,9 @@ function parseEntry(value: unknown, key: string, source: string): WorkflowReceip
|
|
|
202
232
|
if (state === "not-resumable" && (typeof reason !== "string" || !reason.trim())) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' non-resumable reason is missing.`);
|
|
203
233
|
const terminalOutcome = parseTerminalOutcome(entry.terminalOutcome, `Invalid workflow receipt '${source}': entry '${key}' terminalOutcome`);
|
|
204
234
|
parseExternalCliReceiptMetadata(entry.externalAdapter, key, source);
|
|
205
|
-
|
|
235
|
+
const lane = normalizeWorkflowLaneMetadata(entry.lane, `Invalid workflow receipt '${source}': entry '${key}'.lane`);
|
|
236
|
+
assertWorkflowLaneKey(lane, key, `Invalid workflow receipt '${source}': entry '${key}'.lane`);
|
|
237
|
+
return { ...(value as WorkflowReceiptEntry), ...(lane ? { lane } : {}), ...(terminalOutcome ? { terminalOutcome } : {}) };
|
|
206
238
|
}
|
|
207
239
|
|
|
208
240
|
function parseWorkflowResolution(value: unknown, source: string): WorkflowTerminalResolution | undefined {
|
|
@@ -230,7 +262,7 @@ export function readWorkflowReceipt(asyncDirRoot: string, workflowRunId: string)
|
|
|
230
262
|
const receiptPath = workflowReceiptPath(asyncDirRoot, workflowRunId);
|
|
231
263
|
let value: unknown;
|
|
232
264
|
try {
|
|
233
|
-
value =
|
|
265
|
+
value = readWorkflowReceiptFile(receiptPath);
|
|
234
266
|
} catch (error) {
|
|
235
267
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
236
268
|
const workflowDir = path.dirname(receiptPath);
|
|
@@ -254,10 +286,17 @@ export function readWorkflowReceipt(asyncDirRoot: string, workflowRunId: string)
|
|
|
254
286
|
for (const [key, entry] of Object.entries(receipt.entries as Record<string, unknown>)) entries[assertKey(key, "workflow receipt key")] = parseEntry(entry, key, receiptPath);
|
|
255
287
|
const workflowChildren = parseWorkflowChildSummary(receipt.workflowChildren);
|
|
256
288
|
if (workflowChildren && workflowChildren.workflowRunId !== workflowRunId) throw new Error(`Workflow receipt '${receiptPath}' is stale: workflowChildren.workflowRunId does not match.`);
|
|
289
|
+
let hostSteps: NonNullable<WorkflowReceipt["hostSteps"]> | undefined;
|
|
290
|
+
if (receipt.hostSteps !== undefined) {
|
|
291
|
+
if (!Array.isArray(receipt.hostSteps)) throw new Error(`Invalid workflow receipt '${receiptPath}': hostSteps must be an array.`);
|
|
292
|
+
if (receipt.hostSteps.length > HOST_STEP_MAX_COUNT) throw new Error(`Invalid workflow receipt '${receiptPath}': hostSteps exceeds ${HOST_STEP_MAX_COUNT} entries.`);
|
|
293
|
+
hostSteps = receipt.hostSteps.map((hostStep, index) => parseHostStepNode(hostStep, `${receiptPath} hostSteps[${index}]`));
|
|
294
|
+
assertUniqueHostStepIds(hostSteps, receiptPath);
|
|
295
|
+
}
|
|
257
296
|
const workflowResolution = parseWorkflowResolution(receipt.workflowResolution, receiptPath);
|
|
258
297
|
const terminalOutcome = parseTerminalOutcome(receipt.terminalOutcome, `Invalid workflow receipt '${receiptPath}': terminalOutcome`);
|
|
259
298
|
const recovery = parseRecovery(receipt.recovery, workflowRunId, entries, receiptPath);
|
|
260
|
-
return { version: 1, workflowRunId, state: receipt.state, createdAt: receipt.createdAt, entries, ...(workflowChildren ? { workflowChildren } : {}), ...(workflowResolution ? { workflowResolution } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(recovery ? { recovery } : {}) };
|
|
299
|
+
return { version: 1, workflowRunId, state: receipt.state, createdAt: receipt.createdAt, entries, ...(hostSteps?.length ? { hostSteps } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(workflowResolution ? { workflowResolution } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(recovery ? { recovery } : {}) };
|
|
261
300
|
}
|
|
262
301
|
|
|
263
302
|
export function resolveWorkflowReceiptResumeEntry(input: {
|