@aion0/forge 0.5.48 → 0.5.50

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 (72) hide show
  1. package/CLAUDE.md +0 -1
  2. package/RELEASE_NOTES.md +50 -4
  3. package/app/api/craft-system/build/route.ts +78 -0
  4. package/app/api/craft-system/delete/route.ts +28 -0
  5. package/app/api/craft-system/helpers/file/route.ts +20 -0
  6. package/app/api/craft-system/helpers/openapi/route.ts +27 -0
  7. package/app/api/craft-system/helpers/shell/route.ts +26 -0
  8. package/app/api/craft-system/inject/route.ts +41 -0
  9. package/app/api/craft-system/kill-session/route.ts +19 -0
  10. package/app/api/craft-system/manifest/route.ts +71 -0
  11. package/app/api/craft-system/marketplace/install/route.ts +11 -0
  12. package/app/api/craft-system/marketplace/route.ts +18 -0
  13. package/app/api/craft-system/marketplace/uninstall/route.ts +11 -0
  14. package/app/api/craft-system/marketplace/update/route.ts +10 -0
  15. package/app/api/craft-system/marketplace/updates/route.ts +17 -0
  16. package/app/api/craft-system/publish/auto/route.ts +173 -0
  17. package/app/api/craft-system/publish/route.ts +50 -0
  18. package/app/api/craft-system/registry/route.ts +16 -0
  19. package/app/api/craft-system/runtime/react/route.ts +26 -0
  20. package/app/api/craft-system/runtime/react-jsx/route.ts +11 -0
  21. package/app/api/craft-system/runtime/sdk/route.ts +18 -0
  22. package/app/api/craft-system/scaffold/route.ts +164 -0
  23. package/app/api/craft-system/sessions/route.ts +45 -0
  24. package/app/api/craft-system/storage/route.ts +44 -0
  25. package/app/api/craft-system/tmux-sessions/route.ts +62 -0
  26. package/app/api/craft-system/ui/route.ts +30 -0
  27. package/app/api/crafts/[name]/[...route]/route.ts +48 -0
  28. package/app/api/crafts/route.ts +29 -0
  29. package/app/api/tasks/[id]/log/entry/route.ts +13 -0
  30. package/app/api/tasks/[id]/log/route.ts +23 -0
  31. package/app/api/tasks/route.ts +2 -2
  32. package/components/CraftBuilder.tsx +241 -0
  33. package/components/CraftManifestEditor.tsx +258 -0
  34. package/components/CraftMarketplaceModal.tsx +207 -0
  35. package/components/CraftPublishModal.tsx +285 -0
  36. package/components/CraftTabs.tsx +279 -0
  37. package/components/CraftTerminal.tsx +305 -0
  38. package/components/CraftTerminalPicker.tsx +179 -0
  39. package/components/CraftsDropdown.tsx +186 -0
  40. package/components/CraftsMarketplacePanel.tsx +194 -0
  41. package/components/ProjectDetail.tsx +102 -13
  42. package/components/SkillsPanel.tsx +12 -4
  43. package/components/TaskDetail.tsx +250 -52
  44. package/lib/craft-sdk/client.tsx +260 -0
  45. package/lib/craft-sdk/server.ts +14 -0
  46. package/lib/crafts/loader.ts +117 -0
  47. package/lib/crafts/registry.ts +272 -0
  48. package/lib/crafts/runtime.ts +208 -0
  49. package/lib/crafts/types.ts +92 -0
  50. package/lib/forge-skills/craft-builder.md +231 -0
  51. package/lib/help-docs/15-crafts.md +127 -0
  52. package/lib/help-docs/CLAUDE.md +2 -2
  53. package/lib/task-manager.ts +110 -0
  54. package/lib/terminal-standalone.ts +1 -0
  55. package/next.config.ts +1 -1
  56. package/package.json +2 -1
  57. package/src/types/index.ts +7 -0
  58. package/tsconfig.json +6 -0
  59. package/app/api/migration/config/route.ts +0 -19
  60. package/app/api/migration/discover/route.ts +0 -26
  61. package/app/api/migration/failures/route.ts +0 -35
  62. package/app/api/migration/fix/route.ts +0 -82
  63. package/app/api/migration/run/route.ts +0 -22
  64. package/app/api/migration/run-batch/route.ts +0 -86
  65. package/components/MigrationCockpit.tsx +0 -541
  66. package/lib/help-docs/14-migration.md +0 -154
  67. package/lib/migration/differ.ts +0 -193
  68. package/lib/migration/discoverer.ts +0 -363
  69. package/lib/migration/openapi.ts +0 -137
  70. package/lib/migration/runner.ts +0 -219
  71. package/lib/migration/store.ts +0 -89
  72. package/lib/migration/types.ts +0 -115
