@worca/app 1.3.0 → 1.4.0
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/stats.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
budgetStatus, costWindowStart, costWindowEnd, allTimeTotals, roundUsd,
|
|
17
17
|
askWindowedSpendUsd,
|
|
18
18
|
} from './cost-budget.mjs';
|
|
19
|
+
import { savedUsd } from '../shared/human-estimate.mjs';
|
|
20
|
+
import { effectiveHumanRateUsd } from './human-rate.mjs';
|
|
19
21
|
|
|
20
22
|
const RANGES = ['today', 'week', 'month', 'all'];
|
|
21
23
|
|
|
@@ -33,7 +35,8 @@ const TOTALS_SELECT = `
|
|
|
33
35
|
COALESCE(SUM(pr_url IS NOT NULL), 0) AS prsOpened,
|
|
34
36
|
COALESCE(SUM(pr_state = 'MERGED'), 0) AS prsMerged,
|
|
35
37
|
COALESCE(SUM(CASE WHEN p.total_cost_usd > 0 THEN p.total_cost_usd ELSE COALESCE(s.sc, 0) END), 0) AS spend,
|
|
36
|
-
COALESCE(SUM(CASE WHEN p.total_active_ms > 0 THEN p.total_active_ms ELSE COALESCE(s.sa, 0) END), 0) AS active
|
|
38
|
+
COALESCE(SUM(CASE WHEN p.total_active_ms > 0 THEN p.total_active_ms ELSE COALESCE(s.sa, 0) END), 0) AS active,
|
|
39
|
+
COALESCE(SUM(p.human_hours), 0) AS humanHours
|
|
37
40
|
FROM pipelines p
|
|
38
41
|
LEFT JOIN (SELECT pipeline_id, SUM(cost_usd) sc, SUM(active_ms) sa
|
|
39
42
|
FROM pipeline_steps GROUP BY pipeline_id) s ON s.pipeline_id = p.id`;
|
|
@@ -46,6 +49,7 @@ function totalsRow(sql, isoA, isoB) {
|
|
|
46
49
|
prsOpened: row.prsOpened, prsMerged: row.prsMerged,
|
|
47
50
|
workedMs: Number(row.active || 0),
|
|
48
51
|
cohortSpendUsd: roundUsd(row.spend || 0),
|
|
52
|
+
humanHours: Math.round(Number(row.humanHours || 0) * 100) / 100,
|
|
49
53
|
};
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -97,6 +101,28 @@ function askTotals(fromMs, toMs) {
|
|
|
97
101
|
return { spendUsd: roundUsd(row?.s || 0), sessions: row?.sessions || 0, turns: row?.turns || 0 };
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
/** "Saved this month/week" for the sidebar spend indicator (money-saved design §10): human
|
|
105
|
+
* hours × rate − spent over the budget's own reset window. The same cohort (runs STARTED in
|
|
106
|
+
* [windowStartMs, windowEndMs), archived included), the same rate and the same spend as the
|
|
107
|
+
* Statistics "Saved" tile for the matching range, so the sidebar and the tile never disagree.
|
|
108
|
+
* A lean SUM rather than cohortTotals(): /api/budget is refetched on every tick while runs
|
|
109
|
+
* are live, and the pipeline_steps join TOTALS_SELECT carries is dead weight for one column.
|
|
110
|
+
* @param {{windowStartMs:number, windowEndMs:number, windowSpendUsd:number}} budget budgetStatus()
|
|
111
|
+
* @returns {{windowHumanHours:number, windowSavedUsd:number}} */
|
|
112
|
+
export function budgetWindowSavings(budget) {
|
|
113
|
+
const row = prepare(`
|
|
114
|
+
SELECT COALESCE(SUM(p.human_hours), 0) AS humanHours
|
|
115
|
+
FROM pipelines p
|
|
116
|
+
WHERE COALESCE(p.started_at, p.updated_at) >= ?
|
|
117
|
+
AND COALESCE(p.started_at, p.updated_at) < ?`)
|
|
118
|
+
.get(new Date(budget.windowStartMs).toISOString(), new Date(budget.windowEndMs).toISOString());
|
|
119
|
+
const windowHumanHours = Math.round(Number(row?.humanHours || 0) * 100) / 100;
|
|
120
|
+
return {
|
|
121
|
+
windowHumanHours,
|
|
122
|
+
windowSavedUsd: savedUsd(windowHumanHours, effectiveHumanRateUsd(), budget.windowSpendUsd),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
100
126
|
/** Build zero-filled buckets [{startMs, endMs}] from windowStart through `now`. */
|
|
101
127
|
function buildBuckets(windowStart, now, bucket) {
|
|
102
128
|
const out = [];
|
|
@@ -122,6 +148,7 @@ function buildBuckets(windowStart, now, bucket) {
|
|
|
122
148
|
export function getStats({ range = 'month', now = new Date() } = {}) {
|
|
123
149
|
if (!RANGES.includes(range)) throw new RangeError(`unknown stats range: ${range}`);
|
|
124
150
|
const budget = budgetStatus(now);
|
|
151
|
+
const rate = effectiveHumanRateUsd();
|
|
125
152
|
|
|
126
153
|
let windowStart, windowEnd, prevStart, prevEnd, bucket;
|
|
127
154
|
if (range === 'today') {
|
|
@@ -155,6 +182,8 @@ export function getStats({ range = 'month', now = new Date() } = {}) {
|
|
|
155
182
|
return {
|
|
156
183
|
spentUsd: roundUsd(pipelineSpendUsd + ask.spendUsd),
|
|
157
184
|
pipelineSpendUsd, ask,
|
|
185
|
+
humanHours: c.humanHours,
|
|
186
|
+
savedUsd: savedUsd(c.humanHours, rate, roundUsd(pipelineSpendUsd + ask.spendUsd)),
|
|
158
187
|
workedMs: c.workedMs,
|
|
159
188
|
runs: c.runs, finished: c.finished, stopped: c.stopped, failed: c.failed,
|
|
160
189
|
paused: c.paused, running: c.running,
|
|
@@ -170,6 +199,8 @@ export function getStats({ range = 'month', now = new Date() } = {}) {
|
|
|
170
199
|
totals = {
|
|
171
200
|
spentUsd: roundUsd(at.spendUsd + ask.spendUsd),
|
|
172
201
|
pipelineSpendUsd: at.spendUsd, ask,
|
|
202
|
+
humanHours: at.humanHours,
|
|
203
|
+
savedUsd: savedUsd(at.humanHours, rate, roundUsd(at.spendUsd + ask.spendUsd)),
|
|
173
204
|
workedMs: at.activeMs,
|
|
174
205
|
runs: c.runs, finished: c.finished, stopped: c.stopped, failed: c.failed,
|
|
175
206
|
paused: c.paused, running: c.running,
|
|
@@ -196,6 +227,6 @@ export function getStats({ range = 'month', now = new Date() } = {}) {
|
|
|
196
227
|
return {
|
|
197
228
|
range, bucket,
|
|
198
229
|
windowStartMs: windowStart.getTime(), windowEndMs: windowEnd.getTime(),
|
|
199
|
-
totals, prev, budget, series,
|
|
230
|
+
totals, prev, budget, series, humanRateUsd: rate,
|
|
200
231
|
};
|
|
201
232
|
}
|
|
@@ -23,6 +23,8 @@ import { registryPortsFn } from './graph/registry-ports.mjs';
|
|
|
23
23
|
import { validateGraph, formatIssue } from '../shared/graph/validate.mjs';
|
|
24
24
|
import { EFFORTS as EFFORT_LIST } from './model-env.mjs';
|
|
25
25
|
import { loadAgentRegistry } from './agent-registry.mjs';
|
|
26
|
+
import { loadScriptRegistry } from './script-registry.mjs';
|
|
27
|
+
import { normalizeCases, CASES_VERSION } from '../shared/graph/script-cases.mjs';
|
|
26
28
|
import { slugify } from './artifacts.mjs';
|
|
27
29
|
import { isValidSkillName, collectRequiredSkills, resolveSkill, pluginSkillDirs } from './skills.mjs';
|
|
28
30
|
import { normalizeProjectPath } from './projects.mjs';
|
|
@@ -396,6 +398,15 @@ async function buildExportSet({ workflowId, destination, projectDir, slug, inclu
|
|
|
396
398
|
// its consumes/produces (derived from ports + wires, §2). Flow cards (task/end/
|
|
397
399
|
// and/or/combine) are NOT dispatched — only agent nodes become steps.
|
|
398
400
|
const graph = await resolveGraph(destination === 'project' ? projectDir : null, workflowId, registry);
|
|
401
|
+
// D19: the exported skill tree simulates the graph in Claude Code; a child-process
|
|
402
|
+
// card has no rendering there yet. Refuse loudly rather than export a skill that
|
|
403
|
+
// silently skips the gate.
|
|
404
|
+
const scriptNodes = (graph.template.nodes || []).filter((n) => n && n.kind === 'script');
|
|
405
|
+
if (scriptNodes.length) {
|
|
406
|
+
const names = scriptNodes.map((n) => `${graph.nodes[n.id]?.meta?.displayName || n.key} (${n.id})`);
|
|
407
|
+
throw Object.assign(err(`export to Claude Code does not carry script cards yet: ${names.join(', ')}`, 'UNSUPPORTED'),
|
|
408
|
+
{ nodes: scriptNodes.map((n) => n.id) });
|
|
409
|
+
}
|
|
399
410
|
const manifest = buildGraphManifest(graph.template, graph.agentsByKey,
|
|
400
411
|
{ overlays: { nodes: graph.nodes, wires: graph.wires } });
|
|
401
412
|
const tnodeById = new Map((graph.template.nodes || []).map((n) => [n.id, n]));
|
|
@@ -431,6 +442,18 @@ async function buildExportSet({ workflowId, destination, projectDir, slug, inclu
|
|
|
431
442
|
const declared = Array.isArray(node.tools) ? node.tools : [];
|
|
432
443
|
const stripped = declared.filter((t) => STRIPPED_TOOLS.has(t) && t !== 'AskUserQuestion');
|
|
433
444
|
if (stripped.length) warnings.push(`node "${node.key}": dropped subagent-incompatible tool(s): ${stripped.join(', ')}`);
|
|
445
|
+
// Ask forms (spec §8) do not travel: a Claude Code subagent has no worca form
|
|
446
|
+
// host, so the exported agent falls back to generic questions — which it
|
|
447
|
+
// already does structurally (makeAgentMd emits frontmatter + the markdown BODY
|
|
448
|
+
// and never reads the sidecar the `ask` block lives in). Say so, or an author
|
|
449
|
+
// ships a workflow believing their form travelled with it. The PLUGIN export
|
|
450
|
+
// is different on purpose: it copies the sidecar byte-identically, because a
|
|
451
|
+
// plugin is re-imported into worca where forms work.
|
|
452
|
+
const formIds = Object.keys((registry[node.key] && registry[node.key].ask && registry[node.key].ask.forms) || {});
|
|
453
|
+
if (formIds.length) {
|
|
454
|
+
warnings.push(`node "${node.key}": ask form(s) ${formIds.join(', ')} are not exported — `
|
|
455
|
+
+ 'a console subagent has no worca form host and falls back to generic questions.');
|
|
456
|
+
}
|
|
434
457
|
}
|
|
435
458
|
}
|
|
436
459
|
|
|
@@ -1052,7 +1075,8 @@ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName,
|
|
|
1052
1075
|
// ── The stored graph must be runnable HERE before it is shared: a stranded
|
|
1053
1076
|
// key (deleted agent) would only surface at the recipient's link.
|
|
1054
1077
|
const registry = loadAgentRegistry();
|
|
1055
|
-
const
|
|
1078
|
+
const scripts = loadScriptRegistry({ agentKeys: Object.keys(registry) });
|
|
1079
|
+
const { errors } = validateGraph({ ...payload, id: tpl.id }, registryPortsFn(registry, scripts));
|
|
1056
1080
|
if (errors.length) {
|
|
1057
1081
|
const summary = summarizeUnknownAgents(errors);
|
|
1058
1082
|
throw Object.assign(
|
|
@@ -1081,6 +1105,27 @@ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName,
|
|
|
1081
1105
|
}
|
|
1082
1106
|
if (builtins.length) warnings.push(`built-in agent(s) not bundled (present on every Worca host): ${builtins.join(', ')}`);
|
|
1083
1107
|
|
|
1108
|
+
// ── Scripts: user-owned are bundled, built-ins never, another plugin's
|
|
1109
|
+
// refused — the same ownership rule the agents block above applies (§8.3).
|
|
1110
|
+
const scriptKeys = [...new Set(payload.nodes.filter((n) => n && n.kind === 'script' && n.key).map((n) => n.key))].sort();
|
|
1111
|
+
const userScripts = [];
|
|
1112
|
+
const foreignScripts = [];
|
|
1113
|
+
const builtinScripts = [];
|
|
1114
|
+
for (const key of scriptKeys) {
|
|
1115
|
+
const meta = scripts[key];
|
|
1116
|
+
if (!meta) continue; // validateGraph refused above
|
|
1117
|
+
const origin = String(meta.origin || '');
|
|
1118
|
+
if (origin === 'builtin') builtinScripts.push(key);
|
|
1119
|
+
else if (origin.startsWith('plugin:')) foreignScripts.push({ key, plugin: origin.slice('plugin:'.length) });
|
|
1120
|
+
else userScripts.push({ key, meta });
|
|
1121
|
+
}
|
|
1122
|
+
if (foreignScripts.length) {
|
|
1123
|
+
const list = foreignScripts.map((f) => `"${f.key}" (owned by plugin "${f.plugin}")`).join(', ');
|
|
1124
|
+
throw err(`cannot bundle ${list} — a shared workflow bundles only your own scripts; `
|
|
1125
|
+
+ 'the recipient installs that plugin alongside, or you duplicate the script under your own name', 'UNSUPPORTED');
|
|
1126
|
+
}
|
|
1127
|
+
if (builtinScripts.length) warnings.push(`built-in script(s) not bundled (present on every worca host): ${builtinScripts.join(', ')}`);
|
|
1128
|
+
|
|
1084
1129
|
// ── Skills the bundled agents require: filled from global/project/other-plugin
|
|
1085
1130
|
// sources; a Worca-shipped (bundle) skill is skipped, like a built-in agent.
|
|
1086
1131
|
const depSkills = resolveDepSkills(registry, userAgents.map((a) => a.key), dir, 'plugin', null, repoRoot,
|
|
@@ -1100,6 +1145,51 @@ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName,
|
|
|
1100
1145
|
targets.push({ path: join(dir, 'agents', `${key}.md`), text: await readFile(mdPath, 'utf8') });
|
|
1101
1146
|
targets.push({ path: join(dir, 'agents', `${key}.meta.json`), text: await readFile(sidecarPath, 'utf8') });
|
|
1102
1147
|
}
|
|
1148
|
+
// Scripts ride like agents: byte-identical sidecar + program. EVERY platform
|
|
1149
|
+
// entry of `file` travels, host platform irrelevant — the recipient may be on
|
|
1150
|
+
// Windows and need the .cmd half (spec §10).
|
|
1151
|
+
const bundledScripts = [];
|
|
1152
|
+
for (const { key, meta } of userScripts) {
|
|
1153
|
+
const srcDir = meta.scriptsDir || (meta.scriptPath ? dirname(meta.scriptPath) : null);
|
|
1154
|
+
const sidecar = srcDir ? join(srcDir, `${key}.meta.json`) : null;
|
|
1155
|
+
if (!sidecar || !existsSync(sidecar)) throw err(`script sidecar not found for "${key}" (${sidecar || key})`, 'NOT_FOUND');
|
|
1156
|
+
targets.push({ path: join(dir, 'scripts', `${key}.meta.json`), text: await readFile(sidecar, 'utf8') });
|
|
1157
|
+
const rels = [...new Set((typeof meta.file === 'string' ? [meta.file] : Object.values(meta.file || {})).filter(Boolean))];
|
|
1158
|
+
for (const rel of rels) {
|
|
1159
|
+
const from = join(srcDir, rel);
|
|
1160
|
+
if (!existsSync(from)) throw err(`script source not found for "${key}" (${from})`, 'NOT_FOUND');
|
|
1161
|
+
targets.push({ path: join(dir, 'scripts', rel), text: await readFile(from, 'utf8') });
|
|
1162
|
+
}
|
|
1163
|
+
// Cases travel too, minus any that name a project folder: a scratch case is
|
|
1164
|
+
// the only kind the recipient can run (W9, §8.3).
|
|
1165
|
+
const casesPath = join(srcDir, `${key}.tests.json`);
|
|
1166
|
+
const casesTarget = join(dir, 'scripts', `${key}.tests.json`);
|
|
1167
|
+
let scratchCases = [];
|
|
1168
|
+
if (existsSync(casesPath)) {
|
|
1169
|
+
let raw = null;
|
|
1170
|
+
try { raw = JSON.parse(await readFile(casesPath, 'utf8')); }
|
|
1171
|
+
catch (e) { warnings.push(`script "${key}" cases: ${e.message}`); }
|
|
1172
|
+
const { cases, errors } = normalizeCases(raw, meta);
|
|
1173
|
+
for (const e of errors) warnings.push(`script "${key}" cases: ${e}`);
|
|
1174
|
+
scratchCases = cases.filter((k) => k.cwd && k.cwd.kind === 'scratch');
|
|
1175
|
+
const dropped = cases.length - scratchCases.length;
|
|
1176
|
+
if (dropped) {
|
|
1177
|
+
warnings.push(`script "${key}": ${dropped} test case(s) that run in a project folder were not bundled `
|
|
1178
|
+
+ '— a shared case runs in a scratch folder');
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
// The shipped set FOLLOWS the author's: a file an earlier export wrote is
|
|
1182
|
+
// overwritten even with the empty set (the exporter never deletes), so a case
|
|
1183
|
+
// the author removed — or one a port edit made invalid, which would fail the
|
|
1184
|
+
// folder's own validation — does not live on in the plugin.
|
|
1185
|
+
if (scratchCases.length || existsSync(casesTarget)) {
|
|
1186
|
+
targets.push({
|
|
1187
|
+
path: casesTarget,
|
|
1188
|
+
text: JSON.stringify({ version: CASES_VERSION, cases: scratchCases }, null, 2) + '\n',
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
bundledScripts.push({ key, runtime: meta.runtime, files: [`${key}.meta.json`, ...rels] });
|
|
1192
|
+
}
|
|
1103
1193
|
for (const s of depSkills) {
|
|
1104
1194
|
const skillMd = join(dir, 'skills', s.skill, 'SKILL.md');
|
|
1105
1195
|
if (s.skipped) skipped.push({ path: skillMd, reason: `skill "${s.skill}" ships with Worca (${s.resolvedFrom}) — not bundled` });
|
|
@@ -1124,7 +1214,7 @@ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName,
|
|
|
1124
1214
|
const manifest = existing ? { ...existing } : {
|
|
1125
1215
|
name, version: '0.1.0',
|
|
1126
1216
|
description: `Workflows shared from Worca — ${tpl.name}`,
|
|
1127
|
-
engines: { 'worca-cc-api': '>=
|
|
1217
|
+
engines: { 'worca-cc-api': '>=4 <5' },
|
|
1128
1218
|
};
|
|
1129
1219
|
if (!manifest.name) manifest.name = name;
|
|
1130
1220
|
let version = typeof manifest.version === 'string' ? manifest.version : '';
|
|
@@ -1149,7 +1239,8 @@ export async function exportWorkflowPlugin({ workflowId, targetDir, pluginName,
|
|
|
1149
1239
|
|
|
1150
1240
|
const plan = {
|
|
1151
1241
|
dir, name, slug, version: manifest.version || null,
|
|
1152
|
-
created, updated, noop, skipped, conflicts: [], warnings, orphans: [], written: [],
|
|
1242
|
+
created, updated, noop, skipped, conflicts: [], warnings, orphans: [], written: [],
|
|
1243
|
+
scripts: bundledScripts, validation: null,
|
|
1153
1244
|
};
|
|
1154
1245
|
if (dryRun) return plan;
|
|
1155
1246
|
|
|
@@ -9,17 +9,21 @@
|
|
|
9
9
|
// exportGraphJson(id) -> { version:2, name, domain, nodes, wires, canvas? }
|
|
10
10
|
// saveGraphWorkflow(body) -> composer semantics: keeps a legal `id`, 409 on a minted collision
|
|
11
11
|
// importGraphWorkflow(body, opts) -> import semantics: id/origin ignored, name suffixed on collision
|
|
12
|
+
// listScriptNodes(graph, scripts) -> the command/code values an import must show first (D18)
|
|
13
|
+
// formatScriptNodes(list) -> SCRIPT_IMPORT_NOTICE + those values, for the CLI
|
|
12
14
|
// nodeDefaultsError(raw, models, at) -> '' | reason (per-node tunables vs the project-less catalog)
|
|
13
15
|
//
|
|
14
16
|
// Errors carry `.code` so surfaces map them to HTTP / exit codes:
|
|
15
17
|
// BAD_REQUEST | INVALID_GRAPH (+ .errors/.warnings/.summary) | RESERVED_NAME |
|
|
16
|
-
// ID_TAKEN (+ .id) | NOT_FOUND | UNSUPPORTED
|
|
18
|
+
// ID_TAKEN (+ .id) | NOT_FOUND | UNSUPPORTED | SCRIPTS_UNCONFIRMED (+ .scriptNodes)
|
|
17
19
|
|
|
18
20
|
import { listModels } from './config.mjs';
|
|
19
21
|
import { EFFORTS, subagentModelIssue } from './model-env.mjs';
|
|
20
22
|
import { loadAgentRegistry, DEFAULT_AGENTS_DIR } from './agent-registry.mjs';
|
|
23
|
+
import { loadScriptRegistry } from './script-registry.mjs';
|
|
21
24
|
import { registryPortsFn } from './graph/registry-ports.mjs';
|
|
22
25
|
import { validateGraph, AGENT_TUNABLES } from '../shared/graph/validate.mjs';
|
|
26
|
+
import { effectiveScriptParams, CONFIRM_PARAM_TYPES } from '../shared/graph/script-meta.mjs';
|
|
23
27
|
import { readWorkflow, writeGraphWorkflow } from './workflows.mjs';
|
|
24
28
|
|
|
25
29
|
/** How many `Name (n)` retries an import makes before giving up. */
|
|
@@ -27,6 +31,39 @@ export const SUFFIX_CAP = 20;
|
|
|
27
31
|
|
|
28
32
|
function err(message, code, extra = {}) { return Object.assign(new Error(message), { code, ...extra }); }
|
|
29
33
|
|
|
34
|
+
/** D18: the one sentence the CLI prints and the Import dialog shows above the commands. */
|
|
35
|
+
export const SCRIPT_IMPORT_NOTICE = "These commands run on this machine with worca's privileges when the workflow runs.";
|
|
36
|
+
|
|
37
|
+
/** The script nodes an import must show first (D18): every node whose EFFECTIVE
|
|
38
|
+
* params include a command- or code-typed value, with those values. */
|
|
39
|
+
export function listScriptNodes(graph, scripts) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const n of Array.isArray(graph?.nodes) ? graph.nodes : []) {
|
|
42
|
+
if (!n || n.kind !== 'script' || !n.key) continue;
|
|
43
|
+
const meta = scripts?.[n.key];
|
|
44
|
+
if (!meta) continue; // V4 reports it; nothing to show
|
|
45
|
+
const values = effectiveScriptParams(meta, n.config);
|
|
46
|
+
const params = {};
|
|
47
|
+
for (const p of meta.params || []) {
|
|
48
|
+
if (CONFIRM_PARAM_TYPES.includes(p.type) && typeof values[p.id] === 'string') params[p.id] = values[p.id];
|
|
49
|
+
}
|
|
50
|
+
if (Object.keys(params).length) out.push({ nodeId: n.id, key: n.key, displayName: meta.displayName || n.key, runtime: meta.runtime, params });
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The CLI block: the notice, then one indented value per command/code param. */
|
|
56
|
+
export function formatScriptNodes(list) {
|
|
57
|
+
const lines = [SCRIPT_IMPORT_NOTICE];
|
|
58
|
+
for (const n of list) {
|
|
59
|
+
for (const [id, value] of Object.entries(n.params)) {
|
|
60
|
+
lines.push(`- ${n.displayName} (${n.nodeId}, ${n.runtime}) ${id}:`);
|
|
61
|
+
for (const l of String(value).split('\n')) lines.push(` ${l}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return lines.join('\n') + '\n';
|
|
65
|
+
}
|
|
66
|
+
|
|
30
67
|
/**
|
|
31
68
|
* Validate one node-defaults block against the project-less catalog. Returns an
|
|
32
69
|
* error message, or '' when the block is acceptable. Mirrors setStep's rules so a
|
|
@@ -120,8 +157,8 @@ function prepareGraph(body, { keepId, name }) {
|
|
|
120
157
|
return graph;
|
|
121
158
|
}
|
|
122
159
|
|
|
123
|
-
/** Catalog + structural validation. Throws BAD_REQUEST / INVALID_GRAPH; returns warnings. */
|
|
124
|
-
async function validateForSave(graph, { registry, agentsDir }) {
|
|
160
|
+
/** Catalog + structural validation. Throws BAD_REQUEST / INVALID_GRAPH; returns the warnings and the script registry it used. */
|
|
161
|
+
async function validateForSave(graph, { registry, scripts, agentsDir }) {
|
|
125
162
|
// Catalog validation FIRST: a v2 node's `config` IS its defaults block (§4),
|
|
126
163
|
// so a value the per-project override could not name must not ride in
|
|
127
164
|
// through a template save. Only AGENT_TUNABLES are handed to nodeDefaultsError —
|
|
@@ -133,15 +170,14 @@ async function validateForSave(graph, { registry, agentsDir }) {
|
|
|
133
170
|
const bad = nodeDefaultsError(picked, models, `node "${n.id}"`);
|
|
134
171
|
if (bad) throw err(bad, 'BAD_REQUEST');
|
|
135
172
|
}
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
return warnings;
|
|
173
|
+
const reg = registry || loadAgentRegistry(agentsDir || DEFAULT_AGENTS_DIR);
|
|
174
|
+
const scr = scripts && typeof scripts === 'object' ? scripts : loadScriptRegistry({ agentKeys: Object.keys(reg) });
|
|
175
|
+
const { errors, warnings } = validateGraph({ ...graph, version: 2 }, registryPortsFn(reg, scr));
|
|
176
|
+
// The message stays the composer's verbatim 'invalid graph' (app.js renders
|
|
177
|
+
// the issue list); `summary` is the one-line V4 fold for the CLI and the
|
|
178
|
+
// import button, which have no issue list to show.
|
|
179
|
+
if (errors.length) throw err('invalid graph', 'INVALID_GRAPH', { errors, warnings, summary: summarizeUnknownAgents(errors) });
|
|
180
|
+
return { warnings, scripts: scr };
|
|
145
181
|
}
|
|
146
182
|
|
|
147
183
|
/**
|
|
@@ -152,7 +188,7 @@ async function validateForSave(graph, { registry, agentsDir }) {
|
|
|
152
188
|
*/
|
|
153
189
|
export async function saveGraphWorkflow(body, { registry, agentsDir } = {}) {
|
|
154
190
|
const graph = prepareGraph(body || {}, { keepId: true });
|
|
155
|
-
const warnings = await validateForSave(graph, { registry, agentsDir });
|
|
191
|
+
const { warnings } = await validateForSave(graph, { registry, agentsDir });
|
|
156
192
|
const workflow = await writeGraphWorkflow(graph, { rejectCollision: true });
|
|
157
193
|
return { workflow, warnings };
|
|
158
194
|
}
|
|
@@ -164,13 +200,26 @@ const SUFFIX_RE = /\s\((\d+)\)$/;
|
|
|
164
200
|
* importer mints), `name` overrides the file's name, and a collision with a live
|
|
165
201
|
* row retries as `Name (2)`, `Name (3)`… up to SUFFIX_CAP — never overwrite.
|
|
166
202
|
* @param {object} body the exportGraphJson shape
|
|
167
|
-
* @param {{name?: string, registry?: object, agentsDir?: string}} [opts]
|
|
168
|
-
*
|
|
203
|
+
* @param {{name?: string, registry?: object, scripts?: object, agentsDir?: string, dryRun?: boolean, acceptScripts?: boolean}} [opts]
|
|
204
|
+
* dryRun: validate and list the script nodes (D18) WITHOUT writing — the Import
|
|
205
|
+
* dialog and the CLI confirm from this before the real import.
|
|
206
|
+
* acceptScripts: the caller SHOWED the user every command/code value and the user
|
|
207
|
+
* agreed. Anything but the literal `true` refuses a graph that carries one (P10):
|
|
208
|
+
* the default is the safe one, so a caller that never heard of scripts cannot
|
|
209
|
+
* import a command by accident.
|
|
210
|
+
* @returns {Promise<{workflow: object|null, warnings: Array, requestedName: string, renamed: boolean, scriptNodes: Array}>}
|
|
211
|
+
* @throws code 'SCRIPTS_UNCONFIRMED' (`.scriptNodes`) — nothing was written
|
|
169
212
|
*/
|
|
170
|
-
export async function importGraphWorkflow(body, { name, registry, agentsDir } = {}) {
|
|
213
|
+
export async function importGraphWorkflow(body, { name, registry, scripts, agentsDir, dryRun = false, acceptScripts = false } = {}) {
|
|
171
214
|
const graph = prepareGraph(body || {}, { keepId: false, name });
|
|
172
|
-
const warnings = await validateForSave(graph, { registry, agentsDir });
|
|
215
|
+
const { warnings, scripts: scr } = await validateForSave(graph, { registry, scripts, agentsDir });
|
|
216
|
+
const scriptNodes = listScriptNodes(graph, scr);
|
|
173
217
|
const requestedName = graph.name;
|
|
218
|
+
if (dryRun) return { workflow: null, warnings, requestedName, renamed: false, scriptNodes };
|
|
219
|
+
if (scriptNodes.length && acceptScripts !== true) {
|
|
220
|
+
throw err(`this workflow runs commands on this machine (${scriptNodes.map((n) => `${n.displayName} · ${n.nodeId}`).join(', ')}) — review them, then import again with the scripts accepted`,
|
|
221
|
+
'SCRIPTS_UNCONFIRMED', { scriptNodes });
|
|
222
|
+
}
|
|
174
223
|
const m = SUFFIX_RE.exec(requestedName);
|
|
175
224
|
const base = m ? requestedName.slice(0, m.index) : requestedName;
|
|
176
225
|
let n = m ? Number(m[1]) : 1;
|
|
@@ -178,7 +227,7 @@ export async function importGraphWorkflow(body, { name, registry, agentsDir } =
|
|
|
178
227
|
for (let attempt = 0; attempt <= SUFFIX_CAP; attempt++) {
|
|
179
228
|
try {
|
|
180
229
|
const workflow = await writeGraphWorkflow({ ...graph, name: candidate }, { rejectCollision: true });
|
|
181
|
-
return { workflow, warnings, requestedName, renamed: candidate !== requestedName };
|
|
230
|
+
return { workflow, warnings, requestedName, renamed: candidate !== requestedName, scriptNodes };
|
|
182
231
|
} catch (e) {
|
|
183
232
|
if (e?.code === 'RESERVED_NAME') {
|
|
184
233
|
throw err(`${e.message} (pass a different name to import it)`, 'RESERVED_NAME');
|
package/src/core/workflows.mjs
CHANGED
|
@@ -22,9 +22,13 @@ import { isSubagentModelValue } from './model-env.mjs';
|
|
|
22
22
|
const validSubagentModel = (v) => (isSubagentModelValue(v) ? v : undefined);
|
|
23
23
|
import { slugify } from './artifacts.mjs';
|
|
24
24
|
import { DEFAULT_AGENTS_DIR, loadAgentRegistry } from './agent-registry.mjs'; // fileURLToPath-based (Windows-safe)
|
|
25
|
+
import { loadScriptRegistry } from './script-registry.mjs';
|
|
25
26
|
import { readPluginsLock } from './plugins-lock.mjs'; // a DISABLED plugin's rows are hidden
|
|
27
|
+
import { teamDefault } from './policy/cache.mjs'; // team-policy models.steps defaults
|
|
26
28
|
import { validateGraph, formatIssue, AGENT_TUNABLES } from '../shared/graph/validate.mjs';
|
|
27
29
|
import { classifyLoops } from '../shared/graph/loops.mjs';
|
|
30
|
+
import { KEYED_KINDS } from '../shared/graph/constants.mjs';
|
|
31
|
+
import { scriptNodeCtx } from '../shared/graph/script-meta.mjs';
|
|
28
32
|
import {
|
|
29
33
|
GRAPH_DEFAULT_WORKFLOW, AUTO_WORKFLOW_ID, AUTO_WORKFLOW_NAME, AUTO_WORKFLOW_STUB,
|
|
30
34
|
GRAPH_MEMORY_DEFRAG_WORKFLOW, MEMORY_DEFRAG_WORKFLOW_ID, MEMORY_DEFRAG_WORKFLOW_NAME, isReservedWorkflowId,
|
|
@@ -409,11 +413,13 @@ export async function listWorkflows({ includeArchived = false, includeDisabled =
|
|
|
409
413
|
* and must never refuse a run.
|
|
410
414
|
* @param {object} tpl a version-2 template
|
|
411
415
|
* @param {Record<string,object>} [registry] injected registry; the live one otherwise
|
|
416
|
+
* @param {Record<string,object>} [scripts] injected script registry; the live one otherwise
|
|
412
417
|
* @throws {Error} code 'INVALID_GRAPH', `issues` = the validator's error list
|
|
413
418
|
*/
|
|
414
|
-
function assertValidGraph(tpl, registry) {
|
|
419
|
+
function assertValidGraph(tpl, registry, scripts) {
|
|
415
420
|
const reg = registry && typeof registry === 'object' ? registry : loadAgentRegistry();
|
|
416
|
-
const
|
|
421
|
+
const scr = scripts && typeof scripts === 'object' ? scripts : loadScriptRegistry({ agentKeys: Object.keys(reg) });
|
|
422
|
+
const { ok, errors } = validateGraph(tpl, registryPortsFn(reg, scr));
|
|
417
423
|
if (ok) return;
|
|
418
424
|
throw Object.assign(
|
|
419
425
|
new Error(`workflow "${tpl.id}" no longer matches the agents it uses: `
|
|
@@ -428,9 +434,9 @@ function assertValidGraph(tpl, registry) {
|
|
|
428
434
|
* `checkGraph:false` is the READ escape hatch (GET /api/workflows/:id): a
|
|
429
435
|
* stale template must still open in the Composer, or it can never be repaired.
|
|
430
436
|
* @param {string} id
|
|
431
|
-
* @param {{registry?:Record<string,object>, checkGraph?:boolean}} [opts]
|
|
437
|
+
* @param {{registry?:Record<string,object>, scripts?:Record<string,object>, checkGraph?:boolean}} [opts]
|
|
432
438
|
*/
|
|
433
|
-
export async function assertRunnableWorkflow(id, { registry, checkGraph = true } = {}) {
|
|
439
|
+
export async function assertRunnableWorkflow(id, { registry, scripts, checkGraph = true } = {}) {
|
|
434
440
|
const wanted = typeof id === 'string' && id.trim() ? id.trim() : GRAPH_DEFAULT_WORKFLOW.id;
|
|
435
441
|
// The Auto entry has no graph to check: the run decides one (spec §5.1).
|
|
436
442
|
if (wanted === AUTO_WORKFLOW_ID) return AUTO_WORKFLOW_STUB;
|
|
@@ -445,7 +451,7 @@ export async function assertRunnableWorkflow(id, { registry, checkGraph = true }
|
|
|
445
451
|
+ `enable the plugin (Plugins view, or: worca plugin enable ${plugin}) to use it`), { code: 'PLUGIN_DISABLED' });
|
|
446
452
|
}
|
|
447
453
|
// A v1 row is not a graph: engine-select owns its refusal (V1_RUN_RETIRED).
|
|
448
|
-
if (checkGraph && live.version === 2) assertValidGraph(live, registry);
|
|
454
|
+
if (checkGraph && live.version === 2) assertValidGraph(live, registry, scripts);
|
|
449
455
|
return live;
|
|
450
456
|
}
|
|
451
457
|
const archived = await readWorkflow(wanted, { includeArchived: true });
|
|
@@ -603,6 +609,7 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
|
|
|
603
609
|
// constant) whose agent nodes carry the RESOLVED key after workspace substitution.
|
|
604
610
|
const tpl = structuredClone(stored);
|
|
605
611
|
const reg = registry && typeof registry === 'object' ? registry : {};
|
|
612
|
+
const scripts = opts.scripts && typeof opts.scripts === 'object' ? opts.scripts : loadScriptRegistry({ agentKeys: Object.keys(reg) });
|
|
606
613
|
const isWorkspace = !!opts.isWorkspace;
|
|
607
614
|
const variants = isWorkspace ? workspaceVariants(reg) : {};
|
|
608
615
|
// Per-project overlays. An Auto run owns its tuning (spec D7/D9): with
|
|
@@ -627,11 +634,39 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
|
|
|
627
634
|
// store (projectKey(null) throws); its legacy per-role layer is empty by definition.
|
|
628
635
|
const stepsCfg = (!ignore && workflowId === GRAPH_DEFAULT_WORKFLOW.id && projectDir) ? (await readConfig(projectDir)).steps : {};
|
|
629
636
|
const firstDefined = (...vals) => vals.find((v) => v !== undefined);
|
|
637
|
+
// Team policy `models.steps` (a default, team-policy design §8): model and effort per ROLE for
|
|
638
|
+
// roles the project has not configured — below the project's own node and per-role config,
|
|
639
|
+
// above the template's authored config. Cache-only read; absent without a policy.
|
|
640
|
+
const teamSteps = (!ignore && projectDir) ? (teamDefault(projectDir, 'models.steps') || {}) : {};
|
|
641
|
+
// Memory defragment (Settings › Memory, memory-defrag-model.mjs): the run's model/effort PAIR —
|
|
642
|
+
// named at start, else the global setting. The one layer that beats even the project's own node
|
|
643
|
+
// pick (the setting is global by design: no per-project override), and it carries its own
|
|
644
|
+
// effort, so no lower layer's effort rides under a model it was not chosen for. Absent (every
|
|
645
|
+
// other caller, and a defragment run with nothing named or stored) → the layers below, unchanged.
|
|
646
|
+
const pair = opts.agentPair && typeof opts.agentPair.model === 'string' && opts.agentPair.model.trim()
|
|
647
|
+
? {
|
|
648
|
+
model: opts.agentPair.model.trim(),
|
|
649
|
+
effort: typeof opts.agentPair.effort === 'string' && opts.agentPair.effort ? opts.agentPair.effort : undefined,
|
|
650
|
+
}
|
|
651
|
+
: null;
|
|
630
652
|
|
|
631
653
|
const nodes = {};
|
|
632
654
|
const agentsByKey = {};
|
|
633
655
|
const agentKeys = new Set();
|
|
656
|
+
const scriptsByKey = {};
|
|
657
|
+
const scriptKeys = new Set();
|
|
634
658
|
for (const node of Array.isArray(tpl.nodes) ? tpl.nodes : []) {
|
|
659
|
+
if (node.kind === 'script') {
|
|
660
|
+
const meta = scripts[node.key];
|
|
661
|
+
if (!meta) throw new Error(`unknown script "${node.key}" — no such key in the registry`);
|
|
662
|
+
if (meta.placeable === false) throw new Error(`script "${node.key}" declares placeable: false and cannot be a graph node`);
|
|
663
|
+
// v4 T5: ONE builder for the run-time facts of a placed card — the resume path (Task 9), the offline runner
|
|
664
|
+
// (Task 13) and P1c's bench build the same object from the same function, so they cannot drift apart.
|
|
665
|
+
nodes[node.id] = scriptNodeCtx(node, meta);
|
|
666
|
+
scriptsByKey[node.key] = meta;
|
|
667
|
+
scriptKeys.add(node.key);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
635
670
|
if (node.kind !== 'agent') {
|
|
636
671
|
nodes[node.id] = { nodeId: node.id, kind: node.kind, key: null, config: { ...(node.config || {}) } };
|
|
637
672
|
continue;
|
|
@@ -653,6 +688,7 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
|
|
|
653
688
|
// Legacy per-role config is keyed by the AUTHORED key, so a substituted
|
|
654
689
|
// variant still inherits the user's model/effort for that role.
|
|
655
690
|
const legacy = stepsCfg[authored] || {};
|
|
691
|
+
const team = (sel.model || sel.effort || legacy.model || legacy.effort) ? {} : (teamSteps[authored] || {});
|
|
656
692
|
const cfg = node.config && typeof node.config === 'object' ? node.config : {};
|
|
657
693
|
nodes[node.id] = {
|
|
658
694
|
nodeId: node.id,
|
|
@@ -666,10 +702,10 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
|
|
|
666
702
|
promptHints: typeof meta.promptHints === 'string' ? meta.promptHints : '',
|
|
667
703
|
tools,
|
|
668
704
|
config: { ...cfg },
|
|
669
|
-
model: firstDefined(sel.model, legacy.model, cfg.model),
|
|
705
|
+
model: pair ? pair.model : firstDefined(sel.model, legacy.model, team.model, cfg.model),
|
|
670
706
|
// An effort only travels with the model that advertises it: an override
|
|
671
707
|
// naming its own model must not inherit the lower layer's effort.
|
|
672
|
-
effort: firstDefined(sel.effort, legacy.effort, (sel.model || legacy.model) ? undefined : cfg.effort),
|
|
708
|
+
effort: pair ? pair.effort : firstDefined(sel.effort, legacy.effort, team.effort, (sel.model || legacy.model || team.model) ? undefined : cfg.effort),
|
|
673
709
|
// workspaceFanOut forces fan-out on a workspace run (the generic
|
|
674
710
|
// replacement for the v1 FANOUT_ELIGIBLE key list).
|
|
675
711
|
fanOut: isWorkspace && meta.workspaceFanOut
|
|
@@ -699,13 +735,13 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
|
|
|
699
735
|
|
|
700
736
|
// Two nodes sharing one agent key prefix their run-store outputs (executor dupPrefix).
|
|
701
737
|
const keyCount = new Map();
|
|
702
|
-
for (const nc of Object.values(nodes)) if (nc.kind
|
|
703
|
-
for (const nc of Object.values(nodes)) if (nc.kind
|
|
738
|
+
for (const nc of Object.values(nodes)) if (KEYED_KINDS.includes(nc.kind)) keyCount.set(nc.key, (keyCount.get(nc.key) || 0) + 1);
|
|
739
|
+
for (const nc of Object.values(nodes)) if (KEYED_KINDS.includes(nc.kind)) nc.duplicateKey = (keyCount.get(nc.key) || 0) > 1;
|
|
704
740
|
|
|
705
741
|
// Budgets ride LOOP wires only: overlay > authored > DEFAULT_MAX_CYCLES.
|
|
706
742
|
// portsFn + loops are computed ONCE here and RETURNED — P4 hands them to the
|
|
707
743
|
// scheduler and the manifest builder instead of re-deriving them.
|
|
708
|
-
const portsFn = registryPortsFn(agentsByKey);
|
|
744
|
+
const portsFn = registryPortsFn(agentsByKey, scriptsByKey);
|
|
709
745
|
const loops = classifyLoops(tpl, portsFn);
|
|
710
746
|
const { loopWireIds } = loops;
|
|
711
747
|
const wires = {};
|
|
@@ -714,5 +750,5 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
|
|
|
714
750
|
const raw = Number(wireCfg[w.id]?.maxCycles ?? w.config?.maxCycles);
|
|
715
751
|
wires[w.id] = { maxCycles: Number.isInteger(raw) && raw >= 1 ? raw : DEFAULT_MAX_CYCLES };
|
|
716
752
|
}
|
|
717
|
-
return { template: tpl, ports: portsFn, loops, nodes, wires, agentsByKey, agentKeys };
|
|
753
|
+
return { template: tpl, ports: portsFn, loops, nodes, wires, agentsByKey, agentKeys, scriptsByKey, scriptKeys };
|
|
718
754
|
}
|
package/src/core/workspaces.mjs
CHANGED
|
@@ -42,14 +42,14 @@ function err(message, code) { return Object.assign(new Error(message), { code })
|
|
|
42
42
|
* @returns {string|null}
|
|
43
43
|
* @throws err(code: BAD_REQUEST)
|
|
44
44
|
*/
|
|
45
|
-
function memberPathFor(paths, candidate) {
|
|
45
|
+
function memberPathFor(paths, candidate, field = 'metricsProject') {
|
|
46
46
|
if (candidate == null || candidate === '') return null;
|
|
47
|
-
if (typeof candidate !== 'string') throw err(
|
|
47
|
+
if (typeof candidate !== 'string') throw err(`${field} must be a project path or null`, 'BAD_REQUEST');
|
|
48
48
|
const want = normalizeProjectPath(candidate);
|
|
49
49
|
// normalizeProjectPath(' ') -> null; canonicalProjectRoot(null) would throw ERR_INVALID_ARG_TYPE -> 500.
|
|
50
|
-
if (!want) throw err(
|
|
50
|
+
if (!want) throw err(`${field} must be a project path or null`, 'BAD_REQUEST');
|
|
51
51
|
const hit = paths.find((p) => p === want || canonicalProjectRoot(p) === canonicalProjectRoot(want));
|
|
52
|
-
if (!hit) throw err(
|
|
52
|
+
if (!hit) throw err(`${field} must be one of the workspace projects`, 'BAD_REQUEST');
|
|
53
53
|
return hit;
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -130,6 +130,7 @@ function annotate(entry) {
|
|
|
130
130
|
projectKeys: pairs.map((x) => x.key),
|
|
131
131
|
exists: pairs.map((x) => isDir(x.path)),
|
|
132
132
|
metricsProject: entry.metricsProject ?? null,
|
|
133
|
+
policyProject: entry.policyProject ?? null,
|
|
133
134
|
createdAt: entry.createdAt,
|
|
134
135
|
updatedAt: entry.updatedAt,
|
|
135
136
|
};
|
|
@@ -150,6 +151,7 @@ function rowToEntry(r) {
|
|
|
150
151
|
description: typeof r.description === 'string' ? r.description : '',
|
|
151
152
|
projectPaths: memberPaths(r.id),
|
|
152
153
|
metricsProject: r.metrics_project ?? null,
|
|
154
|
+
policyProject: r.policy_project ?? null,
|
|
153
155
|
createdAt: typeof r.created_at === 'string' ? r.created_at : '',
|
|
154
156
|
updatedAt: typeof r.updated_at === 'string' ? r.updated_at : '',
|
|
155
157
|
};
|
|
@@ -159,7 +161,7 @@ function rowToEntry(r) {
|
|
|
159
161
|
function readEntry(id) {
|
|
160
162
|
getDb();
|
|
161
163
|
const r = prepare(
|
|
162
|
-
'SELECT id, name, description, metrics_project, created_at, updated_at FROM workspaces WHERE id = ?'
|
|
164
|
+
'SELECT id, name, description, metrics_project, policy_project, created_at, updated_at FROM workspaces WHERE id = ?'
|
|
163
165
|
).get(id);
|
|
164
166
|
return r ? rowToEntry(r) : null;
|
|
165
167
|
}
|
|
@@ -171,7 +173,7 @@ function readEntry(id) {
|
|
|
171
173
|
export async function listWorkspaces() {
|
|
172
174
|
getDb();
|
|
173
175
|
const rows = prepare(
|
|
174
|
-
'SELECT id, name, description, metrics_project, created_at, updated_at FROM workspaces ORDER BY created_at, name'
|
|
176
|
+
'SELECT id, name, description, metrics_project, policy_project, created_at, updated_at FROM workspaces ORDER BY created_at, name'
|
|
175
177
|
).all();
|
|
176
178
|
return rows.map(rowToEntry).map(annotate);
|
|
177
179
|
}
|
|
@@ -242,6 +244,7 @@ export async function createWorkspace(input = {}) {
|
|
|
242
244
|
}
|
|
243
245
|
|
|
244
246
|
const metricsProject = memberPathFor(members, input.metricsProject ?? null);
|
|
247
|
+
const policyProject = memberPathFor(members, input.policyProject ?? null, 'policyProject');
|
|
245
248
|
const id = workspaceKey({ name, projectPaths: members });
|
|
246
249
|
const hash = rootsHash(members);
|
|
247
250
|
const now = new Date().toISOString();
|
|
@@ -259,8 +262,8 @@ export async function createWorkspace(input = {}) {
|
|
|
259
262
|
}
|
|
260
263
|
}
|
|
261
264
|
prepare(
|
|
262
|
-
'INSERT INTO workspaces (id, name, description, metrics_project, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
|
263
|
-
).run(id, name, description, metricsProject, now, now);
|
|
265
|
+
'INSERT INTO workspaces (id, name, description, metrics_project, policy_project, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
266
|
+
).run(id, name, description, metricsProject, policyProject, now, now);
|
|
264
267
|
const insMember = prepare(
|
|
265
268
|
'INSERT INTO workspace_projects (workspace_id, project_key, ordinal) VALUES (?, ?, ?)'
|
|
266
269
|
);
|
|
@@ -270,7 +273,7 @@ export async function createWorkspace(input = {}) {
|
|
|
270
273
|
|
|
271
274
|
// Return the annotated entry (derived fields recomputed from the persisted paths).
|
|
272
275
|
return annotate({
|
|
273
|
-
id, name, description, projectPaths: members, metricsProject, createdAt: now, updatedAt: now,
|
|
276
|
+
id, name, description, projectPaths: members, metricsProject, policyProject, createdAt: now, updatedAt: now,
|
|
274
277
|
});
|
|
275
278
|
}
|
|
276
279
|
|
|
@@ -299,6 +302,9 @@ export async function updateWorkspace(id, patch = {}) {
|
|
|
299
302
|
const metricsProject = Object.prototype.hasOwnProperty.call(patch || {}, 'metricsProject')
|
|
300
303
|
? memberPathFor(entry.projectPaths, patch.metricsProject)
|
|
301
304
|
: entry.metricsProject ?? null;
|
|
305
|
+
const policyProject = Object.prototype.hasOwnProperty.call(patch || {}, 'policyProject')
|
|
306
|
+
? memberPathFor(entry.projectPaths, patch.policyProject, 'policyProject')
|
|
307
|
+
: entry.policyProject ?? null;
|
|
302
308
|
const now = new Date().toISOString();
|
|
303
309
|
|
|
304
310
|
tx(() => {
|
|
@@ -308,11 +314,11 @@ export async function updateWorkspace(id, patch = {}) {
|
|
|
308
314
|
).get(name, id);
|
|
309
315
|
if (clash) throw err(`a workspace named "${name}" already exists`, 'DUPLICATE_NAME');
|
|
310
316
|
prepare(
|
|
311
|
-
'UPDATE workspaces SET name = ?, description = ?, metrics_project = ?, updated_at = ? WHERE id = ?'
|
|
312
|
-
).run(name, description, metricsProject, now, id);
|
|
317
|
+
'UPDATE workspaces SET name = ?, description = ?, metrics_project = ?, policy_project = ?, updated_at = ? WHERE id = ?'
|
|
318
|
+
).run(name, description, metricsProject, policyProject, now, id);
|
|
313
319
|
});
|
|
314
320
|
|
|
315
|
-
return annotate({ ...entry, name, description, metricsProject, updatedAt: now });
|
|
321
|
+
return annotate({ ...entry, name, description, metricsProject, policyProject, updatedAt: now });
|
|
316
322
|
}
|
|
317
323
|
|
|
318
324
|
/** Thin setter: edit only the description. */
|