edsger 0.69.0 → 0.71.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 (78) hide show
  1. package/dist/api/github.d.ts +1 -1
  2. package/dist/api/github.js +1 -1
  3. package/dist/commands/architecture-diagram/index.d.ts +8 -0
  4. package/dist/commands/architecture-diagram/index.js +10 -0
  5. package/dist/commands/class-diagram/index.d.ts +7 -0
  6. package/dist/commands/class-diagram/index.js +9 -0
  7. package/dist/commands/data-flow/index.d.ts +5 -5
  8. package/dist/commands/data-flow/index.js +8 -8
  9. package/dist/commands/diagram-shared/index.d.ts +21 -0
  10. package/dist/commands/diagram-shared/index.js +37 -0
  11. package/dist/commands/discover/index.d.ts +14 -0
  12. package/dist/commands/discover/index.js +29 -0
  13. package/dist/commands/er-diagram/index.d.ts +19 -0
  14. package/dist/commands/er-diagram/index.js +55 -0
  15. package/dist/commands/flowchart/index.d.ts +8 -0
  16. package/dist/commands/flowchart/index.js +10 -0
  17. package/dist/commands/screen-flow/index.d.ts +5 -5
  18. package/dist/commands/screen-flow/index.js +8 -8
  19. package/dist/commands/sequence-diagram/index.d.ts +19 -0
  20. package/dist/commands/sequence-diagram/index.js +55 -0
  21. package/dist/commands/state-diagram/index.d.ts +7 -0
  22. package/dist/commands/state-diagram/index.js +9 -0
  23. package/dist/index.js +139 -5
  24. package/dist/phases/architecture-diagram/index.d.ts +15 -0
  25. package/dist/phases/architecture-diagram/index.js +51 -0
  26. package/dist/phases/class-diagram/index.d.ts +14 -0
  27. package/dist/phases/class-diagram/index.js +76 -0
  28. package/dist/phases/data-flow/index.d.ts +2 -2
  29. package/dist/phases/data-flow/index.js +37 -37
  30. package/dist/phases/data-flow/mcp-server.d.ts +1 -1
  31. package/dist/phases/data-flow/mcp-server.js +2 -2
  32. package/dist/phases/data-flow/types.d.ts +1 -1
  33. package/dist/phases/data-flow/types.js +1 -1
  34. package/dist/phases/diagram-shared/clone-repos.d.ts +63 -0
  35. package/dist/phases/diagram-shared/clone-repos.js +153 -0
  36. package/dist/phases/diagram-shared/generate.d.ts +42 -0
  37. package/dist/phases/diagram-shared/generate.js +162 -0
  38. package/dist/phases/diagram-shared/graph.d.ts +62 -0
  39. package/dist/phases/diagram-shared/graph.js +169 -0
  40. package/dist/phases/diagram-shared/mcp.d.ts +35 -0
  41. package/dist/phases/diagram-shared/mcp.js +68 -0
  42. package/dist/phases/diagram-shared/prompts.d.ts +23 -0
  43. package/dist/phases/diagram-shared/prompts.js +35 -0
  44. package/dist/phases/discover-services/index.d.ts +29 -0
  45. package/dist/phases/discover-services/index.js +528 -0
  46. package/dist/phases/er-diagram/index.d.ts +28 -0
  47. package/dist/phases/er-diagram/index.js +290 -0
  48. package/dist/phases/er-diagram/mcp-server.d.ts +77 -0
  49. package/dist/phases/er-diagram/mcp-server.js +144 -0
  50. package/dist/phases/er-diagram/prompts.d.ts +14 -0
  51. package/dist/phases/er-diagram/prompts.js +36 -0
  52. package/dist/phases/er-diagram/types.d.ts +76 -0
  53. package/dist/phases/er-diagram/types.js +84 -0
  54. package/dist/phases/flowchart/index.d.ts +15 -0
  55. package/dist/phases/flowchart/index.js +50 -0
  56. package/dist/phases/output-contracts.js +178 -2
  57. package/dist/phases/screen-flow/index.d.ts +3 -3
  58. package/dist/phases/screen-flow/index.js +47 -45
  59. package/dist/phases/screen-flow/mcp-server.js +2 -2
  60. package/dist/phases/sequence-diagram/index.d.ts +30 -0
  61. package/dist/phases/sequence-diagram/index.js +290 -0
  62. package/dist/phases/sequence-diagram/mcp-server.d.ts +64 -0
  63. package/dist/phases/sequence-diagram/mcp-server.js +134 -0
  64. package/dist/phases/sequence-diagram/prompts.d.ts +14 -0
  65. package/dist/phases/sequence-diagram/prompts.js +36 -0
  66. package/dist/phases/sequence-diagram/types.d.ts +52 -0
  67. package/dist/phases/sequence-diagram/types.js +93 -0
  68. package/dist/phases/state-diagram/index.d.ts +15 -0
  69. package/dist/phases/state-diagram/index.js +53 -0
  70. package/dist/skills/phase/architecture-diagram/SKILL.md +41 -0
  71. package/dist/skills/phase/class-diagram/SKILL.md +44 -0
  72. package/dist/skills/phase/er-diagram/SKILL.md +71 -0
  73. package/dist/skills/phase/flowchart/SKILL.md +38 -0
  74. package/dist/skills/phase/sequence-diagram/SKILL.md +67 -0
  75. package/dist/skills/phase/state-diagram/SKILL.md +38 -0
  76. package/dist/workspace/session-workspace.d.ts +2 -2
  77. package/dist/workspace/session-workspace.js +2 -2
  78. package/package.json +1 -1
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Phase: discover-services (GitHub Discovery)
3
+ *
4
+ * Scans every repository in a team's connected GitHub organisation and infers
5
+ * a service for each one. The server mints a short-lived org-scoped token
6
+ * (`github/org_token`) and the CLI reads each repo's signal files directly via
7
+ * Octokit — a handful of root files (README, package manifests, Dockerfile) —
8
+ * so private repos are read over the API without cloning. It then derives a
9
+ * service identity (name, language, kind, description) heuristically and
10
+ * upserts it into the team-scoped service catalog (`services` + a `repo`
11
+ * component).
12
+ *
13
+ * Progress is recorded against a `discovery_runs` row: the run is flipped to
14
+ * `running`, heart-beaten on every repo, and finalized as `success`/`failed`.
15
+ * The desktop Discover Services page reads that row to render live status.
16
+ */
17
+ import { Octokit } from '@octokit/rest';
18
+ import { callMcpEndpoint } from '../../api/mcp-client.js';
19
+ import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
20
+ import { logDebug, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
21
+ const ORG_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
22
+ const MAX_FILE_BYTES = 16_000;
23
+ /** Signal files read from each repo's root to infer a service. */
24
+ const SIGNAL_FILES = [
25
+ 'README.md',
26
+ 'readme.md',
27
+ 'package.json',
28
+ 'pyproject.toml',
29
+ 'requirements.txt',
30
+ 'go.mod',
31
+ 'pom.xml',
32
+ 'build.gradle',
33
+ 'Cargo.toml',
34
+ 'Gemfile',
35
+ 'composer.json',
36
+ 'Dockerfile',
37
+ ];
38
+ /**
39
+ * Read a repo's root-level signal files with Octokit. Lists the root tree once,
40
+ * then fetches only the signal files that actually exist (in parallel) — avoids
41
+ * a 404 round-trip per candidate file. Returns a path → content map (capped).
42
+ */
43
+ async function readRepoSignalFiles(octokit, owner, name, ref) {
44
+ const files = new Map();
45
+ let rootNames;
46
+ try {
47
+ const { data } = await octokit.repos.getContent({
48
+ owner,
49
+ repo: name,
50
+ path: '',
51
+ ...(ref ? { ref } : {}),
52
+ });
53
+ if (!Array.isArray(data)) {
54
+ return files;
55
+ }
56
+ rootNames = new Set(data.filter((e) => e.type === 'file').map((e) => e.name));
57
+ }
58
+ catch {
59
+ // Empty repo, no access, or missing ref — nothing to read.
60
+ return files;
61
+ }
62
+ const wanted = SIGNAL_FILES.filter((p) => rootNames.has(p));
63
+ await Promise.all(wanted.map(async (path) => {
64
+ try {
65
+ const { data } = await octokit.repos.getContent({
66
+ owner,
67
+ repo: name,
68
+ path,
69
+ ...(ref ? { ref } : {}),
70
+ });
71
+ if (!Array.isArray(data) &&
72
+ data.type === 'file' &&
73
+ typeof data.content === 'string') {
74
+ files.set(path, Buffer.from(data.content, 'base64')
75
+ .toString('utf8')
76
+ .slice(0, MAX_FILE_BYTES));
77
+ }
78
+ }
79
+ catch {
80
+ // Skip unreadable file.
81
+ }
82
+ }));
83
+ return files;
84
+ }
85
+ const SERVER_FRAMEWORKS = [
86
+ 'express',
87
+ 'fastify',
88
+ 'koa',
89
+ '@nestjs/core',
90
+ 'next',
91
+ 'hapi',
92
+ 'flask',
93
+ 'django',
94
+ 'fastapi',
95
+ 'uvicorn',
96
+ 'gunicorn',
97
+ 'gin',
98
+ 'echo',
99
+ 'fiber',
100
+ 'spring-boot',
101
+ 'actix',
102
+ 'rails',
103
+ 'sinatra',
104
+ ];
105
+ /** Read the first markdown heading or paragraph from a README as a fallback description. */
106
+ function descriptionFromReadme(content) {
107
+ const lines = content.split('\n');
108
+ for (const raw of lines) {
109
+ const line = raw.trim();
110
+ if (!line || line.startsWith('![') || line.startsWith('<')) {
111
+ continue;
112
+ }
113
+ const heading = line.match(/^#{1,3}\s+(.*)$/);
114
+ if (heading) {
115
+ const text = heading[1].trim();
116
+ // Skip a bare title that just repeats the repo name — keep scanning for prose.
117
+ if (text.length > 0 && /\s/.test(text)) {
118
+ return text.slice(0, 280);
119
+ }
120
+ continue;
121
+ }
122
+ // First non-heading prose line.
123
+ return line.replace(/[*_`>#-]/g, '').trim().slice(0, 280) || null;
124
+ }
125
+ return null;
126
+ }
127
+ /** Map manifest presence to a language when GitHub doesn't report one. */
128
+ function languageFromManifests(files) {
129
+ const byManifest = [
130
+ ['package.json', 'JavaScript'],
131
+ ['pyproject.toml', 'Python'],
132
+ ['requirements.txt', 'Python'],
133
+ ['go.mod', 'Go'],
134
+ ['pom.xml', 'Java'],
135
+ ['build.gradle', 'Java'],
136
+ ['Cargo.toml', 'Rust'],
137
+ ['Gemfile', 'Ruby'],
138
+ ['composer.json', 'PHP'],
139
+ ];
140
+ for (const [manifest, language] of byManifest) {
141
+ if (files.has(manifest)) {
142
+ return language;
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+ /** Parse the subset of package.json fields we use for inference. */
148
+ function parsePackageJson(raw) {
149
+ const empty = {
150
+ name: null,
151
+ deps: [],
152
+ hasStart: false,
153
+ isPrivate: null,
154
+ hasMain: false,
155
+ };
156
+ if (!raw) {
157
+ return empty;
158
+ }
159
+ try {
160
+ const pkg = JSON.parse(raw);
161
+ return {
162
+ name: typeof pkg.name === 'string' ? pkg.name.replace(/^@[^/]+\//, '') : null,
163
+ deps: [
164
+ ...Object.keys(pkg.dependencies ?? {}),
165
+ ...Object.keys(pkg.devDependencies ?? {}),
166
+ ],
167
+ hasStart: Boolean(pkg.scripts?.start),
168
+ isPrivate: pkg.private === true,
169
+ hasMain: Boolean(pkg.main || pkg.exports || pkg.module),
170
+ };
171
+ }
172
+ catch {
173
+ // Malformed package.json — fall back to empty.
174
+ return empty;
175
+ }
176
+ }
177
+ /** Infer a service identity from a repo + its signal files. */
178
+ function inferService(repo, files) {
179
+ const fullName = repo.full_name;
180
+ const hasDockerfile = files.has('Dockerfile');
181
+ // Parse package.json if present.
182
+ const pkgRaw = files.get('package.json');
183
+ const pkg = parsePackageJson(pkgRaw);
184
+ const { name: pkgName, deps: pkgDeps, hasStart: pkgHasStart, isPrivate: pkgIsPrivate, hasMain: pkgHasMain, } = pkg;
185
+ // Detect language from repo metadata or manifest presence.
186
+ const language = repo.language ?? languageFromManifests(files);
187
+ // Detect server frameworks across manifests for kind + tags.
188
+ const manifestBlob = [
189
+ pkgDeps.join(' '),
190
+ files.get('requirements.txt') ?? '',
191
+ files.get('pyproject.toml') ?? '',
192
+ files.get('go.mod') ?? '',
193
+ files.get('pom.xml') ?? '',
194
+ files.get('build.gradle') ?? '',
195
+ files.get('Gemfile') ?? '',
196
+ ]
197
+ .join(' ')
198
+ .toLowerCase();
199
+ const detectedFrameworks = SERVER_FRAMEWORKS.filter((fw) => manifestBlob.includes(fw.toLowerCase()));
200
+ // Decide kind. Anything deployable (Dockerfile, start script, or a server
201
+ // framework) is a service; a published package manifest with a main/exports
202
+ // entry and no run target is a library; otherwise default to service.
203
+ const isDeployable = hasDockerfile || pkgHasStart || detectedFrameworks.length > 0;
204
+ const isLibrary = !isDeployable &&
205
+ Boolean(pkgRaw) &&
206
+ pkgHasMain &&
207
+ !pkgHasStart &&
208
+ pkgIsPrivate === false;
209
+ const kind = isLibrary ? 'library' : 'service';
210
+ let kindConfidence = 0.5;
211
+ if (isDeployable) {
212
+ kindConfidence = 0.8;
213
+ }
214
+ else if (isLibrary) {
215
+ kindConfidence = 0.7;
216
+ }
217
+ const name = (repo.name || pkgName || fullName.split('/')[1]).trim();
218
+ // Description: repo metadata wins, then README.
219
+ const readme = files.get('README.md') ?? files.get('readme.md');
220
+ const description = repo.description?.trim() ||
221
+ (readme ? descriptionFromReadme(readme) : null) ||
222
+ null;
223
+ const descriptionConfidence = repo.description?.trim() ? 0.9 : 0.5;
224
+ const tags = Array.from(new Set([
225
+ ...(language ? [language.toLowerCase()] : []),
226
+ ...detectedFrameworks.map((f) => f.replace(/^@[^/]+\//, '')),
227
+ ])).slice(0, 12);
228
+ const fieldSources = {
229
+ name: {
230
+ value: name,
231
+ confidence: 0.9,
232
+ source: 'github',
233
+ source_detail: fullName,
234
+ },
235
+ kind: { value: kind, confidence: kindConfidence, source: 'github' },
236
+ };
237
+ if (language) {
238
+ fieldSources.language = {
239
+ value: language,
240
+ confidence: repo.language ? 1.0 : 0.6,
241
+ source: 'github',
242
+ };
243
+ }
244
+ if (description) {
245
+ fieldSources.description = {
246
+ value: description,
247
+ confidence: descriptionConfidence,
248
+ source: 'github',
249
+ };
250
+ }
251
+ return {
252
+ name,
253
+ display_name: name,
254
+ description,
255
+ kind,
256
+ language,
257
+ tags,
258
+ field_sources: fieldSources,
259
+ };
260
+ }
261
+ /**
262
+ * Upsert one inferred service + its repo component, mirroring the
263
+ * `services.upsert_draft` semantics (insert when new, update only unlocked
264
+ * fields when it already exists). Returns whether the service was created or
265
+ * updated so the caller can keep run-level counters.
266
+ */
267
+ async function upsertServiceForRepo(
268
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supabase client type
269
+ supabase, teamId, userId, repo, inferred) {
270
+ const { data: existing } = await supabase
271
+ .from('services')
272
+ .select('id, locked_fields')
273
+ .eq('team_id', teamId)
274
+ .eq('name', inferred.name)
275
+ .maybeSingle();
276
+ const fields = {
277
+ display_name: inferred.display_name,
278
+ description: inferred.description,
279
+ kind: inferred.kind,
280
+ language: inferred.language,
281
+ tags: inferred.tags,
282
+ source: 'github',
283
+ source_ref: `github:${repo.full_name}`,
284
+ field_sources: inferred.field_sources,
285
+ };
286
+ let serviceId;
287
+ let action;
288
+ if (existing) {
289
+ const locked = existing.locked_fields ?? [];
290
+ const updates = {};
291
+ for (const [k, v] of Object.entries(fields)) {
292
+ if (!locked.includes(k) && v !== null && v !== undefined) {
293
+ updates[k] = v;
294
+ }
295
+ }
296
+ const { error } = await supabase
297
+ .from('services')
298
+ .update(updates)
299
+ .eq('id', existing.id);
300
+ if (error) {
301
+ throw error;
302
+ }
303
+ serviceId = existing.id;
304
+ action = 'updated';
305
+ }
306
+ else {
307
+ const { data: ins, error } = await supabase
308
+ .from('services')
309
+ .insert({
310
+ team_id: teamId,
311
+ name: inferred.name,
312
+ ...fields,
313
+ needs_review: true,
314
+ created_by: userId,
315
+ })
316
+ .select('id')
317
+ .single();
318
+ if (error) {
319
+ throw error;
320
+ }
321
+ serviceId = ins?.id ?? null;
322
+ action = 'created';
323
+ }
324
+ if (serviceId) {
325
+ await supabase.from('service_components').upsert({
326
+ service_id: serviceId,
327
+ kind: 'repo',
328
+ provider: 'github',
329
+ external_id: repo.full_name,
330
+ url: repo.html_url,
331
+ metadata: {
332
+ default_branch: repo.default_branch,
333
+ private: repo.private,
334
+ language: repo.language,
335
+ },
336
+ }, { onConflict: 'service_id,kind,external_id' });
337
+ }
338
+ return action;
339
+ }
340
+ export async function discoverServices(opts) {
341
+ const { teamId, verbose } = opts;
342
+ const err = (message) => ({
343
+ status: 'error',
344
+ message,
345
+ reposScanned: 0,
346
+ servicesCreated: 0,
347
+ servicesUpdated: 0,
348
+ });
349
+ if (!hasSupabaseSession()) {
350
+ return err('Supabase session unavailable. Sign in to the Edsger desktop app.');
351
+ }
352
+ const supabase = getSupabase();
353
+ const { data: { user }, } = await supabase.auth.getUser();
354
+ if (!user) {
355
+ return err('Not authenticated');
356
+ }
357
+ // Resolve the team's GitHub org.
358
+ let orgLogin = opts.org;
359
+ if (!orgLogin) {
360
+ const { data: team, error: teamErr } = await supabase
361
+ .from('teams')
362
+ .select('github_org')
363
+ .eq('id', teamId)
364
+ .single();
365
+ if (teamErr || !team) {
366
+ return err(`Team not found: ${teamId}`);
367
+ }
368
+ orgLogin = team.github_org ?? undefined;
369
+ }
370
+ if (!orgLogin) {
371
+ return err('No GitHub organization configured for this team. Set it in team settings first.');
372
+ }
373
+ if (!ORG_NAME_RE.test(orgLogin) || orgLogin.length > 39) {
374
+ return err(`Invalid GitHub organization name: "${orgLogin}"`);
375
+ }
376
+ // Resolve / create the discovery_runs row and flip it to running.
377
+ let runId = opts.runId ?? null;
378
+ if (runId) {
379
+ const { error: updErr } = await supabase
380
+ .from('discovery_runs')
381
+ .update({
382
+ status: 'running',
383
+ last_heartbeat_at: new Date().toISOString(),
384
+ })
385
+ .eq('id', runId)
386
+ .eq('team_id', teamId);
387
+ if (updErr) {
388
+ logWarning(`Could not mark discovery run as running: ${updErr.message}`);
389
+ }
390
+ }
391
+ else {
392
+ const { data: inserted, error: insErr } = await supabase
393
+ .from('discovery_runs')
394
+ .insert({
395
+ team_id: teamId,
396
+ scope: 'org',
397
+ scope_ref: orgLogin,
398
+ status: 'running',
399
+ created_by: user.id,
400
+ last_heartbeat_at: new Date().toISOString(),
401
+ })
402
+ .select('id')
403
+ .single();
404
+ if (insErr) {
405
+ logWarning(`Could not create discovery run row: ${insErr.message}`);
406
+ }
407
+ else {
408
+ runId = inserted?.id ?? null;
409
+ }
410
+ }
411
+ const finalize = async (status, counts, error) => {
412
+ if (!runId) {
413
+ return;
414
+ }
415
+ await supabase
416
+ .from('discovery_runs')
417
+ .update({
418
+ status,
419
+ repos_scanned: counts.repos,
420
+ services_found: counts.created,
421
+ services_updated: counts.updated,
422
+ error: error ?? null,
423
+ completed_at: new Date().toISOString(),
424
+ last_heartbeat_at: new Date().toISOString(),
425
+ })
426
+ .eq('id', runId)
427
+ .eq('team_id', teamId);
428
+ };
429
+ // List the org's repos via the Edsger GitHub App (server-side).
430
+ logInfo(`Scanning GitHub org "${orgLogin}" for services...`);
431
+ let repos;
432
+ try {
433
+ const res = (await callMcpEndpoint('github/org_repos', {
434
+ team_id: teamId,
435
+ org: orgLogin,
436
+ }));
437
+ if (!res.configured) {
438
+ const message = res.message || `The Edsger GitHub App is not configured for "${orgLogin}".`;
439
+ await finalize('failed', { repos: 0, created: 0, updated: 0 }, message);
440
+ return err(message);
441
+ }
442
+ repos = res.repos ?? [];
443
+ }
444
+ catch (e) {
445
+ const message = `Failed to list repos: ${e instanceof Error ? e.message : String(e)}`;
446
+ await finalize('failed', { repos: 0, created: 0, updated: 0 }, message);
447
+ return err(message);
448
+ }
449
+ if (repos.length === 0) {
450
+ await finalize('success', { repos: 0, created: 0, updated: 0 });
451
+ logSuccess(`No repositories found in "${orgLogin}"`);
452
+ return {
453
+ status: 'success',
454
+ message: `No repositories found in ${orgLogin}`,
455
+ reposScanned: 0,
456
+ servicesCreated: 0,
457
+ servicesUpdated: 0,
458
+ };
459
+ }
460
+ // Mint an org-scoped GitHub token so the CLI can read repo files itself.
461
+ let octokit;
462
+ try {
463
+ const tokenRes = (await callMcpEndpoint('github/org_token', {
464
+ team_id: teamId,
465
+ org: orgLogin,
466
+ }));
467
+ if (!tokenRes.configured || !tokenRes.token) {
468
+ const message = tokenRes.message ||
469
+ `Could not obtain a GitHub token for "${orgLogin}".`;
470
+ await finalize('failed', { repos: 0, created: 0, updated: 0 }, message);
471
+ return err(message);
472
+ }
473
+ octokit = new Octokit({ auth: tokenRes.token });
474
+ }
475
+ catch (e) {
476
+ const message = `Failed to obtain GitHub token: ${e instanceof Error ? e.message : String(e)}`;
477
+ await finalize('failed', { repos: 0, created: 0, updated: 0 }, message);
478
+ return err(message);
479
+ }
480
+ logInfo(`Found ${repos.length} repos. Inferring services...`);
481
+ let scanned = 0;
482
+ let created = 0;
483
+ let updated = 0;
484
+ for (const repo of repos) {
485
+ scanned++;
486
+ try {
487
+ // Read the repo's signal files directly via Octokit.
488
+ const [owner, name] = repo.full_name.split('/');
489
+ const files = await readRepoSignalFiles(octokit, owner, name, repo.default_branch ?? undefined);
490
+ const inferred = inferService(repo, files);
491
+ const action = await upsertServiceForRepo(supabase, teamId, user.id, repo, inferred);
492
+ if (action === 'created') {
493
+ created++;
494
+ }
495
+ else {
496
+ updated++;
497
+ }
498
+ if (verbose) {
499
+ logDebug(` ${repo.full_name} → ${inferred.name} (${inferred.kind}${inferred.language ? `, ${inferred.language}` : ''})`);
500
+ }
501
+ }
502
+ catch (e) {
503
+ logWarning(`Skipped ${repo.full_name}: ${e instanceof Error ? e.message : String(e)}`);
504
+ }
505
+ // Heartbeat + running counters so the UI can show live progress.
506
+ if (runId && (scanned % 3 === 0 || scanned === repos.length)) {
507
+ await supabase
508
+ .from('discovery_runs')
509
+ .update({
510
+ repos_scanned: scanned,
511
+ services_found: created,
512
+ services_updated: updated,
513
+ last_heartbeat_at: new Date().toISOString(),
514
+ })
515
+ .eq('id', runId)
516
+ .eq('team_id', teamId);
517
+ }
518
+ }
519
+ await finalize('success', { repos: scanned, created, updated });
520
+ logSuccess(`Discovery completed: scanned ${scanned} repos, ${created} services created, ${updated} updated`);
521
+ return {
522
+ status: 'success',
523
+ message: `Discovered ${created + updated} services from ${scanned} repos`,
524
+ reposScanned: scanned,
525
+ servicesCreated: created,
526
+ servicesUpdated: updated,
527
+ };
528
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * er-diagram phase: clone the product's repo, ask Claude to map every
3
+ * persistence entity (table / view / enum / junction) and the relationships
4
+ * between them into a structured ErDiagramExtraction, then persist the result
5
+ * to diagrams / diagram_nodes / diagram_edges (rows tagged `type = 'er'`) via the
6
+ * Supabase SDK.
7
+ *
8
+ * Companion to data-flow / screen-flow: same generation pattern (workspace
9
+ * clone + Claude Agent SDK + in-process MCP server), same storage tables,
10
+ * different domain.
11
+ */
12
+ export interface ErDiagramPhaseOptions {
13
+ /** Product-scoped diagram. Mutually exclusive with `repoId`. */
14
+ productId?: string;
15
+ /** Repo-only diagram: a single repositories row, no product context. */
16
+ repoId?: string;
17
+ diagramId: string;
18
+ guidance?: string;
19
+ verbose?: boolean;
20
+ }
21
+ export interface ErDiagramPhaseResult {
22
+ status: 'success' | 'error';
23
+ message: string;
24
+ nodesCreated?: number;
25
+ edgesCreated?: number;
26
+ summary?: string;
27
+ }
28
+ export declare function runErDiagramPhase(options: ErDiagramPhaseOptions): Promise<ErDiagramPhaseResult>;