@getpipher/armory-fleet 0.11.0 → 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 +112 -8
- package/src/panel/fleet-panel.ts +165 -11
- package/src/runtime/async-runner.ts +79 -25
- package/src/runtime/reconcile.ts +12 -9
- package/src/runtime/resume.ts +21 -15
- package/src/runtime/run-journal.ts +2 -2
- package/src/scheduling/scheduler.ts +4 -0
- package/src/tools/fleet.ts +179 -0
- package/src/tools/subagent.ts +8 -2
- 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/src/worktree/worktree-service.ts +10 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
interface Judgment { score: number; reason?: string }
|
|
4
|
+
interface PanelResult { index: number; attempt: unknown; score: number; judgments: Judgment[] }
|
|
5
|
+
|
|
6
|
+
/** SPEC-6-3 §3.3 — N judges score every attempt; highest average score wins. Closes 6-2.1. */
|
|
7
|
+
export async function judgePanel(
|
|
8
|
+
attempts: unknown[],
|
|
9
|
+
opts: { judges?: number; rubric?: string; tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number } = {},
|
|
10
|
+
ctx: HelperCtx,
|
|
11
|
+
): Promise<PanelResult | undefined> {
|
|
12
|
+
if (attempts.length === 0) return undefined;
|
|
13
|
+
const judges = opts.judges ?? 3;
|
|
14
|
+
const rubric = opts.rubric ?? "overall quality and correctness";
|
|
15
|
+
const perAttempt: Judgment[][] = attempts.map(() => []);
|
|
16
|
+
for (let j = 0; j < judges; j++) {
|
|
17
|
+
for (let a = 0; a < attempts.length; a++) {
|
|
18
|
+
const prompt = `You are judge ${j + 1} of ${judges}. Score this attempt on a 0-10 scale.\nRubric: ${rubric}\nAttempt ${a}: ${JSON.stringify(attempts[a])}\nRespond as JSON: {"score": number, "reason": string}`;
|
|
19
|
+
const res = await ctx.spawn(prompt, { agent: "reviewer", ...(opts.tier ? { tier: opts.tier } : {}), ...(opts.model ? { model: opts.model } : {}), ...(opts.skills ? { skills: opts.skills } : {}), ...(opts.backend ? { backend: opts.backend } : {}), ...(opts.retries ? { retries: opts.retries } : {}), ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
20
|
+
if (res && res.status === "completed") {
|
|
21
|
+
try { const p = JSON.parse(res.finalText) as { score?: number; reason?: string }; perAttempt[a]!.push({ score: typeof p.score === "number" ? p.score : 0, ...(p.reason ? { reason: p.reason } : {}) }); }
|
|
22
|
+
catch { perAttempt[a]!.push({ score: 0 }); }
|
|
23
|
+
} else { perAttempt[a]!.push({ score: 0 }); }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
let best: PanelResult | undefined;
|
|
27
|
+
for (let a = 0; a < attempts.length; a++) {
|
|
28
|
+
const js = perAttempt[a]!;
|
|
29
|
+
const score = js.reduce((s, x) => s + x.score, 0) / (js.length || 1);
|
|
30
|
+
if (!best || score > best.score) best = { index: a, attempt: attempts[a], score, judgments: js };
|
|
31
|
+
}
|
|
32
|
+
return best;
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** SPEC-6-3 §3.3 — discovery loop: call round(n) → items; de-dupe by key; stop after consecutiveEmpty empty rounds or maxRounds. */
|
|
4
|
+
export async function loopUntilDry(
|
|
5
|
+
opts: { round: (roundIndex: number) => unknown[] | Promise<unknown[]>; key?: (item: unknown) => string; consecutiveEmpty?: number; maxRounds?: number },
|
|
6
|
+
ctx: HelperCtx,
|
|
7
|
+
): Promise<unknown[]> {
|
|
8
|
+
const consecutiveEmpty = opts.consecutiveEmpty ?? 2;
|
|
9
|
+
const maxRounds = opts.maxRounds ?? 50;
|
|
10
|
+
const key = opts.key ?? ((item: unknown) => JSON.stringify(item));
|
|
11
|
+
const seen = new Set<string>();
|
|
12
|
+
const acc: unknown[] = [];
|
|
13
|
+
let emptyStreak = 0;
|
|
14
|
+
for (let n = 0; n < maxRounds; n++) {
|
|
15
|
+
const items = await opts.round(n);
|
|
16
|
+
const fresh = (Array.isArray(items) ? items : []).filter((it) => { const k = key(it); if (seen.has(k)) return false; seen.add(k); return true; });
|
|
17
|
+
for (const f of fresh) acc.push(f);
|
|
18
|
+
if (fresh.length === 0) { emptyStreak++; if (emptyStreak >= consecutiveEmpty) break; } else emptyStreak = 0;
|
|
19
|
+
}
|
|
20
|
+
return acc;
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** SPEC-6-3 §3.3 — retry a thunk up to `attempts`; `until` decides when to stop. */
|
|
4
|
+
export async function retry(
|
|
5
|
+
thunk: (attempt: number) => unknown | Promise<unknown>,
|
|
6
|
+
opts: { attempts?: number; until?: (result: unknown) => boolean } = {},
|
|
7
|
+
ctx: HelperCtx,
|
|
8
|
+
): Promise<unknown> {
|
|
9
|
+
const attempts = opts.attempts ?? 3;
|
|
10
|
+
const until = opts.until ?? (() => true);
|
|
11
|
+
let last: unknown;
|
|
12
|
+
for (let n = 0; n < attempts; n++) {
|
|
13
|
+
try { last = await thunk(n); } catch { continue; } // recoverable: try again
|
|
14
|
+
if (until(last)) return last;
|
|
15
|
+
}
|
|
16
|
+
return last;
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { WorkflowJournal } from "../journal.ts";
|
|
2
|
+
|
|
3
|
+
export interface HelperSpawnResult {
|
|
4
|
+
finalText: string;
|
|
5
|
+
runId: string;
|
|
6
|
+
status: "completed" | "failed";
|
|
7
|
+
costTotal?: number;
|
|
8
|
+
tokenTotal?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HelperCtx {
|
|
12
|
+
spawn: (prompt: string, opts?: { agent?: string; tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number }) => Promise<HelperSpawnResult | null>;
|
|
13
|
+
journal: WorkflowJournal;
|
|
14
|
+
runId: string;
|
|
15
|
+
budget?: { spent: () => number; remaining: () => number };
|
|
16
|
+
onCheckpoint?: (prompt: string, opts: Record<string, unknown>) => Promise<unknown>;
|
|
17
|
+
getModelContextWindow?: (model: string) => number | undefined;
|
|
18
|
+
nextCallIndex: () => number;
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const REAL_RE = /\b(real|valid|correct|true|confirmed|legit)\b/i;
|
|
4
|
+
|
|
5
|
+
function judgeVote(text: string): { real: boolean; reason?: string } {
|
|
6
|
+
return { real: REAL_RE.test(text), reason: text.slice(0, 200) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** SPEC-6-3 §3.3 — N reviewers vote; real = realCount/total >= threshold (default 0.5). */
|
|
10
|
+
export async function verify(
|
|
11
|
+
item: unknown,
|
|
12
|
+
opts: { reviewers?: number; threshold?: number; lens?: string | string[]; tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number } = {},
|
|
13
|
+
ctx: HelperCtx,
|
|
14
|
+
): Promise<{ real: boolean; realCount: number; total: number; votes: Array<{ real: boolean; reason?: string }> }> {
|
|
15
|
+
const reviewers = opts.reviewers ?? 2;
|
|
16
|
+
const threshold = opts.threshold ?? 0.5;
|
|
17
|
+
const lens = opts.lens ? ` Focus lens: ${Array.isArray(opts.lens) ? opts.lens.join(", ") : opts.lens}.` : "";
|
|
18
|
+
const prompt = `You are an independent reviewer. Decide if the following item is REAL/valid.\nItem: ${JSON.stringify(item)}${lens}\nRespond with "real" or "fake" + a one-line reason.`;
|
|
19
|
+
const votes: Array<{ real: boolean; reason?: string }> = [];
|
|
20
|
+
for (let i = 0; i < reviewers; i++) {
|
|
21
|
+
const res = await ctx.spawn(prompt, { agent: "reviewer", ...(opts.tier ? { tier: opts.tier } : {}), ...(opts.model ? { model: opts.model } : {}), ...(opts.skills ? { skills: opts.skills } : {}), ...(opts.backend ? { backend: opts.backend } : {}), ...(opts.retries ? { retries: opts.retries } : {}), ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
22
|
+
if (!res || res.status !== "completed") { votes.push({ real: false, reason: "reviewer failed" }); continue; }
|
|
23
|
+
votes.push(judgeVote(res.finalText));
|
|
24
|
+
}
|
|
25
|
+
const realCount = votes.filter((v) => v.real).length;
|
|
26
|
+
return { real: realCount / reviewers >= threshold, realCount, total: reviewers, votes };
|
|
27
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPEC-6-3 — the per-workflow positional-call-index journal.
|
|
2
|
+
// Append-only JSONL at <dir>/<runId>.jsonl. Crash-safe: a partial last line is discarded.
|
|
3
|
+
// Separate from RunLog (per-agent conversations/) and RunJournal (per-lifecycle runs/).
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export interface WorkflowStartedEvent { type: "wf:started"; runId: string; script: string; args?: unknown; phases?: { title: string }[]; mode: "auto" | "checkpointed"; ts: number; }
|
|
8
|
+
export interface AgentCallEvent { type: "agent:call"; callIndex: number; label: string; phase: string; prompt: string; opts: Record<string, unknown>; childRunId?: string; ts: number; }
|
|
9
|
+
export interface AgentResultEvent { type: "agent:result"; callIndex: number; childRunId: string; result: unknown; status: "completed" | "failed"; costTotal?: number; tokenTotal?: number; ts: number; }
|
|
10
|
+
export interface HelperCallEvent { type: "helper:call"; callIndex: number; name: string; args: unknown; ts: number; }
|
|
11
|
+
export interface HelperResultEvent { type: "helper:result"; callIndex: number; name: string; result: unknown; ts: number; }
|
|
12
|
+
export interface CheckpointEvent { type: "checkpoint"; callIndex: number; prompt: string; response: unknown; ts: number; }
|
|
13
|
+
export interface WorkflowCompletedEvent { type: "wf:completed"; runId: string; result: unknown; costTotal?: number; tokenTotal?: number; ts: number; }
|
|
14
|
+
export interface WorkflowAbortedEvent { type: "wf:aborted"; runId: string; reason: string; ts: number; }
|
|
15
|
+
|
|
16
|
+
export interface WorkflowProgressJournalEvent {
|
|
17
|
+
type: "wf:progress";
|
|
18
|
+
kind: "started" | "phase" | "child-started" | "child-completed" | "child-failed" | "helper-started" | "helper-completed" | "log" | "checkpoint" | "checkpoint-resolved" | "completed" | "failed" | "aborted";
|
|
19
|
+
runId: string;
|
|
20
|
+
status: string;
|
|
21
|
+
currentPhase: string;
|
|
22
|
+
phases: Array<{ title: string; agents: number; cached: number; reRun: number }>;
|
|
23
|
+
childRunIds: string[];
|
|
24
|
+
logs: string[];
|
|
25
|
+
tokenTotal: number;
|
|
26
|
+
costTotal: number;
|
|
27
|
+
checkpoint?: { prompt: string; opts: Record<string, unknown> };
|
|
28
|
+
ts: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type WorkflowJournalEvent =
|
|
32
|
+
| WorkflowStartedEvent | AgentCallEvent | AgentResultEvent | HelperCallEvent
|
|
33
|
+
| HelperResultEvent | CheckpointEvent | WorkflowCompletedEvent | WorkflowAbortedEvent
|
|
34
|
+
| WorkflowProgressJournalEvent;
|
|
35
|
+
|
|
36
|
+
const WORKFLOW_TERMINAL = new Set<WorkflowJournalEvent["type"]>(["wf:completed", "wf:aborted"]);
|
|
37
|
+
|
|
38
|
+
export class WorkflowJournal {
|
|
39
|
+
constructor(private readonly dir: string) {}
|
|
40
|
+
|
|
41
|
+
private file(runId: string): string { return join(this.dir, `${runId}.jsonl`); }
|
|
42
|
+
|
|
43
|
+
append(runId: string, event: WorkflowJournalEvent): void {
|
|
44
|
+
try {
|
|
45
|
+
mkdirSync(this.dir, { recursive: true });
|
|
46
|
+
appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
// best-effort: never fail the workflow because the journal couldn't persist.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
replay(runId: string): WorkflowJournalEvent[] {
|
|
53
|
+
const f = this.file(runId);
|
|
54
|
+
if (!existsSync(f)) return [];
|
|
55
|
+
const events: WorkflowJournalEvent[] = [];
|
|
56
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
57
|
+
if (!line) continue;
|
|
58
|
+
try { events.push(JSON.parse(line) as WorkflowJournalEvent); }
|
|
59
|
+
catch { /* partial last line (crash mid-append) — discard */ }
|
|
60
|
+
}
|
|
61
|
+
return events;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
scanNonTerminal(): string[] {
|
|
65
|
+
if (!existsSync(this.dir)) return [];
|
|
66
|
+
const ids: string[] = [];
|
|
67
|
+
for (const f of readdirSync(this.dir)) {
|
|
68
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
69
|
+
const runId = f.slice(0, -".jsonl".length);
|
|
70
|
+
const events = this.replay(runId);
|
|
71
|
+
const last = events[events.length - 1];
|
|
72
|
+
if (last && !WORKFLOW_TERMINAL.has(last.type)) ids.push(runId);
|
|
73
|
+
}
|
|
74
|
+
return ids;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPEC-6-3 §9 — bounded keyword authorization for workflow capability.
|
|
2
|
+
// Returns a bounded system hint when the prompt mentions "workflow"/"workflows"
|
|
3
|
+
// as a standalone word (not as part of an identifier or file path).
|
|
4
|
+
|
|
5
|
+
const WORKFLOW_KEYWORD = /(?:^|[^A-Za-z0-9_])workflows?(?=$|[^A-Za-z0-9_])/i
|
|
6
|
+
|
|
7
|
+
export function workflowKeywordHint(prompt: string): string | undefined {
|
|
8
|
+
const match = prompt.match(WORKFLOW_KEYWORD)
|
|
9
|
+
if (!match) return undefined
|
|
10
|
+
|
|
11
|
+
const fullMatch = match[0]!
|
|
12
|
+
// If the match includes a preceding non-word char, check if it's a path separator.
|
|
13
|
+
const firstChar = fullMatch[0]!
|
|
14
|
+
if (firstChar === "/" || firstChar === ".") return undefined
|
|
15
|
+
|
|
16
|
+
// Check the character after "workflow" in the original string.
|
|
17
|
+
const afterIdx = (match.index ?? 0) + fullMatch.length
|
|
18
|
+
const afterChar = prompt[afterIdx]
|
|
19
|
+
if (afterChar === "-" || afterChar === ".") return undefined
|
|
20
|
+
|
|
21
|
+
return "workflow capability authorized — use action:'workflow' with a script or workflowName"
|
|
22
|
+
}
|
|
@@ -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
|
+
}
|