pi-workflow-engine 1.1.0 → 1.2.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 +199 -78
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
- package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
- package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +114 -31
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +74 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
- package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/types.ts +44 -9
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +17 -6
- package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
- package/README.md +11 -3
- package/USAGE.md +121 -22
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +160 -0
|
@@ -3,7 +3,7 @@ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/p
|
|
|
3
3
|
import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress-types.ts";
|
|
4
4
|
import { formatCount } from "../text.ts";
|
|
5
5
|
import { compactUsage, dot, fit, GLYPH, joinParts, keyHints, progressBar, spread, type ThemeColor } from "./kit.ts";
|
|
6
|
-
import { countAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
|
|
6
|
+
import { countAgents, finishedAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
|
|
7
7
|
|
|
8
8
|
/** pi caps string widgets at this many rows; the component keeps to the same budget. */
|
|
9
9
|
const MAX_WIDGET_LINES = 10;
|
|
@@ -64,7 +64,7 @@ export class WidthAwareWidget implements Component {
|
|
|
64
64
|
/** `◆ background · code-review 56b57550 · Find 6/10 · 12s`: one line for a run the user sent to the background. */
|
|
65
65
|
export function renderBackgroundWorkflowLine(snapshot: WorkflowProgressSnapshot, width: number, theme: Theme): string[] {
|
|
66
66
|
const counts = countAgents(snapshot.phases);
|
|
67
|
-
const progress = counts.total > 0 ? `${snapshot.currentPhase} ${counts
|
|
67
|
+
const progress = counts.total > 0 ? `${snapshot.currentPhase} ${finishedAgents(counts)}/${counts.total}` : snapshot.currentPhase;
|
|
68
68
|
const line = joinParts([
|
|
69
69
|
`${theme.fg("accent", GLYPH.workflow)} ${theme.fg("muted", "background")}`,
|
|
70
70
|
`${theme.bold(snapshot.title)} ${theme.fg("dim", snapshot.runId.slice(0, 8))}`,
|
|
@@ -92,7 +92,7 @@ function classifyPhases(snapshot: WorkflowProgressSnapshot): ClassifiedPhases {
|
|
|
92
92
|
|
|
93
93
|
function headerLine(snapshot: WorkflowProgressSnapshot, counts: WorkflowStatusCounts, width: number, now: number, theme: Theme): string {
|
|
94
94
|
const name = `${theme.fg("accent", GLYPH.workflow)} ${theme.bold(snapshot.title)}`;
|
|
95
|
-
const finished = counts
|
|
95
|
+
const finished = finishedAgents(counts);
|
|
96
96
|
const stats = joinParts([
|
|
97
97
|
counts.total > 0 ? theme.fg("text", `${finished}/${counts.total}`) : undefined,
|
|
98
98
|
theme.fg("muted", formatElapsed(now - snapshot.startedAt)),
|
|
@@ -156,8 +156,8 @@ function phaseBody(
|
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
158
|
* Spread the row budget over active phases by importance — a reserved share for failures so they always
|
|
159
|
-
* show, then running, remaining failures, done, queued — while each phase lists its chosen rows
|
|
160
|
-
*
|
|
159
|
+
* show, then running, remaining failures, skipped, done, queued — while each phase lists its chosen rows
|
|
160
|
+
* in that same status order.
|
|
161
161
|
*/
|
|
162
162
|
function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<PhaseSnapshot, AgentRowPlan> {
|
|
163
163
|
const byStatus = (status: AgentRowSnapshot["status"]) =>
|
|
@@ -177,10 +177,11 @@ function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<Ph
|
|
|
177
177
|
take(failed, failed.length > 0 ? Math.max(1, Math.floor(budget / 3)) : 0);
|
|
178
178
|
take(byStatus("running"));
|
|
179
179
|
take(failed);
|
|
180
|
+
take(byStatus("skipped"));
|
|
180
181
|
take(byStatus("done"));
|
|
181
182
|
take(byStatus("queued"));
|
|
182
183
|
|
|
183
|
-
const rank = { running: 0, failed: 1,
|
|
184
|
+
const rank = { running: 0, failed: 1, skipped: 2, done: 3, queued: 4 } as const;
|
|
184
185
|
const plans = new Map<PhaseSnapshot, AgentRowPlan>();
|
|
185
186
|
for (const phase of active) {
|
|
186
187
|
const shown = phase.agents.filter((agent) => chosen.has(agent)).sort((a, b) => rank[a.status] - rank[b.status]);
|
|
@@ -196,7 +197,7 @@ function agentLabelWidth(plans: ReadonlyMap<PhaseSnapshot, AgentRowPlan>, width:
|
|
|
196
197
|
|
|
197
198
|
function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Theme): string {
|
|
198
199
|
const counts = countAgents([phase]);
|
|
199
|
-
const glyph = counts.failed === 0
|
|
200
|
+
const glyph = counts.failed === 0 && counts.skipped === 0
|
|
200
201
|
? theme.fg("success", GLYPH.done)
|
|
201
202
|
: counts.failed === counts.total ? theme.fg("error", GLYPH.failed) : theme.fg("warning", GLYPH.warning);
|
|
202
203
|
const span = phaseSpan(phase.agents);
|
|
@@ -204,17 +205,19 @@ function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Them
|
|
|
204
205
|
counts.total > 0 ? theme.fg("muted", plural(counts.total, "agent")) : undefined,
|
|
205
206
|
span === undefined ? undefined : theme.fg("muted", shortDuration(span)),
|
|
206
207
|
counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
|
|
208
|
+
counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
|
|
207
209
|
], theme);
|
|
208
210
|
return ` ${glyph} ${theme.fg("muted", fit(phase.title, titleWidth))} ${detail}`.trimEnd();
|
|
209
211
|
}
|
|
210
212
|
|
|
211
213
|
function foldedPhasesLine(phases: readonly PhaseSnapshot[], theme: Theme): string {
|
|
212
214
|
const counts = countAgents(phases);
|
|
213
|
-
const glyph = counts.failed > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
|
|
215
|
+
const glyph = counts.failed > 0 || counts.skipped > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
|
|
214
216
|
const titles = phases.map((phase) => phase.title).join(" · ");
|
|
215
217
|
const detail = joinParts([
|
|
216
218
|
theme.fg("muted", plural(counts.total, "agent")),
|
|
217
219
|
counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
|
|
220
|
+
counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
|
|
218
221
|
], theme);
|
|
219
222
|
return ` ${glyph} ${theme.fg("muted", titles)} ${detail}`;
|
|
220
223
|
}
|
|
@@ -226,6 +229,7 @@ function activePhaseLine(plan: AgentRowPlan, titleWidth: number, theme: Theme):
|
|
|
226
229
|
counts.queued > 0 ? theme.fg("muted", `${counts.queued} queued`) : undefined,
|
|
227
230
|
counts.done > 0 ? theme.fg("muted", `${counts.done} done`) : undefined,
|
|
228
231
|
counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
|
|
232
|
+
counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
|
|
229
233
|
plan.hidden > 0 ? theme.fg("dim", `+${plan.hidden} more`) : undefined,
|
|
230
234
|
], theme);
|
|
231
235
|
return ` ${theme.fg("accent", GLYPH.running)} ${theme.bold(fit(plan.phase.title, titleWidth))} ${detail}`.trimEnd();
|
|
@@ -246,6 +250,8 @@ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
|
|
|
246
250
|
return theme.fg("error", GLYPH.failed);
|
|
247
251
|
case "done":
|
|
248
252
|
return theme.fg("success", GLYPH.done);
|
|
253
|
+
case "skipped":
|
|
254
|
+
return theme.fg("warning", GLYPH.skipped);
|
|
249
255
|
case "queued":
|
|
250
256
|
return theme.fg("dim", GLYPH.queued);
|
|
251
257
|
}
|
|
@@ -264,6 +270,8 @@ function agentDetail(agent: AgentRowSnapshot, now: number, theme: Theme): string
|
|
|
264
270
|
], theme);
|
|
265
271
|
case "failed":
|
|
266
272
|
return theme.fg("error", oneLine(agent.error ?? "failed"));
|
|
273
|
+
case "skipped":
|
|
274
|
+
return theme.fg("warning", "skipped");
|
|
267
275
|
case "queued":
|
|
268
276
|
return theme.fg("dim", "queued");
|
|
269
277
|
}
|
|
@@ -20,7 +20,9 @@ export type AdvisoryVerified = IdentifiedAdvisoryCandidate & {
|
|
|
20
20
|
|
|
21
21
|
export interface LensVerificationPipelineResult {
|
|
22
22
|
verified: AdvisoryVerified[];
|
|
23
|
+
/** Every candidate the finders returned, including those over the per-lens cap. */
|
|
23
24
|
rawCandidates: number;
|
|
25
|
+
/** Candidates never verified: over the per-lens cap, duplicates, or out of scope. */
|
|
24
26
|
dropped: number;
|
|
25
27
|
coverage: AdvisoryStageCoverage[];
|
|
26
28
|
}
|
|
@@ -73,6 +75,7 @@ export async function runLensVerificationPipeline(
|
|
|
73
75
|
const { api, lenses, perLens, finderPhase = "Find", finderPrompt, verifierPrompt, boundCandidate } = options;
|
|
74
76
|
const coverage: AdvisoryStageCoverage[] = [];
|
|
75
77
|
let rawCandidates = 0;
|
|
78
|
+
let overCap = 0;
|
|
76
79
|
api.phase(finderPhase);
|
|
77
80
|
const found = await collectAdvisoryStage(api, "Find", lenses.map((lens) => ({ id: lens.label, run: async () => {
|
|
78
81
|
const result = await api.agent(finderPrompt(lens), {
|
|
@@ -80,9 +83,16 @@ export async function runLensVerificationPipeline(
|
|
|
80
83
|
tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
|
|
81
84
|
profile: "small", schema: AdvisoryCandidatesSchema,
|
|
82
85
|
});
|
|
86
|
+
rawCandidates += result.candidates.length;
|
|
87
|
+
api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: result.candidates.length });
|
|
88
|
+
// A finder can return more than the cap its prompt states; keep the first perLens and report the rest.
|
|
89
|
+
const cut = Math.max(0, result.candidates.length - perLens);
|
|
90
|
+
if (cut > 0) {
|
|
91
|
+
overCap += cut;
|
|
92
|
+
api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: cut });
|
|
93
|
+
api.log(`Dropped ${cut} ${lens.label} candidate(s) over the per-lens cap of ${perLens}`);
|
|
94
|
+
}
|
|
83
95
|
const raw = identifyCandidates(result.candidates.slice(0, perLens), lens.label);
|
|
84
|
-
rawCandidates += raw.length;
|
|
85
|
-
api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
|
|
86
96
|
const candidates = boundCandidate ? raw.flatMap((candidate) => {
|
|
87
97
|
const bounded = boundCandidate(candidate, lens);
|
|
88
98
|
return bounded ? [bounded] : [];
|
|
@@ -94,11 +104,12 @@ export async function runLensVerificationPipeline(
|
|
|
94
104
|
return candidates;
|
|
95
105
|
} })), coverage);
|
|
96
106
|
const candidates = await mergeEquivalentCandidates(api, dedupeCandidates(found.flat()));
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta:
|
|
100
|
-
api.log(`Dropped ${
|
|
107
|
+
const duplicateOrOutOfScope = rawCandidates - overCap - candidates.length;
|
|
108
|
+
if (duplicateOrOutOfScope > 0) {
|
|
109
|
+
api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: duplicateOrOutOfScope });
|
|
110
|
+
api.log(`Dropped ${duplicateOrOutOfScope} duplicate or out-of-scope candidate(s)`);
|
|
101
111
|
}
|
|
112
|
+
const dropped = overCap + duplicateOrOutOfScope;
|
|
102
113
|
api.phase("Verify");
|
|
103
114
|
const verified = await collectAdvisoryStage(api, "Verify", candidates.map((candidate) => ({
|
|
104
115
|
id: candidate.candidateId, candidate, run: async (): Promise<AdvisoryVerified> => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { sessionKey } from "./session-identity.ts";
|
|
3
|
+
import type { WorkflowMeta } from "./types.ts";
|
|
4
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
5
|
+
import { DEFAULT_WORKFLOW_SIZE_GUIDELINE, workflowSizeGuidelineLine, type WorkflowSizeGuideline } from "./workflow-settings.ts";
|
|
6
|
+
|
|
7
|
+
/** System prompt section pi wraps as `<workflows>`: the registered workflows the host may run by name. */
|
|
8
|
+
export const WORKFLOWS_SECTION = "workflows";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One line per workflow, sorted by name, then the size guideline, so the section text depends only on
|
|
12
|
+
* what is registered and configured.
|
|
13
|
+
*/
|
|
14
|
+
export function formatWorkflowCatalog(workflows: Iterable<WorkflowMeta>, sizeLine?: string): string | undefined {
|
|
15
|
+
const lines = [...workflows]
|
|
16
|
+
// Code-unit order rather than localeCompare, whose result depends on the host locale.
|
|
17
|
+
.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
|
|
18
|
+
.map((meta) => {
|
|
19
|
+
const description = meta.description.trim();
|
|
20
|
+
const whenToUse = meta.whenToUse?.trim();
|
|
21
|
+
return `- ${meta.name}${description ? `: ${description}` : ""}${whenToUse ? ` ${whenToUse}` : ""}`;
|
|
22
|
+
});
|
|
23
|
+
const blocks: string[] = [];
|
|
24
|
+
if (lines.length > 0) {
|
|
25
|
+
blocks.push(["Registered workflows the workflow tool runs by `name`, passing the target or instructions as `args` (only once the user opted into workflow orchestration):", ...lines].join("\n"));
|
|
26
|
+
}
|
|
27
|
+
if (sizeLine) blocks.push(sizeLine);
|
|
28
|
+
return blocks.length > 0 ? blocks.join("\n\n") : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WorkflowCatalogSources {
|
|
32
|
+
readonly discover: (ctx: ExtensionContext) => Promise<Iterable<WorkflowMeta>>;
|
|
33
|
+
readonly size: (ctx: ExtensionContext) => WorkflowSizeGuideline;
|
|
34
|
+
readonly log?: (message: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The catalog section for each session, built once from discovery and settings and then reused, so every
|
|
39
|
+
* run in the session sends identical text and keeps the provider's prompt cache. A new session start or a
|
|
40
|
+
* size change rebuilds it.
|
|
41
|
+
*/
|
|
42
|
+
export class WorkflowCatalogPrompt {
|
|
43
|
+
private readonly sessions = new Map<string, Promise<string | undefined>>();
|
|
44
|
+
private readonly log: (message: string) => void;
|
|
45
|
+
|
|
46
|
+
constructor(private readonly sources: WorkflowCatalogSources) {
|
|
47
|
+
this.log = sources.log ?? ((message) => process.stderr.write(`${message}\n`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
section(ctx: ExtensionContext): Promise<string | undefined> {
|
|
51
|
+
const key = sessionKey(ctx);
|
|
52
|
+
const cached = this.sessions.get(key);
|
|
53
|
+
if (cached) return cached;
|
|
54
|
+
const built = this.build(ctx);
|
|
55
|
+
this.sessions.set(key, built);
|
|
56
|
+
return built;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
forget(ctx: Pick<ExtensionContext, "sessionManager">): void {
|
|
60
|
+
this.sessions.delete(sessionKey(ctx));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The catalog only advertises workflows and a guideline; neither failure may block the prompt.
|
|
64
|
+
private async build(ctx: ExtensionContext): Promise<string | undefined> {
|
|
65
|
+
let size = DEFAULT_WORKFLOW_SIZE_GUIDELINE;
|
|
66
|
+
try {
|
|
67
|
+
size = this.sources.size(ctx);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.log(`[workflow] workflow size guideline unavailable, using ${size}: ${unknownErrorMessage(error)}`);
|
|
70
|
+
}
|
|
71
|
+
let workflows: Iterable<WorkflowMeta> = [];
|
|
72
|
+
try {
|
|
73
|
+
workflows = await this.sources.discover(ctx);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
this.log(`[workflow] workflow catalog unavailable: ${unknownErrorMessage(error)}`);
|
|
76
|
+
}
|
|
77
|
+
return formatWorkflowCatalog(workflows, workflowSizeGuidelineLine(size));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -25,6 +25,8 @@ export interface WorkflowResultEnvelope {
|
|
|
25
25
|
readonly perf?: WorkflowPerfDetails;
|
|
26
26
|
readonly runId?: string;
|
|
27
27
|
readonly resumedFromRunId?: string;
|
|
28
|
+
/** Whether an inline run's script was persisted beside its journal. */
|
|
29
|
+
readonly scriptPersisted?: boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/** A workflow result's own summary: the result itself when it is a string, else its string `summary` field. */
|
|
@@ -36,7 +38,7 @@ export function workflowResultSummary(result: unknown): string | undefined {
|
|
|
36
38
|
export type ResolvedWorkflowRunner = (
|
|
37
39
|
ctx: ExtensionContext,
|
|
38
40
|
mod: LoadedWorkflow,
|
|
39
|
-
args:
|
|
41
|
+
args: unknown,
|
|
40
42
|
options: ResolvedWorkflowRunOptions,
|
|
41
43
|
) => Promise<unknown>;
|
|
42
44
|
|
|
@@ -44,7 +46,8 @@ export interface WorkflowExecutionInput {
|
|
|
44
46
|
readonly ctx: ExtensionContext;
|
|
45
47
|
readonly name: string;
|
|
46
48
|
readonly mod: LoadedWorkflow;
|
|
47
|
-
|
|
49
|
+
/** A slash command's argument string, or a tool call's string or JSON value. */
|
|
50
|
+
readonly args: unknown;
|
|
48
51
|
readonly options: ResolvedWorkflowRunOptions;
|
|
49
52
|
readonly perfRecorder?: PerfSink;
|
|
50
53
|
readonly runResolvedWorkflow: ResolvedWorkflowRunner;
|
|
@@ -99,6 +102,7 @@ export async function executeWorkflowInvocation(input: WorkflowExecutionInput):
|
|
|
99
102
|
perf: perfSnapshot?.enabled ? { aggregates: perfSnapshot.aggregates } : undefined,
|
|
100
103
|
runId: runMetadata?.runId,
|
|
101
104
|
resumedFromRunId: runMetadata?.resumedFromRunId,
|
|
105
|
+
scriptPersisted: runMetadata?.scriptPath !== undefined,
|
|
102
106
|
};
|
|
103
107
|
}
|
|
104
108
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// A workflow's input is the string a slash command passes, or any JSON value from a tool call or
|
|
2
|
+
// `api.workflow()`. Workflows read it verbatim as `api.input` and as text as `api.args`.
|
|
3
|
+
|
|
4
|
+
/** `api.args`: a string verbatim, any other JSON value as JSON text, and "" when nothing was passed. */
|
|
5
|
+
export function workflowArgsText(input: unknown): string {
|
|
6
|
+
if (typeof input === "string") return input;
|
|
7
|
+
return JSON.stringify(input) ?? "";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Whether a run had arguments. Run records never keep them, so such a run cannot be relaunched. */
|
|
11
|
+
export function workflowInputPresent(input: unknown): boolean {
|
|
12
|
+
return input !== undefined && input !== "";
|
|
13
|
+
}
|
|
@@ -2,10 +2,14 @@ import type {
|
|
|
2
2
|
LoadedWorkflow,
|
|
3
3
|
WorkflowMeta,
|
|
4
4
|
WorkflowModule,
|
|
5
|
+
WorkflowPhaseMeta,
|
|
5
6
|
WorkflowSourceIdentity,
|
|
6
7
|
} from "./types.ts";
|
|
7
8
|
import type { WorktreeBaseline } from "./worktree.ts";
|
|
8
9
|
import { isRecord } from "./guards.ts";
|
|
10
|
+
import { isWorkflowModelProfileName } from "./model-profiles.ts";
|
|
11
|
+
|
|
12
|
+
const PHASES_SHAPE = 'meta.phases must be an array of { title: string, profile?: "small" | "medium" | "big" }';
|
|
9
13
|
|
|
10
14
|
/** Validate and normalize workflow metadata without executing workflow code. */
|
|
11
15
|
export function parseWorkflowMeta(meta: unknown): { meta: WorkflowMeta } | { reason: string } {
|
|
@@ -15,10 +19,14 @@ export function parseWorkflowMeta(meta: unknown): { meta: WorkflowMeta } | { rea
|
|
|
15
19
|
const description = meta.description;
|
|
16
20
|
if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
|
|
17
21
|
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
22
|
+
const whenToUse = meta.whenToUse;
|
|
23
|
+
if (whenToUse !== undefined && typeof whenToUse !== "string") return { reason: "meta.whenToUse must be a string when provided" };
|
|
24
|
+
|
|
25
|
+
const phases = meta.phases === undefined ? undefined : parseWorkflowPhases(meta.phases);
|
|
26
|
+
if (phases === null) return { reason: PHASES_SHAPE };
|
|
20
27
|
|
|
21
28
|
const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
|
|
29
|
+
if (whenToUse !== undefined) workflowMeta.whenToUse = whenToUse;
|
|
22
30
|
if (phases !== undefined) workflowMeta.phases = phases;
|
|
23
31
|
return { meta: workflowMeta };
|
|
24
32
|
}
|
|
@@ -50,6 +58,15 @@ function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
|
|
|
50
58
|
return typeof value === "function";
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
/** The declared phases keeping only known fields, or null when any entry is malformed. */
|
|
62
|
+
function parseWorkflowPhases(value: unknown): WorkflowPhaseMeta[] | null {
|
|
63
|
+
if (!Array.isArray(value)) return null;
|
|
64
|
+
const phases: WorkflowPhaseMeta[] = [];
|
|
65
|
+
for (const phase of value) {
|
|
66
|
+
if (!isRecord(phase) || typeof phase.title !== "string") return null;
|
|
67
|
+
const { profile } = phase;
|
|
68
|
+
if (profile !== undefined && (typeof profile !== "string" || !isWorkflowModelProfileName(profile))) return null;
|
|
69
|
+
phases.push({ title: phase.title, ...(profile === undefined ? {} : { profile }) });
|
|
70
|
+
}
|
|
71
|
+
return phases;
|
|
55
72
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { formatAdvisoryLocation, isAdvisoryReport, type AdvisoryFinding, type AdvisoryReport } from "./advisory-schema.ts";
|
|
2
3
|
import { isRecord } from "./guards.ts";
|
|
4
|
+
import { WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
5
|
+
import { workflowRunScriptFile } from "./workflow-run-record.ts";
|
|
3
6
|
import { toReviewIssues } from "./review/review-issues.ts";
|
|
4
7
|
import { formatPerfSummary } from "./perf.ts";
|
|
5
8
|
import { prettyJson } from "./text.ts";
|
|
@@ -15,19 +18,36 @@ export function formatWorkflowResultForContext(envelope: WorkflowResultEnvelope)
|
|
|
15
18
|
return `## Workflow: ${envelope.name}\n\n${formatResultForContext(envelope.result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
/** Run id, usage and perf lines appended for the host model. */
|
|
21
|
+
/** Run id, run files, usage and perf lines appended for the host model. */
|
|
19
22
|
function formatWorkflowDetailLines(envelope: WorkflowResultEnvelope): string[] {
|
|
20
|
-
const run = envelope.runId
|
|
21
|
-
? envelope.resumedFromRunId ? `Run: ${envelope.runId} (resumed from ${envelope.resumedFromRunId})` : `Run: ${envelope.runId}`
|
|
22
|
-
: undefined;
|
|
23
23
|
return [
|
|
24
|
-
|
|
24
|
+
...(envelope.runId
|
|
25
|
+
? [formatWorkflowRunLine({ runId: envelope.runId, resumedFromRunId: envelope.resumedFromRunId }), ...formatWorkflowRunFileLines(envelope.runId, envelope.scriptPersisted === true)]
|
|
26
|
+
: []),
|
|
25
27
|
formatWorkflowUsageLine(envelope.usage),
|
|
26
28
|
envelope.perf ? formatPerfSummary(envelope.perf.aggregates) : undefined,
|
|
27
29
|
].filter((line): line is string => line !== undefined);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
/** The run id, naming the run a resume continues. */
|
|
33
|
+
export function formatWorkflowRunLine(run: { readonly runId: string; readonly resumedFromRunId?: string }): string {
|
|
34
|
+
return run.resumedFromRunId ? `Run: ${run.runId} (resumed from ${run.resumedFromRunId})` : `Run: ${run.runId}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where the host model can read what the run did, relative to the workflow cwd: the persisted script it
|
|
39
|
+
* can edit and rerun with `scriptPath`, and the replay journal of completed agent results.
|
|
40
|
+
*/
|
|
41
|
+
export function formatWorkflowRunFileLines(runId: string, scriptPersisted: boolean): string[] {
|
|
42
|
+
return [
|
|
43
|
+
...(scriptPersisted ? [`Script: ${join(WORKFLOW_RUNS_DIR, workflowRunScriptFile(runId))}`] : []),
|
|
44
|
+
`Journal: ${join(WORKFLOW_RUNS_DIR, `${runId}.jsonl`)}`,
|
|
45
|
+
"If the result is empty or unexpected, read the journal before diagnosing.",
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A workflow result as the host model reads it: advisory reports as findings, other values as bounded JSON. */
|
|
50
|
+
export function formatResultForContext(result: unknown): string {
|
|
31
51
|
if (typeof result === "string") return result;
|
|
32
52
|
if (isAdvisoryReport(result)) return formatAdvisoryReportForContext(result);
|
|
33
53
|
const summary = workflowResultSummary(result);
|
|
@@ -23,6 +23,7 @@ export function createPersistedWorkflowBackground(
|
|
|
23
23
|
origin: {
|
|
24
24
|
sessionId: truncateText(origin.sessionId, MAX_BACKGROUND_TEXT),
|
|
25
25
|
requestedAt: origin.requestedAt,
|
|
26
|
+
...(origin.wakeOnComplete ? { wakeOnComplete: true } : {}),
|
|
26
27
|
},
|
|
27
28
|
delivery: { state: "pending" },
|
|
28
29
|
};
|
|
@@ -49,6 +50,7 @@ export function updateWorkflowRunDelivery(
|
|
|
49
50
|
export function isPersistedWorkflowBackground(value: unknown): value is PersistedWorkflowBackground {
|
|
50
51
|
if (!isRecord(value) || !isRecord(value.origin) || !isRecord(value.delivery)) return false;
|
|
51
52
|
if (typeof value.origin.sessionId !== "string" || !isFiniteNumber(value.origin.requestedAt)) return false;
|
|
53
|
+
if (value.origin.wakeOnComplete !== undefined && typeof value.origin.wakeOnComplete !== "boolean") return false;
|
|
52
54
|
switch (value.delivery.state) {
|
|
53
55
|
case "pending":
|
|
54
56
|
return true;
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
7
7
|
import { BackgroundWorkflowCoordinator } from "./background-workflows.ts";
|
|
8
8
|
import { backgroundUnavailableResult, startBackgroundWorkflowTool } from "./background-workflow-tool.ts";
|
|
9
|
-
import { validateWorkflowRunId } from "./journal.ts";
|
|
9
|
+
import { validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
10
10
|
import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "./options.ts";
|
|
11
11
|
import type { LoadedWorkflow } from "./types.ts";
|
|
12
12
|
import {
|
|
@@ -17,15 +17,19 @@ import {
|
|
|
17
17
|
formatWorkflowRunSummary,
|
|
18
18
|
isWorkflowRunLifecycleAction,
|
|
19
19
|
parseWorkflowRunsCommand,
|
|
20
|
+
isInlineWorkflowRun,
|
|
20
21
|
retainedWorkflowRunOutcome,
|
|
21
22
|
supersedingRuns,
|
|
22
23
|
WORKFLOW_RUN_ACTIONS,
|
|
23
24
|
WORKFLOW_RUN_HISTORY_LIMIT,
|
|
25
|
+
workflowRunDisplayName,
|
|
24
26
|
type WorkflowRunActionContext,
|
|
25
27
|
type WorkflowRunLifecycleAction,
|
|
26
28
|
} from "./workflow-run-history.ts";
|
|
27
|
-
import { stopWorkflowRunRecord, type WorkflowRunRecord } from "./workflow-run-record.ts";
|
|
28
|
-
import {
|
|
29
|
+
import { stopWorkflowRunRecord, workflowRunScriptFile, type WorkflowRunRecord } from "./workflow-run-record.ts";
|
|
30
|
+
import { verifyWorkflowRunScriptSeal } from "./workflow-run-seal.ts";
|
|
31
|
+
import { ProjectWorkflowRunStore, readWorkflowRunScript, type WorkflowRunStore } from "./workflow-run-store.ts";
|
|
32
|
+
import { compileInlineWorkflow, inlineScriptFingerprint } from "./inline-workflow.ts";
|
|
29
33
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
30
34
|
import {
|
|
31
35
|
WorkflowUsageLimitScheduler,
|
|
@@ -39,7 +43,8 @@ import { completeCurrentArgument, splitArgumentPrefix } from "./command-completi
|
|
|
39
43
|
type WorkflowRunCompletionContext = Pick<ExtensionContext, "cwd" | "sessionManager">;
|
|
40
44
|
|
|
41
45
|
interface WorkflowRunControllerDependencies {
|
|
42
|
-
|
|
46
|
+
/** The registered workflow a relaunch runs; `ctx` decides which project's saved workflows are visible. */
|
|
47
|
+
readonly resolveWorkflow: (name: string, ctx: ExtensionContext) => Promise<LoadedWorkflow | undefined>;
|
|
43
48
|
readonly execute: (
|
|
44
49
|
ctx: ExtensionContext,
|
|
45
50
|
name: string,
|
|
@@ -160,7 +165,7 @@ export class WorkflowRunController {
|
|
|
160
165
|
}
|
|
161
166
|
const actions = availableWorkflowRunActions(record, runs);
|
|
162
167
|
const selectedAction = await ctx.ui.select(
|
|
163
|
-
`${record
|
|
168
|
+
`${workflowRunDisplayName(record)} · ${record.runId}`,
|
|
164
169
|
[...actions],
|
|
165
170
|
);
|
|
166
171
|
const action = actions.find((candidate) => candidate === selectedAction);
|
|
@@ -232,14 +237,10 @@ export class WorkflowRunController {
|
|
|
232
237
|
): Promise<string> {
|
|
233
238
|
const unavailable = backgroundUnavailableResult(ctx.mode);
|
|
234
239
|
if (unavailable) throw new Error(unavailable.content[0].text);
|
|
235
|
-
const workflow = await this.
|
|
236
|
-
if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
|
|
237
|
-
if (workflow.source.kind !== "file") {
|
|
238
|
-
throw new Error(`registered workflow ${record.workflow.name} no longer has verifiable file provenance`);
|
|
239
|
-
}
|
|
240
|
+
const { workflow, fingerprint } = await this.relaunchWorkflow(ctx, record);
|
|
240
241
|
if (
|
|
241
242
|
action === "resume"
|
|
242
|
-
&&
|
|
243
|
+
&& fingerprint !== record.workflow.sourceFingerprint
|
|
243
244
|
) {
|
|
244
245
|
throw new Error("workflow source changed, so journal replay cannot resume safely");
|
|
245
246
|
}
|
|
@@ -264,6 +265,8 @@ export class WorkflowRunController {
|
|
|
264
265
|
ctx,
|
|
265
266
|
name: workflow.meta.name,
|
|
266
267
|
options,
|
|
268
|
+
// A relaunch continues the original run, so it delivers the way that run would have.
|
|
269
|
+
wakeOnComplete: record.background?.origin.wakeOnComplete === true,
|
|
267
270
|
execute: (backgroundCtx, backgroundOptions) =>
|
|
268
271
|
this.dependencies.execute(backgroundCtx, workflow.meta.name, workflow, backgroundOptions),
|
|
269
272
|
});
|
|
@@ -272,6 +275,35 @@ export class WorkflowRunController {
|
|
|
272
275
|
return message;
|
|
273
276
|
}
|
|
274
277
|
|
|
278
|
+
/** The module a relaunch runs: an inline run's persisted script, else the registered workflow of the same name. */
|
|
279
|
+
private async relaunchWorkflow(
|
|
280
|
+
ctx: ExtensionContext,
|
|
281
|
+
record: WorkflowRunRecord,
|
|
282
|
+
): Promise<{ readonly workflow: LoadedWorkflow; readonly fingerprint: string }> {
|
|
283
|
+
if (record.workflow.sourceKind === "fingerprint") {
|
|
284
|
+
if (record.scriptFile === undefined) throw new Error(`inline workflow ${record.workflow.name} did not retain its script`);
|
|
285
|
+
const fingerprint = await sealedFingerprint(record);
|
|
286
|
+
let source: string;
|
|
287
|
+
try {
|
|
288
|
+
source = await readWorkflowRunScript(ctx.cwd, record.runId);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
throw new Error(`inline workflow script ${record.scriptFile} is unavailable: ${unknownErrorMessage(error)}`);
|
|
291
|
+
}
|
|
292
|
+
if (inlineScriptFingerprint(source) !== fingerprint) throw new Error(unsealedRunMessage(record));
|
|
293
|
+
return { workflow: compileInlineWorkflow(source), fingerprint };
|
|
294
|
+
}
|
|
295
|
+
const workflow = await this.dependencies.resolveWorkflow(record.workflow.name, ctx);
|
|
296
|
+
if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
|
|
297
|
+
if (workflow.source.kind !== "file") {
|
|
298
|
+
throw new Error(`registered workflow ${record.workflow.name} no longer has verifiable file provenance`);
|
|
299
|
+
}
|
|
300
|
+
// A repository can also ship a saved script under the name a forged record claims.
|
|
301
|
+
if (workflow.source.selfContained === true && (await sealedFingerprint(record)) !== workflow.source.fingerprint) {
|
|
302
|
+
throw new Error(unsealedRunMessage(record));
|
|
303
|
+
}
|
|
304
|
+
return { workflow, fingerprint: workflow.source.fingerprint };
|
|
305
|
+
}
|
|
306
|
+
|
|
275
307
|
private async autoResume(ctx: ExtensionContext, runId: string, attempt: number): Promise<void> {
|
|
276
308
|
const record = await this.loadRecord(ctx.cwd, runId);
|
|
277
309
|
if (
|
|
@@ -326,7 +358,7 @@ export class WorkflowRunController {
|
|
|
326
358
|
.filter((record) => availableWorkflowRunActions(record, runs).includes(action))
|
|
327
359
|
.map((record) => ({
|
|
328
360
|
value: record.runId,
|
|
329
|
-
description: `${record.state} · ${record
|
|
361
|
+
description: `${record.state} · ${workflowRunDisplayName(record)}`,
|
|
330
362
|
})),
|
|
331
363
|
);
|
|
332
364
|
}
|
|
@@ -340,12 +372,36 @@ export class WorkflowRunController {
|
|
|
340
372
|
{ value: "last", description: "Inspect the current or most recent in-session workflow" },
|
|
341
373
|
...records.map((record) => ({
|
|
342
374
|
value: record.runId,
|
|
343
|
-
description: `${record.state} · ${record
|
|
375
|
+
description: `${record.state} · ${workflowRunDisplayName(record)}`,
|
|
344
376
|
})),
|
|
345
377
|
]);
|
|
346
378
|
}
|
|
347
379
|
}
|
|
348
380
|
|
|
381
|
+
/**
|
|
382
|
+
* The fingerprint a script-sourced record's seal covers. A repository can ship a record of its own, so
|
|
383
|
+
* the seal is checked before any script is read; it is this install's, over the script as recorded.
|
|
384
|
+
*/
|
|
385
|
+
async function sealedFingerprint(record: WorkflowRunRecord): Promise<string> {
|
|
386
|
+
const fingerprint = record.workflow.sourceFingerprint;
|
|
387
|
+
if (
|
|
388
|
+
fingerprint === undefined
|
|
389
|
+
|| record.scriptSeal === undefined
|
|
390
|
+
|| !(await verifyWorkflowRunScriptSeal(record.runId, fingerprint, record.scriptSeal))
|
|
391
|
+
) {
|
|
392
|
+
throw new Error(unsealedRunMessage(record));
|
|
393
|
+
}
|
|
394
|
+
return fingerprint;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Why a script-sourced record will not run, and how to run the script anyway. */
|
|
398
|
+
function unsealedRunMessage(record: WorkflowRunRecord): string {
|
|
399
|
+
const rerun = isInlineWorkflowRun(record)
|
|
400
|
+
? `rerun it with the workflow tool's scriptPath (${WORKFLOW_RUNS_DIR}/${workflowRunScriptFile(record.runId)})`
|
|
401
|
+
: `run ${record.workflow.name} again by name`;
|
|
402
|
+
return `${isInlineWorkflowRun(record) ? "inline run" : "run"} ${record.runId} was not recorded by this pi install, or its script changed since; ${rerun}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
349
405
|
/**
|
|
350
406
|
* Runs that any stored run resumed, whatever that resume's outcome. A resumed run keeps its paused
|
|
351
407
|
* record and pause attempt, so automatic resume treats the pause as spent: re-arming it after a
|
|
@@ -86,9 +86,28 @@ export function availableWorkflowRunActions(
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export function canRelaunchWorkflowRun(record: WorkflowRunRecord): boolean {
|
|
89
|
+
return hasRelaunchableWorkflowSource(record) && record.options.argumentsPresent === false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A registered workflow resolves again by name; an inline one relaunches from the script persisted beside
|
|
94
|
+
* its record, and only when the record carries a seal. This check is only the seal's presence: relaunch
|
|
95
|
+
* verifies it, and the script's fingerprint, before running anything.
|
|
96
|
+
*/
|
|
97
|
+
export function hasRelaunchableWorkflowSource(record: WorkflowRunRecord): boolean {
|
|
98
|
+
if (typeof record.workflow.sourceFingerprint !== "string") return false;
|
|
89
99
|
return record.workflow.sourceKind === "file"
|
|
90
|
-
&&
|
|
91
|
-
|
|
100
|
+
|| (isInlineWorkflowRun(record) && record.scriptFile !== undefined && record.scriptSeal !== undefined);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A run of an inline script (a tool `script` or `scriptPath`), not of a registered workflow. */
|
|
104
|
+
export function isInlineWorkflowRun(record: WorkflowRunRecord): boolean {
|
|
105
|
+
return record.workflow.sourceKind === "fingerprint";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The run's workflow name, marked when it ran an inline script, so such a record cannot pose as a registered workflow's run. */
|
|
109
|
+
export function workflowRunDisplayName(record: WorkflowRunRecord): string {
|
|
110
|
+
return isInlineWorkflowRun(record) ? `${record.workflow.name} (inline)` : record.workflow.name;
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
export function formatWorkflowRunHistory(
|
|
@@ -112,7 +131,7 @@ export function formatWorkflowRunSummary(
|
|
|
112
131
|
const usage = formatWorkflowUsageLine(record.usage);
|
|
113
132
|
const resumedAs = runs.resumedAs.get(record.runId);
|
|
114
133
|
const actions = availableWorkflowRunActions(record, runs).filter((action) => action !== "inspect");
|
|
115
|
-
return `${record.state.toUpperCase()} ${record
|
|
134
|
+
return `${record.state.toUpperCase()} ${workflowRunDisplayName(record)} · age ${formatDuration(Math.max(0, now - record.createdAt))} · duration ${formatWorkflowRunDuration(record, now)}${usage ? ` · ${usage}` : ""} · ${record.runId}${resumedAs ? ` · resumed as ${resumedAs}` : ""}${actions.length > 0 ? ` · actions ${actions.join(", ")}` : ""}`;
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
export function formatWorkflowRunDetails(
|
|
@@ -127,7 +146,7 @@ export function formatWorkflowRunDetails(
|
|
|
127
146
|
const usage = formatWorkflowUsageLine(record.usage);
|
|
128
147
|
const lines = [
|
|
129
148
|
`Workflow run ${record.runId}`,
|
|
130
|
-
`Workflow: ${record
|
|
149
|
+
`Workflow: ${workflowRunDisplayName(record)}`,
|
|
131
150
|
`State: ${record.state.toUpperCase()}`,
|
|
132
151
|
`Age: ${formatDuration(Math.max(0, now - record.createdAt))}`,
|
|
133
152
|
`Duration: ${formatWorkflowRunDuration(record, now)}`,
|