@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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/engine/concurrency-lock.ts +20 -15
  3. package/src/engine/spawnSubagent.ts +8 -2
  4. package/src/index.ts +104 -3
  5. package/src/panel/fleet-panel.ts +165 -11
  6. package/src/runtime/reconcile.ts +12 -9
  7. package/src/tools/fleet.ts +179 -0
  8. package/src/workflows/builtin/adversarial-review.js +19 -0
  9. package/src/workflows/builtin/code-review.js +13 -0
  10. package/src/workflows/builtin/codebase-audit.js +16 -0
  11. package/src/workflows/builtin/deep-research.js +12 -0
  12. package/src/workflows/builtin/multi-perspective.js +17 -0
  13. package/src/workflows/helpers/checkpoint.ts +15 -0
  14. package/src/workflows/helpers/completeness-check.ts +18 -0
  15. package/src/workflows/helpers/gate.ts +22 -0
  16. package/src/workflows/helpers/index.ts +8 -0
  17. package/src/workflows/helpers/judge-panel.ts +33 -0
  18. package/src/workflows/helpers/loop-until-dry.ts +21 -0
  19. package/src/workflows/helpers/retry.ts +17 -0
  20. package/src/workflows/helpers/types.ts +19 -0
  21. package/src/workflows/helpers/verify.ts +27 -0
  22. package/src/workflows/journal.ts +76 -0
  23. package/src/workflows/keyword.ts +22 -0
  24. package/src/workflows/panel/workflows-items.ts +150 -0
  25. package/src/workflows/panel/workflows-rows.ts +3 -0
  26. package/src/workflows/panel-host.ts +179 -0
  27. package/src/workflows/registry.ts +68 -0
  28. package/src/workflows/runner.ts +507 -0
  29. package/src/workflows/runtime/adapters.ts +182 -0
  30. package/src/workflows/runtime/controller.ts +493 -0
  31. package/src/workflows/runtime/hydrate.ts +116 -0
  32. package/src/workflows/runtime/pause-gate.ts +41 -0
  33. package/src/workflows/runtime/run-store.ts +31 -0
  34. package/src/workflows/runtime/save.ts +111 -0
  35. package/src/workflows/runtime/types.ts +78 -0
  36. package/src/workflows/source.ts +156 -0
  37. package/src/workflows/vm-realm.ts +106 -0
