@worca/app 1.0.0 → 1.1.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 +22 -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 +319 -45
- 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 +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -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 +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- 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/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 +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- 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 +77 -14
- 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 +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -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 +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -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 +269 -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 +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- 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
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from './run-manifest.mjs';
|
|
31
31
|
import { branchExists, hasGh, findPrForBranch } from './git-info.mjs';
|
|
32
32
|
import { retainedWorkPatchName } from './results.mjs';
|
|
33
|
+
import { deleteCommentsForRun } from './diff-comments.mjs';
|
|
33
34
|
|
|
34
35
|
// Statuses for which deletion is refused (the entry is or may be live).
|
|
35
36
|
const ACTIVE = new Set(['running', 'starting', 'created', 'pausing']);
|
|
@@ -60,8 +61,12 @@ function lookupRow(storeKey, id) {
|
|
|
60
61
|
*/
|
|
61
62
|
function artifactAbsPath(relPath, pipelineDir, storeRootDir) {
|
|
62
63
|
if (isAbsolute(relPath)) return relPath;
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
// Rows are indexed with '/' (see _recordArtifact); rows written by earlier
|
|
65
|
+
// Windows builds carry '\\' — normalise before the layout check so those
|
|
66
|
+
// shared plan/review files are still re-rooted (and unlinked) correctly.
|
|
67
|
+
const rel = relPath.replace(/\\/g, '/');
|
|
68
|
+
if (rel.startsWith('plans/') || rel.startsWith('reviews/')) return join(storeRootDir, rel);
|
|
69
|
+
return join(pipelineDir, rel);
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -252,6 +257,12 @@ export async function archivePipeline({ projectDir = null, key = null, workspace
|
|
|
252
257
|
// pipeline_steps and the other children stay: stats fallback sums need them.
|
|
253
258
|
tx(() => {
|
|
254
259
|
getDb().prepare('DELETE FROM artifacts WHERE pipeline_id = ?').run(row.id);
|
|
260
|
+
// The run dir (and with it diff-patch.patch) was just removed, so nothing could
|
|
261
|
+
// re-anchor these comments and creation is refused from here on. They die with
|
|
262
|
+
// the artifacts index rather than lingering as unreachable rows — the declared
|
|
263
|
+
// pipelines cascade cannot do it, because the pipelines row is never DELETEd
|
|
264
|
+
// (see this file's header).
|
|
265
|
+
deleteCommentsForRun(row.id);
|
|
255
266
|
getDb().prepare('UPDATE pipelines SET archived_at = ? WHERE id = ?')
|
|
256
267
|
.run(new Date().toISOString(), row.id);
|
|
257
268
|
});
|
package/src/core/plugin-api.mjs
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
// core graph.
|
|
9
9
|
//
|
|
10
10
|
// API 2 adds the chatChannels contribution + persistent channel worker
|
|
11
|
-
// protocol; the
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
// protocol; API 3 changes the DATA contract: agent sidecars are meta v2 (typed
|
|
12
|
+
// ports) and workflows/*.json are v2 graphs. The task-source connector and the
|
|
13
|
+
// channel-worker protocols are unchanged across 1 -> 2 -> 3, so a connector-only
|
|
14
|
+
// ">=1 <2" plugin and a chat plugin's ">=2 <3" keep negotiating 1 and 2 and keep
|
|
15
|
+
// working untouched. The set is what makes that possible: never collapse it to a
|
|
16
|
+
// single integer.
|
|
17
|
+
export const WORCA_PLUGIN_API = 3;
|
|
18
|
+
export const WORCA_PLUGIN_APIS = [1, 2, 3];
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
// src/core/plugin-config.mjs
|
|
2
2
|
// Per-plugin settings/secrets/state under <pluginDir>/data (spec §5, §7.6).
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
//
|
|
4
|
+
// PROFILES: every file is keyed by profile id, so one plugin can hold several
|
|
5
|
+
// complete, independent setups — two Jira servers, two GitHub orgs — instead of
|
|
6
|
+
// one that gets overwritten each time you switch. A source that does not declare
|
|
7
|
+
// multiProfile simply never uses more than the DEFAULT_PROFILE bucket, which is
|
|
8
|
+
// created implicitly, so single-profile plugins need no special handling here.
|
|
9
|
+
//
|
|
10
|
+
// config.json { "$format": "profiles/1", "profiles": { "<id>": { key: value } } }
|
|
11
|
+
// secrets.json { "$format": "profiles/1", "profiles": { "<id>": { … } } } mode 0600
|
|
12
|
+
// state.json { "$format": "profiles/1", "profiles": { "<id>": { … } } }
|
|
13
|
+
//
|
|
14
|
+
// Secrets: mode 0600, atomic temp+rename (settings.mjs:89-92 idiom),
|
|
15
|
+
// {"$env":"VAR"} indirection resolved at READ time only — stored verbatim so the
|
|
16
|
+
// value never touches disk. Explicitly NOT in worca-cc.db.
|
|
6
17
|
// All functions are sync (contract; callers are the shim + server routes).
|
|
7
18
|
|
|
8
19
|
import { readFileSync, writeFileSync, renameSync, mkdirSync, chmodSync } from 'node:fs';
|
|
@@ -10,6 +21,24 @@ import { join, dirname } from 'node:path';
|
|
|
10
21
|
import { randomBytes } from 'node:crypto';
|
|
11
22
|
import { pluginDataDir } from './plugins-lock.mjs';
|
|
12
23
|
|
|
24
|
+
/** The bucket every source gets for free; the only one a single-profile source uses. */
|
|
25
|
+
export const DEFAULT_PROFILE = 'default';
|
|
26
|
+
|
|
27
|
+
/** Profile ids are directory-safe and URL-safe: they become path segments in a
|
|
28
|
+
* connector's own storage (jira-source hangs a $JTR_CONFIG_DIR off one) and
|
|
29
|
+
* route params on /api/plugins/:name/profiles/:id. */
|
|
30
|
+
export const PROFILE_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
31
|
+
|
|
32
|
+
export function isValidProfileId(id) {
|
|
33
|
+
return PROFILE_ID_RE.test(String(id ?? ''));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function profileId(id) {
|
|
37
|
+
const p = id == null || id === '' ? DEFAULT_PROFILE : String(id);
|
|
38
|
+
if (!isValidProfileId(p)) throw new Error(`invalid profile id "${p}" (lowercase letters, digits and dashes)`);
|
|
39
|
+
return p;
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
function readJson(file) {
|
|
14
43
|
try {
|
|
15
44
|
const v = JSON.parse(readFileSync(file, 'utf8'));
|
|
@@ -19,6 +48,36 @@ function readJson(file) {
|
|
|
19
48
|
}
|
|
20
49
|
}
|
|
21
50
|
|
|
51
|
+
/** In-band format marker for the profile envelope. No shape heuristic can be
|
|
52
|
+
* exact here: config keys come from configSchema, but STATE keys are whatever
|
|
53
|
+
* a connector passed to ctx.state.set(), so a legacy flat file may legally be
|
|
54
|
+
* { "profiles": { <id>: {...} } } byte-for-byte — data, not envelope. Only the
|
|
55
|
+
* marker distinguishes the two, and only writeBucket/deleteProfile emit it. */
|
|
56
|
+
const ENVELOPE_FORMAT = 'profiles/1';
|
|
57
|
+
|
|
58
|
+
const isBag = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
59
|
+
|
|
60
|
+
/** The marked { $format, profiles: {...} } envelope, tolerant of a
|
|
61
|
+
* missing/garbage file. A file WITHOUT the marker is a pre-profiles flat bag
|
|
62
|
+
* ({ key: value } at the top level) — read it, whole and unjudged, as the
|
|
63
|
+
* implicit DEFAULT_PROFILE bucket so an upgrade never orphans stored
|
|
64
|
+
* config/secrets/state; the next writeBucket persists the marked envelope. */
|
|
65
|
+
function readBuckets(file) {
|
|
66
|
+
const raw = readJson(file);
|
|
67
|
+
if (raw.$format === ENVELOPE_FORMAT && isBag(raw.profiles)) {
|
|
68
|
+
return Object.fromEntries(
|
|
69
|
+
Object.entries(raw.profiles).filter(([id, bag]) => isValidProfileId(id) && isBag(bag)),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return Object.keys(raw).length ? { [DEFAULT_PROFILE]: raw } : {};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One profile's bag from a file. */
|
|
76
|
+
function readBucket(file, profile) {
|
|
77
|
+
const b = readBuckets(file)[profile];
|
|
78
|
+
return b && typeof b === 'object' && !Array.isArray(b) ? b : {};
|
|
79
|
+
}
|
|
80
|
+
|
|
22
81
|
function writeJsonAtomic(file, obj, { mode } = {}) {
|
|
23
82
|
mkdirSync(dirname(file), { recursive: true });
|
|
24
83
|
const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
|
|
@@ -27,20 +86,101 @@ function writeJsonAtomic(file, obj, { mode } = {}) {
|
|
|
27
86
|
renameSync(tmp, file);
|
|
28
87
|
}
|
|
29
88
|
|
|
89
|
+
/** Replace one profile's bag inside a file, leaving every other profile alone. */
|
|
90
|
+
function writeBucket(file, profile, bucket, opts) {
|
|
91
|
+
const all = readBuckets(file);
|
|
92
|
+
all[profile] = bucket;
|
|
93
|
+
writeJsonAtomic(file, { $format: ENVELOPE_FORMAT, profiles: all }, opts);
|
|
94
|
+
}
|
|
95
|
+
|
|
30
96
|
const isEnvRef = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && typeof v.$env === 'string';
|
|
31
97
|
/** The exact redaction marker redactedConfig emits — must never be persisted. */
|
|
32
98
|
const isSetMarker = (v) => !!v && typeof v === 'object' && v.set === true && Object.keys(v).length === 1;
|
|
33
99
|
|
|
34
100
|
function files(name) {
|
|
35
101
|
const dir = pluginDataDir(name);
|
|
36
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
config: join(dir, 'config.json'),
|
|
104
|
+
secrets: join(dir, 'secrets.json'),
|
|
105
|
+
state: join(dir, 'state.json'),
|
|
106
|
+
profiles: join(dir, 'profiles.json'),
|
|
107
|
+
};
|
|
37
108
|
}
|
|
38
109
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
110
|
+
// ── profiles ──────────────────────────────────────────────────────────────────
|
|
111
|
+
// The roster lives in its own file rather than being inferred from config.json's
|
|
112
|
+
// keys: a profile is real the moment it is created, BEFORE anything is saved into
|
|
113
|
+
// it, and it keeps a human label the id cannot carry.
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every profile of a plugin, in creation order.
|
|
117
|
+
* @returns {Array<{id:string, label:string}>}
|
|
118
|
+
*/
|
|
119
|
+
export function listProfiles(name) {
|
|
120
|
+
const raw = readJson(files(name).profiles);
|
|
121
|
+
const list = Array.isArray(raw.profiles) ? raw.profiles : [];
|
|
122
|
+
return list
|
|
123
|
+
.filter((p) => p && isValidProfileId(p.id))
|
|
124
|
+
.map((p) => ({ id: String(p.id), label: String(p.label || p.id) }));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Just the ids — the shape resolveProfile()/the UI gate want. */
|
|
128
|
+
export function listProfileIds(name) {
|
|
129
|
+
return listProfiles(name).map((p) => p.id);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a profile. Idempotent on the id (a repeat call updates the label only),
|
|
134
|
+
* so a caller that ensures DEFAULT_PROFILE on every save cannot duplicate it.
|
|
135
|
+
* @returns {{id:string, label:string}}
|
|
136
|
+
*/
|
|
137
|
+
export function createProfile(name, id, label) {
|
|
138
|
+
const pid = profileId(id);
|
|
139
|
+
// The default bucket is shared plumbing, not a profile: chat channels, model
|
|
140
|
+
// secrets and migrated pre-profiles config all live in it via profile-less
|
|
141
|
+
// reads. Enrolling "default" in the roster would make that shared bucket
|
|
142
|
+
// deletable like any member — one Remove click away from wiping them all.
|
|
143
|
+
if (pid === DEFAULT_PROFILE) throw new Error(`profile id "${DEFAULT_PROFILE}" is reserved`);
|
|
144
|
+
const list = listProfiles(name);
|
|
145
|
+
const at = list.findIndex((p) => p.id === pid);
|
|
146
|
+
const entry = { id: pid, label: String(label || (at >= 0 ? list[at].label : pid)) };
|
|
147
|
+
if (at >= 0) list[at] = entry;
|
|
148
|
+
else list.push(entry);
|
|
149
|
+
writeJsonAtomic(files(name).profiles, { profiles: list });
|
|
150
|
+
return entry;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Delete a profile and everything stored under it (config, secrets, state).
|
|
155
|
+
* Callers are responsible for the bindings that named it — see
|
|
156
|
+
* source-bindings.mjs#clearBindingsForProfile.
|
|
157
|
+
*/
|
|
158
|
+
export function deleteProfile(name, id) {
|
|
159
|
+
const pid = profileId(id);
|
|
160
|
+
// Same reservation as createProfile — and a backstop for a roster that
|
|
161
|
+
// enrolled "default" before the reservation existed: deleting it would strip
|
|
162
|
+
// the shared bucket (chat-channel config, model secrets, migrated legacy
|
|
163
|
+
// data) out of all three files.
|
|
164
|
+
if (pid === DEFAULT_PROFILE) throw new Error(`profile id "${DEFAULT_PROFILE}" is reserved — its bucket backs every profile-less read`);
|
|
42
165
|
const f = files(name);
|
|
43
|
-
|
|
166
|
+
writeJsonAtomic(f.profiles, { profiles: listProfiles(name).filter((p) => p.id !== pid) });
|
|
167
|
+
for (const [file, opts] of [[f.config, undefined], [f.secrets, { mode: 0o600 }], [f.state, undefined]]) {
|
|
168
|
+
const all = readBuckets(file);
|
|
169
|
+
if (!(pid in all)) continue;
|
|
170
|
+
delete all[pid];
|
|
171
|
+
writeJsonAtomic(file, { $format: ENVELOPE_FORMAT, profiles: all }, opts);
|
|
172
|
+
}
|
|
173
|
+
return { ok: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── config ────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/** Merged config.json + secrets.json for ONE profile (secrets win), schema
|
|
179
|
+
* defaults applied, {"$env":"VAR"} resolved (unset env -> null). */
|
|
180
|
+
export function readPluginConfig(name, configSchema = [], profile) {
|
|
181
|
+
const pid = profileId(profile);
|
|
182
|
+
const f = files(name);
|
|
183
|
+
const raw = { ...readBucket(f.config, pid), ...readBucket(f.secrets, pid) };
|
|
44
184
|
const out = {};
|
|
45
185
|
for (const field of configSchema) {
|
|
46
186
|
let v = raw[field.key];
|
|
@@ -51,13 +191,15 @@ export function readPluginConfig(name, configSchema = []) {
|
|
|
51
191
|
return out;
|
|
52
192
|
}
|
|
53
193
|
|
|
54
|
-
/** Route values by schema: secret:true -> secrets.json (0600), else config.json
|
|
55
|
-
* undefined / redaction-marker values keep the
|
|
56
|
-
* $env refs are stored verbatim. Both writes
|
|
57
|
-
|
|
194
|
+
/** Route values by schema: secret:true -> secrets.json (0600), else config.json,
|
|
195
|
+
* both inside `profile`'s bucket. undefined / redaction-marker values keep the
|
|
196
|
+
* prior stored value; null clears. $env refs are stored verbatim. Both writes
|
|
197
|
+
* are temp+rename atomic. */
|
|
198
|
+
export function writePluginConfig(name, configSchema = [], values = {}, profile) {
|
|
199
|
+
const pid = profileId(profile);
|
|
58
200
|
const f = files(name);
|
|
59
|
-
const config =
|
|
60
|
-
const secrets =
|
|
201
|
+
const config = readBucket(f.config, pid);
|
|
202
|
+
const secrets = readBucket(f.secrets, pid);
|
|
61
203
|
const secretKeys = new Set(configSchema.filter((x) => x && x.secret).map((x) => x.key));
|
|
62
204
|
for (const [k, v] of Object.entries(values && typeof values === 'object' ? values : {})) {
|
|
63
205
|
if (v === undefined || isSetMarker(v)) continue; // absent / echoed marker -> keep prior
|
|
@@ -67,17 +209,19 @@ export function writePluginConfig(name, configSchema = [], values = {}) {
|
|
|
67
209
|
if (v === null) delete bucket[k];
|
|
68
210
|
else bucket[k] = v;
|
|
69
211
|
}
|
|
70
|
-
|
|
71
|
-
|
|
212
|
+
writeBucket(f.config, pid, config);
|
|
213
|
+
writeBucket(f.secrets, pid, secrets, { mode: 0o600 });
|
|
72
214
|
return { ok: true };
|
|
73
215
|
}
|
|
74
216
|
|
|
75
|
-
/** UI echo shape: secrets -> { set: true|false } markers,
|
|
76
|
-
* (with defaults). Secret VALUES never reach the browser
|
|
77
|
-
|
|
217
|
+
/** UI echo shape for ONE profile: secrets -> { set: true|false } markers,
|
|
218
|
+
* non-secrets verbatim (with defaults). Secret VALUES never reach the browser
|
|
219
|
+
* after save (§7.6). */
|
|
220
|
+
export function redactedConfig(name, configSchema = [], profile) {
|
|
221
|
+
const pid = profileId(profile);
|
|
78
222
|
const f = files(name);
|
|
79
|
-
const config =
|
|
80
|
-
const secrets =
|
|
223
|
+
const config = readBucket(f.config, pid);
|
|
224
|
+
const secrets = readBucket(f.secrets, pid);
|
|
81
225
|
const out = {};
|
|
82
226
|
for (const field of configSchema) {
|
|
83
227
|
if (field.secret) out[field.key] = { set: secrets[field.key] !== undefined };
|
|
@@ -86,15 +230,21 @@ export function redactedConfig(name, configSchema = []) {
|
|
|
86
230
|
return out;
|
|
87
231
|
}
|
|
88
232
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
233
|
+
// ── connector state ───────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/** Connector KV for ONE profile (cursors, etags) — host-persisted ctx.state
|
|
236
|
+
* backing (§7.1). Per-profile so two instances of the same source cannot read
|
|
237
|
+
* back each other's cached cursors. */
|
|
238
|
+
export function readPluginState(name, profile) {
|
|
239
|
+
return readBucket(files(name).state, profileId(profile));
|
|
92
240
|
}
|
|
93
241
|
|
|
94
|
-
/** Shallow-merge patch into state
|
|
95
|
-
|
|
242
|
+
/** Shallow-merge patch into one profile's state, atomic write. Returns the new
|
|
243
|
+
* bag for that profile. */
|
|
244
|
+
export function writePluginState(name, patch = {}, profile) {
|
|
245
|
+
const pid = profileId(profile);
|
|
96
246
|
const f = files(name);
|
|
97
|
-
const next = { ...
|
|
98
|
-
|
|
247
|
+
const next = { ...readBucket(f.state, pid), ...(patch && typeof patch === 'object' ? patch : {}) };
|
|
248
|
+
writeBucket(f.state, pid, next);
|
|
99
249
|
return next;
|
|
100
250
|
}
|
|
@@ -29,8 +29,12 @@ export async function inventoryFromCache(repoUrl, sha, subdir, { exec = defaultE
|
|
|
29
29
|
// be parsed as a git option (defense layer 2; the parser rejects it too).
|
|
30
30
|
await exec('git', ['--git-dir', repoCacheDir(repoUrl), 'archive', '--format=tar', '-o', tar,
|
|
31
31
|
...(subdir ? [sha, '--', subdir] : [sha])]);
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Extract with cwd = tmp (the destination) so tar never receives a native
|
|
33
|
+
// Windows path: Git-for-Windows GNU tar reads a colon in `-f` as a `host:path`
|
|
34
|
+
// remote spec ("cannot connect to C:") and cannot parse backslash separators.
|
|
35
|
+
// A relative `-f` and no `-C` sidesteps both; the archive lands in cwd.
|
|
36
|
+
await exec('tar', ['-xf', 'x.tar',
|
|
37
|
+
...(subdir ? ['--strip-components', String(subdir.split('/').length)] : [])], { cwd: tmp });
|
|
34
38
|
await rm(tar, { force: true });
|
|
35
39
|
// Defense-in-depth for the consent display: strip any symlink that escapes the
|
|
36
40
|
// export root before inventorying (the tmp dir is deleted in finally regardless).
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { readFileSync, readdirSync, readlinkSync, existsSync } from 'node:fs';
|
|
7
7
|
import { join, resolve, dirname, sep, isAbsolute } from 'node:path';
|
|
8
|
-
import { WORCA_PLUGIN_APIS } from './plugin-api.mjs';
|
|
9
|
-
import { EFFORTS, isReservedModelEnvKey } from './model-env.mjs';
|
|
8
|
+
import { WORCA_PLUGIN_API, WORCA_PLUGIN_APIS } from './plugin-api.mjs';
|
|
9
|
+
import { EFFORTS, isReservedModelEnvKey, assertModelCost } from './model-env.mjs';
|
|
10
|
+
import { validateMetaV2, normalizeAgentMeta, indexByKey } from '../shared/graph/agent-meta.mjs';
|
|
11
|
+
import { portsFnFor } from '../shared/graph/ports.mjs';
|
|
12
|
+
import { validateGraph } from '../shared/graph/validate.mjs';
|
|
10
13
|
|
|
11
14
|
/** Plugin names are kebab-case, machine-unique, dir-name safe (spec §4.1). */
|
|
12
15
|
export const PLUGIN_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
@@ -17,12 +20,12 @@ const SOURCE_ID_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
|
17
20
|
const FIELD_TYPES = new Set(['text', 'select']);
|
|
18
21
|
const INPUT_TYPES = new Set(['text', 'select', 'remote-select', 'task-browser']);
|
|
19
22
|
const KNOWN_TOP = new Set(['name', 'version', 'description', 'author', 'homepage', 'license', 'engines', 'taskSources', 'chatChannels', 'setup', 'models', 'modelSecrets']);
|
|
20
|
-
const KNOWN_SOURCE = new Set(['id', 'displayName', 'module', 'configSchema', 'inputs']);
|
|
23
|
+
const KNOWN_SOURCE = new Set(['id', 'displayName', 'module', 'configSchema', 'inputs', 'multiProfile']);
|
|
21
24
|
const KNOWN_CHANNEL = new Set(['id', 'displayName', 'platform', 'module', 'ingress', 'capabilities', 'configSchema']);
|
|
22
25
|
const CHANNEL_INGRESS = new Set(['connect', 'webhook']);
|
|
23
26
|
const KNOWN_FIELD = new Set(['key', 'type', 'label', 'secret', 'required', 'default', 'help', 'options']);
|
|
24
27
|
const KNOWN_INPUT = new Set(['key', 'type', 'label', 'default', 'optionsFrom', 'options']);
|
|
25
|
-
const KNOWN_MODEL = new Set(['id', 'label', 'efforts', 'env']);
|
|
28
|
+
const KNOWN_MODEL = new Set(['id', 'label', 'efforts', 'env', 'cost']);
|
|
26
29
|
const KNOWN_MODEL_SECRET = new Set(['key', 'label']);
|
|
27
30
|
/** A manifest env value that defers to the plugin's secrets store (design §9.1). */
|
|
28
31
|
const isSecretRef = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -68,6 +71,86 @@ export function negotiatedApi(range, apis = WORCA_PLUGIN_APIS) {
|
|
|
68
71
|
return best;
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
/** The ONE per-file sentence for each half of the data contract. `validatePluginDir`
|
|
75
|
+
* prefixes `agents/<f>: ` / `workflows/<f>: `; `agent-registry.scanLayer` prefixes the
|
|
76
|
+
* layer and appends ` — ignored`; `plugin-workflows` uses NOT_GRAPH_V2 verbatim as a
|
|
77
|
+
* skip reason. Exported so those three sites can never drift apart — never re-word
|
|
78
|
+
* either string in a second place. */
|
|
79
|
+
export const NOT_META_V2 = 'not a meta v2 sidecar (declare "metaVersion": 2 with typed inputs/outputs) — plugin API 3 no longer reads channel sidecars';
|
|
80
|
+
export const NOT_GRAPH_V2 = 'not a version-2 graph template (nodes/wires) — port the "steps" pipeline';
|
|
81
|
+
|
|
82
|
+
/** The host API a range was BUILT FOR: the lowest integer it accepts. ">=1 <2"
|
|
83
|
+
* and "1" both answer 1; an unconstrained range answers 0; null when nothing
|
|
84
|
+
* satisfies it (an unparseable range fails closed in apiSatisfies too).
|
|
85
|
+
* @param {string} range @returns {number|null} */
|
|
86
|
+
export function declaredApi(range) {
|
|
87
|
+
for (let n = 0; n <= 99; n += 1) if (apiSatisfies(range, n)) return n;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Which files in a plugin dir are still on the API-2 data contract: sidecars
|
|
92
|
+
* without metaVersion 2, templates without version 2. Pure fs read; an absent
|
|
93
|
+
* or unreadable dir/file contributes nothing (validatePluginDir reports those
|
|
94
|
+
* separately as parse errors). Basenames only — the caller prefixes them.
|
|
95
|
+
* @param {string} absDir
|
|
96
|
+
* @returns {{agentsV1: string[], workflowsV1: string[]}} */
|
|
97
|
+
export function dataContractIssues(absDir) {
|
|
98
|
+
const agentsV1 = [];
|
|
99
|
+
const workflowsV1 = [];
|
|
100
|
+
const read = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
101
|
+
const agentsDir = join(absDir, 'agents');
|
|
102
|
+
if (existsSync(agentsDir)) {
|
|
103
|
+
for (const f of readdirSync(agentsDir).filter((x) => x.endsWith('.meta.json')).sort()) {
|
|
104
|
+
const raw = read(join(agentsDir, f));
|
|
105
|
+
if (raw && Number(raw.metaVersion) !== 2) agentsV1.push(f);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const wfDir = join(absDir, 'workflows');
|
|
109
|
+
if (existsSync(wfDir)) {
|
|
110
|
+
for (const f of readdirSync(wfDir).filter((x) => x.endsWith('.json')).sort()) {
|
|
111
|
+
const raw = read(join(wfDir, f));
|
|
112
|
+
if (raw && Number(raw.version) !== 2) workflowsV1.push(f);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { agentsV1, workflowsV1 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The Plugins-view / doctor payload for a plugin whose DATA is still on the old
|
|
120
|
+
* contract, or null when it has nothing the host would ignore. The bump is
|
|
121
|
+
* gated by CONTENT, not by the range: a connector-only plugin declaring
|
|
122
|
+
* ">=1 <2" ships no agents and no templates, so it is never "incompatible" —
|
|
123
|
+
* it simply keeps negotiating API 1.
|
|
124
|
+
* @param {string} range engines['worca-cc-api']
|
|
125
|
+
* @param {{agentsV1: string[], workflowsV1: string[]}} issues
|
|
126
|
+
* @returns {{builtFor: number|null, host: number, agents: number, workflows: number, message: string}|null}
|
|
127
|
+
*/
|
|
128
|
+
export function apiMismatch(range, issues) {
|
|
129
|
+
const agents = (issues && issues.agentsV1 ? issues.agentsV1 : []).length;
|
|
130
|
+
const workflows = (issues && issues.workflowsV1 ? issues.workflowsV1 : []).length;
|
|
131
|
+
if (!agents && !workflows) return null;
|
|
132
|
+
// declaredApi('') is 0 (an unconstrained range accepts everything); report that
|
|
133
|
+
// as null so the message reads "built for an older version", never "API 0".
|
|
134
|
+
const mismatch = { builtFor: declaredApi(range) || null, host: WORCA_PLUGIN_API, agents, workflows };
|
|
135
|
+
mismatch.message = apiMismatchMessage(mismatch);
|
|
136
|
+
return mismatch;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* THE per-plugin sentence (spec §9 wording, "worca" is the product name) —
|
|
141
|
+
* rendered verbatim by the Plugins view (`p.apiMismatch.message`), the doctor's
|
|
142
|
+
* `agents-api` check and `worca plugin list`. An API-outdated plugin is NOT
|
|
143
|
+
* corrupt: it installed fine and its connector or chat channel still works —
|
|
144
|
+
* worca simply ignores the agents and pipeline templates it ships.
|
|
145
|
+
*/
|
|
146
|
+
export function apiMismatchMessage(mismatch) {
|
|
147
|
+
if (!mismatch) return '';
|
|
148
|
+
const { builtFor, agents, workflows } = mismatch;
|
|
149
|
+
return `built for plugin API ${builtFor ?? 'an older version'}; this version of worca requires `
|
|
150
|
+
+ `plugin API ${WORCA_PLUGIN_API} for agents and pipeline templates — update or reinstall the plugin `
|
|
151
|
+
+ `(${agents} agent(s), ${workflows} template(s) ignored)`;
|
|
152
|
+
}
|
|
153
|
+
|
|
71
154
|
const str = (v, d = '') => (typeof v === 'string' ? v.trim() : d);
|
|
72
155
|
|
|
73
156
|
function normOptions(raw) {
|
|
@@ -117,6 +200,31 @@ function badModulePath(mod) {
|
|
|
117
200
|
return null;
|
|
118
201
|
}
|
|
119
202
|
|
|
203
|
+
/**
|
|
204
|
+
* `agentFile` is a PATH, not a label: agent-registry.mjs stamps
|
|
205
|
+
* `agentPath = join(<layer>/agents, meta.agentFile)` and workflows.mjs reads
|
|
206
|
+
* THAT file as the agent's system prompt AND for its `tools:` frontmatter. So
|
|
207
|
+
* it is gated exactly like `module` — relative, no "..", and it must still
|
|
208
|
+
* resolve inside the agents dir. Absent/empty is legal (the agent then has no
|
|
209
|
+
* prompt file). Returns a reason clause or null, like badModulePath.
|
|
210
|
+
*/
|
|
211
|
+
function badAgentFile(agentFile, agentsDir) {
|
|
212
|
+
if (agentFile === undefined || agentFile === null || agentFile === '') return null;
|
|
213
|
+
if (typeof agentFile !== 'string') return 'must be a string';
|
|
214
|
+
const af = agentFile.trim(); // the normalizer trims: judge the string the runtime will use
|
|
215
|
+
if (!af) return null;
|
|
216
|
+
if (isAbsolute(af) || /\\/.test(af)) return 'must be a relative path inside agents/';
|
|
217
|
+
if (af.split('/').includes('..')) return 'must not contain ".."';
|
|
218
|
+
const root = resolve(agentsDir);
|
|
219
|
+
if (!resolve(root, af).startsWith(root + sep)) return 'must be a relative path inside agents/';
|
|
220
|
+
// A contained agentFile must also EXIST: consent and the runtime both read it,
|
|
221
|
+
// so a name with no file behind it says one thing at consent time ("none
|
|
222
|
+
// declared") and another at run time (workflows.loadAgentFile used to fall
|
|
223
|
+
// back into the BUILT-IN agents dir for the same basename).
|
|
224
|
+
if (!existsSync(join(root, af))) return `${af} not found in agents/`;
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
120
228
|
/**
|
|
121
229
|
* Normalize a parsed worca-cc-plugin.json (spec §4.1). Only `name` is required.
|
|
122
230
|
* Unknown fields are ignored and collected as warnings (validatePluginDir
|
|
@@ -195,7 +303,14 @@ export function normalizeManifest(raw, { dir = '' } = {}) {
|
|
|
195
303
|
if (browsers !== 1) {
|
|
196
304
|
errors.push(`${at} ("${id}"): must declare exactly ONE input of type "task-browser" (found ${browsers}) — it is what produces the task (spec §7.4)`);
|
|
197
305
|
}
|
|
198
|
-
|
|
306
|
+
// multiProfile: the source can hold several independent configurations
|
|
307
|
+
// (two Jira servers, two GitHub orgs), each bound to a project/workspace.
|
|
308
|
+
// Opt-in, because it changes the settings UI from one form to a roster —
|
|
309
|
+
// a single-instance source should not pay that.
|
|
310
|
+
taskSources.push({
|
|
311
|
+
id, displayName: str(s.displayName) || id, module, configSchema, inputs,
|
|
312
|
+
multiProfile: s.multiProfile === true,
|
|
313
|
+
});
|
|
199
314
|
});
|
|
200
315
|
}
|
|
201
316
|
const ids = taskSources.map((s) => s.id);
|
|
@@ -293,7 +408,13 @@ export function normalizeManifest(raw, { dir = '' } = {}) {
|
|
|
293
408
|
return;
|
|
294
409
|
}
|
|
295
410
|
for (const [k, v] of Object.entries(envRaw)) {
|
|
296
|
-
|
|
411
|
+
// Reserved keys degrade, never reject: a published manifest is a
|
|
412
|
+
// third-party artifact its user cannot edit, and the spawn-time filter
|
|
413
|
+
// (model-env.mjs prepareModelEnv) drops these defensively anyway — so a
|
|
414
|
+
// host that grows the reserved list (e.g. CLAUDE_CODE_SUBAGENT_MODEL)
|
|
415
|
+
// must not retroactively brick installed/marketplace plugins. The
|
|
416
|
+
// user's own catalog (settings.mjs) still hard-rejects: that author CAN fix it.
|
|
417
|
+
if (isReservedModelEnvKey(k)) { warnings.push(`${at} ("${id}"): env key ${JSON.stringify(k)} is reserved — ignored`); continue; }
|
|
297
418
|
if (isSecretRef(v)) {
|
|
298
419
|
if (!secretKeys.has(v.secret)) { errors.push(`${at} ("${id}"): env ${k} references undeclared modelSecrets key ${JSON.stringify(v.secret)}`); continue; }
|
|
299
420
|
env[k] = { secret: v.secret };
|
|
@@ -303,10 +424,23 @@ export function normalizeManifest(raw, { dir = '' } = {}) {
|
|
|
303
424
|
errors.push(`${at} ("${id}"): env value for ${JSON.stringify(k)} must be a non-empty string or {"secret": "<key>"}`);
|
|
304
425
|
}
|
|
305
426
|
}
|
|
427
|
+
// A plugin that ships a model routed at its OWN endpoint is exactly the
|
|
428
|
+
// case that needs a price pinned (the CLI would price it by NAME), so a
|
|
429
|
+
// manifest may carry `cost` — same shape, same validator as a global
|
|
430
|
+
// catalog entry (model-env.mjs). A user's global entry for the same id
|
|
431
|
+
// still wins, as it does for label/efforts/env (§9.3).
|
|
432
|
+
let cost;
|
|
433
|
+
try {
|
|
434
|
+
cost = assertModelCost(m.cost);
|
|
435
|
+
} catch (e) {
|
|
436
|
+
errors.push(`${at} ("${id}"): ${e.message}`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
306
439
|
models.push({
|
|
307
440
|
id, label: str(m.label) || id,
|
|
308
441
|
efforts: efforts.length ? efforts : [...EFFORTS],
|
|
309
442
|
...(Object.keys(env).length ? { env } : {}),
|
|
443
|
+
...(cost ? { cost } : {}),
|
|
310
444
|
});
|
|
311
445
|
});
|
|
312
446
|
const mids = models.map((m) => m.id.toLowerCase());
|
|
@@ -392,8 +526,26 @@ export function validatePluginDir(absDir, { strict = false } = {}) {
|
|
|
392
526
|
}
|
|
393
527
|
}
|
|
394
528
|
|
|
395
|
-
// agents/: <key>.md + <key>.meta.json pairs, existing dual-file format (§4.2)
|
|
529
|
+
// agents/: <key>.md + <key>.meta.json pairs, existing dual-file format (§4.2).
|
|
530
|
+
// API 3: the sidecar must pass the SAME meta v2 gate the agent-store save path
|
|
531
|
+
// applies, every failed rule named. ALL capability fields are open to plugins
|
|
532
|
+
// (verdict, sideEffect, mockRole, workspace*, placeable, …) — the gate is the
|
|
533
|
+
// schema, not an allow-list.
|
|
396
534
|
const agentKeys = new Set();
|
|
535
|
+
// Keys whose sidecar EXISTS but was rejected by a gate below. They are not
|
|
536
|
+
// shipped (no ports), yet they are not foreign either — the workflows block
|
|
537
|
+
// owes a template that references one a single accurate cause rather than
|
|
538
|
+
// "this plugin does not ship it" or the derived V4/V5/V20/V21 cascade.
|
|
539
|
+
const ungatedKeys = new Set();
|
|
540
|
+
const ownMetas = [];
|
|
541
|
+
// A range that admits the CURRENT API (or no engines at all) claims to be an
|
|
542
|
+
// API-3 plugin, so v1-shaped data is a hard error; --strict promotes it for
|
|
543
|
+
// everyone else, because that flag is the plugin AUTHOR's gate. A manifest
|
|
544
|
+
// that did not PARSE fails SOFT: its declared API is unknowable, and the JSON
|
|
545
|
+
// error above is already the only actionable line.
|
|
546
|
+
const hardData = raw !== null
|
|
547
|
+
&& (strict || negotiatedApi(raw && raw.engines ? raw.engines['worca-cc-api'] : '') === WORCA_PLUGIN_API);
|
|
548
|
+
const dataLevel = hardData ? 'error' : 'warn';
|
|
397
549
|
const agentsDir = join(absDir, 'agents');
|
|
398
550
|
if (existsSync(agentsDir)) {
|
|
399
551
|
const files = readdirSync(agentsDir);
|
|
@@ -406,7 +558,19 @@ export function validatePluginDir(absDir, { strict = false } = {}) {
|
|
|
406
558
|
if (!KEY_RE.test(key)) { push('error', `agents/${f}: "${key}" must be a valid agent key (letters/digits/_-)`); continue; }
|
|
407
559
|
if (key !== stem) push('error', `agents/${f}: key "${key}" must match the filename stem "${stem}"`);
|
|
408
560
|
if (!files.includes(`${stem}.md`)) push('error', `agents/${f}: missing sibling ${stem}.md`);
|
|
561
|
+
// Path traversal is never softened by dataLevel: an escaping agentFile is
|
|
562
|
+
// a security defect in a v1 sidecar exactly as in a v2 one, and it is
|
|
563
|
+
// checked BEFORE the meta gate so exactly one cause is reported.
|
|
564
|
+
const afErr = badAgentFile(meta.agentFile, agentsDir);
|
|
565
|
+
if (afErr) { push('error', `agents/${f}: "agentFile" ${afErr}`); ungatedKeys.add(key); continue; }
|
|
566
|
+
if (Number(meta.metaVersion) !== 2) { push(dataLevel, `agents/${f}: ${NOT_META_V2}`); ungatedKeys.add(key); continue; }
|
|
567
|
+
const { errors } = validateMetaV2(meta);
|
|
568
|
+
for (const e of errors) push('error', `agents/${f}: ${e}`);
|
|
569
|
+
if (errors.length) { ungatedKeys.add(key); continue; }
|
|
570
|
+
// BELOW every gate: a rejected sidecar ships no ports, so counting its key
|
|
571
|
+
// as shipped is what let a ports-less node reach validateGraph.
|
|
409
572
|
agentKeys.add(key);
|
|
573
|
+
ownMetas.push(normalizeAgentMeta(meta).meta);
|
|
410
574
|
}
|
|
411
575
|
for (const f of files.filter((x) => x.endsWith('.md'))) {
|
|
412
576
|
const stem = f.slice(0, -3);
|
|
@@ -426,18 +590,42 @@ export function validatePluginDir(absDir, { strict = false } = {}) {
|
|
|
426
590
|
}
|
|
427
591
|
}
|
|
428
592
|
|
|
429
|
-
// workflows/*.json
|
|
593
|
+
// workflows/*.json are v2 GRAPHS (API 3), validated by the SAME shared
|
|
594
|
+
// validator the composer and POST /api/workflows use, over a ports function
|
|
595
|
+
// built from this plugin's OWN sidecars plus the engine's flow-card ports.
|
|
596
|
+
// The isolation rule stays: a template may reference only keys this plugin
|
|
597
|
+
// ships, so a host rename or a deleted user agent can never break it.
|
|
430
598
|
const wfDir = join(absDir, 'workflows');
|
|
431
599
|
if (existsSync(wfDir)) {
|
|
600
|
+
const portsFn = portsFnFor(indexByKey(ownMetas));
|
|
432
601
|
for (const f of readdirSync(wfDir).filter((x) => x.endsWith('.json'))) {
|
|
433
602
|
let tpl = null;
|
|
434
603
|
try { tpl = JSON.parse(readFileSync(join(wfDir, f), 'utf8')); }
|
|
435
604
|
catch { push('error', `workflows/${f}: invalid JSON`); continue; }
|
|
436
|
-
if (
|
|
437
|
-
const
|
|
605
|
+
if (Number(tpl?.version) !== 2) { push(dataLevel, `workflows/${f}: ${NOT_GRAPH_V2}`); continue; }
|
|
606
|
+
const nodes = Array.isArray(tpl.nodes) ? tpl.nodes : [];
|
|
607
|
+
const keys = nodes.filter((n) => n && n.kind === 'agent').map((n) => n.key).filter(Boolean);
|
|
608
|
+
let unresolved = false;
|
|
438
609
|
for (const k of new Set(keys)) {
|
|
439
|
-
if (
|
|
610
|
+
if (agentKeys.has(k)) continue;
|
|
611
|
+
if (ungatedKeys.has(k)) {
|
|
612
|
+
// The sidecar is this plugin's, it just did not pass. Report at the
|
|
613
|
+
// DATA level so an API-1 plugin keeps installing (spec §9) instead of
|
|
614
|
+
// being refused for a template that is fine.
|
|
615
|
+
push(dataLevel, `workflows/${f}: references agent key "${k}" whose sidecar is not a valid meta v2 sidecar`);
|
|
616
|
+
} else {
|
|
617
|
+
push('error', `workflows/${f}: references agent key "${k}" which this plugin does not ship`);
|
|
618
|
+
}
|
|
619
|
+
unresolved = true;
|
|
440
620
|
}
|
|
621
|
+
// An unresolved key has no ports, so every wire touching it would also
|
|
622
|
+
// fire V4/V5 — one clear cause beats five derived ones.
|
|
623
|
+
if (unresolved) continue;
|
|
624
|
+
const { errors, warnings } = validateGraph(
|
|
625
|
+
{ ...tpl, nodes, wires: Array.isArray(tpl.wires) ? tpl.wires : [] }, portsFn,
|
|
626
|
+
);
|
|
627
|
+
for (const e of errors) push('error', `workflows/${f}: ${e.code}: ${e.message}`);
|
|
628
|
+
for (const w of warnings) push('warn', `workflows/${f}: ${w.code}: ${w.message}`);
|
|
441
629
|
}
|
|
442
630
|
}
|
|
443
631
|
|
|
@@ -49,6 +49,7 @@ export function allPluginModels() {
|
|
|
49
49
|
out.push({
|
|
50
50
|
plugin: name, id: m.id, label: m.label, efforts: [...m.efforts],
|
|
51
51
|
...(m.env ? { env: m.env } : {}),
|
|
52
|
+
...(m.cost ? { cost: m.cost } : {}), // manifest-pinned pricing (config.mjs modelCostConfig)
|
|
52
53
|
secrets: [...new Set(secrets)],
|
|
53
54
|
});
|
|
54
55
|
}
|