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
|
@@ -69,7 +69,9 @@ export class ProgressTracker {
|
|
|
69
69
|
private readonly lanes = new Map<string, WorkflowLaneItem[]>();
|
|
70
70
|
private readonly laneOverflow = new Map<string, number>();
|
|
71
71
|
private readonly rowsById = new Map<number, AgentRow>();
|
|
72
|
-
|
|
72
|
+
/** How to abort each running row's agent; never part of a snapshot, so it is never persisted. */
|
|
73
|
+
private readonly skipHandlers = new Map<number, () => void>();
|
|
74
|
+
private readonly agentCounts: Record<AgentRowStatus, number> = { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 };
|
|
73
75
|
private readonly startedAt = Date.now();
|
|
74
76
|
private readonly laneItemLimit = laneItemLimitFromEnv();
|
|
75
77
|
private doneAt: number | undefined;
|
|
@@ -168,12 +170,13 @@ export class ProgressTracker {
|
|
|
168
170
|
return id;
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
agentStart(id: number): void {
|
|
173
|
+
agentStart(id: number, skip?: () => void): void {
|
|
172
174
|
const row = this.rowsById.get(id);
|
|
173
175
|
if (row) {
|
|
174
176
|
this.transitionAgentStatus(row, "running");
|
|
175
177
|
row.startedAt = Date.now();
|
|
176
178
|
row.error = undefined;
|
|
179
|
+
if (skip) this.skipHandlers.set(id, skip);
|
|
177
180
|
}
|
|
178
181
|
this.publish();
|
|
179
182
|
}
|
|
@@ -188,8 +191,9 @@ export class ProgressTracker {
|
|
|
188
191
|
}
|
|
189
192
|
|
|
190
193
|
agentDone(id: number): void {
|
|
194
|
+
this.skipHandlers.delete(id);
|
|
191
195
|
const row = this.rowsById.get(id);
|
|
192
|
-
if (row && row.status !== "failed") {
|
|
196
|
+
if (row && row.status !== "failed" && row.status !== "skipped") {
|
|
193
197
|
this.transitionAgentStatus(row, "done");
|
|
194
198
|
row.doneAt = Date.now();
|
|
195
199
|
}
|
|
@@ -197,6 +201,7 @@ export class ProgressTracker {
|
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
agentFailed(id: number, error: unknown): void {
|
|
204
|
+
this.skipHandlers.delete(id);
|
|
200
205
|
const row = this.rowsById.get(id);
|
|
201
206
|
if (row) {
|
|
202
207
|
this.transitionAgentStatus(row, "failed");
|
|
@@ -206,6 +211,28 @@ export class ProgressTracker {
|
|
|
206
211
|
this.publish();
|
|
207
212
|
}
|
|
208
213
|
|
|
214
|
+
agentSkipped(id: number): void {
|
|
215
|
+
this.skipHandlers.delete(id);
|
|
216
|
+
const row = this.rowsById.get(id);
|
|
217
|
+
if (row) {
|
|
218
|
+
this.transitionAgentStatus(row, "skipped");
|
|
219
|
+
row.doneAt = Date.now();
|
|
220
|
+
}
|
|
221
|
+
this.publish();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Abort one running agent at the user's request (the inspector's skip key); false when the row is
|
|
226
|
+
* no longer running. The row turns `skipped` once the agent's session has wound down.
|
|
227
|
+
*/
|
|
228
|
+
skipAgent(id: number): boolean {
|
|
229
|
+
const skip = this.skipHandlers.get(id);
|
|
230
|
+
if (!skip || this.rowsById.get(id)?.status !== "running") return false;
|
|
231
|
+
this.skipHandlers.delete(id);
|
|
232
|
+
skip();
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
209
236
|
updateUsage(snapshot: WorkflowUsageSnapshot): void {
|
|
210
237
|
this.usageSnapshot = snapshot;
|
|
211
238
|
this.publish();
|
|
@@ -238,7 +265,8 @@ export class ProgressTracker {
|
|
|
238
265
|
running: this.agentCounts.running,
|
|
239
266
|
done: this.agentCounts.done,
|
|
240
267
|
failed: this.agentCounts.failed,
|
|
241
|
-
|
|
268
|
+
skipped: this.agentCounts.skipped,
|
|
269
|
+
total: this.agentCounts.queued + this.agentCounts.running + this.agentCounts.done + this.agentCounts.failed + this.agentCounts.skipped,
|
|
242
270
|
};
|
|
243
271
|
}
|
|
244
272
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BeforeAgentStartEventResult, NormalizedBuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type PromptSectionOptions = Pick<NormalizedBuildSystemPromptOptions, "sections" | "forceSystemPrompt">;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Finish adding the `name` section from a `before_agent_start` handler. Normally the section in
|
|
7
|
+
* `options.sections` is enough and pi appends it as a patch. An earlier handler that forced the whole
|
|
8
|
+
* prompt makes pi render no sections, so the section is appended to that text instead; pi hands the
|
|
9
|
+
* returned prompt to later handlers as their `forceSystemPrompt`, so several sections chain.
|
|
10
|
+
*/
|
|
11
|
+
export function appendSectionToForcedPrompt(options: PromptSectionOptions, name: string): BeforeAgentStartEventResult | undefined {
|
|
12
|
+
const text = options.sections[name];
|
|
13
|
+
if (options.forceSystemPrompt === undefined || text === undefined) return undefined;
|
|
14
|
+
return { systemPrompt: `${options.forceSystemPrompt}\n\n<${name}>\n${text}\n</${name}>` };
|
|
15
|
+
}
|
|
@@ -29,7 +29,8 @@ export function normalizeResearchLanes(plan: ResearchPlan): ResearchLane[] {
|
|
|
29
29
|
return lanes;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/** Distinct claims, most important first, bounded to {@link MAX_VERIFICATION_CLAIMS}; `overCap` counts the claims cut. */
|
|
33
|
+
export function buildClaimCandidates(results: readonly ResearchLaneResult[]): { candidates: ResearchClaimCandidate[]; overCap: number } {
|
|
33
34
|
const byClaim = new Map<string, { claim: string; importance: ResearchEvidence["importance"]; evidence: ResearchEvidence[] }>();
|
|
34
35
|
for (const item of results.flatMap((result) => result.evidence.map(sanitizeEvidence).filter(isDefined))) {
|
|
35
36
|
const key = item.claim.toLowerCase();
|
|
@@ -41,9 +42,8 @@ export function buildClaimCandidates(results: readonly ResearchLaneResult[]): Re
|
|
|
41
42
|
if (IMPORTANCE_RANK[item.importance] < IMPORTANCE_RANK[current.importance]) current.importance = item.importance;
|
|
42
43
|
current.evidence = dedupeEvidence([...current.evidence, item]);
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.slice(0, MAX_VERIFICATION_CLAIMS);
|
|
45
|
+
const ranked = [...byClaim.values()].sort((left, right) => IMPORTANCE_RANK[left.importance] - IMPORTANCE_RANK[right.importance]);
|
|
46
|
+
return { candidates: ranked.slice(0, MAX_VERIFICATION_CLAIMS), overCap: Math.max(0, ranked.length - MAX_VERIFICATION_CLAIMS) };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export function sanitizeVerification(
|
|
@@ -438,7 +438,7 @@ export async function captureWorkflowResumeContext(
|
|
|
438
438
|
if (mod.source.kind === "unverifiable") {
|
|
439
439
|
return unverifiableWorkflowResumeContext(mod.meta.name, mod.source.reason);
|
|
440
440
|
}
|
|
441
|
-
if (mod.source.kind === "fingerprint") {
|
|
441
|
+
if (mod.source.kind === "fingerprint" || mod.source.selfContained) {
|
|
442
442
|
return mod.source.fingerprint.length > 0
|
|
443
443
|
? { kind: "verified", name: mod.meta.name, sourceFingerprint: mod.source.fingerprint }
|
|
444
444
|
: unverifiableWorkflowResumeContext(mod.meta.name, "workflow source fingerprint is empty");
|
|
@@ -16,14 +16,23 @@ import type { WorkflowRunState } from "./workflow-run-record.ts";
|
|
|
16
16
|
|
|
17
17
|
export type { WorkflowLaneItemStatus } from "./progress-types.ts";
|
|
18
18
|
|
|
19
|
-
/** A
|
|
20
|
-
export type WorkflowRef = string;
|
|
19
|
+
/** A registered workflow by name, or an inline-format script file under the workflow cwd. */
|
|
20
|
+
export type WorkflowRef = string | { readonly scriptPath: string };
|
|
21
|
+
|
|
22
|
+
/** One declared phase of a workflow. */
|
|
23
|
+
export interface WorkflowPhaseMeta {
|
|
24
|
+
title: string;
|
|
25
|
+
/** Profile for agents that pass this title as `phase` and set neither `model` nor `profile`. */
|
|
26
|
+
profile?: WorkflowModelProfileName;
|
|
27
|
+
}
|
|
21
28
|
|
|
22
29
|
/** Metadata every workflow module must export. */
|
|
23
30
|
export interface WorkflowMeta {
|
|
24
31
|
name: string;
|
|
25
32
|
description: string;
|
|
26
|
-
|
|
33
|
+
/** When a host model should choose this workflow; listed in the system prompt's workflow catalog. */
|
|
34
|
+
whenToUse?: string;
|
|
35
|
+
phases?: WorkflowPhaseMeta[];
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
export interface WorkflowRunStats {
|
|
@@ -39,12 +48,16 @@ export interface WorkflowRunMetadata {
|
|
|
39
48
|
readonly resumedFromRunId?: string;
|
|
40
49
|
readonly journalPath: string;
|
|
41
50
|
readonly recordPath: string;
|
|
51
|
+
/** The inline run's persisted script; absent for registered workflows or when the write failed. */
|
|
52
|
+
readonly scriptPath?: string;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
export interface WorkflowBackgroundOrigin {
|
|
45
56
|
/** Stable pi session id used to route and deduplicate completion delivery. */
|
|
46
57
|
readonly sessionId: string;
|
|
47
58
|
readonly requestedAt: number;
|
|
59
|
+
/** Deliver the completion with a new agent turn, because Dynamax was active when the run started. */
|
|
60
|
+
readonly wakeOnComplete?: boolean;
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
export interface WorkflowRunOptions {
|
|
@@ -101,6 +114,8 @@ export interface WorkflowProgressSource {
|
|
|
101
114
|
snapshot(): WorkflowProgressSnapshot;
|
|
102
115
|
/** Durable lifecycle state of the run, so a finished snapshot can tell completed from failed. */
|
|
103
116
|
readonly state?: () => WorkflowRunState;
|
|
117
|
+
/** Skip one running agent by its progress row id; false when it is no longer running. */
|
|
118
|
+
readonly skipAgent?: (agentId: number) => boolean;
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
/** Options for a single `agent()` call. */
|
|
@@ -226,12 +241,14 @@ export interface WorkflowApi {
|
|
|
226
241
|
*/
|
|
227
242
|
classify(context: ClassifierContext, opts?: ClassifyOptions): Promise<Record<string, ClassifierAnswer>>;
|
|
228
243
|
/**
|
|
229
|
-
* Run another
|
|
244
|
+
* Run another workflow inline as a sub-step and return its result: a registered workflow by name, or
|
|
245
|
+
* an inline-format script file via `{ scriptPath }` relative to the workflow cwd. The child shares
|
|
230
246
|
* this run's concurrency cap, abort signal, and perf sink. Nests one level only: calling
|
|
231
|
-
* `workflow()` from within a sub-workflow rejects. Rejects on unknown name
|
|
232
|
-
* a missing sub-workflow gracefully.
|
|
247
|
+
* `workflow()` from within a sub-workflow rejects. Rejects on an unknown name, an unreadable
|
|
248
|
+
* scriptPath, or a script compile error — catch it to handle a missing sub-workflow gracefully.
|
|
249
|
+
* `args` is the child's input: a string or any JSON value, read there as `api.input` and `api.args`.
|
|
233
250
|
*/
|
|
234
|
-
workflow(ref: WorkflowRef, args?:
|
|
251
|
+
workflow(ref: WorkflowRef, args?: unknown): Promise<unknown>;
|
|
235
252
|
/**
|
|
236
253
|
* Run every thunk concurrently and wait for all (a barrier). Recoverable thunk
|
|
237
254
|
* failures become null slots by default; settled mode retains serialisable errors.
|
|
@@ -248,8 +265,13 @@ export interface WorkflowApi {
|
|
|
248
265
|
log(message: string): void;
|
|
249
266
|
/** Emit a structured progress event for native workflow UI surfaces. */
|
|
250
267
|
progress(event: WorkflowProgressEvent): void;
|
|
251
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* The invocation's arguments as text: the string passed after the workflow name, a non-string tool or
|
|
270
|
+
* `workflow()` argument as JSON text, or "" when none was given.
|
|
271
|
+
*/
|
|
252
272
|
args: string;
|
|
273
|
+
/** The invocation's arguments verbatim: the slash command's string, or the tool or `workflow()` JSON value. */
|
|
274
|
+
input: unknown;
|
|
253
275
|
/** Working directory of the host session (typically the repo root). */
|
|
254
276
|
cwd: string;
|
|
255
277
|
/**
|
|
@@ -264,8 +286,19 @@ export interface WorkflowApi {
|
|
|
264
286
|
|
|
265
287
|
export type WorkflowRun = (api: WorkflowApi) => Promise<unknown>;
|
|
266
288
|
|
|
289
|
+
/**
|
|
290
|
+
* `file` workflows resolve by name from `path` under `root`. Their `fingerprint` hashes the `root` tree,
|
|
291
|
+
* revalidated on resume because imports may load lazily, unless `selfContained` marks a compiled saved
|
|
292
|
+
* script: it can import nothing, so the hash of its own content is its whole identity.
|
|
293
|
+
*/
|
|
267
294
|
export type WorkflowSourceIdentity =
|
|
268
|
-
| {
|
|
295
|
+
| {
|
|
296
|
+
readonly kind: "file";
|
|
297
|
+
readonly path: string;
|
|
298
|
+
readonly root: string;
|
|
299
|
+
readonly fingerprint: string;
|
|
300
|
+
readonly selfContained?: true;
|
|
301
|
+
}
|
|
269
302
|
| { readonly kind: "fingerprint"; readonly fingerprint: string }
|
|
270
303
|
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
271
304
|
|
|
@@ -279,4 +312,6 @@ export interface WorkflowModule {
|
|
|
279
312
|
export interface LoadedWorkflow extends WorkflowModule {
|
|
280
313
|
readonly source: WorkflowSourceIdentity;
|
|
281
314
|
readonly isolatedWorktreeBaseline?: WorktreeBaseline;
|
|
315
|
+
/** An inline run's script source, persisted beside its run record so the run can be read and relaunched. */
|
|
316
|
+
readonly inlineScript?: string;
|
|
282
317
|
}
|
|
@@ -77,6 +77,7 @@ export interface WorkflowStatusCounts {
|
|
|
77
77
|
readonly running: number;
|
|
78
78
|
readonly done: number;
|
|
79
79
|
readonly failed: number;
|
|
80
|
+
readonly skipped: number;
|
|
80
81
|
readonly total: number;
|
|
81
82
|
}
|
|
82
83
|
|
|
@@ -96,7 +97,7 @@ export function formatWorkflowInspection(inspection: WorkflowInspectionSource):
|
|
|
96
97
|
`Workflow inspector: ${inspection.name}`,
|
|
97
98
|
`Run: ${snapshot.runId}`,
|
|
98
99
|
`Phase: ${snapshot.currentPhase}`,
|
|
99
|
-
`Agents: ${counts.running} running, ${counts.queued} queued, ${counts.done} done, ${counts.failed} failed`,
|
|
100
|
+
`Agents: ${counts.running} running, ${counts.queued} queued, ${counts.done} done, ${counts.failed} failed${counts.skipped > 0 ? `, ${counts.skipped} skipped` : ""}`,
|
|
100
101
|
];
|
|
101
102
|
const usage = formatWorkflowUsageLine(snapshot.usage);
|
|
102
103
|
if (usage) lines.push(usage);
|
|
@@ -109,7 +110,7 @@ export function statusTextFromCounts(snapshot: WorkflowProgressSnapshot, counts:
|
|
|
109
110
|
if (snapshot.doneAt !== undefined) return undefined;
|
|
110
111
|
|
|
111
112
|
const displayName = snapshot.title === "code-review" ? "review" : snapshot.title;
|
|
112
|
-
const progress = counts.total > 0 ? ` ${counts
|
|
113
|
+
const progress = counts.total > 0 ? ` ${finishedAgents(counts)}/${counts.total}` : "";
|
|
113
114
|
return [
|
|
114
115
|
`${theme.fg("accent", GLYPH.workflow)} ${theme.fg("accent", displayName)}`,
|
|
115
116
|
theme.fg("muted", `${snapshot.currentPhase}${progress}`),
|
|
@@ -117,8 +118,13 @@ export function statusTextFromCounts(snapshot: WorkflowProgressSnapshot, counts:
|
|
|
117
118
|
].join(dot(theme));
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
/** Agents that will not run again: done, failed, or skipped. */
|
|
122
|
+
export function finishedAgents(counts: WorkflowStatusCounts): number {
|
|
123
|
+
return counts.done + counts.failed + counts.skipped;
|
|
124
|
+
}
|
|
125
|
+
|
|
120
126
|
export function countAgents(phases: readonly PhaseSnapshot[]): WorkflowStatusCounts {
|
|
121
|
-
const counts = { queued: 0, running: 0, done: 0, failed: 0, total: 0 };
|
|
127
|
+
const counts = { queued: 0, running: 0, done: 0, failed: 0, skipped: 0, total: 0 };
|
|
122
128
|
for (const phase of phases) {
|
|
123
129
|
for (const agent of phase.agents) {
|
|
124
130
|
counts[agent.status]++;
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
truncatePath,
|
|
26
26
|
type ThemeColor,
|
|
27
27
|
} from "./kit.ts";
|
|
28
|
-
import { formatDuration } from "./workflow-format.ts";
|
|
28
|
+
import { countAgents, finishedAgents, formatDuration } from "./workflow-format.ts";
|
|
29
29
|
import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./workflow-viewer-layout.ts";
|
|
30
30
|
|
|
31
31
|
/** What the inspector knows about a run beyond its progress snapshot. */
|
|
@@ -40,6 +40,12 @@ export interface WorkflowInspectorOutcome {
|
|
|
40
40
|
|
|
41
41
|
type OutcomeSource = WorkflowInspectorOutcome | (() => WorkflowInspectorOutcome | undefined);
|
|
42
42
|
|
|
43
|
+
/** Actions on a live run. A retained or finished run has none, so its inspector is read-only. */
|
|
44
|
+
export interface WorkflowInspectorControls {
|
|
45
|
+
/** Skip one running agent by its progress row id; false when it is no longer running. */
|
|
46
|
+
readonly skipAgent?: (agentId: number) => boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
/** The tables need about 80 columns, so narrow terminals give the inspector nearly full width instead of 80%. */
|
|
44
50
|
export const WORKFLOW_INSPECTOR_OVERLAY_OPTIONS = {
|
|
45
51
|
overlay: true,
|
|
@@ -51,9 +57,10 @@ export async function showWorkflowInspector(
|
|
|
51
57
|
ui: Pick<ExtensionContext["ui"], "custom">,
|
|
52
58
|
snapshot: () => WorkflowProgressSnapshot,
|
|
53
59
|
outcome?: OutcomeSource,
|
|
60
|
+
controls?: WorkflowInspectorControls,
|
|
54
61
|
): Promise<void> {
|
|
55
62
|
await ui.custom<void>(
|
|
56
|
-
(...[tui, theme, , done]) => new WorkflowInspector(snapshot, tui, theme, () => done(undefined), outcome),
|
|
63
|
+
(...[tui, theme, , done]) => new WorkflowInspector(snapshot, tui, theme, () => done(undefined), outcome, controls),
|
|
57
64
|
WORKFLOW_INSPECTOR_OVERLAY_OPTIONS,
|
|
58
65
|
);
|
|
59
66
|
}
|
|
@@ -113,6 +120,12 @@ export class WorkflowInspector {
|
|
|
113
120
|
private readonly cursorIndexes = new Map<TabId, number>();
|
|
114
121
|
private readonly scrollOffsets = new Map<TabId, number>();
|
|
115
122
|
private readonly expanded = new Set<string>();
|
|
123
|
+
/** Agents whose skip was sent; they keep running until their session winds down. */
|
|
124
|
+
private readonly skipRequested = new Set<number>();
|
|
125
|
+
/** Agent armed by a first skip press; any other key disarms it. */
|
|
126
|
+
private pendingSkip: number | undefined;
|
|
127
|
+
/** One-keypress footer warning, as in the runs browser. */
|
|
128
|
+
private notice: string | undefined;
|
|
116
129
|
private refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
117
130
|
|
|
118
131
|
constructor(
|
|
@@ -121,6 +134,7 @@ export class WorkflowInspector {
|
|
|
121
134
|
private readonly theme: Theme,
|
|
122
135
|
private readonly close: () => void,
|
|
123
136
|
private readonly outcomeSource?: OutcomeSource,
|
|
137
|
+
private readonly controls?: WorkflowInspectorControls,
|
|
124
138
|
) {}
|
|
125
139
|
|
|
126
140
|
handleInput(data: string): void {
|
|
@@ -128,6 +142,10 @@ export class WorkflowInspector {
|
|
|
128
142
|
this.close();
|
|
129
143
|
return;
|
|
130
144
|
}
|
|
145
|
+
const armedSkip = this.pendingSkip;
|
|
146
|
+
const hadNotice = this.notice !== undefined;
|
|
147
|
+
this.pendingSkip = undefined;
|
|
148
|
+
this.notice = undefined;
|
|
131
149
|
const tabs = this.tabs(this.outcome());
|
|
132
150
|
const digit = TAB_DIGITS.findIndex((candidate) => matchesKey(data, candidate));
|
|
133
151
|
if (matchesKey(data, "tab") || matchesKey(data, "right")) this.tabIndex = (this.tabIndex + 1) % tabs.length;
|
|
@@ -142,7 +160,8 @@ export class WorkflowInspector {
|
|
|
142
160
|
else if (matchesKey(data, "g") || matchesKey(data, "home")) this.move(-Infinity);
|
|
143
161
|
else if (data === "G" || matchesKey(data, "shift+g") || matchesKey(data, "end")) this.move(Infinity);
|
|
144
162
|
else if (matchesKey(data, "enter") || matchesKey(data, "return")) this.toggleExpanded();
|
|
145
|
-
else
|
|
163
|
+
else if (matchesKey(data, "s")) this.skipSelected(armedSkip);
|
|
164
|
+
else if (!hadNotice) return;
|
|
146
165
|
this.tui.requestRender();
|
|
147
166
|
}
|
|
148
167
|
|
|
@@ -175,11 +194,12 @@ export class WorkflowInspector {
|
|
|
175
194
|
const title = `${this.theme.fg("accent", GLYPH.workflow)} ${this.theme.bold(snapshot.title)}`;
|
|
176
195
|
// Border chrome around the two labels: `╭─ ` + title + ` ` … ` ` + right + ` ─╮`, plus one fill cell.
|
|
177
196
|
const rightRoom = Math.max(0, width - visibleWidth(title) - 9);
|
|
197
|
+
const skippable = this.skippableAgent(active, snapshot, status) !== undefined;
|
|
178
198
|
return frame(
|
|
179
199
|
[tabLine, "", ...visible, ...Array.from({ length: viewRows - visible.length }, () => "")],
|
|
180
200
|
width,
|
|
181
201
|
this.theme,
|
|
182
|
-
{ title, right: this.headerStatus(snapshot, status, rightRoom), footer: this.footer(active, rows, viewRows, offset, below, width) },
|
|
202
|
+
{ title, right: this.headerStatus(snapshot, status, rightRoom), footer: this.footer(active, rows, viewRows, offset, below, width, skippable) },
|
|
183
203
|
);
|
|
184
204
|
}
|
|
185
205
|
|
|
@@ -250,6 +270,29 @@ export class WorkflowInspector {
|
|
|
250
270
|
else this.expanded.add(key);
|
|
251
271
|
}
|
|
252
272
|
|
|
273
|
+
/** Skipping ends live work, so like stopping a run in the runs browser it takes a second press on the same agent. */
|
|
274
|
+
private skipSelected(armed: number | undefined): void {
|
|
275
|
+
const snapshot = this.snapshotProvider();
|
|
276
|
+
const agent = this.skippableAgent(this.activeTab(), snapshot, runStatus(snapshot, this.outcome()?.state));
|
|
277
|
+
if (!agent) return;
|
|
278
|
+
if (armed !== agent.id) {
|
|
279
|
+
this.pendingSkip = agent.id;
|
|
280
|
+
this.notice = `press s again to skip ${agent.label}`;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (this.controls?.skipAgent?.(agent.id)) this.skipRequested.add(agent.id);
|
|
284
|
+
else this.notice = `${agent.label} is no longer running`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The selected agent when it can be skipped: a running row of a live run whose source accepts skips. */
|
|
288
|
+
private skippableAgent(tab: TabId, snapshot: WorkflowProgressSnapshot, status: RunStatus): AgentRowSnapshot | undefined {
|
|
289
|
+
if (!this.controls?.skipAgent || tab !== "agents" || !status.live) return undefined;
|
|
290
|
+
const agents = snapshot.phases.flatMap((phase) => phase.agents);
|
|
291
|
+
const selected = this.selectedKey("agents", agents.map(agentKey));
|
|
292
|
+
const agent = agents.find((candidate) => agentKey(candidate) === selected);
|
|
293
|
+
return agent?.status === "running" && !this.skipRequested.has(agent.id) ? agent : undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
253
296
|
/** Resolves the cursor by key so live rows inserted above it do not move the selection. */
|
|
254
297
|
private cursorIndex(tab: TabId, keys: readonly string[]): number {
|
|
255
298
|
const byKey = keys.indexOf(this.cursorKeys.get(tab) ?? "");
|
|
@@ -299,27 +342,29 @@ export class WorkflowInspector {
|
|
|
299
342
|
/** State first; lower-priority parts (run id, then elapsed) drop whole rather than truncating. */
|
|
300
343
|
private headerStatus(snapshot: WorkflowProgressSnapshot, status: RunStatus, room: number): string {
|
|
301
344
|
const th = this.theme;
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const degraded = !status.live && failed > 0 && status.color === "success";
|
|
345
|
+
const counts = countAgents(snapshot.phases);
|
|
346
|
+
// A run that finished around failed or skipped agents is complete but not clean.
|
|
347
|
+
const degraded = !status.live && counts.failed + counts.skipped > 0 && status.color === "success";
|
|
306
348
|
const state = degraded ? th.fg("warning", `${GLYPH.warning} ${status.word}`) : th.fg(status.color, `${status.glyph} ${status.word}`);
|
|
307
|
-
const failures = failed > 0 ? th.fg("error", `${failed} failed`) : undefined;
|
|
349
|
+
const failures = counts.failed > 0 ? th.fg("error", `${counts.failed} failed`) : undefined;
|
|
350
|
+
const skips = counts.skipped > 0 ? th.fg("warning", `${counts.skipped} skipped`) : undefined;
|
|
308
351
|
const runId = th.fg("dim", `run ${snapshot.runId.slice(0, 8)}`);
|
|
309
352
|
if (status.live) {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
return fitParts([state, th.fg("muted", progress), failures, th.fg("muted", formatDuration(Date.now() - snapshot.startedAt)), runId], room, th);
|
|
353
|
+
const progress = counts.total > 0 ? `${snapshot.currentPhase} ${finishedAgents(counts)}/${counts.total}` : snapshot.currentPhase;
|
|
354
|
+
return fitParts([state, th.fg("muted", progress), failures, skips, th.fg("muted", formatDuration(Date.now() - snapshot.startedAt)), runId], room, th);
|
|
313
355
|
}
|
|
314
356
|
const elapsed = snapshot.doneAt !== undefined ? th.fg("muted", formatDuration(snapshot.doneAt - snapshot.startedAt)) : undefined;
|
|
315
|
-
return fitParts([state, failures, elapsed, runId], room, th);
|
|
357
|
+
return fitParts([state, failures, skips, elapsed, runId], room, th);
|
|
316
358
|
}
|
|
317
359
|
|
|
318
|
-
private footer(tab: TabId, rows: readonly Row[], viewRows: number, offset: number, below: number, width: number): string {
|
|
360
|
+
private footer(tab: TabId, rows: readonly Row[], viewRows: number, offset: number, below: number, width: number, skippable: boolean): string {
|
|
361
|
+
if (this.notice) return this.theme.fg("warning", `${GLYPH.warning} ${this.notice}`);
|
|
319
362
|
const clipped = rows.length > viewRows;
|
|
320
363
|
const pairs: (readonly [string, string])[] = [["tab", "sections"]];
|
|
321
364
|
if (LIST_TABS.has(tab) && selectableKeys(rows).length > 0) pairs.push(["↑↓", "move"], ["enter", "expand"]);
|
|
322
365
|
else if (clipped) pairs.push(["↑↓", "scroll"]);
|
|
366
|
+
// Like the runs browser's action keys, skip is listed only while the selection can take it.
|
|
367
|
+
if (skippable) pairs.push(["s", "skip"]);
|
|
323
368
|
pairs.push(["q", "close"]);
|
|
324
369
|
const hints = keyHints(pairs, this.theme);
|
|
325
370
|
const scroll = [offset > 0 ? `↑ ${offset} more` : undefined, below > 0 ? `↓ ${below} more` : undefined].filter(Boolean).join(" ");
|
|
@@ -367,6 +412,7 @@ export class WorkflowInspector {
|
|
|
367
412
|
countNote(phase.agents, "running", "accent", th),
|
|
368
413
|
countNote(phase.agents, "queued", "dim", th),
|
|
369
414
|
countNote(phase.agents, "failed", "error", th),
|
|
415
|
+
countNote(phase.agents, "skipped", "warning", th),
|
|
370
416
|
],
|
|
371
417
|
th,
|
|
372
418
|
);
|
|
@@ -449,9 +495,8 @@ export class WorkflowInspector {
|
|
|
449
495
|
const now = Date.now();
|
|
450
496
|
const rows: Row[] = [];
|
|
451
497
|
for (const phase of phases) {
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
rows.push({ text: ` ${th.bold(th.fg("muted", phase.title))}${note}`, heading: true });
|
|
498
|
+
const notes = joinParts([countNote(phase.agents, "failed", "error", th), countNote(phase.agents, "skipped", "warning", th)], th);
|
|
499
|
+
rows.push({ text: ` ${th.bold(th.fg("muted", phase.title))}${notes ? ` ${notes}` : ""}`, heading: true });
|
|
455
500
|
for (const agent of phase.agents) {
|
|
456
501
|
const key = agentKey(agent);
|
|
457
502
|
const cursor = key === selected ? th.fg("accent", GLYPH.cursor) : " ";
|
|
@@ -460,7 +505,8 @@ export class WorkflowInspector {
|
|
|
460
505
|
const tools = agent.toolUses > 0 ? `${agent.toolUses} tool${agent.toolUses === 1 ? "" : "s"}` : "";
|
|
461
506
|
const expanded = this.expanded.has(key);
|
|
462
507
|
// Expanded rows carry the full error and tool below, so the inline note would repeat them.
|
|
463
|
-
const
|
|
508
|
+
const skipping = agent.status === "running" && this.skipRequested.has(agent.id);
|
|
509
|
+
const note = skipping ? th.fg("warning", "skipping…") : expanded ? "" : agentNote(agent, th);
|
|
464
510
|
const line = `${cursor} ${agentGlyph(agent, th)} ${label} ${padStartVisible(th.fg("muted", duration), 6)} ${fit(th.fg("dim", tools), 8)} ${note}`;
|
|
465
511
|
rows.push({ text: line.trimEnd(), key });
|
|
466
512
|
if (expanded) rows.push(...this.agentDetails(agent, phase, snapshot, width));
|
|
@@ -474,6 +520,7 @@ export class WorkflowInspector {
|
|
|
474
520
|
const fields: [string, string][] = [];
|
|
475
521
|
if (agent.error) fields.push(["error", th.fg("error", agent.error)]);
|
|
476
522
|
if (agent.status === "queued") fields.push(["status", th.fg("muted", "queued, waiting for a free agent slot")]);
|
|
523
|
+
if (agent.status === "skipped") fields.push(["status", th.fg("warning", "skipped by the user; its result was dropped")]);
|
|
477
524
|
if (agent.lastTool) {
|
|
478
525
|
const calls = agent.toolUses > 1 ? th.fg("dim", ` · last of ${agent.toolUses} tool calls`) : "";
|
|
479
526
|
fields.push(["tool", `${agent.lastTool}${calls}`]);
|
|
@@ -611,6 +658,7 @@ function phaseGlyph(phase: PhaseSnapshot, activeAndLive: boolean): { glyph: stri
|
|
|
611
658
|
if (agents.length > 0 && agents.every((agent) => agent.status === "queued")) return { glyph: GLYPH.queued, color: "dim" };
|
|
612
659
|
if (agents.some((agent) => agent.status === "queued")) return { glyph: GLYPH.running, color: "accent" };
|
|
613
660
|
if (agents.length > 0 && agents.every((agent) => agent.status === "failed")) return { glyph: GLYPH.failed, color: "error" };
|
|
661
|
+
if (agents.length > 0 && agents.every((agent) => agent.status === "skipped")) return { glyph: GLYPH.skipped, color: "warning" };
|
|
614
662
|
return { glyph: GLYPH.done, color: "success" };
|
|
615
663
|
}
|
|
616
664
|
|
|
@@ -655,6 +703,8 @@ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
|
|
|
655
703
|
return theme.fg("success", GLYPH.done);
|
|
656
704
|
case "failed":
|
|
657
705
|
return theme.fg("error", GLYPH.failed);
|
|
706
|
+
case "skipped":
|
|
707
|
+
return theme.fg("warning", GLYPH.skipped);
|
|
658
708
|
case "running":
|
|
659
709
|
return theme.fg("accent", GLYPH.running);
|
|
660
710
|
case "queued":
|
|
@@ -664,6 +714,7 @@ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
|
|
|
664
714
|
|
|
665
715
|
function agentNote(agent: AgentRowSnapshot, theme: Theme): string {
|
|
666
716
|
if (agent.status === "failed") return theme.fg("error", firstLine(agent.error ?? "failed"));
|
|
717
|
+
if (agent.status === "skipped") return theme.fg("warning", "skipped");
|
|
667
718
|
if (agent.status === "queued") return theme.fg("dim", "queued");
|
|
668
719
|
if (agent.status === "running" && agent.lastTool) return theme.fg("dim", agent.lastTool);
|
|
669
720
|
return "";
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { decodeKittyPrintable, matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
2
|
+
import { decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import { isRecord } from "../guards.ts";
|
|
4
4
|
import { formatCount, truncateText } from "../text.ts";
|
|
5
5
|
import {
|
|
6
6
|
availableWorkflowRunActions,
|
|
7
7
|
canRelaunchWorkflowRun,
|
|
8
8
|
formatWorkflowRunDuration,
|
|
9
|
+
hasRelaunchableWorkflowSource,
|
|
10
|
+
isInlineWorkflowRun,
|
|
9
11
|
type WorkflowRunActionContext,
|
|
10
12
|
type WorkflowRunLifecycleAction,
|
|
11
13
|
} from "../workflow-run-history.ts";
|
|
@@ -72,9 +74,12 @@ const OUTCOME_LINES = 3;
|
|
|
72
74
|
const OUTCOME_CHARS = 600;
|
|
73
75
|
const LIST_MIN = 3;
|
|
74
76
|
const REFRESH_MS = 1_000;
|
|
77
|
+
const INLINE_TAG = "inline";
|
|
75
78
|
|
|
76
79
|
interface RunCells {
|
|
77
80
|
readonly name: string;
|
|
81
|
+
/** `inline` for a run of an inline script; it stays visible however far the name is truncated. */
|
|
82
|
+
readonly tag: string;
|
|
78
83
|
readonly state: string;
|
|
79
84
|
readonly duration: string;
|
|
80
85
|
readonly age: string;
|
|
@@ -212,9 +217,12 @@ export class WorkflowRunsBrowser implements Component {
|
|
|
212
217
|
const style = STATE_STYLE[record.state];
|
|
213
218
|
const selected = index === this.cursor;
|
|
214
219
|
const cursor = selected ? this.theme.fg("accent", GLYPH.cursor) : " ";
|
|
215
|
-
const
|
|
220
|
+
const tagWidth = cells.tag ? visibleWidth(cells.tag) + 1 : 0;
|
|
221
|
+
const name = truncateToWidth(cells.name, Math.max(1, layout.name - tagWidth), "…");
|
|
222
|
+
const tag = cells.tag ? ` ${this.theme.fg("dim", cells.tag)}` : "";
|
|
223
|
+
const padding = " ".repeat(Math.max(0, layout.name - visibleWidth(name) - tagWidth));
|
|
216
224
|
const parts = [
|
|
217
|
-
`${cursor} ${this.theme.fg(style.color, style.glyph)} ${selected ? this.theme.bold(name) : name}`,
|
|
225
|
+
`${cursor} ${this.theme.fg(style.color, style.glyph)} ${selected ? this.theme.bold(name) : name}${tag}${padding}`,
|
|
218
226
|
this.theme.fg(style.color, fit(cells.state, layout.state)),
|
|
219
227
|
];
|
|
220
228
|
if (layout.duration > 0) parts.push(this.theme.fg("muted", padStartVisible(cells.duration, layout.duration)));
|
|
@@ -366,7 +374,7 @@ interface ColumnLayout {
|
|
|
366
374
|
function columnLayout(cells: readonly RunCells[], width: number): ColumnLayout {
|
|
367
375
|
const widest = (pick: (cell: RunCells) => string) => Math.max(0, ...cells.map((cell) => visibleWidth(pick(cell))));
|
|
368
376
|
let layout: ColumnLayout = {
|
|
369
|
-
name: Math.min(NAME_MAX, widest((cell) => cell.name)),
|
|
377
|
+
name: Math.min(NAME_MAX, widest((cell) => (cell.tag ? `${cell.name} ${cell.tag}` : cell.name))),
|
|
370
378
|
state: widest((cell) => cell.state),
|
|
371
379
|
duration: widest((cell) => cell.duration),
|
|
372
380
|
age: widest((cell) => cell.age),
|
|
@@ -389,6 +397,7 @@ function runCells(record: WorkflowRunRecord, now: number): RunCells {
|
|
|
389
397
|
const cost = record.usage?.totals.cost.total ?? 0;
|
|
390
398
|
return {
|
|
391
399
|
name: record.workflow.name,
|
|
400
|
+
tag: isInlineWorkflowRun(record) ? INLINE_TAG : "",
|
|
392
401
|
state: record.state,
|
|
393
402
|
duration: record.state === "queued" ? "" : formatWorkflowRunDuration(record, now),
|
|
394
403
|
age: relativeTime(record.createdAt, now),
|
|
@@ -446,8 +455,10 @@ function describeOutcome(record: WorkflowRunRecord): string | undefined {
|
|
|
446
455
|
/** Why a finished or paused run cannot be relaunched, mirroring `canRelaunchWorkflowRun`. */
|
|
447
456
|
function relaunchBlocker(record: WorkflowRunRecord): string | undefined {
|
|
448
457
|
if (record.state === "queued" || record.state === "running" || canRelaunchWorkflowRun(record)) return undefined;
|
|
449
|
-
if (record
|
|
450
|
-
return "the workflow source cannot be verified";
|
|
458
|
+
if (!hasRelaunchableWorkflowSource(record)) {
|
|
459
|
+
if (!isInlineWorkflowRun(record)) return "the workflow source cannot be verified";
|
|
460
|
+
if (record.scriptFile === undefined) return "its inline script was not retained";
|
|
461
|
+
return "its inline script has no seal from this pi install";
|
|
451
462
|
}
|
|
452
463
|
return record.options.argumentsPresent !== false ? "its arguments were not retained" : undefined;
|
|
453
464
|
}
|
|
@@ -2,6 +2,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
3
|
import { isRecord } from "../guards.ts";
|
|
4
4
|
import { readInlineWorkflowMeta } from "../inline-workflow.ts";
|
|
5
|
+
import { workflowArgsText } from "../workflow-input.ts";
|
|
5
6
|
import { GLYPH, hangingWrap, keyHints, spread } from "./kit.ts";
|
|
6
7
|
import { LinesComponent, shortRunId, type WorkflowResultHints } from "./result-layout.ts";
|
|
7
8
|
import { isWorkflowResult, workflowResultComponent } from "./workflow-result-renderer.ts";
|
|
@@ -10,7 +11,9 @@ import { isWorkflowResult, workflowResultComponent } from "./workflow-result-ren
|
|
|
10
11
|
export interface WorkflowToolCallArgs {
|
|
11
12
|
readonly name?: string;
|
|
12
13
|
readonly script?: string;
|
|
13
|
-
readonly
|
|
14
|
+
readonly scriptPath?: string;
|
|
15
|
+
/** A string or any JSON value. */
|
|
16
|
+
readonly args?: unknown;
|
|
14
17
|
readonly background?: boolean;
|
|
15
18
|
readonly resumeFromRunId?: string;
|
|
16
19
|
}
|
|
@@ -22,11 +25,13 @@ export interface WorkflowToolResultView {
|
|
|
22
25
|
|
|
23
26
|
/** A named workflow, or an inline one identified by its literal meta (`inline` until that parses). */
|
|
24
27
|
export function workflowToolTitle(args: WorkflowToolCallArgs): { readonly name: string; readonly detail?: string } {
|
|
25
|
-
const detail = collapseWhitespace(args.args
|
|
28
|
+
const detail = collapseWhitespace(workflowArgsText(args.args));
|
|
26
29
|
const named = args.name?.trim();
|
|
27
30
|
if (named) return { name: named, detail: detail || undefined };
|
|
28
31
|
const meta = args.script ? readInlineWorkflowMeta(args.script) : undefined;
|
|
29
|
-
|
|
32
|
+
// The row renders synchronously, so a script file is named by its path rather than read for its meta.
|
|
33
|
+
const fallback = meta?.description ?? args.scriptPath ?? "";
|
|
34
|
+
return { name: meta?.name ?? "inline", detail: detail || collapseWhitespace(fallback) || undefined };
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/** `▸ workflow code-review · HEAD~3 (background)`: the detail is shortened before the tags are. */
|