wicked-crew 0.6.0 → 0.7.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 (99) hide show
  1. package/dist/api/audit.d.ts +13 -0
  2. package/dist/api/audit.d.ts.map +1 -1
  3. package/dist/api/audit.js +18 -2
  4. package/dist/api/audit.js.map +1 -1
  5. package/dist/api/guidance-index.d.ts +39 -0
  6. package/dist/api/guidance-index.d.ts.map +1 -0
  7. package/dist/api/guidance-index.js +67 -0
  8. package/dist/api/guidance-index.js.map +1 -0
  9. package/dist/api/open-path.d.ts +16 -0
  10. package/dist/api/open-path.d.ts.map +1 -1
  11. package/dist/api/open-path.js +22 -0
  12. package/dist/api/open-path.js.map +1 -1
  13. package/dist/api/retry-index.d.ts +30 -0
  14. package/dist/api/retry-index.d.ts.map +1 -0
  15. package/dist/api/retry-index.js +45 -0
  16. package/dist/api/retry-index.js.map +1 -0
  17. package/dist/api/routes.d.ts +53 -1
  18. package/dist/api/routes.d.ts.map +1 -1
  19. package/dist/api/routes.js +352 -23
  20. package/dist/api/routes.js.map +1 -1
  21. package/dist/api/run-files.d.ts +63 -0
  22. package/dist/api/run-files.d.ts.map +1 -0
  23. package/dist/api/run-files.js +271 -0
  24. package/dist/api/run-files.js.map +1 -0
  25. package/dist/api/server.d.ts +79 -0
  26. package/dist/api/server.d.ts.map +1 -1
  27. package/dist/api/server.js +135 -6
  28. package/dist/api/server.js.map +1 -1
  29. package/dist/api/stall-watchdog.d.ts +62 -0
  30. package/dist/api/stall-watchdog.d.ts.map +1 -0
  31. package/dist/api/stall-watchdog.js +138 -0
  32. package/dist/api/stall-watchdog.js.map +1 -0
  33. package/dist/cli/index.js +78 -13
  34. package/dist/cli/index.js.map +1 -1
  35. package/dist/core/adapter.d.ts +24 -10
  36. package/dist/core/adapter.d.ts.map +1 -1
  37. package/dist/core/adapter.js +191 -30
  38. package/dist/core/adapter.js.map +1 -1
  39. package/dist/core/bridge-reaper.d.ts +134 -0
  40. package/dist/core/bridge-reaper.d.ts.map +1 -0
  41. package/dist/core/bridge-reaper.js +286 -0
  42. package/dist/core/bridge-reaper.js.map +1 -0
  43. package/dist/core/deliver.d.ts +118 -0
  44. package/dist/core/deliver.d.ts.map +1 -0
  45. package/dist/core/deliver.js +241 -0
  46. package/dist/core/deliver.js.map +1 -0
  47. package/dist/core/deliverable-floor.d.ts +103 -0
  48. package/dist/core/deliverable-floor.d.ts.map +1 -0
  49. package/dist/core/deliverable-floor.js +173 -0
  50. package/dist/core/deliverable-floor.js.map +1 -0
  51. package/dist/core/exec.d.ts +2 -0
  52. package/dist/core/exec.d.ts.map +1 -1
  53. package/dist/core/exec.js.map +1 -1
  54. package/dist/core/types.d.ts +79 -1
  55. package/dist/core/types.d.ts.map +1 -1
  56. package/dist/core/types.js +3 -0
  57. package/dist/core/types.js.map +1 -1
  58. package/dist/interactive/bridge-pool.d.ts +28 -0
  59. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  60. package/dist/interactive/bridge-pool.js +67 -10
  61. package/dist/interactive/bridge-pool.js.map +1 -1
  62. package/dist/interactive/chat-events.d.ts +207 -0
  63. package/dist/interactive/chat-events.d.ts.map +1 -0
  64. package/dist/interactive/chat-events.js +769 -0
  65. package/dist/interactive/chat-events.js.map +1 -0
  66. package/dist/interactive/demo-events.d.ts +283 -0
  67. package/dist/interactive/demo-events.d.ts.map +1 -0
  68. package/dist/interactive/demo-events.js +889 -0
  69. package/dist/interactive/demo-events.js.map +1 -0
  70. package/dist/interactive/draft-events.d.ts +87 -7
  71. package/dist/interactive/draft-events.d.ts.map +1 -1
  72. package/dist/interactive/draft-events.js +352 -49
  73. package/dist/interactive/draft-events.js.map +1 -1
  74. package/dist/interactive/edit-events.d.ts +22 -0
  75. package/dist/interactive/edit-events.d.ts.map +1 -1
  76. package/dist/interactive/edit-events.js +73 -2
  77. package/dist/interactive/edit-events.js.map +1 -1
  78. package/dist/interactive/repo-snapshot.d.ts +100 -0
  79. package/dist/interactive/repo-snapshot.d.ts.map +1 -0
  80. package/dist/interactive/repo-snapshot.js +289 -0
  81. package/dist/interactive/repo-snapshot.js.map +1 -0
  82. package/dist/projects/graph-paths.d.ts +92 -0
  83. package/dist/projects/graph-paths.d.ts.map +1 -0
  84. package/dist/projects/graph-paths.js +130 -0
  85. package/dist/projects/graph-paths.js.map +1 -0
  86. package/dist/projects/graph.d.ts +179 -0
  87. package/dist/projects/graph.d.ts.map +1 -0
  88. package/dist/projects/graph.js +775 -0
  89. package/dist/projects/graph.js.map +1 -0
  90. package/dist/projects/routes.d.ts +7 -0
  91. package/dist/projects/routes.d.ts.map +1 -1
  92. package/dist/projects/routes.js +122 -0
  93. package/dist/projects/routes.js.map +1 -1
  94. package/dist/studio/assets/index-8p8uwCxG.js +530 -0
  95. package/dist/studio/assets/index-D6S9zUtO.css +32 -0
  96. package/dist/studio/index.html +5 -3
  97. package/package.json +3 -3
  98. package/dist/studio/assets/index-CCwXa1cn.js +0 -428
  99. package/dist/studio/assets/index-HWxo0h41.css +0 -32