@@ -119,6 +119,116 @@ export function listTasks(status?: TaskStatus): Task[] {
119
119
  return rows.map(rowToTask);
120
120
  }
121
121
 
122
+ // Slim list — omits the heavy fields (log / git_diff / result_summary) so the
123
+ // task list view doesn't pull megabytes of JSON for tasks with long sessions.
124
+ // Callers that need the full body should fetch /api/tasks/[id] on demand.
125
+ export function listTasksLite(status?: TaskStatus): Task[] {
126
+ const SLIM_COLS = `
127
+ id, project_name, project_path, prompt, mode, status, priority,
128
+ conversation_id, watch_config, git_branch, cost_usd, error, agent,
129
+ created_at, started_at, completed_at, scheduled_at,
130
+ length(log) AS log_size,
131
+ CASE WHEN result_summary IS NULL THEN NULL ELSE substr(result_summary, 1, 1024) END AS result_summary,
132
+ CASE WHEN git_diff IS NULL THEN 0 ELSE 1 END AS has_git_diff
133
+ `;
134
+ let query = `SELECT ${SLIM_COLS} FROM tasks`;
135
+ const params: string[] = [];
136
+ if (status) {
137
+ query += ' WHERE status = ?';
138
+ params.push(status);
139
+ }
140
+ query += ' ORDER BY created_at DESC';
141
+ const rows = db().prepare(query).all(...params) as any[];
142
+ return rows.map(r => rowToLiteTask(r));
143
+ }
144
+
145
+ // Slice the log without parsing the whole JSON in JS — sqlite JSON1's
146
+ // json_each walks the array and we LIMIT/OFFSET in SQL. For a 1.6 MB log
147
+ // with 238 entries this returns just the requested ~100 in milliseconds.
148
+ export function getTaskLogSlice(id: string, opts: { offset?: number; limit?: number; truncate?: number } = {}):
149
+ { entries: (TaskLogEntry & { _truncated?: number; _index?: number })[]; total: number } {
150
+ const totalRow = db().prepare(
151
+ `SELECT COALESCE(json_array_length(log), 0) AS n FROM tasks WHERE id = ?`
152
+ ).get(id) as { n: number } | undefined;
153
+ const total = totalRow?.n ?? 0;
154
+ if (total === 0) return { entries: [], total: 0 };
155
+
156
+ const limit = Math.max(1, Math.min(opts.limit ?? 200, 2000));
157
+ const offset = Math.max(0, opts.offset ?? Math.max(0, total - limit));
158
+ const truncate = opts.truncate ?? 8192; // per-entry content cap; 0 = no cap
159
+ const rows = db().prepare(
160
+ `SELECT json_each.key AS idx, value FROM tasks, json_each(tasks.log)
161
+ WHERE tasks.id = ?
162
+ ORDER BY json_each.key
163
+ LIMIT ? OFFSET ?`
164
+ ).all(id, limit, offset) as { idx: number; value: string }[];
165
+
166
+ const entries = rows.map(r => {
167
+ let entry: TaskLogEntry & { _truncated?: number; _index?: number };
168
+ try { entry = JSON.parse(r.value); }
169
+ catch { entry = { type: 'system', content: '<unparseable entry>' } as any; }
170
+ entry._index = r.idx;
171
+ if (truncate > 0 && typeof entry.content === 'string' && entry.content.length > truncate) {
172
+ const fullLen = entry.content.length;
173
+ entry.content = entry.content.slice(0, truncate);
174
+ entry._truncated = fullLen;
175
+ }
176
+ return entry;
177
+ });
178
+ return { entries, total };
179
+ }
180
+
181
+ // Single entry by index — used to "show full" a previously truncated entry.
182
+ export function getTaskLogEntry(id: string, index: number): TaskLogEntry | null {
183
+ const row = db().prepare(
184
+ `SELECT value FROM tasks, json_each(tasks.log)
185
+ WHERE tasks.id = ? AND json_each.key = ?`
186
+ ).get(id, index) as { value: string } | undefined;
187
+ if (!row) return null;
188
+ try { return JSON.parse(row.value); } catch { return null; }
189
+ }
190
+
191
+ // Fetch only the heavy fields by id (used when the client needs them after
192
+ // having gotten the lite list earlier).
193
+ export function getTaskBody(id: string): { resultSummary?: string; gitDiff?: string; error?: string } | null {
194
+ const row = db().prepare(
195
+ `SELECT result_summary, git_diff, error FROM tasks WHERE id = ?`
196
+ ).get(id) as any;
197
+ if (!row) return null;
198
+ return {
199
+ resultSummary: row.result_summary || undefined,
200
+ gitDiff: row.git_diff || undefined,
201
+ error: row.error || undefined,
202
+ };
203
+ }
204
+
205
+ function rowToLiteTask(row: any): Task {
206
+ return {
207
+ id: row.id,
208
+ projectName: row.project_name,
209
+ projectPath: row.project_path,
210
+ prompt: row.prompt,
211
+ mode: row.mode || 'prompt',
212
+ status: row.status,
213
+ priority: row.priority,
214
+ conversationId: row.conversation_id || undefined,
215
+ watchConfig: row.watch_config ? JSON.parse(row.watch_config) : undefined,
216
+ log: [], // slim — fetch detail separately
217
+ resultSummary: row.result_summary || undefined, // first 1KB only
218
+ gitDiff: undefined, // not loaded
219
+ gitBranch: row.git_branch || undefined,
220
+ costUSD: row.cost_usd || undefined,
221
+ error: row.error || undefined,
222
+ createdAt: toIsoUTC(row.created_at) ?? row.created_at,
223
+ startedAt: toIsoUTC(row.started_at) ?? undefined,
224
+ completedAt: toIsoUTC(row.completed_at) ?? undefined,
225
+ scheduledAt: toIsoUTC(row.scheduled_at) ?? undefined,
226
+ agent: row.agent || undefined,
227
+ logSize: row.log_size || 0,
228
+ hasGitDiff: !!row.has_git_diff,
229
+ } as Task;
230
+ }
231
+
122
232
  export function cancelTask(id: string): boolean {
123
233
  const task = getTask(id);
124
234
  if (!task) return false;
@@ -209,6 +209,7 @@ function cleanupOrphanedSessions() {
209
209
  for (const s of sessions) {
210
210
  if (s.attached) continue;
211
211
  if (s.name.startsWith(`${SESSION_PREFIX}forge-`)) continue; // workspace agent session — managed by orchestrator
212
+ if (s.name.startsWith('mw-craft-')) continue; // craft session — managed by craft loader
212
213
  if (knownSessions.has(s.name)) continue; // saved in terminal state — preserve
213
214
  const clients = sessionClients.get(s.name)?.size ?? 0;
214
215
  if (clients === 0) {
package/next.config.ts CHANGED
@@ -10,7 +10,7 @@ const localIPs = Object.values(networkInterfaces())
10
10
  .map(i => i!.address);
11
11
 
12
12
  const nextConfig: NextConfig = {
13
- serverExternalPackages: ['better-sqlite3'],
13
+ serverExternalPackages: ['better-sqlite3', 'esbuild'],
14
14
  allowedDevOrigins: localIPs,
15
15
  async rewrites() {
16
16
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.48",
3
+ "version": "0.5.50",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -41,6 +41,7 @@
41
41
  "@xyflow/react": "^12.10.1",
42
42
  "ai": "^6.0.116",
43
43
  "better-sqlite3": "^12.6.2",
44
+ "esbuild": "^0.27.3",
44
45
  "next": "^16.2.1",
45
46
  "next-auth": "5.0.0-beta.30",
46
47
  "node-pty": "1.0.0",
@@ -109,6 +109,10 @@ export interface Task {
109
109
  startedAt?: string;
110
110
  completedAt?: string;
111
111
  scheduledAt?: string;
112
+ agent?: string;
113
+ // Lite-list metadata: present in /api/tasks responses, undefined in detail
114
+ logSize?: number;
115
+ hasGitDiff?: boolean;
112
116
  }
113
117
 
114
118
  export interface TaskLogEntry {
@@ -117,6 +121,9 @@ export interface TaskLogEntry {
117
121
  content: string;
118
122
  tool?: string;
119
123
  timestamp: string;
124
+ // Slice-API metadata (server adds these when serving a truncated chunk)
125
+ _index?: number;
126
+ _truncated?: number; // original content.length before truncation
120
127
  }
121
128
 
122
129
  export interface AppConfig {
package/tsconfig.json CHANGED
@@ -25,6 +25,12 @@
25
25
  "paths": {
26
26
  "@/*": [
27
27
  "./*"
28
+ ],
29
+ "@forge/craft": [
30
+ "./lib/craft-sdk/client.tsx"
31
+ ],
32
+ "@forge/craft/server": [
33
+ "./lib/craft-sdk/server.ts"
28
34
  ]
29
35
  }
30
36
  },
@@ -1,19 +0,0 @@
1
- import { NextResponse } from 'next/server';
2
- import { loadConfig, saveConfig } from '@/lib/migration/store';
3
- import type { MigrationConfig } from '@/lib/migration/types';
4
-
5
- // GET /api/migration/config?projectPath=...
6
- export async function GET(req: Request) {
7
- const url = new URL(req.url);
8
- const projectPath = url.searchParams.get('projectPath');
9
- if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
10
- return NextResponse.json(loadConfig(projectPath));
11
- }
12
-
13
- // POST /api/migration/config — body: { projectPath, config }
14
- export async function POST(req: Request) {
15
- const { projectPath, config } = await req.json() as { projectPath: string; config: MigrationConfig };
16
- if (!projectPath || !config) return NextResponse.json({ error: 'projectPath + config required' }, { status: 400 });
17
- saveConfig(projectPath, config);
18
- return NextResponse.json({ ok: true });
19
- }
@@ -1,26 +0,0 @@
1
- import { NextResponse } from 'next/server';
2
- import { discoverEndpoints } from '@/lib/migration/discoverer';
3
- import { loadConfig, saveEndpoints, loadEndpoints } from '@/lib/migration/store';
4
-
5
- // GET /api/migration/discover?projectPath=... → return cached endpoints
6
- export async function GET(req: Request) {
7
- const url = new URL(req.url);
8
- const projectPath = url.searchParams.get('projectPath');
9
- if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
10
- return NextResponse.json({ endpoints: loadEndpoints(projectPath) });
11
- }
12
-
13
- // POST /api/migration/discover — body: { projectPath } → re-scan docs
14
- export async function POST(req: Request) {
15
- const { projectPath } = await req.json() as { projectPath: string };
16
- if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
17
- const config = loadConfig(projectPath);
18
- const result = discoverEndpoints(projectPath, config);
19
- saveEndpoints(projectPath, result.endpoints);
20
- return NextResponse.json({
21
- endpoints: result.endpoints,
22
- warnings: result.warnings,
23
- sources: result.sources,
24
- total: result.endpoints.length,
25
- });
26
- }
@@ -1,35 +0,0 @@
1
- import { NextResponse } from 'next/server';
2
- import { loadFailures } from '@/lib/migration/store';
3
- import type { Failure, FailureCluster } from '@/lib/migration/types';
4
-
5
- function cluster(failures: Failure[]): FailureCluster[] {
6
- const byType = new Map<string, Map<string, Failure[]>>();
7
- for (const f of failures) {
8
- let m = byType.get(f.errorType);
9
- if (!m) { m = new Map(); byType.set(f.errorType, m); }
10
- let arr = m.get(f.controller);
11
- if (!arr) { arr = []; m.set(f.controller, arr); }
12
- arr.push(f);
13
- }
14
- const out: FailureCluster[] = [];
15
- for (const [errorType, ctrlMap] of byType) {
16
- const controllers = [...ctrlMap.entries()].map(([controller, failures]) => ({ controller, failures }));
17
- controllers.sort((a, b) => b.failures.length - a.failures.length);
18
- out.push({
19
- errorType,
20
- count: controllers.reduce((sum, c) => sum + c.failures.length, 0),
21
- controllers,
22
- });
23
- }
24
- out.sort((a, b) => b.count - a.count);
25
- return out;
26
- }
27
-
28
- // GET /api/migration/failures?projectPath=...
29
- export async function GET(req: Request) {
30
- const url = new URL(req.url);
31
- const projectPath = url.searchParams.get('projectPath');
32
- if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
33
- const failures = loadFailures(projectPath);
34
- return NextResponse.json({ failures, clusters: cluster(failures) });
35
- }
@@ -1,82 +0,0 @@
1
- import { NextResponse } from 'next/server';
2
- import { execSync } from 'node:child_process';
3
- import { tmpdir } from 'node:os';
4
- import { writeFileSync, unlinkSync } from 'node:fs';
5
- import { join } from 'node:path';
6
- import { loadEndpoints } from '@/lib/migration/store';
7
- import { createTask } from '@/lib/task-manager';
8
- import type { Failure } from '@/lib/migration/types';
9
-
10
- interface FixRequest {
11
- projectPath: string;
12
- projectName?: string;
13
- mode: 'inject' | 'task';
14
- endpointIds?: string[]; // single or batch
15
- failures?: Failure[]; // optional pre-clustered failures
16
- sessionName?: string; // tmux session for inject mode
17
- customPrompt?: string;
18
- }
19
-
20
- function buildPrompt(eps: any[], failures?: Failure[], custom?: string): string {
21
- const lines: string[] = [];
22
- lines.push('# API parity fix request');
23
- lines.push('');
24
- lines.push('The following endpoints in the new web-server module produce results that differ from the legacy module. The legacy code MUST NOT be changed — fix the new module so its output matches.');
25
- lines.push('');
26
- if (failures && failures.length > 0) {
27
- lines.push('## Failures');
28
- for (const f of failures) {
29
- lines.push(`- \`${f.method} ${f.path}\` (${f.controller}) — ${f.errorType}: ${f.errorMessage}`);
30
- }
31
- } else if (eps.length > 0) {
32
- lines.push('## Endpoints');
33
- for (const e of eps) {
34
- lines.push(`- \`${e.method} ${e.path}\` — controller: ${e.controller}, status: ${e.status}${e.notes ? `, notes: ${e.notes}` : ''}`);
35
- }
36
- }
37
- lines.push('');
38
- lines.push('## Approach');
39
- lines.push('1. Read .forge/migration/runs/*.json for the latest run output and diffs.');
40
- lines.push('2. Identify the controller class in the new web-server module.');
41
- lines.push('3. Compare to the legacy implementation — focus on response shape, status codes, field names.');
42
- lines.push('4. Apply minimal fix; do not change unrelated code.');
43
- lines.push('5. Re-run the migration cockpit batch test for these endpoints.');
44
- if (custom) {
45
- lines.push('');
46
- lines.push('## Additional context');
47
- lines.push(custom);
48
- }
49
- return lines.join('\n');
50
- }
51
-
52
- export async function POST(req: Request) {
53
- const body = await req.json() as FixRequest;
54
- const { projectPath, projectName, mode, endpointIds, failures, sessionName, customPrompt } = body;
55
- if (!projectPath || !mode) return NextResponse.json({ error: 'projectPath + mode required' }, { status: 400 });
56
-
57
- const all = loadEndpoints(projectPath);
58
- const ids = new Set(endpointIds || []);
59
- const eps = ids.size > 0 ? all.filter(e => ids.has(e.id)) : [];
60
- const prompt = buildPrompt(eps, failures, customPrompt);
61
-
62
- if (mode === 'task') {
63
- const name = projectName || projectPath.split('/').filter(Boolean).pop() || 'project';
64
- const task = createTask({ projectName: name, projectPath, prompt });
65
- return NextResponse.json({ ok: true, mode: 'task', taskId: task.id });
66
- }
67
-
68
- if (mode === 'inject') {
69
- if (!sessionName) return NextResponse.json({ error: 'sessionName required for inject mode' }, { status: 400 });
70
- try {
71
- const buf = join(tmpdir(), `forge-migration-fix-${Date.now()}.txt`);
72
- writeFileSync(buf, prompt);
73
- execSync(`tmux load-buffer -t "${sessionName}" "${buf}" && tmux paste-buffer -t "${sessionName}" && sleep 0.2 && tmux send-keys -t "${sessionName}" Enter`, { timeout: 5000 });
74
- try { unlinkSync(buf); } catch {}
75
- return NextResponse.json({ ok: true, mode: 'inject', sessionName });
76
- } catch (e: any) {
77
- return NextResponse.json({ error: e?.message || String(e) }, { status: 500 });
78
- }
79
- }
80
-
81
- return NextResponse.json({ error: 'invalid mode' }, { status: 400 });
82
- }
@@ -1,22 +0,0 @@
1
- import { NextResponse } from 'next/server';
2
- import { loadConfig, loadEndpoints, saveRun } from '@/lib/migration/store';
3
- import { runEndpoint } from '@/lib/migration/runner';
4
- import { loadOpenApi } from '@/lib/migration/openapi';
5
-
6
- // POST /api/migration/run — body: { projectPath, endpointId }
7
- export async function POST(req: Request) {
8
- const { projectPath, endpointId } = await req.json() as { projectPath: string; endpointId: string };
9
- if (!projectPath || !endpointId) return NextResponse.json({ error: 'projectPath + endpointId required' }, { status: 400 });
10
-
11
- const eps = loadEndpoints(projectPath);
12
- const ep = eps.find(e => e.id === endpointId);
13
- if (!ep) return NextResponse.json({ error: 'endpoint not found' }, { status: 404 });
14
-
15
- const config = loadConfig(projectPath);
16
- const openApi = config.endpointSource.openApiSpec
17
- ? loadOpenApi(projectPath, config.endpointSource.openApiSpec)
18
- : null;
19
- const result = await runEndpoint(ep, config, openApi);
20
- saveRun(projectPath, [result]);
21
- return NextResponse.json(result);
22
- }
@@ -1,86 +0,0 @@
1
- import { loadConfig, loadEndpoints, saveRun, saveFailures } from '@/lib/migration/store';
2
- import { runEndpoints } from '@/lib/migration/runner';
3
- import type { Endpoint, RunResult, Failure } from '@/lib/migration/types';
4
-
5
- export const dynamic = 'force-dynamic';
6
-
7
- // POST /api/migration/run-batch — body: { projectPath, endpointIds?, onlyStatus?, concurrency? }
8
- // Returns SSE stream with progress and final result.
9
- export async function POST(req: Request) {
10
- const { projectPath, endpointIds, onlyStatus, concurrency } = await req.json() as {
11
- projectPath: string;
12
- endpointIds?: string[];
13
- onlyStatus?: string[];
14
- concurrency?: number;
15
- };
16
- if (!projectPath) return new Response(JSON.stringify({ error: 'projectPath required' }), { status: 400 });
17
-
18
- const config = loadConfig(projectPath);
19
- const all = loadEndpoints(projectPath);
20
- let toRun: Endpoint[] = all;
21
- if (endpointIds && endpointIds.length > 0) {
22
- const ids = new Set(endpointIds);
23
- toRun = all.filter(e => ids.has(e.id));
24
- } else if (onlyStatus && onlyStatus.length > 0) {
25
- const s = new Set(onlyStatus);
26
- toRun = all.filter(e => s.has(e.status));
27
- }
28
-
29
- const encoder = new TextEncoder();
30
- const stream = new ReadableStream({
31
- async start(controller) {
32
- const send = (event: string, data: any) => {
33
- controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
34
- };
35
- send('start', { total: toRun.length });
36
-
37
- try {
38
- const results = await runEndpoints(toRun, config, {
39
- concurrency: concurrency ?? 4,
40
- projectPath,
41
- onProgress: (done, total, last) => {
42
- send('progress', { done, total, result: last });
43
- },
44
- });
45
- saveRun(projectPath, results);
46
-
47
- const failures: Failure[] = results
48
- .filter(r => r.match === 'fail' || r.match === 'error')
49
- .map(r => {
50
- const ep = toRun.find(e => e.id === r.endpointId)!;
51
- return {
52
- endpointId: r.endpointId,
53
- controller: ep.controller,
54
- method: ep.method,
55
- path: ep.path,
56
- errorType: r.errorType || 'unknown',
57
- errorMessage: r.errorMessage || '',
58
- lastSeenAt: r.startedAt,
59
- };
60
- });
61
- saveFailures(projectPath, failures);
62
-
63
- send('done', {
64
- total: results.length,
65
- pass: results.filter(r => r.match === 'pass').length,
66
- fail: results.filter(r => r.match === 'fail').length,
67
- stubOk: results.filter(r => r.match === 'stub-ok').length,
68
- error: results.filter(r => r.match === 'error').length,
69
- failures: failures.length,
70
- });
71
- } catch (e: any) {
72
- send('error', { message: e?.message || String(e) });
73
- } finally {
74
- controller.close();
75
- }
76
- },
77
- });
78
-
79
- return new Response(stream, {
80
- headers: {
81
- 'Content-Type': 'text/event-stream',
82
- 'Cache-Control': 'no-cache',
83
- 'Connection': 'keep-alive',
84
- },
85
- });
86
- }