@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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// src/core/policy/effective.mjs
|
|
2
|
+
// What applies where (team-policy design §6): fold a policy document and the developer's
|
|
3
|
+
// own settings into the value a run (or a page) will use, field by field. Pure — the
|
|
4
|
+
// snapshot of local settings comes from policy/local.mjs, the document from policy/sync.mjs.
|
|
5
|
+
|
|
6
|
+
import { FIELDS, effectiveKind, tierRank, semverAtLeast } from './registry.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The team entries that apply to ONE run kind: `workspaceRuns` replaces `fields` for
|
|
10
|
+
* workspace runs, key by key, and `hard` reads as soft (registry.effectiveKind).
|
|
11
|
+
* @returns {Record<string, {kind:string, declaredKind:string, value:*, fromWorkspaceRuns:boolean, onBreach?:string, requireReason?:boolean, window?:string}>}
|
|
12
|
+
*/
|
|
13
|
+
export function fieldsForRun(doc, { workspaceRun = false } = {}) {
|
|
14
|
+
const out = {};
|
|
15
|
+
if (!doc) return out;
|
|
16
|
+
for (const f of FIELDS) {
|
|
17
|
+
const ws = workspaceRun ? doc.workspaceRuns?.[f.key] : null;
|
|
18
|
+
const e = ws || doc.fields?.[f.key] || null;
|
|
19
|
+
if (!e) continue;
|
|
20
|
+
out[f.key] = { ...e, kind: effectiveKind(f, e.kind), declaredKind: e.kind, fromWorkspaceRuns: !!ws };
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The cap a gate enforces. `local` is the developer's stored cap (null = none); `team` the
|
|
27
|
+
* entry from fieldsForRun (null = the policy says nothing).
|
|
28
|
+
* default: local wins when set, else the team value starts the developer off
|
|
29
|
+
* soft: the tighter of the two; ties go to local (no "team" pause for an equal number)
|
|
30
|
+
* @returns {{cap:number|null, binding:'local'|'team'|'team-default'|null, team:object|null}}
|
|
31
|
+
*/
|
|
32
|
+
export function effectiveCap({ local = null, team = null } = {}) {
|
|
33
|
+
if (!team) return { cap: local ?? null, binding: local == null ? null : 'local', team: null };
|
|
34
|
+
if (team.kind === 'default') {
|
|
35
|
+
return local != null ? { cap: local, binding: 'local', team } : { cap: team.value, binding: 'team-default', team };
|
|
36
|
+
}
|
|
37
|
+
if (local == null || team.value < local) return { cap: team.value, binding: 'team', team };
|
|
38
|
+
return { cap: local, binding: 'local', team };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A default-kind scalar: the developer's stored value wins when SET, else the team's. */
|
|
42
|
+
export function effectiveDefault({ local = null, team = null } = {}) {
|
|
43
|
+
if (local && local.set) return { value: local.value, source: 'local' };
|
|
44
|
+
if (team) return { value: team.value, source: 'team-default' };
|
|
45
|
+
return { value: local ? local.value : null, source: local ? 'default' : 'none' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fmtUsd = (n) => `$${Number(n).toFixed(2)}`;
|
|
49
|
+
// Built-in guardrail ids render as their display names everywhere (`secure` shows as "Strict",
|
|
50
|
+
// the guardrail-store.mjs BUILTIN_META rule); a policy set reads "gp:<id>".
|
|
51
|
+
const GUARDRAIL_NAMES = { permissive: 'Permissive', normal: 'Normal', secure: 'Strict' };
|
|
52
|
+
/** A field value as the page shows it ("$25.00", "Strict", "on", "a, b"). */
|
|
53
|
+
export function fmtValue(meta, v) {
|
|
54
|
+
if (v == null) return '—';
|
|
55
|
+
if (meta.key === 'guardrails.default' || meta.key === 'guardrails.minimum') return GUARDRAIL_NAMES[v] || String(v);
|
|
56
|
+
switch (meta.type) {
|
|
57
|
+
case 'usd': case 'usd-or-null': return fmtUsd(v);
|
|
58
|
+
case 'bool': return v ? 'on' : 'off';
|
|
59
|
+
case 'string[]': return v.length ? v.join(', ') : '(none)';
|
|
60
|
+
case 'plugins': return v.length ? v.map((p) => `${p.name}${p.minVersion ? ` ≥ ${p.minVersion}` : ''}`).join(', ') : '(none)';
|
|
61
|
+
case 'steps': return Object.entries(v).map(([r, s]) => `${r} ${s.model || '·'}${s.effort ? ` / ${s.effort}` : ''}`).join(' · ') || '(none)';
|
|
62
|
+
default: return String(v);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Rows for the effective-policy table (design board 4) and the CLI `policy show`.
|
|
68
|
+
* `local` is the policy/local.mjs snapshot: { [key]: { value, set } }.
|
|
69
|
+
* @returns {Array<{key,group,label,help,team:object|null,local:object|null,effective:{value,display,source},note:string|null,shown:boolean}>}
|
|
70
|
+
*/
|
|
71
|
+
export function effectiveRows({ doc = null, workspaceRun = false, local = {} } = {}) {
|
|
72
|
+
const team = fieldsForRun(doc, { workspaceRun });
|
|
73
|
+
const rows = [];
|
|
74
|
+
for (const meta of FIELDS) {
|
|
75
|
+
const t = team[meta.key] || null;
|
|
76
|
+
const l = local[meta.key] || null;
|
|
77
|
+
let effective; let note = null;
|
|
78
|
+
if (meta.cap) {
|
|
79
|
+
const r = effectiveCap({ local: l?.set ? l.value : null, team: t });
|
|
80
|
+
effective = { value: r.cap, display: fmtValue(meta, r.cap), source: r.binding || 'none' };
|
|
81
|
+
if (r.binding === 'team' && l?.set && l.value > t.value) note = `yours (${fmtUsd(l.value)}) is looser; the team cap applies`;
|
|
82
|
+
if (r.binding === 'local' && t?.kind === 'soft' && l?.set && l.value <= t.value) note = 'yours is tighter';
|
|
83
|
+
} else if (meta.advisory) {
|
|
84
|
+
effective = t ? { value: t.value, display: `${fmtUsd(t.value)} / ${t.window || 'monthly'}`, source: 'advisory' } : { value: null, display: '—', source: 'none' };
|
|
85
|
+
} else if (t && t.kind === 'soft') {
|
|
86
|
+
// An expectation: the run proceeds either way, deviations warn and are recorded.
|
|
87
|
+
effective = { value: t.value, display: fmtValue(meta, t.value), source: 'team' };
|
|
88
|
+
if (meta.key === 'metrics.record' && l && l.set && l.value === false && t.value === true) note = 'your "Include my runs" is off; the team expects recording';
|
|
89
|
+
} else {
|
|
90
|
+
const r = effectiveDefault({ local: l, team: t });
|
|
91
|
+
effective = { value: r.value, display: fmtValue(meta, r.value), source: r.source };
|
|
92
|
+
}
|
|
93
|
+
rows.push({
|
|
94
|
+
key: meta.key, group: meta.group, label: meta.label, help: meta.help, type: meta.type,
|
|
95
|
+
team: t ? { ...t, display: fmtValue(meta, t.value) } : null,
|
|
96
|
+
local: l ? { ...l, display: l.set ? fmtValue(meta, l.value) : '—' } : null,
|
|
97
|
+
effective, note, shown: !!t,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Off-policy findings for a run (design §8, §10). Codes are what the metrics record stores;
|
|
105
|
+
* `text` is the run-log / New pipeline line. Nothing here blocks anything.
|
|
106
|
+
* @param {object} fields fieldsForRun() result
|
|
107
|
+
* @param {object} ctx { guardrailsId, guardrailSet, stepModels:[{role,model}], installed:{name:{version,enabled}},
|
|
108
|
+
* worcaVersion, metricsRecord:boolean|null }
|
|
109
|
+
* @returns {Array<{code:string, text:string, level:'warn'|'info'}>}
|
|
110
|
+
*/
|
|
111
|
+
export function deviationsFor(fields, ctx = {}) {
|
|
112
|
+
const out = [];
|
|
113
|
+
const min = fields['guardrails.minimum'];
|
|
114
|
+
if (min && ctx.guardrailsId) {
|
|
115
|
+
const have = ctx.guardrailSet ? tierRank(ctx.guardrailSet) : tierRank(ctx.guardrailsId);
|
|
116
|
+
const want = tierRank(min.value);
|
|
117
|
+
if (have != null && want != null && have < want) {
|
|
118
|
+
const name = (id) => GUARDRAIL_NAMES[id] || id;
|
|
119
|
+
out.push({ code: `guardrails:${ctx.guardrailsId}<${min.value}`, level: 'warn', text: `Guardrails ${name(ctx.guardrailsId)} rank below the team minimum (${name(min.value)}). The run proceeds and is recorded as off-policy.` });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const allowed = fields['models.allowed'];
|
|
123
|
+
if (allowed && Array.isArray(ctx.stepModels)) {
|
|
124
|
+
const ok = new Set(allowed.value.map((m) => m.toLowerCase()));
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
for (const s of ctx.stepModels) {
|
|
127
|
+
const id = typeof s?.model === 'string' ? s.model.trim() : '';
|
|
128
|
+
if (!id || seen.has(id.toLowerCase()) || ok.has(id.toLowerCase())) continue;
|
|
129
|
+
seen.add(id.toLowerCase());
|
|
130
|
+
out.push({ code: `model:${id}`, level: 'warn', text: `${s.role ? `The ${s.role}'s model ` : 'Model '}${id} is not in the allowed list (${allowed.value.join(', ')}). The run proceeds and is recorded as off-policy.` });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const required = fields['plugins.required'];
|
|
134
|
+
if (required && ctx.installed) {
|
|
135
|
+
for (const p of required.value) {
|
|
136
|
+
const have = ctx.installed[p.name];
|
|
137
|
+
if (!have) out.push({ code: `plugin-missing:${p.name}`, level: 'warn', text: `Required plugin ${p.name} is not installed.` });
|
|
138
|
+
else if (have.enabled === false) out.push({ code: `plugin-disabled:${p.name}`, level: 'warn', text: `Required plugin ${p.name} is disabled.` });
|
|
139
|
+
else if (p.minVersion && have.version && !semverAtLeast(have.version, p.minVersion)) out.push({ code: `plugin-outdated:${p.name}`, level: 'warn', text: `Required plugin ${p.name} is ${have.version}; the team expects at least ${p.minVersion}.` });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const blocked = fields['plugins.blocked'];
|
|
143
|
+
if (blocked && ctx.installed) {
|
|
144
|
+
for (const name of blocked.value) {
|
|
145
|
+
const have = ctx.installed[name];
|
|
146
|
+
if (have && have.enabled !== false) out.push({ code: `plugin-blocked:${name}`, level: 'warn', text: `Plugin ${name} is enabled but blocked by the team policy.` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const minVer = fields['worca.minVersion'];
|
|
150
|
+
if (minVer && ctx.worcaVersion && !semverAtLeast(ctx.worcaVersion, minVer.value)) {
|
|
151
|
+
out.push({ code: `worca-version:${ctx.worcaVersion}<${minVer.value}`, level: 'warn', text: `Your Worca is ${ctx.worcaVersion}; this policy expects at least ${minVer.value}. Some fields may not apply.` });
|
|
152
|
+
}
|
|
153
|
+
const rec = fields['metrics.record'];
|
|
154
|
+
if (rec && rec.value === true && ctx.metricsRecord === false) {
|
|
155
|
+
out.push({ code: 'metrics-off', level: 'info', text: 'Your "Include my runs" is off; the team expects runs to be recorded.' });
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The three-number summary the Projects cell, the Settings readout and the CLI print. */
|
|
161
|
+
export function capSummary(doc, { workspaceRun = false } = {}) {
|
|
162
|
+
const f = fieldsForRun(doc, { workspaceRun });
|
|
163
|
+
const pick = (k) => (f[k] ? { kind: f[k].kind, value: f[k].value, onBreach: f[k].onBreach || 'pause', requireReason: !!f[k].requireReason } : null);
|
|
164
|
+
return {
|
|
165
|
+
pipeline: pick('cost.pipelineLimitUsd'),
|
|
166
|
+
total: pick('cost.totalLimitUsd'),
|
|
167
|
+
resetPeriod: f['cost.resetPeriod'] ? f['cost.resetPeriod'].value : null,
|
|
168
|
+
pooled: f['cost.pooledBudgetUsd'] ? { value: f['cost.pooledBudgetUsd'].value, window: f['cost.pooledBudgetUsd'].window || 'monthly' } : null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** How many entries a document sets, for "14 fields" copy. */
|
|
173
|
+
export function fieldCount(doc) {
|
|
174
|
+
return doc ? Object.keys(doc.fields || {}).length + Object.keys(doc.workspaceRuns || {}).length : 0;
|
|
175
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/core/policy/gate.mjs
|
|
2
|
+
// The team-cap gates the run ENTRY points share (POST /api/run, POST /api/resume, the CLI):
|
|
3
|
+
// the same fold the harness applies at every step boundary (run-harness._checkCostLimits),
|
|
4
|
+
// evaluated once before a run or resume is created so the caller can answer with a dialog
|
|
5
|
+
// instead of a pause. Soft only (team-policy design §7): a breach is never a refusal when
|
|
6
|
+
// the developer says `pastTeamCap`, when the cap says `onBreach: warn`, or when nobody is
|
|
7
|
+
// there to click (an unattended run).
|
|
8
|
+
|
|
9
|
+
import { pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod, readSettings } from '../settings.mjs';
|
|
10
|
+
import { totalWindowSpendUsd, costWindowStart } from '../cost-budget.mjs';
|
|
11
|
+
import { projectKey } from '../store.mjs';
|
|
12
|
+
import { resolveProjectPolicy, resolveWorkspacePolicy } from './sync.mjs';
|
|
13
|
+
import { fieldsForRun, effectiveCap } from './effective.mjs';
|
|
14
|
+
import { readTotalAck, setTotalAck, hasPipelineOverride, writePolicyState, cleanReason } from './state.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the policy for a run target and fold its two caps. Never throws; a target with no
|
|
18
|
+
* policy answers { policy: null }.
|
|
19
|
+
* @param {{projectDir?:string, workspaceId?:string}} target
|
|
20
|
+
*/
|
|
21
|
+
export async function teamCapsForTarget({ projectDir = null, workspaceId = null } = {}, { discover = 'if-missing' } = {}) {
|
|
22
|
+
let r = null;
|
|
23
|
+
try {
|
|
24
|
+
r = workspaceId ? await resolveWorkspacePolicy(workspaceId, { discover }) : projectDir ? await resolveProjectPolicy(projectDir, { discover }) : null;
|
|
25
|
+
} catch { r = null; }
|
|
26
|
+
if (!r || !r.ok) return { policy: null, fields: {}, pipeline: effectiveCap({ local: pipelineCostLimitUsd() }), total: effectiveCap({ local: totalCostLimitUsd() }), period: costLimitResetPeriod() };
|
|
27
|
+
const fields = fieldsForRun(r.doc, { workspaceRun: !!workspaceId });
|
|
28
|
+
const stored = readSettings().costLimitResetPeriod;
|
|
29
|
+
const teamPeriod = fields['cost.resetPeriod']?.value;
|
|
30
|
+
const period = stored === 'weekly' || stored === 'monthly' ? stored : (teamPeriod === 'weekly' || teamPeriod === 'monthly' ? teamPeriod : costLimitResetPeriod());
|
|
31
|
+
return {
|
|
32
|
+
policy: { home: r.home, homeDir: r.homeDir, sha: r.sha, delegated: r.delegated, from: r.from },
|
|
33
|
+
fields,
|
|
34
|
+
pipeline: effectiveCap({ local: pipelineCostLimitUsd(), team: fields['cost.pipelineLimitUsd'] || null }),
|
|
35
|
+
total: effectiveCap({ local: totalCostLimitUsd(), team: fields['cost.totalLimitUsd'] || null }),
|
|
36
|
+
period,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const usd = (n) => Number(n).toFixed(2);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The team TOTAL cap at a run entry. `pastTeamCap` records the once-per-window acknowledgement
|
|
44
|
+
* (design §7) and lets the run through; a cap with `requireReason` refuses an empty reason.
|
|
45
|
+
* @returns {Promise<{blocked:false, caps:object, ack?:object} | {blocked:true, caps:object, error:string, code:'team_total'|'reason_required', policy:object}>}
|
|
46
|
+
*/
|
|
47
|
+
export async function checkTeamTotalGate(target, { pastTeamCap = false, reason = null, unattended = false, now = new Date() } = {}) {
|
|
48
|
+
const caps = await teamCapsForTarget(target);
|
|
49
|
+
const { total, policy, period } = caps;
|
|
50
|
+
if (!policy || total.binding !== 'team' || total.cap == null) return { blocked: false, caps };
|
|
51
|
+
const windowStartMs = costWindowStart(now, period).getTime();
|
|
52
|
+
const spent = totalWindowSpendUsd(windowStartMs);
|
|
53
|
+
if (spent < total.cap) return { blocked: false, caps };
|
|
54
|
+
const team = total.team;
|
|
55
|
+
const ackKey = projectKey(policy.homeDir);
|
|
56
|
+
const w = period === 'weekly' ? 'week' : 'month';
|
|
57
|
+
const detail = { home: policy.home, cap: total.cap, spent, period, window: w, windowStartMs, requireReason: !!team.requireReason, onBreach: team.onBreach || 'pause' };
|
|
58
|
+
if (readTotalAck(ackKey, policy.home, windowStartMs)) return { blocked: false, caps, acked: true };
|
|
59
|
+
if ((team.onBreach || 'pause') === 'warn' || unattended) return { blocked: false, caps, warned: true };
|
|
60
|
+
if (pastTeamCap) {
|
|
61
|
+
const clean = cleanReason(reason);
|
|
62
|
+
if (team.requireReason && !clean) return { blocked: true, caps, code: 'reason_required', error: `the team policy on ${policy.home} requires a reason to continue past its total cap`, policy: detail };
|
|
63
|
+
const ack = setTotalAck(ackKey, policy.home, windowStartMs, { reason: clean });
|
|
64
|
+
return { blocked: false, caps, ack };
|
|
65
|
+
}
|
|
66
|
+
return { blocked: true, caps, code: 'team_total', error: `team total cap reached ($${usd(spent)} >= $${usd(total.cap)} this ${w}, ${policy.home})`, policy: detail };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The team PIPELINE cap at a resume. `pastTeamCap` arms the per-pipeline override (persisted on
|
|
71
|
+
* the run, design §7) and lets the resume through.
|
|
72
|
+
* @returns {{blocked:false} | {blocked:true, error:string, code:'team_pipeline'|'reason_required', policy:object}}
|
|
73
|
+
*/
|
|
74
|
+
export function checkTeamPipelineGate(caps, { pipelineId, spentSoFar = 0, pastTeamCap = false, reason = null, unattended = false } = {}) {
|
|
75
|
+
const { pipeline, policy } = caps;
|
|
76
|
+
// The team SOFT cap is checked whether or not it is the tighter number (the local override
|
|
77
|
+
// never bypasses it — run-harness._checkCostLimits applies the same rule at every boundary).
|
|
78
|
+
const team = pipeline?.team && pipeline.team.kind === 'soft' ? pipeline.team : null;
|
|
79
|
+
if (!policy || !team) return { blocked: false };
|
|
80
|
+
if (spentSoFar < team.value) return { blocked: false };
|
|
81
|
+
if (hasPipelineOverride(pipelineId)) return { blocked: false, overridden: true };
|
|
82
|
+
const detail = { home: policy.home, cap: team.value, spent: spentSoFar, requireReason: !!team.requireReason, onBreach: team.onBreach || 'pause' };
|
|
83
|
+
if ((team.onBreach || 'pause') === 'warn' || unattended) return { blocked: false, warned: true };
|
|
84
|
+
if (pastTeamCap) {
|
|
85
|
+
const clean = cleanReason(reason);
|
|
86
|
+
if (team.requireReason && !clean) return { blocked: true, code: 'reason_required', error: `the team policy on ${policy.home} requires a reason to continue past its cost cap`, policy: detail };
|
|
87
|
+
writePolicyState(pipelineId, { home: policy.home, sha: policy.sha, overrides: ['pipeline'], ...(clean ? { reason: clean } : {}) });
|
|
88
|
+
return { blocked: false, overridden: true };
|
|
89
|
+
}
|
|
90
|
+
return { blocked: true, code: 'team_pipeline', error: `team cost cap reached ($${usd(spentSoFar)} >= $${usd(team.value)}, ${policy.home})`, policy: detail };
|
|
91
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/core/policy/local.mjs
|
|
2
|
+
// The developer's side of the fold (team-policy design §6): a snapshot of the local settings
|
|
3
|
+
// a policy can govern, the installed-plugin map the deviations need, and the plugin / marketplace
|
|
4
|
+
// requirements the Plugins page and the setup checklist show. Core-only (reads settings.json,
|
|
5
|
+
// project_config, plugins.lock.json, marketplaces.json); nothing here touches git.
|
|
6
|
+
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import {
|
|
9
|
+
readSettings, pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod, humanRateUsdPerHour, askMaxTurns, askMaxBudgetUsd, hideBuiltinModels,
|
|
10
|
+
} from '../settings.mjs';
|
|
11
|
+
import { readConfigRow, readTeamMetricsPrefs } from '../config.mjs';
|
|
12
|
+
import { projectKey } from '../store.mjs';
|
|
13
|
+
import { readPluginsLock } from '../plugins-lock.mjs';
|
|
14
|
+
import { readMarketplaces, writeMarketplaces, normalizeMarketplaceUrl, marketplaceId, addMarketplace } from '../marketplaces.mjs';
|
|
15
|
+
import { fieldsForRun } from './effective.mjs';
|
|
16
|
+
import { semverAtLeast } from './registry.mjs';
|
|
17
|
+
|
|
18
|
+
export const WORCA_VERSION = createRequire(import.meta.url)('../../../package.json').version;
|
|
19
|
+
|
|
20
|
+
const parseJson = (s, fallback) => { try { const v = JSON.parse(s); return v ?? fallback; } catch { return fallback; } };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* { [policyKey]: { value, set } } — `set` is whether the DEVELOPER stored a value (the
|
|
24
|
+
* default-kind rule keys off it), `value` is what Worca applies today (defaults included).
|
|
25
|
+
* @param {string|null} projectDir project-level keys are null without one
|
|
26
|
+
*/
|
|
27
|
+
export function localSnapshot(projectDir = null) {
|
|
28
|
+
const raw = readSettings();
|
|
29
|
+
const has = (k) => raw[k] !== undefined && raw[k] !== null && raw[k] !== '';
|
|
30
|
+
const out = {
|
|
31
|
+
'cost.pipelineLimitUsd': { value: pipelineCostLimitUsd(), set: pipelineCostLimitUsd() != null },
|
|
32
|
+
'cost.totalLimitUsd': { value: totalCostLimitUsd(), set: totalCostLimitUsd() != null },
|
|
33
|
+
'cost.resetPeriod': { value: costLimitResetPeriod(), set: has('costLimitResetPeriod') },
|
|
34
|
+
'cost.humanRateUsd': { value: humanRateUsdPerHour(), set: humanRateUsdPerHour() != null },
|
|
35
|
+
'ask.maxTurns': { value: askMaxTurns(), set: has('askMaxTurns') },
|
|
36
|
+
'ask.maxBudgetUsd': { value: askMaxBudgetUsd(), set: raw.askMaxBudgetUsd !== undefined }, // literal null = "no cap", a choice
|
|
37
|
+
'models.hideBuiltins': { value: hideBuiltinModels(), set: has('hideBuiltinModels') || raw.hideBuiltinModelsChosen === true },
|
|
38
|
+
'plugins.marketplaces': { value: Object.values(readMarketplaces().marketplaces).map((m) => m.url), set: true },
|
|
39
|
+
'plugins.required': { value: Object.entries(readPluginsLock()).map(([name, e]) => ({ name, version: e?.version ?? null, enabled: e?.enabled !== false })), set: true },
|
|
40
|
+
};
|
|
41
|
+
if (projectDir) {
|
|
42
|
+
const key = projectKey(projectDir);
|
|
43
|
+
const row = readConfigRow(key);
|
|
44
|
+
const active = row && typeof row.active_workflow_id === 'string' ? row.active_workflow_id.trim() : '';
|
|
45
|
+
out['workflows.default'] = { value: active || null, set: !!active };
|
|
46
|
+
out['run.humanInLoop'] = { value: !(row && row.human_in_loop === 0), set: !!row && row.human_in_loop === 0 };
|
|
47
|
+
const steps = row ? parseJson(row.steps, {}) : {};
|
|
48
|
+
out['models.steps'] = { value: steps && typeof steps === 'object' ? steps : {}, set: !!steps && Object.keys(steps).length > 0 };
|
|
49
|
+
const tm = readTeamMetricsPrefs(key);
|
|
50
|
+
out['metrics.record'] = { value: tm ? tm.record !== false : null, set: !!tm && tm.record === false };
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** { name: { version, enabled } } from plugins.lock.json. */
|
|
56
|
+
export function installedPluginsMap() {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [name, e] of Object.entries(readPluginsLock())) out[name] = { version: e?.version ?? null, enabled: e?.enabled !== false, via: e?.via ?? null };
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The union of `plugins.required` across policy homes, each with its installed state
|
|
64
|
+
* (Plugins page strip, setup checklist, `worca policy setup`). Two homes → the higher floor.
|
|
65
|
+
* @param {Array<{slug:string, doc:object}>} homes
|
|
66
|
+
*/
|
|
67
|
+
export function pluginRequirements(homes) {
|
|
68
|
+
const installed = installedPluginsMap();
|
|
69
|
+
const byName = new Map();
|
|
70
|
+
for (const h of homes || []) {
|
|
71
|
+
const f = fieldsForRun(h.doc);
|
|
72
|
+
for (const p of f['plugins.required']?.value || []) {
|
|
73
|
+
const cur = byName.get(p.name) || { name: p.name, marketplace: p.marketplace ?? null, minVersion: null, config: null, homes: [] };
|
|
74
|
+
if (p.minVersion && (!cur.minVersion || semverAtLeast(p.minVersion, cur.minVersion))) cur.minVersion = p.minVersion;
|
|
75
|
+
if (p.config && !cur.config) cur.config = p.config;
|
|
76
|
+
if (!cur.marketplace && p.marketplace) cur.marketplace = p.marketplace;
|
|
77
|
+
if (!cur.homes.includes(h.slug)) cur.homes.push(h.slug);
|
|
78
|
+
byName.set(p.name, cur);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const req of byName.values()) {
|
|
83
|
+
const have = installed[req.name] || null;
|
|
84
|
+
let state = 'ok';
|
|
85
|
+
if (!have) state = 'missing';
|
|
86
|
+
else if (have.enabled === false) state = 'disabled';
|
|
87
|
+
else if (req.minVersion && have.version && !semverAtLeast(have.version, req.minVersion)) state = 'outdated';
|
|
88
|
+
out.push({ ...req, installed: have, state });
|
|
89
|
+
}
|
|
90
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Blocked plugins that are enabled here, per home. */
|
|
94
|
+
export function blockedPluginFindings(homes) {
|
|
95
|
+
const installed = installedPluginsMap();
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const h of homes || []) {
|
|
98
|
+
for (const name of fieldsForRun(h.doc)['plugins.blocked']?.value || []) {
|
|
99
|
+
const have = installed[name];
|
|
100
|
+
if (have && have.enabled !== false) out.push({ name, home: h.slug });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Marketplace URLs a policy asks for that this machine has neither added nor deliberately
|
|
108
|
+
* removed (marketplaces.json `policySeeded` remembers what policy added, design §6).
|
|
109
|
+
*/
|
|
110
|
+
export function marketplaceSeedCandidates(homes) {
|
|
111
|
+
const state = readMarketplaces();
|
|
112
|
+
const seeded = new Set(Array.isArray(state.policySeeded) ? state.policySeeded : []);
|
|
113
|
+
const present = new Set(Object.values(state.marketplaces).map((m) => m.url));
|
|
114
|
+
const out = new Map();
|
|
115
|
+
for (const h of homes || []) {
|
|
116
|
+
for (const raw of fieldsForRun(h.doc)['plugins.marketplaces']?.value || []) {
|
|
117
|
+
const url = normalizeMarketplaceUrl(raw);
|
|
118
|
+
if (!url || present.has(url) || seeded.has(url)) continue;
|
|
119
|
+
const cur = out.get(url) || { url, homes: [] };
|
|
120
|
+
if (!cur.homes.includes(h.slug)) cur.homes.push(h.slug);
|
|
121
|
+
out.set(url, cur);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...out.values()];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Add the candidate marketplaces (metadata only: a git archive, no code runs) and remember
|
|
129
|
+
* each in `policySeeded`, added or not, so a later local removal is never undone. Best-effort:
|
|
130
|
+
* one unreachable marketplace never blocks the others. `add` is injectable for tests.
|
|
131
|
+
*/
|
|
132
|
+
export async function seedPolicyMarketplaces(homes, { add = addMarketplace } = {}) {
|
|
133
|
+
const results = [];
|
|
134
|
+
for (const c of marketplaceSeedCandidates(homes)) {
|
|
135
|
+
let entry = null; let error = null;
|
|
136
|
+
try { entry = await add(c.url); }
|
|
137
|
+
catch (err) { if (err?.code === 'EXISTS') entry = readMarketplaces().marketplaces[marketplaceId(c.url)] || null; else error = String(err?.message || err); }
|
|
138
|
+
const state = readMarketplaces();
|
|
139
|
+
const seeded = new Set(Array.isArray(state.policySeeded) ? state.policySeeded : []);
|
|
140
|
+
seeded.add(c.url);
|
|
141
|
+
writeMarketplaces({ ...state, policySeeded: [...seeded] });
|
|
142
|
+
results.push({ url: c.url, homes: c.homes, added: !!entry && !error, error });
|
|
143
|
+
}
|
|
144
|
+
return results;
|
|
145
|
+
}
|