@worca/app 1.2.0-rc.1 → 1.2.0-rc.3
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 +36 -2
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +358 -19
- package/src/core/ask/models.mjs +7 -1
- package/src/core/ask/turn.mjs +5 -0
- package/src/core/config.mjs +33 -4
- package/src/core/folder-dialog.mjs +21 -12
- package/src/core/marketplaces.mjs +13 -0
- package/src/core/model-env.mjs +41 -0
- package/src/core/model-test.mjs +2 -1
- package/src/core/plugin-manifest.mjs +45 -8
- package/src/core/run-harness.mjs +7 -0
- package/src/core/settings.mjs +61 -0
- package/src/core/title.mjs +72 -9
- package/src/core/ui-instance.mjs +262 -0
- package/src/core/workflow-export.mjs +1169 -0
- package/src/core/workflow-share.mjs +191 -0
- package/src/core/workflows.mjs +35 -4
- package/src/shared/graph/validate.mjs +5 -3
- package/ui/public/app.js +653 -22
- package/ui/public/ask-panel.mjs +4 -2
- package/ui/public/export-slug.mjs +30 -0
- package/ui/public/graph/composer.mjs +24 -3
- package/ui/public/graph/inspector.mjs +4 -1
- package/ui/public/index.html +127 -1
- package/ui/public/models-view.mjs +34 -3
- package/ui/public/style.css +65 -5
- package/ui/server.mjs +305 -90
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// src/core/workflow-share.mjs
|
|
2
|
+
// "Share this workflow with another Worca user" (issue #421): the JSON round
|
|
3
|
+
// trip. ONE validate-then-save path for a v2 graph that arrives from outside
|
|
4
|
+
// the running composer, shared by POST /api/workflows (the composer's Save),
|
|
5
|
+
// POST /api/workflows/import-json, `worca workflow import` and the plugin exporter,
|
|
6
|
+
// so the CLI and the server can never disagree about what a legal graph is.
|
|
7
|
+
//
|
|
8
|
+
// Public surface:
|
|
9
|
+
// exportGraphJson(id) -> { version:2, name, domain, nodes, wires, canvas? }
|
|
10
|
+
// saveGraphWorkflow(body) -> composer semantics: keeps a legal `id`, 409 on a minted collision
|
|
11
|
+
// importGraphWorkflow(body, opts) -> import semantics: id/origin ignored, name suffixed on collision
|
|
12
|
+
// nodeDefaultsError(raw, models, at) -> '' | reason (per-node tunables vs the project-less catalog)
|
|
13
|
+
//
|
|
14
|
+
// Errors carry `.code` so surfaces map them to HTTP / exit codes:
|
|
15
|
+
// BAD_REQUEST | INVALID_GRAPH (+ .errors/.warnings/.summary) | RESERVED_NAME |
|
|
16
|
+
// ID_TAKEN (+ .id) | NOT_FOUND | UNSUPPORTED
|
|
17
|
+
|
|
18
|
+
import { listModels } from './config.mjs';
|
|
19
|
+
import { EFFORTS, subagentModelIssue } from './model-env.mjs';
|
|
20
|
+
import { loadAgentRegistry, DEFAULT_AGENTS_DIR } from './agent-registry.mjs';
|
|
21
|
+
import { registryPortsFn } from './graph/registry-ports.mjs';
|
|
22
|
+
import { validateGraph, AGENT_TUNABLES } from '../shared/graph/validate.mjs';
|
|
23
|
+
import { readWorkflow, writeGraphWorkflow } from './workflows.mjs';
|
|
24
|
+
|
|
25
|
+
/** How many `Name (n)` retries an import makes before giving up. */
|
|
26
|
+
export const SUFFIX_CAP = 20;
|
|
27
|
+
|
|
28
|
+
function err(message, code, extra = {}) { return Object.assign(new Error(message), { code, ...extra }); }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate one node-defaults block against the project-less catalog. Returns an
|
|
32
|
+
* error message, or '' when the block is acceptable. Mirrors setStep's rules so a
|
|
33
|
+
* workflow default can never name something a per-project override could not.
|
|
34
|
+
* (Moved here from ui/server.mjs so the CLI import applies the same gate.)
|
|
35
|
+
*/
|
|
36
|
+
export function nodeDefaultsError(raw, models, where) {
|
|
37
|
+
if (raw == null) return '';
|
|
38
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return `defaults for ${where} must be an object`;
|
|
39
|
+
const model = typeof raw.model === 'string' ? raw.model.trim() : '';
|
|
40
|
+
const effort = typeof raw.effort === 'string' ? raw.effort.trim() : '';
|
|
41
|
+
const entry = model ? models.find((m) => m.id === model) : null;
|
|
42
|
+
if (model && !entry) return `unknown model "${model}"`;
|
|
43
|
+
// subagentModel is a fixed alias enum, NOT a catalog id: validated via the
|
|
44
|
+
// shared helper so a typo is a 400 with the same message every writer uses.
|
|
45
|
+
const subIssue = subagentModelIssue(raw.subagentModel);
|
|
46
|
+
if (subIssue) return subIssue;
|
|
47
|
+
if (!effort) return '';
|
|
48
|
+
if (!EFFORTS.includes(effort)) return `unknown effort "${effort}"`;
|
|
49
|
+
if (!entry) return 'select a model before choosing an effort';
|
|
50
|
+
if (!entry.efforts.includes(effort)) return `model "${model}" does not support effort "${effort}"`;
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The shareable form of a saved v2 workflow: the STORED row (not the resolved
|
|
56
|
+
* graph the Claude Code exporter snapshots — that one applies workspace-variant
|
|
57
|
+
* substitution and drops `canvas`). No id, origin or timestamps: the importer
|
|
58
|
+
* mints an id. Per-node `config` stays; project-layer overrides never ride along.
|
|
59
|
+
* @param {string} id
|
|
60
|
+
*/
|
|
61
|
+
export async function exportGraphJson(id) {
|
|
62
|
+
const tpl = await readWorkflow(id);
|
|
63
|
+
if (!tpl) throw err(`workflow not found: ${id}`, 'NOT_FOUND');
|
|
64
|
+
if (tpl.version !== 2) {
|
|
65
|
+
throw err(`workflow ${id} is a v1 template — open and save it in the composer first`, 'UNSUPPORTED');
|
|
66
|
+
}
|
|
67
|
+
const out = {
|
|
68
|
+
version: 2,
|
|
69
|
+
name: tpl.name,
|
|
70
|
+
domain: tpl.domain || 'general',
|
|
71
|
+
nodes: Array.isArray(tpl.nodes) ? tpl.nodes : [],
|
|
72
|
+
wires: Array.isArray(tpl.wires) ? tpl.wires : [],
|
|
73
|
+
};
|
|
74
|
+
if (tpl.canvas && typeof tpl.canvas === 'object') out.canvas = tpl.canvas;
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Filename stem for a shared workflow: the id without its `wf_` prefix. */
|
|
79
|
+
export function workflowFileSlug(id) {
|
|
80
|
+
const stem = String(id || '').replace(/^wf_/, '');
|
|
81
|
+
return stem.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'workflow';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The V4 "unknown agent" issues folded into ONE actionable line, or null when
|
|
86
|
+
* the errors carry none. A workflow that references agents the recipient does
|
|
87
|
+
* not have is useless without them — which is what the plugin format is for.
|
|
88
|
+
* @param {Array<{code:string, message:string}>} errors
|
|
89
|
+
*/
|
|
90
|
+
export function summarizeUnknownAgents(errors) {
|
|
91
|
+
const keys = [];
|
|
92
|
+
for (const e of errors || []) {
|
|
93
|
+
if (e?.code !== 'V4') continue;
|
|
94
|
+
const m = /unknown agent "([^"]+)"/.exec(String(e.message || ''));
|
|
95
|
+
if (m && !keys.includes(m[1])) keys.push(m[1]);
|
|
96
|
+
}
|
|
97
|
+
if (!keys.length) return null;
|
|
98
|
+
return `this workflow references ${keys.length} agent${keys.length === 1 ? '' : 's'} not installed here `
|
|
99
|
+
+ `(${keys.join(', ')}) — ask its author to share it as a plugin `
|
|
100
|
+
+ '(worca workflow export <id> --format plugin), which bundles them';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Body -> the graph the validator and the store see. `keepId` is the composer's
|
|
104
|
+
* re-save of a loaded row; an import never trusts an id from a file. */
|
|
105
|
+
function prepareGraph(body, { keepId, name }) {
|
|
106
|
+
if (Number(body?.version) !== 2) {
|
|
107
|
+
throw err('v1 pipeline templates are no longer accepted — save a graph (version 2)', 'BAD_REQUEST');
|
|
108
|
+
}
|
|
109
|
+
const override = typeof name === 'string' ? name.trim() : '';
|
|
110
|
+
const graph = {
|
|
111
|
+
id: keepId && typeof body.id === 'string' ? body.id : undefined,
|
|
112
|
+
name: override || (typeof body.name === 'string' ? body.name.trim() : ''),
|
|
113
|
+
domain: typeof body.domain === 'string' ? body.domain : undefined,
|
|
114
|
+
nodes: Array.isArray(body.nodes) ? body.nodes : [],
|
|
115
|
+
wires: Array.isArray(body.wires) ? body.wires : [],
|
|
116
|
+
...(body.canvas && typeof body.canvas === 'object' ? { canvas: body.canvas } : {}),
|
|
117
|
+
};
|
|
118
|
+
if (!graph.name) throw err('name is required', 'BAD_REQUEST');
|
|
119
|
+
return graph;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Catalog + structural validation. Throws BAD_REQUEST / INVALID_GRAPH; returns warnings. */
|
|
123
|
+
async function validateForSave(graph, { registry, agentsDir }) {
|
|
124
|
+
// Catalog validation FIRST: a v2 node's `config` IS its defaults block (§4),
|
|
125
|
+
// so a value the per-project override could not name must not ride in
|
|
126
|
+
// through a template save. Only AGENT_TUNABLES are handed to nodeDefaultsError —
|
|
127
|
+
// topology keys (awaitAll, arity, planStoreSeed) never are.
|
|
128
|
+
const models = await listModels('');
|
|
129
|
+
for (const n of graph.nodes) {
|
|
130
|
+
if (!n || n.kind !== 'agent' || !n.config || typeof n.config !== 'object') continue;
|
|
131
|
+
const picked = Object.fromEntries(AGENT_TUNABLES.filter((k) => k in n.config).map((k) => [k, n.config[k]]));
|
|
132
|
+
const bad = nodeDefaultsError(picked, models, `node "${n.id}"`);
|
|
133
|
+
if (bad) throw err(bad, 'BAD_REQUEST');
|
|
134
|
+
}
|
|
135
|
+
const portsFn = registryPortsFn(registry || loadAgentRegistry(agentsDir || DEFAULT_AGENTS_DIR));
|
|
136
|
+
const { errors, warnings } = validateGraph({ ...graph, version: 2 }, portsFn);
|
|
137
|
+
if (errors.length) {
|
|
138
|
+
// The message stays the composer's verbatim 'invalid graph' (app.js renders
|
|
139
|
+
// the issue list); `summary` is the one-line V4 fold for the CLI and the
|
|
140
|
+
// import button, which have no issue list to show.
|
|
141
|
+
throw err('invalid graph', 'INVALID_GRAPH', { errors, warnings, summary: summarizeUnknownAgents(errors) });
|
|
142
|
+
}
|
|
143
|
+
return warnings;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The composer's Save. A legal `id` in the body is a re-save of that row; a
|
|
148
|
+
* body without one mints wf_<slug(name)> and REFUSES to land on a live row
|
|
149
|
+
* (ID_TAKEN carries the id so the dialog can offer rename/overwrite).
|
|
150
|
+
* @returns {Promise<{workflow: object, warnings: Array}>}
|
|
151
|
+
*/
|
|
152
|
+
export async function saveGraphWorkflow(body, { registry, agentsDir } = {}) {
|
|
153
|
+
const graph = prepareGraph(body || {}, { keepId: true });
|
|
154
|
+
const warnings = await validateForSave(graph, { registry, agentsDir });
|
|
155
|
+
const workflow = await writeGraphWorkflow(graph, { rejectCollision: true });
|
|
156
|
+
return { workflow, warnings };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const SUFFIX_RE = /\s\((\d+)\)$/;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Import a shared graph into the library. The file's id/origin are ignored (the
|
|
163
|
+
* importer mints), `name` overrides the file's name, and a collision with a live
|
|
164
|
+
* row retries as `Name (2)`, `Name (3)`… up to SUFFIX_CAP — never overwrite.
|
|
165
|
+
* @param {object} body the exportGraphJson shape
|
|
166
|
+
* @param {{name?: string, registry?: object, agentsDir?: string}} [opts]
|
|
167
|
+
* @returns {Promise<{workflow: object, warnings: Array, requestedName: string, renamed: boolean}>}
|
|
168
|
+
*/
|
|
169
|
+
export async function importGraphWorkflow(body, { name, registry, agentsDir } = {}) {
|
|
170
|
+
const graph = prepareGraph(body || {}, { keepId: false, name });
|
|
171
|
+
const warnings = await validateForSave(graph, { registry, agentsDir });
|
|
172
|
+
const requestedName = graph.name;
|
|
173
|
+
const m = SUFFIX_RE.exec(requestedName);
|
|
174
|
+
const base = m ? requestedName.slice(0, m.index) : requestedName;
|
|
175
|
+
let n = m ? Number(m[1]) : 1;
|
|
176
|
+
let candidate = requestedName;
|
|
177
|
+
for (let attempt = 0; attempt <= SUFFIX_CAP; attempt++) {
|
|
178
|
+
try {
|
|
179
|
+
const workflow = await writeGraphWorkflow({ ...graph, name: candidate }, { rejectCollision: true });
|
|
180
|
+
return { workflow, warnings, requestedName, renamed: candidate !== requestedName };
|
|
181
|
+
} catch (e) {
|
|
182
|
+
if (e?.code === 'RESERVED_NAME') {
|
|
183
|
+
throw err(`${e.message} (pass a different name to import it)`, 'RESERVED_NAME');
|
|
184
|
+
}
|
|
185
|
+
if (e?.code !== 'ID_TAKEN') throw e;
|
|
186
|
+
n += 1;
|
|
187
|
+
candidate = `${base} (${n})`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
throw err(`could not find a free name for "${requestedName}" after ${SUFFIX_CAP} tries — pass --name`, 'ID_TAKEN');
|
|
191
|
+
}
|
package/src/core/workflows.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { isSubagentModelValue } from './model-env.mjs';
|
|
|
22
22
|
const validSubagentModel = (v) => (isSubagentModelValue(v) ? v : undefined);
|
|
23
23
|
import { slugify } from './artifacts.mjs';
|
|
24
24
|
import { DEFAULT_AGENTS_DIR, loadAgentRegistry } from './agent-registry.mjs'; // fileURLToPath-based (Windows-safe)
|
|
25
|
+
import { readPluginsLock } from './plugins-lock.mjs'; // a DISABLED plugin's rows are hidden
|
|
25
26
|
import { validateGraph, formatIssue, AGENT_TUNABLES } from '../shared/graph/validate.mjs';
|
|
26
27
|
import { classifyLoops } from '../shared/graph/loops.mjs';
|
|
27
28
|
import { GRAPH_DEFAULT_WORKFLOW } from './graph/builtin-workflows.mjs';
|
|
@@ -372,16 +373,36 @@ export async function readWorkflow(id, opts = {}) {
|
|
|
372
373
|
return readRaw(id, opts);
|
|
373
374
|
}
|
|
374
375
|
|
|
376
|
+
/**
|
|
377
|
+
* A row owned by a plugin that is DISABLED in the lock. Disabling a plugin already
|
|
378
|
+
* withdraws its agents, skills and task sources (agent-registry / skills /
|
|
379
|
+
* sources honour `enabled:false`); its workflow templates follow the same rule,
|
|
380
|
+
* or the composer would list a pipeline whose agents are gone.
|
|
381
|
+
* @param {string|null} origin 'plugin:<name>' | null
|
|
382
|
+
* @param {object} lock readPluginsLock()
|
|
383
|
+
*/
|
|
384
|
+
function pluginDisabled(origin, lock) {
|
|
385
|
+
if (typeof origin !== 'string' || !origin.startsWith('plugin:')) return false;
|
|
386
|
+
const entry = lock[origin.slice('plugin:'.length)];
|
|
387
|
+
return !!entry && entry.enabled === false;
|
|
388
|
+
}
|
|
389
|
+
|
|
375
390
|
/**
|
|
376
391
|
* List user templates (NOT GRAPH_DEFAULT_WORKFLOW — callers prepend it), newest first by
|
|
377
|
-
* createdAt. Archived rows are hidden unless asked for
|
|
392
|
+
* createdAt. Archived rows are hidden unless asked for; so are the rows of a
|
|
393
|
+
* disabled plugin (`includeDisabled` lifts that — the plugin lifecycle's own
|
|
394
|
+
* guards need to see them). Empty store => [].
|
|
378
395
|
* @returns {Promise<object[]>}
|
|
379
396
|
*/
|
|
380
|
-
export async function listWorkflows({ includeArchived = false } = {}) {
|
|
397
|
+
export async function listWorkflows({ includeArchived = false, includeDisabled = false } = {}) {
|
|
381
398
|
getDb();
|
|
382
399
|
const where = includeArchived ? '' : 'WHERE archived_at IS NULL';
|
|
383
400
|
const rows = prepare(`SELECT ${ROW_COLS} FROM workflows ${where} ORDER BY created_at DESC, id`).all();
|
|
384
|
-
|
|
401
|
+
const lock = includeDisabled ? null : readPluginsLock();
|
|
402
|
+
return rows
|
|
403
|
+
.filter((r) => r.id !== GRAPH_DEFAULT_WORKFLOW.id)
|
|
404
|
+
.filter((r) => includeDisabled || !pluginDisabled(r.origin, lock))
|
|
405
|
+
.map(rowToTpl);
|
|
385
406
|
}
|
|
386
407
|
|
|
387
408
|
/**
|
|
@@ -418,6 +439,14 @@ export async function assertRunnableWorkflow(id, { registry, checkGraph = true }
|
|
|
418
439
|
const wanted = typeof id === 'string' && id.trim() ? id.trim() : GRAPH_DEFAULT_WORKFLOW.id;
|
|
419
440
|
const live = await readWorkflow(wanted);
|
|
420
441
|
if (live) {
|
|
442
|
+
// A disabled plugin's template is neither runnable nor openable: its agents
|
|
443
|
+
// are withdrawn with the plugin. Same posture as ARCHIVED — a coded refusal
|
|
444
|
+
// the callers map, with the fix in the message.
|
|
445
|
+
if (pluginDisabled(live.origin, readPluginsLock())) {
|
|
446
|
+
const plugin = live.origin.slice('plugin:'.length);
|
|
447
|
+
throw Object.assign(new Error(`workflow "${wanted}" belongs to plugin "${plugin}", which is disabled — `
|
|
448
|
+
+ `enable the plugin (Plugins view, or: worca plugin enable ${plugin}) to use it`), { code: 'PLUGIN_DISABLED' });
|
|
449
|
+
}
|
|
421
450
|
// A v1 row is not a graph: engine-select owns its refusal (V1_RUN_RETIRED).
|
|
422
451
|
if (checkGraph && live.version === 2) assertValidGraph(live, registry);
|
|
423
452
|
return live;
|
|
@@ -574,7 +603,9 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
|
|
|
574
603
|
const { nodes: nodeCfg, wires: wireCfg } = await resolveRunConfig(projectDir, workflowId);
|
|
575
604
|
// The legacy per-role layer is the Default workflow's storage only (saved rows
|
|
576
605
|
// use nodeCfg); it is addressed by agent KEY, never by node id.
|
|
577
|
-
|
|
606
|
+
// A defaults-only global export passes projectDir=null and must not touch the config
|
|
607
|
+
// store (projectKey(null) throws); its legacy per-role layer is empty by definition.
|
|
608
|
+
const stepsCfg = (workflowId === GRAPH_DEFAULT_WORKFLOW.id && projectDir) ? (await readConfig(projectDir)).steps : {};
|
|
578
609
|
const firstDefined = (...vals) => vals.find((v) => v !== undefined);
|
|
579
610
|
|
|
580
611
|
const nodes = {};
|
|
@@ -269,7 +269,9 @@ export const RULES = [
|
|
|
269
269
|
if (n.kind !== 'agent' || !metaOf(n.id)) continue; // V3/V4 own an unresolved node
|
|
270
270
|
for (const inp of inputsOf(n.id)) {
|
|
271
271
|
if (!inp?.required || inp.loop || isLoopInput(n.id, inp.id)) continue;
|
|
272
|
-
|
|
272
|
+
// `incomplete`: the graph is UNFINISHED here, not wrong — the composer shows
|
|
273
|
+
// these as work to do rather than as errors (Save stays gated either way).
|
|
274
|
+
if (!isWired(n.id, inp.id)) add(`required input '${n.id}.${inp.id}' is unwired`, { nodeId: n.id, portId: inp.id, incomplete: true });
|
|
273
275
|
}
|
|
274
276
|
}
|
|
275
277
|
} },
|
|
@@ -474,7 +476,7 @@ export const RULES = [
|
|
|
474
476
|
if (!metaOf(t.id)) continue;
|
|
475
477
|
if (inputsOf(t.id).length) add(`task node '${t.id}' must declare zero inputs`, { nodeId: t.id });
|
|
476
478
|
if (!liveWires.some((w) => w.from.node === t.id && w.from.port === 'task')) {
|
|
477
|
-
add(`task node '${t.id}' output 'task' must have at least one wire`, { nodeId: t.id });
|
|
479
|
+
add(`task node '${t.id}' output 'task' must have at least one wire`, { nodeId: t.id, incomplete: true });
|
|
478
480
|
}
|
|
479
481
|
}
|
|
480
482
|
} },
|
|
@@ -485,7 +487,7 @@ export const RULES = [
|
|
|
485
487
|
for (const e of endNodes) {
|
|
486
488
|
if (!metaOf(e.id)) continue;
|
|
487
489
|
if (outputsOf(e.id).length) add(`end node '${e.id}' must declare zero outputs`, { nodeId: e.id });
|
|
488
|
-
if (!isWired(e.id, 'result')) add(`end node '${e.id}' input 'result' must be wired`, { nodeId: e.id });
|
|
490
|
+
if (!isWired(e.id, 'result')) add(`end node '${e.id}' input 'result' must be wired`, { nodeId: e.id, incomplete: true });
|
|
489
491
|
}
|
|
490
492
|
} },
|
|
491
493
|
];
|