@worca/app 1.2.0 → 1.3.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 +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
// src/core/metrics/sync.mjs
|
|
2
|
+
// Team metrics write side + discovery (team-metrics-design.md §4.5–§4.8).
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { mkdir, mkdtemp, readdir, readFile, writeFile, rename, rm, copyFile, stat } from 'node:fs/promises';
|
|
7
|
+
import { join, dirname, basename, resolve } from 'node:path';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { worcaHome, listProjects } from '../projects.mjs';
|
|
10
|
+
import { projectKey, canonicalProjectRoot } from '../store.mjs';
|
|
11
|
+
import { listRemotes, parseRemoteUrl } from '../git-info.mjs';
|
|
12
|
+
import { readTeamMetricsPrefs, writeTeamMetricsPrefs } from '../config.mjs';
|
|
13
|
+
import { readWorkspace, listWorkspaces, isGitRepo } from '../workspaces.mjs';
|
|
14
|
+
import { withLock } from './lock.mjs';
|
|
15
|
+
import { writeRunLedger, readRunLedger, sweepRunLedger } from './ledger.mjs';
|
|
16
|
+
import { matchesWorkspace } from '../../shared/team-metrics/workspace-match.mjs';
|
|
17
|
+
|
|
18
|
+
export const METRICS_BRANCH = 'worca-metrics';
|
|
19
|
+
export const METRICS_DIR = '.worca-metrics';
|
|
20
|
+
export const REMOTE_REF = `refs/remotes/origin/${METRICS_BRANCH}`;
|
|
21
|
+
export const FETCH_REFSPEC = `+refs/heads/${METRICS_BRANCH}:${REMOTE_REF}`;
|
|
22
|
+
export const DISCOVERY_TTL_MS = 60 * 60_000;
|
|
23
|
+
export const OUTBOX_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
|
24
|
+
export const MAX_PUSH_ATTEMPTS = 5;
|
|
25
|
+
export const PROTECTION_HINT = `exempt \`${METRICS_BRANCH}\` from branch protection in the repository rules — Worca pushes to it directly, without a pull request`;
|
|
26
|
+
export const MIN_GIT = '2.31'; // --path-format=absolute (prune), init -b in tests; documented in docs/team-metrics.md
|
|
27
|
+
const README_TEXT = `# worca-metrics\n\nThis branch is written by Worca team metrics. Each file under \`.worca-metrics/runs/\` is one finished pipeline run (one JSON line). Do not edit it by hand and do not merge it into other branches. To stop recording for the whole team, delete this branch: \`git push origin --delete ${METRICS_BRANCH}\`.\n`;
|
|
28
|
+
|
|
29
|
+
export const metricsEvents = new EventEmitter();
|
|
30
|
+
const emit = (slug, action, extra = {}) => metricsEvents.emit('changed', { slug, action, ...extra });
|
|
31
|
+
|
|
32
|
+
// ---- paths ------------------------------------------------------------------
|
|
33
|
+
export function metricsRoot() { return join(worcaHome(), 'metrics'); }
|
|
34
|
+
|
|
35
|
+
/** Separator for "/" in directory names. `~` never occurs in a slug segment ([a-z0-9._-]),
|
|
36
|
+
* so "acme/my__repo" → "acme~my__repo" decodes back unambiguously and never nests. */
|
|
37
|
+
const SLUG_SEP = '~';
|
|
38
|
+
|
|
39
|
+
/** "acme/billing-api" → "acme~billing-api" (one directory level). */
|
|
40
|
+
export function slugDirName(slug) {
|
|
41
|
+
const segs = String(slug || '').toLowerCase().split('/');
|
|
42
|
+
if (!segs.length || segs.some((s) => !/^[a-z0-9._-]+$/.test(s) || s === '.' || s === '..')) {
|
|
43
|
+
throw Object.assign(new Error(`invalid metrics slug: ${slug}`), { code: 'BAD_REQUEST' });
|
|
44
|
+
}
|
|
45
|
+
return segs.join(SLUG_SEP);
|
|
46
|
+
}
|
|
47
|
+
export function slugFromDirName(name) { return String(name).split(SLUG_SEP).join('/'); }
|
|
48
|
+
export function worktreePath(slug) { return join(metricsRoot(), 'repos', slugDirName(slug)); }
|
|
49
|
+
export function outboxDir(slug) { return join(metricsRoot(), 'outbox', slugDirName(slug)); }
|
|
50
|
+
/** Empty hooks directory: metrics git commands must never run the project's hooks (decision 26). */
|
|
51
|
+
export function noHooksDir() { return join(metricsRoot(), 'no-hooks'); }
|
|
52
|
+
|
|
53
|
+
// ---- git ----------------------------------------------------------------------
|
|
54
|
+
// Env that must never leak into metrics git calls (e.g. when Worca itself runs under a git hook).
|
|
55
|
+
const STRIP_ENV = [
|
|
56
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_COMMON_DIR', 'GIT_PREFIX',
|
|
57
|
+
// The identity must come from `git config` or the Worca fallback, never from the ambient
|
|
58
|
+
// environment: these beat `-c user.name=…`, so under attribution:'none' a shell/CI that exports
|
|
59
|
+
// GIT_AUTHOR_NAME stamped the developer's real name onto the shared branch (verified).
|
|
60
|
+
'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_AUTHOR_DATE',
|
|
61
|
+
'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'GIT_COMMITTER_DATE',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** Applied to EVERY metrics git call (decision 33): no project hooks (reference-transaction fires on
|
|
65
|
+
* fetch/reset too), no signing prompts. A hooksPath that does not exist yet simply has no hooks. */
|
|
66
|
+
function hookFreeArgs() {
|
|
67
|
+
let dir;
|
|
68
|
+
try { dir = noHooksDir(); } catch { return ['-c', 'commit.gpgsign=false', '-c', 'push.gpgSign=false']; } // no worcaHome (bare unit test): nothing to write anyway
|
|
69
|
+
return ['-c', `core.hooksPath=${dir}`, '-c', 'commit.gpgsign=false', '-c', 'push.gpgSign=false'];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function defaultGit(cwd, args, { timeoutMs = 60_000, env = null } = {}) {
|
|
73
|
+
const base = { ...process.env };
|
|
74
|
+
for (const k of STRIP_ENV) delete base[k];
|
|
75
|
+
return new Promise((done) => {
|
|
76
|
+
execFile('git', [...hookFreeArgs(), ...args], {
|
|
77
|
+
cwd, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 32 * 1024 * 1024,
|
|
78
|
+
// Non-interactive and untranslated (isNonFastForward/pushHint parse stderr); same hardening as worktree.mjs ASK_GIT_ENV.
|
|
79
|
+
env: {
|
|
80
|
+
// No GIT_SSH_COMMAND override: it would silently beat the user's core.sshCommand (agents, keys).
|
|
81
|
+
// A passphrase prompt is bounded by the per-command timeout instead.
|
|
82
|
+
...base, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0', GIT_PAGER: 'cat', GIT_ASKPASS: '', SSH_ASKPASS: '',
|
|
83
|
+
...(env || {}),
|
|
84
|
+
},
|
|
85
|
+
}, (err, stdout, stderr) => done({
|
|
86
|
+
ok: !err,
|
|
87
|
+
code: err ? (typeof err.code === 'number' ? err.code : -1) : 0,
|
|
88
|
+
stdout: String(stdout || ''),
|
|
89
|
+
stderr: String(stderr || '') || (err ? String(err.message) : ''),
|
|
90
|
+
}));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
let _git = defaultGit;
|
|
94
|
+
export function runGit(cwd, args, opts) { return _git(cwd, args, opts); }
|
|
95
|
+
|
|
96
|
+
function metricsError(code, message, extra = {}) {
|
|
97
|
+
return Object.assign(new Error(message), { code, ...extra });
|
|
98
|
+
}
|
|
99
|
+
const firstLine = (s) => String(s || '').trim().split('\n').find(Boolean) || '';
|
|
100
|
+
|
|
101
|
+
// ---- identity -----------------------------------------------------------------
|
|
102
|
+
/** One slug segment → the slug alphabet. Percent-escapes are decoded first ("My%20Repo" → "my-repo"). */
|
|
103
|
+
function slugSegment(x) {
|
|
104
|
+
let d;
|
|
105
|
+
try { d = decodeURIComponent(x); } catch { d = x; }
|
|
106
|
+
return d.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[.-]+|-+$/g, '') || '_';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Machine- AND protocol-independent metrics identity from a remote URL (§4.3, decision 35).
|
|
111
|
+
* github.com keeps the familiar "owner/repo"; every other host keeps `host` + the FULL repo path.
|
|
112
|
+
* `remoteRepoSlug` keeps only the last two segments off github.com, which collides
|
|
113
|
+
* (gitlab.com/g1/sub/api vs g2/sub/api → both "gitlab.com/sub/api") and mangles Azure DevOps
|
|
114
|
+
* ("dev.azure.com/_git/api" for every repo named api, in every org).
|
|
115
|
+
* @returns {string|null} null when the URL has no usable repo path (caller falls back to the basename)
|
|
116
|
+
*/
|
|
117
|
+
export function metricsSlugFromUrl(url) {
|
|
118
|
+
const parsed = parseRemoteUrl(url);
|
|
119
|
+
if (!parsed) return null;
|
|
120
|
+
const s = String(url).trim();
|
|
121
|
+
const m = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/:]+(?::\d+)?\/(.+)$/i.exec(s) || /^(?:[^@/\s]+@)?[^:/\s]+:(.+)$/.exec(s);
|
|
122
|
+
let segs = (m ? m[1] : '').replace(/\/+$/, '').replace(/\.git$/i, '').split('/').filter(Boolean);
|
|
123
|
+
let host = String(parsed.host || '').toLowerCase();
|
|
124
|
+
// ssh and https spellings of the same Azure repo must give the same slug.
|
|
125
|
+
if (host === 'ssh.dev.azure.com' || host === 'vs-ssh.visualstudio.com') { host = 'dev.azure.com'; if (segs[0] === 'v3') segs = segs.slice(1); }
|
|
126
|
+
segs = segs.filter((x) => x !== '_git'); // Azure DevOps https
|
|
127
|
+
if (segs[0] === 'scm' && segs.length > 2) segs = segs.slice(1); // Bitbucket Server https
|
|
128
|
+
segs = segs.map(slugSegment);
|
|
129
|
+
if (segs.length < 2) return null;
|
|
130
|
+
return host === 'github.com' ? segs.slice(-2).join('/') : [slugSegment(host), ...segs].join('/');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** "owner/repo" (github) or "host/…/repo" from origin, else basename of the canonical root (§4.3). */
|
|
134
|
+
export async function projectSlug(projectDir) {
|
|
135
|
+
const r = await listRemotes(projectDir);
|
|
136
|
+
const origin = r.ok ? r.remotes.find((x) => x.name === 'origin') : null;
|
|
137
|
+
const url = origin?.pushUrl || origin?.fetchUrl || null;
|
|
138
|
+
// Basename fallback may hold spaces/unicode: sanitize so slugDirName never throws.
|
|
139
|
+
const fallback = slugSegment(basename(canonicalProjectRoot(projectDir))) || 'project';
|
|
140
|
+
// Never trust an unsanitized hosted slug: "dev.azure.com/org/My%20Project/_git/My Repo" would
|
|
141
|
+
// make slugDirName throw BAD_REQUEST, and every record for that project would be dropped.
|
|
142
|
+
const slug = (url && metricsSlugFromUrl(url)) || fallback;
|
|
143
|
+
return { slug, hasOrigin: !!origin, originUrl: origin?.fetchUrl ?? null, remotesOk: r.ok };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function gitUserName(dir) {
|
|
147
|
+
const r = await _git(dir, ['config', 'user.name'], { timeoutMs: 10_000 });
|
|
148
|
+
return r.ok ? r.stdout.trim() || null : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `-c` identity args for metrics commits (§4.5: git user, or Worca under attribution:none).
|
|
152
|
+
* A name without an email would make `git commit` fail, so both must be set to use the git user. */
|
|
153
|
+
async function identityArgs(dir, attribution) {
|
|
154
|
+
if (attribution !== 'none' && await gitUserName(dir)) {
|
|
155
|
+
const email = await _git(dir, ['config', 'user.email'], { timeoutMs: 10_000 });
|
|
156
|
+
if (email.ok && email.stdout.trim()) return [];
|
|
157
|
+
}
|
|
158
|
+
return ['-c', 'user.name=Worca', '-c', 'user.email=worca@local'];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const normAttribution = (a) => (a === 'none' ? 'none' : 'git-user');
|
|
162
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
163
|
+
|
|
164
|
+
// ---- discovery (§4.6) ---------------------------------------------------------------
|
|
165
|
+
// Two fetches of the SAME repository can collide on refs/remotes/origin/worca-metrics: discovery
|
|
166
|
+
// fetches outside the slug lock (background tick, page open, POST /discover, validateDelegateTarget,
|
|
167
|
+
// readScope, the CLI) while a flush fetches inside it. Observed: "cannot lock ref
|
|
168
|
+
// 'refs/remotes/origin/worca-metrics': is at … but expected …" (git 2.50) and "fetching ref … failed:
|
|
169
|
+
// incorrect old value provided" (git 2.53) — the flush then aborted with records ready to push.
|
|
170
|
+
const LOCAL_REF_RACE = /cannot lock ref|incorrect old value provided|unable to update local ref|Unable to create '[^']*\.lock'/;
|
|
171
|
+
|
|
172
|
+
export async function fetchMetricsBranch(dir, { timeoutMs = 120_000 } = {}) {
|
|
173
|
+
let r;
|
|
174
|
+
for (let i = 0; i < 3; i++) {
|
|
175
|
+
// --no-write-fetch-head (git ≥ 2.29, below MIN_GIT): a metrics fetch must not overwrite the
|
|
176
|
+
// developer's .git/FETCH_HEAD, which a concurrent `git pull` in the project reads (§4.5
|
|
177
|
+
// "invisible to the project's tooling").
|
|
178
|
+
r = await _git(dir, ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', FETCH_REFSPEC], { timeoutMs });
|
|
179
|
+
if (r.ok || !LOCAL_REF_RACE.test(r.stderr)) return r;
|
|
180
|
+
await new Promise((res) => setTimeout(res, 50 + Math.floor(Math.random() * 200)));
|
|
181
|
+
}
|
|
182
|
+
return r;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function readRemoteConfig(dir) {
|
|
186
|
+
const r = await _git(dir, ['show', `${REMOTE_REF}:${METRICS_DIR}/config.json`], { timeoutMs: 10_000 });
|
|
187
|
+
if (!r.ok) return null;
|
|
188
|
+
try {
|
|
189
|
+
const c = JSON.parse(r.stdout);
|
|
190
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
|
|
191
|
+
// Anyone with push access can hand-edit this file. A non-string delegateTo threw
|
|
192
|
+
// ("prefs.config.delegateTo.toLowerCase is not a function") out of workspaceMetricsStatus
|
|
193
|
+
// and 500'd GET /api/team-metrics/scopes, i.e. the whole page (verified with delegateTo: 42).
|
|
194
|
+
if (c.delegateTo != null && typeof c.delegateTo !== 'string') delete c.delegateTo;
|
|
195
|
+
if (c.attribution != null && typeof c.attribution !== 'string') delete c.attribution;
|
|
196
|
+
return c;
|
|
197
|
+
} catch { return null; }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `git ls-remote --heads origin worca-metrics`, cached in project_config.extra.teamMetrics.
|
|
202
|
+
* Network failure keeps the previous `enabled` verdict (offline ≠ disabled).
|
|
203
|
+
*/
|
|
204
|
+
export async function discoverProject(projectDir, { force = false, now = Date.now(), lsTimeoutMs = 20_000, fetchTimeoutMs = 120_000 } = {}) {
|
|
205
|
+
const key = projectKey(projectDir);
|
|
206
|
+
const prev = readTeamMetricsPrefs(key) || {};
|
|
207
|
+
if (!force && prev.checkedAt && now - Date.parse(prev.checkedAt) < DISCOVERY_TTL_MS) return prev;
|
|
208
|
+
// A missing directory (unmounted drive, moved checkout) is "unknown", not "no origin":
|
|
209
|
+
// never overwrite a cached enabled=true with it, and never create a junk config row.
|
|
210
|
+
if (!existsSync(projectDir)) return readTeamMetricsPrefs(key);
|
|
211
|
+
const { slug, hasOrigin, remotesOk, originUrl } = await projectSlug(projectDir);
|
|
212
|
+
// "github.com/acme/billing-api" for the Enable dialog (board 5); never includes credentials.
|
|
213
|
+
const originDisplay = originUrl ? String(originUrl).replace(/^[a-z+]+:\/\//i, '').replace(/^[^@/]*@/, '').replace(/:(?!\d)/, '/').replace(/\.git$/, '') : null;
|
|
214
|
+
if (!remotesOk) return prev.checkedAt ? prev : null;
|
|
215
|
+
// Two discoveries of one project can overlap (fire-and-forget on project add vs Enable's forced
|
|
216
|
+
// one, or the background tick): the one that started later wins; an older result never overwrites it.
|
|
217
|
+
const write = (patch) => {
|
|
218
|
+
const cur = readTeamMetricsPrefs(key);
|
|
219
|
+
if (cur?.checkedAt && Date.parse(cur.checkedAt) > now) return cur;
|
|
220
|
+
return writeTeamMetricsPrefs(key, patch);
|
|
221
|
+
};
|
|
222
|
+
if (!hasOrigin) {
|
|
223
|
+
// disabledAt starts the §4.7 retention clock. Without it, an outbox for a project whose origin
|
|
224
|
+
// was removed is kept forever and every flush reports BRANCH_MISSING for ever.
|
|
225
|
+
return write({
|
|
226
|
+
enabled: false, hasOrigin: false, remote: null, slug, originDisplay: null, config: null,
|
|
227
|
+
configKnown: false, headSha: null, checkedAt: iso(now), disabledAt: prev.disabledAt ?? iso(now),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const ls = await _git(projectDir, ['ls-remote', '--heads', 'origin', `refs/heads/${METRICS_BRANCH}`], { timeoutMs: lsTimeoutMs });
|
|
231
|
+
if (!ls.ok) {
|
|
232
|
+
// Offline ≠ disabled: keep the previous verdict. checkedAt is NOT advanced, so the next
|
|
233
|
+
// trigger (flush, page open, hourly tick) retries instead of waiting an hour.
|
|
234
|
+
return write({ hasOrigin: true, remote: 'origin', slug, lastDiscoveryError: firstLine(ls.stderr), lastDiscoveryAt: iso(now) });
|
|
235
|
+
}
|
|
236
|
+
const sha = ls.stdout.trim().split(/\s+/)[0] || null;
|
|
237
|
+
if (!sha) {
|
|
238
|
+
const next = write({
|
|
239
|
+
enabled: false, hasOrigin: true, remote: 'origin', slug, headSha: null, config: null, configKnown: false,
|
|
240
|
+
checkedAt: iso(now), lastDiscoveryError: null,
|
|
241
|
+
disabledAt: prev.enabled ? iso(now) : (prev.disabledAt ?? null),
|
|
242
|
+
});
|
|
243
|
+
if (prev.enabled) emit(slug, 'disabled');
|
|
244
|
+
return next;
|
|
245
|
+
}
|
|
246
|
+
let config = prev.config ?? null;
|
|
247
|
+
let headSha = prev.headSha ?? null;
|
|
248
|
+
if (sha !== prev.headSha || !prev.configKnown) {
|
|
249
|
+
const f = await fetchMetricsBranch(projectDir, { timeoutMs: fetchTimeoutMs });
|
|
250
|
+
if (!f.ok) {
|
|
251
|
+
// Decision 32: the branch exists but its config.json could not be read. Keep the old
|
|
252
|
+
// headSha + checkedAt (the next trigger refetches, so a stale config is never pinned) and
|
|
253
|
+
// never claim a config we have not read at all. A config read for an earlier head stays
|
|
254
|
+
// usable meanwhile: heads move on every teammate's flush, configs almost never do, and
|
|
255
|
+
// dropping it would silently skip every run recorded while offline.
|
|
256
|
+
const next = write({
|
|
257
|
+
enabled: true, hasOrigin: true, remote: 'origin', slug, originDisplay, disabledAt: null,
|
|
258
|
+
configKnown: !!prev.configKnown,
|
|
259
|
+
lastDiscoveryError: firstLine(f.stderr), lastDiscoveryAt: iso(now),
|
|
260
|
+
});
|
|
261
|
+
if (!prev.enabled) emit(slug, 'discovered');
|
|
262
|
+
return next;
|
|
263
|
+
}
|
|
264
|
+
config = await readRemoteConfig(projectDir);
|
|
265
|
+
headSha = sha;
|
|
266
|
+
}
|
|
267
|
+
const next = write({
|
|
268
|
+
enabled: true, hasOrigin: true, remote: 'origin', slug, originDisplay, headSha, config, configKnown: true,
|
|
269
|
+
checkedAt: iso(now), lastDiscoveryError: null, disabledAt: null,
|
|
270
|
+
});
|
|
271
|
+
if (!prev.enabled) emit(slug, 'discovered');
|
|
272
|
+
return next;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Enabled, config actually read, and not a delegation marker (decision 32). */
|
|
276
|
+
export const recordsLocally = (prefs) => !!prefs?.enabled && !!prefs.configKnown && !prefs.config?.delegateTo;
|
|
277
|
+
|
|
278
|
+
/** Every registered project plus every workspace metrics home (homes may be unregistered paths). */
|
|
279
|
+
export async function discoverAll({ force = false } = {}) {
|
|
280
|
+
const paths = new Set((await listProjects()).filter((p) => p.exists).map((p) => p.path));
|
|
281
|
+
for (const w of await listWorkspaces()) if (w.metricsProject) paths.add(w.metricsProject);
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const p of paths) {
|
|
284
|
+
try { out.push({ path: p, prefs: await discoverProject(p, { force }) }); }
|
|
285
|
+
catch (err) { out.push({ path: p, error: String(err?.message || err) }); }
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const isStale = (prefs) => !prefs?.checkedAt || Date.now() - Date.parse(prefs.checkedAt) >= DISCOVERY_TTL_MS;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* discover: true → rediscover when the cache is older than DISCOVERY_TTL_MS
|
|
294
|
+
* false → cache only (UI status calls)
|
|
295
|
+
* 'if-missing'→ cache only, unless there is no cache entry at all; then one bounded
|
|
296
|
+
* discovery (10 s ls-remote, 15 s fetch). Used by the terminal hook
|
|
297
|
+
* (decision 25) so a run's `done` never waits on the network.
|
|
298
|
+
*/
|
|
299
|
+
export const DISCOVERY_RETRY_MS = 5 * 60_000;
|
|
300
|
+
|
|
301
|
+
async function prefsFor(projectDir, { discover = true } = {}) {
|
|
302
|
+
let prefs = readTeamMetricsPrefs(projectKey(projectDir));
|
|
303
|
+
if (discover === 'if-missing') {
|
|
304
|
+
// A FAILED first discovery writes lastDiscoveryAt but no checkedAt, so keying only off
|
|
305
|
+
// checkedAt made every terminal hook pay 10 s ls-remote + 15 s fetch while offline —
|
|
306
|
+
// exactly what decision 25 forbids. A recent failed attempt counts as "tried".
|
|
307
|
+
const triedRecently = prefs?.checkedAt
|
|
308
|
+
|| (prefs?.lastDiscoveryAt && Date.now() - Date.parse(prefs.lastDiscoveryAt) < DISCOVERY_RETRY_MS);
|
|
309
|
+
if (!triedRecently) prefs = await discoverProject(projectDir, { lsTimeoutMs: 10_000, fetchTimeoutMs: 15_000 }).catch(() => prefs);
|
|
310
|
+
} else if (discover && isStale(prefs)) {
|
|
311
|
+
prefs = await discoverProject(projectDir).catch(() => prefs);
|
|
312
|
+
}
|
|
313
|
+
return prefs;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** First registered, existing project whose (cached or computed) slug matches. */
|
|
317
|
+
export async function findLocalProjectBySlug(slug) {
|
|
318
|
+
const want = String(slug || '').toLowerCase();
|
|
319
|
+
for (const p of await listProjects()) {
|
|
320
|
+
if (!p.exists) continue;
|
|
321
|
+
const cached = readTeamMetricsPrefs(p.key)?.slug;
|
|
322
|
+
const s = cached || (await projectSlug(p.path)).slug;
|
|
323
|
+
if (s === want) return p;
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* A local git checkout for `slug` that can own the metrics worktree: a registered project,
|
|
330
|
+
* else a workspace member / metrics home (homes need not be registered projects, so a
|
|
331
|
+
* workspace-run outbox must still find its repository). Used by flush and read.
|
|
332
|
+
* @returns {Promise<{key:string, path:string, name:string}|null>}
|
|
333
|
+
*/
|
|
334
|
+
export async function findLocalRepoBySlug(slug) {
|
|
335
|
+
const p = await findLocalProjectBySlug(slug);
|
|
336
|
+
if (p) return p;
|
|
337
|
+
const want = String(slug || '').toLowerCase();
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
for (const w of await listWorkspaces()) {
|
|
340
|
+
for (const path of [w.metricsProject, ...(w.projectPaths || [])]) {
|
|
341
|
+
if (!path || seen.has(path) || !existsSync(path)) continue;
|
|
342
|
+
seen.add(path);
|
|
343
|
+
const key = projectKey(path);
|
|
344
|
+
const s = readTeamMetricsPrefs(key)?.slug || (await projectSlug(path)).slug;
|
|
345
|
+
if (s === want) return { key, path, name: basename(path) };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function setRecordMyRuns(projectDir, record) {
|
|
352
|
+
const next = writeTeamMetricsPrefs(projectKey(projectDir), { record: record !== false });
|
|
353
|
+
emit(next.slug || null, 'record-toggled');
|
|
354
|
+
return next;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- resolver (§4.5 step 1, §4.6b, §4.8) --------------------------------------
|
|
358
|
+
function invalidDelegate(code, detail) {
|
|
359
|
+
return { ok: false, reason: 'delegate-invalid', code, detail };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Where a single-project run on `projectDir` is recorded.
|
|
364
|
+
* @returns {Promise<{ok:true, slug, projectDir, attribution, record, delegated, from}
|
|
365
|
+
* | {ok:false, reason:'no-origin'|'not-enabled'|'delegate-invalid', code?, detail?}>}
|
|
366
|
+
*/
|
|
367
|
+
export async function resolveProjectSink(projectDir, { discover = true } = {}) {
|
|
368
|
+
const own = await prefsFor(projectDir, { discover });
|
|
369
|
+
if (own?.hasOrigin === false) return { ok: false, reason: 'no-origin' };
|
|
370
|
+
if (!own?.enabled) return { ok: false, reason: 'not-enabled' };
|
|
371
|
+
// Decision 32: an unread config could be a delegation marker — never route onto it.
|
|
372
|
+
if (!own.configKnown) return { ok: false, reason: 'not-enabled', code: 'CONFIG_UNKNOWN', detail: 'the worca-metrics branch exists but could not be fetched yet' };
|
|
373
|
+
const record = own.record !== false;
|
|
374
|
+
const delegateTo = own.config?.delegateTo ? String(own.config.delegateTo).toLowerCase() : null;
|
|
375
|
+
if (!delegateTo) {
|
|
376
|
+
return { ok: true, slug: own.slug, projectDir, attribution: normAttribution(own.config?.attribution), record, delegated: false, from: own.slug };
|
|
377
|
+
}
|
|
378
|
+
if (delegateTo === own.slug) return invalidDelegate('DELEGATE_SELF', `${own.slug} delegates to itself`);
|
|
379
|
+
// Registered project OR workspace member/home (decision 11 / 28): a home need not be registered.
|
|
380
|
+
const target = await findLocalRepoBySlug(delegateTo);
|
|
381
|
+
if (!target) return invalidDelegate('DELEGATE_UNKNOWN', `points at ${delegateTo}, which is not a project in Worca on this machine`);
|
|
382
|
+
const t = await prefsFor(target.path, { discover });
|
|
383
|
+
if (!t?.enabled) return invalidDelegate('DELEGATE_DANGLING', `points at ${delegateTo}, which no longer records`);
|
|
384
|
+
if (!t.configKnown) return invalidDelegate('DELEGATE_UNKNOWN_CONFIG', `points at ${delegateTo}, whose branch could not be fetched yet`);
|
|
385
|
+
if (t.config?.delegateTo) return invalidDelegate('DELEGATE_CHAIN', `points at ${delegateTo}, which itself delegates to ${t.config.delegateTo} (no chains)`);
|
|
386
|
+
return { ok: true, slug: t.slug, projectDir: target.path, attribution: normAttribution(t.config?.attribution), record, delegated: true, from: own.slug };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const samePath = (a, b) => {
|
|
390
|
+
if (!a || !b) return false;
|
|
391
|
+
if (resolve(a) === resolve(b)) return true;
|
|
392
|
+
try { return canonicalProjectRoot(a) === canonicalProjectRoot(b); } catch { return false; }
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/** Where a workspace run is recorded: its local metrics home, which must record locally (§4.8). */
|
|
396
|
+
export async function resolveWorkspaceSink(workspaceId, { discover = true } = {}) {
|
|
397
|
+
const ws = workspaceId ? await readWorkspace(workspaceId) : null;
|
|
398
|
+
if (!ws) return { ok: false, reason: 'not-enabled' };
|
|
399
|
+
if (!ws.metricsProject) return { ok: false, reason: 'no-home' };
|
|
400
|
+
if (!ws.projectPaths.some((p) => samePath(p, ws.metricsProject))) {
|
|
401
|
+
return { ok: false, reason: 'home-stale', code: 'HOME_NOT_MEMBER', detail: 'the metrics home is no longer a workspace member' };
|
|
402
|
+
}
|
|
403
|
+
const home = await prefsFor(ws.metricsProject, { discover });
|
|
404
|
+
if (!home?.enabled) return { ok: false, reason: 'home-stale', code: 'HOME_BRANCH_MISSING', detail: 'branch missing on origin' };
|
|
405
|
+
if (!home.configKnown) return { ok: false, reason: 'home-stale', code: 'HOME_CONFIG_UNKNOWN', detail: 'branch not fetched yet' };
|
|
406
|
+
if (home.config?.delegateTo) return { ok: false, reason: 'home-stale', code: 'HOME_DELEGATES', detail: `the home delegates to ${home.config.delegateTo}; a home must record locally` };
|
|
407
|
+
return {
|
|
408
|
+
ok: true, slug: home.slug, projectDir: ws.metricsProject, attribution: normAttribution(home.config?.attribution),
|
|
409
|
+
record: home.record !== false, delegated: false, from: home.slug, workspace: ws,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---- worktree, enable/join, delegation marker (§5.7) --------------------------
|
|
414
|
+
/** git worktree of the project repo at metrics/repos/<slug>, detached at origin/worca-metrics.
|
|
415
|
+
* A corrupted checkout is removed and recreated (§4.7 — it holds nothing not on the remote/outbox). */
|
|
416
|
+
export async function ensureWorktree(slug, projectDir) {
|
|
417
|
+
const dir = worktreePath(slug);
|
|
418
|
+
if (existsSync(dir)) {
|
|
419
|
+
const top = await _git(dir, ['rev-parse', '--show-toplevel'], { timeoutMs: 10_000 });
|
|
420
|
+
let healthy = false;
|
|
421
|
+
// .native on both sides: JS realpathSync keeps the caller's case, git reports the on-disk case
|
|
422
|
+
// (a WORCA_HOME spelled with different case / 8.3 names would otherwise recreate it on every flush).
|
|
423
|
+
try { healthy = top.ok && realpathSync.native(top.stdout.trim()) === realpathSync.native(dir); } catch { healthy = false; }
|
|
424
|
+
if (healthy) return dir;
|
|
425
|
+
await removeWorktreeDir(dir, projectDir);
|
|
426
|
+
emit(slug, 'worktree-recreated');
|
|
427
|
+
}
|
|
428
|
+
await mkdir(dirname(dir), { recursive: true });
|
|
429
|
+
const f = await fetchMetricsBranch(projectDir);
|
|
430
|
+
if (!f.ok) throw metricsError('FETCH_FAILED', firstLine(f.stderr) || 'git fetch failed', { stderr: f.stderr });
|
|
431
|
+
// defaultGit already disables hooks (a failing post-checkout would otherwise fail `worktree add`).
|
|
432
|
+
let add = await _git(projectDir, ['worktree', 'add', '--detach', '--force', dir, REMOTE_REF]);
|
|
433
|
+
if (!add.ok) {
|
|
434
|
+
// A half-created directory from the first attempt makes the retry fail with "already exists".
|
|
435
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 10 });
|
|
436
|
+
await _git(projectDir, ['worktree', 'prune']);
|
|
437
|
+
add = await _git(projectDir, ['worktree', 'add', '--detach', '--force', dir, REMOTE_REF]);
|
|
438
|
+
if (!add.ok) throw metricsError('WORKTREE_FAILED', firstLine(add.stderr), { stderr: add.stderr });
|
|
439
|
+
}
|
|
440
|
+
return dir;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function removeWorktreeDir(dir, projectDir) {
|
|
444
|
+
if (projectDir) await _git(projectDir, ['worktree', 'remove', '--force', dir]);
|
|
445
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 10 });
|
|
446
|
+
if (projectDir) await _git(projectDir, ['worktree', 'prune']);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Build a parentless commit holding `files` ({relPath: content}) in projectDir's object store. */
|
|
450
|
+
async function orphanCommit(projectDir, files, message, attribution) {
|
|
451
|
+
const gitDir = (await _git(projectDir, ['rev-parse', '--absolute-git-dir'])).stdout.trim();
|
|
452
|
+
await mkdir(join(metricsRoot(), 'tmp'), { recursive: true });
|
|
453
|
+
const stage = await mkdtemp(join(metricsRoot(), 'tmp', 'enable-'));
|
|
454
|
+
const env = { GIT_DIR: gitDir, GIT_WORK_TREE: stage, GIT_INDEX_FILE: `${stage}.index` };
|
|
455
|
+
try {
|
|
456
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
457
|
+
await mkdir(dirname(join(stage, rel)), { recursive: true });
|
|
458
|
+
await writeFile(join(stage, rel), content, 'utf8');
|
|
459
|
+
}
|
|
460
|
+
const must = async (args) => {
|
|
461
|
+
const r = await _git(stage, args, { env });
|
|
462
|
+
if (!r.ok) throw metricsError('COMMIT_FAILED', firstLine(r.stderr), { stderr: r.stderr });
|
|
463
|
+
return r.stdout.trim();
|
|
464
|
+
};
|
|
465
|
+
// -f: $GIT_DIR/info/exclude and the user's global excludes apply to this index too.
|
|
466
|
+
await must(['add', '-A', '-f', '.']);
|
|
467
|
+
const tree = await must(['write-tree']);
|
|
468
|
+
return await must([...(await identityArgs(projectDir, attribution)), 'commit-tree', tree, '-m', message]);
|
|
469
|
+
} finally {
|
|
470
|
+
await rm(stage, { recursive: true, force: true });
|
|
471
|
+
await rm(`${stage}.index`, { force: true });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function isNonFastForward(stderr) {
|
|
476
|
+
const s = String(stderr || '');
|
|
477
|
+
return /\[rejected\][^\n]*\((fetch first|non-fast-forward)\)/.test(s)
|
|
478
|
+
|| /Updates were rejected because the (remote contains work|tip of your current branch is behind)/.test(s)
|
|
479
|
+
// A concurrent server-side ref update. The wording depends on the SERVER's git (all three
|
|
480
|
+
// observed against real bare origins; the retry never fired with only the first form):
|
|
481
|
+
// GitHub: "[remote rejected] … (cannot lock ref '…': is at X but expected Y)"
|
|
482
|
+
// git ≤ 2.50: "remote: error: cannot lock ref '…': is at X but expected Y"
|
|
483
|
+
// + " ! [remote rejected] HEAD -> worca-metrics (failed to update ref)"
|
|
484
|
+
// git ≥ 2.51: "[remote rejected] … (incorrect old value provided)"
|
|
485
|
+
|| /\[remote rejected\][^\n]*\((cannot lock ref|failed to update ref|incorrect old value provided)/.test(s)
|
|
486
|
+
|| /cannot lock ref [^\n]*is at [0-9a-f]+ but expected/.test(s);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Exported: the page's sync chip must show the same §4.7 hint the Projects cell does. */
|
|
490
|
+
export function pushHint(stderr) {
|
|
491
|
+
// GH006 = protected branch, GH013 = repository rulesets, GL-HOOK-ERR = GitLab, pre-receive = bare/self-hosted.
|
|
492
|
+
return /protected|hook declined|pre-receive|GH006|GH013|rule violation|push declined|GL-HOOK|denied/i.test(String(stderr || '')) ? PROTECTION_HINT : null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** @returns {Promise<{slug:string, attribution:'git-user'|'none'}>} the validated delegate. */
|
|
496
|
+
async function validateDelegateTarget(delegateTo, ownSlug) {
|
|
497
|
+
const want = String(delegateTo || '').toLowerCase();
|
|
498
|
+
if (!want) throw metricsError('BAD_REQUEST', 'delegateTo is required');
|
|
499
|
+
if (want === ownSlug) throw metricsError('DELEGATE_INVALID', 'a project cannot delegate to itself');
|
|
500
|
+
const target = await findLocalRepoBySlug(want); // registered project or workspace member/home (decision 11)
|
|
501
|
+
if (!target) throw metricsError('DELEGATE_INVALID', `${want} is not a project in Worca on this machine`);
|
|
502
|
+
const t = (await discoverProject(target.path, { force: true }).catch(() => null)) || {};
|
|
503
|
+
if (!t.enabled) throw metricsError('DELEGATE_INVALID', `${want} does not record team metrics`);
|
|
504
|
+
if (!t.configKnown) throw metricsError('FETCH_FAILED', `could not read the ${METRICS_BRANCH} branch of ${want}; try again`);
|
|
505
|
+
if (t.config?.delegateTo) throw metricsError('DELEGATE_INVALID', `${want} delegates to ${t.config.delegateTo}; pick a project that records locally`);
|
|
506
|
+
// §4.6b "attribution follows the delegate": the marker commit is made under the SINK's policy.
|
|
507
|
+
return { slug: want, attribution: normAttribution(t.config?.attribution) };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* POST /api/projects/:key/team-metrics/enable (§4.6, §4.6b).
|
|
512
|
+
* mode 'here' → create orphan branch with README + config {attribution}; or join if it exists.
|
|
513
|
+
* mode 'delegate' → create a marker branch {delegateTo}; or join; with change:true rewrite an existing marker.
|
|
514
|
+
* @returns {Promise<{action:'created'|'joined'|'changed', slug:string, config:object|null}>}
|
|
515
|
+
*/
|
|
516
|
+
export async function enableTeamMetrics(projectDir, { mode = 'here', attribution = 'git-user', delegateTo = null, change = false, now = new Date() } = {}) {
|
|
517
|
+
if (mode !== 'here' && mode !== 'delegate') throw metricsError('BAD_REQUEST', `mode must be "here" or "delegate"`);
|
|
518
|
+
const { slug, hasOrigin } = await projectSlug(projectDir);
|
|
519
|
+
if (!hasOrigin) throw metricsError('NO_ORIGIN', 'this project has no origin remote — team metrics push to origin, so there is nowhere to record');
|
|
520
|
+
const delegate = mode === 'delegate' ? await validateDelegateTarget(delegateTo, slug) : null;
|
|
521
|
+
const target = delegate?.slug ?? null;
|
|
522
|
+
// §4.6b: "attribution follows the delegate". A marker branch is committed under the DELEGATE's
|
|
523
|
+
// policy, not the caller's default — otherwise, on a team whose sink chose attribution:'none',
|
|
524
|
+
// every member's marker still carried the developer's real git name, and "Route all members"
|
|
525
|
+
// (which passes no attribution at all) did it for the whole workspace in one click.
|
|
526
|
+
const attr = mode === 'delegate' ? delegate.attribution : normAttribution(attribution);
|
|
527
|
+
const user = await gitUserName(projectDir);
|
|
528
|
+
const config = mode === 'delegate'
|
|
529
|
+
// enabledBy follows the same policy as the commit identity (decision 13): anonymising the
|
|
530
|
+
// marker's author while leaving the developer's name in its JSON body would defeat the point.
|
|
531
|
+
? { schema: 1, enabledAt: now.toISOString().replace(/\.\d{3}Z$/, 'Z'), enabledBy: attr === 'none' ? null : user, delegateTo: target }
|
|
532
|
+
: { schema: 1, enabledAt: now.toISOString().replace(/\.\d{3}Z$/, 'Z'), enabledBy: attr === 'none' ? null : user, attribution: attr, notes: '' };
|
|
533
|
+
|
|
534
|
+
const result = await withSlugLock(slug, async () => {
|
|
535
|
+
const ls = await _git(projectDir, ['ls-remote', '--heads', 'origin', `refs/heads/${METRICS_BRANCH}`], { timeoutMs: 20_000 });
|
|
536
|
+
if (!ls.ok) throw metricsError('REMOTE_UNREACHABLE', firstLine(ls.stderr), { stderr: ls.stderr });
|
|
537
|
+
if (ls.stdout.trim()) {
|
|
538
|
+
if (change && mode === 'delegate') return rewriteDelegation(slug, projectDir, config, attr);
|
|
539
|
+
await ensureWorktree(slug, projectDir);
|
|
540
|
+
return { action: 'joined' };
|
|
541
|
+
}
|
|
542
|
+
const commit = await orphanCommit(projectDir, {
|
|
543
|
+
'README.md': README_TEXT,
|
|
544
|
+
[`${METRICS_DIR}/config.json`]: JSON.stringify(config, null, 2) + '\n',
|
|
545
|
+
}, mode === 'delegate' ? `metrics: delegate to ${target}` : 'metrics: enable team metrics', attr);
|
|
546
|
+
const push = await _git(projectDir, ['push', '--no-verify', 'origin', `${commit}:refs/heads/${METRICS_BRANCH}`], { timeoutMs: 120_000 });
|
|
547
|
+
if (!push.ok) {
|
|
548
|
+
if (isNonFastForward(push.stderr)) { await ensureWorktree(slug, projectDir); return { action: 'joined' }; } // a teammate won the race
|
|
549
|
+
throw metricsError('PUSH_REJECTED', firstLine(push.stderr), { stderr: push.stderr, hint: pushHint(push.stderr) });
|
|
550
|
+
}
|
|
551
|
+
await ensureWorktree(slug, projectDir);
|
|
552
|
+
return { action: 'created' };
|
|
553
|
+
});
|
|
554
|
+
const prefs = (await discoverProject(projectDir, { force: true }).catch(() => null)) || {};
|
|
555
|
+
emit(slug, result.action);
|
|
556
|
+
return { action: result.action, slug, config: prefs.config ?? null };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Rewrite .worca-metrics/config.json on an existing marker branch (the "Change…" action). */
|
|
560
|
+
async function rewriteDelegation(slug, projectDir, config, attr = 'git-user') {
|
|
561
|
+
const dir = await ensureWorktree(slug, projectDir);
|
|
562
|
+
for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
|
|
563
|
+
const f = await fetchMetricsBranch(dir);
|
|
564
|
+
if (!f.ok) throw metricsError('FETCH_FAILED', firstLine(f.stderr), { stderr: f.stderr });
|
|
565
|
+
const reset = await _git(dir, ['reset', '--hard', REMOTE_REF]);
|
|
566
|
+
if (!reset.ok) throw metricsError('WORKTREE_FAILED', firstLine(reset.stderr), { stderr: reset.stderr });
|
|
567
|
+
const cur = await readFile(join(dir, METRICS_DIR, 'config.json'), 'utf8').then(JSON.parse).catch(() => ({}));
|
|
568
|
+
if (!cur.delegateTo) throw metricsError('DELEGATE_INVALID', 'this project records locally; its branch is not a delegation marker');
|
|
569
|
+
if (String(cur.delegateTo).toLowerCase() === config.delegateTo) return { action: 'changed' }; // already points there: nothing to commit
|
|
570
|
+
await writeFile(join(dir, METRICS_DIR, 'config.json'), JSON.stringify({ ...cur, enabledAt: config.enabledAt, enabledBy: config.enabledBy, delegateTo: config.delegateTo }, null, 2) + '\n');
|
|
571
|
+
await _git(dir, ['add', '-A', '-f', METRICS_DIR]); // single segment: no separator to normalise
|
|
572
|
+
// Same policy as enableTeamMetrics: the marker commit follows the DELEGATE's attribution,
|
|
573
|
+
// which enableTeamMetrics already validated and passes in as `attr`.
|
|
574
|
+
const c = await _git(dir, [...(await identityArgs(projectDir, attr)), 'commit', '-q', '--no-verify', '-m', `metrics: delegate to ${config.delegateTo}`]);
|
|
575
|
+
if (!c.ok) throw metricsError('COMMIT_FAILED', firstLine(c.stderr), { stderr: c.stderr });
|
|
576
|
+
const push = await _git(dir, ['push', '--no-verify', 'origin', `HEAD:refs/heads/${METRICS_BRANCH}`], { timeoutMs: 120_000 });
|
|
577
|
+
if (push.ok) return { action: 'changed' };
|
|
578
|
+
if (!isNonFastForward(push.stderr)) throw metricsError('PUSH_REJECTED', firstLine(push.stderr), { stderr: push.stderr, hint: pushHint(push.stderr) });
|
|
579
|
+
}
|
|
580
|
+
throw metricsError('PUSH_RETRIES_EXHAUSTED', 'the worca-metrics branch kept moving; try again');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---- outbox, per-slug queue + lock, flush, retention, prune (§5.8) ------------
|
|
584
|
+
const OUTBOX_FILE_RE = /^(\d{8}T\d{6}Z)-([A-Za-z0-9._-]{1,64})\.jsonl$/;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* "2026-09-15T14:30:12Z" + id → "20260915T143012Z-<id>.jsonl"
|
|
588
|
+
*
|
|
589
|
+
* The name is (startedAt, runId), so a run recorded once as `stopped` and again as `done` after a
|
|
590
|
+
* resume lands on the SAME path and the second flush rewrites that one file. That is deliberate
|
|
591
|
+
* (plan decision 37):
|
|
592
|
+
* the last terminal state of a run is the true one, one run stays one line, and the branch keeps
|
|
593
|
+
* one path per run (§4.5). It is the single exception to "records are immutable" (design §7, plan decision 37), which is
|
|
594
|
+
* about not back-filling PR state and cost corrections. If `startedAt` differs between the two
|
|
595
|
+
* records, both files exist and the reader keeps the first by `id` (§7.3 `seen`).
|
|
596
|
+
*/
|
|
597
|
+
export function recordFileName(record) {
|
|
598
|
+
// Normalise to UTC first: a `…+02:00` stamp survives the [-:] strip as `20260915T163012+0200`,
|
|
599
|
+
// fails OUTBOX_FILE_RE and throws BAD_REQUEST — i.e. the record is silently dropped. Step 3
|
|
600
|
+
// always feeds toISOString(), so this only makes the invariant hold by construction.
|
|
601
|
+
const src = record.startedAt || record.recordedAt;
|
|
602
|
+
const ms = Date.parse(src);
|
|
603
|
+
const iso = Number.isFinite(ms) ? new Date(ms).toISOString() : String(src);
|
|
604
|
+
const ts = iso.replace(/[-:]/g, '').replace(/\.\d+/, '');
|
|
605
|
+
const name = `${ts}-${String(record.id).replace(/[^A-Za-z0-9._-]/g, '-')}.jsonl`;
|
|
606
|
+
if (!OUTBOX_FILE_RE.test(name)) throw metricsError('BAD_REQUEST', `cannot name a record file for ${record.id}`);
|
|
607
|
+
return name;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** .worca-metrics/runs/YYYY/MM/<name> (month by startedAt UTC, §4.2). */
|
|
611
|
+
export function runFilePath(name) {
|
|
612
|
+
return join(METRICS_DIR, 'runs', name.slice(0, 4), name.slice(4, 6), name);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Durability point (§4.5): atomic write of one JSON line into the slug's outbox. */
|
|
616
|
+
export async function writeOutbox(slug, record) {
|
|
617
|
+
const dir = outboxDir(slug);
|
|
618
|
+
await mkdir(dir, { recursive: true });
|
|
619
|
+
const name = recordFileName(record);
|
|
620
|
+
const tmp = join(dir, `.${name}.${randomBytes(4).toString('hex')}.tmp`);
|
|
621
|
+
await writeFile(tmp, JSON.stringify(record) + '\n', 'utf8');
|
|
622
|
+
await rename(tmp, join(dir, name));
|
|
623
|
+
return name;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export async function listOutbox(slug) {
|
|
627
|
+
try { return (await readdir(outboxDir(slug))).filter((n) => OUTBOX_FILE_RE.test(n)).sort(); }
|
|
628
|
+
catch { return []; }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Every slug with an outbox directory (encoded names decoded back). */
|
|
632
|
+
export async function listOutboxSlugs() {
|
|
633
|
+
try {
|
|
634
|
+
const names = await readdir(join(metricsRoot(), 'outbox'), { withFileTypes: true });
|
|
635
|
+
return names.filter((d) => d.isDirectory()).map((d) => slugFromDirName(d.name));
|
|
636
|
+
} catch { return []; }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const runIdOf = (name) => OUTBOX_FILE_RE.exec(name)?.[2] ?? null;
|
|
640
|
+
|
|
641
|
+
// In-process serialization per slug, then the cross-process lock file (§4.5).
|
|
642
|
+
const _queues = new Map();
|
|
643
|
+
export function withSlugLock(slug, fn) {
|
|
644
|
+
const lockFile = join(outboxDir(slug), '.lock');
|
|
645
|
+
const prev = _queues.get(lockFile) || Promise.resolve();
|
|
646
|
+
const run = prev.catch(() => {}).then(() => withLock(lockFile, fn));
|
|
647
|
+
const tail = run.catch(() => {});
|
|
648
|
+
_queues.set(lockFile, tail);
|
|
649
|
+
tail.then(() => { if (_queues.get(lockFile) === tail) _queues.delete(lockFile); });
|
|
650
|
+
return run;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function backoffMs(attempt, random = Math.random) {
|
|
654
|
+
const base = 250 * 2 ** (attempt - 1);
|
|
655
|
+
return Math.round(base / 2 + random() * base);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const _lastFailedAt = new Map(); // slug → ms of the last failed flush (page-open throttle)
|
|
659
|
+
export const PAGE_OPEN_FLUSH_BACKOFF_MS = 60_000;
|
|
660
|
+
|
|
661
|
+
function recordFailure(ownerKey, slug, code, stderr, pending) {
|
|
662
|
+
_lastFailedAt.set(slug, Date.now());
|
|
663
|
+
const hint = code === 'PUSH_REJECTED' ? pushHint(stderr) : null;
|
|
664
|
+
if (ownerKey) writeTeamMetricsPrefs(ownerKey, { lastError: String(stderr || code).trim(), lastErrorCode: code, lastErrorAt: new Date().toISOString() });
|
|
665
|
+
emit(slug, 'flush-failed', { code });
|
|
666
|
+
return { ok: false, slug, code, stderr: String(stderr || ''), hint, pending, pushed: 0 };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** §4.7: once the branch is gone, the outbox is retained for 30 days from the moment discovery
|
|
670
|
+
* saw it disappear (`prefs.disabledAt`), then dropped with a log line. */
|
|
671
|
+
async function dropExpired(slug, files, now, prefs) {
|
|
672
|
+
const disabledMs = Date.parse(prefs?.disabledAt ?? '');
|
|
673
|
+
if (!Number.isFinite(disabledMs) || now - disabledMs <= OUTBOX_RETENTION_MS) return files;
|
|
674
|
+
const kept = [];
|
|
675
|
+
for (const name of files) {
|
|
676
|
+
const p = join(outboxDir(slug), name);
|
|
677
|
+
const st = await stat(p).catch(() => null);
|
|
678
|
+
// A record written AFTER the deletion (a teammate's stale cache) gets its own 30 days.
|
|
679
|
+
if (st && now - Math.max(st.mtimeMs, disabledMs) > OUTBOX_RETENTION_MS) {
|
|
680
|
+
await rm(p, { force: true });
|
|
681
|
+
console.warn(`[worca] team metrics: dropped ${name} from the ${slug} outbox — the ${METRICS_BRANCH} branch is gone and the record is older than 30 days`);
|
|
682
|
+
const id = runIdOf(name);
|
|
683
|
+
if (id) writeRunLedger(id, { state: 'skipped', slug, reason: 'expired', detail: 'branch deleted; outbox retention elapsed' });
|
|
684
|
+
} else kept.push(name);
|
|
685
|
+
}
|
|
686
|
+
return kept;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Flush one slug's outbox (§4.5): fetch → reset --hard origin/worca-metrics → copy → commit → push,
|
|
691
|
+
* retrying ≤ MAX_PUSH_ATTEMPTS with jittered backoff on non-fast-forward. Never throws.
|
|
692
|
+
* @param {{hooks?:{beforePush?:Function}, sleep?:Function, random?:Function, now?:number}} [opts]
|
|
693
|
+
*/
|
|
694
|
+
export async function flushSlug(slug, { hooks: testHooks = {}, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), random = Math.random, now = Date.now() } = {}) {
|
|
695
|
+
try {
|
|
696
|
+
return await withSlugLock(slug, async () => {
|
|
697
|
+
let files = await listOutbox(slug);
|
|
698
|
+
if (!files.length) {
|
|
699
|
+
// Nothing left to push: a stale lastError (e.g. the files were flushed by the CLI) must not
|
|
700
|
+
// keep the Projects cell on "Retry" forever.
|
|
701
|
+
const owner = await findLocalRepoBySlug(slug).catch(() => null);
|
|
702
|
+
if (owner && readTeamMetricsPrefs(projectKey(owner.path))?.lastError) {
|
|
703
|
+
writeTeamMetricsPrefs(projectKey(owner.path), { lastError: null, lastErrorCode: null, lastErrorAt: null });
|
|
704
|
+
}
|
|
705
|
+
return { ok: true, slug, pushed: 0, pending: 0 };
|
|
706
|
+
}
|
|
707
|
+
const owner = await findLocalRepoBySlug(slug); // registered project OR workspace member/home (decision 28)
|
|
708
|
+
if (!owner) return recordFailure(null, slug, 'NO_LOCAL_PROJECT', `no local checkout of ${slug} is known to Worca on this machine`, files.length);
|
|
709
|
+
const ownerKey = projectKey(owner.path);
|
|
710
|
+
const prefs = (await discoverProject(owner.path).catch(() => null)) || readTeamMetricsPrefs(ownerKey);
|
|
711
|
+
if (prefs && prefs.enabled === false) {
|
|
712
|
+
files = await dropExpired(slug, files, now, prefs);
|
|
713
|
+
return recordFailure(ownerKey, slug, 'BRANCH_MISSING', `origin has no ${METRICS_BRANCH} branch any more`, files.length);
|
|
714
|
+
}
|
|
715
|
+
// Decision 32 / §4.6b: a delegation marker never holds run files — e.g. the old sink became a
|
|
716
|
+
// marker while records were queued, or its config is still unread. Keep the outbox and report.
|
|
717
|
+
if (prefs?.config?.delegateTo) {
|
|
718
|
+
return recordFailure(ownerKey, slug, 'SINK_DELEGATES', `${slug} now delegates to ${prefs.config.delegateTo}; a delegation marker never holds run files`, files.length);
|
|
719
|
+
}
|
|
720
|
+
if (prefs && prefs.enabled && !prefs.configKnown) {
|
|
721
|
+
return recordFailure(ownerKey, slug, 'FETCH_FAILED', prefs.lastDiscoveryError || `could not read the ${METRICS_BRANCH} config of ${slug}`, files.length);
|
|
722
|
+
}
|
|
723
|
+
let dir;
|
|
724
|
+
try { dir = await ensureWorktree(slug, owner.path); }
|
|
725
|
+
catch (err) { return recordFailure(ownerKey, slug, err.code || 'WORKTREE_FAILED', err.stderr || err.message, files.length); }
|
|
726
|
+
const identity = await identityArgs(owner.path, normAttribution(prefs?.config?.attribution));
|
|
727
|
+
|
|
728
|
+
for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
|
|
729
|
+
const f = await fetchMetricsBranch(dir);
|
|
730
|
+
if (!f.ok) {
|
|
731
|
+
// The branch is gone on origin. The cached verdict can be up to an hour old (the
|
|
732
|
+
// discover above is TTL-gated), so refresh it now: otherwise §4.7's 30-day retention
|
|
733
|
+
// clock (prefs.disabledAt) starts late and the cell shows a raw git error instead of
|
|
734
|
+
// "branch missing" (verified with a fresh cache after a branch deletion).
|
|
735
|
+
if (/couldn't find remote ref|no such ref|not our ref/i.test(f.stderr)) {
|
|
736
|
+
const fresh = (await discoverProject(owner.path, { force: true }).catch(() => null)) || {};
|
|
737
|
+
if (fresh.enabled === false) {
|
|
738
|
+
files = await dropExpired(slug, files, now, fresh);
|
|
739
|
+
return recordFailure(ownerKey, slug, 'BRANCH_MISSING', `origin has no ${METRICS_BRANCH} branch any more`, files.length);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return recordFailure(ownerKey, slug, 'FETCH_FAILED', f.stderr, files.length);
|
|
743
|
+
}
|
|
744
|
+
const reset = await _git(dir, ['reset', '--hard', REMOTE_REF]);
|
|
745
|
+
if (!reset.ok) {
|
|
746
|
+
await removeWorktreeDir(dir, owner.path); // recreated on the next trigger (§4.7)
|
|
747
|
+
return recordFailure(ownerKey, slug, 'WORKTREE_FAILED', reset.stderr, files.length);
|
|
748
|
+
}
|
|
749
|
+
await _git(dir, ['clean', '-fdq']);
|
|
750
|
+
for (const name of files) {
|
|
751
|
+
const dest = join(dir, runFilePath(name));
|
|
752
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
753
|
+
await copyFile(join(outboxDir(slug), name), dest);
|
|
754
|
+
}
|
|
755
|
+
// -f: info/exclude (shared by every worktree) or a global excludes file matching *.jsonl
|
|
756
|
+
// would otherwise drop the records silently — and the clean-tree branch below would
|
|
757
|
+
// then delete them from the outbox as "already pushed" (data loss).
|
|
758
|
+
// posix pathspec: join() yields `.worca-metrics\\runs` on win32, which git does not match.
|
|
759
|
+
const add = await _git(dir, ['add', '-A', '-f', `${METRICS_DIR}/runs`]);
|
|
760
|
+
if (!add.ok) return recordFailure(ownerKey, slug, 'COMMIT_FAILED', add.stderr, files.length);
|
|
761
|
+
const status = await _git(dir, ['status', '--porcelain']);
|
|
762
|
+
if (status.ok && !status.stdout.trim()) {
|
|
763
|
+
// Nothing to commit: only trust it if every file really is in HEAD (an earlier push whose cleanup failed).
|
|
764
|
+
const missing = [];
|
|
765
|
+
for (const name of files) {
|
|
766
|
+
const inHead = await _git(dir, ['cat-file', '-e', `HEAD:${runFilePath(name).split('\\').join('/')}`]);
|
|
767
|
+
if (!inHead.ok) missing.push(name);
|
|
768
|
+
}
|
|
769
|
+
if (missing.length) return recordFailure(ownerKey, slug, 'COMMIT_FAILED', `git did not stage ${missing.length} record file(s): ${missing[0]}`, files.length);
|
|
770
|
+
await finishFlushed(slug, files, ownerKey);
|
|
771
|
+
return { ok: true, slug, pushed: 0, pending: 0, attempts: attempt };
|
|
772
|
+
}
|
|
773
|
+
const commit = await _git(dir, [...identity, 'commit', '-q', '--no-verify', '-m', `metrics: ${files.length} run(s)`]);
|
|
774
|
+
if (!commit.ok) return recordFailure(ownerKey, slug, 'COMMIT_FAILED', commit.stderr, files.length);
|
|
775
|
+
if (testHooks.beforePush) await testHooks.beforePush({ attempt, dir });
|
|
776
|
+
const push = await _git(dir, ['push', '--no-verify', 'origin', `HEAD:refs/heads/${METRICS_BRANCH}`], { timeoutMs: 120_000 });
|
|
777
|
+
if (push.ok) {
|
|
778
|
+
await finishFlushed(slug, files, ownerKey);
|
|
779
|
+
return { ok: true, slug, pushed: files.length, pending: 0, attempts: attempt };
|
|
780
|
+
}
|
|
781
|
+
if (!isNonFastForward(push.stderr)) return recordFailure(ownerKey, slug, 'PUSH_REJECTED', push.stderr, files.length);
|
|
782
|
+
if (attempt === MAX_PUSH_ATTEMPTS) return recordFailure(ownerKey, slug, 'PUSH_RETRIES_EXHAUSTED', push.stderr, files.length);
|
|
783
|
+
await sleep(backoffMs(attempt, random));
|
|
784
|
+
}
|
|
785
|
+
return recordFailure(ownerKey, slug, 'PUSH_RETRIES_EXHAUSTED', '', files.length); // unreachable
|
|
786
|
+
});
|
|
787
|
+
} catch (err) {
|
|
788
|
+
return { ok: false, slug, code: err?.code || 'FLUSH_FAILED', stderr: String(err?.message || err), hint: null, pending: (await listOutbox(slug)).length, pushed: 0 };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function finishFlushed(slug, files, ownerKey) {
|
|
793
|
+
_lastFailedAt.delete(slug);
|
|
794
|
+
for (const name of files) {
|
|
795
|
+
await rm(join(outboxDir(slug), name), { force: true });
|
|
796
|
+
const id = runIdOf(name);
|
|
797
|
+
if (id) writeRunLedger(id, { state: 'recorded', slug });
|
|
798
|
+
}
|
|
799
|
+
writeTeamMetricsPrefs(ownerKey, { lastSyncAt: new Date().toISOString(), lastError: null, lastErrorCode: null, lastErrorAt: null });
|
|
800
|
+
emit(slug, 'flushed', { count: files.length });
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export async function flushAll(opts) {
|
|
804
|
+
const out = [];
|
|
805
|
+
for (const slug of await listOutboxSlugs()) {
|
|
806
|
+
if ((await listOutbox(slug)).length) out.push(await flushSlug(slug, opts));
|
|
807
|
+
}
|
|
808
|
+
return out;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Flush whatever a project's runs are recorded into (its own slug or its delegate). */
|
|
812
|
+
export async function flushProject(projectDir, opts) {
|
|
813
|
+
const sink = await resolveProjectSink(projectDir);
|
|
814
|
+
if (!sink.ok) return { ok: false, slug: null, code: sink.reason.toUpperCase().replace(/-/g, '_'), stderr: sink.detail || sink.reason, pending: 0, pushed: 0 };
|
|
815
|
+
return flushSlug(sink.slug, opts);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Coalesced background flushes (after every record; on server start; page open; Push now).
|
|
819
|
+
const _scheduled = new Map();
|
|
820
|
+
const _inflight = new Set();
|
|
821
|
+
export function scheduleFlush(slug, opts = {}) {
|
|
822
|
+
// Page-open trigger backs off after a recent failure (see "Page-open flush throttle" in §5.9).
|
|
823
|
+
if (opts.reason === 'page-open' && Date.now() - (_lastFailedAt.get(slug) ?? -Infinity) < PAGE_OPEN_FLUSH_BACKOFF_MS) return null;
|
|
824
|
+
if (_scheduled.has(slug)) return _scheduled.get(slug);
|
|
825
|
+
const p = new Promise((r) => setImmediate(r))
|
|
826
|
+
.then(() => { _scheduled.delete(slug); return flushSlug(slug, opts); });
|
|
827
|
+
_scheduled.set(slug, p);
|
|
828
|
+
_inflight.add(p);
|
|
829
|
+
// `void p.catch(...)`: the promise returned by a bare `.finally()` is discarded, so any future
|
|
830
|
+
// throw between `_scheduled.delete` and flushSlug's own try would surface as an unhandled
|
|
831
|
+
// rejection in the UI server process. flushSlug never rejects today; this keeps it that way.
|
|
832
|
+
void p.catch(() => {}).finally(() => _inflight.delete(p));
|
|
833
|
+
return p;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Await in-flight flushes (CLI before exit). Resolves after `timeoutMs` regardless. */
|
|
837
|
+
export async function drainFlushes({ timeoutMs = 30_000 } = {}) {
|
|
838
|
+
if (!_inflight.size) return;
|
|
839
|
+
await Promise.race([
|
|
840
|
+
Promise.allSettled([..._inflight]),
|
|
841
|
+
new Promise((r) => setTimeout(r, timeoutMs).unref?.()),
|
|
842
|
+
]);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Periodic outbox retention for slugs whose branch the team deleted (§4.7). */
|
|
846
|
+
export async function sweepOutboxRetention({ now = Date.now() } = {}) {
|
|
847
|
+
for (const slug of await listOutboxSlugs()) {
|
|
848
|
+
const owner = await findLocalRepoBySlug(slug).catch(() => null);
|
|
849
|
+
const prefs = owner ? readTeamMetricsPrefs(projectKey(owner.path)) : null;
|
|
850
|
+
if (prefs && prefs.enabled === false) await withSlugLock(slug, async () => dropExpired(slug, await listOutbox(slug), now, prefs));
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* removeProject hook: drop metrics worktrees that belong to this project's repository (§4.5).
|
|
856
|
+
* Matches by the worktree's `.git` file (`gitdir: <common>/worktrees/<name>`), which still works
|
|
857
|
+
* when the project directory is already gone. Each removal holds the slug lock, so it never
|
|
858
|
+
* pulls a worktree out from under a running flush or read.
|
|
859
|
+
*/
|
|
860
|
+
export async function pruneMetricsWorktreesFor(projectPath) {
|
|
861
|
+
const reposDir = join(metricsRoot(), 'repos');
|
|
862
|
+
if (!existsSync(reposDir)) return [];
|
|
863
|
+
const common = await _git(projectPath, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
864
|
+
const want = common.ok ? safeReal(common.stdout.trim()) : safeReal(join(projectPath, '.git'));
|
|
865
|
+
const removed = [];
|
|
866
|
+
for (const name of await readdir(reposDir)) {
|
|
867
|
+
const dir = join(reposDir, name);
|
|
868
|
+
const gitFile = await readFile(join(dir, '.git'), 'utf8').catch(() => '');
|
|
869
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(gitFile);
|
|
870
|
+
const worktreeGitDir = m ? resolve(dir, m[1]) : null;
|
|
871
|
+
// <common>/worktrees/<name> → <common>
|
|
872
|
+
const owned = worktreeGitDir && safeReal(dirname(dirname(worktreeGitDir))) === want;
|
|
873
|
+
if (!owned) continue;
|
|
874
|
+
// try/catch per entry: withSlugLock -> outboxDir -> slugDirName throws BAD_REQUEST for any
|
|
875
|
+
// metrics/repos/ entry whose name is not a valid slug, and one stray directory must not abort
|
|
876
|
+
// the prune for every other worktree of this repository.
|
|
877
|
+
try {
|
|
878
|
+
const slug = slugFromDirName(name);
|
|
879
|
+
await withSlugLock(slug, async () => {
|
|
880
|
+
await removeWorktreeDir(dir, common.ok ? projectPath : null);
|
|
881
|
+
// The DB row for the removed project is already gone (removeProject deletes it before
|
|
882
|
+
// calling this), so findLocalRepoBySlug(slug) now reflects the post-removal state: it
|
|
883
|
+
// still finds another local checkout of the same slug (a second clone, or a workspace
|
|
884
|
+
// member/home) if one exists. Only drop the outbox when nothing can push it any more —
|
|
885
|
+
// otherwise queued records that are still pushable would be silently lost.
|
|
886
|
+
const owner = await findLocalRepoBySlug(slug).catch(() => null);
|
|
887
|
+
if (owner) return;
|
|
888
|
+
for (const file of await listOutbox(slug)) {
|
|
889
|
+
console.warn(`[worca] team metrics: dropped ${file} from the ${slug} outbox — the project that owned its worktree was removed and no local checkout can push it any more`);
|
|
890
|
+
const id = runIdOf(file);
|
|
891
|
+
if (id) writeRunLedger(id, { state: 'skipped', slug, reason: 'project-removed' });
|
|
892
|
+
}
|
|
893
|
+
await rm(outboxDir(slug), { recursive: true, force: true, maxRetries: 10 });
|
|
894
|
+
});
|
|
895
|
+
removed.push(dir);
|
|
896
|
+
} catch (err) {
|
|
897
|
+
console.warn(`[worca] team metrics: could not prune ${dir}: ${err?.message || err}`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return removed;
|
|
901
|
+
}
|
|
902
|
+
/** realpath of the longest existing prefix + the missing tail. git writes `gitdir:` realpath'd
|
|
903
|
+
* (/private/var/… on macOS), so a deleted clone under /var/… must still compare equal. */
|
|
904
|
+
function safeReal(p) {
|
|
905
|
+
let cur = resolve(p);
|
|
906
|
+
const tail = [];
|
|
907
|
+
for (;;) {
|
|
908
|
+
try { return join(realpathSync.native(cur), ...tail); } catch { /* walk up */ }
|
|
909
|
+
const up = dirname(cur);
|
|
910
|
+
if (up === cur) return resolve(p);
|
|
911
|
+
tail.unshift(basename(cur));
|
|
912
|
+
cur = up;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ---- scan, route, status, background, test seam (§5.9) ------------------------
|
|
917
|
+
async function countRuns(slug) {
|
|
918
|
+
// Local worktree only (no fetch): cheap status counts for cards. read.mjs owns parsing.
|
|
919
|
+
const { readRecordsFromDir } = await import('./read.mjs');
|
|
920
|
+
const dir = worktreePath(slug);
|
|
921
|
+
if (!existsSync(dir)) return null;
|
|
922
|
+
return (await readRecordsFromDir(dir)).records;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Status for the Projects page cell and the scope list (§4.11). */
|
|
926
|
+
export async function projectMetricsStatus(p, { discover = false, recordsBySink = new Map() } = {}) {
|
|
927
|
+
const prefs = await prefsFor(p.path, { discover }) || {};
|
|
928
|
+
const status = {
|
|
929
|
+
key: p.key, name: p.name, path: p.path, exists: p.exists,
|
|
930
|
+
slug: prefs.slug ?? null, hasOrigin: prefs.hasOrigin ?? null, enabled: !!prefs.enabled,
|
|
931
|
+
enabledAt: prefs.config?.enabledAt ?? null, attribution: prefs.config?.delegateTo ? null : (prefs.enabled ? normAttribution(prefs.config?.attribution) : null),
|
|
932
|
+
delegateTo: prefs.config?.delegateTo ?? null, delegateState: null, delegateDetail: null, delegateCode: null, blocked: null,
|
|
933
|
+
origin: prefs.originDisplay ?? null,
|
|
934
|
+
recordsLocally: recordsLocally(prefs),
|
|
935
|
+
record: prefs.record !== false, pending: 0, runs: null,
|
|
936
|
+
lastSyncAt: prefs.lastSyncAt ?? null, lastError: prefs.lastError ?? null, lastErrorCode: prefs.lastErrorCode ?? null,
|
|
937
|
+
lastErrorHint: prefs.lastErrorCode === 'PUSH_REJECTED' ? pushHint(prefs.lastError) : null,
|
|
938
|
+
checkedAt: prefs.checkedAt ?? null, sinkSlug: null,
|
|
939
|
+
};
|
|
940
|
+
// Undiscovered project (no prefs row yet): settle `hasOrigin` now rather than leaving
|
|
941
|
+
// it null, which made the cell read "Off" and offer "Set up team metrics…" to folders
|
|
942
|
+
// that can never record — a non-git folder (no origin to push to; the workspace scan
|
|
943
|
+
// refuses it) or a git repo without an origin remote (setup would fail NO_ORIGIN).
|
|
944
|
+
// Only undiscovered projects pay for these git spawns; discovered ones carry the
|
|
945
|
+
// answer in prefs.
|
|
946
|
+
if (status.hasOrigin == null && p.exists) {
|
|
947
|
+
if (!isGitRepo(p.path)) { status.hasOrigin = false; status.noGit = true; }
|
|
948
|
+
else {
|
|
949
|
+
const { hasOrigin, slug } = await projectSlug(p.path);
|
|
950
|
+
status.hasOrigin = hasOrigin;
|
|
951
|
+
if (status.slug == null) status.slug = slug;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (!status.enabled || !p.exists) return status;
|
|
955
|
+
const sink = await resolveProjectSink(p.path, { discover });
|
|
956
|
+
if (!sink.ok) {
|
|
957
|
+
// Only a delegation problem is a delegate problem: a CONFIG_UNKNOWN project does not delegate,
|
|
958
|
+
// and calling it delegateState:'invalid' made the cell render plain "On" (projectTmState needs
|
|
959
|
+
// delegateTo) while its runs were being skipped. `blocked` is what the cell and listScopes use.
|
|
960
|
+
status.delegateState = sink.reason === 'delegate-invalid' ? 'invalid' : null;
|
|
961
|
+
status.delegateDetail = sink.detail ?? null;
|
|
962
|
+
status.delegateCode = sink.code ?? null;
|
|
963
|
+
status.blocked = sink.code || sink.reason;
|
|
964
|
+
return status;
|
|
965
|
+
}
|
|
966
|
+
status.blocked = null;
|
|
967
|
+
if (sink.delegated) status.delegateState = 'ok';
|
|
968
|
+
status.sinkSlug = sink.slug;
|
|
969
|
+
status.pending = (await listOutbox(sink.slug)).length;
|
|
970
|
+
// A promise in the memo: statuses are built a few at a time (listScopes), and two projects on
|
|
971
|
+
// one sink must share a single read of its worktree.
|
|
972
|
+
if (!recordsBySink.has(sink.slug)) recordsBySink.set(sink.slug, countRuns(sink.slug));
|
|
973
|
+
const recs = await recordsBySink.get(sink.slug);
|
|
974
|
+
status.runs = recs ? recs.filter((r) => r.target?.kind === 'project' && r.target.project === status.slug).length : null;
|
|
975
|
+
if (sink.delegated) {
|
|
976
|
+
const t = readTeamMetricsPrefs(projectKey(sink.projectDir));
|
|
977
|
+
if (t?.lastError) { status.lastError = t.lastError; status.lastErrorCode = t.lastErrorCode; status.lastErrorHint = t.lastErrorCode === 'PUSH_REJECTED' ? pushHint(t.lastError) : null; }
|
|
978
|
+
}
|
|
979
|
+
return status;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** POST /api/workspaces/metrics-scan (§4.8): per member, does origin/worca-metrics exist? */
|
|
983
|
+
export async function scanMembers(projectPaths) {
|
|
984
|
+
const members = [];
|
|
985
|
+
for (const path of projectPaths) {
|
|
986
|
+
const prefs = (await discoverProject(path, { force: true }).catch((err) => ({ hasOrigin: null, lastDiscoveryError: String(err?.message || err) })))
|
|
987
|
+
|| { hasOrigin: null, lastDiscoveryError: 'could not read this repository' };
|
|
988
|
+
const recs = recordsLocally(prefs) ? await countRuns(prefs.slug) : null;
|
|
989
|
+
members.push({
|
|
990
|
+
// hasOrigin is strictly true only when discovery saw an origin (an error is not "has origin").
|
|
991
|
+
path, key: projectKey(path), slug: prefs.slug ?? basename(path), hasOrigin: prefs.hasOrigin === true,
|
|
992
|
+
enabled: !!prefs.enabled, delegateTo: prefs.config?.delegateTo ?? null,
|
|
993
|
+
recordsLocally: recordsLocally(prefs),
|
|
994
|
+
enabledAt: prefs.config?.enabledAt ?? null,
|
|
995
|
+
workspaceRuns: recs ? recs.filter((r) => r.target?.kind === 'workspace').length : null,
|
|
996
|
+
error: prefs.lastDiscoveryError ?? null,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
return { members };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* The metrics home a new workspace gets without being asked: the ONE member that already
|
|
1004
|
+
* records locally, or null (none, several, or the scan could not run — e.g. a member that
|
|
1005
|
+
* is not a git repository). The workspace card's "Choose…" covers every other case, so the
|
|
1006
|
+
* create wizard no longer carries a team-metrics step. `scan` is injectable for tests.
|
|
1007
|
+
*/
|
|
1008
|
+
export async function autoMetricsHome(projectPaths, { scan = scanMembers } = {}) {
|
|
1009
|
+
let members;
|
|
1010
|
+
try { ({ members } = await scan(projectPaths)); } catch { return null; }
|
|
1011
|
+
const recording = (members || []).filter((m) => m.hasOrigin && m.recordsLocally && !m.error);
|
|
1012
|
+
return recording.length === 1 ? recording[0].path : null;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Workspace card status: home state + member routing summary (§4.8, §4.6b). */
|
|
1016
|
+
export async function workspaceMetricsStatus(ws, { discover = false } = {}) {
|
|
1017
|
+
const members = [];
|
|
1018
|
+
const homeSlug = ws.metricsProject ? (await prefsFor(ws.metricsProject, { discover }))?.slug ?? null : null;
|
|
1019
|
+
for (const path of ws.projectPaths) {
|
|
1020
|
+
const prefs = await prefsFor(path, { discover }) || {};
|
|
1021
|
+
const isHome = !!ws.metricsProject && samePath(path, ws.metricsProject);
|
|
1022
|
+
let state; let reason = null;
|
|
1023
|
+
// recordsOn: the repository whose worca-metrics branch receives this project's runs (its
|
|
1024
|
+
// own slug, the home's, or a delegation target); null when nothing is recorded.
|
|
1025
|
+
let recordsOn = null;
|
|
1026
|
+
if (isHome) { state = 'home'; recordsOn = prefs.slug ?? null; }
|
|
1027
|
+
else if (prefs.hasOrigin === false) { state = 'not-recording'; reason = 'no origin remote'; }
|
|
1028
|
+
else if (prefs.config?.delegateTo && prefs.config.delegateTo.toLowerCase() === homeSlug) { state = 'routed'; recordsOn = homeSlug; }
|
|
1029
|
+
else if (prefs.enabled) { state = 'records-elsewhere'; reason = prefs.config?.delegateTo ? `delegates to ${prefs.config.delegateTo}` : 'records on its own branch'; recordsOn = prefs.config?.delegateTo ?? prefs.slug ?? null; }
|
|
1030
|
+
else { state = 'not-recording'; reason = 'no worca-metrics branch'; }
|
|
1031
|
+
members.push({ path, slug: prefs.slug ?? basename(path), state, reason, recordsOn });
|
|
1032
|
+
}
|
|
1033
|
+
let home = { state: 'unset', path: null, slug: null, runs: null, detail: null };
|
|
1034
|
+
if (ws.metricsProject) {
|
|
1035
|
+
const sink = await resolveWorkspaceSink(ws.id, { discover });
|
|
1036
|
+
// `record`: the home project's own "Include my runs" switch — it is what decides whether
|
|
1037
|
+
// THIS machine's workspace runs are written (docs/team-metrics.md, "Include my runs").
|
|
1038
|
+
home = { path: ws.metricsProject, slug: homeSlug ?? basename(ws.metricsProject), runs: null, detail: sink.ok ? null : sink.detail, state: sink.ok ? 'ok' : 'stale', code: sink.ok ? null : sink.code,
|
|
1039
|
+
record: readTeamMetricsPrefs(projectKey(ws.metricsProject))?.record !== false };
|
|
1040
|
+
if (sink.ok) {
|
|
1041
|
+
const recs = await countRuns(sink.slug);
|
|
1042
|
+
home.runs = recs ? recs.filter((r) => matchesWorkspace(r.target, ws)).length : null;
|
|
1043
|
+
home.pending = (await listOutbox(sink.slug)).length;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
const count = (s) => members.filter((m) => m.state === s).length;
|
|
1047
|
+
return {
|
|
1048
|
+
id: ws.id, name: ws.name, projectPaths: ws.projectPaths, home, members,
|
|
1049
|
+
counts: { recordsHere: count('home'), routed: count('routed'), notRecording: count('not-recording') + count('records-elsewhere') },
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** Workspace card "Route all members to the metrics home" (§4.6b): one marker push per member. */
|
|
1054
|
+
export async function routeWorkspaceMembers(workspaceId) {
|
|
1055
|
+
const sink = await resolveWorkspaceSink(workspaceId);
|
|
1056
|
+
if (!sink.ok) throw metricsError('NOT_ENABLED', sink.detail || 'this workspace has no valid metrics home');
|
|
1057
|
+
const results = [];
|
|
1058
|
+
for (const path of sink.workspace.projectPaths) {
|
|
1059
|
+
if (samePath(path, sink.projectDir)) continue;
|
|
1060
|
+
const prefs = (await discoverProject(path, { force: true }).catch(() => null)) || {}; // null: unreadable repo
|
|
1061
|
+
const slug = prefs.slug ?? basename(path);
|
|
1062
|
+
if (prefs.hasOrigin === false) { results.push({ path, slug, result: 'skipped', reason: 'no origin remote' }); continue; }
|
|
1063
|
+
if (prefs.enabled) {
|
|
1064
|
+
results.push({ path, slug, result: 'skipped', reason: prefs.config?.delegateTo ? `already delegates to ${prefs.config.delegateTo}` : 'already records on its own branch' });
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
try {
|
|
1068
|
+
await enableTeamMetrics(path, { mode: 'delegate', delegateTo: sink.slug });
|
|
1069
|
+
results.push({ path, slug, result: 'routed' });
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
results.push({ path, slug, result: 'failed', code: err.code || 'ERROR', error: err.message, stderr: err.stderr || null, hint: err.hint || null });
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return { home: sink.slug, results };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** UI server: discovery + flush on start, then hourly (§4.5, §4.6). Returns a stop function. */
|
|
1078
|
+
export function startTeamMetricsBackground({ log = (m) => console.warn(m) } = {}) {
|
|
1079
|
+
const tick = async (force) => {
|
|
1080
|
+
try { await discoverAll({ force }); } catch (err) { log(`[worca-ui] team metrics discovery: ${err?.message || err}`); }
|
|
1081
|
+
try { await sweepOutboxRetention(); } catch { /* best-effort */ }
|
|
1082
|
+
try { sweepRunLedger(); } catch { /* best-effort: one file per run would otherwise accumulate forever */ }
|
|
1083
|
+
try { for (const slug of await listOutboxSlugs()) if ((await listOutbox(slug)).length) scheduleFlush(slug); }
|
|
1084
|
+
catch (err) { log(`[worca-ui] team metrics flush: ${err?.message || err}`); }
|
|
1085
|
+
};
|
|
1086
|
+
tick(true);
|
|
1087
|
+
// Half the TTL: a tick exactly one TTL after the last check finds checkedAt just under the TTL
|
|
1088
|
+
// and skips, which would make "hourly" discovery run every two hours.
|
|
1089
|
+
const timer = setInterval(() => tick(false), DISCOVERY_TTL_MS / 2);
|
|
1090
|
+
timer.unref?.();
|
|
1091
|
+
return () => clearInterval(timer);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
export { readRunLedger };
|
|
1095
|
+
|
|
1096
|
+
export const _testing = {
|
|
1097
|
+
setGit(fn) { _git = fn; },
|
|
1098
|
+
reset() { _git = defaultGit; _queues.clear(); _scheduled.clear(); _lastFailedAt.clear(); },
|
|
1099
|
+
defaultGit,
|
|
1100
|
+
};
|