@getpipher/armory-fleet 0.11.1 → 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/package.json +1 -1
- package/src/engine/concurrency-lock.ts +20 -15
- package/src/engine/spawnSubagent.ts +8 -2
- package/src/index.ts +104 -3
- package/src/panel/fleet-panel.ts +165 -11
- package/src/runtime/reconcile.ts +12 -9
- package/src/tools/fleet.ts +179 -0
- package/src/workflows/builtin/adversarial-review.js +19 -0
- package/src/workflows/builtin/code-review.js +13 -0
- package/src/workflows/builtin/codebase-audit.js +16 -0
- package/src/workflows/builtin/deep-research.js +12 -0
- package/src/workflows/builtin/multi-perspective.js +17 -0
- package/src/workflows/helpers/checkpoint.ts +15 -0
- package/src/workflows/helpers/completeness-check.ts +18 -0
- package/src/workflows/helpers/gate.ts +22 -0
- package/src/workflows/helpers/index.ts +8 -0
- package/src/workflows/helpers/judge-panel.ts +33 -0
- package/src/workflows/helpers/loop-until-dry.ts +21 -0
- package/src/workflows/helpers/retry.ts +17 -0
- package/src/workflows/helpers/types.ts +19 -0
- package/src/workflows/helpers/verify.ts +27 -0
- package/src/workflows/journal.ts +76 -0
- package/src/workflows/keyword.ts +22 -0
- package/src/workflows/panel/workflows-items.ts +150 -0
- package/src/workflows/panel/workflows-rows.ts +3 -0
- package/src/workflows/panel-host.ts +179 -0
- package/src/workflows/registry.ts +68 -0
- package/src/workflows/runner.ts +507 -0
- package/src/workflows/runtime/adapters.ts +182 -0
- package/src/workflows/runtime/controller.ts +493 -0
- package/src/workflows/runtime/hydrate.ts +116 -0
- package/src/workflows/runtime/pause-gate.ts +41 -0
- package/src/workflows/runtime/run-store.ts +31 -0
- package/src/workflows/runtime/save.ts +111 -0
- package/src/workflows/runtime/types.ts +78 -0
- package/src/workflows/source.ts +156 -0
- package/src/workflows/vm-realm.ts +106 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -6,19 +6,24 @@ export interface SingleSlotLock {
|
|
|
6
6
|
current(): string | null;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export class SingleSlotLockImpl implements SingleSlotLock {
|
|
10
|
+
private holding: string | null = null;
|
|
11
|
+
tryAcquire(id: string): boolean {
|
|
12
|
+
if (this.holding !== null) return false;
|
|
13
|
+
this.holding = id;
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
release(): void {
|
|
17
|
+
this.holding = null;
|
|
18
|
+
}
|
|
19
|
+
current(): string | null {
|
|
20
|
+
return this.holding;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Alias so tests can `new SingleSlotLock()`. */
|
|
25
|
+
export const SingleSlotLock = SingleSlotLockImpl;
|
|
26
|
+
|
|
9
27
|
export function createSingleSlotLock(): SingleSlotLock {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
tryAcquire(id): boolean {
|
|
13
|
-
if (holding !== null) return false;
|
|
14
|
-
holding = id;
|
|
15
|
-
return true;
|
|
16
|
-
},
|
|
17
|
-
release(): void {
|
|
18
|
-
holding = null;
|
|
19
|
-
},
|
|
20
|
-
current(): string | null {
|
|
21
|
-
return holding;
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
}
|
|
28
|
+
return new SingleSlotLock();
|
|
29
|
+
}
|
|
@@ -140,6 +140,8 @@ export interface SpawnOptions {
|
|
|
140
140
|
tierRegistry?: TierRegistry;
|
|
141
141
|
/** SPEC-6-1: model catalog for contextFloor filtering. Optional — absent means no catalog filtering. */
|
|
142
142
|
modelRegistry?: ModelRegistryLike;
|
|
143
|
+
/** SPEC-6-3: workflow adapter tier override — replaces agent.tier before model resolution. */
|
|
144
|
+
tierOverride?: string;
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
export interface SpawnResult {
|
|
@@ -195,8 +197,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
195
197
|
const childAgent = opts.skillsOverride ? { ...agentDef, skills: opts.skillsOverride } : agentDef;
|
|
196
198
|
|
|
197
199
|
// SPEC-6-1: resolve model via tier registry (Q4 precedence + Q5 contextFloor/catalog filter).
|
|
200
|
+
// SPEC-6-3: workflow tierOverride replaces agent.tier before resolution.
|
|
201
|
+
const effectiveAgent = opts.tierOverride
|
|
202
|
+
? { ...agentDef, tier: opts.tierOverride }
|
|
203
|
+
: agentDef;
|
|
198
204
|
const resolved = resolveAgentModel(
|
|
199
|
-
|
|
205
|
+
effectiveAgent, opts.model, opts.parentModel,
|
|
200
206
|
opts.tierRegistry ?? new TierRegistry({ tiers: [], agents: new Map() }),
|
|
201
207
|
opts.modelRegistry ?? { find: () => undefined },
|
|
202
208
|
);
|
|
@@ -410,4 +416,4 @@ async function finishRun(
|
|
|
410
416
|
status, finalText, runId, todoId, agent: agentName, model,
|
|
411
417
|
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error,
|
|
412
418
|
};
|
|
413
|
-
}
|
|
419
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
11
|
import { createSubagentTool, type SubagentToolDeps } from "./tools/subagent.ts";
|
|
12
|
-
|
|
12
|
+
// SPEC-6-3: /fleet uses openWorkflowPanelLoop (Task 12) instead of the raw openFleetPanel factory.
|
|
13
13
|
import { discoverAgents } from "./registry/discovery.ts";
|
|
14
14
|
import { RunRegistry } from "./engine/run-registry.ts";
|
|
15
15
|
import { createSingleSlotLock } from "./engine/concurrency-lock.ts";
|
|
@@ -49,6 +49,16 @@ import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts";
|
|
|
49
49
|
import { BUILTIN_TIERS } from "./tiers/builtin.ts";
|
|
50
50
|
import { TierStore } from "./tiers/tier-store.ts";
|
|
51
51
|
import { splitModel } from "./tiers/resolve.ts";
|
|
52
|
+
import { WorkflowJournal } from "./workflows/journal.ts";
|
|
53
|
+
import { discoverWorkflows, WorkflowRegistry, type WorkflowDef } from "./workflows/registry.ts";
|
|
54
|
+
import { runWorkflow, type WorkflowRunDeps, type WorkflowRunResult } from "./workflows/runner.ts";
|
|
55
|
+
import { scanWorkflowResumeCandidates } from "./runtime/reconcile.ts";
|
|
56
|
+
import { WorkflowController } from "./workflows/runtime/controller.ts";
|
|
57
|
+
import { WorkflowRunStore } from "./workflows/runtime/run-store.ts";
|
|
58
|
+
import { createWorkflowAdapters } from "./workflows/runtime/adapters.ts";
|
|
59
|
+
import { openWorkflowPanelLoop } from "./workflows/panel-host.ts";
|
|
60
|
+
import { workflowKeywordHint } from "./workflows/keyword.ts";
|
|
61
|
+
import { createFleetTool } from "./tools/fleet.ts";
|
|
52
62
|
|
|
53
63
|
/** The package builtin agents/ dir, resolved relative to this module. */
|
|
54
64
|
function builtinAgentsDir(): string {
|
|
@@ -200,6 +210,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
200
210
|
// SPEC-5b-2: the live widget (above editor) controller.
|
|
201
211
|
// Display-only, independent of the /fleet panel; constructed per-session in session_start.
|
|
202
212
|
let fleetWidget: FleetWidgetController | null = null;
|
|
213
|
+
// SPEC-6-3: hoisted so /fleet command handler + session_shutdown can reach them.
|
|
214
|
+
let wfController: WorkflowController | null = null;
|
|
215
|
+
let wfStore: WorkflowRunStore | null = null;
|
|
216
|
+
let wfRegistry: WorkflowRegistry | null = null;
|
|
217
|
+
let wfSessionAbort: AbortController | null = null;
|
|
203
218
|
// The async runner's runLifecycle adapter: call the real runLifecycle with the worktree as the
|
|
204
219
|
// spawn cwd + override genRunId so the lifecycle runId IS the async runner's runId (Q1=B seam).
|
|
205
220
|
const asyncRunLifecycle: AsyncRunnerDeps["runLifecycle"] = async (task, lifecycleName, opts) => {
|
|
@@ -342,10 +357,86 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
342
357
|
};
|
|
343
358
|
deps.reloadTiers = reloadTiers;
|
|
344
359
|
reloadTiers(); // build the real merged registry (replaces the builtin-only placeholder)
|
|
360
|
+
|
|
361
|
+
// SPEC-6-3: workflow journal + registry + runner + fleet tool wiring.
|
|
362
|
+
const workflowJournal = new WorkflowJournal(join(dir, "workflows"));
|
|
363
|
+
wfRegistry = new WorkflowRegistry(discoverWorkflows({
|
|
364
|
+
projectDir: join(ctx.cwd, ".pi", "fleet", "workflows"),
|
|
365
|
+
globalDir: join(process.env.HOME ?? "", ".pi", "agent", "fleet", "workflows"),
|
|
366
|
+
builtinDir: join(new URL(".", import.meta.url).pathname, "workflows", "builtin"),
|
|
367
|
+
}).workflows);
|
|
368
|
+
const workflowRegistry = wfRegistry;
|
|
369
|
+
// SPEC-6-3: production workflow child/lifecycle adapters (Task 5). Replace the inline spawn stub
|
|
370
|
+
// with the adapter factory so workflow-internal parallel calls use the per-workflow ConcurrencyPool
|
|
371
|
+
// + fresh per-child locks (not the foreground singleton), and lifecycle phase spawns share the pool.
|
|
372
|
+
wfSessionAbort = new AbortController();
|
|
373
|
+
const adapters = createWorkflowAdapters({
|
|
374
|
+
registry: deps.registry as unknown as Map<string, unknown>,
|
|
375
|
+
todoSync: deps.todoSync,
|
|
376
|
+
runRegistry: deps.runRegistry,
|
|
377
|
+
backendRegistry: deps.backendRegistry,
|
|
378
|
+
parentModel: deps.parentModel,
|
|
379
|
+
parentCwd: ctx.cwd,
|
|
380
|
+
runLog: deps.runLog,
|
|
381
|
+
tierRegistry: deps.tierRegistry,
|
|
382
|
+
modelRegistry: deps.modelRegistry,
|
|
383
|
+
lifecycleDeps: deps.lifecycleDeps,
|
|
384
|
+
spawnSubagentFn: async (opts) => { const { spawnSubagent } = await import("./engine/spawnSubagent.ts"); return spawnSubagent(opts); },
|
|
385
|
+
runLifecycleFn: async (task, name, lcOpts) => { const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts"); return runLifecycle(task, name, lcOpts); },
|
|
386
|
+
}, { concurrency: 3, signal: wfSessionAbort.signal });
|
|
387
|
+
const wfRunnerDeps: WorkflowRunDeps = {
|
|
388
|
+
spawn: adapters.spawn,
|
|
389
|
+
runLifecycle: adapters.runLifecycle,
|
|
390
|
+
worktree: deps.asyncRunner.worktree,
|
|
391
|
+
tierRegistry: deps.tierRegistry ?? new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry }),
|
|
392
|
+
journal: workflowJournal,
|
|
393
|
+
runRegistry: deps.runRegistry,
|
|
394
|
+
getModelContextWindow: (m: string) => deps.getModelContextWindow?.(m),
|
|
395
|
+
genRunId: () => "wf-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
|
|
396
|
+
notify: (m: string, l?: "info" | "warning" | "error") => ctx.ui.notify(m, l ?? "info"),
|
|
397
|
+
onCheckpoint: async () => undefined,
|
|
398
|
+
resolveWorkflow: (name: string) => workflowRegistry.get(name) as { sourceText: string; executable: string } | undefined,
|
|
399
|
+
};
|
|
400
|
+
wfStore = new WorkflowRunStore();
|
|
401
|
+
wfController = new WorkflowController({
|
|
402
|
+
registry: workflowRegistry,
|
|
403
|
+
projectDir: join(ctx.cwd, ".pi", "fleet", "workflows"),
|
|
404
|
+
store: wfStore,
|
|
405
|
+
journal: workflowJournal,
|
|
406
|
+
runWorkflow,
|
|
407
|
+
runDepsFactory: () => wfRunnerDeps,
|
|
408
|
+
inbox: resultsInbox,
|
|
409
|
+
genRunId: wfRunnerDeps.genRunId,
|
|
410
|
+
notify: (m: string, l?: "info" | "warning" | "error") => ctx.ui.notify(m, l ?? "info"),
|
|
411
|
+
});
|
|
412
|
+
wfController.hydrate();
|
|
413
|
+
const wfCands = scanWorkflowResumeCandidates(join(dir, "workflows"));
|
|
414
|
+
if (wfCands.length > 0) {
|
|
415
|
+
ctx.ui.notify(`${wfCands.length} interrupted workflow${wfCands.length > 1 ? "s" : ""} — open /fleet Workflows to resume`, "info");
|
|
416
|
+
}
|
|
417
|
+
pi.registerTool(createFleetTool({
|
|
418
|
+
getController: () => {
|
|
419
|
+
if (!wfController) throw new Error("workflow runtime not initialized for this session");
|
|
420
|
+
return wfController;
|
|
421
|
+
},
|
|
422
|
+
}) as never);
|
|
345
423
|
});
|
|
346
424
|
|
|
347
425
|
pi.on("session_shutdown", () => {
|
|
348
426
|
if (fleetWidget) { fleetWidget.dispose(); fleetWidget = null; }
|
|
427
|
+
// SPEC-6-3: abort in-flight workflow children via the session-wide adapter signal.
|
|
428
|
+
// Terminal runs are not re-journaled — only non-terminal spawns observe the abort.
|
|
429
|
+
wfSessionAbort?.abort();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// SPEC-6-3: inject bounded ResultsInbox + workflow-keyword hints into the system prompt without
|
|
433
|
+
// consuming the inbox. renderHint() is read-only; pull() consumes (left to the model's fleet.results).
|
|
434
|
+
pi.on("before_agent_start", async (event) => {
|
|
435
|
+
const hint = resultsInbox.renderHint();
|
|
436
|
+
const kw = workflowKeywordHint(event.prompt) ?? "";
|
|
437
|
+
if (!hint && !kw) return undefined;
|
|
438
|
+
const block = [hint, kw].filter(Boolean).join("\n");
|
|
439
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + block };
|
|
349
440
|
});
|
|
350
441
|
|
|
351
442
|
pi.on("resources_discover", (event, ctx) => {
|
|
@@ -364,7 +455,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
364
455
|
ctx.ui.notify("fleet panel is TUI-only; use the subagent tool in non-interactive modes.", "info");
|
|
365
456
|
return;
|
|
366
457
|
}
|
|
367
|
-
|
|
458
|
+
await openWorkflowPanelLoop(
|
|
459
|
+
{ ...deps, workflowController: wfController!, workflowStore: wfStore!, workflowRegistry: wfRegistry! },
|
|
460
|
+
{
|
|
461
|
+
custom: (factory) => { ctx.ui.custom(factory as never); },
|
|
462
|
+
editor: (initial) => ctx.ui.editor("Edit workflow source", initial) as Promise<string>,
|
|
463
|
+
input: (prompt) => ctx.ui.input(prompt) as Promise<string>,
|
|
464
|
+
confirm: (prompt) => ctx.ui.confirm(prompt, "Proceed?") as Promise<boolean>,
|
|
465
|
+
notify: (m, t) => ctx.ui.notify(m, t ?? "info"),
|
|
466
|
+
sendUserMessage: (text) => { void pi.sendUserMessage(text); },
|
|
467
|
+
},
|
|
468
|
+
);
|
|
368
469
|
},
|
|
369
470
|
});
|
|
370
471
|
|
|
@@ -423,4 +524,4 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
423
524
|
ctx.ui.notify("fleet-register-gate: custom gates must be registered via the GateRegistry module export (see src/lifecycle/gates/registry.ts).", "info");
|
|
424
525
|
},
|
|
425
526
|
});
|
|
426
|
-
}
|
|
527
|
+
}
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -29,8 +29,13 @@ import { runLifecycle } from "../lifecycle/run-lifecycle.ts";
|
|
|
29
29
|
import type { TierRegistry } from "../tiers/tier-registry.ts";
|
|
30
30
|
import type { TierStore } from "../tiers/tier-store.ts";
|
|
31
31
|
import { buildTiersItems, setTierCostCap, setTierModels, setTierContextFloor, addTier, deleteTier } from "./tiers-items.ts";
|
|
32
|
+
import { buildWorkflowPanelItems, actionsForWorkflowItem, parseWorkflowPanelKey, type WorkflowPanelItem, type WorkflowPanelAction } from "../workflows/panel/workflows-rows.ts";
|
|
33
|
+
import type { WorkflowController } from "../workflows/runtime/controller.ts";
|
|
34
|
+
import type { WorkflowRunStore } from "../workflows/runtime/run-store.ts";
|
|
35
|
+
import type { WorkflowRegistry } from "../workflows/registry.ts";
|
|
36
|
+
import type { WorkflowPanelIntent } from "../workflows/panel-host.ts";
|
|
32
37
|
|
|
33
|
-
type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled" | "tiers";
|
|
38
|
+
type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled" | "tiers" | "workflows";
|
|
34
39
|
|
|
35
40
|
export interface FleetPanelDeps {
|
|
36
41
|
registry: Map<string, AgentDef>;
|
|
@@ -58,19 +63,25 @@ export interface FleetPanelDeps {
|
|
|
58
63
|
reloadTiers?: () => void;
|
|
59
64
|
/** SPEC-6-1: model contextWindow resolver for Runs-tab ctx% (Surface C). Optional — ctx% hidden when absent. */
|
|
60
65
|
getModelContextWindow?: (model: string) => number | undefined;
|
|
66
|
+
/** SPEC-6-3: live workflow controller + store + registry for the Workflows view. */
|
|
67
|
+
workflowController: WorkflowController;
|
|
68
|
+
workflowStore: WorkflowRunStore;
|
|
69
|
+
workflowRegistry: WorkflowRegistry;
|
|
70
|
+
/** SPEC-6-3: panel intent callback for host-only actions (Task 12 wires). */
|
|
71
|
+
onWorkflowIntent?: (intent: { action: string; runId?: string; definitionName?: string }) => void;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
export interface FleetPanelOpts {
|
|
64
75
|
theme: Theme;
|
|
65
76
|
deps: FleetPanelDeps;
|
|
66
|
-
onDone: () => void;
|
|
77
|
+
onDone: (intent?: WorkflowPanelIntent | null) => void;
|
|
67
78
|
onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
export class FleetPanel extends Container {
|
|
71
82
|
private readonly theme: Theme;
|
|
72
83
|
private readonly deps: FleetPanelDeps;
|
|
73
|
-
private readonly onDone: () => void;
|
|
84
|
+
private readonly onDone: (intent?: WorkflowPanelIntent | null) => void;
|
|
74
85
|
private readonly onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
|
|
75
86
|
private view: View = "fleet";
|
|
76
87
|
private list: SelectList;
|
|
@@ -120,6 +131,10 @@ export class FleetPanel extends Container {
|
|
|
120
131
|
* so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
|
|
121
132
|
private readonly unsubs: (() => void)[] = [];
|
|
122
133
|
private closed = false; // SPEC-5a proper-fix: guard against double-close calling onDone twice
|
|
134
|
+
// SPEC-6-3: Workflows view — inline Run prompt input state
|
|
135
|
+
private wfRunMode = false;
|
|
136
|
+
private wfPromptInput: Input | null = null;
|
|
137
|
+
private wfRunDefinitionName = "";
|
|
123
138
|
|
|
124
139
|
constructor(opts: FleetPanelOpts) {
|
|
125
140
|
super();
|
|
@@ -139,6 +154,8 @@ export class FleetPanel extends Container {
|
|
|
139
154
|
// and the async/bg case (onProgress mutates BgRunsStore while the parent is idle).
|
|
140
155
|
this.unsubs.push(this.deps.runRegistry.subscribe(() => this.refresh()));
|
|
141
156
|
if (this.deps.bgRuns) this.unsubs.push(this.deps.bgRuns.subscribe(() => this.refresh()));
|
|
157
|
+
// SPEC-6-3: subscribe to workflow store mutations → live Workflows view refresh.
|
|
158
|
+
this.unsubs.push(this.deps.workflowStore.subscribe(() => this.refresh()));
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
private buildList(): SelectList {
|
|
@@ -155,7 +172,9 @@ export class FleetPanel extends Container {
|
|
|
155
172
|
? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) }))
|
|
156
173
|
: this.view === "tiers"
|
|
157
174
|
? (this.deps.tierRegistry ? buildTiersItems({ tierRegistry: this.deps.tierRegistry, runRegistry: this.deps.runRegistry }) : [])
|
|
158
|
-
|
|
175
|
+
: this.view === "workflows"
|
|
176
|
+
? buildWorkflowPanelItems({ definitions: this.deps.workflowRegistry.list(), runs: this.deps.workflowStore.values() })
|
|
177
|
+
: this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
|
|
159
178
|
const fresh = new SelectList(items, 12, {
|
|
160
179
|
selectedPrefix: (s: string) => this.theme.fg("accent", s),
|
|
161
180
|
selectedText: (s: string) => this.theme.fg("accent", s),
|
|
@@ -180,12 +199,12 @@ export class FleetPanel extends Container {
|
|
|
180
199
|
/** SPEC-5a proper-fix: tear down store subscriptions then close the panel.
|
|
181
200
|
* Every exit path routes here so listeners never leak past the panel's lifetime.
|
|
182
201
|
* Idempotent — safe to call multiple times (esc + q + pi teardown). */
|
|
183
|
-
private close(): void {
|
|
202
|
+
private close(intent?: WorkflowPanelIntent | null): void {
|
|
184
203
|
if (this.closed) return;
|
|
185
204
|
this.closed = true;
|
|
186
205
|
for (const u of this.unsubs) u();
|
|
187
206
|
this.unsubs.length = 0;
|
|
188
|
-
this.onDone();
|
|
207
|
+
this.onDone(intent ?? null);
|
|
189
208
|
}
|
|
190
209
|
|
|
191
210
|
private renderShell(): void {
|
|
@@ -193,7 +212,7 @@ export class FleetPanel extends Container {
|
|
|
193
212
|
this.children.length = 0;
|
|
194
213
|
this.children.push(...keep);
|
|
195
214
|
const accent = (s: string): string => this.theme.fg("accent", s);
|
|
196
|
-
|
|
215
|
+
const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled", "tiers", "workflows"] as View[])
|
|
197
216
|
.map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
|
|
198
217
|
.join(" ");
|
|
199
218
|
this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
|
|
@@ -309,6 +328,11 @@ export class FleetPanel extends Container {
|
|
|
309
328
|
this.addChild(tl);
|
|
310
329
|
}
|
|
311
330
|
this.addChild(new Text(this.theme.fg("dim", " enter:Full-message esc:Back"), 0, 0));
|
|
331
|
+
} else if (this.wfRunMode && this.wfPromptInput) {
|
|
332
|
+
// SPEC-6-3: Workflows tab — inline Run prompt input.
|
|
333
|
+
this.addChild(new Text(this.theme.fg("accent", ` run ${this.wfRunDefinitionName}> `), 0, 0));
|
|
334
|
+
this.addChild(this.wfPromptInput);
|
|
335
|
+
this.addChild(new Text(this.theme.fg("dim", " enter submit (blank=direct run) • esc cancel"), 0, 0));
|
|
312
336
|
} else if (this.steerMode && this.steerInput) {
|
|
313
337
|
// SPEC-5b-4: Fleet tab — mid-run steer input.
|
|
314
338
|
this.addChild(new Text(this.theme.fg("accent", " steer> "), 0, 0));
|
|
@@ -374,8 +398,10 @@ export class FleetPanel extends Container {
|
|
|
374
398
|
: this.view === "scheduled"
|
|
375
399
|
? " a:Add p:Pause/resume d:Delete i:Info tab:Tiers q:Quit"
|
|
376
400
|
: this.view === "tiers"
|
|
377
|
-
? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:
|
|
378
|
-
|
|
401
|
+
? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:Workflows q:Quit"
|
|
402
|
+
: this.view === "workflows"
|
|
403
|
+
? " r:Run e:Edit-and-resume o:Open p:Pause u:Resume x:Stop s:Save-as v:View-result tab:Fleet q:Quit"
|
|
404
|
+
: " r:Refresh i:Info tab:Fleet q:Quit";
|
|
379
405
|
this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
|
|
380
406
|
this.addChild(new Spacer(1));
|
|
381
407
|
this.addChild(new DynamicBorder(accent));
|
|
@@ -446,7 +472,7 @@ export class FleetPanel extends Container {
|
|
|
446
472
|
: this.view === "lifecycle" ? "runs"
|
|
447
473
|
: this.view === "runs" ? "agents"
|
|
448
474
|
: this.view === "agents" ? "backends"
|
|
449
|
-
: this.view === "backends" ? "scheduled" : this.view === "scheduled" ? "tiers" : "fleet";
|
|
475
|
+
: this.view === "backends" ? "scheduled" : this.view === "scheduled" ? "tiers" : this.view === "tiers" ? "workflows" : "fleet";
|
|
450
476
|
this.selectedBackend = null;
|
|
451
477
|
this.selectedLifecycle = null;
|
|
452
478
|
this.selectedSchedule = null;
|
|
@@ -530,6 +556,12 @@ export class FleetPanel extends Container {
|
|
|
530
556
|
this.invalidate();
|
|
531
557
|
return;
|
|
532
558
|
}
|
|
559
|
+
if (this.wfRunMode && this.wfPromptInput) {
|
|
560
|
+
if (matchesKey(data, "escape")) { this.cancelWorkflowRun(); return; }
|
|
561
|
+
this.wfPromptInput.handleInput(data);
|
|
562
|
+
this.invalidate();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
533
565
|
if (this.steerMode && this.steerInput) {
|
|
534
566
|
if (matchesKey(data, "escape")) { this.cancelSteer(); return; }
|
|
535
567
|
this.steerInput.handleInput(data);
|
|
@@ -663,6 +695,95 @@ export class FleetPanel extends Container {
|
|
|
663
695
|
return;
|
|
664
696
|
}
|
|
665
697
|
}
|
|
698
|
+
// SPEC-6-3: Workflows view — direct p/u/x controls + host-only intent completion
|
|
699
|
+
if (this.view === "workflows") {
|
|
700
|
+
const sel = this.list.getSelectedItem()
|
|
701
|
+
if (!sel) return
|
|
702
|
+
const parsed = parseWorkflowPanelKey(sel.value)
|
|
703
|
+
const item: WorkflowPanelItem = parsed.kind === "definition"
|
|
704
|
+
? { kind: "definition", definition: this.deps.workflowRegistry.get(parsed.name) ?? { name: parsed.name, description: "", phases: [], sourceText: "", body: "", executable: "", source: "builtin", filePath: "" } }
|
|
705
|
+
: { kind: "run", run: this.deps.workflowStore.get(parsed.runId) ?? { runId: parsed.runId, name: parsed.runId, script: "", mode: "auto", status: "completed", startedAt: 0, currentPhase: "default", phases: [], childRunIds: [], logs: [], tokenTotal: 0, costTotal: 0 } }
|
|
706
|
+
const available = actionsForWorkflowItem(item)
|
|
707
|
+
|
|
708
|
+
const keyAction: Record<string, WorkflowPanelAction> = {
|
|
709
|
+
r: "run", e: "edit-resume", o: "open", p: "pause", u: "resume", x: "stop", s: "save", v: "view-result", c: "respond",
|
|
710
|
+
}
|
|
711
|
+
const action = keyAction[data]
|
|
712
|
+
if (!action) return
|
|
713
|
+
if (!available.includes(action)) {
|
|
714
|
+
this.onNotify(`action '${action}' not available for this item`, "warning")
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Direct controls (no panel close)
|
|
719
|
+
if (action === "pause") {
|
|
720
|
+
if (parsed.kind === "run") this.deps.workflowController.pause(parsed.runId)
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
if (action === "resume") {
|
|
724
|
+
if (parsed.kind === "run") {
|
|
725
|
+
void this.deps.workflowController.resume(parsed.runId).then(() => {
|
|
726
|
+
this.refresh()
|
|
727
|
+
}).catch((e: unknown) => {
|
|
728
|
+
this.onNotify(`resume failed: ${(e as Error).message}`, "error")
|
|
729
|
+
})
|
|
730
|
+
}
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
if (action === "stop") {
|
|
734
|
+
if (parsed.kind === "run") {
|
|
735
|
+
void this.deps.workflowController.stop(parsed.runId).then(() => {
|
|
736
|
+
this.refresh()
|
|
737
|
+
}).catch((e: unknown) => {
|
|
738
|
+
this.onNotify(`stop failed: ${(e as Error).message}`, "error")
|
|
739
|
+
})
|
|
740
|
+
}
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Run action: inline prompt input for definitions
|
|
745
|
+
if (action === "run" && parsed.kind === "definition") {
|
|
746
|
+
this.startWorkflowRun(parsed.name)
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Host-only actions: close panel with intent (Task 12 host loop handles them)
|
|
751
|
+
if (action === "run" && parsed.kind === "run") {
|
|
752
|
+
this.close({ action: "run", definitionName: parsed.runId, prompt: "" })
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
if (action === "edit-resume") {
|
|
756
|
+
if (parsed.kind === "run") this.close({ action: "edit-resume", runId: parsed.runId })
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
if (action === "open") {
|
|
760
|
+
if (parsed.kind === "definition") {
|
|
761
|
+
this.close({ action: "open-definition", name: parsed.name })
|
|
762
|
+
} else {
|
|
763
|
+
const run = this.deps.workflowStore.get(parsed.runId)
|
|
764
|
+
const childId = run?.childRunIds[0]
|
|
765
|
+
if (childId) {
|
|
766
|
+
this.close({ action: "open-child", runId: parsed.runId, childRunId: childId })
|
|
767
|
+
} else {
|
|
768
|
+
this.onNotify(`run '${parsed.runId}' has no child runs to open`, "info")
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
if (action === "save") {
|
|
774
|
+
if (parsed.kind === "run") this.close({ action: "save", runId: parsed.runId })
|
|
775
|
+
return
|
|
776
|
+
}
|
|
777
|
+
if (action === "view-result") {
|
|
778
|
+
if (parsed.kind === "run") this.close({ action: "view-result", runId: parsed.runId })
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
if (action === "respond") {
|
|
782
|
+
if (parsed.kind === "run") this.close({ action: "respond", runId: parsed.runId })
|
|
783
|
+
return
|
|
784
|
+
}
|
|
785
|
+
return
|
|
786
|
+
}
|
|
666
787
|
// SPEC-4: pending checkpoint keys (c/v/a)
|
|
667
788
|
if (this.pendingCheckpoint && !this.lcRevising) {
|
|
668
789
|
if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; }
|
|
@@ -840,6 +961,39 @@ export class FleetPanel extends Container {
|
|
|
840
961
|
this.renderShell();
|
|
841
962
|
}
|
|
842
963
|
|
|
964
|
+
// ───────────────────────────────── SPEC-6-3: Workflows Run prompt ─────────────────────────────────
|
|
965
|
+
|
|
966
|
+
private startWorkflowRun(definitionName: string): void {
|
|
967
|
+
this.wfRunDefinitionName = definitionName;
|
|
968
|
+
this.wfPromptInput = new Input();
|
|
969
|
+
this.wfPromptInput.onSubmit = (prompt: string) => {
|
|
970
|
+
if (prompt.trim()) {
|
|
971
|
+
this.close({ action: "run", definitionName: this.wfRunDefinitionName, prompt: prompt.trim() });
|
|
972
|
+
} else {
|
|
973
|
+
// Blank prompt → execute the definition directly
|
|
974
|
+
void this.deps.workflowController.start({
|
|
975
|
+
workflowName: this.wfRunDefinitionName,
|
|
976
|
+
mode: "checkpointed",
|
|
977
|
+
}).then(() => {
|
|
978
|
+
this.refresh();
|
|
979
|
+
}).catch((e: unknown) => {
|
|
980
|
+
this.onNotify(`start failed: ${(e as Error).message}`, "error");
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
this.cancelWorkflowRun();
|
|
984
|
+
};
|
|
985
|
+
this.wfPromptInput.onEscape = () => this.cancelWorkflowRun();
|
|
986
|
+
this.wfRunMode = true;
|
|
987
|
+
this.renderShell();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
private cancelWorkflowRun(): void {
|
|
991
|
+
this.wfRunMode = false;
|
|
992
|
+
this.wfPromptInput = null;
|
|
993
|
+
this.wfRunDefinitionName = "";
|
|
994
|
+
this.renderShell();
|
|
995
|
+
}
|
|
996
|
+
|
|
843
997
|
private cancelTiersEdit(): void {
|
|
844
998
|
this.tiersEditPhase = null;
|
|
845
999
|
this.tiersInput = null;
|
|
@@ -1031,4 +1185,4 @@ export function openFleetPanel(
|
|
|
1031
1185
|
ctx.ui.custom((_tui, theme, _kb, done) => {
|
|
1032
1186
|
return new FleetPanel({ theme, deps, onDone: done, onNotify: (m, t) => ctx.ui.notify(m, t) });
|
|
1033
1187
|
});
|
|
1034
|
-
}
|
|
1188
|
+
}
|
package/src/runtime/reconcile.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
// src/runtime/reconcile.ts
|
|
2
|
-
// SPEC-
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
// SPEC-6-2: rewritten to be probe-driven — uses `probeRun` (handle/pid/age-fallback) instead of
|
|
7
|
-
// age+grace alone. This catches orphans whose process died but whose startedAt is within grace
|
|
8
|
-
// (e.g. a crash seconds after start), and avoids aborting runs whose process is alive but old
|
|
9
|
-
// (e.g. a long-running build).
|
|
2
|
+
// SPEC-6-3 — restart-recovery: scan workflow journals for non-terminal runs
|
|
3
|
+
// (interrupted workflows become resume candidates in the Workflows view).
|
|
4
|
+
import { WorkflowJournal } from "../workflows/journal.ts";
|
|
10
5
|
import type { RunLog } from "./run-log.ts";
|
|
11
6
|
import type { RunRegistry, RunRecord } from "../engine/run-registry.ts";
|
|
12
7
|
|
|
@@ -52,4 +47,12 @@ export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
|
|
|
52
47
|
aborted.push(meta.runId);
|
|
53
48
|
}
|
|
54
49
|
return aborted;
|
|
55
|
-
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** SPEC-6-3 — on pi start, scan .pi/fleet/workflows/ for non-terminal workflow journals.
|
|
53
|
+
* Returns runIds whose journal has no wf:completed/wf:aborted event — these are
|
|
54
|
+
* interrupted workflows that the Workflows view surfaces as resume candidates. */
|
|
55
|
+
export function scanWorkflowResumeCandidates(workflowsDir: string): string[] {
|
|
56
|
+
const journal = new WorkflowJournal(workflowsDir);
|
|
57
|
+
return journal.scanNonTerminal();
|
|
58
|
+
}
|