pi-workflow-engine 0.10.3 → 0.12.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/.pi/extensions/pi-workflow-engine/index.ts +418 -213
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +82 -207
- package/USAGE.md +295 -21
- package/package.json +9 -9
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
7
|
+
import { BackgroundWorkflowCoordinator } from "./background-workflows.ts";
|
|
8
|
+
import { backgroundUnavailableResult, startBackgroundWorkflowTool } from "./background-workflow-tool.ts";
|
|
9
|
+
import { validateWorkflowRunId } from "./journal.ts";
|
|
10
|
+
import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "./options.ts";
|
|
11
|
+
import type { LoadedWorkflow } from "./types.ts";
|
|
12
|
+
import {
|
|
13
|
+
availableWorkflowRunActions,
|
|
14
|
+
canRelaunchWorkflowRun,
|
|
15
|
+
formatWorkflowRunDetails,
|
|
16
|
+
formatWorkflowRunHistory,
|
|
17
|
+
formatWorkflowRunSummary,
|
|
18
|
+
isWorkflowRunLifecycleAction,
|
|
19
|
+
parseWorkflowRunsCommand,
|
|
20
|
+
retainedWorkflowRunOutcome,
|
|
21
|
+
WORKFLOW_RUN_ACTIONS,
|
|
22
|
+
WORKFLOW_RUN_HISTORY_LIMIT,
|
|
23
|
+
type WorkflowRunLifecycleAction,
|
|
24
|
+
} from "./workflow-run-history.ts";
|
|
25
|
+
import { transitionWorkflowRun, type WorkflowRunRecord } from "./workflow-run-record.ts";
|
|
26
|
+
import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
|
|
27
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
28
|
+
import { emptyWorkflowUsageTotals } from "./usage.ts";
|
|
29
|
+
import {
|
|
30
|
+
WorkflowUsageLimitScheduler,
|
|
31
|
+
type WorkflowUsageLimitSchedulerClock,
|
|
32
|
+
} from "./workflow-usage-limit-scheduler.ts";
|
|
33
|
+
import { WorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
34
|
+
import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./ui/workflow-viewer-layout.ts";
|
|
35
|
+
import { completeCurrentArgument, splitArgumentPrefix } from "./command-completions.ts";
|
|
36
|
+
|
|
37
|
+
type WorkflowRunCompletionContext = Pick<ExtensionContext, "cwd" | "sessionManager">;
|
|
38
|
+
|
|
39
|
+
interface WorkflowRunControllerDependencies {
|
|
40
|
+
readonly resolveWorkflow: (name: string) => Promise<LoadedWorkflow | undefined>;
|
|
41
|
+
readonly execute: (
|
|
42
|
+
ctx: ExtensionContext,
|
|
43
|
+
name: string,
|
|
44
|
+
workflow: LoadedWorkflow,
|
|
45
|
+
options: ResolvedWorkflowRunOptions,
|
|
46
|
+
) => Promise<void>;
|
|
47
|
+
readonly storeForCwd?: (cwd: string) => WorkflowRunStore;
|
|
48
|
+
readonly schedulerClock?: WorkflowUsageLimitSchedulerClock;
|
|
49
|
+
readonly log?: (message: string) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class WorkflowRunController {
|
|
53
|
+
private readonly storeForCwd: (cwd: string) => WorkflowRunStore;
|
|
54
|
+
private readonly usageLimitScheduler: WorkflowUsageLimitScheduler;
|
|
55
|
+
private readonly log: (message: string) => void;
|
|
56
|
+
private completionContext: WorkflowRunCompletionContext | undefined;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly background: BackgroundWorkflowCoordinator,
|
|
60
|
+
private readonly dependencies: WorkflowRunControllerDependencies,
|
|
61
|
+
) {
|
|
62
|
+
this.storeForCwd = dependencies.storeForCwd ?? ((cwd) => new ProjectWorkflowRunStore(cwd));
|
|
63
|
+
this.log = dependencies.log ?? ((message) => process.stderr.write(`${message}\n`));
|
|
64
|
+
this.usageLimitScheduler = new WorkflowUsageLimitScheduler(
|
|
65
|
+
(ctx, runId, attempt) => this.autoResume(ctx, runId, attempt),
|
|
66
|
+
dependencies.schedulerClock,
|
|
67
|
+
dependencies.log,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async runSettled(ctx: ExtensionContext, runId: string): Promise<void> {
|
|
72
|
+
const record = await this.loadRecord(ctx.cwd, runId);
|
|
73
|
+
if (record && canRelaunchWorkflowRun(record)) this.usageLimitScheduler.arm(ctx, record);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async sessionStarted(ctx: ExtensionContext): Promise<void> {
|
|
77
|
+
this.completionContext = { cwd: ctx.cwd, sessionManager: ctx.sessionManager };
|
|
78
|
+
this.usageLimitScheduler.activateSession(ctx);
|
|
79
|
+
try {
|
|
80
|
+
for (const record of await this.storeForCwd(ctx.cwd).list()) {
|
|
81
|
+
if (canRelaunchWorkflowRun(record)) this.usageLimitScheduler.arm(ctx, record);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
this.log(`[workflow] provider-limit recovery could not load run history: ${unknownErrorMessage(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
sessionShutdown(ctx: Pick<ExtensionContext, "sessionManager">): void {
|
|
89
|
+
this.usageLimitScheduler.cancelSession(ctx);
|
|
90
|
+
this.completionContext = undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async handleCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
94
|
+
const command = parseWorkflowRunsCommand(args);
|
|
95
|
+
if (command.kind === "error") {
|
|
96
|
+
ctx.ui.notify(command.message, "warning");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (command.kind === "action") {
|
|
100
|
+
await this.perform(command.action, command.runId, ctx);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!ctx.hasUI) {
|
|
104
|
+
const records = await this.listRecent(ctx.cwd);
|
|
105
|
+
ctx.ui.notify(formatWorkflowRunHistory(records, this.background.activeRunIds(ctx)), "info");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await this.openRunSelector(ctx);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async inspectStoredRun(ctx: ExtensionContext, runId: string): Promise<boolean> {
|
|
112
|
+
const record = await this.loadRecord(ctx.cwd, runId);
|
|
113
|
+
if (!record) return false;
|
|
114
|
+
await this.inspect(record, ctx);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async openRunSelector(ctx: ExtensionCommandContext): Promise<void> {
|
|
119
|
+
while (true) {
|
|
120
|
+
const records = await this.listRecent(ctx.cwd);
|
|
121
|
+
const active = this.background.activeRunIds(ctx);
|
|
122
|
+
if (records.length === 0) {
|
|
123
|
+
ctx.ui.notify(formatWorkflowRunHistory(records, active), "info");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const options = records.map((record) =>
|
|
127
|
+
formatWorkflowRunSummary(record, active.has(record.runId))
|
|
128
|
+
);
|
|
129
|
+
const selected = await ctx.ui.select("Workflow Runs", options);
|
|
130
|
+
if (!selected) return;
|
|
131
|
+
const selectedIndex = options.indexOf(selected);
|
|
132
|
+
const selectedRecord = records[selectedIndex];
|
|
133
|
+
const record = selectedRecord && await this.loadRecord(ctx.cwd, selectedRecord.runId);
|
|
134
|
+
if (!record) {
|
|
135
|
+
ctx.ui.notify("The selected workflow run is no longer available.", "warning");
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const actions = availableWorkflowRunActions(record, active.has(record.runId));
|
|
139
|
+
const selectedAction = await ctx.ui.select(
|
|
140
|
+
`${record.workflow.name} · ${record.runId}`,
|
|
141
|
+
[...actions],
|
|
142
|
+
);
|
|
143
|
+
const action = actions.find((candidate) => candidate === selectedAction);
|
|
144
|
+
if (!action) continue;
|
|
145
|
+
if (action === "inspect") await this.inspect(record, ctx);
|
|
146
|
+
else await this.perform(action, record.runId, ctx);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async inspect(record: WorkflowRunRecord, ctx: ExtensionContext): Promise<void> {
|
|
151
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
152
|
+
ctx.ui.notify(
|
|
153
|
+
formatWorkflowRunDetails(record, this.background.activeRunIds(ctx).has(record.runId)),
|
|
154
|
+
"info",
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await ctx.ui.custom<void>(
|
|
159
|
+
(tui, theme, _keybindings, done) => new WorkflowInspector(
|
|
160
|
+
() => record.progress,
|
|
161
|
+
tui,
|
|
162
|
+
theme,
|
|
163
|
+
() => done(undefined),
|
|
164
|
+
{ label: `${record.state.toUpperCase()} outcome`, text: retainedWorkflowRunOutcome(record) },
|
|
165
|
+
),
|
|
166
|
+
WORKFLOW_VIEWER_OVERLAY_OPTIONS,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async perform(
|
|
171
|
+
action: WorkflowRunLifecycleAction,
|
|
172
|
+
runId: string,
|
|
173
|
+
ctx: ExtensionContext,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const record = await this.loadRecord(ctx.cwd, runId);
|
|
176
|
+
if (!record) {
|
|
177
|
+
ctx.ui.notify(`Workflow run ${runId} was not found.`, "warning");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (action === "inspect") {
|
|
181
|
+
await this.inspect(record, ctx);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const available = availableWorkflowRunActions(
|
|
185
|
+
record,
|
|
186
|
+
this.background.activeRunIds(ctx).has(record.runId),
|
|
187
|
+
);
|
|
188
|
+
if (!available.includes(action)) {
|
|
189
|
+
ctx.ui.notify(`Action ${action} is not available for ${record.state} run ${runId}.`, "warning");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
if (action === "stop") {
|
|
195
|
+
if (record.state === "paused") {
|
|
196
|
+
this.usageLimitScheduler.cancel(runId);
|
|
197
|
+
const stopped = transitionWorkflowRun(record, {
|
|
198
|
+
state: "stopped",
|
|
199
|
+
progress: record.progress,
|
|
200
|
+
usage: record.usage ?? record.progress.usage ?? {
|
|
201
|
+
agents: [],
|
|
202
|
+
totals: emptyWorkflowUsageTotals(),
|
|
203
|
+
assistantMessages: 0,
|
|
204
|
+
},
|
|
205
|
+
error: new Error("Workflow stopped by user."),
|
|
206
|
+
});
|
|
207
|
+
await this.storeForCwd(ctx.cwd).save(stopped);
|
|
208
|
+
await this.background.durableRunSettled(ctx, runId);
|
|
209
|
+
ctx.ui.notify(`Workflow run ${runId} is now stopped.`, "info");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const stopped = await this.background.stop(ctx, runId);
|
|
213
|
+
ctx.ui.notify(`Workflow run ${runId} is now ${stopped.state}.`, "info");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this.usageLimitScheduler.cancel(runId);
|
|
217
|
+
const message = await this.relaunch(ctx, record, action);
|
|
218
|
+
ctx.ui.notify(message, "info");
|
|
219
|
+
} catch (error) {
|
|
220
|
+
ctx.ui.notify(`Workflow ${action} failed: ${unknownErrorMessage(error)}`, "error");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async relaunch(
|
|
225
|
+
ctx: ExtensionContext,
|
|
226
|
+
record: WorkflowRunRecord,
|
|
227
|
+
action: "resume" | "restart",
|
|
228
|
+
): Promise<string> {
|
|
229
|
+
const unavailable = backgroundUnavailableResult(ctx.mode);
|
|
230
|
+
if (unavailable) {
|
|
231
|
+
const first = unavailable.content[0];
|
|
232
|
+
throw new Error(first?.type === "text" ? first.text : "background workflows are unavailable");
|
|
233
|
+
}
|
|
234
|
+
const workflow = await this.dependencies.resolveWorkflow(record.workflow.name);
|
|
235
|
+
if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
|
|
236
|
+
if (workflow.source.kind !== "file") {
|
|
237
|
+
throw new Error(`registered workflow ${record.workflow.name} no longer has verifiable file provenance`);
|
|
238
|
+
}
|
|
239
|
+
if (
|
|
240
|
+
action === "resume"
|
|
241
|
+
&& workflow.source.fingerprint !== record.workflow.sourceFingerprint
|
|
242
|
+
) {
|
|
243
|
+
throw new Error("workflow source changed, so journal replay cannot resume safely");
|
|
244
|
+
}
|
|
245
|
+
const options = resolveWorkflowRunOptions({
|
|
246
|
+
inspect: false,
|
|
247
|
+
perf: record.options.perf,
|
|
248
|
+
concurrency: record.options.concurrency,
|
|
249
|
+
parallelSubmissionLimit: record.options.parallelSubmissionLimit ?? undefined,
|
|
250
|
+
maxAgents: record.options.maxAgents,
|
|
251
|
+
agentTimeoutMs: record.options.agentTimeoutMs,
|
|
252
|
+
agentRetries: record.options.agentRetries,
|
|
253
|
+
autoResumeOnUsageLimit: record.options.autoResumeOnUsageLimit,
|
|
254
|
+
usageLimitMaxAttempts: record.options.usageLimitMaxAttempts,
|
|
255
|
+
usageLimitMaxDelayMs: record.options.usageLimitMaxDelayMs,
|
|
256
|
+
usageLimitAttempt: action === "resume"
|
|
257
|
+
? (record.state === "paused" ? record.pause?.attempt : undefined) ?? record.options.usageLimitAttempt
|
|
258
|
+
: 0,
|
|
259
|
+
budget: record.options.budget ?? undefined,
|
|
260
|
+
resultViewer: "skip",
|
|
261
|
+
resumeFromRunId: action === "resume" ? record.runId : undefined,
|
|
262
|
+
});
|
|
263
|
+
const result = await startBackgroundWorkflowTool({
|
|
264
|
+
coordinator: this.background,
|
|
265
|
+
ctx,
|
|
266
|
+
name: workflow.meta.name,
|
|
267
|
+
options,
|
|
268
|
+
execute: (backgroundCtx, backgroundOptions) =>
|
|
269
|
+
this.dependencies.execute(backgroundCtx, workflow.meta.name, workflow, backgroundOptions),
|
|
270
|
+
});
|
|
271
|
+
const first = result.content[0];
|
|
272
|
+
const message = first?.type === "text" ? first.text : `Workflow ${action} started.`;
|
|
273
|
+
if (typeof result.details.error === "string") throw new Error(message);
|
|
274
|
+
return message;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async autoResume(ctx: ExtensionContext, runId: string, attempt: number): Promise<void> {
|
|
278
|
+
const record = await this.loadRecord(ctx.cwd, runId);
|
|
279
|
+
if (
|
|
280
|
+
record?.state !== "paused"
|
|
281
|
+
|| record.pause?.kind !== "provider_usage_limit"
|
|
282
|
+
|| !record.pause.autoResume
|
|
283
|
+
|| record.pause.attempt !== attempt
|
|
284
|
+
|| !canRelaunchWorkflowRun(record)
|
|
285
|
+
) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const message = await this.relaunch(ctx, record, "resume");
|
|
289
|
+
ctx.ui.notify(message, "info");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async loadRecord(cwd: string, runId: string): Promise<WorkflowRunRecord | undefined> {
|
|
293
|
+
try {
|
|
294
|
+
validateWorkflowRunId(runId);
|
|
295
|
+
} catch {
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
return await this.storeForCwd(cwd).load(runId);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async listRecent(cwd: string): Promise<WorkflowRunRecord[]> {
|
|
302
|
+
const records = await this.storeForCwd(cwd).list();
|
|
303
|
+
return records
|
|
304
|
+
.sort((left, right) => right.createdAt - left.createdAt)
|
|
305
|
+
.slice(0, WORKFLOW_RUN_HISTORY_LIMIT);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async argumentCompletions(argumentPrefix: string): Promise<AutocompleteItem[] | null> {
|
|
309
|
+
const ctx = this.completionContext;
|
|
310
|
+
const parts = splitArgumentPrefix(argumentPrefix);
|
|
311
|
+
if (parts.completed.length === 0) {
|
|
312
|
+
return completeCurrentArgument(argumentPrefix, WORKFLOW_RUN_ACTIONS);
|
|
313
|
+
}
|
|
314
|
+
if (parts.completed.length !== 1 || !ctx) return null;
|
|
315
|
+
const action = parts.completed[0];
|
|
316
|
+
if (!action || !isWorkflowRunLifecycleAction(action)) return null;
|
|
317
|
+
const records = await this.listRecent(ctx.cwd);
|
|
318
|
+
const active = this.background.activeRunIds(ctx);
|
|
319
|
+
return completeCurrentArgument(
|
|
320
|
+
argumentPrefix,
|
|
321
|
+
records
|
|
322
|
+
.filter((record) => availableWorkflowRunActions(record, active.has(record.runId)).includes(action))
|
|
323
|
+
.map((record) => ({
|
|
324
|
+
value: record.runId,
|
|
325
|
+
description: `${record.state} · ${record.workflow.name}`,
|
|
326
|
+
})),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async inspectorArgumentCompletions(argumentPrefix: string): Promise<AutocompleteItem[] | null> {
|
|
331
|
+
const ctx = this.completionContext;
|
|
332
|
+
const parts = splitArgumentPrefix(argumentPrefix);
|
|
333
|
+
if (parts.completed.length > 0) return null;
|
|
334
|
+
const records = ctx ? await this.listRecent(ctx.cwd) : [];
|
|
335
|
+
return completeCurrentArgument(argumentPrefix, [
|
|
336
|
+
{ value: "last", description: "Inspect the current or most recent in-session workflow" },
|
|
337
|
+
...records.map((record) => ({
|
|
338
|
+
value: record.runId,
|
|
339
|
+
description: `${record.state} · ${record.workflow.name}`,
|
|
340
|
+
})),
|
|
341
|
+
]);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function registerWorkflowRunCommand(
|
|
346
|
+
pi: Pick<ExtensionAPI, "registerCommand">,
|
|
347
|
+
controller: WorkflowRunController,
|
|
348
|
+
): void {
|
|
349
|
+
pi.registerCommand("workflow:runs", {
|
|
350
|
+
description: "List, inspect, stop, resume, or restart durable workflow runs",
|
|
351
|
+
getArgumentCompletions: (argumentPrefix) => controller.argumentCompletions(argumentPrefix),
|
|
352
|
+
handler: (args, ctx) => controller.handleCommand(args, ctx),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { formatWorkflowUsageLine } from "./usage.ts";
|
|
2
|
+
import type { WorkflowRunRecord, WorkflowRunState } from "./workflow-run-record.ts";
|
|
3
|
+
import { formatDuration } from "./ui/workflow-format.ts";
|
|
4
|
+
|
|
5
|
+
export const WORKFLOW_RUN_HISTORY_LIMIT = 50;
|
|
6
|
+
export const WORKFLOW_RUN_DETAIL_AGENT_LIMIT = 100;
|
|
7
|
+
export const WORKFLOW_RUN_OUTCOME_TEXT_LIMIT = 16_000;
|
|
8
|
+
|
|
9
|
+
export const WORKFLOW_RUN_ACTIONS = [
|
|
10
|
+
{ value: "inspect", description: "Show a retained workflow run" },
|
|
11
|
+
{ value: "stop", description: "Stop an active or paused workflow run" },
|
|
12
|
+
{ value: "resume", description: "Resume a paused workflow run" },
|
|
13
|
+
{ value: "restart", description: "Restart a terminal workflow run" },
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type WorkflowRunLifecycleAction = typeof WORKFLOW_RUN_ACTIONS[number]["value"];
|
|
17
|
+
|
|
18
|
+
const WORKFLOW_RUN_ACTION_VALUES: ReadonlySet<string> = new Set(
|
|
19
|
+
WORKFLOW_RUN_ACTIONS.map((action) => action.value),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export type WorkflowRunsCommand =
|
|
23
|
+
| { readonly kind: "list" }
|
|
24
|
+
| { readonly kind: "action"; readonly action: WorkflowRunLifecycleAction; readonly runId: string }
|
|
25
|
+
| { readonly kind: "error"; readonly message: string };
|
|
26
|
+
|
|
27
|
+
export function parseWorkflowRunsCommand(input: string): WorkflowRunsCommand {
|
|
28
|
+
const parts = input.trim().split(/\s+/).filter(Boolean);
|
|
29
|
+
if (parts.length === 0) return { kind: "list" };
|
|
30
|
+
const action = parts[0];
|
|
31
|
+
if (!action || !isWorkflowRunLifecycleAction(action)) {
|
|
32
|
+
return { kind: "error", message: workflowRunsUsage() };
|
|
33
|
+
}
|
|
34
|
+
if (parts.length !== 2 || !parts[1]) return { kind: "error", message: workflowRunsUsage() };
|
|
35
|
+
return { kind: "action", action, runId: parts[1] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isWorkflowRunLifecycleAction(value: string): value is WorkflowRunLifecycleAction {
|
|
39
|
+
return WORKFLOW_RUN_ACTION_VALUES.has(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function workflowRunsUsage(): string {
|
|
43
|
+
return `Usage: /workflow:runs [${WORKFLOW_RUN_ACTIONS.map((action) => action.value).join("|")} <run-id>]`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function availableWorkflowRunActions(
|
|
47
|
+
record: WorkflowRunRecord,
|
|
48
|
+
active: boolean,
|
|
49
|
+
): readonly WorkflowRunLifecycleAction[] {
|
|
50
|
+
const actions: WorkflowRunLifecycleAction[] = ["inspect"];
|
|
51
|
+
if ((record.state === "queued" || record.state === "running") && active) actions.push("stop");
|
|
52
|
+
if (record.state === "paused") {
|
|
53
|
+
actions.push("stop");
|
|
54
|
+
if (canRelaunchWorkflowRun(record)) actions.push("resume");
|
|
55
|
+
}
|
|
56
|
+
if (
|
|
57
|
+
(record.state === "completed" || record.state === "failed" || record.state === "stopped")
|
|
58
|
+
&& canRelaunchWorkflowRun(record)
|
|
59
|
+
) {
|
|
60
|
+
actions.push("restart");
|
|
61
|
+
}
|
|
62
|
+
return actions;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function canRelaunchWorkflowRun(record: WorkflowRunRecord): boolean {
|
|
66
|
+
return record.workflow.sourceKind === "file"
|
|
67
|
+
&& typeof record.workflow.sourceFingerprint === "string"
|
|
68
|
+
&& record.options.argumentsPresent === false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatWorkflowRunHistory(
|
|
72
|
+
records: readonly WorkflowRunRecord[],
|
|
73
|
+
activeRunIds: ReadonlySet<string> = new Set(),
|
|
74
|
+
now = Date.now(),
|
|
75
|
+
): string {
|
|
76
|
+
if (records.length === 0) return "No durable workflow runs are available for this project.";
|
|
77
|
+
const lines = ["Recent workflow runs:"];
|
|
78
|
+
for (const record of records.slice(0, WORKFLOW_RUN_HISTORY_LIMIT)) {
|
|
79
|
+
lines.push(`- ${formatWorkflowRunSummary(record, activeRunIds.has(record.runId), now)}`);
|
|
80
|
+
}
|
|
81
|
+
if (records.length > WORKFLOW_RUN_HISTORY_LIMIT) {
|
|
82
|
+
lines.push(`… ${records.length - WORKFLOW_RUN_HISTORY_LIMIT} older runs hidden`);
|
|
83
|
+
}
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatWorkflowRunSummary(
|
|
88
|
+
record: WorkflowRunRecord,
|
|
89
|
+
active: boolean,
|
|
90
|
+
now = Date.now(),
|
|
91
|
+
): string {
|
|
92
|
+
const usage = formatWorkflowUsageLine(record.usage);
|
|
93
|
+
const actions = availableWorkflowRunActions(record, active).filter((action) => action !== "inspect");
|
|
94
|
+
return `${workflowRunStateLabel(record.state)} ${record.workflow.name} · age ${formatDuration(Math.max(0, now - record.createdAt))} · duration ${formatWorkflowRunDuration(record, now)}${usage ? ` · ${usage}` : ""} · ${record.runId}${actions.length > 0 ? ` · actions ${actions.join(", ")}` : ""}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatWorkflowRunDetails(
|
|
98
|
+
record: WorkflowRunRecord,
|
|
99
|
+
active: boolean,
|
|
100
|
+
now = Date.now(),
|
|
101
|
+
): string {
|
|
102
|
+
const agents = record.progress.phases.flatMap((phase) =>
|
|
103
|
+
phase.agents.map((agent) => `${phase.title} / ${agent.label}: ${agent.status}`)
|
|
104
|
+
);
|
|
105
|
+
const shownAgents = agents.slice(0, WORKFLOW_RUN_DETAIL_AGENT_LIMIT);
|
|
106
|
+
const usage = formatWorkflowUsageLine(record.usage);
|
|
107
|
+
const lines = [
|
|
108
|
+
`Workflow run ${record.runId}`,
|
|
109
|
+
`Workflow: ${record.workflow.name}`,
|
|
110
|
+
`State: ${workflowRunStateLabel(record.state)}`,
|
|
111
|
+
`Age: ${formatDuration(Math.max(0, now - record.createdAt))}`,
|
|
112
|
+
`Duration: ${formatWorkflowRunDuration(record, now)}`,
|
|
113
|
+
`Phase: ${record.progress.currentPhase}`,
|
|
114
|
+
`Actions: ${availableWorkflowRunActions(record, active).join(", ")}`,
|
|
115
|
+
];
|
|
116
|
+
if (usage) lines.push(usage);
|
|
117
|
+
if (record.options.resumeEditedWorkflow) lines.push("Edited-workflow resume: enabled");
|
|
118
|
+
const cachedAgents = record.progress.counters.find((counter) => counter.key === "resume.cached")?.value ?? 0;
|
|
119
|
+
const liveAgents = record.progress.counters.find((counter) => counter.key === "resume.live")?.value ?? 0;
|
|
120
|
+
if (cachedAgents > 0 || liveAgents > 0) lines.push(`Resume calls: ${cachedAgents} cached, ${liveAgents} live`);
|
|
121
|
+
if (record.state === "paused" && record.pause?.kind === "provider_usage_limit") {
|
|
122
|
+
lines.push(
|
|
123
|
+
`Provider limit attempt: ${record.pause.attempt}/${record.pause.maxAttempts}`,
|
|
124
|
+
`Next eligible: ${new Date(record.pause.nextEligibleAt).toISOString()}`,
|
|
125
|
+
`Automatic resume: ${record.pause.autoResume ? "scheduled" : "disabled"}`,
|
|
126
|
+
`Provider message: ${record.pause.providerMessage}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (shownAgents.length > 0) {
|
|
130
|
+
lines.push("Agents:", ...shownAgents.map((agent) => `- ${agent}`));
|
|
131
|
+
if (agents.length > shownAgents.length) lines.push(`… ${agents.length - shownAgents.length} agents hidden`);
|
|
132
|
+
}
|
|
133
|
+
lines.push("Outcome:", retainedWorkflowRunOutcome(record));
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function retainedWorkflowRunOutcome(record: WorkflowRunRecord): string {
|
|
138
|
+
if (record.state === "completed") {
|
|
139
|
+
if (record.result.kind === "unavailable") return boundedOutcome(`Result unavailable: ${record.result.reason}`);
|
|
140
|
+
return boundedOutcome(JSON.stringify(record.result.value, null, 2));
|
|
141
|
+
}
|
|
142
|
+
if (record.state === "failed" || record.state === "stopped" || record.state === "paused") {
|
|
143
|
+
return boundedOutcome(record.message);
|
|
144
|
+
}
|
|
145
|
+
return "Run is still in progress.";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatWorkflowRunDuration(record: WorkflowRunRecord, now = Date.now()): string {
|
|
149
|
+
const start = record.startedAt ?? record.createdAt;
|
|
150
|
+
const end = record.endedAt ?? (record.state === "paused" ? record.updatedAt : now);
|
|
151
|
+
return formatDuration(Math.max(0, end - start));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function workflowRunStateLabel(state: WorkflowRunState): string {
|
|
155
|
+
return state.toUpperCase();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function boundedOutcome(value: string): string {
|
|
159
|
+
return value.length <= WORKFLOW_RUN_OUTCOME_TEXT_LIMIT
|
|
160
|
+
? value
|
|
161
|
+
: `${value.slice(0, WORKFLOW_RUN_OUTCOME_TEXT_LIMIT - 1)}…`;
|
|
162
|
+
}
|