@worca/app 1.3.0 → 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
package/src/core/phases.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// prompt so the system prompt is never empty. Interface is locked by docs/ARCHITECTURE.md §3.5.
|
|
15
15
|
|
|
16
16
|
import { runClaude } from './claude-runner.mjs';
|
|
17
|
-
import { resolveModelEnv } from './config.mjs';
|
|
17
|
+
import { resolveModelEnv, bridgedModelInfo } from './config.mjs';
|
|
18
18
|
import { SUBAGENT_AUTO, SUBAGENT_INHERIT, SUBAGENT_MODELS, effectiveSubagentModel } from './model-env.mjs';
|
|
19
19
|
import { readClarify, readReview } from './protocol.mjs';
|
|
20
20
|
import { writeClarify, readClarifyRow } from './artifacts.mjs';
|
|
@@ -83,6 +83,22 @@ export function ctxFanOut(ctx) {
|
|
|
83
83
|
* ctx — from modelHasBaseUrlRouting(effective model), so this stays pure.
|
|
84
84
|
* A present node wins, mirroring ctxFanOut. Pure + exported for testing.
|
|
85
85
|
*/
|
|
86
|
+
/**
|
|
87
|
+
* The model env for a dispatched node: resolveModelEnv tagged with the
|
|
88
|
+
* execution id (the bridge books its calls per tag). A bridged model whose
|
|
89
|
+
* provider is not usable makes resolveModelEnv throw (fail fast, model-bridge
|
|
90
|
+
* design §8.5) — except under the offline mock, which spawns nothing and must
|
|
91
|
+
* keep running for a catalog that names a Copilot model nobody signed in to.
|
|
92
|
+
*/
|
|
93
|
+
function resolveDispatchModelEnv(c, ctx) {
|
|
94
|
+
try {
|
|
95
|
+
return resolveModelEnv(c.model, { tag: ctx.executionId });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (c.mock && err && err.bridgeReason) return undefined;
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
86
102
|
export function ctxEndpointRouted(ctx) {
|
|
87
103
|
if (!ctx || typeof ctx !== 'object') return false;
|
|
88
104
|
return !!(ctx.node ? ctx.node.endpointRouted : ctx.endpointRouted);
|
|
@@ -429,6 +445,55 @@ export const RESUME_HEADER =
|
|
|
429
445
|
'state of your previous work (files/artifacts you already wrote), then continue the\n' +
|
|
430
446
|
'ORIGINAL task below to completion. Do not redo work that is already done.\n\n';
|
|
431
447
|
|
|
448
|
+
/**
|
|
449
|
+
* The per-agent forms section (spec §4): every form the agent may ask with, its
|
|
450
|
+
* data schema, its answer schema and its example. Returns '' for an agent that
|
|
451
|
+
* declares none, which is what keeps every prompt that exists today byte-identical.
|
|
452
|
+
* `path` is the file to write the payload to; omitted for a clarifier, whose
|
|
453
|
+
* Ports block already names its answers port. PURE.
|
|
454
|
+
* @param {Record<string, object>|null|undefined} forms
|
|
455
|
+
* @param {{path?: string}} [opts]
|
|
456
|
+
*/
|
|
457
|
+
export function askFormsBlock(forms, { path: askPath } = {}) {
|
|
458
|
+
const entries = Object.entries(forms || {});
|
|
459
|
+
if (!entries.length) return '';
|
|
460
|
+
const body = entries.map(([id, def]) => (
|
|
461
|
+
`### \`${id}\` — ${def.title || id} (version ${Number.isInteger(def.version) ? def.version : 1})\n\n` +
|
|
462
|
+
'data you must supply:\n```json\n' + JSON.stringify(def.data, null, 2) + '\n```\n\n' +
|
|
463
|
+
'answer you will receive:\n```json\n' + JSON.stringify(def.answer, null, 2) + '\n```\n\n' +
|
|
464
|
+
'example data:\n```json\n' + JSON.stringify(def.example, null, 2) + '\n```\n'
|
|
465
|
+
)).join('\n');
|
|
466
|
+
return (
|
|
467
|
+
'## Forms you may ask with\n\n' +
|
|
468
|
+
(askPath ? `Instead of the questions shape, write {"form":"<id>","data":{…}} to: ${askPath}\n` : '') +
|
|
469
|
+
'A `file` value is a path relative to your working directory or the pipeline dir. ' +
|
|
470
|
+
'You will be resumed with {"form","version","values"}.\n\n' +
|
|
471
|
+
body
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The one repair round a refused form ask gets (spec §5 gate 2): the exact error
|
|
477
|
+
* list plus the data schema, and the SAME file to rewrite. Rendered by runOpts
|
|
478
|
+
* for BOTH engines — a clarifier never has questionsEnabled, so it could not
|
|
479
|
+
* ride questionsPromptBlock. '' when nothing is being repaired, which is what
|
|
480
|
+
* keeps every other prompt byte-identical. PURE.
|
|
481
|
+
* @param {{formRepair?: {form: string, errors: Array, schema: object|null, file: string}}} ctx
|
|
482
|
+
*/
|
|
483
|
+
export function formRepairBlock(ctx) {
|
|
484
|
+
const r = ctx && ctx.formRepair;
|
|
485
|
+
if (!r) return '';
|
|
486
|
+
const lines = (Array.isArray(r.errors) ? r.errors : [])
|
|
487
|
+
.map((e) => `- ${e && e.path ? `${e.path}: ` : ''}${(e && e.message) || 'invalid'}`)
|
|
488
|
+
.join('\n');
|
|
489
|
+
return (
|
|
490
|
+
'\n\n## Your form ask was refused\n\n' +
|
|
491
|
+
`Form \`${r.form}\`:\n${lines || '- invalid'}\n\n` +
|
|
492
|
+
(r.schema ? 'data schema:\n```json\n' + JSON.stringify(r.schema, null, 2) + '\n```\n\n' : '') +
|
|
493
|
+
`Write a corrected {"form","data"} to: ${r.file}\n`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
432
497
|
/**
|
|
433
498
|
* Ask-then-resume prompt block for a questions-enabled node (spec 2026-07-11).
|
|
434
499
|
* Appended by runOpts, so EVERY producer/verifier runner inherits it with no
|
|
@@ -447,16 +512,28 @@ export function questionsPromptBlock(ctx) {
|
|
|
447
512
|
const answered = prior.length
|
|
448
513
|
? '## Already answered — DO NOT ask these again\n\n' + renderAnswers(prior) + '\n'
|
|
449
514
|
: '';
|
|
515
|
+
// Form answers already collected for this node, in the exact shape the agent
|
|
516
|
+
// receives them (spec §4). '' when there are none, so the legacy block is
|
|
517
|
+
// byte-identical.
|
|
518
|
+
const priorForms = Array.isArray(ctx.formAnswers) ? ctx.formAnswers : [];
|
|
519
|
+
const formAnswered = priorForms.length
|
|
520
|
+
? '## Your form answers\n\n' + priorForms
|
|
521
|
+
.map((f) => '```json\n' + JSON.stringify({ form: f.form, version: f.version, values: f.values }, null, 2) + '\n```')
|
|
522
|
+
.join('\n\n') + '\n\n'
|
|
523
|
+
: '';
|
|
524
|
+
const forms = askFormsBlock(ctx.askForms, { path: ctx.questionsFile });
|
|
450
525
|
if (!ctx.questionsFile) {
|
|
451
526
|
return (
|
|
452
|
-
'\n\n' + answered +
|
|
527
|
+
'\n\n' + answered + formAnswered +
|
|
453
528
|
'## Asking the user\n\n' +
|
|
454
529
|
'No more question rounds are available this run — proceed with reasonable assumptions.\n'
|
|
455
530
|
);
|
|
456
531
|
}
|
|
457
|
-
|
|
532
|
+
// MOCK_ASK only while NOTHING has been answered yet — legacy OR form: a resume that
|
|
533
|
+
// re-emitted it would make the offline mock re-ask every round until the cap.
|
|
534
|
+
const mock = (prior.length || priorForms.length) ? '' : mockMarkers({ MOCK_ASK: ctx.questionsFile }) + '\n';
|
|
458
535
|
return (
|
|
459
|
-
'\n\n' + answered +
|
|
536
|
+
'\n\n' + answered + formAnswered +
|
|
460
537
|
'## Asking the user (enabled)\n\n' +
|
|
461
538
|
'If a decision materially shapes the outcome and you cannot resolve it from the task, ' +
|
|
462
539
|
'the inputs, or the codebase — including anything material you are about to silently ' +
|
|
@@ -466,6 +543,7 @@ export function questionsPromptBlock(ctx) {
|
|
|
466
543
|
'2. STOP immediately — do no further work. You will be resumed with the answers.\n' +
|
|
467
544
|
'Assume freely on minor choices; on material ones, ask instead of assuming. Never pad, ' +
|
|
468
545
|
'and never re-ask an answered question.\n\n' +
|
|
546
|
+
(forms ? forms + '\n' : '') +
|
|
469
547
|
mock
|
|
470
548
|
);
|
|
471
549
|
}
|
|
@@ -495,7 +573,8 @@ export function runOpts(ctx, { role, prompt, systemPrompt, allowedTools }) {
|
|
|
495
573
|
return {
|
|
496
574
|
cwd: ctx.projectDir,
|
|
497
575
|
systemPrompt,
|
|
498
|
-
prompt: (ctx.resumeSessionId ? RESUME_HEADER + prompt : prompt)
|
|
576
|
+
prompt: (ctx.resumeSessionId ? RESUME_HEADER + prompt : prompt)
|
|
577
|
+
+ questionsPromptBlock(ctx) + formRepairBlock(ctx),
|
|
499
578
|
resumeSessionId: ctx.resumeSessionId,
|
|
500
579
|
// Grant the role's baseline tools PLUS whatever the agent declared in its
|
|
501
580
|
// frontmatter (e.g. the Playwright MCP browser_* tools). ctx.node is present
|
|
@@ -515,6 +594,12 @@ export function runOpts(ctx, { role, prompt, systemPrompt, allowedTools }) {
|
|
|
515
594
|
// by the real runner (it is never a spawn flag).
|
|
516
595
|
workspaceWriteTargets: workspaceWriteTargetsFor(ctx),
|
|
517
596
|
permissionMode: c.permissionMode || 'acceptEdits',
|
|
597
|
+
// Agent memory (memory-write-split design D4): the writable copy lives OUTSIDE the cwd, so it
|
|
598
|
+
// rides --add-dir — acceptEdits auto-approves edits "inside your working directory or
|
|
599
|
+
// additionalDirectories" (documented), nothing else about the spawn changes, and the CLI loads
|
|
600
|
+
// nothing from that dir (CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD is never set here).
|
|
601
|
+
// undefined when the run has no mount ⇒ buildClaudeArgs emits nothing and the argv is byte-identical.
|
|
602
|
+
addDirs: typeof ctx.memoryMount === 'string' && ctx.memoryMount ? [ctx.memoryMount] : undefined,
|
|
518
603
|
model: c.model,
|
|
519
604
|
effort: c.effort, // per-role effort from the orchestrator
|
|
520
605
|
// Per-model routing env (design §4.4), resolved HERE — the one funnel every
|
|
@@ -524,7 +609,11 @@ export function runOpts(ctx, { role, prompt, systemPrompt, allowedTools }) {
|
|
|
524
609
|
// env byte-identical. The sub-agent model policy deliberately does NOT
|
|
525
610
|
// touch this env: its only wire is the prompt block (subagentModelDirective),
|
|
526
611
|
// and CLAUDE_CODE_SUBAGENT_MODEL is a reserved model-env key.
|
|
527
|
-
modelEnv:
|
|
612
|
+
modelEnv: resolveDispatchModelEnv(c, ctx),
|
|
613
|
+
// Model bridge (model-bridge-design.md §5.3): a translated model has no
|
|
614
|
+
// server-side web tools, so the runner withholds them. undefined for every
|
|
615
|
+
// non-bridged model ⇒ nothing emitted ⇒ argv byte-identical.
|
|
616
|
+
disallowedTools: bridgedModelInfo(c.model)?.excludeTools,
|
|
528
617
|
// Agent memory (§4.3): Task-tool sub-agents inherit the rules natively but not
|
|
529
618
|
// --append-system-prompt, so the pointer block rides the sub-agent flag. undefined when the
|
|
530
619
|
// run has no mount ⇒ buildClaudeArgs emits nothing and legacy argv stays byte-identical.
|
package/src/core/plugin-api.mjs
CHANGED
|
@@ -9,10 +9,27 @@
|
|
|
9
9
|
//
|
|
10
10
|
// API 2 adds the chatChannels contribution + persistent channel worker
|
|
11
11
|
// protocol; API 3 changes the DATA contract: agent sidecars are meta v2 (typed
|
|
12
|
-
// ports) and workflows/*.json are v2 graphs.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
// ports) and workflows/*.json are v2 graphs. API 4 adds ONE thing: an agent
|
|
13
|
+
// sidecar's `ask` block (declared question forms) is honoured. It does NOT
|
|
14
|
+
// touch the sidecar or template contract, which is why WORCA_AGENT_DATA_API
|
|
15
|
+
// exists and is 3 — comparing the data contract against WORCA_PLUGIN_API would
|
|
16
|
+
// silently demote every current ">=3 <4" plugin's v1 sidecars from error to
|
|
17
|
+
// warning on this bump. The task-source connector and the channel-worker
|
|
18
|
+
// protocols are unchanged across 1 -> 2 -> 3 -> 4, so a connector-only
|
|
19
|
+
// ">=1 <2" plugin and a chat plugin's ">=2 <3" keep negotiating 1 and 2 and
|
|
20
|
+
// keep working untouched. The set is what makes that possible: never collapse
|
|
21
|
+
// it to a single integer.
|
|
22
|
+
export const WORCA_PLUGIN_API = 4;
|
|
23
|
+
export const WORCA_PLUGIN_APIS = [1, 2, 3, 4];
|
|
24
|
+
|
|
25
|
+
/** The API whose DATA contract agent sidecars and pipeline templates must meet:
|
|
26
|
+
* meta v2 (typed ports) + version-2 graphs. Introduced by API 3, UNCHANGED by
|
|
27
|
+
* 4. `validatePluginDir`'s hard/soft split and the apiMismatch sentence are
|
|
28
|
+
* about THIS, never about the newest host API. */
|
|
29
|
+
export const WORCA_AGENT_DATA_API = 3;
|
|
30
|
+
|
|
31
|
+
/** The API a plugin must NEGOTIATE (highest member its range admits) for the
|
|
32
|
+
* `ask` blocks of the agents it ships to be honoured. Below it the block is
|
|
33
|
+
* stripped at load and reported as an ignored contribution; the agent keeps
|
|
34
|
+
* working with generic questions (ask-forms spec §10). */
|
|
35
|
+
export const WORCA_ASK_FORMS_API = 4;
|
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
import { readFileSync, readdirSync, readlinkSync, existsSync } from 'node:fs';
|
|
7
7
|
import { join, resolve, dirname, sep, isAbsolute } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import { WORCA_PLUGIN_API, WORCA_PLUGIN_APIS } from './plugin-api.mjs';
|
|
10
|
-
import { EFFORTS, isReservedModelEnvKey, assertModelCost } from './model-env.mjs';
|
|
9
|
+
import { WORCA_PLUGIN_API, WORCA_PLUGIN_APIS, WORCA_AGENT_DATA_API, WORCA_ASK_FORMS_API } from './plugin-api.mjs';
|
|
10
|
+
import { EFFORTS, isReservedModelEnvKey, assertModelCost, assertModelUpstream, upstreamEnvConflict } from './model-env.mjs';
|
|
11
11
|
import { validateMetaV2, normalizeAgentMeta, indexByKey } from '../shared/graph/agent-meta.mjs';
|
|
12
12
|
import { portsFnFor } from '../shared/graph/ports.mjs';
|
|
13
13
|
import { validateGraph } from '../shared/graph/validate.mjs';
|
|
14
|
+
import { validateScriptMetaV2, normalizeScriptMeta } from '../shared/graph/script-meta.mjs';
|
|
15
|
+
import { normalizeCases } from '../shared/graph/script-cases.mjs';
|
|
16
|
+
import { normalizeAskBlock, validateFormDef } from '../shared/forms/form-def.mjs';
|
|
17
|
+
import { ASK_LIMITS } from '../shared/forms/catalog.mjs';
|
|
14
18
|
|
|
15
19
|
/** Plugin names are kebab-case, machine-unique, dir-name safe (spec §4.1). */
|
|
16
20
|
export const PLUGIN_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
@@ -51,12 +55,32 @@ export function builtinAgentMetas(dir = BUILTIN_AGENTS_DIR) {
|
|
|
51
55
|
}
|
|
52
56
|
return out;
|
|
53
57
|
}
|
|
58
|
+
/** The built-in script layer (repo scripts/, D20). Same URL math as script-registry's DEFAULT_SCRIPTS_DIR. */
|
|
59
|
+
const BUILTIN_SCRIPTS_DIR = fileURLToPath(new URL('../../scripts/', import.meta.url));
|
|
60
|
+
|
|
61
|
+
/** Normalized built-in script sidecars, for the plugin-template isolation rule: a
|
|
62
|
+
* template may reference built-in scripts (present on every host) plus the plugin's own. */
|
|
63
|
+
export function builtinScriptMetas(dir = BUILTIN_SCRIPTS_DIR) {
|
|
64
|
+
const out = [];
|
|
65
|
+
let files = [];
|
|
66
|
+
try { files = readdirSync(dir).filter((f) => f.endsWith('.meta.json')); } catch { return out; }
|
|
67
|
+
for (const f of files.sort()) {
|
|
68
|
+
let meta = null;
|
|
69
|
+
try { meta = JSON.parse(readFileSync(join(dir, f), 'utf8')); } catch { continue; }
|
|
70
|
+
if (Number(meta?.metaVersion) !== 2 || !KEY_RE.test(String(meta.key || ''))) continue;
|
|
71
|
+
const { meta: norm, errors } = normalizeScriptMeta(meta);
|
|
72
|
+
if (errors.length) continue;
|
|
73
|
+
out.push(norm);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
const KNOWN_SOURCE = new Set(['id', 'displayName', 'module', 'configSchema', 'inputs', 'multiProfile']);
|
|
55
79
|
const KNOWN_CHANNEL = new Set(['id', 'displayName', 'platform', 'module', 'ingress', 'capabilities', 'configSchema']);
|
|
56
80
|
const CHANNEL_INGRESS = new Set(['connect', 'webhook']);
|
|
57
81
|
const KNOWN_FIELD = new Set(['key', 'type', 'label', 'secret', 'required', 'default', 'help', 'options']);
|
|
58
82
|
const KNOWN_INPUT = new Set(['key', 'type', 'label', 'default', 'optionsFrom', 'options']);
|
|
59
|
-
const KNOWN_MODEL = new Set(['id', 'label', 'efforts', 'env', 'cost']);
|
|
83
|
+
const KNOWN_MODEL = new Set(['id', 'label', 'efforts', 'env', 'cost', 'upstream']);
|
|
60
84
|
const KNOWN_MODEL_SECRET = new Set(['key', 'label']);
|
|
61
85
|
/** A manifest env value that defers to the plugin's secrets store (design §9.1). */
|
|
62
86
|
const isSecretRef = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -110,6 +134,13 @@ export function negotiatedApi(range, apis = WORCA_PLUGIN_APIS) {
|
|
|
110
134
|
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';
|
|
111
135
|
export const NOT_GRAPH_V2 = 'not a version-2 graph template (nodes/wires) — port the "steps" pipeline';
|
|
112
136
|
|
|
137
|
+
/** The ONE sentence for a plugin that ships agent ask forms without negotiating
|
|
138
|
+
* the API that honours them. Used verbatim by `agent-registry.scanLayer` (the
|
|
139
|
+
* load-time strip) and by `validatePluginDir` (the author-time warning), so the
|
|
140
|
+
* two can never drift — never re-word it in a second place. */
|
|
141
|
+
export const ASK_NEEDS_API_4 = `ask forms need plugin API ${WORCA_ASK_FORMS_API}`
|
|
142
|
+
+ ' (declare "engines": { "worca-cc-api": ">=4 <5" }) — the ask block is ignored and the agent keeps generic questions';
|
|
143
|
+
|
|
113
144
|
/** The host API a range was BUILT FOR: the lowest integer it accepts. ">=1 <2"
|
|
114
145
|
* and "1" both answer 1; an unconstrained range answers 0; null when nothing
|
|
115
146
|
* satisfies it (an unparseable range fails closed in apiSatisfies too).
|
|
@@ -128,6 +159,7 @@ export function declaredApi(range) {
|
|
|
128
159
|
export function dataContractIssues(absDir) {
|
|
129
160
|
const agentsV1 = [];
|
|
130
161
|
const workflowsV1 = [];
|
|
162
|
+
const scriptsV1 = [];
|
|
131
163
|
const read = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
132
164
|
const agentsDir = join(absDir, 'agents');
|
|
133
165
|
if (existsSync(agentsDir)) {
|
|
@@ -136,6 +168,13 @@ export function dataContractIssues(absDir) {
|
|
|
136
168
|
if (raw && Number(raw.metaVersion) !== 2) agentsV1.push(f);
|
|
137
169
|
}
|
|
138
170
|
}
|
|
171
|
+
const scDir = join(absDir, 'scripts');
|
|
172
|
+
if (existsSync(scDir)) {
|
|
173
|
+
for (const f of readdirSync(scDir).filter((x) => x.endsWith('.meta.json')).sort()) {
|
|
174
|
+
const raw = read(join(scDir, f));
|
|
175
|
+
if (raw && Number(raw.metaVersion) !== 2) scriptsV1.push(f);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
139
178
|
const wfDir = join(absDir, 'workflows');
|
|
140
179
|
if (existsSync(wfDir)) {
|
|
141
180
|
for (const f of readdirSync(wfDir).filter((x) => x.endsWith('.json')).sort()) {
|
|
@@ -143,7 +182,7 @@ export function dataContractIssues(absDir) {
|
|
|
143
182
|
if (raw && Number(raw.version) !== 2) workflowsV1.push(f);
|
|
144
183
|
}
|
|
145
184
|
}
|
|
146
|
-
return { agentsV1, workflowsV1 };
|
|
185
|
+
return { agentsV1, workflowsV1, scriptsV1 };
|
|
147
186
|
}
|
|
148
187
|
|
|
149
188
|
/**
|
|
@@ -159,10 +198,11 @@ export function dataContractIssues(absDir) {
|
|
|
159
198
|
export function apiMismatch(range, issues) {
|
|
160
199
|
const agents = (issues && issues.agentsV1 ? issues.agentsV1 : []).length;
|
|
161
200
|
const workflows = (issues && issues.workflowsV1 ? issues.workflowsV1 : []).length;
|
|
162
|
-
|
|
201
|
+
const scripts = (issues && issues.scriptsV1 ? issues.scriptsV1 : []).length;
|
|
202
|
+
if (!agents && !workflows && !scripts) return null;
|
|
163
203
|
// declaredApi('') is 0 (an unconstrained range accepts everything); report that
|
|
164
204
|
// as null so the message reads "built for an older version", never "API 0".
|
|
165
|
-
const mismatch = { builtFor: declaredApi(range) || null, host: WORCA_PLUGIN_API, agents, workflows };
|
|
205
|
+
const mismatch = { builtFor: declaredApi(range) || null, host: WORCA_PLUGIN_API, agents, workflows, ...(scripts ? { scripts } : {}) };
|
|
166
206
|
mismatch.message = apiMismatchMessage(mismatch);
|
|
167
207
|
return mismatch;
|
|
168
208
|
}
|
|
@@ -176,10 +216,10 @@ export function apiMismatch(range, issues) {
|
|
|
176
216
|
*/
|
|
177
217
|
export function apiMismatchMessage(mismatch) {
|
|
178
218
|
if (!mismatch) return '';
|
|
179
|
-
const { builtFor, agents, workflows } = mismatch;
|
|
219
|
+
const { builtFor, agents, workflows, scripts } = mismatch;
|
|
180
220
|
return `built for plugin API ${builtFor ?? 'an older version'}; this version of worca requires `
|
|
181
|
-
+ `plugin API ${
|
|
182
|
-
+ `(${agents} agent(s), ${workflows} template(s) ignored)`;
|
|
221
|
+
+ `plugin API ${WORCA_AGENT_DATA_API} for agents and pipeline templates — update or reinstall the plugin `
|
|
222
|
+
+ `(${agents} agent(s), ${scripts ? `${scripts} script(s), ` : ''}${workflows} template(s) ignored)`;
|
|
183
223
|
}
|
|
184
224
|
|
|
185
225
|
const str = (v, d = '') => (typeof v === 'string' ? v.trim() : d);
|
|
@@ -467,11 +507,28 @@ export function normalizeManifest(raw, { dir = '' } = {}) {
|
|
|
467
507
|
errors.push(`${at} ("${id}"): ${e.message}`);
|
|
468
508
|
return;
|
|
469
509
|
}
|
|
510
|
+
// A bridged model (model-bridge-design.md §6.4): same shape and validator
|
|
511
|
+
// as a global entry. A plugin cannot ship provider credentials — a
|
|
512
|
+
// `copilot` entry resolves against the user's own sign-in, and an
|
|
513
|
+
// `openai`/`anthropic` apiKey may only be a ${VAR} ref, never a literal.
|
|
514
|
+
let upstream;
|
|
515
|
+
try {
|
|
516
|
+
upstream = assertModelUpstream(m.upstream);
|
|
517
|
+
if (upstream && upstream.apiKey && !/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(upstream.apiKey)) {
|
|
518
|
+
throw new Error('upstream.apiKey in a plugin must be a ${VAR} reference, never a literal key');
|
|
519
|
+
}
|
|
520
|
+
const clash = upstream ? upstreamEnvConflict(env) : null;
|
|
521
|
+
if (clash) { warnings.push(`${at} ("${id}"): env key ${JSON.stringify(clash)} is set by the bridge for an upstream entry — ignored`); delete env[clash]; }
|
|
522
|
+
} catch (e) {
|
|
523
|
+
errors.push(`${at} ("${id}"): ${e.message}`);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
470
526
|
models.push({
|
|
471
527
|
id, label: str(m.label) || id,
|
|
472
528
|
efforts: efforts.length ? efforts : [...EFFORTS],
|
|
473
529
|
...(Object.keys(env).length ? { env } : {}),
|
|
474
530
|
...(cost ? { cost } : {}),
|
|
531
|
+
...(upstream ? { upstream } : {}),
|
|
475
532
|
});
|
|
476
533
|
});
|
|
477
534
|
const mids = models.map((m) => m.id.toLowerCase());
|
|
@@ -569,13 +626,13 @@ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {})
|
|
|
569
626
|
// "this plugin does not ship it" or the derived V4/V5/V20/V21 cascade.
|
|
570
627
|
const ungatedKeys = new Set();
|
|
571
628
|
const ownMetas = [];
|
|
572
|
-
// A range that admits the
|
|
573
|
-
//
|
|
574
|
-
// everyone else, because that flag is the plugin
|
|
575
|
-
//
|
|
576
|
-
//
|
|
629
|
+
// A range that admits the DATA-CONTRACT API (or no engines at all) claims to
|
|
630
|
+
// ship meta v2 sidecars and v2 graphs, so v1-shaped data is a hard error;
|
|
631
|
+
// --strict promotes it for everyone else, because that flag is the plugin
|
|
632
|
+
// AUTHOR's gate. Compared against WORCA_AGENT_DATA_API, never the newest host
|
|
633
|
+
// API: an API-5 host must not make today's ">=3 <4" plugins soft again.
|
|
577
634
|
const hardData = raw !== null
|
|
578
|
-
&& (strict || negotiatedApi(raw && raw.engines ? raw.engines['worca-cc-api'] : '')
|
|
635
|
+
&& (strict || (negotiatedApi(raw && raw.engines ? raw.engines['worca-cc-api'] : '') ?? -1) >= WORCA_AGENT_DATA_API);
|
|
579
636
|
const dataLevel = hardData ? 'error' : 'warn';
|
|
580
637
|
const agentsDir = join(absDir, 'agents');
|
|
581
638
|
if (existsSync(agentsDir)) {
|
|
@@ -602,6 +659,51 @@ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {})
|
|
|
602
659
|
// as shipped is what let a ports-less node reach validateGraph.
|
|
603
660
|
agentKeys.add(key);
|
|
604
661
|
ownMetas.push(normalizeAgentMeta(meta).meta);
|
|
662
|
+
|
|
663
|
+
// ── ask forms (spec §10) ────────────────────────────────────────────────
|
|
664
|
+
// The SAME gate 1 the registry, the agent store and the Agents view run.
|
|
665
|
+
// Level: a bad form is a WARNING, because the host's behaviour for one is
|
|
666
|
+
// already defined (it is dropped at load and the agent keeps generic
|
|
667
|
+
// questions), and a plugin whose connector is fine must stay installable.
|
|
668
|
+
// --strict is the AUTHOR's gate, so there it is an error. This split is
|
|
669
|
+
// keyed on `strict` alone, NOT on dataLevel: a form's validity has nothing
|
|
670
|
+
// to do with which API the plugin negotiates.
|
|
671
|
+
if (meta.ask !== undefined && meta.ask !== null) {
|
|
672
|
+
const askLevel = strict ? 'error' : 'warn';
|
|
673
|
+
// normalizeAskBlock is used ONLY for what it alone knows: a whole-block
|
|
674
|
+
// refusal (id '*'). Its per-form `reason` is a collapsed one-liner of the
|
|
675
|
+
// first three errors — right for a registry log line, wrong here, where
|
|
676
|
+
// the author wants every failed rule with its path. So each form goes
|
|
677
|
+
// through validateFormDef directly.
|
|
678
|
+
const blockDrops = normalizeAskBlock(meta.ask).dropped.filter((d) => d.id === '*');
|
|
679
|
+
let declaredForms = 0;
|
|
680
|
+
if (blockDrops.length) {
|
|
681
|
+
for (const d of blockDrops) push(askLevel, `agents/${f}: ask: ${d.reason}`);
|
|
682
|
+
} else {
|
|
683
|
+
const declared = meta.ask.forms && typeof meta.ask.forms === 'object' ? meta.ask.forms : {};
|
|
684
|
+
const ids = Object.keys(declared);
|
|
685
|
+
declaredForms = ids.length;
|
|
686
|
+
if (ids.length > ASK_LIMITS.formsPerAgent) {
|
|
687
|
+
push(askLevel, `agents/${f}: ask.forms: at most ${ASK_LIMITS.formsPerAgent} forms per agent`);
|
|
688
|
+
}
|
|
689
|
+
for (const id of ids) {
|
|
690
|
+
// P1 addresses layout items as `layout#<n>` (depth-first, 1-based);
|
|
691
|
+
// print the path exactly as it comes so the Plugins card, the 422
|
|
692
|
+
// body and this line all name the same item.
|
|
693
|
+
for (const e of validateFormDef(declared[id], { id }).errors) {
|
|
694
|
+
push(askLevel, `agents/${f}: ask.forms."${id}"${e.path ? ` ${e.path}` : ''}: ${e.message}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// A plugin may ship forms it knows an older host will ignore; say so
|
|
699
|
+
// once, by name, and never fail the validate for it. ONE sentence,
|
|
700
|
+
// exported so the load-time strip and this line can never drift. Only
|
|
701
|
+
// when the block DECLARES a form: an empty `forms` map loads as "no
|
|
702
|
+
// forms" (normalizeAgentMeta drops it) and the host ignores nothing.
|
|
703
|
+
if (declaredForms && !(Number(negotiatedApi(raw && raw.engines ? raw.engines['worca-cc-api'] : '')) >= WORCA_ASK_FORMS_API)) {
|
|
704
|
+
push('warn', `agents/${f}: ${ASK_NEEDS_API_4}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
605
707
|
}
|
|
606
708
|
for (const f of files.filter((x) => x.endsWith('.md'))) {
|
|
607
709
|
const stem = f.slice(0, -3);
|
|
@@ -611,6 +713,61 @@ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {})
|
|
|
611
713
|
}
|
|
612
714
|
}
|
|
613
715
|
|
|
716
|
+
// scripts/: <key>.meta.json + the program it names (spec §9.3). The SAME meta
|
|
717
|
+
// v2 gate the registry loader applies, every failed rule named; every platform
|
|
718
|
+
// entry of `file` must exist inside scripts/ and never escape it.
|
|
719
|
+
const scriptKeys = new Set();
|
|
720
|
+
const ungatedScriptKeys = new Set();
|
|
721
|
+
const ownScriptMetas = [];
|
|
722
|
+
const scriptsDir = join(absDir, 'scripts');
|
|
723
|
+
if (existsSync(scriptsDir)) {
|
|
724
|
+
const files = readdirSync(scriptsDir);
|
|
725
|
+
for (const f of files.filter((x) => x.endsWith('.meta.json'))) {
|
|
726
|
+
const stem = f.slice(0, -'.meta.json'.length);
|
|
727
|
+
let meta = null;
|
|
728
|
+
try { meta = JSON.parse(readFileSync(join(scriptsDir, f), 'utf8')); }
|
|
729
|
+
catch { push('error', `scripts/${f}: invalid JSON`); continue; }
|
|
730
|
+
const key = typeof meta?.key === 'string' ? meta.key : '';
|
|
731
|
+
if (!KEY_RE.test(key)) { push('error', `scripts/${f}: "${key}" must be a valid script key (letters/digits/_-)`); continue; }
|
|
732
|
+
if (key !== stem) push('error', `scripts/${f}: key "${key}" must match the filename stem "${stem}"`);
|
|
733
|
+
if (Number(meta.metaVersion) !== 2) { push(dataLevel, `scripts/${f}: ${NOT_META_V2}`); ungatedScriptKeys.add(key); continue; }
|
|
734
|
+
const { errors } = validateScriptMetaV2(meta);
|
|
735
|
+
for (const e of errors) push('error', `scripts/${f}: ${e}`);
|
|
736
|
+
if (errors.length) { ungatedScriptKeys.add(key); continue; }
|
|
737
|
+
const norm = normalizeScriptMeta(meta).meta;
|
|
738
|
+
// Every platform's program must be a contained, existing file.
|
|
739
|
+
const entries = typeof norm.file === 'string' ? [norm.file] : norm.file ? Object.values(norm.file) : [];
|
|
740
|
+
let fileOk = true;
|
|
741
|
+
for (const rel of entries) {
|
|
742
|
+
if (isAbsolute(rel) || /[\\/]/.test(rel) || rel.includes('..') || !resolve(scriptsDir, rel).startsWith(resolve(scriptsDir) + sep)) {
|
|
743
|
+
push('error', `scripts/${f}: file must be a plain basename inside scripts/ (got "${rel}")`); fileOk = false; continue;
|
|
744
|
+
}
|
|
745
|
+
if (!existsSync(join(scriptsDir, rel))) { push('error', `scripts/${f}: file "${rel}" not found in scripts/`); fileOk = false; }
|
|
746
|
+
}
|
|
747
|
+
if (!fileOk) { ungatedScriptKeys.add(key); continue; }
|
|
748
|
+
scriptKeys.add(key);
|
|
749
|
+
ownScriptMetas.push(norm);
|
|
750
|
+
// <key>.tests.json is optional; when it ships it goes through the SAME
|
|
751
|
+
// normalizer the store, the bench and the UI use, as a SHIPPED set — a
|
|
752
|
+
// case that names a project folder cannot travel to a recipient (W9).
|
|
753
|
+
const casesFile = join(scriptsDir, `${key}.tests.json`);
|
|
754
|
+
if (existsSync(casesFile)) {
|
|
755
|
+
let rawCases = null;
|
|
756
|
+
try { rawCases = JSON.parse(readFileSync(casesFile, 'utf8')); }
|
|
757
|
+
catch { push('error', `scripts/${key}.tests.json: invalid JSON`); continue; }
|
|
758
|
+
for (const e of normalizeCases(rawCases, norm, { shipped: true }).errors) {
|
|
759
|
+
push('error', `scripts/${key}.tests.json: ${e}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// A tests file with no sidecar beside it: the W18 overlay is a USER-layer
|
|
764
|
+
// idea, so inside a plugin it is a file nothing will ever read.
|
|
765
|
+
for (const f of files.filter((x) => x.endsWith('.tests.json'))) {
|
|
766
|
+
const stem = f.slice(0, -'.tests.json'.length);
|
|
767
|
+
if (!files.includes(`${stem}.meta.json`)) push('error', `scripts/${f}: no ${stem}.meta.json beside it`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
614
771
|
// skills/<name>/SKILL.md required (rides the existing injection mechanism, §9.2)
|
|
615
772
|
const skillsDir = join(absDir, 'skills');
|
|
616
773
|
if (existsSync(skillsDir)) {
|
|
@@ -634,16 +791,19 @@ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {})
|
|
|
634
791
|
if (existsSync(wfDir)) {
|
|
635
792
|
const hostMetas = Array.isArray(builtinMetas) ? builtinMetas : builtinAgentMetas();
|
|
636
793
|
const hostKeys = new Set(hostMetas.map((m) => m.key));
|
|
637
|
-
const
|
|
794
|
+
const hostScriptMetas = builtinScriptMetas();
|
|
795
|
+
const hostScriptKeys = new Set(hostScriptMetas.map((m) => m.key));
|
|
796
|
+
const portsFn = portsFnFor(indexByKey([...hostMetas, ...ownMetas]), indexByKey([...hostScriptMetas, ...ownScriptMetas]));
|
|
638
797
|
for (const f of readdirSync(wfDir).filter((x) => x.endsWith('.json'))) {
|
|
639
798
|
let tpl = null;
|
|
640
799
|
try { tpl = JSON.parse(readFileSync(join(wfDir, f), 'utf8')); }
|
|
641
800
|
catch { push('error', `workflows/${f}: invalid JSON`); continue; }
|
|
642
801
|
if (Number(tpl?.version) !== 2) { push(dataLevel, `workflows/${f}: ${NOT_GRAPH_V2}`); continue; }
|
|
643
802
|
const nodes = Array.isArray(tpl.nodes) ? tpl.nodes : [];
|
|
644
|
-
const
|
|
803
|
+
const agentRefs = nodes.filter((n) => n && n.kind === 'agent').map((n) => n.key).filter(Boolean);
|
|
804
|
+
const scriptRefs = nodes.filter((n) => n && n.kind === 'script').map((n) => n.key).filter(Boolean);
|
|
645
805
|
let unresolved = false;
|
|
646
|
-
for (const k of new Set(
|
|
806
|
+
for (const k of new Set(agentRefs)) {
|
|
647
807
|
if (agentKeys.has(k) || hostKeys.has(k)) continue;
|
|
648
808
|
if (ungatedKeys.has(k)) {
|
|
649
809
|
// The sidecar is this plugin's, it just did not pass. Report at the
|
|
@@ -655,6 +815,12 @@ export function validatePluginDir(absDir, { strict = false, builtinMetas } = {})
|
|
|
655
815
|
}
|
|
656
816
|
unresolved = true;
|
|
657
817
|
}
|
|
818
|
+
for (const k of new Set(scriptRefs)) {
|
|
819
|
+
if (scriptKeys.has(k) || hostScriptKeys.has(k)) continue;
|
|
820
|
+
if (ungatedScriptKeys.has(k)) push(dataLevel, `workflows/${f}: references script key "${k}" whose sidecar is not a valid meta v2 sidecar`);
|
|
821
|
+
else push('error', `workflows/${f}: references script key "${k}" which is neither a built-in nor shipped by this plugin`);
|
|
822
|
+
unresolved = true;
|
|
823
|
+
}
|
|
658
824
|
// An unresolved key has no ports, so every wire touching it would also
|
|
659
825
|
// fire V4/V5 — one clear cause beats five derived ones.
|
|
660
826
|
if (unresolved) continue;
|
|
@@ -50,6 +50,7 @@ export function allPluginModels() {
|
|
|
50
50
|
plugin: name, id: m.id, label: m.label, efforts: [...m.efforts],
|
|
51
51
|
...(m.env ? { env: m.env } : {}),
|
|
52
52
|
...(m.cost ? { cost: m.cost } : {}), // manifest-pinned pricing (config.mjs modelCostConfig)
|
|
53
|
+
...(m.upstream ? { upstream: m.upstream } : {}), // bridged entry (bridge/registry.mjs)
|
|
53
54
|
secrets: [...new Set(secrets)],
|
|
54
55
|
});
|
|
55
56
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// src/core/plugin-script-cases.mjs
|
|
2
|
+
// `worca plugin validate <dir> --run-cases` (spec §8.1): run every case a plugin
|
|
3
|
+
// dir SHIPS through the one bench the app and the CLI use, against the dir's own
|
|
4
|
+
// scripts and a scratch cwd. Deliberately NOT inside plugin-manifest.mjs:
|
|
5
|
+
// validatePluginDir is the cheap synchronous gate every install path calls, and
|
|
6
|
+
// it must never spawn a child process.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { loadScriptRegistry } from './script-registry.mjs';
|
|
12
|
+
import { runBenchOnce } from './script-bench.mjs';
|
|
13
|
+
import { normalizeCases, evaluateExpect } from '../shared/graph/script-cases.mjs';
|
|
14
|
+
|
|
15
|
+
/** One case through the bench: the dir's registry, a scratch cwd, always. */
|
|
16
|
+
async function runOneCase(key, kase, registry, deps) {
|
|
17
|
+
const request = {
|
|
18
|
+
key,
|
|
19
|
+
params: kase.params || {},
|
|
20
|
+
inputs: kase.inputs || {},
|
|
21
|
+
cwd: { kind: 'scratch' },
|
|
22
|
+
...(kase.ports ? { ports: kase.ports } : {}),
|
|
23
|
+
...(kase.timeoutMs ? { timeoutMs: kase.timeoutMs } : {}),
|
|
24
|
+
};
|
|
25
|
+
const benchDeps = { registry, agentKeys: [] };
|
|
26
|
+
// The caller's hooks, per case: the live bench (so a signal can stop the child
|
|
27
|
+
// tree) and the streamed lines, tagged with the script the case belongs to.
|
|
28
|
+
if (typeof deps.onBench === 'function') benchDeps.onBench = deps.onBench;
|
|
29
|
+
if (typeof deps.onLine === 'function') {
|
|
30
|
+
benchDeps.onLine = (ev) => deps.onLine({ key, caseId: kase.id, stream: ev && ev.stream, text: ev && ev.text });
|
|
31
|
+
}
|
|
32
|
+
if (deps.runner) benchDeps.runner = deps.runner;
|
|
33
|
+
if (deps.home) benchDeps.home = deps.home;
|
|
34
|
+
if (deps.platform) benchDeps.platform = deps.platform;
|
|
35
|
+
try {
|
|
36
|
+
return await runBenchOnce(request, benchDeps);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
// A transport-level refusal (a bad request, the bench cap) IS this case's
|
|
39
|
+
// result: --run-cases reports per case and never throws at the caller.
|
|
40
|
+
return {
|
|
41
|
+
status: 'error', exitCode: null, runtime: null, durationMs: 0, summary: '', warnings: [],
|
|
42
|
+
fired: [], outputs: {}, verdict: null, envelopePath: null,
|
|
43
|
+
error: { message: e && e.message ? e.message : String(e), tail: [] },
|
|
44
|
+
expect: null, draft: false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run every shipped case of every script under `<absDir>/scripts`.
|
|
51
|
+
* @param {string} absDir the plugin folder
|
|
52
|
+
* @param {{runner?: Function, home?: string, platform?: string,
|
|
53
|
+
* onBench?: (bench: object) => void,
|
|
54
|
+
* onLine?: (ev: {key: string, caseId: string, stream: string, text: string}) => void,
|
|
55
|
+
* stopRequested?: () => boolean}} [deps]
|
|
56
|
+
* onBench receives each case's live bench (call `stop()` on it); stopRequested is
|
|
57
|
+
* asked before every case. A STOPPED case fails and ends the batch: `stopped` is
|
|
58
|
+
* true and the cases not reached are absent from the rows.
|
|
59
|
+
* @returns {Promise<{scripts: Array<{key: string, runtime: string, cases: Array<{
|
|
60
|
+
* caseId: string, name: string, status: string, checked: boolean, pass: boolean,
|
|
61
|
+
* diffs: string[], durationMs: number}>}>, passed: number, failed: number,
|
|
62
|
+
* unchecked: number, stopped: boolean, problems: string[]}>}
|
|
63
|
+
*/
|
|
64
|
+
export async function runPluginScriptCases(absDir, deps = {}) {
|
|
65
|
+
const dir = join(absDir, 'scripts');
|
|
66
|
+
const out = { scripts: [], passed: 0, failed: 0, unchecked: 0, stopped: false, problems: [] };
|
|
67
|
+
const stopRequested = typeof deps.stopRequested === 'function' ? deps.stopRequested : () => false;
|
|
68
|
+
if (!existsSync(dir)) return out;
|
|
69
|
+
// The plugin dir IS the registry for this run: its scripts load as the only
|
|
70
|
+
// layer. agentKeys is empty on purpose — D16 drops a script whose key a HOST
|
|
71
|
+
// agent holds, which says nothing about a folder that is only being linted.
|
|
72
|
+
const registry = loadScriptRegistry({
|
|
73
|
+
scriptsDir: dir, userScriptsDir: null, includePlugins: false, agentKeys: [],
|
|
74
|
+
});
|
|
75
|
+
for (const f of readdirSync(dir).filter((x) => x.endsWith('.meta.json')).sort()) {
|
|
76
|
+
const key = f.slice(0, -'.meta.json'.length);
|
|
77
|
+
const meta = registry[key];
|
|
78
|
+
if (!meta) continue; // validatePluginDir already named it
|
|
79
|
+
const casesFile = join(dir, `${key}.tests.json`);
|
|
80
|
+
if (!existsSync(casesFile)) continue;
|
|
81
|
+
let raw = null;
|
|
82
|
+
try { raw = JSON.parse(readFileSync(casesFile, 'utf8')); }
|
|
83
|
+
catch (e) { out.problems.push(`scripts/${key}.tests.json: invalid JSON (${e.message})`); continue; }
|
|
84
|
+
const { cases, errors } = normalizeCases(raw, meta, { shipped: true });
|
|
85
|
+
for (const e of errors) out.problems.push(`scripts/${key}.tests.json: ${e}`);
|
|
86
|
+
if (!cases.length) continue;
|
|
87
|
+
const row = { key, runtime: meta.runtime, cases: [] };
|
|
88
|
+
for (const kase of cases) {
|
|
89
|
+
if (stopRequested()) { out.stopped = true; break; }
|
|
90
|
+
const result = await runOneCase(key, kase, registry, deps);
|
|
91
|
+
const ev = evaluateExpect(kase.expect, result);
|
|
92
|
+
const checked = !!ev;
|
|
93
|
+
// `stopped` verified nothing either: it fails, and it ends the batch below.
|
|
94
|
+
const broke = result.status === 'error' || result.status === 'timeout' || result.status === 'stopped';
|
|
95
|
+
// An UNCHECKED case that could not run is still a failure: a shipped
|
|
96
|
+
// script that throws is broken whether or not it declared an expectation.
|
|
97
|
+
// A STOPPED case never passes, checked or not: an expectation that names no
|
|
98
|
+
// verdict (`{ fired: [] }`) is SATISFIED by a run that was cut short.
|
|
99
|
+
const stopped = result.status === 'stopped';
|
|
100
|
+
const pass = !stopped && (checked ? ev.pass : !broke);
|
|
101
|
+
// WHY it broke rides along either way: "expected clean, got error" alone sends
|
|
102
|
+
// the author to the bench to learn that python is missing on the CI host.
|
|
103
|
+
const why = broke ? [result.error ? result.error.message : result.status] : [];
|
|
104
|
+
const diffs = checked ? [...ev.diffs, ...(ev.pass && !stopped ? [] : why)] : why;
|
|
105
|
+
if (!pass) out.failed += 1;
|
|
106
|
+
else if (checked) out.passed += 1;
|
|
107
|
+
else out.unchecked += 1;
|
|
108
|
+
row.cases.push({
|
|
109
|
+
caseId: kase.id, name: kase.name, status: result.status,
|
|
110
|
+
checked, pass, diffs, durationMs: result.durationMs || 0,
|
|
111
|
+
});
|
|
112
|
+
if (result.status === 'stopped') { out.stopped = true; break; }
|
|
113
|
+
}
|
|
114
|
+
if (row.cases.length) out.scripts.push(row);
|
|
115
|
+
if (out.stopped) break;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|