@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,330 @@
|
|
|
1
|
+
// src/core/policy/registry.mjs
|
|
2
|
+
// The team-policy field registry (team-policy design §5): ONE list that drives document
|
|
3
|
+
// validation, the editor rows, the effective-policy table and the run-log lines. Pure —
|
|
4
|
+
// no I/O, no DB, importable from the browser bundle's tests as well as the core.
|
|
5
|
+
//
|
|
6
|
+
// Document shape (schema 1):
|
|
7
|
+
// { schema, updatedAt, updatedBy, title, notes,
|
|
8
|
+
// fields: { '<key>': { kind, value, ...attrs } },
|
|
9
|
+
// workspaceRuns: { '<key>': { kind, value, ...attrs } }, // replaces `fields` for workspace runs
|
|
10
|
+
// catalogs: { guardrailSets: [...], models: [...] } }
|
|
11
|
+
// A delegate marker is the same file with `delegateTo` and no fields.
|
|
12
|
+
//
|
|
13
|
+
// Kinds: 'default' (the developer's value wins when set), 'soft' (the tighter/expected value
|
|
14
|
+
// applies, the developer may go past it and the overshoot is recorded), 'hard' (RESERVED:
|
|
15
|
+
// accepted on read, downgraded to soft with a warning — a later version enforces it without a
|
|
16
|
+
// format change). Every field lists the kinds it accepts; the editor disables the rest.
|
|
17
|
+
|
|
18
|
+
export const POLICY_SCHEMA = 1;
|
|
19
|
+
export const KINDS = Object.freeze(['default', 'soft', 'hard']);
|
|
20
|
+
export const ON_BREACH = Object.freeze(['pause', 'warn']);
|
|
21
|
+
export const TIERS = Object.freeze(['permissive', 'normal', 'secure']);
|
|
22
|
+
export const WINDOWS = Object.freeze(['weekly', 'monthly']);
|
|
23
|
+
/** Text fields on the document (title, notes, updatedBy, override reasons) are clipped here. */
|
|
24
|
+
export const TEXT_MAX = 200;
|
|
25
|
+
|
|
26
|
+
const GROUPS = Object.freeze({
|
|
27
|
+
cost: 'Cost', ask: 'Ask Worca', guardrails: 'Guardrails', models: 'Models', plugins: 'Plugins', runs: 'Runs',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One row per policy key. `type` drives validation and the editor control; `kinds` is the
|
|
32
|
+
* subset of KINDS the field accepts; `attrs` are the kind-specific extras a soft cap carries
|
|
33
|
+
* (onBreach, requireReason) or an advisory figure needs (window); `local` names the Worca
|
|
34
|
+
* setting the field governs today (informational: the effective layer reads it).
|
|
35
|
+
*/
|
|
36
|
+
export const FIELDS = Object.freeze([
|
|
37
|
+
{ key: 'cost.pipelineLimitUsd', group: 'cost', label: 'Per-pipeline cap (USD)', help: 'Pauses one pipeline at this estimated cost. Soft: the tighter of team and local applies; continue past it on resume.', type: 'usd', kinds: ['default', 'soft'], cap: true, attrs: ['onBreach', 'requireReason'], local: 'pipelineCostLimitUsd' },
|
|
38
|
+
{ key: 'cost.totalLimitUsd', group: 'cost', label: 'Total cap per period (USD)', help: 'Per developer, per reset period; pipelines and Ask Worca together.', type: 'usd', kinds: ['default', 'soft'], cap: true, attrs: ['onBreach', 'requireReason'], local: 'totalCostLimitUsd' },
|
|
39
|
+
{ key: 'cost.resetPeriod', group: 'cost', label: 'Reset period', help: 'The developer\'s own period wins when set.', type: 'enum', values: WINDOWS, kinds: ['default'], local: 'costLimitResetPeriod' },
|
|
40
|
+
{ key: 'cost.pooledBudgetUsd', group: 'cost', label: 'Pooled budget (USD)', help: 'Whole team, read from team metrics. Advisory: it never pauses a run.', type: 'usd', kinds: ['soft'], advisory: true, attrs: ['window'] },
|
|
41
|
+
{ key: 'cost.humanRateUsd', group: 'cost', label: 'Developer rate (USD/h)', help: 'Prices the estimated human hours behind "Saved". A developer\'s own rate wins when set.', type: 'usd', kinds: ['default'], local: 'humanRateUsdPerHour' },
|
|
42
|
+
{ key: 'ask.maxTurns', group: 'ask', label: 'Turn limit', help: 'Ask Worca agentic turns per chat turn.', type: 'int', min: 1, max: 500, kinds: ['default'], local: 'askMaxTurns' },
|
|
43
|
+
{ key: 'ask.maxBudgetUsd', group: 'ask', label: 'Per-turn cost cap (USD)', help: 'Ask Worca per-turn cap; null means no cap.', type: 'usd-or-null', min: 0.1, max: 100, kinds: ['default'], local: 'askMaxBudgetUsd' },
|
|
44
|
+
{ key: 'guardrails.default', group: 'guardrails', label: 'Default set', help: 'What the New pipeline picker starts on. A built-in id, a user set id, or gp:<name> for a set this policy ships.', type: 'string', kinds: ['default'] },
|
|
45
|
+
{ key: 'guardrails.minimum', group: 'guardrails', label: 'Minimum tier', help: 'A run whose set ranks below this warns and is recorded.', type: 'enum', values: TIERS, kinds: ['soft'] },
|
|
46
|
+
{ key: 'models.allowed', group: 'models', label: 'Allowed models', help: 'A chosen step model outside this list warns and is recorded. Others still run.', type: 'string[]', kinds: ['soft'] },
|
|
47
|
+
{ key: 'models.steps', group: 'models', label: 'Step defaults', help: 'Model and effort per role, applied to roles the project has not configured.', type: 'steps', kinds: ['default'] },
|
|
48
|
+
{ key: 'models.hideBuiltins', group: 'models', label: 'Hide built-in models', help: 'Cosmetic; ids still resolve.', type: 'bool', kinds: ['default'], local: 'hideBuiltinModels' },
|
|
49
|
+
{ key: 'plugins.marketplaces', group: 'plugins', label: 'Marketplaces', help: 'Added to every teammate once; a local removal is remembered.', type: 'string[]', kinds: ['default'] },
|
|
50
|
+
{ key: 'plugins.required', group: 'plugins', label: 'Required plugins', help: 'Missing or below the floor: the setup checklist offers to install, with consent. Never automatic.', type: 'plugins', kinds: ['soft'] },
|
|
51
|
+
{ key: 'plugins.blocked', group: 'plugins', label: 'Blocked plugins', help: 'An enabled blocked plugin warns and is recorded; it is never disabled for you.', type: 'string[]', kinds: ['soft'] },
|
|
52
|
+
{ key: 'workflows.default', group: 'runs', label: 'Default workflow', help: 'A built-in (wf_*) or plugin (wfp_*) workflow id. Applies when the project has no active workflow.', type: 'string', kinds: ['default'] },
|
|
53
|
+
{ key: 'run.humanInLoop', group: 'runs', label: 'Human in the loop', help: 'Applies until the project sets its own switch.', type: 'bool', kinds: ['default'] },
|
|
54
|
+
{ key: 'metrics.record', group: 'runs', label: 'Record runs to team metrics', help: 'Expected on: the Projects cell hints when "Include my runs" is off.', type: 'bool', kinds: ['soft'] },
|
|
55
|
+
{ key: 'worca.minVersion', group: 'runs', label: 'Minimum Worca version', help: 'An older client shows a banner and logs a note.', type: 'semver', kinds: ['soft'] },
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const BY_KEY = new Map(FIELDS.map((f) => [f.key, f]));
|
|
59
|
+
|
|
60
|
+
/** @returns {object|null} the registry row for a key */
|
|
61
|
+
export function fieldMeta(key) { return BY_KEY.get(key) || null; }
|
|
62
|
+
export function groupLabel(id) { return GROUPS[id] || id; }
|
|
63
|
+
export const GROUP_ORDER = Object.freeze(Object.keys(GROUPS));
|
|
64
|
+
|
|
65
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
66
|
+
const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:\-[\]]{0,199}$/;
|
|
67
|
+
const PLUGIN_NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
68
|
+
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
69
|
+
// The one import: the zero-import model-env leaf, for the bridged-model
|
|
70
|
+
// `upstream` validator every catalog layer shares (model-bridge-design.md §6.3).
|
|
71
|
+
import { assertModelUpstream, upstreamEnvConflict } from '../model-env.mjs';
|
|
72
|
+
|
|
73
|
+
const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
74
|
+
const clip = (v, max = TEXT_MAX) => {
|
|
75
|
+
if (v == null) return null;
|
|
76
|
+
const s = String(v).replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, ' ').replace(/ {2,}/g, ' ').trim();
|
|
77
|
+
const chars = Array.from(s);
|
|
78
|
+
return chars.length > max ? chars.slice(0, max).join('') : s;
|
|
79
|
+
};
|
|
80
|
+
const finiteNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
81
|
+
|
|
82
|
+
/** A value that looks like a secret must never reach the branch (design §13). */
|
|
83
|
+
export function looksLikeSecret(v) {
|
|
84
|
+
const s = String(v ?? '');
|
|
85
|
+
if (/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(s)) return false; // ${VAR} indirection is the sanctioned form
|
|
86
|
+
if (/^(sk-|xox[abp]-|ghp_|gho_|github_pat_|glpat-|AKIA|eyJ[A-Za-z0-9_-]{10,}\.)/.test(s)) return true;
|
|
87
|
+
return /^[A-Za-z0-9+/_=-]{40,}$/.test(s); // a long opaque token
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate ONE field value against its registry type. Returns null when ok, else a message.
|
|
92
|
+
* Shared by the editor (before publish) and the reader (dropping bad fields with a warning).
|
|
93
|
+
*/
|
|
94
|
+
export function validateValue(meta, value) {
|
|
95
|
+
switch (meta.type) {
|
|
96
|
+
case 'usd': return finiteNum(value) && value > 0 ? null : 'must be a positive number of USD';
|
|
97
|
+
case 'usd-or-null':
|
|
98
|
+
if (value === null) return null;
|
|
99
|
+
return finiteNum(value) && value >= (meta.min ?? 0) && value <= (meta.max ?? Infinity) ? null : `must be null or a number between ${meta.min} and ${meta.max}`;
|
|
100
|
+
case 'int': return Number.isInteger(value) && value >= (meta.min ?? -Infinity) && value <= (meta.max ?? Infinity) ? null : `must be an integer between ${meta.min} and ${meta.max}`;
|
|
101
|
+
case 'bool': return typeof value === 'boolean' ? null : 'must be true or false';
|
|
102
|
+
case 'enum': return meta.values.includes(value) ? null : `must be one of ${meta.values.join(' | ')}`;
|
|
103
|
+
case 'string': return typeof value === 'string' && value.trim() && value.length <= TEXT_MAX ? null : 'must be a non-empty string';
|
|
104
|
+
case 'semver': return typeof value === 'string' && SEMVER_RE.test(value) ? null : 'must be a version like 1.4.0';
|
|
105
|
+
case 'string[]':
|
|
106
|
+
if (!Array.isArray(value)) return 'must be a list';
|
|
107
|
+
return value.every((x) => typeof x === 'string' && x.trim() && x.length <= TEXT_MAX) ? null : 'every entry must be a non-empty string';
|
|
108
|
+
case 'steps': {
|
|
109
|
+
if (!isPlainObject(value)) return 'must be an object of role → { model, effort }';
|
|
110
|
+
for (const [role, sel] of Object.entries(value)) {
|
|
111
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/i.test(role)) return `role "${role}" is not a valid agent key`;
|
|
112
|
+
if (!isPlainObject(sel)) return `role "${role}" must be { model, effort }`;
|
|
113
|
+
if (sel.model != null && !(typeof sel.model === 'string' && MODEL_ID_RE.test(sel.model))) return `role "${role}": model must be a model id`;
|
|
114
|
+
if (sel.effort != null && !EFFORTS.includes(sel.effort)) return `role "${role}": effort must be one of ${EFFORTS.join(' | ')}`;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
case 'plugins': {
|
|
119
|
+
if (!Array.isArray(value)) return 'must be a list of { name, marketplace, minVersion?, config? }';
|
|
120
|
+
for (const p of value) {
|
|
121
|
+
if (!isPlainObject(p)) return 'every entry must be an object';
|
|
122
|
+
if (!(typeof p.name === 'string' && PLUGIN_NAME_RE.test(p.name))) return `"${p?.name}" is not a valid plugin name`;
|
|
123
|
+
if (p.marketplace != null && !(typeof p.marketplace === 'string' && p.marketplace.trim())) return `${p.name}: marketplace must be a string`;
|
|
124
|
+
if (p.minVersion != null && !(typeof p.minVersion === 'string' && SEMVER_RE.test(p.minVersion))) return `${p.name}: minVersion must be a version like 1.2.0`;
|
|
125
|
+
if (p.config != null) {
|
|
126
|
+
if (!isPlainObject(p.config)) return `${p.name}: config must be an object`;
|
|
127
|
+
for (const [k, v] of Object.entries(p.config)) {
|
|
128
|
+
if (typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') return `${p.name}: config.${k} must be a string, number or boolean`;
|
|
129
|
+
if (typeof v === 'string' && looksLikeSecret(v)) return `${p.name}: config.${k} looks like a secret — secrets never go on the branch`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
default: return 'unknown field type';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Normalise ONE field entry ({kind, value, ...attrs}) against its registry row.
|
|
141
|
+
* @returns {{entry:object|null, warning:string|null}} entry null = drop it
|
|
142
|
+
*/
|
|
143
|
+
export function normalizeEntry(key, raw) {
|
|
144
|
+
const meta = fieldMeta(key);
|
|
145
|
+
if (!meta) return { entry: null, warning: `unknown field ${key}` };
|
|
146
|
+
if (!isPlainObject(raw)) return { entry: null, warning: `${key}: not an object` };
|
|
147
|
+
let kind = KINDS.includes(raw.kind) ? raw.kind : null;
|
|
148
|
+
if (!kind) return { entry: null, warning: `${key}: kind must be one of ${KINDS.join(' | ')}` };
|
|
149
|
+
let warning = null;
|
|
150
|
+
if (!meta.kinds.includes(kind)) {
|
|
151
|
+
// A kind the field does not accept: `hard` is the documented downgrade; anything else is
|
|
152
|
+
// a hand edit the editor would never produce.
|
|
153
|
+
if (kind === 'hard' && meta.kinds.includes('soft')) { warning = `${key}: hard constraints are not enforced by this version — treated as soft`; }
|
|
154
|
+
else if (kind === 'hard' && meta.kinds.includes('default')) { warning = `${key}: hard constraints are not enforced by this version — treated as default`; }
|
|
155
|
+
else return { entry: null, warning: `${key}: kind "${kind}" is not allowed (accepts ${meta.kinds.join(' | ')})` };
|
|
156
|
+
}
|
|
157
|
+
const err = validateValue(meta, raw.value);
|
|
158
|
+
if (err) return { entry: null, warning: `${key}: ${err}` };
|
|
159
|
+
const entry = { kind, value: raw.value };
|
|
160
|
+
for (const a of meta.attrs || []) {
|
|
161
|
+
if (raw[a] === undefined) continue;
|
|
162
|
+
if (a === 'onBreach') { if (ON_BREACH.includes(raw[a])) entry.onBreach = raw[a]; else warning ||= `${key}: onBreach must be pause | warn (ignored)`; }
|
|
163
|
+
else if (a === 'requireReason') { if (typeof raw[a] === 'boolean') entry.requireReason = raw[a]; else warning ||= `${key}: requireReason must be true | false (ignored)`; }
|
|
164
|
+
else if (a === 'window') { if (WINDOWS.includes(raw[a])) entry.window = raw[a]; else warning ||= `${key}: window must be weekly | monthly (ignored)`; }
|
|
165
|
+
}
|
|
166
|
+
return { entry, warning };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The kind the RUNTIME applies: hard is reserved and reads as soft (or default when the field is default-only). */
|
|
170
|
+
export function effectiveKind(meta, kind) {
|
|
171
|
+
if (kind !== 'hard') return kind;
|
|
172
|
+
return meta.kinds.includes('soft') ? 'soft' : 'default';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeFieldMap(raw, warnings, prefix = '') {
|
|
176
|
+
const out = {};
|
|
177
|
+
if (raw == null) return out;
|
|
178
|
+
if (!isPlainObject(raw)) { warnings.push(`${prefix || 'fields'}: not an object — ignored`); return out; }
|
|
179
|
+
for (const key of Object.keys(raw)) {
|
|
180
|
+
const { entry, warning } = normalizeEntry(key, raw[key]);
|
|
181
|
+
if (warning) warnings.push(prefix ? `${prefix}.${warning}` : warning);
|
|
182
|
+
if (entry) out[key] = entry;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const GUARDRAIL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
188
|
+
function normalizeGuardrailSets(raw, warnings) {
|
|
189
|
+
if (raw == null) return [];
|
|
190
|
+
if (!Array.isArray(raw)) { warnings.push('catalogs.guardrailSets: not a list — ignored'); return []; }
|
|
191
|
+
const out = []; const seen = new Set();
|
|
192
|
+
for (const s of raw) {
|
|
193
|
+
if (!isPlainObject(s) || typeof s.id !== 'string' || !GUARDRAIL_ID_RE.test(s.id)) { warnings.push('catalogs.guardrailSets: an entry without a valid id was dropped'); continue; }
|
|
194
|
+
const id = s.id.toLowerCase();
|
|
195
|
+
if (seen.has(id)) { warnings.push(`catalogs.guardrailSets: duplicate id ${s.id} dropped`); continue; }
|
|
196
|
+
seen.add(id);
|
|
197
|
+
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : []);
|
|
198
|
+
out.push({
|
|
199
|
+
id: s.id, name: clip(typeof s.name === 'string' && s.name.trim() ? s.name : s.id, 80),
|
|
200
|
+
honorProjectSettings: s.honorProjectSettings !== false,
|
|
201
|
+
envScrub: s.envScrub === true,
|
|
202
|
+
envAllowlist: list(s.envAllowlist), protectedPaths: list(s.protectedPaths), deny: list(s.deny),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizeModels(raw, warnings) {
|
|
209
|
+
if (raw == null) return [];
|
|
210
|
+
if (!Array.isArray(raw)) { warnings.push('catalogs.models: not a list — ignored'); return []; }
|
|
211
|
+
const out = []; const seen = new Set();
|
|
212
|
+
for (const m of raw) {
|
|
213
|
+
if (!isPlainObject(m) || typeof m.id !== 'string' || !MODEL_ID_RE.test(m.id)) { warnings.push('catalogs.models: an entry without a valid id was dropped'); continue; }
|
|
214
|
+
const lc = m.id.toLowerCase();
|
|
215
|
+
if (seen.has(lc)) { warnings.push(`catalogs.models: duplicate id ${m.id} dropped`); continue; }
|
|
216
|
+
const entry = { id: m.id, label: clip(typeof m.label === 'string' && m.label.trim() ? m.label : m.id, 80) };
|
|
217
|
+
const efforts = Array.isArray(m.efforts) ? m.efforts.filter((e) => EFFORTS.includes(e)) : [];
|
|
218
|
+
entry.efforts = efforts.length ? efforts : ['medium', 'high'];
|
|
219
|
+
if (m.env != null) {
|
|
220
|
+
if (!isPlainObject(m.env)) { warnings.push(`catalogs.models: ${m.id}: env is not an object — dropped`); continue; }
|
|
221
|
+
const env = {}; let bad = null;
|
|
222
|
+
for (const [k, v] of Object.entries(m.env)) {
|
|
223
|
+
if (!/^[A-Z][A-Z0-9_]{0,127}$/.test(k)) { bad = `env key ${k} is not a valid variable name`; break; }
|
|
224
|
+
if (/^WORCA_/.test(k)) { bad = `env key ${k} is reserved`; break; }
|
|
225
|
+
if (typeof v !== 'string' || !v) { bad = `env ${k} must be a non-empty string`; break; }
|
|
226
|
+
if (looksLikeSecret(v)) { bad = `env ${k} looks like a secret — use \${VAR} indirection`; break; }
|
|
227
|
+
env[k] = v;
|
|
228
|
+
}
|
|
229
|
+
if (bad) { warnings.push(`catalogs.models: ${m.id}: ${bad} — entry dropped`); continue; }
|
|
230
|
+
entry.env = env;
|
|
231
|
+
}
|
|
232
|
+
// A bridged model (model-bridge-design.md §6.3): a policy may ship the
|
|
233
|
+
// upstream shape but never a credential — the apiKey must be a ${VAR} ref
|
|
234
|
+
// and a copilot entry resolves against each developer's own sign-in.
|
|
235
|
+
if (m.upstream != null) {
|
|
236
|
+
let upstream;
|
|
237
|
+
try {
|
|
238
|
+
upstream = assertModelUpstream(m.upstream);
|
|
239
|
+
if (upstream && upstream.apiKey && !/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(upstream.apiKey)) throw new Error('upstream.apiKey must be a ${VAR} reference');
|
|
240
|
+
const clash = upstream ? upstreamEnvConflict(entry.env) : null;
|
|
241
|
+
if (clash) throw new Error(`env key ${clash} is set by the bridge for an upstream entry`);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
warnings.push(`catalogs.models: ${m.id}: ${e.message} — entry dropped`); continue;
|
|
244
|
+
}
|
|
245
|
+
if (upstream) entry.upstream = upstream;
|
|
246
|
+
}
|
|
247
|
+
seen.add(lc);
|
|
248
|
+
out.push(entry);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Normalise a raw document read from the branch (or posted by the editor). NEVER throws:
|
|
255
|
+
* malformed pieces are dropped with a warning each (the readRemoteConfig lesson), so a hand
|
|
256
|
+
* edit can never break the API. `schema` above POLICY_SCHEMA marks the whole document
|
|
257
|
+
* unusable (`unknownSchema`), and the caller falls back to local settings loudly.
|
|
258
|
+
* @returns {{doc:object|null, warnings:string[], unknownSchema:boolean, delegateTo:string|null}}
|
|
259
|
+
*/
|
|
260
|
+
export function normalizePolicyDoc(raw) {
|
|
261
|
+
const warnings = [];
|
|
262
|
+
if (!isPlainObject(raw)) return { doc: null, warnings: ['policy.json is not a JSON object'], unknownSchema: false, delegateTo: null };
|
|
263
|
+
const schema = Number.isInteger(raw.schema) ? raw.schema : 1;
|
|
264
|
+
if (schema > POLICY_SCHEMA) return { doc: null, warnings: [`policy.json schema ${schema} needs a newer Worca (this one reads ${POLICY_SCHEMA})`], unknownSchema: true, delegateTo: null };
|
|
265
|
+
const delegateTo = typeof raw.delegateTo === 'string' && raw.delegateTo.trim() ? raw.delegateTo.trim().toLowerCase() : null;
|
|
266
|
+
const doc = {
|
|
267
|
+
schema,
|
|
268
|
+
updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : null,
|
|
269
|
+
updatedBy: clip(typeof raw.updatedBy === 'string' ? raw.updatedBy : null, 120),
|
|
270
|
+
title: clip(typeof raw.title === 'string' ? raw.title : '', 120) || '',
|
|
271
|
+
notes: clip(typeof raw.notes === 'string' ? raw.notes : '', 2000) || '',
|
|
272
|
+
fields: normalizeFieldMap(raw.fields, warnings),
|
|
273
|
+
workspaceRuns: normalizeFieldMap(raw.workspaceRuns, warnings, 'workspaceRuns'),
|
|
274
|
+
catalogs: {
|
|
275
|
+
guardrailSets: normalizeGuardrailSets(raw.catalogs?.guardrailSets, warnings),
|
|
276
|
+
models: normalizeModels(raw.catalogs?.models, warnings),
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
if (delegateTo) doc.delegateTo = delegateTo;
|
|
280
|
+
return { doc, warnings, unknownSchema: false, delegateTo };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A fresh, empty policy: what "Set up team policy → here" writes. */
|
|
284
|
+
export function emptyPolicyDoc({ updatedBy = null, title = '', now = new Date() } = {}) {
|
|
285
|
+
return {
|
|
286
|
+
schema: POLICY_SCHEMA,
|
|
287
|
+
updatedAt: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
288
|
+
updatedBy, title: title || '', notes: '',
|
|
289
|
+
fields: {}, workspaceRuns: {}, catalogs: { guardrailSets: [], models: [] },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Canonical key order (registry order inside `fields`), so branch diffs stay readable. */
|
|
294
|
+
export function serializePolicyDoc(doc) {
|
|
295
|
+
const ordered = (map) => {
|
|
296
|
+
const out = {};
|
|
297
|
+
for (const f of FIELDS) if (map && map[f.key]) out[f.key] = map[f.key];
|
|
298
|
+
return out;
|
|
299
|
+
};
|
|
300
|
+
const body = {
|
|
301
|
+
schema: doc.schema ?? POLICY_SCHEMA,
|
|
302
|
+
updatedAt: doc.updatedAt ?? null,
|
|
303
|
+
updatedBy: doc.updatedBy ?? null,
|
|
304
|
+
title: doc.title ?? '',
|
|
305
|
+
notes: doc.notes ?? '',
|
|
306
|
+
...(doc.delegateTo ? { delegateTo: doc.delegateTo } : {}),
|
|
307
|
+
fields: ordered(doc.fields),
|
|
308
|
+
workspaceRuns: ordered(doc.workspaceRuns),
|
|
309
|
+
catalogs: { guardrailSets: doc.catalogs?.guardrailSets ?? [], models: doc.catalogs?.models ?? [] },
|
|
310
|
+
};
|
|
311
|
+
return JSON.stringify(body, null, 2) + '\n';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Rank a guardrail tier (or a set's contents) for the minimum-tier rule: permissive < normal < secure. */
|
|
315
|
+
export function tierRank(idOrSet) {
|
|
316
|
+
if (typeof idOrSet === 'string') { const i = TIERS.indexOf(idOrSet); return i === -1 ? null : i; }
|
|
317
|
+
if (!isPlainObject(idOrSet)) return null;
|
|
318
|
+
const s = idOrSet.settings && isPlainObject(idOrSet.settings) ? idOrSet.settings : idOrSet;
|
|
319
|
+
if (s.envScrub === true) return 2;
|
|
320
|
+
if ((Array.isArray(s.deny) && s.deny.length) || (Array.isArray(s.protectedPaths) && s.protectedPaths.length)) return 1;
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** "1.4.0" >= "1.3.2" → true; prerelease tags are ignored. */
|
|
325
|
+
export function semverAtLeast(have, min) {
|
|
326
|
+
const p = (v) => String(v || '').split('-')[0].split('.').map((x) => parseInt(x, 10) || 0);
|
|
327
|
+
const a = p(have); const b = p(min);
|
|
328
|
+
for (let i = 0; i < 3; i++) { if ((a[i] || 0) > (b[i] || 0)) return true; if ((a[i] || 0) < (b[i] || 0)) return false; }
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/core/policy/scope.mjs
|
|
2
|
+
// One scope's team policy, resolved and folded against THIS machine (team-policy design §11):
|
|
3
|
+
// the reader behind GET /api/policy (the Team policy page) and Ask Worca's get_team_policy /
|
|
4
|
+
// propose_policy_change, so the page and the chat can never disagree about what applies.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { listProjects } from '../projects.mjs';
|
|
8
|
+
import { readWorkspace } from '../workspaces.mjs';
|
|
9
|
+
import { projectKey } from '../store.mjs';
|
|
10
|
+
import { resolveProjectPolicy, resolveWorkspacePolicy } from './sync.mjs';
|
|
11
|
+
import { effectiveRows, capSummary, deviationsFor, fieldsForRun } from './effective.mjs';
|
|
12
|
+
import { localSnapshot, installedPluginsMap, pluginRequirements, blockedPluginFindings, WORCA_VERSION } from './local.mjs';
|
|
13
|
+
import { readTeamMetricsPrefs } from '../config.mjs';
|
|
14
|
+
import { FIELDS } from './registry.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a `{kind:'project'|'workspace', id}` scope to its policy and the run kind it governs.
|
|
18
|
+
* Throws NOT_FOUND for an unknown project or workspace; a scope WITHOUT a policy resolves with
|
|
19
|
+
* `r.ok === false` (the caller decides how to say so).
|
|
20
|
+
*/
|
|
21
|
+
export async function policyForScope(scope) {
|
|
22
|
+
if (scope.kind === 'project') {
|
|
23
|
+
const p = (await listProjects()).find((x) => x.key === scope.id);
|
|
24
|
+
if (!p) throw Object.assign(new Error(`unknown project ${scope.id}`), { code: 'NOT_FOUND' });
|
|
25
|
+
const r = await resolveProjectPolicy(p.path);
|
|
26
|
+
return { meta: { kind: 'project', id: p.key, name: p.name, path: p.path }, r, workspaceRun: false, projectDir: p.path };
|
|
27
|
+
}
|
|
28
|
+
const ws = await readWorkspace(scope.id);
|
|
29
|
+
if (!ws) throw Object.assign(new Error(`unknown workspace ${scope.id}`), { code: 'NOT_FOUND' });
|
|
30
|
+
const r = await resolveWorkspacePolicy(ws);
|
|
31
|
+
return { meta: { kind: 'workspace', id: ws.id, name: ws.name, policyProject: ws.policyProject ?? null }, r, workspaceRun: true, projectDir: ws.policyProject ?? null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The local machine's answer to a resolved policy: the fold, the plugin gaps, the blocked plugins. */
|
|
35
|
+
export function policyPayload(meta, r, { workspaceRun, projectDir }) {
|
|
36
|
+
const local = localSnapshot(workspaceRun ? null : projectDir);
|
|
37
|
+
const homeKey = r.homeDir ? projectKey(r.homeDir) : null;
|
|
38
|
+
const homes = [{ slug: r.home, doc: r.doc }];
|
|
39
|
+
// The machine-level deviations (no run yet: no guardrail set, no picked models) — what the page's
|
|
40
|
+
// "off-policy here" card and Ask Worca's get_team_policy both report.
|
|
41
|
+
const deviations = deviationsFor(fieldsForRun(r.doc, { workspaceRun }), {
|
|
42
|
+
installed: installedPluginsMap(), worcaVersion: WORCA_VERSION,
|
|
43
|
+
metricsRecord: meta.kind === 'project' ? (readTeamMetricsPrefs(meta.id) ? readTeamMetricsPrefs(meta.id).record !== false : null) : null,
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
scope: meta,
|
|
47
|
+
policy: {
|
|
48
|
+
home: r.home, homeKey, sha: r.sha, delegated: r.delegated, from: r.from, warnings: r.warnings || [], checkedAt: r.checkedAt ?? null,
|
|
49
|
+
doc: r.doc, caps: capSummary(r.doc, { workspaceRun }), workspaceRun,
|
|
50
|
+
},
|
|
51
|
+
rows: effectiveRows({ doc: r.doc, workspaceRun, local }),
|
|
52
|
+
deviations,
|
|
53
|
+
local,
|
|
54
|
+
requirements: pluginRequirements(homes),
|
|
55
|
+
blockedPlugins: blockedPluginFindings(homes),
|
|
56
|
+
worcaVersion: WORCA_VERSION,
|
|
57
|
+
registry: FIELDS,
|
|
58
|
+
// Publishing needs the home checkout on THIS machine and a document (not a marker).
|
|
59
|
+
canPublish: !!r.homeDir && existsSync(r.homeDir),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/core/policy/state.mjs
|
|
2
|
+
// Per-run team-policy state (pipelines.policy_state, v32) and the per-window total-cap
|
|
3
|
+
// acknowledgements (project_config.extra.teamPolicy.acks). The run state is what the
|
|
4
|
+
// History meta line, the resume gate and the metrics record read:
|
|
5
|
+
// { home, sha, overrides: ['pipeline'|'total'], exceeded: ['pipeline'|'total'],
|
|
6
|
+
// deviations: ['model:…', …], unattended: bool, reason: string|null, at }
|
|
7
|
+
// Reads never throw; a corrupt blob reads as {}.
|
|
8
|
+
|
|
9
|
+
import { prepare } from '../db.mjs';
|
|
10
|
+
import { readTeamPolicyPrefs, writeTeamPolicyPrefs } from '../config.mjs';
|
|
11
|
+
import { TEXT_MAX } from './registry.mjs';
|
|
12
|
+
|
|
13
|
+
const uniq = (arr) => [...new Set((Array.isArray(arr) ? arr : []).filter((x) => typeof x === 'string' && x))];
|
|
14
|
+
|
|
15
|
+
export function cleanReason(v) {
|
|
16
|
+
if (v == null) return null;
|
|
17
|
+
const s = String(v).replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, ' ').replace(/ {2,}/g, ' ').trim();
|
|
18
|
+
if (!s) return null;
|
|
19
|
+
const chars = Array.from(s);
|
|
20
|
+
return chars.length > TEXT_MAX ? chars.slice(0, TEXT_MAX).join('') : s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @returns {object} the run's policy state, {} when none */
|
|
24
|
+
export function readPolicyState(pipelineId) {
|
|
25
|
+
if (!pipelineId) return {};
|
|
26
|
+
try {
|
|
27
|
+
const row = prepare('SELECT policy_state FROM pipelines WHERE id = ?').get(pipelineId);
|
|
28
|
+
if (!row || !row.policy_state) return {};
|
|
29
|
+
const v = JSON.parse(row.policy_state);
|
|
30
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
31
|
+
} catch { return {}; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Merge `patch` into the run's policy state. List fields (overrides / exceeded / deviations)
|
|
36
|
+
* are UNIONED, never replaced, so the gate can add 'pipeline' after the resume set 'total'.
|
|
37
|
+
* @returns {object} the merged state
|
|
38
|
+
*/
|
|
39
|
+
export function writePolicyState(pipelineId, patch = {}) {
|
|
40
|
+
if (!pipelineId) return {};
|
|
41
|
+
const cur = readPolicyState(pipelineId);
|
|
42
|
+
const next = { ...cur, ...patch };
|
|
43
|
+
for (const k of ['overrides', 'exceeded', 'deviations']) {
|
|
44
|
+
if (patch[k] !== undefined || cur[k] !== undefined) next[k] = uniq([...(cur[k] || []), ...(patch[k] || [])]);
|
|
45
|
+
}
|
|
46
|
+
if (patch.reason !== undefined) next.reason = cleanReason(patch.reason);
|
|
47
|
+
next.at = new Date().toISOString();
|
|
48
|
+
prepare('UPDATE pipelines SET policy_state = ? WHERE id = ?').run(JSON.stringify(next), pipelineId);
|
|
49
|
+
return next;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True when the developer chose to continue past the team's per-pipeline cap on this run. */
|
|
53
|
+
export function hasPipelineOverride(pipelineId) {
|
|
54
|
+
return (readPolicyState(pipelineId).overrides || []).includes('pipeline');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- total-cap acknowledgements (design §7: once per window per home) -----------------
|
|
58
|
+
const ackKey = (home, windowStartMs) => `${String(home).toLowerCase()}|${windowStartMs}`;
|
|
59
|
+
|
|
60
|
+
/** @returns {{at:string, reason:string|null}|null} */
|
|
61
|
+
export function readTotalAck(projectKey, home, windowStartMs) {
|
|
62
|
+
const prefs = readTeamPolicyPrefs(projectKey);
|
|
63
|
+
const acks = prefs?.acks && typeof prefs.acks === 'object' ? prefs.acks : {};
|
|
64
|
+
const v = acks[ackKey(home, windowStartMs)];
|
|
65
|
+
return v && typeof v === 'object' ? v : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Record the acknowledgement; older windows are swept so the blob never grows. */
|
|
69
|
+
export function setTotalAck(projectKey, home, windowStartMs, { reason = null } = {}) {
|
|
70
|
+
const prefs = readTeamPolicyPrefs(projectKey);
|
|
71
|
+
const acks = {};
|
|
72
|
+
for (const [k, v] of Object.entries(prefs?.acks && typeof prefs.acks === 'object' ? prefs.acks : {})) {
|
|
73
|
+
const ms = Number(k.split('|')[1]);
|
|
74
|
+
if (Number.isFinite(ms) && ms >= windowStartMs) acks[k] = v;
|
|
75
|
+
}
|
|
76
|
+
acks[ackKey(home, windowStartMs)] = { at: new Date().toISOString(), reason: cleanReason(reason) };
|
|
77
|
+
writeTeamPolicyPrefs(projectKey, { acks });
|
|
78
|
+
return acks[ackKey(home, windowStartMs)];
|
|
79
|
+
}
|