@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,513 @@
|
|
|
1
|
+
// src/core/policy/sync.mjs
|
|
2
|
+
// The worca-policy branch: discovery, the local worktree, enable / follow, publish, status
|
|
3
|
+
// (team-policy design §4, §9). Reuses the team-metrics git primitives (metrics/sync.mjs:
|
|
4
|
+
// runGit, projectSlug, orphanCommit, identityArgs, findLocalRepoBySlug, isNonFastForward)
|
|
5
|
+
// so a policy commit obeys the same hook-free, sign-free, identity rules as a metrics one.
|
|
6
|
+
//
|
|
7
|
+
// Unlike metrics, policy is few writers editing ONE file: there is no outbox, no flush loop,
|
|
8
|
+
// and the branch is expected to be PROTECTED (only maintainers push). Every read comes from
|
|
9
|
+
// the discovery cache in project_config.extra.teamPolicy — no git call ever sits on a run's
|
|
10
|
+
// path except the one bounded fetch a project with no cache at all pays.
|
|
11
|
+
|
|
12
|
+
import { EventEmitter } from 'node:events';
|
|
13
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
15
|
+
import { join, dirname, basename, resolve } from 'node:path';
|
|
16
|
+
import { worcaHome, listProjects } from '../projects.mjs';
|
|
17
|
+
import { projectKey, canonicalProjectRoot } from '../store.mjs';
|
|
18
|
+
import { readTeamPolicyPrefs, writeTeamPolicyPrefs } from '../config.mjs';
|
|
19
|
+
import { readWorkspace, listWorkspaces } from '../workspaces.mjs';
|
|
20
|
+
import { withLock } from '../metrics/lock.mjs';
|
|
21
|
+
import {
|
|
22
|
+
runGit, projectSlug, gitUserName, orphanCommit, identityArgs, findLocalRepoBySlug, isNonFastForward,
|
|
23
|
+
slugDirName, DISCOVERY_TTL_MS, DISCOVERY_RETRY_MS,
|
|
24
|
+
} from '../metrics/sync.mjs';
|
|
25
|
+
import { normalizePolicyDoc, emptyPolicyDoc, serializePolicyDoc, POLICY_SCHEMA } from './registry.mjs';
|
|
26
|
+
import { capSummary, fieldCount, effectiveRows } from './effective.mjs';
|
|
27
|
+
|
|
28
|
+
export const POLICY_BRANCH = 'worca-policy';
|
|
29
|
+
export const POLICY_DIR = '.worca-policy';
|
|
30
|
+
export const POLICY_FILE = `${POLICY_DIR}/policy.json`;
|
|
31
|
+
export const REMOTE_REF = `refs/remotes/origin/${POLICY_BRANCH}`;
|
|
32
|
+
export const FETCH_REFSPEC = `+refs/heads/${POLICY_BRANCH}:${REMOTE_REF}`;
|
|
33
|
+
export const MAX_PUSH_ATTEMPTS = 5;
|
|
34
|
+
export const PROTECTION_HINT = `you may not have push rights to \`${POLICY_BRANCH}\` — copy the JSON and open a pull request against that branch, or ask a maintainer`;
|
|
35
|
+
export { DISCOVERY_TTL_MS };
|
|
36
|
+
|
|
37
|
+
const README_TEXT = `# worca-policy\n\nThis branch carries the team policy Worca reads for this repository (\`${POLICY_FILE}\`): cost caps, expected plugins, model and guardrail defaults. Edit it from Worca's Team policy page or by pull request. Protect this branch so only maintainers can push; every teammate's Worca reads it. To turn the policy off for the whole team, delete this branch: \`git push origin --delete ${POLICY_BRANCH}\`.\n`;
|
|
38
|
+
|
|
39
|
+
export const policyEvents = new EventEmitter();
|
|
40
|
+
const emit = (slug, action, extra = {}) => policyEvents.emit('changed', { slug, action, ...extra });
|
|
41
|
+
|
|
42
|
+
// ---- paths ------------------------------------------------------------------
|
|
43
|
+
export function policyRoot() { return join(worcaHome(), 'policy'); }
|
|
44
|
+
export function worktreePath(slug) { return join(policyRoot(), 'repos', slugDirName(slug)); }
|
|
45
|
+
function lockFile(slug) { return join(policyRoot(), 'locks', `${slugDirName(slug)}.lock`); }
|
|
46
|
+
|
|
47
|
+
function policyError(code, message, extra = {}) { return Object.assign(new Error(message), { code, ...extra }); }
|
|
48
|
+
const firstLine = (s) => String(s || '').trim().split('\n').find(Boolean) || '';
|
|
49
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
50
|
+
|
|
51
|
+
// In-process serialization per slug, then the cross-process lock file (metrics/lock.mjs).
|
|
52
|
+
const _queues = new Map();
|
|
53
|
+
function withSlugLock(slug, fn) {
|
|
54
|
+
const file = lockFile(slug);
|
|
55
|
+
const prev = _queues.get(file) || Promise.resolve();
|
|
56
|
+
const run = prev.catch(() => {}).then(() => withLock(file, fn));
|
|
57
|
+
const tail = run.catch(() => {});
|
|
58
|
+
_queues.set(file, tail);
|
|
59
|
+
tail.then(() => { if (_queues.get(file) === tail) _queues.delete(file); });
|
|
60
|
+
return run;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- git ----------------------------------------------------------------------
|
|
64
|
+
const LOCAL_REF_RACE = /cannot lock ref|incorrect old value provided|unable to update local ref|Unable to create '[^']*\.lock'/;
|
|
65
|
+
|
|
66
|
+
export async function fetchPolicyBranch(dir, { timeoutMs = 120_000 } = {}) {
|
|
67
|
+
let r;
|
|
68
|
+
for (let i = 0; i < 3; i++) {
|
|
69
|
+
r = await runGit(dir, ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', FETCH_REFSPEC], { timeoutMs });
|
|
70
|
+
if (r.ok || !LOCAL_REF_RACE.test(r.stderr)) return r;
|
|
71
|
+
await new Promise((res) => setTimeout(res, 50 + Math.floor(Math.random() * 200)));
|
|
72
|
+
}
|
|
73
|
+
return r;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `git show origin/worca-policy:.worca-policy/policy.json`, normalised. Never throws. */
|
|
77
|
+
async function readRemotePolicy(dir) {
|
|
78
|
+
const r = await runGit(dir, ['show', `${REMOTE_REF}:${POLICY_FILE}`], { timeoutMs: 10_000 });
|
|
79
|
+
if (!r.ok) return { doc: null, warnings: [`${POLICY_FILE} is missing on the branch`], unknownSchema: false, delegateTo: null };
|
|
80
|
+
let raw;
|
|
81
|
+
try { raw = JSON.parse(r.stdout); } catch { return { doc: null, warnings: [`${POLICY_FILE} is not valid JSON`], unknownSchema: false, delegateTo: null }; }
|
|
82
|
+
return normalizePolicyDoc(raw);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The policy hint for a push rejection: protection is EXPECTED here, so the hint is about rights, not exemption. */
|
|
86
|
+
export function pushHint(stderr) {
|
|
87
|
+
return /protected|hook declined|pre-receive|GH006|GH013|rule violation|push declined|GL-HOOK|denied|permission/i.test(String(stderr || '')) ? PROTECTION_HINT : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- discovery (§9) ---------------------------------------------------------------
|
|
91
|
+
/**
|
|
92
|
+
* `git ls-remote --heads origin worca-policy`, cached in project_config.extra.teamPolicy.
|
|
93
|
+
* Offline keeps the previous verdict and does not advance checkedAt. Same shape and race
|
|
94
|
+
* rules as metrics discoverProject.
|
|
95
|
+
*/
|
|
96
|
+
export async function discoverPolicy(projectDir, { force = false, now = Date.now(), lsTimeoutMs = 20_000, fetchTimeoutMs = 120_000 } = {}) {
|
|
97
|
+
const key = projectKey(projectDir);
|
|
98
|
+
const prev = readTeamPolicyPrefs(key) || {};
|
|
99
|
+
if (!force && prev.checkedAt && now - Date.parse(prev.checkedAt) < DISCOVERY_TTL_MS) return prev;
|
|
100
|
+
if (!existsSync(projectDir)) return readTeamPolicyPrefs(key);
|
|
101
|
+
const { slug, hasOrigin, remotesOk, originUrl } = await projectSlug(projectDir);
|
|
102
|
+
const originDisplay = originUrl ? String(originUrl).replace(/^[a-z+]+:\/\//i, '').replace(/^[^@/]*@/, '').replace(/:(?!\d)/, '/').replace(/\.git$/, '') : null;
|
|
103
|
+
if (!remotesOk) return prev.checkedAt ? prev : null;
|
|
104
|
+
const write = (patch) => {
|
|
105
|
+
const cur = readTeamPolicyPrefs(key);
|
|
106
|
+
if (cur?.checkedAt && Date.parse(cur.checkedAt) > now) return cur;
|
|
107
|
+
return writeTeamPolicyPrefs(key, patch);
|
|
108
|
+
};
|
|
109
|
+
if (!hasOrigin) {
|
|
110
|
+
return write({ present: false, hasOrigin: false, slug, originDisplay: null, doc: null, docKnown: false, delegateTo: null, headSha: null, checkedAt: iso(now) });
|
|
111
|
+
}
|
|
112
|
+
const ls = await runGit(projectDir, ['ls-remote', '--heads', 'origin', `refs/heads/${POLICY_BRANCH}`], { timeoutMs: lsTimeoutMs });
|
|
113
|
+
if (!ls.ok) return write({ hasOrigin: true, slug, originDisplay, lastDiscoveryError: firstLine(ls.stderr), lastDiscoveryAt: iso(now) });
|
|
114
|
+
const sha = ls.stdout.trim().split(/\s+/)[0] || null;
|
|
115
|
+
if (!sha) {
|
|
116
|
+
const next = write({ present: false, hasOrigin: true, slug, originDisplay, headSha: null, doc: null, docKnown: false, delegateTo: null, warnings: [], unknownSchema: false, checkedAt: iso(now), lastDiscoveryError: null });
|
|
117
|
+
if (prev.present) emit(slug, 'disabled');
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
let { doc = null, docKnown = false, warnings = [], unknownSchema = false, delegateTo = null, headSha = null } = prev;
|
|
121
|
+
if (sha !== prev.headSha || !prev.docKnown) {
|
|
122
|
+
const f = await fetchPolicyBranch(projectDir, { timeoutMs: fetchTimeoutMs });
|
|
123
|
+
if (!f.ok) {
|
|
124
|
+
const next = write({ present: true, hasOrigin: true, slug, originDisplay, docKnown: !!prev.docKnown, lastDiscoveryError: firstLine(f.stderr), lastDiscoveryAt: iso(now) });
|
|
125
|
+
if (!prev.present) emit(slug, 'discovered');
|
|
126
|
+
return next;
|
|
127
|
+
}
|
|
128
|
+
const read = await readRemotePolicy(projectDir);
|
|
129
|
+
doc = read.doc; warnings = read.warnings; unknownSchema = read.unknownSchema; delegateTo = read.delegateTo; docKnown = true; headSha = sha;
|
|
130
|
+
}
|
|
131
|
+
const next = write({ present: true, hasOrigin: true, slug, originDisplay, headSha, doc, docKnown, warnings, unknownSchema, delegateTo, checkedAt: iso(now), lastDiscoveryError: null });
|
|
132
|
+
if (!prev.present) emit(slug, 'discovered');
|
|
133
|
+
else if (prev.headSha !== sha) emit(slug, 'updated');
|
|
134
|
+
return next;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Every registered project plus every workspace policy home. */
|
|
138
|
+
export async function discoverAllPolicies({ force = false } = {}) {
|
|
139
|
+
const paths = new Set((await listProjects()).filter((p) => p.exists).map((p) => p.path));
|
|
140
|
+
for (const w of await listWorkspaces()) if (w.policyProject) paths.add(w.policyProject);
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const p of paths) {
|
|
143
|
+
try { out.push({ path: p, prefs: await discoverPolicy(p, { force }) }); }
|
|
144
|
+
catch (err) { out.push({ path: p, error: String(err?.message || err) }); }
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const isStale = (prefs) => !prefs?.checkedAt || Date.now() - Date.parse(prefs.checkedAt) >= DISCOVERY_TTL_MS;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* discover: true → rediscover when stale (page opens, statuses)
|
|
153
|
+
* false → cache only
|
|
154
|
+
* 'if-missing' → cache only unless there is no cache at all; then one bounded
|
|
155
|
+
* discovery (10 s / 15 s). The run-start path (design §9: cache first).
|
|
156
|
+
*/
|
|
157
|
+
async function prefsFor(projectDir, { discover = true } = {}) {
|
|
158
|
+
let prefs = readTeamPolicyPrefs(projectKey(projectDir));
|
|
159
|
+
if (discover === 'if-missing') {
|
|
160
|
+
const tried = prefs?.checkedAt || (prefs?.lastDiscoveryAt && Date.now() - Date.parse(prefs.lastDiscoveryAt) < DISCOVERY_RETRY_MS);
|
|
161
|
+
if (!tried) prefs = await discoverPolicy(projectDir, { lsTimeoutMs: 10_000, fetchTimeoutMs: 15_000 }).catch(() => prefs);
|
|
162
|
+
else if (isStale(prefs)) void discoverPolicy(projectDir).catch(() => {}); // background refresh for the NEXT run
|
|
163
|
+
} else if (discover && isStale(prefs)) {
|
|
164
|
+
prefs = await discoverPolicy(projectDir).catch(() => prefs);
|
|
165
|
+
}
|
|
166
|
+
return prefs;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A project carries a policy (present, read, not a marker). */
|
|
170
|
+
export const carriesPolicy = (prefs) => !!prefs?.present && !!prefs.docKnown && !prefs.delegateTo && !prefs.unknownSchema;
|
|
171
|
+
|
|
172
|
+
// ---- resolver (§6) ----------------------------------------------------------------
|
|
173
|
+
function invalid(code, detail) { return { ok: false, reason: 'delegate-invalid', code, detail }; }
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The policy that governs single-project runs on `projectDir`: its own, or the home it follows.
|
|
177
|
+
* @returns {Promise<{ok:true, home:string, homeDir:string, sha:string|null, doc:object, delegated:boolean, from:string, warnings:string[], checkedAt:string|null}
|
|
178
|
+
* | {ok:false, reason:'no-origin'|'not-enabled'|'unsupported'|'delegate-invalid', code?:string, detail?:string}>}
|
|
179
|
+
*/
|
|
180
|
+
export async function resolveProjectPolicy(projectDir, { discover = true } = {}) {
|
|
181
|
+
const own = await prefsFor(projectDir, { discover });
|
|
182
|
+
if (own?.hasOrigin === false) return { ok: false, reason: 'no-origin' };
|
|
183
|
+
if (!own?.present) return { ok: false, reason: 'not-enabled' };
|
|
184
|
+
if (!own.docKnown) return { ok: false, reason: 'not-enabled', code: 'DOC_UNKNOWN', detail: `the ${POLICY_BRANCH} branch exists but could not be fetched yet` };
|
|
185
|
+
if (own.unknownSchema) return { ok: false, reason: 'unsupported', code: 'SCHEMA_UNKNOWN', detail: own.warnings?.[0] || 'the policy needs a newer Worca' };
|
|
186
|
+
if (!own.delegateTo) {
|
|
187
|
+
return { ok: true, home: own.slug, homeDir: projectDir, sha: own.headSha, doc: own.doc, delegated: false, from: own.slug, warnings: own.warnings || [], checkedAt: own.checkedAt ?? null };
|
|
188
|
+
}
|
|
189
|
+
if (own.delegateTo === own.slug) return invalid('DELEGATE_SELF', `${own.slug} follows itself`);
|
|
190
|
+
const target = await findLocalRepoBySlug(own.delegateTo);
|
|
191
|
+
if (!target) return invalid('DELEGATE_UNKNOWN', `follows ${own.delegateTo}, which is not a project in Worca on this machine`);
|
|
192
|
+
const t = await prefsFor(target.path, { discover });
|
|
193
|
+
if (!t?.present) return invalid('DELEGATE_DANGLING', `follows ${own.delegateTo}, which no longer carries a policy`);
|
|
194
|
+
if (!t.docKnown) return invalid('DELEGATE_UNKNOWN_CONFIG', `follows ${own.delegateTo}, whose branch could not be fetched yet`);
|
|
195
|
+
if (t.delegateTo) return invalid('DELEGATE_CHAIN', `follows ${own.delegateTo}, which itself follows ${t.delegateTo} (no chains)`);
|
|
196
|
+
if (t.unknownSchema) return { ok: false, reason: 'unsupported', code: 'SCHEMA_UNKNOWN', detail: t.warnings?.[0] || 'the policy needs a newer Worca' };
|
|
197
|
+
return { ok: true, home: t.slug, homeDir: target.path, sha: t.headSha, doc: t.doc, delegated: true, from: own.slug, warnings: t.warnings || [], checkedAt: t.checkedAt ?? null };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const samePath = (a, b) => {
|
|
201
|
+
if (!a || !b) return false;
|
|
202
|
+
if (resolve(a) === resolve(b)) return true;
|
|
203
|
+
try { return canonicalProjectRoot(a) === canonicalProjectRoot(b); } catch { return false; }
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The policy that governs runs of a workspace: its policy home's (a member that carries a
|
|
208
|
+
* policy OR follows one — design §9 "follows the home the member follows").
|
|
209
|
+
*/
|
|
210
|
+
export async function resolveWorkspacePolicy(workspaceOrId, { discover = true } = {}) {
|
|
211
|
+
const ws = typeof workspaceOrId === 'string' ? await readWorkspace(workspaceOrId) : workspaceOrId;
|
|
212
|
+
if (!ws) return { ok: false, reason: 'not-enabled' };
|
|
213
|
+
if (!ws.policyProject) return { ok: false, reason: 'no-home' };
|
|
214
|
+
if (!ws.projectPaths.some((p) => samePath(p, ws.policyProject))) {
|
|
215
|
+
return { ok: false, reason: 'home-stale', code: 'HOME_NOT_MEMBER', detail: 'the policy home is no longer a workspace member' };
|
|
216
|
+
}
|
|
217
|
+
const r = await resolveProjectPolicy(ws.policyProject, { discover });
|
|
218
|
+
if (!r.ok) return { ok: false, reason: 'home-stale', code: r.code || r.reason.toUpperCase().replace(/-/g, '_'), detail: r.detail || `the policy home has no ${POLICY_BRANCH} branch` };
|
|
219
|
+
return { ...r, workspace: ws };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- worktree, enable / follow, publish (§9) --------------------------------------
|
|
223
|
+
async function removeWorktreeDir(dir, projectDir) {
|
|
224
|
+
if (projectDir) await runGit(projectDir, ['worktree', 'remove', '--force', dir]);
|
|
225
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 10 });
|
|
226
|
+
if (projectDir) await runGit(projectDir, ['worktree', 'prune']);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** git worktree of the project repo at policy/repos/<slug>, detached at origin/worca-policy. */
|
|
230
|
+
export async function ensureWorktree(slug, projectDir) {
|
|
231
|
+
const dir = worktreePath(slug);
|
|
232
|
+
if (existsSync(dir)) {
|
|
233
|
+
const top = await runGit(dir, ['rev-parse', '--show-toplevel'], { timeoutMs: 10_000 });
|
|
234
|
+
let healthy = false;
|
|
235
|
+
try { healthy = top.ok && realpathSync.native(top.stdout.trim()) === realpathSync.native(dir); } catch { healthy = false; }
|
|
236
|
+
if (healthy) return dir;
|
|
237
|
+
await removeWorktreeDir(dir, projectDir);
|
|
238
|
+
}
|
|
239
|
+
await mkdir(dirname(dir), { recursive: true });
|
|
240
|
+
const f = await fetchPolicyBranch(projectDir);
|
|
241
|
+
if (!f.ok) throw policyError('FETCH_FAILED', firstLine(f.stderr) || 'git fetch failed', { stderr: f.stderr });
|
|
242
|
+
let add = await runGit(projectDir, ['worktree', 'add', '--detach', '--force', dir, REMOTE_REF]);
|
|
243
|
+
if (!add.ok) {
|
|
244
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 10 });
|
|
245
|
+
await runGit(projectDir, ['worktree', 'prune']);
|
|
246
|
+
add = await runGit(projectDir, ['worktree', 'add', '--detach', '--force', dir, REMOTE_REF]);
|
|
247
|
+
if (!add.ok) throw policyError('WORKTREE_FAILED', firstLine(add.stderr), { stderr: add.stderr });
|
|
248
|
+
}
|
|
249
|
+
return dir;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @returns {Promise<{slug:string}>} the validated home to follow */
|
|
253
|
+
async function validateFollowTarget(delegateTo, ownSlug) {
|
|
254
|
+
const want = String(delegateTo || '').toLowerCase();
|
|
255
|
+
if (!want) throw policyError('BAD_REQUEST', 'delegateTo is required');
|
|
256
|
+
if (want === ownSlug) throw policyError('DELEGATE_INVALID', 'a project cannot follow itself');
|
|
257
|
+
const target = await findLocalRepoBySlug(want);
|
|
258
|
+
if (!target) throw policyError('DELEGATE_INVALID', `${want} is not a project in Worca on this machine`);
|
|
259
|
+
const t = (await discoverPolicy(target.path, { force: true }).catch(() => null)) || {};
|
|
260
|
+
if (!t.present) throw policyError('DELEGATE_INVALID', `${want} carries no team policy`);
|
|
261
|
+
if (!t.docKnown) throw policyError('FETCH_FAILED', `could not read the ${POLICY_BRANCH} branch of ${want}; try again`);
|
|
262
|
+
if (t.delegateTo) throw policyError('DELEGATE_INVALID', `${want} follows ${t.delegateTo}; pick a project that carries a policy`);
|
|
263
|
+
return { slug: want };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const stampNow = (now) => now.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
267
|
+
/** Equal documents up to the stamp (updatedAt / updatedBy): title, notes, fields, workspaceRuns, catalogs. */
|
|
268
|
+
function sameContent(a, b) {
|
|
269
|
+
const strip = (d) => serializePolicyDoc({ ...d, updatedAt: null, updatedBy: null });
|
|
270
|
+
return strip(a) === strip(b);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* POST /api/projects/:key/policy/enable (design §9, board 3).
|
|
275
|
+
* mode 'here' → create the orphan branch with README + an EMPTY policy; or join if it exists.
|
|
276
|
+
* mode 'follow' → create a marker { delegateTo }; or join; with change:true rewrite an existing marker.
|
|
277
|
+
* @returns {Promise<{action:'created'|'joined'|'changed', slug:string}>}
|
|
278
|
+
*/
|
|
279
|
+
export async function enableTeamPolicy(projectDir, { mode = 'here', delegateTo = null, change = false, title = '', now = new Date() } = {}) {
|
|
280
|
+
if (mode !== 'here' && mode !== 'follow') throw policyError('BAD_REQUEST', 'mode must be "here" or "follow"');
|
|
281
|
+
const { slug, hasOrigin } = await projectSlug(projectDir);
|
|
282
|
+
if (!hasOrigin) throw policyError('NO_ORIGIN', 'this project has no origin remote — a team policy lives on origin, so there is nowhere to put it');
|
|
283
|
+
const target = mode === 'follow' ? (await validateFollowTarget(delegateTo, slug)).slug : null;
|
|
284
|
+
const user = await gitUserName(projectDir);
|
|
285
|
+
const doc = mode === 'follow'
|
|
286
|
+
? { schema: POLICY_SCHEMA, enabledAt: stampNow(now), enabledBy: user, delegateTo: target }
|
|
287
|
+
: emptyPolicyDoc({ updatedBy: user, title: title || `${slug} team policy`, now });
|
|
288
|
+
const body = mode === 'follow' ? JSON.stringify(doc, null, 2) + '\n' : serializePolicyDoc(doc);
|
|
289
|
+
const result = await withSlugLock(slug, async () => {
|
|
290
|
+
const ls = await runGit(projectDir, ['ls-remote', '--heads', 'origin', `refs/heads/${POLICY_BRANCH}`], { timeoutMs: 20_000 });
|
|
291
|
+
if (!ls.ok) throw policyError('REMOTE_UNREACHABLE', firstLine(ls.stderr), { stderr: ls.stderr });
|
|
292
|
+
if (ls.stdout.trim()) {
|
|
293
|
+
if (change && mode === 'follow') return rewriteMarker(slug, projectDir, doc);
|
|
294
|
+
await ensureWorktree(slug, projectDir);
|
|
295
|
+
return { action: 'joined' };
|
|
296
|
+
}
|
|
297
|
+
const commit = await orphanCommit(projectDir, { 'README.md': README_TEXT, [POLICY_FILE]: body },
|
|
298
|
+
mode === 'follow' ? `policy: follow ${target}` : 'policy: enable team policy', 'git-user');
|
|
299
|
+
const push = await runGit(projectDir, ['push', '--no-verify', 'origin', `${commit}:refs/heads/${POLICY_BRANCH}`], { timeoutMs: 120_000 });
|
|
300
|
+
if (!push.ok) {
|
|
301
|
+
if (isNonFastForward(push.stderr)) { await ensureWorktree(slug, projectDir); return { action: 'joined' }; }
|
|
302
|
+
throw policyError('PUSH_REJECTED', firstLine(push.stderr), { stderr: push.stderr, hint: pushHint(push.stderr) });
|
|
303
|
+
}
|
|
304
|
+
await ensureWorktree(slug, projectDir);
|
|
305
|
+
return { action: 'created' };
|
|
306
|
+
});
|
|
307
|
+
await discoverPolicy(projectDir, { force: true }).catch(() => null);
|
|
308
|
+
emit(slug, result.action);
|
|
309
|
+
return { action: result.action, slug };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Rewrite the marker on an existing branch (the "Change…" action). */
|
|
313
|
+
async function rewriteMarker(slug, projectDir, doc) {
|
|
314
|
+
const dir = await ensureWorktree(slug, projectDir);
|
|
315
|
+
for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
|
|
316
|
+
const f = await fetchPolicyBranch(dir);
|
|
317
|
+
if (!f.ok) throw policyError('FETCH_FAILED', firstLine(f.stderr), { stderr: f.stderr });
|
|
318
|
+
const reset = await runGit(dir, ['reset', '--hard', REMOTE_REF]);
|
|
319
|
+
if (!reset.ok) throw policyError('WORKTREE_FAILED', firstLine(reset.stderr), { stderr: reset.stderr });
|
|
320
|
+
const cur = await readFile(join(dir, POLICY_FILE), 'utf8').then(JSON.parse).catch(() => ({}));
|
|
321
|
+
if (!cur.delegateTo) throw policyError('DELEGATE_INVALID', 'this project carries its own policy; its branch is not a marker');
|
|
322
|
+
if (String(cur.delegateTo).toLowerCase() === doc.delegateTo) return { action: 'changed' };
|
|
323
|
+
await writeFile(join(dir, POLICY_FILE), JSON.stringify({ ...cur, enabledAt: doc.enabledAt, enabledBy: doc.enabledBy, delegateTo: doc.delegateTo }, null, 2) + '\n');
|
|
324
|
+
await runGit(dir, ['add', '-A', '-f', POLICY_DIR]);
|
|
325
|
+
const c = await runGit(dir, [...(await identityArgs(projectDir, 'git-user')), 'commit', '-q', '--no-verify', '-m', `policy: follow ${doc.delegateTo}`]);
|
|
326
|
+
if (!c.ok) throw policyError('COMMIT_FAILED', firstLine(c.stderr), { stderr: c.stderr });
|
|
327
|
+
const push = await runGit(dir, ['push', '--no-verify', 'origin', `HEAD:refs/heads/${POLICY_BRANCH}`], { timeoutMs: 120_000 });
|
|
328
|
+
if (push.ok) return { action: 'changed' };
|
|
329
|
+
if (!isNonFastForward(push.stderr)) throw policyError('PUSH_REJECTED', firstLine(push.stderr), { stderr: push.stderr, hint: pushHint(push.stderr) });
|
|
330
|
+
}
|
|
331
|
+
throw policyError('PUSH_RETRIES_EXHAUSTED', `the ${POLICY_BRANCH} branch kept moving; try again`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Publish a document to a home's branch (the editor's "Publish to worca-policy", §11 board 5).
|
|
336
|
+
* Validates strictly — the editor never sends a field the reader would drop, so any warning
|
|
337
|
+
* is a caller bug and answers BAD_REQUEST with the list. A rejection is thrown VERBATIM.
|
|
338
|
+
* @returns {Promise<{ok:true, slug:string, sha:string|null, doc:object}>}
|
|
339
|
+
*/
|
|
340
|
+
export async function publishPolicy(projectDir, rawDoc, { message = null, now = new Date() } = {}) {
|
|
341
|
+
const prefs = (await discoverPolicy(projectDir, { force: true }).catch(() => null)) || readTeamPolicyPrefs(projectKey(projectDir)) || {};
|
|
342
|
+
if (!prefs.present) throw policyError('NOT_HOME', `this project has no ${POLICY_BRANCH} branch`);
|
|
343
|
+
if (!prefs.docKnown) throw policyError('FETCH_FAILED', `could not read the ${POLICY_BRANCH} branch; try again`);
|
|
344
|
+
if (prefs.delegateTo) throw policyError('NOT_HOME', `this project follows ${prefs.delegateTo}; edit the policy there`);
|
|
345
|
+
const { doc, warnings, unknownSchema } = normalizePolicyDoc(rawDoc);
|
|
346
|
+
if (!doc || unknownSchema) throw policyError('BAD_REQUEST', warnings[0] || 'invalid policy document', { warnings });
|
|
347
|
+
if (warnings.length) throw policyError('BAD_REQUEST', `the document has ${warnings.length} problem(s): ${warnings[0]}`, { warnings });
|
|
348
|
+
const slug = prefs.slug;
|
|
349
|
+
doc.updatedAt = stampNow(now);
|
|
350
|
+
doc.updatedBy = await gitUserName(projectDir);
|
|
351
|
+
delete doc.delegateTo;
|
|
352
|
+
const body = serializePolicyDoc(doc);
|
|
353
|
+
const commitMsg = message && String(message).trim() ? `policy: ${String(message).trim().slice(0, 120)}` : 'policy: update team policy';
|
|
354
|
+
const out = await withSlugLock(slug, async () => {
|
|
355
|
+
const dir = await ensureWorktree(slug, projectDir);
|
|
356
|
+
for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
|
|
357
|
+
const f = await fetchPolicyBranch(dir);
|
|
358
|
+
if (!f.ok) throw policyError('FETCH_FAILED', firstLine(f.stderr), { stderr: f.stderr });
|
|
359
|
+
const reset = await runGit(dir, ['reset', '--hard', REMOTE_REF]);
|
|
360
|
+
if (!reset.ok) { await removeWorktreeDir(dir, projectDir); throw policyError('WORKTREE_FAILED', firstLine(reset.stderr), { stderr: reset.stderr }); }
|
|
361
|
+
// Same content as the branch (everything but the stamp): nothing to publish. Compared on
|
|
362
|
+
// the normalised shape, so a re-publish of what is already there never mints a commit.
|
|
363
|
+
const current = await readFile(join(dir, POLICY_FILE), 'utf8').then((t) => normalizePolicyDoc(JSON.parse(t)).doc).catch(() => null);
|
|
364
|
+
if (current && sameContent(current, doc)) {
|
|
365
|
+
const head = await runGit(dir, ['rev-parse', 'HEAD']);
|
|
366
|
+
return { sha: head.ok ? head.stdout.trim() : null, unchanged: true };
|
|
367
|
+
}
|
|
368
|
+
await mkdir(join(dir, POLICY_DIR), { recursive: true });
|
|
369
|
+
await writeFile(join(dir, POLICY_FILE), body, 'utf8');
|
|
370
|
+
if (!existsSync(join(dir, 'README.md'))) await writeFile(join(dir, 'README.md'), README_TEXT, 'utf8');
|
|
371
|
+
await runGit(dir, ['add', '-A', '-f', POLICY_DIR, 'README.md']);
|
|
372
|
+
const status = await runGit(dir, ['status', '--porcelain']);
|
|
373
|
+
if (status.ok && !status.stdout.trim()) {
|
|
374
|
+
const head = await runGit(dir, ['rev-parse', 'HEAD']);
|
|
375
|
+
return { sha: head.ok ? head.stdout.trim() : null, unchanged: true };
|
|
376
|
+
}
|
|
377
|
+
const c = await runGit(dir, [...(await identityArgs(projectDir, 'git-user')), 'commit', '-q', '--no-verify', '-m', commitMsg]);
|
|
378
|
+
if (!c.ok) throw policyError('COMMIT_FAILED', firstLine(c.stderr), { stderr: c.stderr });
|
|
379
|
+
const push = await runGit(dir, ['push', '--no-verify', 'origin', `HEAD:refs/heads/${POLICY_BRANCH}`], { timeoutMs: 120_000 });
|
|
380
|
+
if (push.ok) { const head = await runGit(dir, ['rev-parse', 'HEAD']); return { sha: head.ok ? head.stdout.trim() : null, unchanged: false }; }
|
|
381
|
+
if (!isNonFastForward(push.stderr)) throw policyError('PUSH_REJECTED', firstLine(push.stderr), { stderr: push.stderr, hint: pushHint(push.stderr) });
|
|
382
|
+
}
|
|
383
|
+
throw policyError('PUSH_RETRIES_EXHAUSTED', `the ${POLICY_BRANCH} branch kept moving; try again`);
|
|
384
|
+
});
|
|
385
|
+
const fresh = await discoverPolicy(projectDir, { force: true }).catch(() => null);
|
|
386
|
+
emit(slug, 'published', { unchanged: out.unchanged });
|
|
387
|
+
return { ok: true, slug, sha: fresh?.headSha ?? out.sha, doc: fresh?.doc ?? doc, unchanged: out.unchanged };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- statuses (§11) --------------------------------------------------------------------
|
|
391
|
+
/** Status for the Projects cell, the scope list and the Settings readout. */
|
|
392
|
+
export async function projectPolicyStatus(p, { discover = false } = {}) {
|
|
393
|
+
const prefs = await prefsFor(p.path, { discover }) || {};
|
|
394
|
+
const status = {
|
|
395
|
+
key: p.key, name: p.name, path: p.path, exists: p.exists,
|
|
396
|
+
slug: prefs.slug ?? null, hasOrigin: prefs.hasOrigin ?? null, present: !!prefs.present, docKnown: !!prefs.docKnown,
|
|
397
|
+
unknownSchema: !!prefs.unknownSchema, warnings: prefs.warnings || [],
|
|
398
|
+
delegateTo: prefs.delegateTo ?? null, delegateState: null, delegateCode: null, delegateDetail: null, blocked: null,
|
|
399
|
+
home: null, homeKey: null, sha: null, title: null, updatedAt: null, updatedBy: null, fieldCount: 0, caps: null, workspaceCaps: null,
|
|
400
|
+
origin: prefs.originDisplay ?? null, checkedAt: prefs.checkedAt ?? null, lastDiscoveryError: prefs.lastDiscoveryError ?? null,
|
|
401
|
+
carries: carriesPolicy(prefs),
|
|
402
|
+
};
|
|
403
|
+
if (status.hasOrigin == null && p.exists) {
|
|
404
|
+
try { const { hasOrigin, slug } = await projectSlug(p.path); status.hasOrigin = hasOrigin; if (status.slug == null) status.slug = slug; } catch { /* leave null */ }
|
|
405
|
+
}
|
|
406
|
+
if (!status.present || !p.exists) return status;
|
|
407
|
+
const r = await resolveProjectPolicy(p.path, { discover });
|
|
408
|
+
if (!r.ok) {
|
|
409
|
+
status.delegateState = r.reason === 'delegate-invalid' ? 'invalid' : null;
|
|
410
|
+
status.delegateDetail = r.detail ?? null; status.delegateCode = r.code ?? null;
|
|
411
|
+
status.blocked = r.code || r.reason;
|
|
412
|
+
return status;
|
|
413
|
+
}
|
|
414
|
+
if (r.delegated) status.delegateState = 'ok';
|
|
415
|
+
status.home = r.home; status.sha = r.sha; status.title = r.doc?.title || null; status.updatedAt = r.doc?.updatedAt || null; status.updatedBy = r.doc?.updatedBy || null;
|
|
416
|
+
status.fieldCount = fieldCount(r.doc); status.caps = capSummary(r.doc); status.workspaceCaps = capSummary(r.doc, { workspaceRun: true });
|
|
417
|
+
status.warnings = r.warnings || [];
|
|
418
|
+
try { const hk = (await listProjects()).find((x) => samePath(x.path, r.homeDir)); status.homeKey = hk ? hk.key : null; } catch { /* optional */ }
|
|
419
|
+
return status;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Workspace card status (board 6): the home, where each member's policy comes from. */
|
|
423
|
+
export async function workspacePolicyStatus(ws, { discover = false } = {}) {
|
|
424
|
+
const members = [];
|
|
425
|
+
let homeR = null;
|
|
426
|
+
let home = { state: 'unset', path: null, slug: null, follows: null, detail: null, code: null };
|
|
427
|
+
if (ws.policyProject) {
|
|
428
|
+
const r = await resolveWorkspacePolicy(ws, { discover });
|
|
429
|
+
homeR = r.ok ? r : null;
|
|
430
|
+
home = r.ok
|
|
431
|
+
? { state: 'ok', path: ws.policyProject, slug: r.home, follows: r.delegated ? r.from : null, detail: null, code: null, sha: r.sha, title: r.doc?.title || null, updatedAt: r.doc?.updatedAt || null, workspaceCaps: capSummary(r.doc, { workspaceRun: true }), workspaceFields: Object.keys(r.doc?.workspaceRuns || {}).length,
|
|
432
|
+
// What the workspaceRuns block CHANGES for workspace runs, by name (board 6): never the fall-through values.
|
|
433
|
+
workspaceRuns: effectiveRows({ doc: r.doc, workspaceRun: true }).filter((x) => x.team?.fromWorkspaceRuns).map((x) => ({ key: x.key, label: x.label, display: x.team.display })) }
|
|
434
|
+
: { state: 'stale', path: ws.policyProject, slug: null, follows: null, detail: r.detail || 'the policy home is stale', code: r.code || null };
|
|
435
|
+
}
|
|
436
|
+
for (const path of ws.projectPaths) {
|
|
437
|
+
const prefs = await prefsFor(path, { discover }) || {};
|
|
438
|
+
const isHome = !!ws.policyProject && samePath(path, ws.policyProject);
|
|
439
|
+
let state; let policyFrom = null;
|
|
440
|
+
if (isHome) { state = 'home'; policyFrom = homeR?.home ?? prefs.slug ?? null; }
|
|
441
|
+
else if (prefs.hasOrigin === false) state = 'no-origin';
|
|
442
|
+
else if (!prefs.present) state = 'none';
|
|
443
|
+
else if (prefs.delegateTo) { state = homeR && prefs.delegateTo === homeR.home ? 'follows-home' : 'follows-other'; policyFrom = prefs.delegateTo; }
|
|
444
|
+
else { state = homeR && prefs.slug === homeR.home ? 'is-home' : 'own'; policyFrom = prefs.slug; }
|
|
445
|
+
members.push({ path, slug: prefs.slug ?? basename(path), state, policyFrom });
|
|
446
|
+
}
|
|
447
|
+
return { id: ws.id, name: ws.name, projectPaths: ws.projectPaths, home, members,
|
|
448
|
+
counts: { onHome: members.filter((m) => m.state === 'home' || m.state === 'follows-home' || m.state === 'is-home').length, none: members.filter((m) => m.state === 'none' || m.state === 'no-origin').length } };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The ONE member whose policy resolves, or the home every resolving member shares; else null. */
|
|
452
|
+
export async function autoPolicyHome(ws) {
|
|
453
|
+
const seen = new Map(); // home slug → { carrier, first }: a member that CARRIES the policy beats one that follows it
|
|
454
|
+
for (const path of ws.projectPaths || []) {
|
|
455
|
+
const r = await resolveProjectPolicy(path, { discover: false }).catch(() => null);
|
|
456
|
+
if (!r?.ok) continue;
|
|
457
|
+
const cur = seen.get(r.home) || { carrier: null, first: path };
|
|
458
|
+
if (!r.delegated && !cur.carrier) cur.carrier = path;
|
|
459
|
+
seen.set(r.home, cur);
|
|
460
|
+
}
|
|
461
|
+
if (seen.size !== 1) return null;
|
|
462
|
+
const [{ carrier, first }] = seen.values();
|
|
463
|
+
return carrier || first;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** "Route all members to the policy home": one marker push per member without a branch. */
|
|
467
|
+
export async function routeWorkspaceMembersPolicy(workspaceId) {
|
|
468
|
+
const r = await resolveWorkspacePolicy(workspaceId);
|
|
469
|
+
if (!r.ok) throw policyError('NOT_ENABLED', r.detail || 'this workspace has no valid policy home');
|
|
470
|
+
const results = [];
|
|
471
|
+
for (const path of r.workspace.projectPaths) {
|
|
472
|
+
if (samePath(path, r.workspace.policyProject)) continue;
|
|
473
|
+
const prefs = (await discoverPolicy(path, { force: true }).catch(() => null)) || {};
|
|
474
|
+
const slug = prefs.slug ?? basename(path);
|
|
475
|
+
if (prefs.hasOrigin === false) { results.push({ path, slug, result: 'skipped', reason: 'no origin remote' }); continue; }
|
|
476
|
+
if (prefs.present) { results.push({ path, slug, result: 'skipped', reason: prefs.delegateTo ? `already follows ${prefs.delegateTo}` : 'already carries its own policy' }); continue; }
|
|
477
|
+
try { await enableTeamPolicy(path, { mode: 'follow', delegateTo: r.home }); results.push({ path, slug, result: 'routed' }); }
|
|
478
|
+
catch (err) { results.push({ path, slug, result: 'failed', code: err.code || 'ERROR', error: err.message, stderr: err.stderr || null, hint: err.hint || null }); }
|
|
479
|
+
}
|
|
480
|
+
return { home: r.home, results };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Scope list + statuses in one call (Team policy page, Projects cells, workspace cards, Settings readout). */
|
|
484
|
+
export async function listPolicyScopes({ discover = false } = {}) {
|
|
485
|
+
const projects = [];
|
|
486
|
+
for (const p of await listProjects()) projects.push(await projectPolicyStatus(p, { discover }));
|
|
487
|
+
const workspaces = [];
|
|
488
|
+
for (const w of await listWorkspaces()) workspaces.push(await workspacePolicyStatus(w, { discover }));
|
|
489
|
+
const homes = new Map();
|
|
490
|
+
for (const s of projects) if (s.home && !homes.has(s.home)) homes.set(s.home, { slug: s.home, key: s.homeKey, title: s.title, caps: s.caps, workspaceCaps: s.workspaceCaps, usedBy: [] });
|
|
491
|
+
for (const s of projects) if (s.home) homes.get(s.home).usedBy.push(s.slug);
|
|
492
|
+
const scopes = {
|
|
493
|
+
projects: projects.filter((s) => s.present && !s.blocked && !s.unknownSchema)
|
|
494
|
+
.map((s) => ({ id: `project:${s.key}`, label: s.slug, name: s.name, home: s.home, follows: s.delegateState === 'ok' ? s.home : null })),
|
|
495
|
+
workspaces: workspaces.filter((w) => w.home.state === 'ok')
|
|
496
|
+
.map((w) => ({ id: `workspace:${w.id}`, label: w.name, home: w.home.slug })),
|
|
497
|
+
};
|
|
498
|
+
return { projects, workspaces, scopes, homes: [...homes.values()], anyEnabled: scopes.projects.length + scopes.workspaces.length > 0 };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** UI server: discovery on start, then hourly (half-TTL interval, like metrics). Returns a stop function. */
|
|
502
|
+
export function startTeamPolicyBackground({ log = (m) => console.warn(m), onTick = null } = {}) {
|
|
503
|
+
const tick = async (force) => {
|
|
504
|
+
try { await discoverAllPolicies({ force }); } catch (err) { log(`[worca-ui] team policy discovery: ${err?.message || err}`); }
|
|
505
|
+
if (onTick) { try { await onTick(); } catch (err) { log(`[worca-ui] team policy tick: ${err?.message || err}`); } }
|
|
506
|
+
};
|
|
507
|
+
tick(true);
|
|
508
|
+
const timer = setInterval(() => tick(false), DISCOVERY_TTL_MS / 2);
|
|
509
|
+
timer.unref?.();
|
|
510
|
+
return () => clearInterval(timer);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export const _testing = { reset() { _queues.clear(); } };
|
package/src/core/protocol.mjs
CHANGED
|
@@ -192,6 +192,49 @@ export async function readQuestionsFile(absPath) {
|
|
|
192
192
|
return { ...normalizeClarify(parsed), malformed: false };
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Classify one parsed ask payload into the two shapes the runtime protocol
|
|
197
|
+
* allows (spec §4).
|
|
198
|
+
*
|
|
199
|
+
* { questions: [...] } the LEGACY shape — handed straight to
|
|
200
|
+
* normalizeClarify, caps and all
|
|
201
|
+
* { form: '<id>', data } the FORM shape (spec §4)
|
|
202
|
+
*
|
|
203
|
+
* A payload carrying BOTH an array `questions` and a `form` takes the LEGACY
|
|
204
|
+
* path: a payload today's readers already accept must never change meaning.
|
|
205
|
+
* Anything else is `{ kind: 'none' }`. PURE — the file reader below wraps it.
|
|
206
|
+
* @param {unknown} data
|
|
207
|
+
* @returns {{kind:'questions', questions:Array}|{kind:'form', form:string, data:object}|{kind:'none'}}
|
|
208
|
+
*/
|
|
209
|
+
export function classifyAskPayload(data) {
|
|
210
|
+
if (!data || typeof data !== 'object') return { kind: 'none' };
|
|
211
|
+
if (Array.isArray(data.questions)) return { kind: 'questions', ...normalizeClarify(data) };
|
|
212
|
+
const form = typeof data.form === 'string' ? data.form.trim() : '';
|
|
213
|
+
if (!form) return { kind: 'none' };
|
|
214
|
+
return { kind: 'form', form, data: data.data === undefined ? {} : data.data };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read one ask file (the per-round questions file, or a clarifier's answers
|
|
219
|
+
* port) from an ABSOLUTE path and classify it. Same tolerance as
|
|
220
|
+
* readQuestionsFile: a missing file is "the agent chose not to ask"
|
|
221
|
+
* (`malformed: false`), a present-but-unparseable one is `malformed: true` so
|
|
222
|
+
* the caller can audit-warn while proceeding.
|
|
223
|
+
* @param {string} absPath
|
|
224
|
+
* @returns {Promise<{kind:string, malformed:boolean, questions?:Array, form?:string, data?:object}>}
|
|
225
|
+
*/
|
|
226
|
+
export async function readAskFile(absPath) {
|
|
227
|
+
let text;
|
|
228
|
+
try {
|
|
229
|
+
text = await readFile(absPath, 'utf8');
|
|
230
|
+
} catch {
|
|
231
|
+
return { kind: 'none', malformed: false };
|
|
232
|
+
}
|
|
233
|
+
const parsed = safeParseJson(text);
|
|
234
|
+
if (parsed === null) return { kind: 'none', malformed: true };
|
|
235
|
+
return { ...classifyAskPayload(parsed), malformed: false };
|
|
236
|
+
}
|
|
237
|
+
|
|
195
238
|
/**
|
|
196
239
|
* Coerce arbitrary parsed data into the canonical review shape:
|
|
197
240
|
* { issues: [ { severity, title, detail, location } ], summary }
|