@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.
Files changed (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3715 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1359 -214
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +423 -25
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1450 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. 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
+ };