@@ -0,0 +1,775 @@
1
+ /**
2
+ * The project code graph — every `crew.repo` member of one project in ONE wicked-estate database.
3
+ *
4
+ * # What changed to make this possible
5
+ *
6
+ * One estate database used to hold exactly one repo. SymbolIds embed the repo-relative path, so two
7
+ * repos that both contain `src/index.ts` mint identical file rows AND identical symbol ids: the
8
+ * second index overwrote the first and said nothing. `wicked-estate index <path> --repo <label>`
9
+ * (wicked-estate#117) namespaces every path as `<label>/…`, so N repos co-exist, each queryable,
10
+ * with a guard that refuses — before a row is written — any index that would overwrite another
11
+ * repo's content.
12
+ *
13
+ * # THE LIMIT
14
+ *
15
+ * CO-LOCATION IS NOT LINKAGE. estate resolves edges within a labelled repo's own nodes, exactly as
16
+ * if each repo sat in its own database. `studio → wicked-crew-api-types → crew` does not traverse.
17
+ * What this module federates is per-repo results into one answer with the repo named on every hit —
18
+ * which is genuinely useful ("who calls `record`, anywhere in this project") and is NOT a cross-repo
19
+ * dependency trace. Every response carries `linkage: 'co-located'` and {@link CO_LOCATION_NOTE} so a
20
+ * consumer cannot mistake one for the other.
21
+ *
22
+ * # Why the estate binary is capability-PROBED before anything is indexed
23
+ *
24
+ * `wicked-estate 0.14.4` — the version installed on this machine while this was written — accepts
25
+ * `--repo`, IGNORES it, and exits 0. Indexing three repos through it produces a database holding
26
+ * only the third, with no error anywhere and results that look perfectly healthy. That is the exact
27
+ * silent-loss failure the labelling work exists to end, reachable by nothing worse than a stale
28
+ * binary on PATH. So the flag's support is established from the binary that will actually run,
29
+ * before the first index (help text), and confirmed from the database afterwards (the labelled repo
30
+ * registry `stats` prints) — a claim, then evidence.
31
+ *
32
+ * # Honest degradation
33
+ *
34
+ * Every path out of here names its cause. A project with no repo members, one whose graph was never
35
+ * built, one whose member repo the registry no longer knows, and an addon too old to publish
36
+ * `code_graph_db` are four different situations with four different remedies; collapsing them into
37
+ * an empty result set is the failure estate's own R3 rule exists to prevent, and it is the failure
38
+ * FINDING-069 actually shipped.
39
+ */
40
+ import { existsSync } from 'node:fs';
41
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
42
+ import { dirname } from 'node:path';
43
+ import { ExecOutputTooLarge, execCapped } from '../core/exec.js';
44
+ import { codeGraphDb } from '../core/repoPaths.js';
45
+ import { projectGraphDb, projectGraphManifest, repoLabel } from './graph-paths.js';
46
+ /** The sentence every project-graph response carries. Stated on the wire, not just in this file. */
47
+ export const CO_LOCATION_NOTE = 'Co-located, not linked: each repo is indexed under its own label and edges do not resolve ' +
48
+ 'across repos. Results are per-repo hits gathered into one answer, never a cross-repo trace.';
49
+ /**
50
+ * The second sentence `/graph/search` carries, and only it.
51
+ *
52
+ * `matches: []` from an exact-name resolver against a healthy graph is the empty-result-that-reads-
53
+ * as-an-answer this whole surface is built to refuse: a caller who typed half a name is told
54
+ * "nothing in this project", which is false. estate's `resolve` has no substring mode (the header
55
+ * on {@link projectSymbolSearch} argues why a `nodes --json` dump is not the answer), so the
56
+ * matching RULE goes on the wire instead of a matching mode that does not exist.
57
+ */
58
+ export const EXACT_NAME_NOTE = 'Exact-name matching only: this resolves a whole symbol name, so a partial or fuzzy name ' +
59
+ 'returns no matches even when the symbol exists. An empty `matches` means "no symbol by that ' +
60
+ 'exact name", never "not in this project".';
61
+ /** `wicked-estate`, overridable exactly as `WICKED_CORE_EXE` overrides `wicked-core` in routes.ts. */
62
+ function estateExe(env = process.env) {
63
+ return env['WICKED_ESTATE_EXE'] ?? 'wicked-estate';
64
+ }
65
+ /** Queries are interactive; a full repo index is not. Both are bounded — neither hangs the daemon. */
66
+ const QUERY_TIMEOUT_MS = 30_000;
67
+ const INDEX_TIMEOUT_MS = 600_000;
68
+ function message(err) {
69
+ return err instanceof Error ? err.message : String(err);
70
+ }
71
+ async function readManifest(projectId, env) {
72
+ try {
73
+ const raw = JSON.parse(await readFile(projectGraphManifest(projectId, env), 'utf8'));
74
+ if (raw?.version !== 1 || !Array.isArray(raw.repos))
75
+ return null;
76
+ return raw;
77
+ }
78
+ catch {
79
+ // Missing or malformed. A lost manifest costs a re-index, never a wrong answer: the fallback is
80
+ // "index everything", which is the safe direction. The dangerous direction — trusting a manifest
81
+ // that describes rows the database does not hold — is closed separately, by ignoring the
82
+ // manifest entirely when the database is absent.
83
+ return null;
84
+ }
85
+ }
86
+ async function writeManifest(projectId, manifest, env) {
87
+ const path = projectGraphManifest(projectId, env);
88
+ await mkdir(dirname(path), { recursive: true });
89
+ const tmp = `${path}.tmp-${process.pid}`;
90
+ await writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf8');
91
+ await rename(tmp, path);
92
+ }
93
+ /**
94
+ * The project's `crew.repo` members, resolved against the registry.
95
+ *
96
+ * Note the `.filter`, where `resolveProjectRepo` (draft-events.ts) takes `.find`. That is not a
97
+ * style difference: the only thing in this daemon that reads project repo membership today looks at
98
+ * the FIRST member and ignores the rest, which is correct for its job (ground one launch in one
99
+ * repo) and would be a silent half-answer here.
100
+ *
101
+ * A member whose ref the registry does not know is kept as `dangling` rather than skipped, because
102
+ * "this project claims a repo that no longer exists" and "this project has no repos" need different
103
+ * responses from an operator.
104
+ */
105
+ async function resolveMembers(adapter, projectId) {
106
+ const members = await adapter.projectMembers(projectId);
107
+ const refs = members.filter((m) => m.member_kind === 'crew.repo').map((m) => m.member_ref);
108
+ if (refs.length === 0)
109
+ return { repos: [], dangling: [] };
110
+ const registry = await adapter.listRepos();
111
+ const repos = [];
112
+ const dangling = [];
113
+ for (const ref of refs) {
114
+ const repo = registry.find((r) => r.id === ref);
115
+ if (repo === undefined)
116
+ dangling.push({ repoId: ref, label: repoLabel(ref) });
117
+ else
118
+ repos.push({ repoId: ref, label: repoLabel(ref), repo });
119
+ }
120
+ return { repos, dangling };
121
+ }
122
+ /**
123
+ * Two member repos resolving to ONE label would have repo B's rows overwrite repo A's under a name
124
+ * that looks right in every result. estate's guard catches it too (it refuses a label already bound
125
+ * to another repo), but only after the first repo is already in the database and only with a message
126
+ * about estate labels rather than about the two project members that produced them.
127
+ */
128
+ function assertLabelsUnique(repos) {
129
+ const seen = new Map();
130
+ for (const r of repos) {
131
+ const first = seen.get(r.label);
132
+ if (first !== undefined) {
133
+ throw new Error(`repos '${first}' and '${r.repoId}' both map to the estate label '${r.label}' — ` +
134
+ `one would overwrite the other's symbols in the project graph`);
135
+ }
136
+ seen.set(r.label, r.repoId);
137
+ }
138
+ }
139
+ // ── Capability probes ─────────────────────────────────────────────────────────
140
+ /**
141
+ * Does the `wicked-estate` that will actually run support `--repo`?
142
+ *
143
+ * A version comparison would be the obvious probe and the wrong one: it asks a string on disk
144
+ * instead of the binary on PATH, and a locally built or vendored estate can carry any version it
145
+ * likes. The usage banner is printed by the same binary that would do the indexing.
146
+ */
147
+ export async function estateSupportsMultiRepo(env = process.env) {
148
+ try {
149
+ const { stdout } = await execCapped(estateExe(env), ['--help'], { timeout: 10_000 });
150
+ return /index <path>[^\n]*--repo <name>/.test(stdout);
151
+ }
152
+ catch (err) {
153
+ // `--help` exits non-zero on some builds; the banner still went to stdout on the error object.
154
+ const out = err.stdout;
155
+ return typeof out === 'string' && /index <path>[^\n]*--repo <name>/.test(out);
156
+ }
157
+ }
158
+ /**
159
+ * Confirm from the DATABASE that the label landed, after the first repo goes in.
160
+ *
161
+ * `stats` prints a `repos (N):` block listing every labelled repo. Its absence after a `--repo`
162
+ * index means the binary accepted the flag and dropped it — 0.14.4's behaviour — and every
163
+ * subsequent repo would overwrite this one. Evidence, after the help text's claim.
164
+ */
165
+ async function graphHoldsLabel(dbPath, label, env) {
166
+ const { stdout } = await execCapped(estateExe(env), ['stats', '--db', dbPath], {
167
+ timeout: QUERY_TIMEOUT_MS,
168
+ });
169
+ return new RegExp(`^\\s+${escapeRe(label)}\\s+files=`, 'm').test(stdout);
170
+ }
171
+ function escapeRe(s) {
172
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
173
+ }
174
+ /**
175
+ * The evidence check failed: the binary took `--repo` and dropped it.
176
+ *
177
+ * A distinct type because it is NOT a per-repo failure and must not be collected as one. It is the
178
+ * same fact the capability probe establishes — this binary cannot co-locate — discovered one step
179
+ * later, and the only safe response is the probe's: stop, before the next repo's index overwrites
180
+ * the rows just written. Recording it in `failed` and continuing runs a full index per member, each
181
+ * clobbering the last, while the message on every entry claims the opposite.
182
+ */
183
+ export class EstateDroppedRepoLabelError extends Error {
184
+ constructor(message) {
185
+ super(message);
186
+ this.name = 'EstateDroppedRepoLabelError';
187
+ }
188
+ }
189
+ // ── Git freshness ─────────────────────────────────────────────────────────────
190
+ /**
191
+ * The commit a clean checkout is at, or `null` for a dirty tree, a non-git directory, or a git that
192
+ * would not answer.
193
+ *
194
+ * `null` means NO EVIDENCE OF UNCHANGEDNESS, and every caller treats it as "re-index". A dirty tree
195
+ * genuinely cannot be identified by its HEAD — the working copy differs from it — so skipping one
196
+ * would serve stale symbols for edits already on disk.
197
+ */
198
+ async function cleanHead(rootPath) {
199
+ try {
200
+ const [{ stdout: head }, { stdout: status }] = await Promise.all([
201
+ execCapped('git', ['rev-parse', 'HEAD'], { timeout: 10_000, cwd: rootPath, windowsHide: true }),
202
+ execCapped('git', ['status', '--porcelain'], { timeout: 30_000, cwd: rootPath, windowsHide: true }),
203
+ ]);
204
+ if (status.trim() !== '')
205
+ return null;
206
+ const sha = head.trim();
207
+ return sha === '' ? null : sha;
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ function buildStatus({ projectId, members, manifest, dbExists, env }) {
214
+ const dbPath = projectGraphDb(projectId, env);
215
+ const indexedBy = new Map((manifest?.repos ?? []).map((r) => [r.label, r]));
216
+ const repos = [
217
+ ...members.repos.map((m) => {
218
+ const row = dbExists ? indexedBy.get(m.label) : undefined;
219
+ if (row === undefined) {
220
+ return {
221
+ repoId: m.repoId,
222
+ label: m.label,
223
+ rootPath: m.repo.root_path,
224
+ indexed: false,
225
+ reason: dbExists
226
+ ? 'attached since the last refresh — not in the graph yet'
227
+ : 'the project graph has never been built',
228
+ // Both causes are exactly what a refresh is for: the member is registered and its root
229
+ // is where the registry says, there are simply no rows for it yet.
230
+ remedy: `POST /api/v1/projects/${projectId}/graph/refresh fixes it.`,
231
+ };
232
+ }
233
+ // The rows under this label were written from a DIFFERENT root than the registry now names.
234
+ // `indexed: true` here would be a claim about a checkout that was never indexed, and it is
235
+ // the shape a refused refresh leaves behind: estate binds a label to the root it first saw,
236
+ // so re-indexing a repo that moved is refused as a collision (`repo_scope.rs::guard` #4) and
237
+ // the prior manifest row survives untouched. Reporting that as a healthy graph made a project
238
+ // read `ready` with `missingRepos: []` for ever while queries served the old tree's symbols.
239
+ // The repo is NOT in the graph as currently registered, so it is missing, its rows are
240
+ // excluded from answers (`queryable` builds its label map from `indexed`), and the reason
241
+ // names both roots plus the remedy the guard's own message asks for.
242
+ if (row.rootPath !== m.repo.root_path) {
243
+ return {
244
+ repoId: m.repoId,
245
+ label: m.label,
246
+ rootPath: m.repo.root_path,
247
+ indexed: false,
248
+ reason: `the graph holds '${m.label}' as indexed from ${row.rootPath}, but the registry now ` +
249
+ `points at ${m.repo.root_path} — those rows describe a different checkout and are ` +
250
+ `excluded. A refresh re-indexes it; if wicked-estate refuses the label as already ` +
251
+ `bound to the old root, delete ${dbPath} and rebuild the project graph.`,
252
+ };
253
+ }
254
+ return {
255
+ repoId: m.repoId,
256
+ label: m.label,
257
+ rootPath: m.repo.root_path,
258
+ indexed: true,
259
+ ...(row.head === undefined ? {} : { head: row.head }),
260
+ indexedAt: row.indexedAt,
261
+ };
262
+ }),
263
+ ...members.dangling.map((d) => ({
264
+ repoId: d.repoId,
265
+ label: d.label,
266
+ rootPath: '',
267
+ indexed: false,
268
+ reason: 'attached as a member, but the repo registry no longer knows this ref',
269
+ // NOT a refresh: there is no registered repo to index, so a refresh would report the same
270
+ // dangling member and change nothing. The member ref and the registry have to agree first.
271
+ remedy: `Re-register the repo under this ref, or drop the member with ` +
272
+ `DELETE /api/v1/projects/${projectId}/members/${d.repoId}.`,
273
+ })),
274
+ ];
275
+ const missingRepos = repos.filter((r) => !r.indexed).map((r) => r.repoId);
276
+ const memberLabels = new Set(members.repos.map((m) => m.label));
277
+ const staleRepos = (manifest?.repos ?? [])
278
+ .filter((r) => !memberLabels.has(r.label))
279
+ .map((r) => r.label);
280
+ const indexedCount = repos.filter((r) => r.indexed).length;
281
+ const base = {
282
+ projectId,
283
+ dbPath: dbExists ? dbPath : null,
284
+ repos,
285
+ missingRepos,
286
+ staleRepos,
287
+ linkage: 'co-located',
288
+ note: CO_LOCATION_NOTE,
289
+ updatedAt: manifest === null || !dbExists
290
+ ? null
291
+ : manifest.repos.reduce((max, r) => Math.max(max, r.indexedAt), 0) || null,
292
+ };
293
+ if (members.repos.length === 0 && members.dangling.length === 0) {
294
+ return {
295
+ ...base,
296
+ state: 'no-repo-members',
297
+ detail: `Project ${projectId} has no crew.repo members, so there is nothing to build a code graph ` +
298
+ `from. Attach one with POST /api/v1/projects/${projectId}/members {"kind":"crew.repo","ref":"<repoId>"}.`,
299
+ };
300
+ }
301
+ const dangling = members.dangling.map((d) => d.repoId).join(', ');
302
+ if (members.repos.length === 0) {
303
+ // Every member ref is dangling. `not-indexed` is the right STATE (there is no graph), but the
304
+ // refresh remedy the branch below offers is not the right advice: a refresh would resolve zero
305
+ // repos and build nothing, so an operator would run it, see no change, and have learned
306
+ // nothing. The cause is the membership, and the message says so.
307
+ return {
308
+ ...base,
309
+ state: 'not-indexed',
310
+ detail: `Project ${projectId}'s only repo member(s) — ${dangling} — are not in the repo registry, ` +
311
+ `so there is nothing to index. Re-attach a registered repo, or re-register the missing one; ` +
312
+ `a refresh cannot help while every member is dangling.`,
313
+ };
314
+ }
315
+ if (!dbExists || indexedCount === 0) {
316
+ return {
317
+ ...base,
318
+ state: 'not-indexed',
319
+ detail: `Project ${projectId} has ${members.repos.length} repo member(s) but no code graph yet. ` +
320
+ `Build it with POST /api/v1/projects/${projectId}/graph/refresh.` +
321
+ (dangling === '' ? '' : ` (${dangling} is a member the repo registry does not know.)`),
322
+ };
323
+ }
324
+ if (indexedCount === 1) {
325
+ return {
326
+ ...base,
327
+ state: 'ready-single-repo',
328
+ detail: `Project ${projectId}'s graph holds exactly one repo (${repos.find((r) => r.indexed)?.label ?? '?'})` +
329
+ `${missingRepos.length > 0 ? `, with ${missingRepos.length} member repo(s) missing from it` : ''}` +
330
+ `. Answers are correct but cannot span repos.`,
331
+ };
332
+ }
333
+ return {
334
+ ...base,
335
+ state: 'ready',
336
+ detail: `Project ${projectId}'s graph holds ${indexedCount} co-located repos` +
337
+ `${missingRepos.length > 0 ? `; ${missingRepos.length} member repo(s) are NOT in it and every answer is partial` : ''}.`,
338
+ };
339
+ }
340
+ /**
341
+ * Thrown when the running addon predates `code_graph_db` on the repo record. Mapped to 501: this is
342
+ * a capability gap in the engine, not a bad request.
343
+ *
344
+ * WHY this surface gates on a field it does not itself use. The project graph lives in crew's own
345
+ * state directory and is built from `root_path`, so it could be built against any addon. But
346
+ * `code_graph_db` is the engine's statement that it can vouch for where a repo's graph lives, and
347
+ * `/repos/:id/graph` HARD-THROWS without it (repoPaths.ts, deliberately — a local re-derivation is
348
+ * what FINDING-069 was). Quietly serving a project graph on a daemon whose per-repo graph surface
349
+ * cannot answer would leave two graph endpoints disagreeing about whether this repo has a graph at
350
+ * all, and the operator's real problem — a stale addon — unmentioned.
351
+ */
352
+ export class ProjectGraphEngineTooOldError extends Error {
353
+ cause;
354
+ constructor(cause) {
355
+ super(cause);
356
+ this.cause = cause;
357
+ this.name = 'ProjectGraphEngineTooOldError';
358
+ }
359
+ }
360
+ function assertEngineFresh(repos) {
361
+ for (const m of repos) {
362
+ try {
363
+ codeGraphDb(m.repo);
364
+ }
365
+ catch (err) {
366
+ throw new ProjectGraphEngineTooOldError(message(err));
367
+ }
368
+ }
369
+ }
370
+ /** Read the project graph's standing without touching it. */
371
+ export async function projectGraphStatus(adapter, projectId, env = process.env) {
372
+ const members = await resolveMembers(adapter, projectId);
373
+ try {
374
+ assertEngineFresh(members.repos);
375
+ }
376
+ catch (err) {
377
+ if (!(err instanceof ProjectGraphEngineTooOldError))
378
+ throw err;
379
+ return {
380
+ projectId,
381
+ state: 'engine-too-old',
382
+ detail: err.cause,
383
+ dbPath: null,
384
+ repos: [],
385
+ missingRepos: members.repos.map((m) => m.repoId),
386
+ staleRepos: [],
387
+ linkage: 'co-located',
388
+ note: CO_LOCATION_NOTE,
389
+ updatedAt: null,
390
+ };
391
+ }
392
+ const dbPath = projectGraphDb(projectId, env);
393
+ const dbExists = existsSync(dbPath);
394
+ // The manifest is read ONLY when the database exists. A manifest describing rows in a file
395
+ // somebody deleted would make every repo look fresh and every refresh a no-op, leaving the
396
+ // project permanently answering "nothing found" — indistinguishable from a project of empty repos.
397
+ const manifest = dbExists ? await readManifest(projectId, env) : null;
398
+ return buildStatus({ projectId, members, manifest, dbExists, env });
399
+ }
400
+ // ── Refresh ───────────────────────────────────────────────────────────────────
401
+ /**
402
+ * One refresh at a time per project. Two `wicked-estate index` runs against one SQLite file is a
403
+ * writer race, and the second caller wanting the same work done is served by the first — so
404
+ * concurrent callers COALESCE onto the in-flight refresh rather than getting a 409 for asking.
405
+ */
406
+ const inFlight = new Map();
407
+ export async function refreshProjectGraph(adapter, projectId, env = process.env) {
408
+ const running = inFlight.get(projectId);
409
+ if (running !== undefined)
410
+ return running;
411
+ const started = doRefresh(adapter, projectId, env).finally(() => inFlight.delete(projectId));
412
+ inFlight.set(projectId, started);
413
+ return started;
414
+ }
415
+ async function doRefresh(adapter, projectId, env) {
416
+ const members = await resolveMembers(adapter, projectId);
417
+ assertEngineFresh(members.repos);
418
+ const dbPath = projectGraphDb(projectId, env);
419
+ const indexed = [];
420
+ const skipped = [];
421
+ const failed = [];
422
+ if (members.repos.length === 0) {
423
+ return {
424
+ status: buildStatus({ projectId, members, manifest: null, dbExists: existsSync(dbPath), env }),
425
+ indexed,
426
+ skipped,
427
+ failed,
428
+ };
429
+ }
430
+ assertLabelsUnique(members.repos);
431
+ if (!(await estateSupportsMultiRepo(env))) {
432
+ // Refusing is the ONLY safe answer. An estate without `--repo` accepts the flag, drops it, and
433
+ // exits 0, so proceeding would co-locate three repos into a database holding one of them, with
434
+ // nothing in any output to say so.
435
+ throw new Error(`${estateExe(env)} does not support 'index --repo <name>' (wicked-estate#117). ` +
436
+ `Older builds ACCEPT the flag, ignore it, and exit 0 — every member repo after the first ` +
437
+ `would silently overwrite the one before it. Upgrade that binary, or point ` +
438
+ `WICKED_ESTATE_EXE at a build that has it.`);
439
+ }
440
+ await mkdir(dirname(dbPath), { recursive: true });
441
+ const dbExisted = existsSync(dbPath);
442
+ const prior = dbExisted ? await readManifest(projectId, env) : null;
443
+ const priorByLabel = new Map((prior?.repos ?? []).map((r) => [r.label, r]));
444
+ // Prior rows survive a refresh only if the database they describe does. When it does not, every
445
+ // repo is re-indexed and the manifest is rebuilt from this run alone.
446
+ const rows = dbExisted ? [...(prior?.repos ?? [])] : [];
447
+ // FALSE at the start of EVERY refresh, deliberately — not seeded from the prior manifest.
448
+ //
449
+ // Seeding it from "a previous run wrote labels" makes the evidence check a one-time initiation
450
+ // rite, and the thing it guards against is not a property of the graph, it is a property of the
451
+ // BINARY THIS RUN WILL USE. A `wicked-estate` that advertises `--repo` and drops it (0.14.4's
452
+ // behaviour under a newer usage banner — a vendored build, a downgrade, a WICKED_ESTATE_EXE
453
+ // pointed somewhere else) sails past the help-text probe, and with the check already "verified"
454
+ // nothing looks at the database again: the refresh answers 200 / `indexed: [...]` / `failed: []`
455
+ // and the status reads `ready` with `missingRepos: []` while every repo's rows have been
456
+ // overwritten by unlabelled ones and every query returns `[]`. Reproduced end-to-end against the
457
+ // real 0.14.4 before this line changed.
458
+ //
459
+ // The price is one extra `stats` per refresh that indexes anything at all, and none when every
460
+ // repo skips.
461
+ let verifiedLabelling = false;
462
+ for (const m of members.repos) {
463
+ const head = await cleanHead(m.repo.root_path);
464
+ const before = priorByLabel.get(m.label);
465
+ // Skip only on POSITIVE evidence: a clean checkout, at the same commit the graph already holds,
466
+ // in the same place it was indexed from. Anything less re-indexes — estate's own incremental
467
+ // digest skip then makes an unchanged re-index cheap, so the cost of being wrong here is a
468
+ // second, not a stale answer.
469
+ if (dbExisted &&
470
+ before !== undefined &&
471
+ head !== null &&
472
+ before.head === head &&
473
+ before.rootPath === m.repo.root_path) {
474
+ skipped.push(m.label);
475
+ continue;
476
+ }
477
+ try {
478
+ await execCapped(estateExe(env), ['index', m.repo.root_path, '--db', dbPath, '--repo', m.label], { timeout: INDEX_TIMEOUT_MS, cwd: m.repo.root_path });
479
+ if (!verifiedLabelling) {
480
+ if (!(await graphHoldsLabel(dbPath, m.label, env))) {
481
+ throw new EstateDroppedRepoLabelError(`wicked-estate indexed ${m.repoId} but the graph records no repo labelled ` +
482
+ `'${m.label}' — the binary accepted --repo and ignored it, so the next repo would ` +
483
+ `overwrite this one. Refusing to continue; delete ${dbPath} and upgrade wicked-estate.`);
484
+ }
485
+ verifiedLabelling = true;
486
+ }
487
+ const row = {
488
+ repoId: m.repoId,
489
+ label: m.label,
490
+ rootPath: m.repo.root_path,
491
+ ...(head === null ? {} : { head }),
492
+ indexedAt: Date.now(),
493
+ };
494
+ const at = rows.findIndex((r) => r.label === m.label);
495
+ if (at >= 0)
496
+ rows[at] = row;
497
+ else
498
+ rows.push(row);
499
+ indexed.push(m.label);
500
+ }
501
+ catch (err) {
502
+ // A binary that drops `--repo` is a property of the RUN, not of this repo: every remaining
503
+ // member would index over the rows just written, so this refresh stops here.
504
+ //
505
+ // And the PRIOR manifest goes with it. By the time the evidence check can fire, the offending
506
+ // index has already run: whatever labelled rows the database held have been overwritten by
507
+ // unlabelled ones. Leaving the manifest describing them makes the refusal loud on the write
508
+ // path and silent on the read path — `GET /graph` keeps answering `ready` / `missingRepos:
509
+ // []` while every query returns `[]` at 200, which is the exact shape this surface exists to
510
+ // refuse. Emptying it re-derives the truth: nothing in that database can be attributed to a
511
+ // repo, so the project is `not-indexed` and the query routes refuse WITH A CAUSE. The
512
+ // database itself is left on disk, because the error names deleting it as the operator's
513
+ // step and destroying data on the way out of an error path is not this function's call.
514
+ if (err instanceof EstateDroppedRepoLabelError) {
515
+ await writeManifest(projectId, { version: 1, projectId, repos: [] }, env);
516
+ throw err;
517
+ }
518
+ failed.push({
519
+ repoId: m.repoId,
520
+ label: m.label,
521
+ error: err instanceof ExecOutputTooLarge ? err.message : message(err),
522
+ });
523
+ }
524
+ }
525
+ const manifest = { version: 1, projectId, repos: rows };
526
+ await writeManifest(projectId, manifest, env);
527
+ return {
528
+ status: buildStatus({ projectId, members, manifest, dbExists: existsSync(dbPath), env }),
529
+ indexed,
530
+ skipped,
531
+ failed,
532
+ };
533
+ }
534
+ /**
535
+ * How many repo labels a binding reason names before it starts counting instead. Eight fits a
536
+ * readable log line and covers every project anyone has built so far; past that the count carries
537
+ * the information and the labels are just volume.
538
+ */
539
+ const MAX_NAMED_LABELS = 8;
540
+ /** `a, b, c` — or `a, b, … and 12 more` once the list stops being readable. */
541
+ function labelList(labels) {
542
+ if (labels.length <= MAX_NAMED_LABELS)
543
+ return labels.join(', ');
544
+ const shown = labels.slice(0, MAX_NAMED_LABELS).join(', ');
545
+ return `${shown}, and ${labels.length - MAX_NAMED_LABELS} more`;
546
+ }
547
+ /**
548
+ * What a run actually falls back TO when the project graph is unavailable — which is not the same
549
+ * sentence for every run, and saying the wrong one misdirects the operator reading it.
550
+ *
551
+ * A repo-bound run degrades to its own repo's graph: strictly less than the project's, still a
552
+ * complete description of the worktree the worker sits in. A repo-LESS run (the interactive
553
+ * draft/demo seams, which launch with no `repoRef` at all) has no own repo to degrade to, so it
554
+ * gets nothing. Telling such a run it "uses its own repo's code graph" names a graph that does not
555
+ * exist and sends whoever is debugging it looking for one.
556
+ */
557
+ function degradedTo(repoRef) {
558
+ return repoRef === undefined
559
+ ? 'This repo-less run gets no code graph.'
560
+ : "This run uses its own repo's code graph in the meantime.";
561
+ }
562
+ /**
563
+ * Decide which code graph a run launched into `projectId` should be bound to.
564
+ *
565
+ * # This never indexes
566
+ *
567
+ * A refresh is `wicked-estate index` per member repo, bounded at ten minutes EACH. Doing that
568
+ * inside a launch would turn "start a run" into an unannounced multi-repo indexing job that blocks
569
+ * the response, and the first thing an operator would learn about it is a request that appears to
570
+ * hang. So a missing or stale graph DEGRADES the run to the per-repo graph and says so; refreshing
571
+ * stays an explicit `POST /projects/:id/graph/refresh`.
572
+ *
573
+ * # Why the run's OWN repo decides it
574
+ *
575
+ * The engine independently verifies whatever it is handed and falls back on its own, so this
576
+ * function cannot make a run unsafe — but it can make one confusing, and it has information the
577
+ * engine does not. `projectGraphStatus` knows a repo was attached after the last refresh, that its
578
+ * registry root moved out from under the label, that its member ref is dangling. Declining HERE,
579
+ * with that cause attached, is the difference between an operator reading "attached since the last
580
+ * refresh — not in the graph yet" and reading the engine's generic "no files under that label".
581
+ *
582
+ * The rule itself is the engine's, restated on this side: bind when the graph holds THIS RUN'S
583
+ * repo. A graph missing some OTHER member is still bound — it is strictly more than the per-repo
584
+ * graph, and the run's own code is described correctly. A graph missing THIS repo is not, because
585
+ * its answers about the worktree the worker is sitting in would all be "nothing found".
586
+ */
587
+ export async function resolveProjectGraphBinding(adapter, projectId, repoRef, env = process.env) {
588
+ let status;
589
+ try {
590
+ status = await projectGraphStatus(adapter, projectId, env);
591
+ }
592
+ catch (err) {
593
+ // Resolving the binding is an ENHANCEMENT to the launch. A project whose membership cannot be
594
+ // read, or an addon too old to vouch for repo graph paths, must not take the run down with it —
595
+ // the run is still perfectly launchable against its own repo's graph.
596
+ return {
597
+ binding: null,
598
+ reason: `the project graph could not be read (${message(err)}). ${degradedTo(repoRef)}`,
599
+ };
600
+ }
601
+ if (status.dbPath === null) {
602
+ return {
603
+ binding: null,
604
+ reason: `${status.detail} ${degradedTo(repoRef)}`,
605
+ };
606
+ }
607
+ // A repo-less run has no own-repo that could be missing from the graph, so any graph with
608
+ // something in it is a strict gain over the nothing such a run gets today.
609
+ if (repoRef === undefined) {
610
+ const indexed = status.repos.filter((r) => r.indexed);
611
+ if (indexed.length === 0) {
612
+ return {
613
+ binding: null,
614
+ reason: `${status.detail} ${degradedTo(repoRef)}`,
615
+ };
616
+ }
617
+ // The COUNT is exact; the label list is capped. This string is a log line on every repo-less
618
+ // launch, and a project with a hundred members would put a hundred labels on one line of every
619
+ // operator's structured logs — the labels are there to make the answer recognisable at a
620
+ // glance, which a wall of them defeats. Naming a few and counting the rest keeps both.
621
+ return {
622
+ binding: { dbPath: status.dbPath },
623
+ reason: `this repo-less run is bound to the project graph (${indexed.length} repo(s): ` +
624
+ `${labelList(indexed.map((r) => r.label))}).`,
625
+ };
626
+ }
627
+ const own = status.repos.find((r) => r.repoId === repoRef);
628
+ if (own === undefined) {
629
+ // The run targets a repo that is not a member of the project it is filed into. Legal — filing a
630
+ // run and attaching a repo are separate acts — but the project graph provably does not describe
631
+ // this repo, so there is nothing to gain and an own-repo blind spot to lose.
632
+ return {
633
+ binding: null,
634
+ reason: `repo '${repoRef}' is not a crew.repo member of project ${projectId}, so the project ` +
635
+ `graph does not describe it; this run uses the repo's own code graph. Attach it with ` +
636
+ `POST /api/v1/projects/${projectId}/members and refresh the graph to widen future runs.`,
637
+ };
638
+ }
639
+ if (!own.indexed) {
640
+ // The remedy comes from the ROW, which knows the cause, and is omitted when no single action
641
+ // resolves it. Appending "a refresh fixes it" unconditionally — the first cut — was wrong for
642
+ // two of the four causes: a DANGLING member ref has no registered repo to index, and a MOVED
643
+ // root carries its own longer remedy (estate refuses to rebind the label, so the graph must be
644
+ // rebuilt). An operator mid-incident who runs the suggested refresh and sees nothing change
645
+ // has lost the time and stopped believing the next sentence.
646
+ return {
647
+ binding: null,
648
+ reason: `the project graph does not hold '${repoRef}' (${own.reason ?? 'not indexed'}), so binding ` +
649
+ `it would give this run tools that answer "not found" about its own worktree; it uses the ` +
650
+ `repo's own code graph instead.${own.remedy === undefined ? '' : ` ${own.remedy}`}`,
651
+ };
652
+ }
653
+ // Bound. Staleness is NOT checked: `own.head` is the commit indexed, the worktree is wherever it
654
+ // is now, and every code graph — including the per-repo one this would fall back to — is behind
655
+ // its checkout the moment anyone commits. Refusing on drift would disqualify the project graph
656
+ // after a single commit and hand back a graph with exactly the same drift and less in it.
657
+ const partial = status.missingRepos.length;
658
+ return {
659
+ binding: { dbPath: status.dbPath, repoLabel: own.label },
660
+ reason: `bound to the project graph as '${own.label}'` +
661
+ (own.head === undefined ? '' : ` (indexed at ${own.head.slice(0, 8)})`) +
662
+ (partial === 0
663
+ ? '.'
664
+ : `; ${partial} member repo(s) are not in it, so cross-repo answers are partial.`),
665
+ };
666
+ }
667
+ export async function queryable(adapter, projectId, env = process.env) {
668
+ const status = await projectGraphStatus(adapter, projectId, env);
669
+ if (status.state !== 'ready' && status.state !== 'ready-single-repo')
670
+ return { ok: false, status };
671
+ const labels = new Map(status.repos.filter((r) => r.indexed).map((r) => [r.label, r.repoId]));
672
+ return { ok: true, dbPath: status.dbPath, status, labels };
673
+ }
674
+ /**
675
+ * Split an estate path into the repo it belongs to and the repo-relative path inside it.
676
+ *
677
+ * `null` when the prefix is not a CURRENT member's label: rows of a repo that has since been
678
+ * detached are still in the database (estate has no per-label delete), and returning them under a
679
+ * project-scoped query would answer about a repo the project no longer contains. They are excluded
680
+ * here and named in `status.staleRepos`, which is a different thing from being dropped silently.
681
+ */
682
+ function attribute(path, labels) {
683
+ const slash = path.indexOf('/');
684
+ if (slash <= 0)
685
+ return null;
686
+ const label = path.slice(0, slash);
687
+ const repoId = labels.get(label);
688
+ if (repoId === undefined)
689
+ return null;
690
+ return { repo: label, repoId, file: path.slice(slash + 1) };
691
+ }
692
+ /**
693
+ * estate's raw hit list → attributed hits plus their per-repo counts. THE federation step, and the
694
+ * only place a result acquires its provenance — exported so the attribution rules are testable
695
+ * without a graph on disk, which is what makes the stale-label exclusion below coverable at all.
696
+ */
697
+ export function attributeHits(raw, labels) {
698
+ const hits = (Array.isArray(raw) ? raw : [])
699
+ .map((r) => toHit(r, labels))
700
+ .filter((h) => h !== null);
701
+ return { hits, byRepo: countByRepo(hits) };
702
+ }
703
+ function countByRepo(hits) {
704
+ const counts = new Map();
705
+ for (const h of hits) {
706
+ const row = counts.get(h.repo);
707
+ if (row === undefined)
708
+ counts.set(h.repo, { repoId: h.repoId, repo: h.repo, count: 1 });
709
+ else
710
+ row.count += 1;
711
+ }
712
+ return [...counts.values()].sort((a, b) => b.count - a.count || a.repo.localeCompare(b.repo));
713
+ }
714
+ function toHit(raw, labels) {
715
+ const file = typeof raw.file === 'string' ? raw.file : '';
716
+ const where = attribute(file, labels);
717
+ if (where === null)
718
+ return null;
719
+ const id = typeof raw.id === 'string' ? raw.id : typeof raw.symbol_id === 'string' ? raw.symbol_id : '';
720
+ return {
721
+ repoId: where.repoId,
722
+ repo: where.repo,
723
+ id,
724
+ name: typeof raw.name === 'string' ? raw.name : '',
725
+ kind: typeof raw.kind === 'string' ? raw.kind : '',
726
+ file: where.file,
727
+ line: typeof raw.line === 'number' ? raw.line : 0,
728
+ };
729
+ }
730
+ /** Dependents of a symbol across every member repo, each hit attributed to the repo it is in. */
731
+ export async function projectBlastRadius(q, name, env = process.env) {
732
+ const { stdout } = await execCapped(estateExe(env), ['blast-radius', name, '--db', q.dbPath, '--json'], { timeout: QUERY_TIMEOUT_MS });
733
+ const raw = JSON.parse(stdout);
734
+ const { hits, byRepo } = attributeHits(raw.dependents, q.labels);
735
+ return {
736
+ projectId: q.status.projectId,
737
+ target: typeof raw.target === 'string' ? raw.target : name,
738
+ dependents: hits,
739
+ byRepo,
740
+ // Carried through verbatim: the honesty contract of `/repos/:id/graph/blast-radius` is that an
741
+ // empty `dependents` never reads as "safe to change", and it does not become less true for
742
+ // being federated.
743
+ unresolved: typeof raw.unresolved === 'number' ? raw.unresolved : 0,
744
+ reposSearched: [...q.labels.keys()].sort(),
745
+ missingRepos: q.status.missingRepos,
746
+ linkage: 'co-located',
747
+ note: CO_LOCATION_NOTE,
748
+ };
749
+ }
750
+ /**
751
+ * Symbol search across every member repo — exact name, which is what estate's `resolve` answers.
752
+ *
753
+ * Not a substring search on purpose: the only estate primitive that would give one is a full
754
+ * `nodes --json` dump filtered daemon-side, whose cost scales with the whole project rather than
755
+ * with the query, and which would need a cap whose truncation is itself a silent wrong answer.
756
+ * `resolve` is the same primitive the repo-scoped surface uses to turn a name into SymbolIds; run
757
+ * against a co-located graph it returns every repo's matches in one call.
758
+ */
759
+ export async function projectSymbolSearch(q, name, env = process.env) {
760
+ const { stdout } = await execCapped(estateExe(env), ['resolve', name, '--db', q.dbPath, '--json'], {
761
+ timeout: QUERY_TIMEOUT_MS,
762
+ });
763
+ const { hits, byRepo } = attributeHits(JSON.parse(stdout), q.labels);
764
+ return {
765
+ projectId: q.status.projectId,
766
+ query: name,
767
+ matches: hits,
768
+ byRepo,
769
+ reposSearched: [...q.labels.keys()].sort(),
770
+ missingRepos: q.status.missingRepos,
771
+ linkage: 'co-located',
772
+ note: `${CO_LOCATION_NOTE} ${EXACT_NAME_NOTE}`,
773
+ };
774
+ }
775
+ //# sourceMappingURL=graph.js.map