@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
|
@@ -11,24 +11,30 @@ import { promisify } from 'node:util';
|
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
12
|
import {
|
|
13
13
|
existsSync, readdirSync, readFileSync, readlinkSync,
|
|
14
|
-
mkdirSync, rmSync, symlinkSync, renameSync,
|
|
14
|
+
mkdirSync, rmSync, symlinkSync, renameSync, unlinkSync,
|
|
15
15
|
} from 'node:fs';
|
|
16
|
-
import { join, resolve } from 'node:path';
|
|
16
|
+
import { join, resolve, isAbsolute, sep } from 'node:path';
|
|
17
17
|
import { WORCA_PLUGIN_APIS } from './plugin-api.mjs';
|
|
18
|
-
import { normalizeManifest, validatePluginDir, apiSatisfies } from './plugin-manifest.mjs';
|
|
18
|
+
import { normalizeManifest, validatePluginDir, apiSatisfies, dataContractIssues, apiMismatch } from './plugin-manifest.mjs';
|
|
19
19
|
import {
|
|
20
20
|
pluginsRoot, pluginDir, pluginCurrentDir, pluginDataDir, readPluginsLock, writePluginsLock,
|
|
21
21
|
DIR_NAME_RE,
|
|
22
22
|
} from './plugins-lock.mjs';
|
|
23
23
|
import { addPluginRepo, fetchCandidate, exportVersion, repoCacheDir } from './plugin-repo.mjs';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
importPluginWorkflows, readPluginWorkflows, removePluginWorkflows, referencedPluginAgents,
|
|
26
|
+
} from './plugin-workflows.mjs';
|
|
27
|
+
import { loadAgentRegistry } from './agent-registry.mjs';
|
|
25
28
|
import { pluginModelSecretStatus } from './plugin-models.mjs';
|
|
26
29
|
import { referencedPluginModels } from './config.mjs';
|
|
30
|
+
import { clearBindingsForPlugin } from './source-bindings.mjs';
|
|
27
31
|
|
|
28
32
|
const execFileP = promisify(execFile);
|
|
29
33
|
const defaultExec = (cmd, args, opts = {}) =>
|
|
30
34
|
execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024, ...opts });
|
|
31
35
|
|
|
36
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
37
|
+
|
|
32
38
|
function readManifestAt(dir) {
|
|
33
39
|
try {
|
|
34
40
|
const res = normalizeManifest(JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8')), { dir });
|
|
@@ -42,6 +48,13 @@ function sha256File(file) {
|
|
|
42
48
|
try { return createHash('sha256').update(readFileSync(file)).digest('hex'); } catch { return null; }
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
/** True when the relative path `rel` still resolves INSIDE `dir`. */
|
|
52
|
+
function insideDir(dir, rel) {
|
|
53
|
+
if (isAbsolute(rel) || /\\/.test(rel)) return false;
|
|
54
|
+
const root = resolve(dir);
|
|
55
|
+
return resolve(root, rel).startsWith(root + sep);
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
/** Private copy of workflows.mjs:66-77 parseFrontmatterTools (module-private there). */
|
|
46
59
|
function frontmatterTools(text) {
|
|
47
60
|
const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
|
|
@@ -64,8 +77,19 @@ export function buildInstallInventory(versionDir) {
|
|
|
64
77
|
if (existsSync(aDir)) {
|
|
65
78
|
for (const f of readdirSync(aDir).filter((x) => x.endsWith('.meta.json')).sort()) {
|
|
66
79
|
const key = f.slice(0, -'.meta.json'.length);
|
|
80
|
+
// Consent must describe the bytes the RUNTIME will read: agent-registry
|
|
81
|
+
// stamps agentPath = join(<layer>/agents, meta.agentFile), so reading
|
|
82
|
+
// `${key}.md` here showed a DECOY for any sidecar naming another file
|
|
83
|
+
// (C-1). Containment is re-checked because this also runs on an
|
|
84
|
+
// unvalidated dir (the pre-install preview); an escaping agentFile falls
|
|
85
|
+
// back to the sibling and validatePluginDir refuses the install anyway.
|
|
86
|
+
let mdFile = `${key}.md`;
|
|
87
|
+
try {
|
|
88
|
+
const af = JSON.parse(readFileSync(join(aDir, f), 'utf8'))?.agentFile;
|
|
89
|
+
if (typeof af === 'string' && af.trim() && insideDir(aDir, af.trim())) mdFile = af.trim();
|
|
90
|
+
} catch { /* unreadable sidecar: fall back to the sibling */ }
|
|
67
91
|
let tools = [];
|
|
68
|
-
try { tools = frontmatterTools(readFileSync(join(aDir,
|
|
92
|
+
try { tools = frontmatterTools(readFileSync(join(aDir, mdFile), 'utf8')); } catch { /* md missing */ }
|
|
69
93
|
agents.push({ key, tools });
|
|
70
94
|
}
|
|
71
95
|
}
|
|
@@ -113,6 +137,45 @@ export function buildInstallInventory(versionDir) {
|
|
|
113
137
|
return { agents, taskSources, chatChannels, models, modelSecrets, skills: skills.sort(), workflows, depCount, setupCommands };
|
|
114
138
|
}
|
|
115
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Contributions a plugin SHIPS but worca IGNORED, as `[{ file, reason }]` sorted
|
|
142
|
+
* by file (`agents/<sidecar>.meta.json`, `workflows/<template>.json`). The two
|
|
143
|
+
* drop sites — the agent registry and the workflow importer — reach only
|
|
144
|
+
* console.warn on their own, so every reporting surface (install receipt,
|
|
145
|
+
* Plugins card, doctor) was free to claim a contribution that does not exist.
|
|
146
|
+
*
|
|
147
|
+
* Generic by construction: nothing here is keyed on an agent key or a plugin
|
|
148
|
+
* name — the drop reasons are authored at the drop sites and carried verbatim.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} name
|
|
151
|
+
* @param {string} dir the plugin's current/ (or version) dir
|
|
152
|
+
* @param {{registry?: object, drops?: Array<{origin:string,file:string,reason:string}>,
|
|
153
|
+
* workflowSkips?: Array<{file:string, errors:string[]}>}} [opts]
|
|
154
|
+
* registry/drops: reuse ONE registry load across plugins; workflowSkips:
|
|
155
|
+
* the importer's OWN result, when the caller just ran it.
|
|
156
|
+
* @returns {Array<{file: string, reason: string}>}
|
|
157
|
+
*/
|
|
158
|
+
export function ignoredContributions(name, dir, opts = {}) {
|
|
159
|
+
const out = [];
|
|
160
|
+
let drops = opts.drops;
|
|
161
|
+
let registry = opts.registry;
|
|
162
|
+
if (!drops) {
|
|
163
|
+
drops = [];
|
|
164
|
+
try { registry = loadAgentRegistry(undefined, { onDrop: (d) => drops.push(d) }); }
|
|
165
|
+
catch { /* no resolvable home: report what the files alone can say */ }
|
|
166
|
+
}
|
|
167
|
+
for (const d of drops) {
|
|
168
|
+
if (d.origin === `plugin:${name}`) out.push({ file: `agents/${d.file}`, reason: d.reason });
|
|
169
|
+
}
|
|
170
|
+
const skips = opts.workflowSkips
|
|
171
|
+
?? (() => {
|
|
172
|
+
try { return readPluginWorkflows(name, dir, { registry, quiet: true }).skipped; }
|
|
173
|
+
catch { return []; }
|
|
174
|
+
})();
|
|
175
|
+
for (const s of skips) out.push({ file: `workflows/${s.file}`, reason: `invalid template (${s.errors.join('; ')})` });
|
|
176
|
+
return out.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
177
|
+
}
|
|
178
|
+
|
|
116
179
|
/** Declared setup FACTS only (spec §4.1): setup.node -> npm ci (lockfile
|
|
117
180
|
* required, scripts ignored); setup.python 'pyproject' -> uv sync. */
|
|
118
181
|
export async function runSetup(versionDir, manifest, { exec = defaultExec } = {}) {
|
|
@@ -121,11 +184,17 @@ export async function runSetup(versionDir, manifest, { exec = defaultExec } = {}
|
|
|
121
184
|
if (!existsSync(join(versionDir, 'package-lock.json'))) {
|
|
122
185
|
throw new Error(`setup.node declared but ${join(versionDir, 'package-lock.json')} is missing (npm ci requires a lockfile)`);
|
|
123
186
|
}
|
|
124
|
-
|
|
187
|
+
// cwd instead of --prefix so no native path is passed as an argument: on
|
|
188
|
+
// Windows npm is `npm.cmd`, which needs a shell (bare `npm` -> ENOENT, and
|
|
189
|
+
// execFile of a `.cmd` -> EINVAL on modern Node), and shell:true does NOT
|
|
190
|
+
// quote args — a `--prefix C:\…` path would be mangled. With the dir as cwd
|
|
191
|
+
// the only args left are flag literals, safe through the shell.
|
|
192
|
+
await exec('npm', ['ci', '--ignore-scripts', '--omit=dev'],
|
|
193
|
+
{ cwd: versionDir, shell: IS_WINDOWS });
|
|
125
194
|
commands.push('npm ci');
|
|
126
195
|
}
|
|
127
196
|
if (manifest?.setup?.python === 'pyproject') {
|
|
128
|
-
await exec('uv', ['sync',
|
|
197
|
+
await exec('uv', ['sync'], { cwd: versionDir });
|
|
129
198
|
commands.push('uv sync');
|
|
130
199
|
}
|
|
131
200
|
return { commands };
|
|
@@ -152,12 +221,35 @@ function currentTarget(name) {
|
|
|
152
221
|
try { return readlinkSync(pluginCurrentDir(name)); } catch { return null; }
|
|
153
222
|
}
|
|
154
223
|
|
|
155
|
-
|
|
224
|
+
// rmSync is the wrong primitive for a POSIX path that may be a symlink: it
|
|
225
|
+
// stats THROUGH the link, so a link to a directory throws ERR_FS_EISDIR and a
|
|
226
|
+
// DANGLING link reads as "already gone" and survives force:true (Node 23).
|
|
227
|
+
// unlink removes the link itself; the fallback covers a real directory
|
|
228
|
+
// (test fixtures build `current` as a plain dir).
|
|
229
|
+
const rmLink = (path) => {
|
|
230
|
+
try { unlinkSync(path); return; } catch (err) {
|
|
231
|
+
if (err.code === 'ENOENT') return;
|
|
232
|
+
}
|
|
233
|
+
rmSync(path, { recursive: true, force: true });
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Atomic swap (§6.1 step 3): write current.tmp symlink, rename(2) over current.
|
|
237
|
+
* Windows can't create a plain symlink without elevated privileges/Developer
|
|
238
|
+
* Mode, and can't rename() a directory reparse point over an existing one
|
|
239
|
+
* (EPERM). It CAN create a directory JUNCTION unprivileged, so there we
|
|
240
|
+
* remove-then-create a junction in place. A junction needs an ABSOLUTE target
|
|
241
|
+
* (POSIX keeps the relative "versions/<sha7>" so the link survives a moved
|
|
242
|
+
* plugin dir); rmSync on a junction drops only the link, never the target. */
|
|
156
243
|
function swapCurrent(name, target) {
|
|
157
244
|
const current = pluginCurrentDir(name);
|
|
158
|
-
const tmp = `${current}.tmp`;
|
|
159
|
-
rmSync(tmp, { force: true });
|
|
160
245
|
mkdirSync(pluginDir(name), { recursive: true });
|
|
246
|
+
if (IS_WINDOWS) {
|
|
247
|
+
rmSync(current, { force: true });
|
|
248
|
+
symlinkSync(resolve(pluginDir(name), target), current, 'junction');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const tmp = `${current}.tmp`;
|
|
252
|
+
rmLink(tmp);
|
|
161
253
|
symlinkSync(target, tmp);
|
|
162
254
|
renameSync(tmp, current);
|
|
163
255
|
}
|
|
@@ -166,19 +258,27 @@ function swapCurrent(name, target) {
|
|
|
166
258
|
* dir, restore/remove current, tidy now-empty dirs. Prior state untouched. */
|
|
167
259
|
function cleanupFailedVersion(name, versionDir, prevCurrent) {
|
|
168
260
|
rmSync(versionDir, { recursive: true, force: true });
|
|
169
|
-
|
|
261
|
+
rmLink(`${pluginCurrentDir(name)}.tmp`);
|
|
170
262
|
if (prevCurrent) { try { swapCurrent(name, prevCurrent); } catch { /* best effort */ } }
|
|
171
|
-
else
|
|
263
|
+
else rmLink(pluginCurrentDir(name));
|
|
172
264
|
for (const d of [join(pluginDir(name), 'versions'), pluginDir(name)]) {
|
|
173
265
|
try { if (readdirSync(d).length === 0) rmSync(d, { recursive: true, force: true }); } catch { /* absent */ }
|
|
174
266
|
}
|
|
175
267
|
}
|
|
176
268
|
|
|
269
|
+
/** Problem lines for a refusal message. `level` is a REPORTING axis, not a
|
|
270
|
+
* cause axis — a plugin whose declared API makes its data problems WARNINGS
|
|
271
|
+
* must never be refused with an empty body, so fall back to every problem when
|
|
272
|
+
* no error line exists. */
|
|
273
|
+
function refusalLines(problems, prefix = ' - ') {
|
|
274
|
+
const errorsOnly = problems.filter((p) => p.level === 'error');
|
|
275
|
+
return (errorsOnly.length ? errorsOnly : problems).map((p) => `${prefix}${p.message}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
177
278
|
function validated(name, versionDir) {
|
|
178
279
|
const v = validatePluginDir(versionDir);
|
|
179
280
|
if (!v.ok) {
|
|
180
|
-
|
|
181
|
-
throw new Error(`plugin "${name}" failed validation:\n${lines.join('\n')}`);
|
|
281
|
+
throw new Error(`plugin "${name}" failed validation:\n${refusalLines(v.problems).join('\n')}`);
|
|
182
282
|
}
|
|
183
283
|
return v.manifest;
|
|
184
284
|
}
|
|
@@ -222,15 +322,20 @@ export async function installPlugin({ repoUrl, subdir = '', name, sha, marketpla
|
|
|
222
322
|
// reach installPlugin's catch — cleanupFailedVersion would delete the version
|
|
223
323
|
// dir a just-written lock entry points at. The install itself already
|
|
224
324
|
// succeeded; warn and continue (re-import happens on the next update).
|
|
325
|
+
let workflowSkips = null;
|
|
225
326
|
try {
|
|
226
327
|
const wf = await importPluginWorkflows(name, versionDir);
|
|
328
|
+
workflowSkips = wf.skipped;
|
|
227
329
|
for (const s of wf.skipped) {
|
|
228
330
|
console.warn(`[plugin-store] ${name}: workflow ${s.file} not imported (${s.errors.join('; ')})`);
|
|
229
331
|
}
|
|
230
332
|
} catch (err) {
|
|
231
333
|
console.warn(`[plugin-store] ${name}: workflow import failed (${err?.message || err}) — plugin installed; re-import via update`);
|
|
232
334
|
}
|
|
233
|
-
|
|
335
|
+
// The receipt says what actually landed: `inventory` is what the plugin
|
|
336
|
+
// SHIPS, `ignored` is the subset worca refused to load (spec §9.3 drops).
|
|
337
|
+
const ignored = ignoredContributions(name, versionDir, workflowSkips ? { workflowSkips } : {});
|
|
338
|
+
return { ok: true, inventory, warnings, ignored };
|
|
234
339
|
} catch (err) {
|
|
235
340
|
cleanupFailedVersion(name, versionDir, prevCurrent);
|
|
236
341
|
throw err;
|
|
@@ -270,15 +375,18 @@ export async function updatePlugin(name, { exec = defaultExec } = {}) {
|
|
|
270
375
|
// §6.2(3): workflow re-import (upsert) after swap + lock update — same
|
|
271
376
|
// isolation rationale as installPlugin: an import failure must not reach
|
|
272
377
|
// this catch (cleanupFailedVersion would tear down the now-live version).
|
|
378
|
+
let workflowSkips = null;
|
|
273
379
|
try {
|
|
274
380
|
const wf = await importPluginWorkflows(name, versionDir);
|
|
381
|
+
workflowSkips = wf.skipped;
|
|
275
382
|
for (const s of wf.skipped) {
|
|
276
383
|
console.warn(`[plugin-store] ${name}: workflow ${s.file} not imported (${s.errors.join('; ')})`);
|
|
277
384
|
}
|
|
278
385
|
} catch (err) {
|
|
279
386
|
console.warn(`[plugin-store] ${name}: workflow import failed (${err?.message || err}) — plugin updated; re-import via update`);
|
|
280
387
|
}
|
|
281
|
-
|
|
388
|
+
const ignored = ignoredContributions(name, versionDir, workflowSkips ? { workflowSkips } : {});
|
|
389
|
+
return { ok: true, updated: true, inventory, warnings, ignored, ...cand };
|
|
282
390
|
} catch (err) {
|
|
283
391
|
cleanupFailedVersion(name, versionDir, prevCurrent);
|
|
284
392
|
throw err;
|
|
@@ -326,7 +434,13 @@ export async function uninstallPlugin(name, { purge = false } = {}) {
|
|
|
326
434
|
);
|
|
327
435
|
}
|
|
328
436
|
await removePluginWorkflows(name); // throws its ReferencedError with the referencing list
|
|
329
|
-
|
|
437
|
+
// Bindings live in the DB, not in the plugin's data dir, so they would
|
|
438
|
+
// outlive the uninstall: a stale row silently rebinds a project the moment
|
|
439
|
+
// the plugin is reinstalled with a same-named profile — possibly pointing at
|
|
440
|
+
// a different tracker. Cleared HERE (not only in the server's DELETE route)
|
|
441
|
+
// so the CLI's `worca plugin remove` drops them too.
|
|
442
|
+
clearBindingsForPlugin(name);
|
|
443
|
+
rmLink(pluginCurrentDir(name)); // symlink in real installs, a plain dir in test fixtures
|
|
330
444
|
rmSync(join(pluginDir(name), 'versions'), { recursive: true, force: true });
|
|
331
445
|
delete lock[name];
|
|
332
446
|
writePluginsLock(lock);
|
|
@@ -388,11 +502,23 @@ export function setPluginEnabled(name, enabled) {
|
|
|
388
502
|
/** Lock + current-manifest merge for the Plugins view / CLI list. */
|
|
389
503
|
export function listInstalledPlugins() {
|
|
390
504
|
const lock = readPluginsLock();
|
|
505
|
+
// ONE registry load for the whole list: it is the only place that knows which
|
|
506
|
+
// sidecars were dropped, and re-scanning per plugin would be N full scans.
|
|
507
|
+
const drops = [];
|
|
508
|
+
let registry = null;
|
|
509
|
+
try { registry = loadAgentRegistry(undefined, { onDrop: (d) => drops.push(d) }); }
|
|
510
|
+
catch { /* no resolvable home: fall back to the file-derived view */ }
|
|
391
511
|
return Object.keys(lock).sort().map((name) => {
|
|
392
512
|
const e = lock[name] || {};
|
|
393
513
|
const cur = pluginCurrentDir(name);
|
|
394
514
|
const manifest = existsSync(cur) ? readManifestAt(cur) : null; // existsSync follows the symlink
|
|
395
515
|
const inv = manifest ? buildInstallInventory(cur) : null;
|
|
516
|
+
// Read from the raw dir, not the manifest: the mismatch is about the DATA
|
|
517
|
+
// the plugin ships, not about whether its manifest normalized. A plugin
|
|
518
|
+
// whose manifest did not normalize is `broken`, which outranks this.
|
|
519
|
+
const mismatch = manifest
|
|
520
|
+
? apiMismatch(manifest.engines?.worcaApi ?? '', dataContractIssues(cur))
|
|
521
|
+
: null;
|
|
396
522
|
return {
|
|
397
523
|
name,
|
|
398
524
|
version: e.version ?? null,
|
|
@@ -403,9 +529,15 @@ export function listInstalledPlugins() {
|
|
|
403
529
|
enabled: e.enabled !== false,
|
|
404
530
|
linked: e.linked === true,
|
|
405
531
|
broken: !manifest,
|
|
532
|
+
apiMismatch: mismatch,
|
|
406
533
|
contributions: inv
|
|
407
534
|
? { agents: inv.agents.length, taskSources: inv.taskSources.length, chatChannels: inv.chatChannels.length, models: inv.models.length, skills: inv.skills.length, workflows: inv.workflows.length }
|
|
408
535
|
: { agents: 0, taskSources: 0, chatChannels: 0, models: 0, skills: 0, workflows: 0 },
|
|
536
|
+
// `contributions` counts what the plugin SHIPS (files on disk); `ignored`
|
|
537
|
+
// names the ones worca refused to load, so the card can stop claiming them.
|
|
538
|
+
// A disabled plugin contributes nothing BY CHOICE — its agents are not in the
|
|
539
|
+
// registry, so its templates would misreport V4.
|
|
540
|
+
ignored: manifest && e.enabled !== false ? ignoredContributions(name, cur, { registry, drops }) : [],
|
|
409
541
|
};
|
|
410
542
|
});
|
|
411
543
|
}
|
|
@@ -429,6 +561,26 @@ export async function doctorPlugin(name) {
|
|
|
429
561
|
if (!resolves) return { ok: false, checks };
|
|
430
562
|
const manifest = readManifestAt(cur);
|
|
431
563
|
checks.push(...dirChecks(cur, manifest));
|
|
564
|
+
// The DATA contract check is ADVISORY, so it lives HERE and never in
|
|
565
|
+
// dirChecks: dirChecks also feeds the install precheck, which THROWS on any
|
|
566
|
+
// failing check, and an outdated data contract must never block an install —
|
|
567
|
+
// the plugin's connector/chat channel still works untouched, worca simply
|
|
568
|
+
// ignores the agents and pipeline templates it ships (spec §9, P7.2).
|
|
569
|
+
if (manifest) {
|
|
570
|
+
const mismatch = apiMismatch(manifest.engines?.worcaApi ?? '', dataContractIssues(cur));
|
|
571
|
+
checks.push({ id: 'agents-api', ok: !mismatch,
|
|
572
|
+
detail: mismatch ? mismatch.message : 'agents and pipeline templates are plugin API 3 (meta v2 + graph templates)' });
|
|
573
|
+
}
|
|
574
|
+
// Same gate as the card: a disabled plugin contributes nothing BY CHOICE — its
|
|
575
|
+
// agents are not in the registry, so its templates would misreport V4 and this
|
|
576
|
+
// check would fail a plugin that is merely switched off.
|
|
577
|
+
if (manifest && entry.enabled !== false) {
|
|
578
|
+
const ignored = ignoredContributions(name, cur);
|
|
579
|
+
c('contributions', ignored.length === 0,
|
|
580
|
+
ignored.length
|
|
581
|
+
? `${ignored.length} ignored: ${ignored.map((i) => `${i.file} — ${i.reason}`).join('; ')}`
|
|
582
|
+
: 'every shipped contribution loaded');
|
|
583
|
+
}
|
|
432
584
|
if (manifest?.setup?.node && !entry.linked) {
|
|
433
585
|
const h = sha256File(join(cur, 'package-lock.json'));
|
|
434
586
|
c('lock-hash', !entry.lockfileHash || h === entry.lockfileHash,
|
|
@@ -449,12 +601,28 @@ export async function doctorPlugin(name) {
|
|
|
449
601
|
try { shim = await import('./plugin-shim.mjs'); } // Task 11 module — may not exist yet
|
|
450
602
|
catch (err) { if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err; }
|
|
451
603
|
if (shim) {
|
|
604
|
+
const { listProfileIds } = await import('./plugin-config.mjs');
|
|
452
605
|
for (const s of manifest.taskSources) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
606
|
+
// A multi-profile source stores nothing in the implicit default bucket;
|
|
607
|
+
// validating that would flag a fully configured plugin as broken. Check
|
|
608
|
+
// each roster profile instead. An EMPTY roster is not healthy-by-vacuity
|
|
609
|
+
// either: every run of the source is rejected ("profile is required")
|
|
610
|
+
// until one exists, and validating the default bucket instead can even
|
|
611
|
+
// report green off legacy config migrated under 'default' after an
|
|
612
|
+
// upgrade flipped the source to multiProfile — a plugin nothing can run.
|
|
613
|
+
const profiles = s.multiProfile ? listProfileIds(name) : [];
|
|
614
|
+
if (s.multiProfile && !profiles.length) {
|
|
615
|
+
c(`config:${s.id}`, false, 'no profiles yet — create one in Plugins settings (every run is rejected until then)');
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
for (const profile of profiles.length ? profiles : [undefined]) {
|
|
619
|
+
const id = profile ? `config:${s.id}@${profile}` : `config:${s.id}`;
|
|
620
|
+
try {
|
|
621
|
+
const r = await shim.callSource({ plugin: name, sourceId: s.id, op: 'validateConfig', profile });
|
|
622
|
+
c(id, r?.ok !== false, r?.ok === false ? JSON.stringify(r.errors ?? r) : 'validateConfig ok');
|
|
623
|
+
} catch (err) {
|
|
624
|
+
c(id, false, String(err?.message || err));
|
|
625
|
+
}
|
|
458
626
|
}
|
|
459
627
|
}
|
|
460
628
|
}
|
|
@@ -462,14 +630,20 @@ export async function doctorPlugin(name) {
|
|
|
462
630
|
return { ok: checks.every((x) => x.ok), checks };
|
|
463
631
|
}
|
|
464
632
|
|
|
465
|
-
/**
|
|
466
|
-
|
|
633
|
+
/**
|
|
634
|
+
* Dev mode (spec §6.6): current -> absolute working dir; lock { linked: true }.
|
|
635
|
+
* ASYNC because linking, like installing, ends with the workflow-template
|
|
636
|
+
* import: `plugin link` is the documented author loop (SKILL.md) and
|
|
637
|
+
* `updatePlugin` refuses a linked plugin, so without this a linked plugin's
|
|
638
|
+
* templates could reach the store by NO path at all. A linked dir is
|
|
639
|
+
* live-edited, so the refresh path afterwards is `worca plugin reimport <name>`.
|
|
640
|
+
* @returns {Promise<{ok:true, name:string, dir:string,
|
|
641
|
+
* workflows:{imported:string[], skipped:Array<{file:string, errors:string[]}>}}>}
|
|
642
|
+
*/
|
|
643
|
+
export async function linkPlugin(name, absDir) {
|
|
467
644
|
const dir = resolve(absDir);
|
|
468
645
|
const v = validatePluginDir(dir);
|
|
469
|
-
if (!v.ok) {
|
|
470
|
-
const lines = v.problems.filter((p) => p.level === 'error').map((p) => p.message);
|
|
471
|
-
throw new Error(`cannot link: ${lines.join('; ')}`);
|
|
472
|
-
}
|
|
646
|
+
if (!v.ok) throw new Error(`cannot link: ${refusalLines(v.problems, '').join('; ')}`);
|
|
473
647
|
if (v.manifest.name !== name) {
|
|
474
648
|
throw new Error(`manifest name "${v.manifest.name}" does not match "${name}"`);
|
|
475
649
|
}
|
|
@@ -481,5 +655,38 @@ export function linkPlugin(name, absDir) {
|
|
|
481
655
|
installedAt: new Date().toISOString(), linked: true,
|
|
482
656
|
};
|
|
483
657
|
writePluginsLock(lock);
|
|
484
|
-
|
|
658
|
+
// Same isolation as installPlugin: an import failure must not undo a link
|
|
659
|
+
// that already landed — the lock and the symlink are correct either way.
|
|
660
|
+
let workflows = { imported: [], skipped: [] };
|
|
661
|
+
try {
|
|
662
|
+
workflows = await importPluginWorkflows(name, dir);
|
|
663
|
+
for (const s of workflows.skipped) {
|
|
664
|
+
console.warn(`[plugin-store] ${name}: workflow ${s.file} not imported (${s.errors.join('; ')})`);
|
|
665
|
+
}
|
|
666
|
+
} catch (err) {
|
|
667
|
+
console.warn(`[plugin-store] ${name}: workflow import failed (${err?.message || err}) — plugin linked; re-import with \`worca plugin reimport ${name}\``);
|
|
668
|
+
}
|
|
669
|
+
return { ok: true, name, dir, workflows };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Re-run the workflow-template import for an INSTALLED OR LINKED plugin
|
|
674
|
+
* (spec §6.6). A linked dir is live-edited and `updatePlugin` refuses linked
|
|
675
|
+
* plugins, so this is the only refresh path a plugin author has; for an
|
|
676
|
+
* installed plugin it is the cheap way to retry an import that failed.
|
|
677
|
+
* Upsert-only: a template file the author DELETED keeps its row until the
|
|
678
|
+
* plugin is removed.
|
|
679
|
+
* @returns {Promise<{ok:true, name:string,
|
|
680
|
+
* workflows:{imported:string[], skipped:Array<{file:string, errors:string[]}>},
|
|
681
|
+
* ignored:Array<{file:string, reason:string}>}>}
|
|
682
|
+
*/
|
|
683
|
+
export async function reimportPlugin(name) {
|
|
684
|
+
if (!readPluginsLock()[name]) throw new Error(`plugin "${name}" is not installed`);
|
|
685
|
+
const cur = pluginCurrentDir(name);
|
|
686
|
+
if (!existsSync(cur)) throw new Error(`plugin "${name}": current symlink missing or dangling`);
|
|
687
|
+
const workflows = await importPluginWorkflows(name, cur);
|
|
688
|
+
for (const s of workflows.skipped) {
|
|
689
|
+
console.warn(`[plugin-store] ${name}: workflow ${s.file} not imported (${s.errors.join('; ')})`);
|
|
690
|
+
}
|
|
691
|
+
return { ok: true, name, workflows, ignored: ignoredContributions(name, cur, { workflowSkips: workflows.skipped }) };
|
|
485
692
|
}
|
|
@@ -11,7 +11,9 @@ import { join, basename } from 'node:path';
|
|
|
11
11
|
import { getDb, prepare, tx } from './db.mjs';
|
|
12
12
|
import { slugify } from './artifacts.mjs';
|
|
13
13
|
import { loadAgentRegistry } from './agent-registry.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { registryPortsFn } from './graph/registry-ports.mjs';
|
|
15
|
+
import { validateGraph } from '../shared/graph/validate.mjs';
|
|
16
|
+
import { NOT_GRAPH_V2 } from './plugin-manifest.mjs';
|
|
15
17
|
import { pluginCurrentDir } from './plugins-lock.mjs';
|
|
16
18
|
|
|
17
19
|
/** Uninstall guard error: plugin workflows are still referenced by project state. */
|
|
@@ -31,60 +33,102 @@ const normDomain = (raw) => {
|
|
|
31
33
|
};
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* @param {
|
|
41
|
-
*
|
|
42
|
-
*
|
|
36
|
+
* READ + VALIDATE every <versionDir>/workflows/*.json WITHOUT touching the DB.
|
|
37
|
+
* Split out of importPluginWorkflows so the Plugins card, the doctor and the
|
|
38
|
+
* install receipt can name a skipped template with the SAME reason the importer
|
|
39
|
+
* logged — a second, re-derived reader would be free to disagree with it.
|
|
40
|
+
* @param {string} name
|
|
41
|
+
* @param {string} versionDir
|
|
42
|
+
* @param {{registry?: object, quiet?: boolean}} [opts] registry: reuse ONE
|
|
43
|
+
* loadAgentRegistry() across plugins; quiet: no console.warn (read-only
|
|
44
|
+
* callers must not re-log what the importer already logged).
|
|
45
|
+
* @returns {{ready: Array<object>, skipped: Array<{file:string, errors:string[]}>}}
|
|
43
46
|
*/
|
|
44
|
-
export
|
|
47
|
+
export function readPluginWorkflows(name, versionDir, { registry = null, quiet = false } = {}) {
|
|
45
48
|
const origin = `plugin:${name}`;
|
|
46
49
|
const dir = join(versionDir, 'workflows');
|
|
47
50
|
let files = [];
|
|
48
51
|
try { files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort(); } catch { /* no workflows/ */ }
|
|
49
|
-
const
|
|
52
|
+
const ready = [];
|
|
50
53
|
const skipped = [];
|
|
51
|
-
if (!files.length) return {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
if (!files.length) return { ready, skipped };
|
|
55
|
+
// ONE registry load for the whole read, and the SHARED port synthesis over
|
|
56
|
+
// it: agent meta ports plus the universal `await` gate and the flow-card
|
|
57
|
+
// table. The templates are not in the DB yet, so resolveGraph is unavailable —
|
|
58
|
+
// registryPortsFn exists for exactly this caller and the server's save route.
|
|
59
|
+
const portsFn = registryPortsFn(registry || loadAgentRegistry());
|
|
60
|
+
const warn = (msg) => { if (!quiet) console.warn(msg); };
|
|
61
|
+
const skip = (f, errors) => {
|
|
62
|
+
skipped.push({ file: f, errors });
|
|
63
|
+
warn(`[plugin-workflows] ${name}/${f}: invalid template — skipped (${errors.join('; ')})`);
|
|
64
|
+
};
|
|
55
65
|
for (const f of files) {
|
|
56
66
|
let raw;
|
|
57
67
|
try {
|
|
58
68
|
raw = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
59
69
|
} catch (err) {
|
|
60
70
|
skipped.push({ file: f, errors: [`unreadable JSON: ${err.message}`] });
|
|
61
|
-
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const tpl = {
|
|
65
|
-
steps: Array.isArray(raw?.steps) ? raw.steps : [],
|
|
66
|
-
feedbacks: Array.isArray(raw?.feedbacks) ? raw.feedbacks : [],
|
|
67
|
-
};
|
|
68
|
-
const v = validateWorkflow(tpl, registry);
|
|
69
|
-
if (!v.ok) {
|
|
70
|
-
skipped.push({ file: f, errors: v.errors });
|
|
71
|
-
console.warn(`[plugin-workflows] ${name}/${f}: invalid template — skipped (${v.errors.join('; ')})`);
|
|
71
|
+
warn(`[plugin-workflows] ${name}/${f}: unreadable JSON — skipped`);
|
|
72
72
|
continue;
|
|
73
73
|
}
|
|
74
|
+
// A v1 `steps` template is called out by name rather than left to V1's
|
|
75
|
+
// generic "version must be 2": the plugin author needs to know their
|
|
76
|
+
// template needs porting, not that a field is off by one. Same clause the
|
|
77
|
+
// validator prints, imported so the two can never drift.
|
|
78
|
+
if (Number(raw?.version) !== 2) { skip(f, [NOT_GRAPH_V2]); continue; }
|
|
74
79
|
const id = `wfp_${name}_${slugify(basename(f, '.json'))}`;
|
|
75
80
|
const rowName = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : basename(f, '.json');
|
|
81
|
+
const domain = normDomain(raw.domain);
|
|
82
|
+
const nodes = Array.isArray(raw.nodes) ? raw.nodes : [];
|
|
83
|
+
const wires = Array.isArray(raw.wires) ? raw.wires : [];
|
|
84
|
+
// The FULL kind set {agent, task, and, or, combine, end} and every rule
|
|
85
|
+
// V1-V21 — including V20/V21's mandatory Task + End cards and V7's
|
|
86
|
+
// one-wire-per-input — apply to a plugin template exactly as they do to a
|
|
87
|
+
// hand-composed one. Warnings never block an import (they do not block a
|
|
88
|
+
// save either); they are logged so a template that will misbehave at run
|
|
89
|
+
// time says so at install.
|
|
90
|
+
const { errors, warnings } = validateGraph({ id, name: rowName, version: 2, domain, nodes, wires }, portsFn);
|
|
91
|
+
if (errors.length) { skip(f, errors.map((e) => `${e.code}: ${e.message}`)); continue; }
|
|
92
|
+
for (const w of warnings) warn(`[plugin-workflows] ${name}/${f}: ${w.code}: ${w.message}`);
|
|
93
|
+
// graph holds {nodes, wires, canvas?} ONLY — id/name/domain/origin are row
|
|
94
|
+
// columns, so a rename can never drift between the two.
|
|
95
|
+
const graph = { nodes, wires };
|
|
96
|
+
if (raw.canvas && typeof raw.canvas === 'object') graph.canvas = raw.canvas; // accepted, engine-ignored
|
|
97
|
+
ready.push({ file: f, id, rowName, domain, graph, origin });
|
|
98
|
+
}
|
|
99
|
+
return { ready, skipped };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Upsert every <versionDir>/workflows/*.json into the workflows table.
|
|
104
|
+
* id = wfp_<plugin>_<slug(filename)>, origin = 'plugin:<plugin>'. Each template
|
|
105
|
+
* is validated by the SHARED `validateGraph` over `registryPortsFn(loadAgentRegistry())`
|
|
106
|
+
* — importing runs AFTER the symlink swap + lock write, so the plugin's own agents resolve. An
|
|
107
|
+
* invalid/unreadable template is skipped with a warning, never thrown (spec §9.3).
|
|
108
|
+
* No workflows/ dir => { imported: [], skipped: [] } (feature-off no-op).
|
|
109
|
+
* @param {string} name plugin name (kebab-case; id stays SAFE_WORKFLOW_ID-legal)
|
|
110
|
+
* @param {string} versionDir the exported version dir (or current/ — same tree)
|
|
111
|
+
* @returns {Promise<{imported: string[], skipped: Array<{file:string, errors:string[]}>}>}
|
|
112
|
+
*/
|
|
113
|
+
export async function importPluginWorkflows(name, versionDir) {
|
|
114
|
+
const { ready, skipped } = readPluginWorkflows(name, versionDir);
|
|
115
|
+
const imported = [];
|
|
116
|
+
if (!ready.length && !skipped.length) return { imported, skipped };
|
|
117
|
+
getDb(); // open + migrate: workflows.origin/graph/archived_at exist (V13/V23)
|
|
118
|
+
const now = new Date().toISOString();
|
|
119
|
+
for (const r of ready) {
|
|
76
120
|
tx(() => {
|
|
77
121
|
prepare(`
|
|
78
|
-
INSERT INTO workflows (id, name, version, domain, steps, feedbacks, origin, created_at, updated_at)
|
|
79
|
-
VALUES (?, ?,
|
|
122
|
+
INSERT INTO workflows (id, name, version, domain, graph, steps, feedbacks, origin, created_at, updated_at, archived_at)
|
|
123
|
+
VALUES (?, ?, 2, ?, ?, '[]', '[]', ?, ?, ?, NULL)
|
|
80
124
|
ON CONFLICT(id) DO UPDATE SET
|
|
81
|
-
name = excluded.name, version =
|
|
82
|
-
|
|
83
|
-
origin = excluded.origin, updated_at = excluded.updated_at
|
|
84
|
-
|
|
85
|
-
|
|
125
|
+
name = excluded.name, version = 2, domain = excluded.domain,
|
|
126
|
+
graph = excluded.graph, steps = '[]', feedbacks = '[]',
|
|
127
|
+
origin = excluded.origin, updated_at = excluded.updated_at,
|
|
128
|
+
archived_at = NULL
|
|
129
|
+
`).run(r.id, r.rowName, r.domain, JSON.stringify(r.graph), r.origin, now, now);
|
|
86
130
|
});
|
|
87
|
-
imported.push(id);
|
|
131
|
+
imported.push(r.id);
|
|
88
132
|
}
|
|
89
133
|
return { imported, skipped };
|
|
90
134
|
}
|
|
@@ -163,15 +207,20 @@ export function referencedPluginAgents(name) {
|
|
|
163
207
|
getDb();
|
|
164
208
|
const out = [];
|
|
165
209
|
for (const row of prepare(
|
|
166
|
-
'SELECT id, name, steps FROM workflows WHERE origin IS NULL OR origin != ?',
|
|
210
|
+
'SELECT id, name, steps, graph FROM workflows WHERE origin IS NULL OR origin != ?',
|
|
167
211
|
).all(`plugin:${name}`)) {
|
|
168
|
-
let steps;
|
|
169
|
-
try { steps = JSON.parse(row.steps); } catch { continue; }
|
|
170
212
|
const found = new Set();
|
|
213
|
+
let steps;
|
|
214
|
+
try { steps = JSON.parse(row.steps); } catch { steps = null; }
|
|
171
215
|
for (const group of Array.isArray(steps) ? steps : []) {
|
|
172
|
-
for (const node of Array.isArray(group) ? group : [])
|
|
173
|
-
|
|
174
|
-
|
|
216
|
+
for (const node of Array.isArray(group) ? group : []) if (node && keys.has(node.key)) found.add(node.key);
|
|
217
|
+
}
|
|
218
|
+
let graph;
|
|
219
|
+
try { graph = JSON.parse(row.graph || 'null'); } catch { graph = null; }
|
|
220
|
+
// v2 rows: only kind 'agent' nodes carry a key (a task/end/and/or/combine
|
|
221
|
+
// card never does), so the walk is narrowed rather than key-only.
|
|
222
|
+
for (const node of Array.isArray(graph?.nodes) ? graph.nodes : []) {
|
|
223
|
+
if (node && node.kind === 'agent' && keys.has(node.key)) found.add(node.key);
|
|
175
224
|
}
|
|
176
225
|
if (found.size) out.push({ workflowId: row.id, name: row.name, keys: [...found].sort() });
|
|
177
226
|
}
|