@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,303 @@
|
|
|
1
|
+
// src/core/metrics/read.mjs
|
|
2
|
+
// Team metrics read side (§4.9): fetch (rate-limited) → reset --hard → glob → parse → filter.
|
|
3
|
+
import { readdir, readFile, lstat, realpath } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { join, sep, resolve } from 'node:path';
|
|
6
|
+
import { listProjects } from '../projects.mjs';
|
|
7
|
+
import { listWorkspaces, readWorkspace, WORKSPACE_KEY_RE } from '../workspaces.mjs';
|
|
8
|
+
import {
|
|
9
|
+
METRICS_DIR, REMOTE_REF, ensureWorktree, fetchMetricsBranch, runGit, withSlugLock, worktreePath, pushHint,
|
|
10
|
+
resolveProjectSink, discoverProject, listOutbox, projectMetricsStatus, workspaceMetricsStatus, recordsLocally,
|
|
11
|
+
metricsEvents,
|
|
12
|
+
} from './sync.mjs';
|
|
13
|
+
import { readTeamMetricsPrefs } from '../config.mjs';
|
|
14
|
+
import { projectKey } from '../store.mjs';
|
|
15
|
+
import { parseRecordLine } from '../../shared/team-metrics/aggregate.mjs';
|
|
16
|
+
import { matchesWorkspace } from '../../shared/team-metrics/workspace-match.mjs';
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
aggregate, resolveRange, weekStartMs, parseRecordLine, toCsv, safeHttpUrl, RANGES, GROUP_BYS, CSV_COLUMNS,
|
|
20
|
+
} from '../../shared/team-metrics/aggregate.mjs';
|
|
21
|
+
|
|
22
|
+
export const FETCH_TTL_MS = 60_000;
|
|
23
|
+
export const FETCH_FAIL_TTL_MS = 15_000; // offline: retry soon, but not on every request
|
|
24
|
+
export const MAX_RECORD_FILE_BYTES = 8 * 1024 * 1024;
|
|
25
|
+
export const PROJECT_KEY_RE = /^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/; // server.mjs imports this (one source)
|
|
26
|
+
|
|
27
|
+
let _now = () => Date.now();
|
|
28
|
+
const _lastFetch = new Map(); // slug → ms of the last successful fetch (implicit or forced)
|
|
29
|
+
const _lastForced = new Map(); // slug → ms of the last refresh=1 fetch
|
|
30
|
+
const _lastFetchFail = new Map(); // slug → ms of the last failed fetch
|
|
31
|
+
const _lastFetchError = new Map(); // slug → stderr of the last failed DEFERRED fetch (a later deferred read reports it)
|
|
32
|
+
const _deferred = new Map(); // slug → Promise of the deferred fetch in flight (one per slug, never stacked)
|
|
33
|
+
|
|
34
|
+
/** "project:<projectKey>" | "workspace:<wks-…>" → {kind, id} | null */
|
|
35
|
+
export function parseScopeParam(s) {
|
|
36
|
+
const m = /^(project|workspace):(.+)$/.exec(String(s || ''));
|
|
37
|
+
if (!m) return null;
|
|
38
|
+
if (m[1] === 'project' && !PROJECT_KEY_RE.test(m[2])) return null;
|
|
39
|
+
if (m[1] === 'workspace' && !WORKSPACE_KEY_RE.test(m[2])) return null;
|
|
40
|
+
return { kind: m[1], id: m[2] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse every .worca-metrics/runs/**\/*.jsonl under a worktree. Malformed/unknown-v counted, never fatal. */
|
|
44
|
+
export async function readRecordsFromDir(dir) {
|
|
45
|
+
const out = { records: [], files: 0, malformed: 0, unknownV: 0 };
|
|
46
|
+
// A committed symlink AT .worca-metrics or .worca-metrics/runs would make readdir walk its target
|
|
47
|
+
// (→ $HOME, → /) and read any *.jsonl there. lstat: isDirectory() is false for a symlink.
|
|
48
|
+
for (const p of [join(dir, METRICS_DIR), join(dir, METRICS_DIR, 'runs')]) {
|
|
49
|
+
const st = await lstat(p).catch(() => null);
|
|
50
|
+
if (!st || !st.isDirectory()) return out;
|
|
51
|
+
}
|
|
52
|
+
const root = join(dir, METRICS_DIR, 'runs');
|
|
53
|
+
const rootReal = await realpath(root).catch(() => null);
|
|
54
|
+
if (!rootReal) return out;
|
|
55
|
+
let ents;
|
|
56
|
+
try { ents = await readdir(root, { recursive: true, withFileTypes: true }); } catch { return out; }
|
|
57
|
+
for (const d of ents) {
|
|
58
|
+
if (!d.name.endsWith('.jsonl')) continue;
|
|
59
|
+
out.files += 1;
|
|
60
|
+
// The branch is writable by anyone with push access: a committed symlink (→ /dev/zero, → a
|
|
61
|
+
// local secret) must never be followed, and a huge file must not stall the slug lock.
|
|
62
|
+
// Dirent.isFile() is false for symlinks; lstat re-checks and gives the size. The realpath
|
|
63
|
+
// containment check also covers a symlinked directory INSIDE runs/ (e.g. runs/2026 → /outside):
|
|
64
|
+
// lstat only inspects the last component, and readdirSync({recursive}) does walk such a link.
|
|
65
|
+
const p = join(d.parentPath ?? d.path, d.name);
|
|
66
|
+
const st = d.isFile() ? await lstat(p).catch(() => null) : null;
|
|
67
|
+
const real = st ? await realpath(p).catch(() => null) : null;
|
|
68
|
+
if (!st || !st.isFile() || st.size > MAX_RECORD_FILE_BYTES || !real || !real.startsWith(rootReal + sep)) { out.malformed += 1; continue; }
|
|
69
|
+
let text;
|
|
70
|
+
try { text = await readFile(p, 'utf8'); } catch { out.malformed += 1; continue; }
|
|
71
|
+
for (const line of text.split('\n')) {
|
|
72
|
+
if (!line.trim()) continue;
|
|
73
|
+
const p = parseRecordLine(line);
|
|
74
|
+
if (p.record) out.records.push(p.record);
|
|
75
|
+
else if (p.unknownV) out.unknownV += 1;
|
|
76
|
+
else out.malformed += 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** §4.9 rate limit: implicit fetch ≤ 1/60 s per slug; refresh=1 forces one, throttled to 1/60 s per slug. */
|
|
83
|
+
export function fetchDecision(slug, { refresh = false, now = _now() } = {}) {
|
|
84
|
+
const sinceFetch = now - (_lastFetch.get(slug) ?? -Infinity);
|
|
85
|
+
const sinceFail = now - (_lastFetchFail.get(slug) ?? -Infinity);
|
|
86
|
+
if (!refresh) return { fetch: sinceFetch >= FETCH_TTL_MS && sinceFail >= FETCH_FAIL_TTL_MS, limited: false, retryInMs: 0 };
|
|
87
|
+
const sinceForced = now - (_lastForced.get(slug) ?? -Infinity);
|
|
88
|
+
if (sinceForced >= FETCH_TTL_MS) return { fetch: true, forced: true, limited: false, retryInMs: 0 };
|
|
89
|
+
// Throttled: fall back to the implicit rule INCLUDING the failed-fetch TTL. Offline, _lastFetch is
|
|
90
|
+
// never set, so without it every throttled Refresh ran a (≤120 s, slug-locked) fetch anyway.
|
|
91
|
+
return { fetch: sinceFetch >= FETCH_TTL_MS && sinceFail >= FETCH_FAIL_TTL_MS, limited: true, retryInMs: FETCH_TTL_MS - sinceForced };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Test seam for the fetch bookkeeping readSink does (a forced attempt and a failure). */
|
|
95
|
+
export function noteFetch(slug, { forced = false, ok = true, now = _now() } = {}) {
|
|
96
|
+
if (forced) _lastForced.set(slug, now);
|
|
97
|
+
if (ok) { _lastFetch.set(slug, now); _lastFetchFail.delete(slug); } else _lastFetchFail.set(slug, now);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The page's two-phase read (docs/team-metrics.md "Loading"): a deferred read serves what the
|
|
102
|
+
* worktree holds NOW and runs the due fetch here, after the request, under the slug lock. When
|
|
103
|
+
* it settles — new commits or not, failure included — one `changed` event ({ action: 'fetched'
|
|
104
|
+
* | 'fetch-failed', updated }) reaches the UI, which reloads the page and repaints its chip. One
|
|
105
|
+
* deferred fetch per slug: a second deferred read while one is in flight joins it. `create`
|
|
106
|
+
* builds the worktree (first read ever), which ensureWorktree fetches on its own.
|
|
107
|
+
*/
|
|
108
|
+
function scheduleDeferredFetch(slug, projectDir, { forced = false, create = false } = {}) {
|
|
109
|
+
if (_deferred.has(slug)) return _deferred.get(slug);
|
|
110
|
+
const p = withSlugLock(slug, async () => {
|
|
111
|
+
let ok = false; let stderr = ''; let before = null; let after = null;
|
|
112
|
+
try {
|
|
113
|
+
if (create) { await ensureWorktree(slug, projectDir); ok = true; }
|
|
114
|
+
else {
|
|
115
|
+
const dir = worktreePath(slug);
|
|
116
|
+
before = (await runGit(dir, ['rev-parse', REMOTE_REF], { timeoutMs: 10_000 })).stdout.trim();
|
|
117
|
+
const f = await fetchMetricsBranch(dir);
|
|
118
|
+
ok = f.ok; stderr = String(f.stderr || '').trim();
|
|
119
|
+
if (ok) {
|
|
120
|
+
await runGit(dir, ['reset', '--hard', REMOTE_REF]);
|
|
121
|
+
await runGit(dir, ['clean', '-fdq']);
|
|
122
|
+
after = (await runGit(dir, ['rev-parse', REMOTE_REF], { timeoutMs: 10_000 })).stdout.trim();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch (err) { ok = false; stderr = String(err?.stderr || err?.message || err).trim(); }
|
|
126
|
+
noteFetch(slug, { forced, ok, now: _now() });
|
|
127
|
+
if (ok) _lastFetchError.delete(slug); else _lastFetchError.set(slug, stderr || 'git fetch failed');
|
|
128
|
+
metricsEvents.emit('changed', { slug, action: ok ? 'fetched' : 'fetch-failed', updated: ok && (create || after !== before) });
|
|
129
|
+
}).catch(() => {});
|
|
130
|
+
const tail = p.finally(() => { if (_deferred.get(slug) === tail) _deferred.delete(slug); });
|
|
131
|
+
_deferred.set(slug, tail);
|
|
132
|
+
return tail;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Promise.all with at most `limit` in flight, results in input order. */
|
|
136
|
+
async function mapLimit(items, limit, fn) {
|
|
137
|
+
const out = new Array(items.length);
|
|
138
|
+
let next = 0;
|
|
139
|
+
const worker = async () => { for (let i = next++; i < items.length; i = next++) out[i] = await fn(items[i], i); };
|
|
140
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function readSink(slug, projectDir, { refresh, defer = false }) {
|
|
145
|
+
return withSlugLock(slug, async () => {
|
|
146
|
+
// ensureWorktree runs its OWN fetch (≤120 s) when the worktree does not exist yet, before
|
|
147
|
+
// fetchDecision is consulted. Offline that made every GET — and every throttled refresh=1 —
|
|
148
|
+
// pay a full fetch under the slug lock while reporting nothing. Consult the throttle first.
|
|
149
|
+
const pre = fetchDecision(slug, { refresh, now: _now() });
|
|
150
|
+
const hadWorktree = existsSync(worktreePath(slug));
|
|
151
|
+
if (!hadWorktree && !pre.fetch) {
|
|
152
|
+
throw Object.assign(new Error(`the ${slug} metrics branch has not been fetched yet; retrying shortly`), { code: 'FETCH_FAILED' });
|
|
153
|
+
}
|
|
154
|
+
if (!hadWorktree && defer) {
|
|
155
|
+
// Nothing to serve yet: build the worktree after this request and answer "pending" now,
|
|
156
|
+
// so the page shows its skeleton with "checking origin" instead of blocking on a clone.
|
|
157
|
+
scheduleDeferredFetch(slug, projectDir, { forced: !!pre.forced, create: true });
|
|
158
|
+
return { records: [], files: 0, malformed: 0, unknownV: 0, decision: { ...pre, fetch: false, deferred: true }, pending: true, fetchError: null, fetchedAt: null };
|
|
159
|
+
}
|
|
160
|
+
let dir;
|
|
161
|
+
try { dir = await ensureWorktree(slug, projectDir); }
|
|
162
|
+
catch (err) { if (err?.code === 'FETCH_FAILED') noteFetch(slug, { forced: !!pre.forced, ok: false }); throw err; }
|
|
163
|
+
// ensureWorktree runs its OWN fetch when it has to create the worktree. Book that fetch here,
|
|
164
|
+
// before re-deciding: otherwise the very first refresh=1 for a slug fetched TWICE under the
|
|
165
|
+
// slug lock (once inside ensureWorktree, then again because _lastForced was still unset) —
|
|
166
|
+
// doubling the worst-case latency of the exact path the throttle exists to bound.
|
|
167
|
+
const now = _now();
|
|
168
|
+
if (!hadWorktree) noteFetch(slug, { forced: !!pre.forced, ok: true, now });
|
|
169
|
+
const decision = fetchDecision(slug, { refresh, now });
|
|
170
|
+
let fetchError = null;
|
|
171
|
+
let pending = false;
|
|
172
|
+
if (decision.fetch && defer) {
|
|
173
|
+
scheduleDeferredFetch(slug, projectDir, { forced: !!decision.forced });
|
|
174
|
+
pending = true;
|
|
175
|
+
decision.deferred = true;
|
|
176
|
+
} else if (defer && _lastFetchError.has(slug)) {
|
|
177
|
+
// The last deferred fetch failed and the throttle skips this one: say so, as the inline
|
|
178
|
+
// path would have on its own request (the chip's "offline" state).
|
|
179
|
+
fetchError = _lastFetchError.get(slug);
|
|
180
|
+
}
|
|
181
|
+
if (decision.fetch && !defer) {
|
|
182
|
+
const f = await fetchMetricsBranch(dir);
|
|
183
|
+
// A forced attempt counts even if it fails (flood guard).
|
|
184
|
+
noteFetch(slug, { forced: !!decision.forced, ok: f.ok, now });
|
|
185
|
+
if (f.ok) {
|
|
186
|
+
await runGit(dir, ['reset', '--hard', REMOTE_REF]);
|
|
187
|
+
await runGit(dir, ['clean', '-fdq']);
|
|
188
|
+
} else {
|
|
189
|
+
fetchError = String(f.stderr || '').trim(); // offline: serve what the worktree has
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const parsed = await readRecordsFromDir(dir);
|
|
193
|
+
return { ...parsed, decision, pending, fetchError, fetchedAt: _lastFetch.has(slug) ? new Date(_lastFetch.get(slug)).toISOString() : null };
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function syncFor(slug, prefs, pending) {
|
|
198
|
+
const lastError = prefs?.lastError ?? null;
|
|
199
|
+
const lastErrorCode = prefs?.lastErrorCode ?? null;
|
|
200
|
+
return {
|
|
201
|
+
slug, pending,
|
|
202
|
+
lastSyncAt: prefs?.lastSyncAt ?? null,
|
|
203
|
+
lastError,
|
|
204
|
+
lastErrorCode,
|
|
205
|
+
// §4.7: "the chip shows the git stderr AND a hint to exempt worca-metrics".
|
|
206
|
+
hint: lastErrorCode === 'PUSH_REJECTED' ? pushHint(lastError) : null,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @returns {Promise<{scope, records, stats:{files,malformed,unknownV}, sinks:string[], sync:object[],
|
|
212
|
+
* refresh:{requested,fetched,limited,retryInMs}, fetchError:string|null}>}
|
|
213
|
+
*/
|
|
214
|
+
export async function readScope(scope, { refresh = false, defer = false } = {}) {
|
|
215
|
+
const sources = [];
|
|
216
|
+
let meta;
|
|
217
|
+
if (scope.kind === 'project') {
|
|
218
|
+
const p = (await listProjects()).find((x) => x.key === scope.id);
|
|
219
|
+
if (!p) throw Object.assign(new Error(`unknown project ${scope.id}`), { code: 'NOT_FOUND' });
|
|
220
|
+
const sink = await resolveProjectSink(p.path);
|
|
221
|
+
if (!sink.ok) {
|
|
222
|
+
const code = sink.reason === 'delegate-invalid' ? 'DELEGATE_INVALID' : 'NOT_ENABLED';
|
|
223
|
+
throw Object.assign(new Error(sink.detail || `team metrics are not enabled for ${p.name}`), { code });
|
|
224
|
+
}
|
|
225
|
+
const ownSlug = sink.from;
|
|
226
|
+
sources.push({ slug: sink.slug, projectDir: sink.projectDir, keep: (r) => r.target?.kind === 'project' && r.target.project === ownSlug });
|
|
227
|
+
meta = { kind: 'project', id: p.key, name: p.name, slug: ownSlug, recordedIn: sink.delegated ? sink.slug : null };
|
|
228
|
+
} else {
|
|
229
|
+
const ws = await readWorkspace(scope.id);
|
|
230
|
+
if (!ws) throw Object.assign(new Error(`unknown workspace ${scope.id}`), { code: 'NOT_FOUND' });
|
|
231
|
+
const name = ws.name.toLowerCase();
|
|
232
|
+
for (const path of ws.projectPaths) {
|
|
233
|
+
const prefs = (await discoverProject(path).catch(() => null)) || readTeamMetricsPrefs(projectKey(path));
|
|
234
|
+
if (!recordsLocally(prefs)) continue; // only members that record locally (config read, not a marker)
|
|
235
|
+
sources.push({ slug: prefs.slug, projectDir: path, keep: (r) => matchesWorkspace(r.target, ws) });
|
|
236
|
+
}
|
|
237
|
+
if (!sources.length) throw Object.assign(new Error(`no member of ${ws.name} records team metrics`), { code: 'NOT_ENABLED' });
|
|
238
|
+
const homePrefs = ws.metricsProject ? readTeamMetricsPrefs(projectKey(ws.metricsProject)) : null;
|
|
239
|
+
meta = { kind: 'workspace', id: ws.id, name: ws.name, home: homePrefs?.slug ?? null, sources: sources.map((s) => s.slug) };
|
|
240
|
+
}
|
|
241
|
+
const seen = new Set();
|
|
242
|
+
const records = [];
|
|
243
|
+
const stats = { files: 0, malformed: 0, unknownV: 0 };
|
|
244
|
+
const sync = [];
|
|
245
|
+
const refreshInfo = { requested: !!refresh, fetched: false, limited: false, retryInMs: 0, pending: false };
|
|
246
|
+
let fetchError = null;
|
|
247
|
+
// Every source has its own slug lock, so the sinks are read side by side; the results are
|
|
248
|
+
// folded in source order below, so the chip's sync rows and the first error stay deterministic.
|
|
249
|
+
const settled = await Promise.all(sources.map((src) => readSink(src.slug, src.projectDir, { refresh, defer }).then((r) => ({ r }), (err) => ({ err }))));
|
|
250
|
+
for (const [i, src] of sources.entries()) {
|
|
251
|
+
const { r, err } = settled[i];
|
|
252
|
+
if (err) {
|
|
253
|
+
// One unreachable member (no worktree yet + offline, lock timeout) must not fail the whole
|
|
254
|
+
// workspace page: report it in the chip and keep reading the others. A project scope has a
|
|
255
|
+
// single source, so it still surfaces the error.
|
|
256
|
+
if (sources.length === 1) throw err;
|
|
257
|
+
fetchError ||= `${src.slug}: ${err?.stderr || err?.message || err}`;
|
|
258
|
+
sync.push({ ...syncFor(src.slug, readTeamMetricsPrefs(projectKey(src.projectDir)), (await listOutbox(src.slug)).length), fetchedAt: null, error: String(err?.message || err) });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
stats.files += r.files; stats.malformed += r.malformed; stats.unknownV += r.unknownV;
|
|
262
|
+
refreshInfo.fetched ||= r.decision.fetch;
|
|
263
|
+
refreshInfo.pending ||= !!r.pending;
|
|
264
|
+
if (r.decision.limited) { refreshInfo.limited = true; refreshInfo.retryInMs = Math.max(refreshInfo.retryInMs, r.decision.retryInMs); }
|
|
265
|
+
fetchError ||= r.fetchError;
|
|
266
|
+
for (const rec of r.records) {
|
|
267
|
+
if (!src.keep(rec) || seen.has(rec.id)) continue;
|
|
268
|
+
seen.add(rec.id);
|
|
269
|
+
records.push(rec);
|
|
270
|
+
}
|
|
271
|
+
sync.push({ ...syncFor(src.slug, readTeamMetricsPrefs(projectKey(src.projectDir)), (await listOutbox(src.slug)).length), fetchedAt: r.fetchedAt });
|
|
272
|
+
}
|
|
273
|
+
return { scope: meta, records, stats, sinks: sources.map((s) => s.slug), sync, refresh: refreshInfo, fetchError };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Scope list + Projects/Workspaces status in one call (Scope select, Projects cells, ws cards, Stats hint). */
|
|
277
|
+
export async function listScopes({ discover = false } = {}) {
|
|
278
|
+
const recordsBySink = new Map();
|
|
279
|
+
// A few at a time, not one after another: an undiscovered or stale project pays git spawns, and
|
|
280
|
+
// the Workspaces page waits for the whole list before its cards get their metrics columns.
|
|
281
|
+
const projects = await mapLimit(await listProjects(), 4, (p) => projectMetricsStatus(p, { discover, recordsBySink }));
|
|
282
|
+
const workspaces = await mapLimit(await listWorkspaces(), 4, (w) => workspaceMetricsStatus(w, { discover }));
|
|
283
|
+
// A project that is some workspace's metrics home also carries THAT workspace's runs, and
|
|
284
|
+
// its "Include my runs" switch governs them too — the Projects cell says so (`homeFor`).
|
|
285
|
+
const norm = (p) => (typeof p === 'string' && p ? resolve(p) : null);
|
|
286
|
+
for (const s of projects) {
|
|
287
|
+
s.homeFor = workspaces.filter((w) => w.home?.path && norm(w.home.path) === norm(s.path)).map((w) => w.name);
|
|
288
|
+
}
|
|
289
|
+
const scopes = {
|
|
290
|
+
projects: projects.filter((s) => s.enabled && s.delegateState !== 'invalid' && !s.blocked)
|
|
291
|
+
.map((s) => ({ id: `project:${s.key}`, label: s.slug, name: s.name, recordedIn: s.delegateState === 'ok' ? s.sinkSlug : null })),
|
|
292
|
+
workspaces: workspaces.filter((w) => w.home.state === 'ok')
|
|
293
|
+
.map((w) => ({ id: `workspace:${w.id}`, label: w.name, home: w.home.slug })),
|
|
294
|
+
};
|
|
295
|
+
return { projects, workspaces, scopes, anyEnabled: scopes.projects.length + scopes.workspaces.length > 0 };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const _testing = {
|
|
299
|
+
setNow(fn) { _now = fn; },
|
|
300
|
+
reset() { _now = () => Date.now(); _lastFetch.clear(); _lastForced.clear(); _lastFetchFail.clear(); _lastFetchError.clear(); },
|
|
301
|
+
/** Resolves once every deferred fetch in flight has settled (tests await the event they emit). */
|
|
302
|
+
settleDeferred() { return Promise.all([..._deferred.values()]); },
|
|
303
|
+
};
|