@worca/app 0.0.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 +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// src/core/workflows.mjs
|
|
2
|
+
// node:sqlite migration: now persisted in the `workflows` table; path helpers vestigial.
|
|
3
|
+
// Global workflow-template store + the built-in DEFAULT_WORKFLOW + resolveWorkflow.
|
|
4
|
+
//
|
|
5
|
+
// Templates are TOPOLOGY + PER-NODE DEFAULTS (steps + feedbacks by node-instance
|
|
6
|
+
// id; each node may carry an optional `defaults` block — newpipeline-ux-design.md
|
|
7
|
+
// §4.4). Per-project model/effort/cycle data is the run-config in config.mjs and
|
|
8
|
+
// OVERRIDES those defaults; resolveWorkflow merges both.
|
|
9
|
+
//
|
|
10
|
+
// Reads never throw: a missing/corrupt store yields []/null.
|
|
11
|
+
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { getDb, prepare, tx } from './db.mjs';
|
|
16
|
+
import { worcaHome } from './projects.mjs';
|
|
17
|
+
import { resolveRunConfig, readConfig, EFFORTS } from './config.mjs';
|
|
18
|
+
import { slugify } from './artifacts.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default feedback cycle count when run-config does not override it. Matches the
|
|
22
|
+
* Composer's per-loop input default (app.js), so an unset loop runs 3 cycles.
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_MAX_CYCLES = 3;
|
|
25
|
+
|
|
26
|
+
/** Local domain guard (mirrors agent-registry DOMAIN_RE). workflows.mjs deliberately
|
|
27
|
+
* does not import the registry, so a one-line constant is cheaper than a new coupling.
|
|
28
|
+
* Unlike the registry's normalizeDomain, this .trim()s — store input may carry
|
|
29
|
+
* whitespace from a prompt. Absent/malformed → the VISIBLE 'general' default. */
|
|
30
|
+
const DOMAIN_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
31
|
+
function normDomain(raw) {
|
|
32
|
+
const v = typeof raw === 'string' ? raw.trim() : '';
|
|
33
|
+
return DOMAIN_RE.test(v) ? v : 'general';
|
|
34
|
+
}
|
|
35
|
+
|
|
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
|
+
/**
|
|
40
|
+
* Read an agent prompt file and pull its declared tools from YAML frontmatter.
|
|
41
|
+
* Returns { prompt, tools }. A missing file => { prompt:'', tools:[] } (fails
|
|
42
|
+
* safe; the orchestrator already tolerates an empty agent body). The frontmatter
|
|
43
|
+
* `tools:` line is a comma-separated list (matches agents/*.md convention).
|
|
44
|
+
* @param {string} agentsDir
|
|
45
|
+
* @param {string|null} agentFile
|
|
46
|
+
* @param {string|null} [agentPath]
|
|
47
|
+
* @returns {Promise<{prompt:string, tools:string[]}>}
|
|
48
|
+
*/
|
|
49
|
+
async function loadAgentFile(agentsDir, agentFile, agentPath = null) {
|
|
50
|
+
if (!agentFile && !agentPath) return { prompt: '', tools: [] };
|
|
51
|
+
let text = '';
|
|
52
|
+
try {
|
|
53
|
+
// Layered registry: the meta's stamped absolute agentPath (built-in OR user
|
|
54
|
+
// layer) wins; the classic agentsDir+agentFile join is the fallback for
|
|
55
|
+
// hand-built registries (tests) and a vanished user .md.
|
|
56
|
+
text = await readFile(agentPath || join(agentsDir, agentFile), 'utf8');
|
|
57
|
+
} catch {
|
|
58
|
+
if (agentPath && agentFile) {
|
|
59
|
+
try { text = await readFile(join(agentsDir, agentFile), 'utf8'); } catch { return { prompt: '', tools: [] }; }
|
|
60
|
+
} else {
|
|
61
|
+
return { prompt: '', tools: [] };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { prompt: text, tools: parseFrontmatterTools(text) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Extract a comma-separated `tools:` list from leading --- YAML frontmatter. */
|
|
68
|
+
function parseFrontmatterTools(text) {
|
|
69
|
+
const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
|
|
70
|
+
if (!m) return [];
|
|
71
|
+
const line = m[1].split(/\r?\n/).find((l) => /^tools\s*:/.test(l));
|
|
72
|
+
if (!line) return [];
|
|
73
|
+
return line
|
|
74
|
+
.replace(/^tools\s*:/, '')
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
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
|
+
/**
|
|
114
|
+
* Sanitize one node's `defaults` block (newpipeline-ux-design.md §4.4). Loud and
|
|
115
|
+
* lenient, matching the module's house style: a malformed FIELD is dropped with a
|
|
116
|
+
* console.warn naming it, the rest of the block survives. An empty/absent block
|
|
117
|
+
* (or one left empty after dropping) yields undefined so callers omit the key.
|
|
118
|
+
* Structural only — that `model` names a catalog entry is validated at the API
|
|
119
|
+
* boundary (where the effective per-project catalog is reachable), exactly like
|
|
120
|
+
* setStep/setNodeModel.
|
|
121
|
+
* @param {unknown} raw
|
|
122
|
+
* @param {string} [nodeId] for the warning message
|
|
123
|
+
* @returns {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean}|undefined}
|
|
124
|
+
*/
|
|
125
|
+
export function sanitizeNodeDefaults(raw, nodeId = '?') {
|
|
126
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
127
|
+
const out = {};
|
|
128
|
+
const warn = (field, why) =>
|
|
129
|
+
console.warn(`workflow node "${nodeId}": dropping defaults.${field} (${why})`);
|
|
130
|
+
|
|
131
|
+
if (raw.model !== undefined) {
|
|
132
|
+
const model = typeof raw.model === 'string' ? raw.model.trim() : '';
|
|
133
|
+
if (model) out.model = model;
|
|
134
|
+
else if (raw.model !== '' && raw.model !== null) warn('model', 'not a non-empty string');
|
|
135
|
+
}
|
|
136
|
+
if (raw.effort !== undefined) {
|
|
137
|
+
const effort = typeof raw.effort === 'string' ? raw.effort.trim() : '';
|
|
138
|
+
if (EFFORTS.includes(effort)) out.effort = effort;
|
|
139
|
+
else if (effort) warn('effort', `unknown effort "${effort}"`);
|
|
140
|
+
}
|
|
141
|
+
// An effort without a model is meaningless (it is filtered by the model's
|
|
142
|
+
// advertised effort list), so it never survives on its own.
|
|
143
|
+
if (out.effort && !out.model) {
|
|
144
|
+
warn('effort', 'no model to interpret it');
|
|
145
|
+
delete out.effort;
|
|
146
|
+
}
|
|
147
|
+
for (const field of ['fanOut', 'askQuestions']) {
|
|
148
|
+
if (raw[field] === undefined) continue;
|
|
149
|
+
if (typeof raw[field] === 'boolean') out[field] = raw[field];
|
|
150
|
+
else warn(field, 'not a boolean');
|
|
151
|
+
}
|
|
152
|
+
return Object.keys(out).length ? out : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Normalize a steps matrix for persistence: pass every node through untouched
|
|
157
|
+
* EXCEPT its `defaults` block, which is sanitized (and dropped when empty).
|
|
158
|
+
* Unknown node fields are preserved verbatim — plugin-shipped templates may carry
|
|
159
|
+
* their own, and this function must not be the thing that silently eats them.
|
|
160
|
+
* Pure: returns new arrays/objects, never mutates the input.
|
|
161
|
+
* @param {unknown} steps
|
|
162
|
+
* @returns {Array<Array<object>>}
|
|
163
|
+
*/
|
|
164
|
+
export function sanitizeWorkflowSteps(steps) {
|
|
165
|
+
if (!Array.isArray(steps)) return [];
|
|
166
|
+
return steps.map((group) => (Array.isArray(group) ? group.map((node) => {
|
|
167
|
+
if (!node || typeof node !== 'object') return node;
|
|
168
|
+
const { defaults, ...rest } = node;
|
|
169
|
+
const clean = sanitizeNodeDefaults(defaults, node.id);
|
|
170
|
+
return clean ? { ...rest, defaults: clean } : rest;
|
|
171
|
+
}) : group));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Flatten a template's per-node defaults to { [nodeId]: defaults }. Nodes without
|
|
176
|
+
* a defaults block are absent from the map. Used by the UI/API to answer "what is
|
|
177
|
+
* this workflow's default for node X" without re-walking steps.
|
|
178
|
+
* @param {object|null} tpl
|
|
179
|
+
* @returns {Record<string,object>}
|
|
180
|
+
*/
|
|
181
|
+
export function workflowNodeDefaults(tpl) {
|
|
182
|
+
const out = {};
|
|
183
|
+
for (const group of Array.isArray(tpl?.steps) ? tpl.steps : []) {
|
|
184
|
+
for (const node of Array.isArray(group) ? group : []) {
|
|
185
|
+
if (node && node.id && node.defaults && typeof node.defaults === 'object') out[node.id] = node.defaults;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Absolute path to ~/.worca-cc/workflows (honors WORCA_HOME via projects.mjs). */
|
|
192
|
+
export function workflowsDir() {
|
|
193
|
+
return join(worcaHome(), 'workflows');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 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. */
|
|
198
|
+
const SAFE_WORKFLOW_ID = /^[A-Za-z0-9_-]+$/;
|
|
199
|
+
function isSafeWorkflowId(id) { return typeof id === 'string' && SAFE_WORKFLOW_ID.test(id); }
|
|
200
|
+
|
|
201
|
+
/** Fail-safe JSON.parse to an array; returns [] on any error. */
|
|
202
|
+
function parseArr(text) {
|
|
203
|
+
if (typeof text !== 'string' || !text) return [];
|
|
204
|
+
try { const v = JSON.parse(text); return Array.isArray(v) ? v : []; } catch { return []; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Map a workflows row to the template object shape. */
|
|
208
|
+
function rowToTpl(r) {
|
|
209
|
+
return {
|
|
210
|
+
id: r.id,
|
|
211
|
+
name: r.name,
|
|
212
|
+
version: r.version,
|
|
213
|
+
domain: r.domain || 'general', // pre-migration NULL → 'general'
|
|
214
|
+
origin: r.origin || null, // 'plugin:<name>' provenance; NULL = user-created
|
|
215
|
+
steps: parseArr(r.steps),
|
|
216
|
+
feedbacks: parseArr(r.feedbacks),
|
|
217
|
+
createdAt: r.created_at,
|
|
218
|
+
updatedAt: r.updated_at,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Read + shallow-validate one stored template row. Unsafe id / missing => null. */
|
|
223
|
+
function readRaw(id) {
|
|
224
|
+
if (!isSafeWorkflowId(id)) return null; // SECURITY: reject path-traversal / unsafe ids
|
|
225
|
+
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);
|
|
229
|
+
if (!r) return null;
|
|
230
|
+
const tpl = rowToTpl(r);
|
|
231
|
+
return Array.isArray(tpl.steps) ? tpl : null; // mirror the legacy steps-array check
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Persist a template. Stamps a wf_<slug> id (from the name) when missing, version 1,
|
|
236
|
+
* createdAt (preserved across re-saves), and a fresh updatedAt. steps/feedbacks are
|
|
237
|
+
* stored as JSON. Returns the stored object. Never mutates the input.
|
|
238
|
+
* @param {object} tpl { id?, name, steps, feedbacks, createdAt? }
|
|
239
|
+
* @returns {Promise<object>}
|
|
240
|
+
*/
|
|
241
|
+
export async function writeWorkflow(tpl) {
|
|
242
|
+
const now = new Date().toISOString();
|
|
243
|
+
const name = (tpl && typeof tpl.name === 'string' && tpl.name.trim()) || 'Untitled';
|
|
244
|
+
const id = (tpl && typeof tpl.id === 'string' && tpl.id.trim()) || `wf_${slugify(name)}`;
|
|
245
|
+
const steps = sanitizeWorkflowSteps(tpl?.steps);
|
|
246
|
+
const feedbacks = Array.isArray(tpl?.feedbacks) ? tpl.feedbacks : [];
|
|
247
|
+
const domain = normDomain(tpl && tpl.domain);
|
|
248
|
+
|
|
249
|
+
getDb();
|
|
250
|
+
// Preserve the original createdAt if this id already exists (re-save).
|
|
251
|
+
const existing = isSafeWorkflowId(id)
|
|
252
|
+
? prepare('SELECT created_at FROM workflows WHERE id = ?').get(id)
|
|
253
|
+
: null;
|
|
254
|
+
const createdAt =
|
|
255
|
+
(tpl && typeof tpl.createdAt === 'string' && tpl.createdAt) ||
|
|
256
|
+
(existing && existing.created_at) ||
|
|
257
|
+
now;
|
|
258
|
+
|
|
259
|
+
const stored = { id, name, version: 1, domain, steps, feedbacks, createdAt, updatedAt: now };
|
|
260
|
+
tx(() => {
|
|
261
|
+
prepare(`
|
|
262
|
+
INSERT INTO workflows (id, name, version, domain, steps, feedbacks, created_at, updated_at)
|
|
263
|
+
VALUES (?, ?, 1, ?, ?, ?, ?, ?)
|
|
264
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
265
|
+
name = excluded.name, version = 1, domain = excluded.domain,
|
|
266
|
+
steps = excluded.steps, feedbacks = excluded.feedbacks,
|
|
267
|
+
updated_at = excluded.updated_at
|
|
268
|
+
`).run(id, name, domain, JSON.stringify(steps), JSON.stringify(feedbacks), createdAt, now);
|
|
269
|
+
});
|
|
270
|
+
return stored;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Read a template by id. Returns the built-in DEFAULT_WORKFLOW for "wf_default";
|
|
275
|
+
* otherwise the stored row, or null when absent/corrupt/unsafe-id.
|
|
276
|
+
* @param {string} id
|
|
277
|
+
* @returns {Promise<object|null>}
|
|
278
|
+
*/
|
|
279
|
+
export async function readWorkflow(id) {
|
|
280
|
+
if (id === DEFAULT_WORKFLOW.id) return DEFAULT_WORKFLOW;
|
|
281
|
+
return readRaw(id);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* List user templates (NOT DEFAULT_WORKFLOW — callers prepend it), newest first by
|
|
286
|
+
* createdAt. Empty store => []. Never throws.
|
|
287
|
+
* @returns {Promise<object[]>}
|
|
288
|
+
*/
|
|
289
|
+
export async function listWorkflows() {
|
|
290
|
+
getDb();
|
|
291
|
+
const rows = prepare(
|
|
292
|
+
'SELECT id, name, version, domain, steps, feedbacks, created_at, updated_at, origin FROM workflows ORDER BY created_at DESC, id'
|
|
293
|
+
).all();
|
|
294
|
+
return rows.filter((r) => r.id !== DEFAULT_WORKFLOW.id).map(rowToTpl);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Replace the per-node `defaults` of a SAVED template (newpipeline-ux-design.md
|
|
299
|
+
* §4.4). `map` is { [nodeId]: defaults|null }: a sanitized block sets that node's
|
|
300
|
+
* defaults, null/empty clears them, and a node absent from the map keeps what it
|
|
301
|
+
* has. Node ids unknown to the template are ignored (a stale UI must not
|
|
302
|
+
* resurrect deleted nodes). Topology is untouched.
|
|
303
|
+
*
|
|
304
|
+
* Refuses wf_default: the built-in is frozen and never persisted, so it has no row
|
|
305
|
+
* to carry defaults — its sensible defaults come from the agent registry instead
|
|
306
|
+
* (design D6). Duplicating it in Composer yields a saved workflow that can.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} id
|
|
309
|
+
* @param {Record<string,object|null>} map
|
|
310
|
+
* @returns {Promise<object>} the updated template
|
|
311
|
+
* @throws {Error} unknown/unsafe id, or the built-in default
|
|
312
|
+
*/
|
|
313
|
+
export async function setWorkflowNodeDefaults(id, map) {
|
|
314
|
+
if (id === DEFAULT_WORKFLOW.id) {
|
|
315
|
+
throw new Error('the built-in Default workflow cannot store defaults — save a copy in Composer first');
|
|
316
|
+
}
|
|
317
|
+
const tpl = readRaw(id);
|
|
318
|
+
if (!tpl) throw new Error(`workflow not found: ${id}`);
|
|
319
|
+
const patch = map && typeof map === 'object' ? map : {};
|
|
320
|
+
|
|
321
|
+
const steps = tpl.steps.map((group) => (Array.isArray(group) ? group.map((node) => {
|
|
322
|
+
if (!node || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(patch, node.id)) return node;
|
|
323
|
+
const { defaults, ...rest } = node;
|
|
324
|
+
const clean = sanitizeNodeDefaults(patch[node.id], node.id);
|
|
325
|
+
return clean ? { ...rest, defaults: clean } : rest;
|
|
326
|
+
}) : group));
|
|
327
|
+
|
|
328
|
+
const now = new Date().toISOString();
|
|
329
|
+
tx(() => {
|
|
330
|
+
prepare('UPDATE workflows SET steps = ?, updated_at = ? WHERE id = ?')
|
|
331
|
+
.run(JSON.stringify(steps), now, id);
|
|
332
|
+
});
|
|
333
|
+
return { ...tpl, steps, updatedAt: now };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Delete a saved template by id. Refuses the built-in DEFAULT_WORKFLOW (false) and
|
|
338
|
+
* unsafe ids (false). Returns false when no row exists; true on removal.
|
|
339
|
+
* @param {string} id
|
|
340
|
+
* @returns {Promise<boolean>}
|
|
341
|
+
*/
|
|
342
|
+
export async function deleteWorkflow(id) {
|
|
343
|
+
if (id === DEFAULT_WORKFLOW.id) return false; // built-in default is undeletable
|
|
344
|
+
if (!isSafeWorkflowId(id)) return false; // SECURITY: reject unsafe ids
|
|
345
|
+
getDb();
|
|
346
|
+
let changed = 0;
|
|
347
|
+
tx(() => {
|
|
348
|
+
changed = prepare('DELETE FROM workflows WHERE id = ?').run(id).changes;
|
|
349
|
+
});
|
|
350
|
+
return changed > 0;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Merge a workflow template + the project's run-config + the agent registry into
|
|
354
|
+
* an ExecutablePlan the dispatcher runs:
|
|
355
|
+
* { id, name, steps:[[Node]], feedbacks:[{id,from,to,maxCycles,gate}] }
|
|
356
|
+
* Node = { nodeId, key, uiPhase, runnerType, agentFile, agentPrompt, model, effort, tools, loopSource }
|
|
357
|
+
* model/effort come from run-config (undefined when unset; the orchestrator folds
|
|
358
|
+
* in the global fallback at dispatch). maxCycles defaults to DEFAULT_MAX_CYCLES.
|
|
359
|
+
* [v2/C5] When `opts.isWorkspace` is set, the review node is substituted at resolve
|
|
360
|
+
* time: any `reviewer` node key becomes `workspaceReviewer` (the fan-out synthesizer
|
|
361
|
+
* that diffs each member's checkpoint and folds one merged verdict). This is the ONE
|
|
362
|
+
* topology change a workspace run makes here; the orchestrator then forces fanOut on
|
|
363
|
+
* the eligible nodes (which now includes `workspaceReviewer`). Absent `isWorkspace`,
|
|
364
|
+
* the resolved plan is BYTE-IDENTICAL to today's single-project path.
|
|
365
|
+
* @param {string} projectDir
|
|
366
|
+
* @param {string} workflowId
|
|
367
|
+
* @param {Record<string,object>} registry loadAgentRegistry() output
|
|
368
|
+
* @param {string} [agentsDir] override for tests; defaults to ../../agents
|
|
369
|
+
* @param {{ isWorkspace?: boolean }} [opts] workspace-mode resolve options
|
|
370
|
+
* @returns {Promise<object>} ExecutablePlan
|
|
371
|
+
* @throws {Error} when the workflow id is unknown, or a node resolves the off-pipeline scanner
|
|
372
|
+
*/
|
|
373
|
+
export async function resolveWorkflow(projectDir, workflowId, registry, agentsDir = DEFAULT_AGENTS_DIR, opts = {}) {
|
|
374
|
+
const tpl = await readWorkflow(workflowId);
|
|
375
|
+
if (!tpl) throw new Error(`workflow not found: ${workflowId}`);
|
|
376
|
+
const reg = registry && typeof registry === 'object' ? registry : {};
|
|
377
|
+
const isWorkspace = !!(opts && opts.isWorkspace);
|
|
378
|
+
const { nodes: nodeCfg, feedbacks: fbCfg } = await resolveRunConfig(projectDir, workflowId);
|
|
379
|
+
// Legacy per-role config (what the Default-workflow UI writes) applies ONLY to
|
|
380
|
+
// the default workflow's nodes — this is what makes its per-agent model/effort/
|
|
381
|
+
// fanOut actually reach the main runs (saved workflows use nodeCfg only).
|
|
382
|
+
const stepsCfg = workflowId === DEFAULT_WORKFLOW.id ? (await readConfig(projectDir)).steps : {};
|
|
383
|
+
const firstDefined = (...vals) => vals.find((v) => v !== undefined);
|
|
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 };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Build the UI stepper manifest from a resolved ExecutablePlan + agent registry.
|
|
462
|
+
* The manifest is the snapshot the Running/History views render from, so it is
|
|
463
|
+
* persisted into state.json (and flows through every 'state' event). It brackets
|
|
464
|
+
* the workflow's step-cells with the framework's real Preflight and Done phases.
|
|
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
|
|
469
|
+
*/
|
|
470
|
+
export function buildStepperManifest(plan, registry) {
|
|
471
|
+
const reg = registry && typeof registry === 'object' ? registry : {};
|
|
472
|
+
const fbs = Array.isArray(plan?.feedbacks) ? plan.feedbacks : [];
|
|
473
|
+
const isCycleTarget = (nodeId) => fbs.some((fb) => fb && fb.to === nodeId);
|
|
474
|
+
|
|
475
|
+
const agentCells = (Array.isArray(plan?.steps) ? plan.steps : []).map((group) => ({
|
|
476
|
+
kind: 'agents',
|
|
477
|
+
nodes: group.map((node) => {
|
|
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
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Rewrite a UI stepper manifest for a decomposed run: replace the single implementer
|
|
508
|
+
* agent cell with one cell PER PHASE, each holding one implementer node PER TASK
|
|
509
|
+
* (node id = task.nodeId, label = task title). Feedback edges whose `to` was the
|
|
510
|
+
* implementer node are retargeted to the first task node so the review->implement
|
|
511
|
+
* loop wire still lands. Pure: returns a NEW manifest; the input is untouched. If no
|
|
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
|
|
519
|
+
*/
|
|
520
|
+
export function rewriteStepperForDecomposition(manifest, phases) {
|
|
521
|
+
const steps = Array.isArray(manifest?.steps) ? manifest.steps : [];
|
|
522
|
+
const phaseList = Array.isArray(phases) ? phases : [];
|
|
523
|
+
|
|
524
|
+
// Idempotency guard: the rewrite emits one node per task with id = task.nodeId
|
|
525
|
+
// (stamped `s_impl_p<ordinal>_t<n>` by _persistDecomposition). If any cell already
|
|
526
|
+
// holds one of those ids, this decomposition has been applied — return unchanged.
|
|
527
|
+
const taskIds = new Set(
|
|
528
|
+
phaseList.flatMap((ph) => (Array.isArray(ph.tasks) ? ph.tasks : []))
|
|
529
|
+
.map((t) => t.nodeId)
|
|
530
|
+
.filter(Boolean),
|
|
531
|
+
);
|
|
532
|
+
if (steps.some((cell) => (cell.nodes || []).some((n) => taskIds.has(n.id)))) return manifest;
|
|
533
|
+
|
|
534
|
+
const implCellIdx = steps.findIndex(
|
|
535
|
+
(cell) => cell.kind === 'agents' && cell.nodes.some((n) => n.key === 'implementer'),
|
|
536
|
+
);
|
|
537
|
+
if (implCellIdx < 0) return manifest;
|
|
538
|
+
|
|
539
|
+
const implNode = steps[implCellIdx].nodes.find((n) => n.key === 'implementer');
|
|
540
|
+
const implNodeId = implNode.id;
|
|
541
|
+
|
|
542
|
+
const phaseCells = phaseList.map((ph) => ({
|
|
543
|
+
kind: 'agents',
|
|
544
|
+
label: `Phase ${ph.ordinal}`,
|
|
545
|
+
nodes: (Array.isArray(ph.tasks) ? ph.tasks : []).map((t) => ({
|
|
546
|
+
id: t.nodeId,
|
|
547
|
+
key: 'implementer',
|
|
548
|
+
uiPhase: 'implement',
|
|
549
|
+
label: t.title || t.id,
|
|
550
|
+
color: implNode.color || '',
|
|
551
|
+
sub: implNode.sub || '',
|
|
552
|
+
cycles: false,
|
|
553
|
+
model: implNode.model || '',
|
|
554
|
+
effort: implNode.effort || '',
|
|
555
|
+
})),
|
|
556
|
+
}));
|
|
557
|
+
|
|
558
|
+
const firstTaskId = phaseCells[0]?.nodes[0]?.id || implNodeId;
|
|
559
|
+
const newSteps = [
|
|
560
|
+
...steps.slice(0, implCellIdx),
|
|
561
|
+
...phaseCells,
|
|
562
|
+
...steps.slice(implCellIdx + 1),
|
|
563
|
+
];
|
|
564
|
+
const newFeedbacks = (Array.isArray(manifest.feedbacks) ? manifest.feedbacks : []).map((fb) =>
|
|
565
|
+
fb.to === implNodeId ? { ...fb, to: firstTaskId } : { ...fb },
|
|
566
|
+
);
|
|
567
|
+
return { ...manifest, steps: newSteps, feedbacks: newFeedbacks };
|
|
568
|
+
}
|