@worca/app 1.0.0 → 1.2.0-rc.1
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/README.md +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
package/src/core/workflows.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// src/core/workflows.mjs
|
|
2
2
|
// node:sqlite migration: now persisted in the `workflows` table; path helpers vestigial.
|
|
3
|
-
// Global workflow-template store + the built-in
|
|
3
|
+
// Global workflow-template store + the built-in GRAPH_DEFAULT_WORKFLOW + resolveGraph.
|
|
4
4
|
//
|
|
5
5
|
// Templates are TOPOLOGY + PER-NODE DEFAULTS (steps + feedbacks by node-instance
|
|
6
6
|
// id; each node may carry an optional `defaults` block — newpipeline-ux-design.md
|
|
7
7
|
// §4.4). Per-project model/effort/cycle data is the run-config in config.mjs and
|
|
8
|
-
// OVERRIDES those defaults;
|
|
8
|
+
// OVERRIDES those defaults; resolveGraph merges both.
|
|
9
9
|
//
|
|
10
10
|
// Reads never throw: a missing/corrupt store yields []/null.
|
|
11
11
|
|
|
@@ -15,7 +15,18 @@ import { join } from 'node:path';
|
|
|
15
15
|
import { getDb, prepare, tx } from './db.mjs';
|
|
16
16
|
import { worcaHome } from './projects.mjs';
|
|
17
17
|
import { resolveRunConfig, readConfig, EFFORTS } from './config.mjs';
|
|
18
|
+
import { isSubagentModelValue } from './model-env.mjs';
|
|
19
|
+
|
|
20
|
+
/** Enum guard for one resolveGraph layer: a legal value passes, anything else
|
|
21
|
+
* is `undefined` so firstDefined falls through to the next layer. */
|
|
22
|
+
const validSubagentModel = (v) => (isSubagentModelValue(v) ? v : undefined);
|
|
18
23
|
import { slugify } from './artifacts.mjs';
|
|
24
|
+
import { DEFAULT_AGENTS_DIR, loadAgentRegistry } from './agent-registry.mjs'; // fileURLToPath-based (Windows-safe)
|
|
25
|
+
import { validateGraph, formatIssue, AGENT_TUNABLES } from '../shared/graph/validate.mjs';
|
|
26
|
+
import { classifyLoops } from '../shared/graph/loops.mjs';
|
|
27
|
+
import { GRAPH_DEFAULT_WORKFLOW } from './graph/builtin-workflows.mjs';
|
|
28
|
+
export { GRAPH_DEFAULT_WORKFLOW };
|
|
29
|
+
import { registryPortsFn } from './graph/registry-ports.mjs';
|
|
19
30
|
|
|
20
31
|
/**
|
|
21
32
|
* Default feedback cycle count when run-config does not override it. Matches the
|
|
@@ -33,9 +44,6 @@ function normDomain(raw) {
|
|
|
33
44
|
return DOMAIN_RE.test(v) ? v : 'general';
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
/** Default location of the agent prompt markdown files (mirrors orchestrator.mjs). */
|
|
37
|
-
const DEFAULT_AGENTS_DIR = new URL('../../agents/', import.meta.url).pathname;
|
|
38
|
-
|
|
39
47
|
/**
|
|
40
48
|
* Read an agent prompt file and pull its declared tools from YAML frontmatter.
|
|
41
49
|
* Returns { prompt, tools }. A missing file => { prompt:'', tools:[] } (fails
|
|
@@ -46,20 +54,20 @@ const DEFAULT_AGENTS_DIR = new URL('../../agents/', import.meta.url).pathname;
|
|
|
46
54
|
* @param {string|null} [agentPath]
|
|
47
55
|
* @returns {Promise<{prompt:string, tools:string[]}>}
|
|
48
56
|
*/
|
|
49
|
-
async function loadAgentFile(agentsDir, agentFile, agentPath = null) {
|
|
57
|
+
export async function loadAgentFile(agentsDir, agentFile, agentPath = null) {
|
|
50
58
|
if (!agentFile && !agentPath) return { prompt: '', tools: [] };
|
|
51
59
|
let text = '';
|
|
52
60
|
try {
|
|
53
|
-
// Layered registry: the meta's stamped absolute agentPath (built-in OR
|
|
54
|
-
// layer) wins; the
|
|
55
|
-
//
|
|
61
|
+
// Layered registry: the meta's stamped absolute agentPath (built-in, user OR
|
|
62
|
+
// plugin layer) wins; the agentsDir+agentFile join serves hand-built
|
|
63
|
+
// registries (tests) that carry no agentPath. A stamped path that cannot be
|
|
64
|
+
// read is an EMPTY prompt, never a fallback into the built-in dir: that
|
|
65
|
+
// fallback let a plugin sidecar naming an absent built-in file (e.g.
|
|
66
|
+
// worca-cc-manual-web-ui-testing.md) run the built-in's prompt and tool
|
|
67
|
+
// grants while its consent card said "none declared" (C-1).
|
|
56
68
|
text = await readFile(agentPath || join(agentsDir, agentFile), 'utf8');
|
|
57
69
|
} catch {
|
|
58
|
-
|
|
59
|
-
try { text = await readFile(join(agentsDir, agentFile), 'utf8'); } catch { return { prompt: '', tools: [] }; }
|
|
60
|
-
} else {
|
|
61
|
-
return { prompt: '', tools: [] };
|
|
62
|
-
}
|
|
70
|
+
return { prompt: '', tools: [] };
|
|
63
71
|
}
|
|
64
72
|
return { prompt: text, tools: parseFrontmatterTools(text) };
|
|
65
73
|
}
|
|
@@ -77,39 +85,6 @@ function parseFrontmatterTools(text) {
|
|
|
77
85
|
.filter(Boolean);
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
/**
|
|
81
|
-
* The built-in default workflow: the CURRENT pipeline Plan -> Refine -> Implement
|
|
82
|
-
* -> Review, with the two feedback loops that reproduce today's _refineLoop and
|
|
83
|
-
* _reviewLoop (orchestrator.mjs:331-459):
|
|
84
|
-
* - refiner self-loop (s1_0 -> s1_0): re-run the refine step on blocking issues.
|
|
85
|
-
* - review -> implement (s3_0 -> s2_0): on blocking review issues, run an
|
|
86
|
-
* implementer fix pass (the 'to' step) then re-review.
|
|
87
|
-
* Default cycle counts come from run-config resolution (resolveRunConfig falls
|
|
88
|
-
* back to DEFAULT_MAX_CYCLES = 3).
|
|
89
|
-
* NOT persisted to the user store; always present; readWorkflow('wf_default')
|
|
90
|
-
* returns it.
|
|
91
|
-
* @type {{id:string,name:string,version:number,steps:Array<Array<{id:string,key:string}>>,feedbacks:Array<{id:string,from:string,to:string}>,createdAt:string,updatedAt:string}}
|
|
92
|
-
*/
|
|
93
|
-
export const DEFAULT_WORKFLOW = Object.freeze({
|
|
94
|
-
id: 'wf_default',
|
|
95
|
-
name: 'Default',
|
|
96
|
-
version: 1,
|
|
97
|
-
domain: 'coding', // built-in coding flow
|
|
98
|
-
steps: [
|
|
99
|
-
[{ id: 's_clarify', key: 'clarify' }],
|
|
100
|
-
[{ id: 's0_0', key: 'planner' }],
|
|
101
|
-
[{ id: 's1_0', key: 'refiner' }],
|
|
102
|
-
[{ id: 's2_0', key: 'implementer' }],
|
|
103
|
-
[{ id: 's3_0', key: 'reviewer' }],
|
|
104
|
-
],
|
|
105
|
-
feedbacks: [
|
|
106
|
-
{ id: 'fb_refine', from: 's1_0', to: 's1_0' },
|
|
107
|
-
{ id: 'fb_review', from: 's3_0', to: 's2_0' },
|
|
108
|
-
],
|
|
109
|
-
createdAt: '1970-01-01T00:00:00.000Z',
|
|
110
|
-
updatedAt: '1970-01-01T00:00:00.000Z',
|
|
111
|
-
});
|
|
112
|
-
|
|
113
88
|
/**
|
|
114
89
|
* Sanitize one node's `defaults` block (newpipeline-ux-design.md §4.4). Loud and
|
|
115
90
|
* lenient, matching the module's house style: a malformed FIELD is dropped with a
|
|
@@ -120,7 +95,7 @@ export const DEFAULT_WORKFLOW = Object.freeze({
|
|
|
120
95
|
* setStep/setNodeModel.
|
|
121
96
|
* @param {unknown} raw
|
|
122
97
|
* @param {string} [nodeId] for the warning message
|
|
123
|
-
* @returns {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean}|undefined}
|
|
98
|
+
* @returns {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean,subagentModel?:string}|undefined}
|
|
124
99
|
*/
|
|
125
100
|
export function sanitizeNodeDefaults(raw, nodeId = '?') {
|
|
126
101
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
@@ -149,6 +124,14 @@ export function sanitizeNodeDefaults(raw, nodeId = '?') {
|
|
|
149
124
|
if (typeof raw[field] === 'boolean') out[field] = raw[field];
|
|
150
125
|
else warn(field, 'not a boolean');
|
|
151
126
|
}
|
|
127
|
+
// subagentModel is an ALIAS enum, not a catalog id (the CLI's Task tool refuses
|
|
128
|
+
// anything else), so unlike `model` it is validated structurally right here.
|
|
129
|
+
if (raw.subagentModel !== undefined) {
|
|
130
|
+
if (isSubagentModelValue(raw.subagentModel)) out.subagentModel = raw.subagentModel;
|
|
131
|
+
else if (raw.subagentModel !== '' && raw.subagentModel !== null) {
|
|
132
|
+
warn('subagentModel', `unknown sub-agent model "${raw.subagentModel}"`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
152
135
|
return Object.keys(out).length ? out : undefined;
|
|
153
136
|
}
|
|
154
137
|
|
|
@@ -180,6 +163,14 @@ export function sanitizeWorkflowSteps(steps) {
|
|
|
180
163
|
*/
|
|
181
164
|
export function workflowNodeDefaults(tpl) {
|
|
182
165
|
const out = {};
|
|
166
|
+
if (Array.isArray(tpl?.nodes)) { // v2: defaults ARE node.config
|
|
167
|
+
for (const node of tpl.nodes) {
|
|
168
|
+
if (node?.kind !== 'agent') continue;
|
|
169
|
+
const clean = sanitizeNodeDefaults(node.config, node.id);
|
|
170
|
+
if (clean) out[node.id] = clean;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
183
174
|
for (const group of Array.isArray(tpl?.steps) ? tpl.steps : []) {
|
|
184
175
|
for (const node of Array.isArray(group) ? group : []) {
|
|
185
176
|
if (node && node.id && node.defaults && typeof node.defaults === 'object') out[node.id] = node.defaults;
|
|
@@ -194,9 +185,22 @@ export function workflowsDir() {
|
|
|
194
185
|
}
|
|
195
186
|
|
|
196
187
|
/** A workflow id is a stem; reject anything that could escape a path-built store
|
|
197
|
-
* (path separators, "..", dots, spaces). Valid ids are wf_<slug> / wf_default.
|
|
188
|
+
* (path separators, "..", dots, spaces). Valid ids are wf_<slug> / wf_default.
|
|
189
|
+
* EXPORTED because it is also the API's gate: PATCH /api/config keys three
|
|
190
|
+
* normalized tables by workflowId, and a workflow id is used as an OBJECT KEY
|
|
191
|
+
* (config.mjs readWorkflowsMap, app.js state.config.workflows[id]) — so every
|
|
192
|
+
* own property name of Object.prototype is refused too. The bare regex accepts
|
|
193
|
+
* "__proto__" (it is only letters and underscores), which is exactly how MAJ-1
|
|
194
|
+
* slipped through. Minted ids are always wf_/wfp_-prefixed, so nothing
|
|
195
|
+
* legitimate can collide with that rule. One rule, one source of truth. */
|
|
198
196
|
const SAFE_WORKFLOW_ID = /^[A-Za-z0-9_-]+$/;
|
|
199
|
-
|
|
197
|
+
/** A workflow id is also an OBJECT KEY (config.mjs readWorkflowsMap, app.js state.config.workflows[id]),
|
|
198
|
+
* so any own property name of Object.prototype ('__proto__', 'constructor', 'hasOwnProperty',
|
|
199
|
+
* 'toString', …) is refused: on a plain object those resolve truthy and the next `.wires[id] =` throws. */
|
|
200
|
+
const isInheritedName = (id) => Object.prototype.hasOwnProperty.call(Object.prototype, id);
|
|
201
|
+
export function isSafeWorkflowId(id) {
|
|
202
|
+
return typeof id === 'string' && SAFE_WORKFLOW_ID.test(id) && !isInheritedName(id);
|
|
203
|
+
}
|
|
200
204
|
|
|
201
205
|
/** Fail-safe JSON.parse to an array; returns [] on any error. */
|
|
202
206
|
function parseArr(text) {
|
|
@@ -204,30 +208,44 @@ function parseArr(text) {
|
|
|
204
208
|
try { const v = JSON.parse(text); return Array.isArray(v) ? v : []; } catch { return []; }
|
|
205
209
|
}
|
|
206
210
|
|
|
207
|
-
/** Map a workflows row to the template object shape.
|
|
211
|
+
/** Map a workflows row to the template object shape. Version-aware: `graph`
|
|
212
|
+
* carries {nodes, wires, canvas?} ONLY — id/name/domain/origin stay row columns,
|
|
213
|
+
* so a rename can never drift. */
|
|
208
214
|
function rowToTpl(r) {
|
|
209
|
-
|
|
215
|
+
const base = {
|
|
210
216
|
id: r.id,
|
|
211
217
|
name: r.name,
|
|
212
218
|
version: r.version,
|
|
213
219
|
domain: r.domain || 'general', // pre-migration NULL → 'general'
|
|
214
220
|
origin: r.origin || null, // 'plugin:<name>' provenance; NULL = user-created
|
|
215
|
-
steps: parseArr(r.steps),
|
|
216
|
-
feedbacks: parseArr(r.feedbacks),
|
|
217
221
|
createdAt: r.created_at,
|
|
218
222
|
updatedAt: r.updated_at,
|
|
223
|
+
archivedAt: r.archived_at || null,
|
|
219
224
|
};
|
|
225
|
+
if (r.version === 2) {
|
|
226
|
+
let graph = {};
|
|
227
|
+
try { graph = JSON.parse(r.graph || '{}') || {}; } catch { graph = {}; }
|
|
228
|
+
base.nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
|
|
229
|
+
base.wires = Array.isArray(graph.wires) ? graph.wires : [];
|
|
230
|
+
if (graph.canvas && typeof graph.canvas === 'object') base.canvas = graph.canvas;
|
|
231
|
+
return base;
|
|
232
|
+
}
|
|
233
|
+
base.steps = parseArr(r.steps);
|
|
234
|
+
base.feedbacks = parseArr(r.feedbacks);
|
|
235
|
+
return base;
|
|
220
236
|
}
|
|
221
237
|
|
|
238
|
+
const ROW_COLS = 'id, name, version, domain, steps, feedbacks, graph, archived_at, created_at, updated_at, origin';
|
|
239
|
+
|
|
222
240
|
/** Read + shallow-validate one stored template row. Unsafe id / missing => null. */
|
|
223
|
-
function readRaw(id) {
|
|
241
|
+
function readRaw(id, { includeArchived = false } = {}) {
|
|
224
242
|
if (!isSafeWorkflowId(id)) return null; // SECURITY: reject path-traversal / unsafe ids
|
|
225
243
|
getDb();
|
|
226
|
-
const r = prepare(
|
|
227
|
-
'SELECT id, name, version, domain, steps, feedbacks, created_at, updated_at, origin FROM workflows WHERE id = ?'
|
|
228
|
-
).get(id);
|
|
244
|
+
const r = prepare(`SELECT ${ROW_COLS} FROM workflows WHERE id = ?`).get(id);
|
|
229
245
|
if (!r) return null;
|
|
246
|
+
if (!includeArchived && r.archived_at) return null;
|
|
230
247
|
const tpl = rowToTpl(r);
|
|
248
|
+
if (tpl.version === 2) return Array.isArray(tpl.nodes) ? tpl : null;
|
|
231
249
|
return Array.isArray(tpl.steps) ? tpl : null; // mirror the legacy steps-array check
|
|
232
250
|
}
|
|
233
251
|
|
|
@@ -271,27 +289,145 @@ export async function writeWorkflow(tpl) {
|
|
|
271
289
|
}
|
|
272
290
|
|
|
273
291
|
/**
|
|
274
|
-
*
|
|
275
|
-
*
|
|
292
|
+
* Persist a v2 graph template. Saving over an archived id UN-archives it (the
|
|
293
|
+
* user rebuilt it on purpose). v1 columns are written empty so a v1 reader can
|
|
294
|
+
* never mistake a graph row for a step plan.
|
|
295
|
+
*
|
|
296
|
+
* `rejectCollision` (MAJ-5) makes a save whose id was MINTED from the name throw
|
|
297
|
+
* ID_TAKEN instead of upserting over a LIVE row it never asked for. It is opt-in
|
|
298
|
+
* because the importers/seeders that re-write a row by design must keep the
|
|
299
|
+
* upsert; the API's save path is the one caller that turns it on.
|
|
300
|
+
*
|
|
301
|
+
* @param {{id?:string, name:string, domain?:string, origin?:string, nodes:Array, wires:Array, canvas?:object}} tpl
|
|
302
|
+
* @param {{rejectCollision?:boolean}} [opts]
|
|
303
|
+
* @throws {Error} code 'RESERVED_NAME' (the name slugs onto wf_default) or,
|
|
304
|
+
* with rejectCollision, code 'ID_TAKEN' + `.id` (the minted id is in use)
|
|
305
|
+
*/
|
|
306
|
+
export async function writeGraphWorkflow(tpl, opts = {}) {
|
|
307
|
+
const now = new Date().toISOString();
|
|
308
|
+
const name = (tpl && typeof tpl.name === 'string' && tpl.name.trim()) || 'Untitled';
|
|
309
|
+
// The ONE reserved id is the built-in default's; a save may never claim it,
|
|
310
|
+
// so it falls back to the slug.
|
|
311
|
+
const asked = tpl && typeof tpl.id === 'string' ? tpl.id.trim() : '';
|
|
312
|
+
const minted = !(asked && isSafeWorkflowId(asked) && asked !== GRAPH_DEFAULT_WORKFLOW.id);
|
|
313
|
+
const id = minted ? `wf_${slugify(name)}` : asked;
|
|
314
|
+
// C-3: the fallback re-mints the reserved id for ANY name slugging to
|
|
315
|
+
// "default" ('Default', ' dEfAuLt ', 'default!!', 'Défault'…). That row is
|
|
316
|
+
// filtered out of listWorkflows(), short-circuited past by readWorkflow() and
|
|
317
|
+
// refused by DELETE — the user's pipeline would vanish behind a 201. Refuse
|
|
318
|
+
// the WRITE instead; only the name is wrong, so the caller can rename.
|
|
319
|
+
if (id === GRAPH_DEFAULT_WORKFLOW.id) {
|
|
320
|
+
throw Object.assign(
|
|
321
|
+
new Error(`the name "${GRAPH_DEFAULT_WORKFLOW.name}" is reserved — choose another name`),
|
|
322
|
+
{ code: 'RESERVED_NAME' });
|
|
323
|
+
}
|
|
324
|
+
const domain = normDomain(tpl && tpl.domain);
|
|
325
|
+
const origin = typeof tpl?.origin === 'string' && tpl.origin ? tpl.origin : null;
|
|
326
|
+
const graph = { nodes: Array.isArray(tpl?.nodes) ? tpl.nodes : [], wires: Array.isArray(tpl?.wires) ? tpl.wires : [] };
|
|
327
|
+
if (tpl?.canvas && typeof tpl.canvas === 'object') graph.canvas = tpl.canvas;
|
|
328
|
+
|
|
329
|
+
getDb();
|
|
330
|
+
const existing = prepare('SELECT created_at, archived_at FROM workflows WHERE id = ?').get(id);
|
|
331
|
+
// MAJ-5: the caller asked for NO id, so wf_<slug(name)> is a GUESS — landing it
|
|
332
|
+
// on a row that already exists replaced someone else's pipeline and kept the
|
|
333
|
+
// victim's created_at, leaving no trace. Only LIVE rows collide: an archived
|
|
334
|
+
// row is invisible in the saved list, so a 409 naming it would be a dead end,
|
|
335
|
+
// and re-saving an archived name is the documented way to rebuild it.
|
|
336
|
+
if (opts?.rejectCollision && minted && existing && !existing.archived_at) {
|
|
337
|
+
throw Object.assign(
|
|
338
|
+
new Error(`a pipeline with the id "${id}" already exists — choose another name`),
|
|
339
|
+
{ code: 'ID_TAKEN', id });
|
|
340
|
+
}
|
|
341
|
+
const createdAt = (typeof tpl?.createdAt === 'string' && tpl.createdAt) || existing?.created_at || now;
|
|
342
|
+
tx(() => {
|
|
343
|
+
prepare(`
|
|
344
|
+
INSERT INTO workflows (id, name, version, domain, steps, feedbacks, graph, archived_at, created_at, updated_at, origin)
|
|
345
|
+
VALUES (?, ?, 2, ?, '[]', '[]', ?, NULL, ?, ?, ?)
|
|
346
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
347
|
+
name = excluded.name, version = 2, domain = excluded.domain,
|
|
348
|
+
steps = '[]', feedbacks = '[]', graph = excluded.graph, archived_at = NULL,
|
|
349
|
+
updated_at = excluded.updated_at,
|
|
350
|
+
-- COALESCE, never a plain overwrite: the composer's Save on a loaded row
|
|
351
|
+
-- sends {id, name, nodes, wires} and NO origin, so a plain
|
|
352
|
+
-- "origin = excluded.origin" would silently detach a plugin-owned wfp_* row
|
|
353
|
+
-- from removePluginWorkflows' guard. v1's writeWorkflow never touches origin
|
|
354
|
+
-- on conflict either. (SQL comments only: a backtick here would END the JS literal.)
|
|
355
|
+
origin = COALESCE(excluded.origin, workflows.origin)
|
|
356
|
+
`).run(id, name, domain, JSON.stringify(graph), createdAt, now, origin);
|
|
357
|
+
});
|
|
358
|
+
// Re-read `origin`: the UPSERT may have KEPT an existing one this call omitted.
|
|
359
|
+
const stored = prepare('SELECT origin FROM workflows WHERE id = ?').get(id);
|
|
360
|
+
return { id, name, version: 2, domain, origin: stored?.origin ?? null, ...graph, createdAt, updatedAt: now };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Read a template by id. Returns the built-in GRAPH_DEFAULT_WORKFLOW for "wf_default";
|
|
365
|
+
* otherwise the stored row, or null when absent/corrupt/unsafe-id/archived.
|
|
276
366
|
* @param {string} id
|
|
277
367
|
* @returns {Promise<object|null>}
|
|
278
368
|
*/
|
|
279
|
-
export async function readWorkflow(id) {
|
|
280
|
-
|
|
281
|
-
|
|
369
|
+
export async function readWorkflow(id, opts = {}) {
|
|
370
|
+
// `wf_default` IS the graph: the v1 default died with the v1 engine.
|
|
371
|
+
if (id === GRAPH_DEFAULT_WORKFLOW.id) return GRAPH_DEFAULT_WORKFLOW;
|
|
372
|
+
return readRaw(id, opts);
|
|
282
373
|
}
|
|
283
374
|
|
|
284
375
|
/**
|
|
285
|
-
* List user templates (NOT
|
|
286
|
-
* createdAt. Empty store => [].
|
|
376
|
+
* List user templates (NOT GRAPH_DEFAULT_WORKFLOW — callers prepend it), newest first by
|
|
377
|
+
* createdAt. Archived rows are hidden unless asked for. Empty store => [].
|
|
287
378
|
* @returns {Promise<object[]>}
|
|
288
379
|
*/
|
|
289
|
-
export async function listWorkflows() {
|
|
380
|
+
export async function listWorkflows({ includeArchived = false } = {}) {
|
|
290
381
|
getDb();
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
).
|
|
294
|
-
|
|
382
|
+
const where = includeArchived ? '' : 'WHERE archived_at IS NULL';
|
|
383
|
+
const rows = prepare(`SELECT ${ROW_COLS} FROM workflows ${where} ORDER BY created_at DESC, id`).all();
|
|
384
|
+
return rows.filter((r) => r.id !== GRAPH_DEFAULT_WORKFLOW.id).map(rowToTpl);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The RUN-TIME graph check. A template is validated against the sidecars of the
|
|
389
|
+
* moment it was SAVED; editing an agent's ports afterwards leaves every saved
|
|
390
|
+
* pipeline wired to a port that no longer exists, and nothing re-checked it —
|
|
391
|
+
* the run started and quiesced "done" (lost output) or bound an empty input
|
|
392
|
+
* (lost input, silent). ERRORS only: warnings (V15 unreachable, …) are advisory
|
|
393
|
+
* and must never refuse a run.
|
|
394
|
+
* @param {object} tpl a version-2 template
|
|
395
|
+
* @param {Record<string,object>} [registry] injected registry; the live one otherwise
|
|
396
|
+
* @throws {Error} code 'INVALID_GRAPH', `issues` = the validator's error list
|
|
397
|
+
*/
|
|
398
|
+
function assertValidGraph(tpl, registry) {
|
|
399
|
+
const reg = registry && typeof registry === 'object' ? registry : loadAgentRegistry();
|
|
400
|
+
const { ok, errors } = validateGraph(tpl, registryPortsFn(reg));
|
|
401
|
+
if (ok) return;
|
|
402
|
+
throw Object.assign(
|
|
403
|
+
new Error(`workflow "${tpl.id}" no longer matches the agents it uses: `
|
|
404
|
+
+ `${errors.map(formatIssue).join('; ')} — open it in the Composer and re-wire it`),
|
|
405
|
+
{ code: 'INVALID_GRAPH', issues: errors },
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The ONE gate every run path goes through (POST /api/run, the CLI's
|
|
410
|
+
* --workflow, Ask's proposal validation). Throws with a `code` the callers map
|
|
411
|
+
* to HTTP/exit codes; the ARCHIVED text is user-facing and verbatim.
|
|
412
|
+
* `checkGraph:false` is the READ escape hatch (GET /api/workflows/:id): a
|
|
413
|
+
* stale template must still open in the Composer, or it can never be repaired.
|
|
414
|
+
* @param {string} id
|
|
415
|
+
* @param {{registry?:Record<string,object>, checkGraph?:boolean}} [opts]
|
|
416
|
+
*/
|
|
417
|
+
export async function assertRunnableWorkflow(id, { registry, checkGraph = true } = {}) {
|
|
418
|
+
const wanted = typeof id === 'string' && id.trim() ? id.trim() : GRAPH_DEFAULT_WORKFLOW.id;
|
|
419
|
+
const live = await readWorkflow(wanted);
|
|
420
|
+
if (live) {
|
|
421
|
+
// A v1 row is not a graph: engine-select owns its refusal (V1_RUN_RETIRED).
|
|
422
|
+
if (checkGraph && live.version === 2) assertValidGraph(live, registry);
|
|
423
|
+
return live;
|
|
424
|
+
}
|
|
425
|
+
const archived = await readWorkflow(wanted, { includeArchived: true });
|
|
426
|
+
if (archived) {
|
|
427
|
+
throw Object.assign(new Error(`workflow "${wanted}" was archived by the v2 upgrade `
|
|
428
|
+
+ '(v1 template, not runnable) — pick a v2 pipeline or rebuild it in the Composer'), { code: 'ARCHIVED' });
|
|
429
|
+
}
|
|
430
|
+
throw Object.assign(new Error(`unknown workflowId "${wanted}"`), { code: 'NOT_FOUND' });
|
|
295
431
|
}
|
|
296
432
|
|
|
297
433
|
/**
|
|
@@ -311,13 +447,30 @@ export async function listWorkflows() {
|
|
|
311
447
|
* @throws {Error} unknown/unsafe id, or the built-in default
|
|
312
448
|
*/
|
|
313
449
|
export async function setWorkflowNodeDefaults(id, map) {
|
|
314
|
-
if (id ===
|
|
450
|
+
if (id === GRAPH_DEFAULT_WORKFLOW.id) {
|
|
315
451
|
throw new Error('the built-in Default workflow cannot store defaults — save a copy in Composer first');
|
|
316
452
|
}
|
|
317
453
|
const tpl = readRaw(id);
|
|
318
454
|
if (!tpl) throw new Error(`workflow not found: ${id}`);
|
|
319
455
|
const patch = map && typeof map === 'object' ? map : {};
|
|
320
456
|
|
|
457
|
+
if (tpl.version === 2) {
|
|
458
|
+
const nodes = tpl.nodes.map((node) => {
|
|
459
|
+
if (!Object.prototype.hasOwnProperty.call(patch, node.id)) return node;
|
|
460
|
+
const clean = sanitizeNodeDefaults(patch[node.id], node.id) || {};
|
|
461
|
+
// Only the AGENT_TUNABLES are defaults; awaitAll/arity/planStoreSeed are
|
|
462
|
+
// TOPOLOGY and must survive a defaults patch untouched.
|
|
463
|
+
const kept = Object.fromEntries(Object.entries(node.config || {}).filter(([k]) => !AGENT_TUNABLES.includes(k)));
|
|
464
|
+
return { ...node, config: { ...kept, ...clean } };
|
|
465
|
+
});
|
|
466
|
+
const now = new Date().toISOString();
|
|
467
|
+
tx(() => {
|
|
468
|
+
prepare('UPDATE workflows SET graph = ?, updated_at = ? WHERE id = ?')
|
|
469
|
+
.run(JSON.stringify({ nodes, wires: tpl.wires, ...(tpl.canvas ? { canvas: tpl.canvas } : {}) }), now, id);
|
|
470
|
+
});
|
|
471
|
+
return { ...tpl, nodes, updatedAt: now };
|
|
472
|
+
}
|
|
473
|
+
|
|
321
474
|
const steps = tpl.steps.map((group) => (Array.isArray(group) ? group.map((node) => {
|
|
322
475
|
if (!node || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(patch, node.id)) return node;
|
|
323
476
|
const { defaults, ...rest } = node;
|
|
@@ -334,13 +487,13 @@ export async function setWorkflowNodeDefaults(id, map) {
|
|
|
334
487
|
}
|
|
335
488
|
|
|
336
489
|
/**
|
|
337
|
-
* Delete a saved template by id. Refuses the built-in
|
|
490
|
+
* Delete a saved template by id. Refuses the built-in GRAPH_DEFAULT_WORKFLOW (false) and
|
|
338
491
|
* unsafe ids (false). Returns false when no row exists; true on removal.
|
|
339
492
|
* @param {string} id
|
|
340
493
|
* @returns {Promise<boolean>}
|
|
341
494
|
*/
|
|
342
495
|
export async function deleteWorkflow(id) {
|
|
343
|
-
if (id ===
|
|
496
|
+
if (id === GRAPH_DEFAULT_WORKFLOW.id) return false; // built-in default is undeletable
|
|
344
497
|
if (!isSafeWorkflowId(id)) return false; // SECURITY: reject unsafe ids
|
|
345
498
|
getDb();
|
|
346
499
|
let changed = 0;
|
|
@@ -370,199 +523,145 @@ export async function deleteWorkflow(id) {
|
|
|
370
523
|
* @returns {Promise<object>} ExecutablePlan
|
|
371
524
|
* @throws {Error} when the workflow id is unknown, or a node resolves the off-pipeline scanner
|
|
372
525
|
*/
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
// CONV-4: map each agent key to the UI stepper bucket the live view understands,
|
|
385
|
-
// so the dispatcher can emit a real `'phase'` per node (every node gets its own
|
|
386
|
-
// stepper cell via the snapshotted manifest; see buildStepperManifest).
|
|
387
|
-
const UI_PHASE = {
|
|
388
|
-
clarify: 'clarify',
|
|
389
|
-
planner: 'plan', refiner: 'refine', decomposer: 'decompose', implementer: 'implement', reviewer: 'review',
|
|
390
|
-
manualTestsChecklist: 'manual-checklist', manualWebUiTesting: 'manual-web', planReviewer: 'plan-review',
|
|
391
|
-
workspaceReviewer: 'review', // shares the single-project review stepper bucket
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
const steps = [];
|
|
395
|
-
for (const group of tpl.steps) {
|
|
396
|
-
const resolvedGroup = [];
|
|
397
|
-
for (const node of group) {
|
|
398
|
-
// [C5] Workspace substitution: the review node becomes the fan-out synthesizer.
|
|
399
|
-
// Applied to the resolved node key (and its nodeId-stable stepper bucket) so the
|
|
400
|
-
// dispatcher routes it to runWorkspaceReviewer; single-project keys are untouched.
|
|
401
|
-
const key = isWorkspace && node.key === 'reviewer' ? 'workspaceReviewer' : node.key;
|
|
402
|
-
// [§6.6] Defensive guard: the off-pipeline scanner is never a workflow node.
|
|
403
|
-
// Reject it if hand-authored into a saved workflow so it can't be dispatched.
|
|
404
|
-
if (key === 'workspaceScanner') {
|
|
405
|
-
throw new Error('workspaceScanner is an off-pipeline producer and cannot be a workflow node');
|
|
406
|
-
}
|
|
407
|
-
const meta = reg[key] || {};
|
|
408
|
-
const { prompt, tools } = await loadAgentFile(agentsDir, meta.agentFile ?? null, meta.agentPath ?? null);
|
|
409
|
-
const sel = nodeCfg[node.id] || {};
|
|
410
|
-
// Legacy per-role config is keyed by the ORIGINAL UI step key (e.g. `reviewer`),
|
|
411
|
-
// so a substituted workspaceReviewer still inherits the user's review model/effort.
|
|
412
|
-
const legacy = stepsCfg[node.key] || {};
|
|
413
|
-
// Workflow-level per-node defaults sit BELOW both run-config layers and ABOVE
|
|
414
|
-
// the registry sidecar (newpipeline-ux-design.md §4.3): a project override
|
|
415
|
-
// still wins, but an untouched project inherits the workflow author's tuning.
|
|
416
|
-
const wfDef = (node.defaults && typeof node.defaults === 'object') ? node.defaults : {};
|
|
417
|
-
resolvedGroup.push({
|
|
418
|
-
nodeId: node.id,
|
|
419
|
-
key,
|
|
420
|
-
uiPhase: UI_PHASE[key] || meta.uiPhase || key, // CONV-4 map > meta.uiPhase (v2) > key
|
|
421
|
-
runnerType: meta.runnerType || 'producer',
|
|
422
|
-
agentFile: meta.agentFile ?? null,
|
|
423
|
-
agentPrompt: prompt,
|
|
424
|
-
promptHints: typeof meta.promptHints === 'string' ? meta.promptHints : '',
|
|
425
|
-
model: firstDefined(sel.model, legacy.model, wfDef.model), // undefined unless configured (folded later)
|
|
426
|
-
// An effort only travels with the model that advertises it, so a project
|
|
427
|
-
// override that names its own model must not inherit the workflow default's
|
|
428
|
-
// effort — otherwise "Opus/max" silently becomes "Haiku/max".
|
|
429
|
-
effort: firstDefined(sel.effort, legacy.effort, (sel.model || legacy.model) ? undefined : wfDef.effort),
|
|
430
|
-
fanOut: !!firstDefined(sel.fanOut, legacy.fanOut, wfDef.fanOut, meta.fanOut, false), // node > role > workflow > sidecar > false
|
|
431
|
-
// Per-agent user questions (spec 2026-07-11): unsupported is ALWAYS off;
|
|
432
|
-
// locked ignores every override; else node > role > workflow > sidecar default.
|
|
433
|
-
askQuestions: !meta.asksQuestions
|
|
434
|
-
? false
|
|
435
|
-
: (meta.questionsLocked
|
|
436
|
-
? !!meta.questionsDefault
|
|
437
|
-
: !!firstDefined(sel.askQuestions, legacy.askQuestions, wfDef.askQuestions, meta.questionsDefault, false)),
|
|
438
|
-
tools,
|
|
439
|
-
loopSource: !!meta.loopSource,
|
|
440
|
-
consumes: meta.consumes || [],
|
|
441
|
-
optionalConsumes: meta.optionalConsumes || [],
|
|
442
|
-
produces: meta.produces || [],
|
|
443
|
-
connectsTo: meta.connectsTo || '*',
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
steps.push(resolvedGroup);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const feedbacks = (Array.isArray(tpl.feedbacks) ? tpl.feedbacks : []).map((fb) => ({
|
|
450
|
-
id: fb.id,
|
|
451
|
-
from: fb.from,
|
|
452
|
-
to: fb.to,
|
|
453
|
-
maxCycles: Number(fbCfg[fb.id]?.maxCycles) > 0 ? Number(fbCfg[fb.id].maxCycles) : DEFAULT_MAX_CYCLES,
|
|
454
|
-
gate: 'hasBlocking',
|
|
455
|
-
}));
|
|
456
|
-
|
|
457
|
-
return { id: tpl.id, name: tpl.name, steps, feedbacks };
|
|
526
|
+
/** Port SIGNATURE for the workspace-variant check: the fields that change
|
|
527
|
+
* SCHEDULING (ids, types, cardinality, loop/expands, conditional routing).
|
|
528
|
+
* Deliberately excludes `as`, filename and store — a variant may render and
|
|
529
|
+
* store differently, it may not fire differently. */
|
|
530
|
+
function portSignature(meta) {
|
|
531
|
+
return JSON.stringify({
|
|
532
|
+
inputs: (meta?.inputs || []).map((p) => ({ id: p.id, type: p.type, required: p.required !== false,
|
|
533
|
+
loop: !!p.loop, expands: !!p.expands })),
|
|
534
|
+
outputs: (meta?.outputs || []).map((p) => ({ id: p.id, type: p.type, when: p.when || 'always' })),
|
|
535
|
+
verdict: Boolean(meta?.verdict),
|
|
536
|
+
});
|
|
458
537
|
}
|
|
459
538
|
|
|
539
|
+
const LAYER_RANK = (origin) => (origin === 'builtin' ? 0 : String(origin || '').startsWith('plugin:') ? 2 : 1);
|
|
540
|
+
|
|
460
541
|
/**
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
* @param {object} plan resolveWorkflow() output: { id, name, steps, feedbacks }
|
|
467
|
-
* @param {Record<string,object>} registry loadAgentRegistry() output
|
|
468
|
-
* @returns {{version:1, steps:Array<{kind:string, nodes:object[]}>, feedbacks:Array<{id:string,from:string,to:string,maxCycles:number}>}} node shape includes model, effort
|
|
542
|
+
* Workspace substitutions, derived from META alone (no agent-key literals): every
|
|
543
|
+
* `scope:'workspace-only'` meta that declares `workspaceVariantOf` claims that
|
|
544
|
+
* target. Ties break by layer — builtin > user > plugin.
|
|
545
|
+
* @returns {Record<string, object>} target key -> variant meta
|
|
469
546
|
*/
|
|
470
|
-
export function
|
|
471
|
-
const
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const meta = reg[node.key] || {};
|
|
479
|
-
return {
|
|
480
|
-
id: node.nodeId,
|
|
481
|
-
key: node.key,
|
|
482
|
-
uiPhase: node.uiPhase || node.key,
|
|
483
|
-
label: meta.displayName || node.key,
|
|
484
|
-
color: meta.color || '',
|
|
485
|
-
sub: meta.description || '',
|
|
486
|
-
cycles: isCycleTarget(node.nodeId),
|
|
487
|
-
model: node.model || '',
|
|
488
|
-
effort: node.effort || '',
|
|
489
|
-
};
|
|
490
|
-
}),
|
|
491
|
-
}));
|
|
492
|
-
|
|
493
|
-
return {
|
|
494
|
-
version: 1,
|
|
495
|
-
steps: [
|
|
496
|
-
{ kind: 'preflight', nodes: [{ id: 'preflight', label: 'Preflight', sub: 'checks' }] },
|
|
497
|
-
...agentCells,
|
|
498
|
-
{ kind: 'done', nodes: [{ id: 'done', label: 'Done', sub: 'complete' }] },
|
|
499
|
-
],
|
|
500
|
-
// Loop edges for the graph renderer (self-cycle = from===to, cross-loop = from!==to).
|
|
501
|
-
// Projected to the UI-facing shape; `gate` is intentionally dropped (UI never reads it).
|
|
502
|
-
feedbacks: fbs.map(({ id, from, to, maxCycles }) => ({ id, from, to, maxCycles })),
|
|
503
|
-
};
|
|
547
|
+
export function workspaceVariants(registry) {
|
|
548
|
+
const out = {};
|
|
549
|
+
for (const meta of Object.values(registry || {})) {
|
|
550
|
+
if (!meta || meta.scope !== 'workspace-only' || !meta.workspaceVariantOf) continue;
|
|
551
|
+
const prev = out[meta.workspaceVariantOf];
|
|
552
|
+
if (!prev || LAYER_RANK(meta.origin) < LAYER_RANK(prev.origin)) out[meta.workspaceVariantOf] = meta;
|
|
553
|
+
}
|
|
554
|
+
return out;
|
|
504
555
|
}
|
|
505
556
|
|
|
506
557
|
/**
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
* implementer cell exists, the manifest is returned unchanged. IDEMPOTENT: when the
|
|
513
|
-
* manifest already carries the decomposed task cells (a resumed run re-enters the
|
|
514
|
-
* decomposed implement stage and re-applies this rewrite to the persisted, already-
|
|
515
|
-
* rewritten manifest), it is returned unchanged instead of duplicating the cells.
|
|
516
|
-
* @param {object} manifest buildStepperManifest() output
|
|
517
|
-
* @param {Array<{ordinal:number, tasks:Array<{id:string,title?:string,nodeId:string}>}>} phases
|
|
518
|
-
* @returns {object} the rewritten manifest
|
|
558
|
+
* Merge a v2 template + the project's run-config + the registry into everything a
|
|
559
|
+
* graph run needs. The template comes back UNMUTATED; effective per-node config
|
|
560
|
+
* lives in `nodes`, per-loop-wire budgets in `wires`. P4's _resolveTopology feeds
|
|
561
|
+
* `nodes`/`wires` to buildGraphManifest as `overlays`.
|
|
562
|
+
* @throws {Error} unknown workflow, a v1 row, an unknown/un-ported/unplaceable agent
|
|
519
563
|
*/
|
|
520
|
-
export function
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
564
|
+
export async function resolveGraph(projectDir, workflowId, registry, agentsDir = DEFAULT_AGENTS_DIR, opts = {}) {
|
|
565
|
+
const stored = await readWorkflow(workflowId);
|
|
566
|
+
if (!stored) throw new Error(`unknown workflowId "${workflowId}"`);
|
|
567
|
+
if (stored.version !== 2) throw new Error('template is not a graph — runs on the v1 engine');
|
|
568
|
+
// The RESOLVED template: a private deep copy (the alias row spreads a deep-frozen
|
|
569
|
+
// constant) whose agent nodes carry the RESOLVED key after workspace substitution.
|
|
570
|
+
const tpl = structuredClone(stored);
|
|
571
|
+
const reg = registry && typeof registry === 'object' ? registry : {};
|
|
572
|
+
const isWorkspace = !!opts.isWorkspace;
|
|
573
|
+
const variants = isWorkspace ? workspaceVariants(reg) : {};
|
|
574
|
+
const { nodes: nodeCfg, wires: wireCfg } = await resolveRunConfig(projectDir, workflowId);
|
|
575
|
+
// The legacy per-role layer is the Default workflow's storage only (saved rows
|
|
576
|
+
// use nodeCfg); it is addressed by agent KEY, never by node id.
|
|
577
|
+
const stepsCfg = workflowId === GRAPH_DEFAULT_WORKFLOW.id ? (await readConfig(projectDir)).steps : {};
|
|
578
|
+
const firstDefined = (...vals) => vals.find((v) => v !== undefined);
|
|
533
579
|
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
580
|
+
const nodes = {};
|
|
581
|
+
const agentsByKey = {};
|
|
582
|
+
const agentKeys = new Set();
|
|
583
|
+
for (const node of Array.isArray(tpl.nodes) ? tpl.nodes : []) {
|
|
584
|
+
if (node.kind !== 'agent') {
|
|
585
|
+
nodes[node.id] = { nodeId: node.id, kind: node.kind, key: null, config: { ...(node.config || {}) } };
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const authored = node.key;
|
|
589
|
+
const key = variants[authored]?.key || authored;
|
|
590
|
+
if (key !== authored) node.key = key; // the resolved template carries the resolved key
|
|
591
|
+
const meta = reg[key];
|
|
592
|
+
if (!meta) throw new Error(`unknown agent "${key}" — no such key in the registry`);
|
|
593
|
+
if (!Array.isArray(meta.inputs) || !Array.isArray(meta.outputs)) {
|
|
594
|
+
throw new Error(`agent "${key}" has no v2 ports — port its sidecar to metaVersion 2`);
|
|
595
|
+
}
|
|
596
|
+
if (meta.placeable === false) throw new Error(`agent "${key}" declares placeable: false and cannot be a graph node`);
|
|
597
|
+
if (key !== authored && portSignature(meta) !== portSignature(reg[authored] || {})) {
|
|
598
|
+
throw new Error(`workspace variant "${key}" does not match the port signature of "${authored}"`);
|
|
599
|
+
}
|
|
600
|
+
const { prompt, tools } = await loadAgentFile(agentsDir, meta.agentFile ?? null, meta.agentPath ?? null);
|
|
601
|
+
const sel = nodeCfg[node.id] || {};
|
|
602
|
+
// Legacy per-role config is keyed by the AUTHORED key, so a substituted
|
|
603
|
+
// variant still inherits the user's model/effort for that role.
|
|
604
|
+
const legacy = stepsCfg[authored] || {};
|
|
605
|
+
const cfg = node.config && typeof node.config === 'object' ? node.config : {};
|
|
606
|
+
nodes[node.id] = {
|
|
607
|
+
nodeId: node.id,
|
|
608
|
+
kind: 'agent',
|
|
609
|
+
key,
|
|
610
|
+
authoredKey: authored,
|
|
611
|
+
meta,
|
|
612
|
+
runnerType: meta.runnerType || 'producer',
|
|
613
|
+
agentFile: meta.agentFile ?? null,
|
|
614
|
+
agentPrompt: prompt,
|
|
615
|
+
promptHints: typeof meta.promptHints === 'string' ? meta.promptHints : '',
|
|
616
|
+
tools,
|
|
617
|
+
config: { ...cfg },
|
|
618
|
+
model: firstDefined(sel.model, legacy.model, cfg.model),
|
|
619
|
+
// An effort only travels with the model that advertises it: an override
|
|
620
|
+
// naming its own model must not inherit the lower layer's effort.
|
|
621
|
+
effort: firstDefined(sel.effort, legacy.effort, (sel.model || legacy.model) ? undefined : cfg.effort),
|
|
622
|
+
// workspaceFanOut forces fan-out on a workspace run (the generic
|
|
623
|
+
// replacement for the v1 FANOUT_ELIGIBLE key list).
|
|
624
|
+
fanOut: isWorkspace && meta.workspaceFanOut
|
|
625
|
+
? true
|
|
626
|
+
: !!firstDefined(sel.fanOut, legacy.fanOut, cfg.fanOut, meta.fanOut, false),
|
|
627
|
+
// Sub-agent model policy. Raw tri-state: '' = unset (the RUNTIME resolves
|
|
628
|
+
// that to the auto default — phases.mjs#ctxSubagentModel), a stored value
|
|
629
|
+
// otherwise. Meaningful only WITH fanOut, but resolved unconditionally so
|
|
630
|
+
// the value survives a fan-out toggle without a second write. Each layer
|
|
631
|
+
// is enum-guarded HERE because the TEMPLATE layer has no write-time
|
|
632
|
+
// validation (validateGraph whitelists the key; a plugin import checks
|
|
633
|
+
// nothing): an off-vocabulary value must fall through to the next layer,
|
|
634
|
+
// never freeze verbatim into the run manifest.
|
|
635
|
+
subagentModel: firstDefined(
|
|
636
|
+
validSubagentModel(sel.subagentModel), validSubagentModel(legacy.subagentModel),
|
|
637
|
+
validSubagentModel(cfg.subagentModel), '') || '',
|
|
638
|
+
askQuestions: !meta.asksQuestions
|
|
639
|
+
? false
|
|
640
|
+
: (meta.questionsLocked
|
|
641
|
+
? !!meta.questionsDefault
|
|
642
|
+
: !!firstDefined(sel.askQuestions, legacy.askQuestions, cfg.askQuestions, meta.questionsDefault, false)),
|
|
643
|
+
awaitAll: !!cfg.awaitAll,
|
|
644
|
+
};
|
|
645
|
+
agentsByKey[key] = meta;
|
|
646
|
+
agentKeys.add(key);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Two nodes sharing one agent key prefix their run-store outputs (executor dupPrefix).
|
|
650
|
+
const keyCount = new Map();
|
|
651
|
+
for (const nc of Object.values(nodes)) if (nc.kind === 'agent') keyCount.set(nc.key, (keyCount.get(nc.key) || 0) + 1);
|
|
652
|
+
for (const nc of Object.values(nodes)) if (nc.kind === 'agent') nc.duplicateKey = (keyCount.get(nc.key) || 0) > 1;
|
|
653
|
+
|
|
654
|
+
// Budgets ride LOOP wires only: overlay > authored > DEFAULT_MAX_CYCLES.
|
|
655
|
+
// portsFn + loops are computed ONCE here and RETURNED — P4 hands them to the
|
|
656
|
+
// scheduler and the manifest builder instead of re-deriving them.
|
|
657
|
+
const portsFn = registryPortsFn(agentsByKey);
|
|
658
|
+
const loops = classifyLoops(tpl, portsFn);
|
|
659
|
+
const { loopWireIds } = loops;
|
|
660
|
+
const wires = {};
|
|
661
|
+
for (const w of Array.isArray(tpl.wires) ? tpl.wires : []) {
|
|
662
|
+
if (!loopWireIds.has(w.id)) continue;
|
|
663
|
+
const raw = Number(wireCfg[w.id]?.maxCycles ?? w.config?.maxCycles);
|
|
664
|
+
wires[w.id] = { maxCycles: Number.isInteger(raw) && raw >= 1 ? raw : DEFAULT_MAX_CYCLES };
|
|
665
|
+
}
|
|
666
|
+
return { template: tpl, ports: portsFn, loops, nodes, wires, agentsByKey, agentKeys };
|
|
568
667
|
}
|