@@ -0,0 +1,150 @@
1
+ // SPEC-6-3 §7/§10 — combined Workflows panel item + action model (pure functions).
2
+ // Definitions first (sorted by source rank, then name), then runs (newest-first).
3
+ import type { SelectItem } from "@earendil-works/pi-tui"
4
+
5
+ import type { WorkflowDef } from "../registry.ts"
6
+ import type { WorkflowRunState } from "../runtime/types.ts"
7
+
8
+ export type WorkflowPanelItem =
9
+ | { kind: "definition"; definition: WorkflowDef }
10
+ | { kind: "run"; run: WorkflowRunState }
11
+
12
+ export type WorkflowPanelAction =
13
+ | "run"
14
+ | "open"
15
+ | "pause"
16
+ | "resume"
17
+ | "stop"
18
+ | "save"
19
+ | "respond"
20
+ | "edit-resume"
21
+ | "view-result"
22
+ | "resume-unchanged"
23
+
24
+ const SOURCE_RANK: Record<WorkflowDef["source"], number> = {
25
+ project: 0,
26
+ global: 1,
27
+ builtin: 2,
28
+ }
29
+
30
+ const RUN_STATUS_GLYPH: Record<WorkflowRunState["status"], string> = {
31
+ queued: "○",
32
+ running: "▶",
33
+ paused: "⏸",
34
+ checkpoint: "⏸",
35
+ completed: "✓",
36
+ failed: "✗",
37
+ aborted: "✗",
38
+ interrupted: "⚠",
39
+ }
40
+
41
+ const DESC_BOUND = 80
42
+ const LOG_BOUND = 120
43
+
44
+ function bound(text: string, max: number): string {
45
+ return text.length > max ? text.slice(0, max - 1) + "…" : text
46
+ }
47
+
48
+ function phaseStrip(phases: { title: string }[], current: string): string {
49
+ return phases.map((p) => (p.title === current ? `${p.title} ▶` : `${p.title} ○`)).join(" ")
50
+ }
51
+
52
+ function definitionLabel(def: WorkflowDef): string {
53
+ const desc = def.description ? ` — ${bound(def.description, DESC_BOUND)}` : ""
54
+ return `◇ ${def.name} [${def.source}]${desc}`
55
+ }
56
+
57
+ function runLabel(state: WorkflowRunState): string {
58
+ const glyph = RUN_STATUS_GLYPH[state.status]
59
+ const strip = state.phases.length > 0 ? ` ${phaseStrip(state.phases, state.currentPhase)}` : ""
60
+ const tokens = state.tokenTotal > 0 ? ` · ${(state.tokenTotal / 1000).toFixed(1)}K tok` : ""
61
+ const cost = state.costTotal > 0 ? ` · $${state.costTotal.toFixed(2)}` : ""
62
+ const lastLog = state.logs.length > 0 ? ` ${bound(state.logs.at(-1) ?? "", LOG_BOUND)}` : ""
63
+ return `${glyph} ${state.runId} [${state.status}]${strip}${tokens}${cost}${lastLog}`
64
+ }
65
+
66
+ export function buildWorkflowPanelItems(input: {
67
+ definitions: WorkflowDef[]
68
+ runs: WorkflowRunState[]
69
+ }): SelectItem[] {
70
+ const sortedDefs = [...input.definitions].sort((a, b) => {
71
+ const rankDiff = SOURCE_RANK[a.source] - SOURCE_RANK[b.source]
72
+ if (rankDiff !== 0) return rankDiff
73
+ return a.name.localeCompare(b.name)
74
+ })
75
+
76
+ const sortedRuns = [...input.runs].sort((a, b) => b.startedAt - a.startedAt)
77
+
78
+ const defItems: SelectItem[] = sortedDefs.map((def) => ({
79
+ value: `definition:${def.name}`,
80
+ label: definitionLabel(def),
81
+ }))
82
+
83
+ const runItems: SelectItem[] = sortedRuns.map((r) => ({
84
+ value: `run:${r.runId}`,
85
+ label: runLabel(r),
86
+ }))
87
+
88
+ return [...defItems, ...runItems]
89
+ }
90
+
91
+ export function actionsForWorkflowItem(item: WorkflowPanelItem): WorkflowPanelAction[] {
92
+ if (item.kind === "definition") {
93
+ return ["run", "open"]
94
+ }
95
+
96
+ const status = item.run.status
97
+ switch (status) {
98
+ case "queued":
99
+ return ["open", "stop"]
100
+ case "running":
101
+ return ["open", "pause", "stop", "save"]
102
+ case "paused":
103
+ return ["open", "resume", "stop", "save"]
104
+ case "checkpoint":
105
+ return ["respond", "stop", "open"]
106
+ case "completed":
107
+ return ["open", "edit-resume", "save", "view-result"]
108
+ case "failed":
109
+ return ["open", "edit-resume", "save", "view-result"]
110
+ case "aborted":
111
+ return ["open", "edit-resume", "save", "view-result"]
112
+ case "interrupted":
113
+ return ["open", "edit-resume", "resume-unchanged", "stop", "save"]
114
+ }
115
+ }
116
+
117
+ export function parseWorkflowPanelKey(
118
+ value: string,
119
+ ): { kind: "definition"; name: string } | { kind: "run"; runId: string } {
120
+ if (value.startsWith("definition:")) {
121
+ return { kind: "definition", name: value.slice("definition:".length) }
122
+ }
123
+ if (value.startsWith("run:")) {
124
+ return { kind: "run", runId: value.slice("run:".length) }
125
+ }
126
+ throw new Error(`invalid workflow panel key: ${value}`)
127
+ }
128
+
129
+ // ── Backward-compat shim (Task 12 will rewire the panel to the new model) ──
130
+
131
+ export interface WorkflowRunRow {
132
+ runId: string
133
+ name: string
134
+ status: "running" | "completed" | "failed" | "aborted" | "paused" | "checkpoint"
135
+ currentPhase: string
136
+ phases: { title: string }[]
137
+ agents: number
138
+ cached: number
139
+ reRun: number
140
+ tokens: number
141
+ cost: number
142
+ }
143
+
144
+ export function buildWorkflowsItems(runs: WorkflowRunRow[]): SelectItem[] {
145
+ return runs.map((r) => ({
146
+ id: r.runId,
147
+ label: `${RUN_STATUS_GLYPH[r.status] ?? "▶"} ${r.name} [${r.status}] ${phaseStrip(r.phases, r.currentPhase)} · ${r.agents} agents (${r.cached} cached, ${r.reRun} re-run) · ${(r.tokens / 1000).toFixed(1)}K tok · $${r.cost.toFixed(2)}`,
148
+ value: r.runId,
149
+ }))
150
+ }
@@ -0,0 +1,3 @@
1
+ // SPEC-6-3 — row rendering helpers (pure). Re-exported for the panel; kept thin.
2
+ export { buildWorkflowsItems, buildWorkflowPanelItems, actionsForWorkflowItem, parseWorkflowPanelKey } from "./workflows-items.ts"
3
+ export type { WorkflowRunRow, WorkflowPanelItem, WorkflowPanelAction } from "./workflows-items.ts"
@@ -0,0 +1,179 @@
1
+ // SPEC-6-3 §12 — panel host loop: consumes WorkflowPanelIntent from the panel
2
+ // and drives editor/input/confirm around it. Never nests ui.editor inside ui.custom.
3
+ import type { Theme } from "@earendil-works/pi-coding-agent"
4
+ import type { Container } from "@earendil-works/pi-tui"
5
+ import type { FleetPanelDeps } from "../panel/fleet-panel.ts"
6
+ import type { WorkflowDef } from "./registry.ts"
7
+ import type { WorkflowRunState } from "./runtime/types.ts"
8
+ import { FleetPanel } from "../panel/fleet-panel.ts"
9
+
10
+ export type WorkflowPanelIntent =
11
+ | { action: "close" }
12
+ | { action: "run"; definitionName: string; prompt: string }
13
+ | { action: "open-definition"; name: string }
14
+ | { action: "open-child"; runId: string; childRunId: string }
15
+ | { action: "edit-resume"; runId: string }
16
+ | { action: "save"; runId: string }
17
+ | { action: "view-result"; runId: string }
18
+ | { action: "respond"; runId: string }
19
+
20
+ export interface WorkflowPanelHostContext {
21
+ custom: (factory: (tui: unknown, theme: Theme, kb: unknown, done: () => void) => Container) => void
22
+ editor: (initial: string) => Promise<string>
23
+ input: (prompt: string) => Promise<string>
24
+ confirm: (prompt: string) => Promise<boolean>
25
+ notify: (msg: string, type?: "info" | "warning" | "error") => void
26
+ sendUserMessage: (text: string) => void
27
+ }
28
+
29
+ const MAX_SOURCE_BYTES = 50_000
30
+ const MAX_SOURCE_LINES = 2000
31
+
32
+ function boundSource(source: string): string {
33
+ const lines = source.split("\n")
34
+ if (lines.length > MAX_SOURCE_LINES) {
35
+ return lines.slice(0, MAX_SOURCE_LINES).join("\n") + "\n… (truncated)"
36
+ }
37
+ if (source.length > MAX_SOURCE_BYTES) {
38
+ return source.slice(0, MAX_SOURCE_BYTES) + "… (truncated)"
39
+ }
40
+ return source
41
+ }
42
+
43
+ function boundResult(result: unknown): string {
44
+ let text: string
45
+ try {
46
+ text = JSON.stringify(result) ?? String(result)
47
+ } catch {
48
+ text = String(result)
49
+ }
50
+ return text.slice(0, MAX_SOURCE_BYTES)
51
+ }
52
+
53
+ export async function openWorkflowPanelLoop(
54
+ deps: FleetPanelDeps,
55
+ host: WorkflowPanelHostContext,
56
+ ): Promise<void> {
57
+ let running = true
58
+
59
+ while (running) {
60
+ const intent = await openPanelOnce(deps, host)
61
+
62
+ if (!intent || intent.action === "close") {
63
+ running = false
64
+ continue
65
+ }
66
+
67
+ switch (intent.action) {
68
+ case "run": {
69
+ if (intent.prompt.trim()) {
70
+ const instruction = `${intent.prompt}\n\nUse the fleet workflow tool (action:'workflow') with the '${intent.definitionName}' workflow to execute this.`
71
+ host.sendUserMessage(instruction)
72
+ running = false
73
+ } else {
74
+ await deps.workflowController.start({
75
+ workflowName: intent.definitionName,
76
+ mode: "checkpointed",
77
+ })
78
+ }
79
+ break
80
+ }
81
+
82
+ case "edit-resume": {
83
+ const run = deps.workflowController.getRun(intent.runId)
84
+ if (!run) {
85
+ host.notify(`run '${intent.runId}' not found`, "error")
86
+ break
87
+ }
88
+ const editedSource = await host.editor(run.script)
89
+ await deps.workflowController.editAndResume(intent.runId, editedSource, "checkpointed")
90
+ break
91
+ }
92
+
93
+ case "save": {
94
+ const run = deps.workflowController.getRun(intent.runId)
95
+ if (!run) {
96
+ host.notify(`run '${intent.runId}' not found`, "error")
97
+ break
98
+ }
99
+ const name = await host.input("Save-as name?")
100
+ let overwrite = false
101
+ const existing = deps.workflowRegistry.get(name)
102
+ const controllerWithCollision = deps.workflowController as unknown as { saveCollision?: boolean }
103
+ if (existing || controllerWithCollision.saveCollision) {
104
+ overwrite = await host.confirm(`Workflow '${name}' already exists. Overwrite?`)
105
+ }
106
+ deps.workflowController.save({ name, source: run.script, overwrite })
107
+ break
108
+ }
109
+
110
+ case "open-definition": {
111
+ const def = deps.workflowRegistry.get(intent.name)
112
+ if (!def) {
113
+ host.notify(`workflow '${intent.name}' not found`, "warning")
114
+ break
115
+ }
116
+ host.notify(`Source: ${def.name}\n${boundSource(def.sourceText)}`, "info")
117
+ break
118
+ }
119
+
120
+ case "open-child": {
121
+ host.notify(`Open child run '${intent.childRunId}' from '${intent.runId}' — use the Runs viewer.`, "info")
122
+ break
123
+ }
124
+
125
+ case "view-result": {
126
+ const run = deps.workflowController.getRun(intent.runId)
127
+ if (!run) {
128
+ host.notify(`run '${intent.runId}' not found`, "error")
129
+ break
130
+ }
131
+ const resultText = run.result !== undefined ? boundResult(run.result) : "(no result)"
132
+ const logText = run.logs.length > 0 ? run.logs.join("\n") : "(no logs)"
133
+ host.notify(`Result: ${resultText}\n\nLogs:\n${logText}`, "info")
134
+ break
135
+ }
136
+
137
+ case "respond": {
138
+ const shouldContinue = await host.confirm("Continue checkpoint?")
139
+ if (shouldContinue) {
140
+ deps.workflowController.respondToCheckpoint(intent.runId, { action: "continue" })
141
+ } else {
142
+ const feedback = await host.input("Revise feedback (blank to abort):")
143
+ if (feedback.trim()) {
144
+ deps.workflowController.respondToCheckpoint(intent.runId, { action: "revise", feedback })
145
+ } else {
146
+ deps.workflowController.respondToCheckpoint(intent.runId, { action: "abort" })
147
+ }
148
+ }
149
+ break
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ function openPanelOnce(
156
+ deps: FleetPanelDeps,
157
+ host: WorkflowPanelHostContext,
158
+ ): Promise<WorkflowPanelIntent | null> {
159
+ return new Promise((resolve) => {
160
+ let resolved = false
161
+ const safeResolve = (intent: WorkflowPanelIntent | null) => {
162
+ if (resolved) return
163
+ resolved = true
164
+ resolve(intent)
165
+ }
166
+ host.custom((_tui, theme, _kb, done) => {
167
+ const panel = new FleetPanel({
168
+ theme,
169
+ deps,
170
+ onDone: (intent?: WorkflowPanelIntent | null) => {
171
+ safeResolve(intent ?? null)
172
+ done()
173
+ },
174
+ onNotify: (m: string, t?: "info" | "warning" | "error") => host.notify(m, t),
175
+ })
176
+ return panel
177
+ })
178
+ })
179
+ }
@@ -0,0 +1,68 @@
1
+ // SPEC-6-3 §3.7 — saved-workflow discovery. Project > global > builtin (later scopes win by name).
2
+ // A workflow .js exports `meta` { name, description, phases? } + a script body. We parse the
3
+ // source via the canonical balanced-brace parser (source.ts) which produces the meta, the
4
+ // stripped body, and a normalized `executable` string the vm realm can compile as CommonJS.
5
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { parseWorkflowSource } from "./source.ts";
8
+
9
+ export type WorkflowSource = "builtin" | "global" | "project";
10
+
11
+ export interface WorkflowDef {
12
+ name: string;
13
+ description: string;
14
+ phases: { title: string }[];
15
+ sourceText: string; // the original file content
16
+ body: string; // script body after the meta declaration is removed
17
+ executable: string; // normalized CommonJS executable (passed to the runner)
18
+ source: WorkflowSource;
19
+ filePath: string;
20
+ }
21
+
22
+ export interface DiscoverOpts { projectDir: string; globalDir: string; builtinDir: string; }
23
+
24
+ export interface DiscoverResult {
25
+ workflows: Map<string, WorkflowDef>;
26
+ errors: string[];
27
+ warnings: string[];
28
+ }
29
+
30
+ function loadDir(dir: string, source: WorkflowSource, into: Map<string, WorkflowDef>, errors: string[]): void {
31
+ if (!existsSync(dir)) return;
32
+ for (const f of readdirSync(dir)) {
33
+ if (!f.endsWith(".js")) continue;
34
+ const filePath = join(dir, f);
35
+ const content = readFileSync(filePath, "utf8");
36
+ try {
37
+ const parsed = parseWorkflowSource(content, { filePath, requireMeta: true });
38
+ if (!parsed.meta) { errors.push(`${filePath}: missing \`export const meta = {…}\``); continue; }
39
+ const { name, description, phases } = parsed.meta;
40
+ into.set(name, { name, description, phases, sourceText: parsed.source, body: parsed.body, executable: parsed.executable, source, filePath });
41
+ } catch (e) {
42
+ errors.push((e as Error).message);
43
+ }
44
+ }
45
+ }
46
+
47
+ /** Discover workflows from three scopes. Later scopes win by name (project > global > builtin). */
48
+ export function discoverWorkflows(opts: DiscoverOpts): DiscoverResult {
49
+ const map = new Map<string, WorkflowDef>();
50
+ const errors: string[] = [];
51
+ loadDir(opts.builtinDir, "builtin", map, errors);
52
+ loadDir(opts.globalDir, "global", map, errors);
53
+ loadDir(opts.projectDir, "project", map, errors); // project shadows (set last wins)
54
+ return { workflows: map, errors, warnings: [] };
55
+ }
56
+
57
+ export class WorkflowRegistry {
58
+ private readonly byName = new Map<string, WorkflowDef>();
59
+ constructor(workflows: Map<string, WorkflowDef>) { for (const [k, v] of workflows) this.byName.set(k, v); }
60
+ get(name: string): WorkflowDef | undefined { return this.byName.get(name); }
61
+ list(): WorkflowDef[] { return [...this.byName.values()]; }
62
+
63
+ /** SPEC-6-3 §6: clear + repopulate the existing map (live reference preserved for all consumers). */
64
+ replace(workflows: WorkflowDef[]): void {
65
+ this.byName.clear();
66
+ for (const w of workflows) this.byName.set(w.name, w);
67
+ }
68
+ }