@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
|
@@ -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
|
+
}
|