@worca/app 1.3.0-rc.2 → 1.4.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 +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// and agent-gen's read-back check. Pure — shared code cannot import
|
|
5
5
|
// claude-runner.mjs, so the mock-role vocabulary is INJECTED.
|
|
6
6
|
import { PORT_TYPES, MAX_PORTS_PER_SIDE, PORT_ID_RE } from './constants.mjs';
|
|
7
|
+
import { normalizeAskBlock } from '../forms/form-def.mjs';
|
|
7
8
|
|
|
8
9
|
const AGENT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
9
10
|
// PORT_ID_RE is P1's, NOT a local copy: spec §3 puts it in constants.mjs
|
|
@@ -64,6 +65,21 @@ export function validateMetaV2(raw, opts = {}) {
|
|
|
64
65
|
return { errors: normalizeAgentMeta(raw, { ...opts, warn: () => {} }).errors };
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/** money-saved design §5: `{ factor?: >=0, hours?: >=0 }`; anything else is dropped with a warning. */
|
|
69
|
+
export function readHumanEffort(raw, warn = () => {}) {
|
|
70
|
+
if (raw === undefined) return null;
|
|
71
|
+
const ok = (v) => v === undefined || (typeof v === 'number' && Number.isFinite(v) && v >= 0);
|
|
72
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !ok(raw.factor) || !ok(raw.hours)
|
|
73
|
+
|| (raw.factor === undefined && raw.hours === undefined)) {
|
|
74
|
+
warn(`humanEffort must be { factor?: number >= 0, hours?: number >= 0 }; ignored: ${JSON.stringify(raw)}`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const out = {};
|
|
78
|
+
if (raw.factor !== undefined) out.factor = raw.factor;
|
|
79
|
+
if (raw.hours !== undefined) out.hours = raw.hours;
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
67
83
|
/**
|
|
68
84
|
* @param {object} raw parsed sidecar
|
|
69
85
|
* @param {{mockWriterRoles?:Set<string>, warn?:(msg:string)=>void}} [opts]
|
|
@@ -137,6 +153,19 @@ export function normalizeAgentMeta(raw, opts = {}) {
|
|
|
137
153
|
else warn(`[agent-registry] ${key || '<unkeyed>'}.mockRole: unknown mock role "${role}"; ignored (the generic mock chain applies)`);
|
|
138
154
|
}
|
|
139
155
|
|
|
156
|
+
// Sidecar ask forms (spec §3). Gate 1 lives in the shared forms core; a form
|
|
157
|
+
// that fails it is DROPPED, never fatal — the agent stays usable with generic
|
|
158
|
+
// questions (§5). The drop needs its OWN channel to the registry's diagnostics
|
|
159
|
+
// sink, because `warn`'s last line is how scanMetaLayer names the reason a
|
|
160
|
+
// WHOLE sidecar was skipped, and a dropped form skips nothing.
|
|
161
|
+
const askBlock = raw.ask === undefined ? null : normalizeAskBlock(raw.ask);
|
|
162
|
+
const onDropForm = typeof opts.onDropForm === 'function' ? opts.onDropForm : null;
|
|
163
|
+
for (const d of askBlock?.dropped || []) {
|
|
164
|
+
const message = `BAD_ASK_FORM: ${key || '<unkeyed>'}/${d.id}: ${d.reason}`;
|
|
165
|
+
if (onDropForm) onDropForm({ formId: d.id, reason: d.reason, message });
|
|
166
|
+
warn(`[agent-registry] ${message}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
140
169
|
const asksQuestions = !!raw.asksQuestions;
|
|
141
170
|
const meta = {
|
|
142
171
|
metaVersion: 2,
|
|
@@ -167,7 +196,13 @@ export function normalizeAgentMeta(raw, opts = {}) {
|
|
|
167
196
|
// an absent field means "the default", so a v2 entry stays diffable against
|
|
168
197
|
// the sidecar that produced it.
|
|
169
198
|
if (verdict) meta.verdict = verdict;
|
|
199
|
+
// Absent when the agent declares no SURVIVING form, so a sidecar without forms
|
|
200
|
+
// round-trips byte-identically through the store (the fixed key set is what
|
|
201
|
+
// makes {...existing, ...raw} safe).
|
|
202
|
+
if (askBlock && Object.keys(askBlock.forms).length) meta.ask = { forms: askBlock.forms };
|
|
170
203
|
if (SIDE_EFFECTS.has(raw.sideEffect)) meta.sideEffect = raw.sideEffect;
|
|
204
|
+
const humanEffort = readHumanEffort(raw.humanEffort, (m) => warn(`[agent-meta] "${key || '<unkeyed>'}": ${m}`));
|
|
205
|
+
if (humanEffort) meta.humanEffort = humanEffort;
|
|
171
206
|
if (mockRole) meta.mockRole = mockRole;
|
|
172
207
|
if (raw.wantsRequest) meta.wantsRequest = true;
|
|
173
208
|
if (raw.workspaceFanOut) meta.workspaceFanOut = true;
|
|
@@ -177,7 +212,7 @@ export function normalizeAgentMeta(raw, opts = {}) {
|
|
|
177
212
|
return { errors, meta };
|
|
178
213
|
}
|
|
179
214
|
|
|
180
|
-
function readVerdict(raw, err) {
|
|
215
|
+
export function readVerdict(raw, err) {
|
|
181
216
|
if (raw === undefined) return null;
|
|
182
217
|
const filename = raw && typeof raw === 'object' && typeof raw.filename === 'string' ? raw.filename.trim() : '';
|
|
183
218
|
if (!filename) { err('verdict must be an object with a filename'); return null; }
|
|
@@ -208,7 +243,11 @@ function readPortHead(raw, side, seen, err) {
|
|
|
208
243
|
return port;
|
|
209
244
|
}
|
|
210
245
|
|
|
211
|
-
|
|
246
|
+
/**
|
|
247
|
+
* @param {{noPromptFields?:boolean}} [opts] scripts: `as`, `directive` and `expands`
|
|
248
|
+
* are prompt-side and REFUSED; no default `as` is stamped either.
|
|
249
|
+
*/
|
|
250
|
+
export function readInputs(raw, err, warn, opts = {}) {
|
|
212
251
|
if (!Array.isArray(raw)) { err('inputs must be an array'); return []; }
|
|
213
252
|
if (raw.length > MAX_PORTS_PER_SIDE) err(`inputs: at most ${MAX_PORTS_PER_SIDE} ports per side (got ${raw.length})`);
|
|
214
253
|
const seen = new Set();
|
|
@@ -227,6 +266,13 @@ function readInputs(raw, err, warn) {
|
|
|
227
266
|
}
|
|
228
267
|
port.required = required;
|
|
229
268
|
if (loop) port.loop = true;
|
|
269
|
+
if (opts.noPromptFields) {
|
|
270
|
+
for (const field of ['as', 'directive', 'expands']) {
|
|
271
|
+
if (p[field] !== undefined) err(`inputs.${port.id}: ${field} is a prompt-side field — a script input does not take it`);
|
|
272
|
+
}
|
|
273
|
+
out.push(port);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
230
276
|
if (p.expands) {
|
|
231
277
|
if (port.type !== 'json') err(`inputs.${port.id}: expands is only legal on json inputs`);
|
|
232
278
|
else port.expands = true;
|
|
@@ -244,9 +290,13 @@ function readInputs(raw, err, warn) {
|
|
|
244
290
|
return out;
|
|
245
291
|
}
|
|
246
292
|
|
|
247
|
-
|
|
293
|
+
/**
|
|
294
|
+
* @param {{allowEmptyOutputs?:boolean, who?:string}} [opts] scripts may declare zero
|
|
295
|
+
* outputs (a pure side effect); `who` names the declarer in the `when` error.
|
|
296
|
+
*/
|
|
297
|
+
export function readOutputs(raw, hasVerdict, err, opts = {}) {
|
|
248
298
|
if (!Array.isArray(raw)) { err('outputs must be an array'); return []; }
|
|
249
|
-
if (raw.length === 0) err('at least one output port is required');
|
|
299
|
+
if (raw.length === 0 && !opts.allowEmptyOutputs) err('at least one output port is required');
|
|
250
300
|
if (raw.length > MAX_PORTS_PER_SIDE) err(`outputs: at most ${MAX_PORTS_PER_SIDE} ports per side (got ${raw.length})`);
|
|
251
301
|
const seen = new Set();
|
|
252
302
|
const out = [];
|
|
@@ -257,7 +307,7 @@ function readOutputs(raw, hasVerdict, err) {
|
|
|
257
307
|
if (!OUTPUT_WHEN.has(when)) err(`outputs.${port.id}: when must be one of ${[...OUTPUT_WHEN].join(', ')}`);
|
|
258
308
|
else {
|
|
259
309
|
if (when !== 'always' && !hasVerdict) {
|
|
260
|
-
err(`outputs.${port.id}: when "${when}" requires the agent to declare verdict: { filename }`);
|
|
310
|
+
err(`outputs.${port.id}: when "${when}" requires the ${opts.who || 'agent'} to declare verdict: { filename }`);
|
|
261
311
|
}
|
|
262
312
|
port.when = when;
|
|
263
313
|
}
|
|
@@ -8,12 +8,18 @@
|
|
|
8
8
|
export const TEMPLATE_VERSION = 2;
|
|
9
9
|
|
|
10
10
|
/** Every node kind a template may carry (palette order). */
|
|
11
|
-
export const KINDS = Object.freeze(['agent', 'task', 'end', 'and', 'or', 'combine']);
|
|
11
|
+
export const KINDS = Object.freeze(['agent', 'script', 'task', 'end', 'and', 'or', 'combine']);
|
|
12
12
|
|
|
13
|
-
/** The flow cards: every kind that is not a spawned
|
|
13
|
+
/** The flow cards: every kind that is not a spawned card. They are pure engine
|
|
14
14
|
* executions — instant, $0, no spawn. */
|
|
15
15
|
export const FLOW_KINDS = Object.freeze(['task', 'end', 'and', 'or', 'combine']);
|
|
16
16
|
|
|
17
|
+
/** The kinds that carry a registry `key` — a placed sidecar, an agent's or a
|
|
18
|
+
* script's. Every consumer that means "a keyed, ported card" tests THIS;
|
|
19
|
+
* consumers that mean "spawns Claude" (tunables, the Ports prompt block) keep
|
|
20
|
+
* `kind === 'agent'`. A script is not a flow kind: it spawns and it is not free. */
|
|
21
|
+
export const KEYED_KINDS = Object.freeze(['agent', 'script']);
|
|
22
|
+
|
|
17
23
|
/** The flow cards' DISPLAY names — one label per FLOW_KIND. The single copy:
|
|
18
24
|
* the run manifest (`manifest.mjs` cell labels), the run monitor and the
|
|
19
25
|
* New-pipeline loop caption all read this table, so a flow card can never be
|
|
@@ -38,6 +44,12 @@ export const DEFAULT_MAX_CYCLES = 3;
|
|
|
38
44
|
* sequencing: no file, no renderer, no directive, no mode effect. */
|
|
39
45
|
export const AWAIT_PORT = Object.freeze({ id: 'await', type: 'any', required: false, synthetic: true });
|
|
40
46
|
|
|
47
|
+
/** The engine-owned `params` input a placed SCRIPT card can opt into (`config.paramsPort`): a json
|
|
48
|
+
* wire whose object overlays the card's params before the program runs. Deliberately NOT
|
|
49
|
+
* `synthetic` — that flag means "the await gate" to geometry, the manifest and the view — so it
|
|
50
|
+
* draws, wires and schedules as an ordinary input; `engine` only marks it as not the script's own. */
|
|
51
|
+
export const PARAMS_PORT = Object.freeze({ id: 'params', type: 'json', required: false, engine: 'params' });
|
|
52
|
+
|
|
41
53
|
/** Task card — the graph's single source: zero inputs, one always-firing md
|
|
42
54
|
* output carrying the rendered task md. */
|
|
43
55
|
export const TASK_PORTS = Object.freeze({
|
|
@@ -9,6 +9,7 @@ import { NODE_W, nodeSize, portAnchor } from './geometry.mjs';
|
|
|
9
9
|
import { rankNodes } from './layout.mjs';
|
|
10
10
|
import { classifyLoops } from './loops.mjs';
|
|
11
11
|
import { portsOf } from './ports.mjs';
|
|
12
|
+
import { KEYED_KINDS } from './constants.mjs';
|
|
12
13
|
|
|
13
14
|
export const FLOW_SCALE = 0.65;
|
|
14
15
|
export const FLOW_PAD = 20; // host padding on X, NOT scaled
|
|
@@ -42,7 +43,7 @@ export function flowOrder(tpl, portsFn, { agentOrder = null } = {}) {
|
|
|
42
43
|
nonLoopIn.set(w.to.node, (nonLoopIn.get(w.to.node) || 0) + 1);
|
|
43
44
|
}
|
|
44
45
|
const given = new Map((Array.isArray(agentOrder) ? agentOrder : []).map((id, i) => [id, i]));
|
|
45
|
-
const cls = (n) => (n.kind === 'task' ? 0 : n.kind === 'end' ? 3 : (n.kind
|
|
46
|
+
const cls = (n) => (n.kind === 'task' ? 0 : n.kind === 'end' ? 3 : (!KEYED_KINDS.includes(n.kind) && !nonLoopIn.get(n.id) ? 2 : 1));
|
|
46
47
|
const key = (n) => [cls(n), rank[n.id] ?? 0, given.has(n.id) ? given.get(n.id) : Number.MAX_SAFE_INTEGER, n.id];
|
|
47
48
|
return nodes.map((n) => ({ n, k: key(n) })).sort((a, b) => {
|
|
48
49
|
for (let i = 0; i < 4; i += 1) if (a.k[i] !== b.k[i]) return a.k[i] < b.k[i] ? -1 : 1;
|
|
@@ -4,17 +4,19 @@
|
|
|
4
4
|
// when agents, wiring, gates, loops and their budgets are identical. Tunables
|
|
5
5
|
// (model/effort/fanOut/askQuestions/subagentModel), positions, ids and names
|
|
6
6
|
// are deliberately invisible — they are per-run tuning, not topology.
|
|
7
|
-
// Pure
|
|
7
|
+
// Pure and browser-safe: its one import is the shared constants table.
|
|
8
|
+
import { KEYED_KINDS } from './constants.mjs';
|
|
8
9
|
|
|
9
10
|
/** The config keys that ARE topology, per kind (mirrors validate.mjs KNOWN_CONFIG minus the tunables). */
|
|
10
11
|
const TOPOLOGY_CONFIG = Object.freeze({
|
|
11
|
-
task: ['planStoreSeed'], agent: ['awaitAll'],
|
|
12
|
+
task: ['planStoreSeed'], agent: ['awaitAll'], script: ['awaitAll', 'ports', 'params'],
|
|
13
|
+
and: ['arity'], or: ['arity'], combine: ['arity'], end: [],
|
|
12
14
|
});
|
|
13
15
|
|
|
14
16
|
export function nodeLabel(node) {
|
|
15
17
|
const kind = String(node?.kind ?? '');
|
|
16
18
|
const cfg = node && typeof node.config === 'object' && node.config ? node.config : {};
|
|
17
|
-
const parts = [kind, kind
|
|
19
|
+
const parts = [kind, KEYED_KINDS.includes(kind) ? String(node.key ?? '') : ''];
|
|
18
20
|
for (const k of TOPOLOGY_CONFIG[kind] || []) {
|
|
19
21
|
const v = cfg[k];
|
|
20
22
|
// A gate's arity defaults to 2; an absent flag equals an explicit false.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// agent cell, and the sub_agents.ui_phase attribution column still needs it. The
|
|
15
15
|
// workflows.mjs copy is gone (the v1 topology helpers went with the v1 engine),
|
|
16
16
|
// so THIS is the only copy — shared code may not import workflows.mjs.
|
|
17
|
-
import { TEMPLATE_VERSION, AWAIT_PORT, DEFAULT_MAX_CYCLES, FLOW_LABEL } from './constants.mjs';
|
|
17
|
+
import { TEMPLATE_VERSION, AWAIT_PORT, DEFAULT_MAX_CYCLES, FLOW_LABEL, KEYED_KINDS } from './constants.mjs';
|
|
18
18
|
import { portsFnFor, portsOf, resolveOrOutType } from './ports.mjs';
|
|
19
19
|
import { classifyLoops } from './loops.mjs';
|
|
20
20
|
import { rankNodes } from './layout.mjs';
|
|
@@ -89,13 +89,14 @@ const isWire = (w) => Boolean(w) && typeof w === 'object' && !Array.isArray(w)
|
|
|
89
89
|
/**
|
|
90
90
|
* @param {object} tpl resolved v2 template
|
|
91
91
|
* @param {Record<string,object>} agentsByKey merged registry metas
|
|
92
|
-
* @param {{overlays?:{nodes?:object, wires?:object}}} [opts] effective per-node config + per-wire budgets
|
|
92
|
+
* @param {{overlays?:{nodes?:object, wires?:object}, scripts?:Record<string,object>}} [opts] effective per-node config + per-wire budgets; the script registry slice
|
|
93
93
|
*/
|
|
94
94
|
export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
|
|
95
95
|
const overlays = opts.overlays || {};
|
|
96
|
+
const scripts = opts.scripts || {};
|
|
96
97
|
const nodeOverlays = overlays.nodes || {};
|
|
97
98
|
const wireOverlays = overlays.wires || {};
|
|
98
|
-
const portsFn = portsFnFor(agentsByKey);
|
|
99
|
+
const portsFn = portsFnFor(agentsByKey, scripts);
|
|
99
100
|
// Objects with real endpoints only. `filter(Boolean)` kept a truthy non-object
|
|
100
101
|
// node and an endpoint-less wire, and `w.from.node` threw one map later — the
|
|
101
102
|
// manifest is built from an ALREADY validated template, but it is also built
|
|
@@ -109,7 +110,9 @@ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
|
|
|
109
110
|
|
|
110
111
|
const manifestNodes = nodes.map((node) => {
|
|
111
112
|
const resolved = portsOf(portsFn, node);
|
|
112
|
-
const
|
|
113
|
+
const keyed = KEYED_KINDS.includes(node.kind);
|
|
114
|
+
const meta = node.kind === 'agent' ? (agentsByKey?.[node.key] || null)
|
|
115
|
+
: node.kind === 'script' ? (scripts?.[node.key] || null) : null;
|
|
113
116
|
const over = nodeOverlays[node.id] || {};
|
|
114
117
|
const cfg = node.config || {};
|
|
115
118
|
const outType = (port) => (node.kind === 'or' && (!port.type || port.type === 'any')
|
|
@@ -117,14 +120,12 @@ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
|
|
|
117
120
|
const cell = {
|
|
118
121
|
id: node.id,
|
|
119
122
|
kind: node.kind,
|
|
120
|
-
key:
|
|
123
|
+
key: keyed ? node.key : null,
|
|
121
124
|
x: Number(node.x) || 0,
|
|
122
125
|
y: Number(node.y) || 0,
|
|
123
|
-
label:
|
|
124
|
-
// The v1 stepper bucket
|
|
125
|
-
|
|
126
|
-
// vocabulary). The whole field goes with the phase shim in Task 16.
|
|
127
|
-
uiPhase: node.kind === 'agent' ? (UI_PHASE[node.key] || node.key) : node.kind,
|
|
126
|
+
label: keyed ? (meta?.displayName || node.key) : (FLOW_LABEL[node.kind] || node.kind),
|
|
127
|
+
// The v1 stepper bucket: the builtin map for agents, the bare key for every other keyed card.
|
|
128
|
+
uiPhase: node.kind === 'agent' ? (UI_PHASE[node.key] || node.key) : keyed ? node.key : node.kind,
|
|
128
129
|
// The AUTHORED config, verbatim and complete (unknown keys included —
|
|
129
130
|
// V17's "preserved and ignored" promise). The manifest is the ONLY
|
|
130
131
|
// persisted copy of the topology: P4 rebuilds the template from it on a
|
|
@@ -133,7 +134,8 @@ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
|
|
|
133
134
|
config: { ...(node.config || {}) },
|
|
134
135
|
ports: {
|
|
135
136
|
inputs: resolved.inputs.filter((p) => !p.synthetic).map((p) => ({
|
|
136
|
-
id: p.id, type: p.type, required: p.required !== false, loop: !!p.loop, expands: !!p.expands
|
|
137
|
+
id: p.id, type: p.type, required: p.required !== false, loop: !!p.loop, expands: !!p.expands,
|
|
138
|
+
...(p.engine ? { engine: p.engine } : {}) })),
|
|
137
139
|
outputs: resolved.outputs.map((p) => ({ id: p.id, type: outType(p), when: p.when || 'always' })),
|
|
138
140
|
await: resolved.inputs.some((p) => p.synthetic),
|
|
139
141
|
},
|
|
@@ -151,6 +153,12 @@ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
|
|
|
151
153
|
// alone — the workflow row is never re-read after the run starts.
|
|
152
154
|
cell.subagentModel = over.subagentModel ?? cfg.subagentModel ?? '';
|
|
153
155
|
}
|
|
156
|
+
if (node.kind === 'script') {
|
|
157
|
+
cell.color = meta?.color || '';
|
|
158
|
+
cell.icon = sanitizeIcon(meta?.icon);
|
|
159
|
+
cell.runtime = meta?.runtime || '';
|
|
160
|
+
cell.awaitAll = !!(over.awaitAll ?? cfg.awaitAll ?? false);
|
|
161
|
+
}
|
|
154
162
|
if (node.kind === 'and' || node.kind === 'or' || node.kind === 'combine') {
|
|
155
163
|
cell.arity = Number.isInteger(cfg.arity) ? cfg.arity : 2;
|
|
156
164
|
}
|
|
@@ -183,7 +191,7 @@ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
|
|
|
183
191
|
uiPhase: n.uiPhase,
|
|
184
192
|
label: n.label,
|
|
185
193
|
color: n.color || '',
|
|
186
|
-
sub: (n.key && agentsByKey?.[n.key]?.description) || '',
|
|
194
|
+
sub: (n.key && (agentsByKey?.[n.key] || scripts?.[n.key])?.description) || '',
|
|
187
195
|
cycles: n.ports.inputs.some((p) => p.loop && isWired.has(`${n.id}.${p.id}`)),
|
|
188
196
|
model: n.model || '',
|
|
189
197
|
effort: n.effort || '',
|
|
@@ -226,6 +234,7 @@ export function manifestPortsFn(manifest) {
|
|
|
226
234
|
displayName: cell.label,
|
|
227
235
|
color: cell.color,
|
|
228
236
|
icon: cell.icon,
|
|
237
|
+
runtime: cell.runtime,
|
|
229
238
|
// A verdict-bearing node is one with a conditional output — enough for V13
|
|
230
239
|
// and firedOutputs; the filename itself never leaves the engine.
|
|
231
240
|
verdict: cell.ports.outputs.some((p) => p.when && p.when !== 'always') ? { filename: '' } : undefined,
|
|
@@ -245,7 +254,7 @@ export function manifestTemplate(manifest) {
|
|
|
245
254
|
// old `{arity, awaitAll}` reconstruction silently dropped everything else
|
|
246
255
|
// (`planStoreSeed` on the Task card of `wf_provided-plan`, for one).
|
|
247
256
|
const node = { id: n.id, kind: n.kind, x: n.x, y: n.y, config: { ...(n.config || {}) } };
|
|
248
|
-
if (n.kind
|
|
257
|
+
if (KEYED_KINDS.includes(n.kind)) node.key = n.key;
|
|
249
258
|
return node;
|
|
250
259
|
}),
|
|
251
260
|
wires: (manifest?.graph?.wires || []).map((w) => {
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// malformed template — an unknown agent key (V4), a dangling endpoint (V5) or
|
|
6
6
|
// an unknown kind (V3) is an error for the validator to COLLECT, so every
|
|
7
7
|
// lookup guards instead of crashing.
|
|
8
|
-
import { AWAIT_PORT, FLOW_KINDS, gatePorts, TASK_PORTS, END_PORTS } from './constants.mjs';
|
|
8
|
+
import { AWAIT_PORT, PARAMS_PORT, FLOW_KINDS, gatePorts, TASK_PORTS, END_PORTS } from './constants.mjs';
|
|
9
9
|
import { hasBlocking } from './verdict.mjs';
|
|
10
|
+
import { readConfigPorts, hasParamsPort } from './script-meta.mjs';
|
|
10
11
|
|
|
11
12
|
/** Engine flow-card ports. `undefined` for an unknown kind — V3's error. */
|
|
12
13
|
export function flowPorts(node) {
|
|
@@ -22,31 +23,56 @@ export function flowPorts(node) {
|
|
|
22
23
|
return undefined;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
const toIndex = (v) => (v instanceof Map ? v : new Map(Object.entries(v && typeof v === 'object' ? v : {})));
|
|
27
|
+
|
|
25
28
|
/**
|
|
26
|
-
* Build the ports function over the merged
|
|
27
|
-
* keyed by
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* unknown key
|
|
31
|
-
* key without v2 ports
|
|
32
|
-
* ported
|
|
29
|
+
* Build the ports function over the merged AGENT registry and the merged SCRIPT
|
|
30
|
+
* registry (objects or Maps keyed by key — the two namespaces never overlap, D16).
|
|
31
|
+
* Keyed nodes get their sidecar's typed ports PLUS the engine-synthesized `await`
|
|
32
|
+
* gate appended LAST; flow cards get flowPorts. Outcomes V4 tells apart:
|
|
33
|
+
* unknown key -> undefined (known:false)
|
|
34
|
+
* agent key without v2 ports -> {known:true, ported:false}
|
|
35
|
+
* config-ported script, no config -> {known:true, ported:false, configPortsMissing:true}
|
|
36
|
+
* config-ported script, bad config -> {known:true, ported:false, configPortsInvalid:true, configPortsErrors}
|
|
37
|
+
* ported -> {known:true, ported:true, inputs:[...ports, params?, await], outputs}
|
|
33
38
|
*/
|
|
34
|
-
export function portsFnFor(agentsByKey) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
: new Map(Object.entries(agentsByKey && typeof agentsByKey === 'object' ? agentsByKey : {}));
|
|
39
|
+
export function portsFnFor(agentsByKey, scriptsByKey = {}) {
|
|
40
|
+
const agents = toIndex(agentsByKey);
|
|
41
|
+
const scripts = toIndex(scriptsByKey);
|
|
38
42
|
return (node) => {
|
|
39
43
|
if (!node || typeof node !== 'object') return undefined;
|
|
40
|
-
if (node.kind
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (!Array.isArray(meta.inputs) || !Array.isArray(meta.outputs)) {
|
|
44
|
-
return { ...meta, known: true, ported: false, inputs: [], outputs: [] };
|
|
45
|
-
}
|
|
46
|
-
return { ...meta, known: true, ported: true, inputs: [...meta.inputs, AWAIT_PORT], outputs: [...meta.outputs] };
|
|
44
|
+
if (node.kind === 'agent') return sidecarPorts(agents.get(node.key));
|
|
45
|
+
if (node.kind === 'script') return scriptPorts(scripts.get(node.key), node);
|
|
46
|
+
return flowPorts(node);
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function sidecarPorts(meta) {
|
|
51
|
+
if (!meta) return undefined;
|
|
52
|
+
if (!Array.isArray(meta.inputs) || !Array.isArray(meta.outputs)) {
|
|
53
|
+
return { ...meta, known: true, ported: false, inputs: [], outputs: [] };
|
|
54
|
+
}
|
|
55
|
+
return { ...meta, known: true, ported: true, inputs: [...meta.inputs, AWAIT_PORT], outputs: [...meta.outputs] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A script card's engine inputs, after its own: the opt-in `params` port, then `await` LAST. */
|
|
59
|
+
const engineInputs = (meta, node) => (hasParamsPort(meta, node?.config) ? [PARAMS_PORT, AWAIT_PORT] : [AWAIT_PORT]);
|
|
60
|
+
|
|
61
|
+
/** Script ports: the sidecar's, or — for `ports: "config"` (D14) — the placed
|
|
62
|
+
* node's `config.ports` through the shared readers. */
|
|
63
|
+
function scriptPorts(meta, node) {
|
|
64
|
+
if (!meta) return undefined;
|
|
65
|
+
if (meta.ports !== 'config') {
|
|
66
|
+
const p = sidecarPorts(meta);
|
|
67
|
+
return p.ported ? { ...p, inputs: [...meta.inputs, ...engineInputs(meta, node)] } : p;
|
|
68
|
+
}
|
|
69
|
+
const cfg = node?.config?.ports;
|
|
70
|
+
if (cfg === undefined) return { ...meta, known: true, ported: false, configPortsMissing: true, inputs: [], outputs: [] };
|
|
71
|
+
const { ports, errors } = readConfigPorts(cfg, { hasVerdict: !!meta.verdict });
|
|
72
|
+
if (!ports) return { ...meta, known: true, ported: false, configPortsInvalid: true, configPortsErrors: errors, inputs: [], outputs: [] };
|
|
73
|
+
return { ...meta, known: true, ported: true, inputs: [...ports.inputs, ...engineInputs(meta, node)], outputs: [...ports.outputs] };
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
/**
|
|
51
77
|
* Resolve one node's ports defensively. NEVER throws — a portsFn that throws,
|
|
52
78
|
* a missing key and an unknown kind all collapse to `known:false`, and a
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// src/shared/graph/script-cases.mjs
|
|
2
|
+
// Saved test cases for a script (workbench spec §3.2). ONE normalizer behind the
|
|
3
|
+
// store's hard 400, the plugin validator and the bench UI, so a case that saves
|
|
4
|
+
// in the app is a case a plugin can ship and `worca script test` can run.
|
|
5
|
+
// Cases live in `<key>.tests.json` beside the script — or, for a built-in or
|
|
6
|
+
// plugin key, in the user layer with no meta beside it (W18: a tests file
|
|
7
|
+
// without a meta is an OVERLAY, never a broken script).
|
|
8
|
+
// Pure: no fs, no node: builtins, no DOM — the Scripts page imports it straight
|
|
9
|
+
// from /src/shared (test/shared-graph-purity.test.mjs).
|
|
10
|
+
import { readConfigPorts, paramValueError, MIN_TIMEOUT_MS } from './script-meta.mjs';
|
|
11
|
+
|
|
12
|
+
export const CASES_VERSION = 1;
|
|
13
|
+
export const MAX_CASES = 32;
|
|
14
|
+
export const MAX_CASE_NAME = 80;
|
|
15
|
+
export const MAX_CASE_INPUT_BYTES = 262144; // W9: a case input is inline text
|
|
16
|
+
/** Case ids are key-shaped (dashes and underscores allowed), not port-shaped. */
|
|
17
|
+
export const CASE_ID_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
18
|
+
export const EXPECT_VERDICTS = Object.freeze(['clean', 'blocking', 'error']);
|
|
19
|
+
|
|
20
|
+
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
21
|
+
/** BYTES, not characters: the cap is a file-size cap (TextEncoder works in both hosts). */
|
|
22
|
+
const byteLength = (s) => new TextEncoder().encode(String(s)).length;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The port set a case runs against: the sidecar's own ports, or — for a
|
|
26
|
+
* `ports: "config"` script (D14) — the set the case carries, falling back to the
|
|
27
|
+
* sidecar's `defaultPorts`. The verdict is always the sidecar's.
|
|
28
|
+
* @param {object} meta a normalized script meta
|
|
29
|
+
* @param {{ports?: object}} [kase]
|
|
30
|
+
* @returns {{inputs: Array, outputs: Array, verdict: object|null}|{errors: string[]}}
|
|
31
|
+
*/
|
|
32
|
+
export function casePortSet(meta, kase) {
|
|
33
|
+
const verdict = meta && meta.verdict ? meta.verdict : null;
|
|
34
|
+
if (!meta || meta.ports !== 'config') {
|
|
35
|
+
return {
|
|
36
|
+
inputs: Array.isArray(meta && meta.inputs) ? meta.inputs : [],
|
|
37
|
+
outputs: Array.isArray(meta && meta.outputs) ? meta.outputs : [],
|
|
38
|
+
verdict,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const raw = kase && kase.ports !== undefined && kase.ports !== null ? kase.ports : meta.defaultPorts;
|
|
42
|
+
const { ports, errors } = readConfigPorts(raw, { hasVerdict: !!verdict });
|
|
43
|
+
if (!ports) return { errors };
|
|
44
|
+
return { inputs: ports.inputs, outputs: ports.outputs, verdict };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize a `<key>.tests.json` document against its script.
|
|
49
|
+
* @param {object|undefined} raw the parsed file, or undefined (no cases)
|
|
50
|
+
* @param {object} meta a normalized script meta
|
|
51
|
+
* @param {{shipped?: boolean, lenient?: boolean}} [opts] shipped: a built-in or plugin
|
|
52
|
+
* file — a project cwd cannot travel to another machine, so it is refused there.
|
|
53
|
+
* lenient: the READ path. A case outlives an edit to its script's sidecar: a param,
|
|
54
|
+
* an input port or an expected output the script NO LONGER declares — or no longer
|
|
55
|
+
* declares the same WAY (an enum option dropped, a port retyped) — is skipped
|
|
56
|
+
* instead of failing the case — a strict read would drop the whole case, and the
|
|
57
|
+
* next write (the page sends the full list it was given) would delete it for good.
|
|
58
|
+
* Every other rule still applies; the WRITE path is always strict.
|
|
59
|
+
* @returns {{cases: Array, errors: string[]}} a case that produced an error is
|
|
60
|
+
* NOT returned: the store refuses the whole write, the UI lists the reasons.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeCases(raw, meta, { shipped = false, lenient = false } = {}) {
|
|
63
|
+
const errors = [];
|
|
64
|
+
const cases = [];
|
|
65
|
+
if (raw === undefined || raw === null) return { cases, errors };
|
|
66
|
+
if (!isObject(raw)) {
|
|
67
|
+
errors.push('cases file must be an object { version: 1, cases: [...] }');
|
|
68
|
+
return { cases, errors };
|
|
69
|
+
}
|
|
70
|
+
if (raw.version !== CASES_VERSION) errors.push(`cases file requires version ${CASES_VERSION}`);
|
|
71
|
+
if (!Array.isArray(raw.cases)) {
|
|
72
|
+
errors.push('cases must be an array');
|
|
73
|
+
return { cases, errors };
|
|
74
|
+
}
|
|
75
|
+
if (raw.cases.length > MAX_CASES) errors.push(`at most ${MAX_CASES} cases (got ${raw.cases.length})`);
|
|
76
|
+
const key = typeof (meta && meta.key) === 'string' ? meta.key : '';
|
|
77
|
+
const declared = new Map((Array.isArray(meta && meta.params) ? meta.params : []).filter(Boolean).map((p) => [p.id, p]));
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < raw.cases.length && cases.length < MAX_CASES; i += 1) {
|
|
81
|
+
const kase = raw.cases[i];
|
|
82
|
+
if (!isObject(kase)) { errors.push(`cases[${i}]: each case must be an object`); continue; }
|
|
83
|
+
const id = typeof kase.id === 'string' ? kase.id.trim() : '';
|
|
84
|
+
if (!CASE_ID_RE.test(id)) { errors.push(`cases[${i}]: bad case id "${id}"`); continue; }
|
|
85
|
+
if (seen.has(id)) { errors.push(`cases[${i}]: duplicate case id "${id}"`); continue; }
|
|
86
|
+
seen.add(id);
|
|
87
|
+
const before = errors.length;
|
|
88
|
+
const bad = (msg) => errors.push(`case "${id}": ${msg}`);
|
|
89
|
+
|
|
90
|
+
let name = id;
|
|
91
|
+
if (kase.name !== undefined && kase.name !== null) {
|
|
92
|
+
if (typeof kase.name !== 'string') bad('name must be a string');
|
|
93
|
+
else if (kase.name.trim().length > MAX_CASE_NAME) bad(`name must be ${MAX_CASE_NAME} characters or fewer`);
|
|
94
|
+
else name = kase.name.trim() || id;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Ports: a config-ported script carries its own set per case (W5); a
|
|
98
|
+
// sidecar-ported one must not carry one at all.
|
|
99
|
+
let ownPorts = null;
|
|
100
|
+
if (meta && meta.ports === 'config') {
|
|
101
|
+
if (kase.ports !== undefined && kase.ports !== null && !isObject(kase.ports)) {
|
|
102
|
+
bad('ports must be an object { inputs: [...], outputs: [...] }');
|
|
103
|
+
} else if (isObject(kase.ports)) ownPorts = kase.ports;
|
|
104
|
+
} else if (kase.ports !== undefined && kase.ports !== null && !lenient) {
|
|
105
|
+
// lenient: the script dropped `ports: "config"` after the case was saved.
|
|
106
|
+
// The stale set is ignored and the sidecar's own ports stand — dropping the
|
|
107
|
+
// case here would blank the list, and the page's next write deletes it.
|
|
108
|
+
bad('ports is only legal for a script that declares ports: "config"');
|
|
109
|
+
}
|
|
110
|
+
let set = casePortSet(meta, { ports: ownPorts });
|
|
111
|
+
// Same rule for a set that no longer validates (a cleared verdict makes a
|
|
112
|
+
// conditional output illegal): fall back to the script's default port set.
|
|
113
|
+
if (set.errors && lenient && ownPorts) set = casePortSet(meta, {});
|
|
114
|
+
if (set.errors) for (const e of set.errors) bad(e);
|
|
115
|
+
const inPorts = new Map((set.inputs || [])
|
|
116
|
+
.filter((p) => p && !p.synthetic && p.id !== 'await').map((p) => [p.id, p]));
|
|
117
|
+
const outIds = new Set((set.outputs || []).map((p) => p && p.id).filter(Boolean));
|
|
118
|
+
|
|
119
|
+
const params = {};
|
|
120
|
+
if (kase.params !== undefined && kase.params !== null) {
|
|
121
|
+
if (!isObject(kase.params)) bad('params must be an object');
|
|
122
|
+
else {
|
|
123
|
+
for (const [pid, value] of Object.entries(kase.params)) {
|
|
124
|
+
const d = declared.get(pid);
|
|
125
|
+
if (!d) {
|
|
126
|
+
if (lenient) continue;
|
|
127
|
+
bad(`unknown param "${pid}" — script "${key}" declares ${declared.size ? [...declared.keys()].join(', ') : 'no params'}`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const why = paramValueError(d, value);
|
|
131
|
+
// lenient: the param is still declared but its SHAPE changed (an enum
|
|
132
|
+
// option dropped, a type swapped). Skipped like an undeclared one —
|
|
133
|
+
// dropping the case blanks the list just the same.
|
|
134
|
+
if (why) { if (!lenient) bad(`param "${pid}" ${why}`); }
|
|
135
|
+
else params[pid] = value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const inputs = {};
|
|
141
|
+
if (kase.inputs !== undefined && kase.inputs !== null) {
|
|
142
|
+
if (!isObject(kase.inputs)) bad('inputs must be an object keyed by input port id');
|
|
143
|
+
else {
|
|
144
|
+
for (const [pid, spec] of Object.entries(kase.inputs)) {
|
|
145
|
+
const port = inPorts.get(pid);
|
|
146
|
+
if (!port) { if (!lenient) bad(`"${pid}" is not an input port of script "${key}"`); continue; }
|
|
147
|
+
if (!isObject(spec)) { bad(`input "${pid}" must be { text } or { fired: true }`); continue; }
|
|
148
|
+
// lenient: the port was RETYPED under the stored spec — skipped like a
|
|
149
|
+
// removed one, because a dropped case is a deleted case one save later.
|
|
150
|
+
if (port.type === 'void') {
|
|
151
|
+
if (spec.fired !== true) { if (!lenient) bad(`input "${pid}" is a void port — use { fired: true }`); }
|
|
152
|
+
else inputs[pid] = { fired: true };
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (typeof spec.text !== 'string') { if (!lenient) bad(`input "${pid}" must be { text }`); continue; }
|
|
156
|
+
if (byteLength(spec.text) > MAX_CASE_INPUT_BYTES) { bad(`input "${pid}" is over ${MAX_CASE_INPUT_BYTES} bytes`); continue; }
|
|
157
|
+
if (port.type === 'json') {
|
|
158
|
+
try { JSON.parse(spec.text); } catch { if (!lenient) bad(`input "${pid}" is not valid JSON`); continue; }
|
|
159
|
+
}
|
|
160
|
+
inputs[pid] = { text: spec.text };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// W1: the scratch folder or a REGISTERED project, never an absolute path —
|
|
166
|
+
// a case travels between machines inside a plugin.
|
|
167
|
+
let cwd = { kind: 'scratch' };
|
|
168
|
+
if (kase.cwd !== undefined && kase.cwd !== null) {
|
|
169
|
+
if (!isObject(kase.cwd) || (kase.cwd.kind !== 'scratch' && kase.cwd.kind !== 'project')) {
|
|
170
|
+
bad('cwd must be { kind: "scratch" } or { kind: "project", projectKey }');
|
|
171
|
+
} else if (kase.cwd.kind === 'project') {
|
|
172
|
+
const pk = typeof kase.cwd.projectKey === 'string' ? kase.cwd.projectKey.trim() : '';
|
|
173
|
+
if (!pk) bad('cwd: a project folder needs a projectKey');
|
|
174
|
+
else if (shipped) bad('a shipped case must run in the scratch folder');
|
|
175
|
+
else cwd = { kind: 'project', projectKey: pk };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let timeoutMs = null;
|
|
180
|
+
if (kase.timeoutMs !== undefined && kase.timeoutMs !== null) {
|
|
181
|
+
if (!Number.isInteger(kase.timeoutMs) || kase.timeoutMs < MIN_TIMEOUT_MS) {
|
|
182
|
+
bad(`timeoutMs must be an integer >= ${MIN_TIMEOUT_MS}`);
|
|
183
|
+
} else timeoutMs = kase.timeoutMs;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let expect = null;
|
|
187
|
+
if (kase.expect !== undefined && kase.expect !== null) {
|
|
188
|
+
if (!isObject(kase.expect)) bad('expect must be an object { verdict?, fired?, summaryIncludes? }');
|
|
189
|
+
else {
|
|
190
|
+
const e = {};
|
|
191
|
+
if (kase.expect.verdict !== undefined) {
|
|
192
|
+
if (!EXPECT_VERDICTS.includes(kase.expect.verdict)) bad(`expect.verdict must be one of ${EXPECT_VERDICTS.join(', ')}`);
|
|
193
|
+
else e.verdict = kase.expect.verdict;
|
|
194
|
+
}
|
|
195
|
+
if (kase.expect.fired !== undefined) {
|
|
196
|
+
const list = Array.isArray(kase.expect.fired) && kase.expect.fired.every((x) => typeof x === 'string')
|
|
197
|
+
? kase.expect.fired : null;
|
|
198
|
+
if (!list) bad('expect.fired must be a list of output port ids');
|
|
199
|
+
else {
|
|
200
|
+
// lenient: an output the script no longer declares drops out of the expectation.
|
|
201
|
+
const kept = lenient ? list.filter((x) => outIds.has(x)) : list;
|
|
202
|
+
const unknown = kept.filter((x) => !outIds.has(x));
|
|
203
|
+
if (unknown.length) bad(`expect.fired names ${unknown.join(', ')}, which is not an output port`);
|
|
204
|
+
// An expectation filtered down to NOTHING is dropped, not kept as []: an empty
|
|
205
|
+
// list is a positive claim ("nothing may fire") the author never made.
|
|
206
|
+
else if (kept.length || kept.length === list.length) e.fired = [...kept];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (kase.expect.summaryIncludes !== undefined) {
|
|
210
|
+
if (typeof kase.expect.summaryIncludes !== 'string' || !kase.expect.summaryIncludes) {
|
|
211
|
+
bad('expect.summaryIncludes must be a non-empty string');
|
|
212
|
+
} else e.summaryIncludes = kase.expect.summaryIncludes;
|
|
213
|
+
}
|
|
214
|
+
if (Object.keys(e).length) expect = e;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (errors.length !== before) continue;
|
|
219
|
+
cases.push({
|
|
220
|
+
id,
|
|
221
|
+
name,
|
|
222
|
+
params,
|
|
223
|
+
ports: meta && meta.ports === 'config' ? { inputs: set.inputs, outputs: set.outputs } : null,
|
|
224
|
+
inputs,
|
|
225
|
+
cwd,
|
|
226
|
+
timeoutMs,
|
|
227
|
+
expect,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return { cases, errors };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Compare a bench result with a case's expectation (spec §3.2). Absent
|
|
235
|
+
* expectation -> null: the case reports its result and is never red.
|
|
236
|
+
* @param {object|null|undefined} expect
|
|
237
|
+
* @param {{status: string, fired: string[], summary: string}} result
|
|
238
|
+
* @returns {{pass: boolean, diffs: string[]}|null}
|
|
239
|
+
*/
|
|
240
|
+
export function evaluateExpect(expect, result) {
|
|
241
|
+
if (!isObject(expect)) return null;
|
|
242
|
+
const diffs = [];
|
|
243
|
+
const status = result && typeof result.status === 'string' ? result.status : 'nothing';
|
|
244
|
+
if (expect.verdict !== undefined && expect.verdict !== status) diffs.push(`expected ${expect.verdict}, got ${status}`);
|
|
245
|
+
if (Array.isArray(expect.fired)) {
|
|
246
|
+
const want = [...new Set(expect.fired)].sort();
|
|
247
|
+
const got = [...new Set(Array.isArray(result && result.fired) ? result.fired : [])].sort();
|
|
248
|
+
if (want.length !== got.length || want.some((x, n) => x !== got[n])) {
|
|
249
|
+
diffs.push(`expected fired ${want.join(', ') || '(none)'}, got ${got.join(', ') || '(none)'}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (typeof expect.summaryIncludes === 'string' && expect.summaryIncludes) {
|
|
253
|
+
const summary = String((result && result.summary) || '');
|
|
254
|
+
if (!summary.includes(expect.summaryIncludes)) diffs.push(`expected the summary to contain "${expect.summaryIncludes}"`);
|
|
255
|
+
}
|
|
256
|
+
return { pass: diffs.length === 0, diffs };
|
|
257
|
+
}
|