@open330/oac 2026.2.5

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 (56) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/LICENSE +21 -0
  3. package/README.md +597 -0
  4. package/dist/budget/index.d.ts +117 -0
  5. package/dist/budget/index.js +23 -0
  6. package/dist/budget/index.js.map +1 -0
  7. package/dist/chunk-4IUL7ECC.js +3152 -0
  8. package/dist/chunk-4IUL7ECC.js.map +1 -0
  9. package/dist/chunk-5GAUWC3L.js +469 -0
  10. package/dist/chunk-5GAUWC3L.js.map +1 -0
  11. package/dist/chunk-6A37SKAJ.js +58 -0
  12. package/dist/chunk-6A37SKAJ.js.map +1 -0
  13. package/dist/chunk-7C7SC4TZ.js +358 -0
  14. package/dist/chunk-7C7SC4TZ.js.map +1 -0
  15. package/dist/chunk-CJAJ4MBO.js +475 -0
  16. package/dist/chunk-CJAJ4MBO.js.map +1 -0
  17. package/dist/chunk-LQC5DLT7.js +317 -0
  18. package/dist/chunk-LQC5DLT7.js.map +1 -0
  19. package/dist/chunk-OTPXGXO7.js +2368 -0
  20. package/dist/chunk-OTPXGXO7.js.map +1 -0
  21. package/dist/chunk-QPVNC7S4.js +1833 -0
  22. package/dist/chunk-QPVNC7S4.js.map +1 -0
  23. package/dist/cli/cli.d.ts +13 -0
  24. package/dist/cli/cli.js +16 -0
  25. package/dist/cli/cli.js.map +1 -0
  26. package/dist/cli/index.d.ts +1 -0
  27. package/dist/cli/index.js +22 -0
  28. package/dist/cli/index.js.map +1 -0
  29. package/dist/completion/index.d.ts +91 -0
  30. package/dist/completion/index.js +587 -0
  31. package/dist/completion/index.js.map +1 -0
  32. package/dist/config-DequKoFA.d.ts +1468 -0
  33. package/dist/core/index.d.ts +64 -0
  34. package/dist/core/index.js +87 -0
  35. package/dist/core/index.js.map +1 -0
  36. package/dist/dashboard/index.d.ts +14 -0
  37. package/dist/dashboard/index.js +1253 -0
  38. package/dist/dashboard/index.js.map +1 -0
  39. package/dist/discovery/index.d.ts +285 -0
  40. package/dist/discovery/index.js +50 -0
  41. package/dist/discovery/index.js.map +1 -0
  42. package/dist/event-bus-KiuR6e3P.d.ts +91 -0
  43. package/dist/execution/index.d.ts +215 -0
  44. package/dist/execution/index.js +27 -0
  45. package/dist/execution/index.js.map +1 -0
  46. package/dist/repo/index.d.ts +33 -0
  47. package/dist/repo/index.js +19 -0
  48. package/dist/repo/index.js.map +1 -0
  49. package/dist/tracking/index.d.ts +357 -0
  50. package/dist/tracking/index.js +15 -0
  51. package/dist/tracking/index.js.map +1 -0
  52. package/dist/types-CYCwgojB.d.ts +34 -0
  53. package/dist/types-Ck7IucqK.d.ts +195 -0
  54. package/docs/config-reference.md +271 -0
  55. package/docs/multi-agent-support-technical-spec.md +312 -0
  56. package/package.json +82 -0
@@ -0,0 +1,1253 @@
1
+ import {
2
+ cloneRepo,
3
+ resolveRepo
4
+ } from "../chunk-CJAJ4MBO.js";
5
+ import {
6
+ CompositeScanner,
7
+ GitHubIssuesScanner,
8
+ LintScanner,
9
+ TodoScanner,
10
+ rankTasks
11
+ } from "../chunk-OTPXGXO7.js";
12
+ import {
13
+ buildExecutionPlan,
14
+ estimateTokens
15
+ } from "../chunk-5GAUWC3L.js";
16
+ import {
17
+ CodexAdapter,
18
+ createSandbox,
19
+ executeTask
20
+ } from "../chunk-QPVNC7S4.js";
21
+ import {
22
+ createEventBus
23
+ } from "../chunk-7C7SC4TZ.js";
24
+ import "../chunk-6A37SKAJ.js";
25
+ import {
26
+ buildLeaderboard,
27
+ contributionLogSchema,
28
+ writeContributionLog
29
+ } from "../chunk-LQC5DLT7.js";
30
+
31
+ // src/dashboard/server.ts
32
+ import { randomUUID as randomUUID2 } from "crypto";
33
+ import { readFile, readdir } from "fs/promises";
34
+ import { resolve } from "path";
35
+ import cors from "@fastify/cors";
36
+ import Fastify from "fastify";
37
+
38
+ // src/dashboard/pipeline.ts
39
+ import { randomUUID } from "crypto";
40
+ import { execa } from "execa";
41
+ import PQueue from "p-queue";
42
+ function buildScanners() {
43
+ const scanners = [new LintScanner(), new TodoScanner()];
44
+ const names = ["lint", "todo"];
45
+ if (process.env.GITHUB_TOKEN) {
46
+ scanners.push(new GitHubIssuesScanner());
47
+ names.push("github-issues");
48
+ }
49
+ return { names, scanner: new CompositeScanner(scanners) };
50
+ }
51
+ async function executeWithCodex(input) {
52
+ const startedAt = Date.now();
53
+ const taskSlug = input.task.id.replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").slice(0, 30);
54
+ const branchName = `oac/${Date.now()}-${taskSlug}`;
55
+ const sandbox = await createSandbox(input.repoPath, branchName, input.baseBranch);
56
+ const eventBus = createEventBus();
57
+ const sandboxInfo = {
58
+ branchName,
59
+ sandboxPath: sandbox.path,
60
+ cleanup: sandbox.cleanup
61
+ };
62
+ try {
63
+ const result = await executeTask(input.codexAdapter, input.task, sandbox, eventBus, {
64
+ tokenBudget: input.estimate.totalEstimatedTokens,
65
+ timeoutMs: input.timeoutSeconds * 1e3
66
+ });
67
+ const commitResult = await commitSandboxChanges(sandbox.path, input.task);
68
+ const filesChanged = commitResult.filesChanged.length > 0 ? commitResult.filesChanged : result.filesChanged.length > 0 ? result.filesChanged : [];
69
+ return {
70
+ execution: {
71
+ success: result.success || commitResult.hasChanges,
72
+ exitCode: result.exitCode,
73
+ totalTokensUsed: result.totalTokensUsed,
74
+ filesChanged,
75
+ duration: result.duration > 0 ? result.duration / 1e3 : (Date.now() - startedAt) / 1e3,
76
+ error: result.error
77
+ },
78
+ sandbox: sandboxInfo
79
+ };
80
+ } catch (error) {
81
+ const commitResult = await commitSandboxChanges(sandbox.path, input.task);
82
+ if (commitResult.hasChanges) {
83
+ return {
84
+ execution: {
85
+ success: true,
86
+ exitCode: 0,
87
+ totalTokensUsed: 0,
88
+ filesChanged: commitResult.filesChanged,
89
+ duration: (Date.now() - startedAt) / 1e3
90
+ },
91
+ sandbox: sandboxInfo
92
+ };
93
+ }
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ return {
96
+ execution: {
97
+ success: false,
98
+ exitCode: 1,
99
+ totalTokensUsed: 0,
100
+ filesChanged: [],
101
+ duration: (Date.now() - startedAt) / 1e3,
102
+ error: message
103
+ },
104
+ sandbox: sandboxInfo
105
+ };
106
+ }
107
+ }
108
+ async function commitSandboxChanges(sandboxPath, task) {
109
+ try {
110
+ const statusResult = await execa("git", ["status", "--porcelain"], { cwd: sandboxPath });
111
+ if (!statusResult.stdout.trim()) {
112
+ return { hasChanges: false, filesChanged: [] };
113
+ }
114
+ await execa("git", ["add", "-A"], { cwd: sandboxPath });
115
+ await execa("git", ["commit", "-m", `[OAC] ${task.title}
116
+
117
+ Automated contribution by OAC.`], {
118
+ cwd: sandboxPath
119
+ });
120
+ const diffResult = await execa("git", ["diff", "--name-only", "HEAD~1", "HEAD"], {
121
+ cwd: sandboxPath
122
+ });
123
+ const changedFiles = diffResult.stdout.trim().split("\n").filter(Boolean);
124
+ return { hasChanges: true, filesChanged: changedFiles };
125
+ } catch {
126
+ return { hasChanges: false, filesChanged: [] };
127
+ }
128
+ }
129
+ async function resolveGitHubToken() {
130
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
131
+ if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
132
+ try {
133
+ const result = await execa("gh", ["auth", "token"], { reject: false, timeout: 5e3 });
134
+ if (result.exitCode === 0 && result.stdout.trim().length > 0) return result.stdout.trim();
135
+ } catch {
136
+ }
137
+ return void 0;
138
+ }
139
+ async function createPullRequest(input) {
140
+ const { branchName, sandboxPath } = input.sandbox;
141
+ try {
142
+ const ghToken = await resolveGitHubToken();
143
+ const ghEnv = { ...process.env };
144
+ if (ghToken) {
145
+ ghEnv.GH_TOKEN = ghToken;
146
+ ghEnv.GITHUB_TOKEN = ghToken;
147
+ }
148
+ await execa("git", ["push", "--set-upstream", "origin", branchName], {
149
+ cwd: sandboxPath,
150
+ env: ghEnv
151
+ });
152
+ const prTitle = `[OAC] ${input.task.title}`;
153
+ const prBody = [
154
+ "## Summary",
155
+ "",
156
+ input.task.description || `Automated contribution for task "${input.task.title}".`,
157
+ "",
158
+ "## Context",
159
+ "",
160
+ `- **Task source:** ${input.task.source}`,
161
+ `- **Complexity:** ${input.task.complexity}`,
162
+ `- **Tokens used:** ${input.execution.totalTokensUsed}`,
163
+ `- **Files changed:** ${input.execution.filesChanged.length}`,
164
+ "",
165
+ "---",
166
+ "*This PR was automatically generated by [OAC](https://github.com/Open330/open-agent-contribution).*"
167
+ ].join("\n");
168
+ const ghResult = await execa(
169
+ "gh",
170
+ [
171
+ "pr",
172
+ "create",
173
+ "--repo",
174
+ input.repoFullName,
175
+ "--title",
176
+ prTitle,
177
+ "--body",
178
+ prBody,
179
+ "--head",
180
+ branchName,
181
+ "--base",
182
+ input.baseBranch
183
+ ],
184
+ { cwd: sandboxPath, env: ghEnv }
185
+ );
186
+ const prUrl = ghResult.stdout.trim();
187
+ const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
188
+ const prNumber = prNumberMatch ? Number.parseInt(prNumberMatch[1], 10) : 0;
189
+ return { number: prNumber, url: prUrl, status: "open" };
190
+ } catch {
191
+ return void 0;
192
+ }
193
+ }
194
+ function resolveGithubUsername() {
195
+ const candidates = [
196
+ process.env.GITHUB_USER,
197
+ process.env.GITHUB_USERNAME,
198
+ process.env.USER,
199
+ process.env.LOGNAME
200
+ ];
201
+ for (const c of candidates) {
202
+ const cleaned = c?.trim().replace(/[^A-Za-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
203
+ if (cleaned && cleaned.length <= 39 && /^(?!-)[A-Za-z0-9-]+(?<!-)$/.test(cleaned)) {
204
+ return cleaned;
205
+ }
206
+ }
207
+ return "oac-user";
208
+ }
209
+ async function executePipeline(config, onEvent) {
210
+ const runId = randomUUID();
211
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
212
+ const progress = {
213
+ tasksDiscovered: 0,
214
+ tasksSelected: 0,
215
+ tasksCompleted: 0,
216
+ tasksFailed: 0,
217
+ prsCreated: 0,
218
+ tokensUsed: 0,
219
+ prUrls: []
220
+ };
221
+ const state = {
222
+ runId,
223
+ status: "running",
224
+ stage: "resolving",
225
+ config,
226
+ startedAt,
227
+ progress
228
+ };
229
+ const emit = (event) => {
230
+ if (event.type === "run:stage") state.stage = event.stage;
231
+ if (event.type === "run:progress") state.progress = event.progress;
232
+ onEvent(event);
233
+ };
234
+ try {
235
+ const preAuthToken = await resolveGitHubToken();
236
+ if (preAuthToken && !process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
237
+ process.env.GITHUB_TOKEN = preAuthToken;
238
+ }
239
+ emit({ type: "run:stage", stage: "resolving", message: `Resolving ${config.repo}...` });
240
+ const resolvedRepo = await resolveRepo(config.repo);
241
+ emit({ type: "run:stage", stage: "cloning", message: `Cloning ${resolvedRepo.fullName}...` });
242
+ await cloneRepo(resolvedRepo);
243
+ const { names, scanner } = buildScanners();
244
+ emit({
245
+ type: "run:stage",
246
+ stage: "scanning",
247
+ message: `Scanning with ${names.join(", ")}...`
248
+ });
249
+ const scannedTasks = await scanner.scan(resolvedRepo.localPath, {
250
+ repo: resolvedRepo
251
+ });
252
+ let candidateTasks = rankTasks(scannedTasks).filter((t) => t.priority >= 20);
253
+ if (config.source) {
254
+ candidateTasks = candidateTasks.filter((t) => t.source === config.source);
255
+ }
256
+ if (config.maxTasks) {
257
+ candidateTasks = candidateTasks.slice(0, config.maxTasks);
258
+ }
259
+ progress.tasksDiscovered = candidateTasks.length;
260
+ emit({ type: "run:progress", progress: { ...progress } });
261
+ if (candidateTasks.length === 0) {
262
+ emit({ type: "run:stage", stage: "completed", message: "No tasks discovered." });
263
+ state.status = "completed";
264
+ state.completedAt = (/* @__PURE__ */ new Date()).toISOString();
265
+ emit({ type: "run:completed", summary: { ...state } });
266
+ return state;
267
+ }
268
+ emit({
269
+ type: "run:stage",
270
+ stage: "estimating",
271
+ message: `Estimating tokens for ${candidateTasks.length} task(s)...`
272
+ });
273
+ const estimates = /* @__PURE__ */ new Map();
274
+ for (const task of candidateTasks) {
275
+ const est = await estimateTokens(task, config.provider);
276
+ estimates.set(task.id, est);
277
+ }
278
+ emit({ type: "run:stage", stage: "planning", message: "Building execution plan..." });
279
+ const plan = buildExecutionPlan(candidateTasks, estimates, config.tokens);
280
+ progress.tasksSelected = plan.selectedTasks.length;
281
+ emit({ type: "run:progress", progress: { ...progress } });
282
+ if (plan.selectedTasks.length === 0) {
283
+ emit({ type: "run:stage", stage: "completed", message: "No tasks fit within budget." });
284
+ state.status = "completed";
285
+ state.completedAt = (/* @__PURE__ */ new Date()).toISOString();
286
+ emit({ type: "run:completed", summary: { ...state } });
287
+ return state;
288
+ }
289
+ const concurrency = Math.max(1, config.concurrency ?? 1);
290
+ emit({
291
+ type: "run:stage",
292
+ stage: "executing",
293
+ message: `Executing ${plan.selectedTasks.length} task(s) (concurrency: ${concurrency})...`
294
+ });
295
+ const codexAdapter = new CodexAdapter();
296
+ const codexAvailability = await codexAdapter.checkAvailability();
297
+ const useRealExecution = config.provider.includes("codex") && codexAvailability.available;
298
+ const taskQueue = new PQueue({ concurrency });
299
+ const taskResults = await Promise.all(
300
+ plan.selectedTasks.map(
301
+ (entry) => taskQueue.add(async () => {
302
+ emit({ type: "run:task-start", taskId: entry.task.id, title: entry.task.title });
303
+ progress.currentTask = entry.task.title;
304
+ emit({ type: "run:progress", progress: { ...progress } });
305
+ let execution;
306
+ let sandbox;
307
+ if (useRealExecution) {
308
+ const result2 = await executeWithCodex({
309
+ task: entry.task,
310
+ estimate: entry.estimate,
311
+ codexAdapter,
312
+ repoPath: resolvedRepo.localPath,
313
+ baseBranch: resolvedRepo.meta.defaultBranch,
314
+ timeoutSeconds: 300
315
+ });
316
+ execution = result2.execution;
317
+ sandbox = result2.sandbox;
318
+ } else {
319
+ await new Promise((r) => setTimeout(r, 500));
320
+ execution = {
321
+ success: true,
322
+ exitCode: 0,
323
+ totalTokensUsed: Math.round(entry.estimate.totalEstimatedTokens * 0.9),
324
+ filesChanged: entry.task.targetFiles.slice(0, 4),
325
+ duration: 0.5
326
+ };
327
+ }
328
+ let pr;
329
+ if (execution.success && sandbox && execution.filesChanged.length > 0) {
330
+ emit({
331
+ type: "run:stage",
332
+ stage: "creating-pr",
333
+ message: `Creating PR for "${entry.task.title}"...`
334
+ });
335
+ pr = await createPullRequest({
336
+ task: entry.task,
337
+ execution,
338
+ sandbox,
339
+ repoFullName: resolvedRepo.fullName,
340
+ baseBranch: resolvedRepo.meta.defaultBranch
341
+ });
342
+ if (pr) {
343
+ progress.prsCreated += 1;
344
+ progress.prUrls.push(pr.url);
345
+ }
346
+ }
347
+ if (execution.success) {
348
+ progress.tasksCompleted += 1;
349
+ } else {
350
+ progress.tasksFailed += 1;
351
+ }
352
+ progress.tokensUsed += execution.totalTokensUsed;
353
+ progress.currentTask = void 0;
354
+ const result = { task: entry.task, execution, sandbox, pr };
355
+ emit({
356
+ type: "run:task-done",
357
+ taskId: entry.task.id,
358
+ title: entry.task.title,
359
+ success: execution.success,
360
+ prUrl: pr?.url,
361
+ filesChanged: execution.filesChanged.length
362
+ });
363
+ emit({ type: "run:progress", progress: { ...progress } });
364
+ return result;
365
+ })
366
+ )
367
+ );
368
+ emit({ type: "run:stage", stage: "tracking", message: "Writing contribution log..." });
369
+ const contributionLog = {
370
+ version: "1.0",
371
+ runId,
372
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
373
+ contributor: { githubUsername: resolveGithubUsername() },
374
+ repo: {
375
+ fullName: resolvedRepo.fullName,
376
+ headSha: resolvedRepo.git.headSha,
377
+ defaultBranch: resolvedRepo.meta.defaultBranch
378
+ },
379
+ budget: {
380
+ provider: config.provider,
381
+ totalTokensBudgeted: config.tokens,
382
+ totalTokensUsed: progress.tokensUsed
383
+ },
384
+ tasks: taskResults.map((r) => ({
385
+ taskId: r.task.id,
386
+ title: r.task.title,
387
+ source: r.task.source,
388
+ complexity: r.task.complexity,
389
+ status: r.execution.success ? "success" : "failed",
390
+ tokensUsed: r.execution.totalTokensUsed,
391
+ duration: r.execution.duration,
392
+ filesChanged: r.execution.filesChanged,
393
+ pr: r.pr,
394
+ error: r.execution.error
395
+ })),
396
+ metrics: {
397
+ tasksDiscovered: progress.tasksDiscovered,
398
+ tasksAttempted: taskResults.length,
399
+ tasksSucceeded: progress.tasksCompleted,
400
+ tasksFailed: progress.tasksFailed,
401
+ totalDuration: (Date.now() - new Date(startedAt).getTime()) / 1e3,
402
+ totalFilesChanged: taskResults.reduce((sum, r) => sum + r.execution.filesChanged.length, 0),
403
+ totalLinesAdded: 0,
404
+ totalLinesRemoved: 0
405
+ }
406
+ };
407
+ try {
408
+ await writeContributionLog(contributionLog, resolvedRepo.localPath);
409
+ } catch {
410
+ }
411
+ emit({ type: "run:stage", stage: "completed", message: "Run completed successfully." });
412
+ state.status = "completed";
413
+ state.completedAt = (/* @__PURE__ */ new Date()).toISOString();
414
+ emit({ type: "run:completed", summary: { ...state } });
415
+ return state;
416
+ } catch (error) {
417
+ const message = error instanceof Error ? error.message : String(error);
418
+ state.status = "failed";
419
+ state.stage = "failed";
420
+ state.error = message;
421
+ state.completedAt = (/* @__PURE__ */ new Date()).toISOString();
422
+ emit({ type: "run:error", error: message });
423
+ return state;
424
+ }
425
+ }
426
+
427
+ // src/dashboard/ui.ts
428
+ function renderDashboardHtml(port) {
429
+ return `<!DOCTYPE html>
430
+ <html lang="en">
431
+ <head>
432
+ <meta charset="UTF-8">
433
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
434
+ <title>OAC Dashboard</title>
435
+ <style>
436
+ :root {
437
+ --bg: #0a0a0a; --card: #111; --border: #222; --text: #e5e5e5;
438
+ --muted: #888; --accent: #3b82f6; --green: #22c55e; --red: #ef4444;
439
+ --yellow: #eab308; --purple: #a855f7;
440
+ }
441
+ * { margin: 0; padding: 0; box-sizing: border-box; }
442
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace; background: var(--bg); color: var(--text); }
443
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
444
+ header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 32px; border-bottom: 1px solid var(--border); padding-bottom: 16px; }
445
+ header h1 { font-size: 24px; font-weight: 700; }
446
+ header h1 span { color: var(--accent); }
447
+ .badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
448
+ .badge-idle { background: #1a1a2e; color: var(--muted); }
449
+ .badge-running { background: #0a2a1a; color: var(--green); animation: pulse 2s infinite; }
450
+ .badge-completed { background: #0a2a1a; color: var(--green); }
451
+ .badge-failed { background: #2a0a0a; color: var(--red); }
452
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
453
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
454
+ @media (max-width: 768px) { .grid { grid-template-columns: 1fr; } }
455
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
456
+ .card h2 { font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; }
457
+ .stat-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); }
458
+ .stat-row:last-child { border-bottom: none; }
459
+ .stat-label { color: var(--muted); font-size: 13px; }
460
+ .stat-value { font-weight: 600; font-size: 14px; }
461
+ .full-width { grid-column: 1 / -1; }
462
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
463
+ th { text-align: left; color: var(--muted); font-weight: 500; padding: 8px 12px; border-bottom: 1px solid var(--border); }
464
+ td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
465
+ tr:hover td { background: #1a1a1a; }
466
+ .rank { color: var(--yellow); font-weight: 700; }
467
+ .empty { color: var(--muted); text-align: center; padding: 40px 0; font-size: 14px; }
468
+ .event-log { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; max-height: 200px; overflow-y: auto; padding: 12px; background: #050505; border-radius: 8px; }
469
+ .event-line { padding: 2px 0; color: var(--muted); }
470
+ .event-line .time { color: var(--accent); margin-right: 8px; }
471
+ .connected { color: var(--green); }
472
+ .toolbar { display: flex; gap: 8px; margin-bottom: 20px; }
473
+ .btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: var(--text); cursor: pointer; font-size: 13px; transition: all 0.15s; }
474
+ .btn:hover { background: #1a1a1a; border-color: var(--accent); }
475
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
476
+ .btn-primary { background: var(--accent); border-color: var(--accent); color: white; }
477
+ .btn-primary:hover:not(:disabled) { opacity: 0.9; }
478
+ footer { text-align: center; padding: 32px 0 16px; color: var(--muted); font-size: 12px; }
479
+
480
+ /* Start Run Form */
481
+ .run-form { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
482
+ .run-form .form-group { display: flex; flex-direction: column; gap: 4px; }
483
+ .run-form .form-group.full { grid-column: 1 / -1; }
484
+ .run-form label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
485
+ .run-form input, .run-form select {
486
+ padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border);
487
+ background: #050505; color: var(--text); font-size: 13px; font-family: inherit;
488
+ }
489
+ .run-form input:focus, .run-form select:focus { outline: none; border-color: var(--accent); }
490
+ .run-form .form-actions { grid-column: 1 / -1; display: flex; gap: 8px; margin-top: 4px; }
491
+
492
+ /* Stage Progress */
493
+ .stage-pipeline { display: flex; align-items: center; gap: 0; margin: 16px 0; overflow-x: auto; padding-bottom: 4px; }
494
+ .stage-dot { display: flex; align-items: center; gap: 0; white-space: nowrap; }
495
+ .stage-dot .dot {
496
+ width: 10px; height: 10px; border-radius: 50%; background: var(--border);
497
+ flex-shrink: 0; transition: background 0.3s;
498
+ }
499
+ .stage-dot .dot.active { background: var(--accent); animation: pulse 1.5s infinite; }
500
+ .stage-dot .dot.done { background: var(--green); }
501
+ .stage-dot .dot.error { background: var(--red); }
502
+ .stage-dot .label { font-size: 11px; color: var(--muted); margin-left: 4px; margin-right: 4px; }
503
+ .stage-dot .label.active { color: var(--accent); font-weight: 600; }
504
+ .stage-dot .label.done { color: var(--green); }
505
+ .stage-connector { width: 16px; height: 1px; background: var(--border); flex-shrink: 0; }
506
+ .stage-connector.done { background: var(--green); }
507
+
508
+ /* Task Results */
509
+ .task-results { margin-top: 12px; }
510
+ .task-result { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
511
+ .task-result:last-child { border-bottom: none; }
512
+ .task-result .icon { font-size: 14px; }
513
+ .task-result a { color: var(--accent); text-decoration: none; }
514
+ .task-result a:hover { text-decoration: underline; }
515
+ .task-result .meta { color: var(--muted); font-size: 12px; margin-left: auto; }
516
+
517
+ .hidden { display: none !important; }
518
+ </style>
519
+ </head>
520
+ <body>
521
+ <div class="container">
522
+ <header>
523
+ <h1><span>OAC</span> Dashboard</h1>
524
+ <div>
525
+ <span id="status-badge" class="badge badge-idle">idle</span>
526
+ <span id="sse-indicator" style="margin-left: 8px; font-size: 11px; color: var(--muted);">connecting...</span>
527
+ </div>
528
+ </header>
529
+
530
+ <!-- Start Run Form -->
531
+ <div id="run-form-card" class="card" style="margin-bottom: 20px;">
532
+ <h2>Start Run</h2>
533
+ <div class="run-form">
534
+ <div class="form-group full">
535
+ <label for="run-repo">Repository (owner/repo or GitHub URL)</label>
536
+ <input type="text" id="run-repo" placeholder="e.g. Open330/open-agent-contribution" />
537
+ </div>
538
+ <div class="form-group">
539
+ <label for="run-provider">Agent Provider</label>
540
+ <select id="run-provider">
541
+ <option value="claude-code">Claude Code</option>
542
+ <option value="codex">Codex CLI</option>
543
+ </select>
544
+ </div>
545
+ <div class="form-group">
546
+ <label for="run-tokens-mode">Token Budget</label>
547
+ <select id="run-tokens-mode" onchange="toggleTokenInput()">
548
+ <option value="fixed">Fixed</option>
549
+ <option value="unlimited">Unlimited (until rate-limited)</option>
550
+ </select>
551
+ </div>
552
+ <div class="form-group" id="run-tokens-value-group">
553
+ <label for="run-tokens">Token Amount</label>
554
+ <input type="number" id="run-tokens" value="100000" min="1000" step="1000" />
555
+ </div>
556
+ <div class="form-group">
557
+ <label for="run-max-tasks">Max Tasks</label>
558
+ <input type="number" id="run-max-tasks" value="5" min="1" max="50" />
559
+ </div>
560
+ <div class="form-group">
561
+ <label for="run-concurrency">Concurrency</label>
562
+ <input type="number" id="run-concurrency" value="2" min="1" max="10" />
563
+ </div>
564
+ <div class="form-group">
565
+ <label for="run-source">Source Filter</label>
566
+ <select id="run-source">
567
+ <option value="">All sources</option>
568
+ <option value="github-issue">GitHub Issues</option>
569
+ <option value="lint">Lint warnings</option>
570
+ <option value="todo">To-do comments</option>
571
+ <option value="test-gap">Test gaps</option>
572
+ </select>
573
+ </div>
574
+ <div class="form-actions">
575
+ <button id="run-start-btn" class="btn btn-primary" onclick="startRun()">Start Run</button>
576
+ <span id="run-error" style="color: var(--red); font-size: 13px; align-self: center;"></span>
577
+ </div>
578
+ </div>
579
+ </div>
580
+
581
+ <!-- Run Progress (shown during active run) -->
582
+ <div id="run-progress-card" class="card hidden" style="margin-bottom: 20px;">
583
+ <h2>Run Progress</h2>
584
+ <div id="stage-pipeline" class="stage-pipeline"></div>
585
+ <div id="run-progress-stats" style="margin-top: 8px;">
586
+ <div class="stat-row"><span class="stat-label">Tasks discovered</span><span class="stat-value" id="prog-discovered">0</span></div>
587
+ <div class="stat-row"><span class="stat-label">Tasks selected</span><span class="stat-value" id="prog-selected">0</span></div>
588
+ <div class="stat-row"><span class="stat-label">Completed</span><span class="stat-value" id="prog-completed">0</span></div>
589
+ <div class="stat-row"><span class="stat-label">Failed</span><span class="stat-value" id="prog-failed">0</span></div>
590
+ <div class="stat-row"><span class="stat-label">PRs created</span><span class="stat-value" id="prog-prs">0</span></div>
591
+ <div class="stat-row"><span class="stat-label">Tokens used</span><span class="stat-value" id="prog-tokens">0</span></div>
592
+ </div>
593
+ <div id="task-results" class="task-results"></div>
594
+ </div>
595
+
596
+ <div class="toolbar">
597
+ <button class="btn" onclick="fetchStatus()">Refresh Status</button>
598
+ <button class="btn" onclick="fetchLogs()">Refresh Logs</button>
599
+ <button class="btn" onclick="fetchLeaderboard()">Refresh Leaderboard</button>
600
+ </div>
601
+
602
+ <div class="grid">
603
+ <div class="card">
604
+ <h2>Run Status</h2>
605
+ <div id="status-content">
606
+ <div class="empty">Loading...</div>
607
+ </div>
608
+ </div>
609
+
610
+ <div class="card">
611
+ <h2>Quick Stats</h2>
612
+ <div id="stats-content">
613
+ <div class="empty">Loading...</div>
614
+ </div>
615
+ </div>
616
+
617
+ <div class="card full-width">
618
+ <h2>Contribution Log</h2>
619
+ <div id="logs-content">
620
+ <div class="empty">Loading...</div>
621
+ </div>
622
+ </div>
623
+
624
+ <div class="card full-width">
625
+ <h2>Leaderboard</h2>
626
+ <div id="leaderboard-content">
627
+ <div class="empty">Loading...</div>
628
+ </div>
629
+ </div>
630
+
631
+ <div class="card full-width">
632
+ <h2>Event Stream</h2>
633
+ <div id="event-log" class="event-log">
634
+ <div class="event-line" style="color: var(--muted);">Connecting to SSE...</div>
635
+ </div>
636
+ </div>
637
+ </div>
638
+
639
+ <footer>OAC v0.1.0 &mdash; Open Agent Contribution</footer>
640
+ </div>
641
+
642
+ <script>
643
+ const API = "";
644
+ const STAGES = ["resolving","cloning","scanning","estimating","planning","executing","creating-pr","tracking","completed"];
645
+ let activeRunId = null;
646
+
647
+ function formatDate(iso) {
648
+ if (!iso) return "-";
649
+ try { return new Date(iso).toLocaleString(); } catch { return iso; }
650
+ }
651
+
652
+ // ---- Start Run ----
653
+
654
+ function toggleTokenInput() {
655
+ const mode = document.getElementById("run-tokens-mode").value;
656
+ const group = document.getElementById("run-tokens-value-group");
657
+ if (mode === "unlimited") {
658
+ group.classList.add("hidden");
659
+ } else {
660
+ group.classList.remove("hidden");
661
+ }
662
+ }
663
+
664
+ async function startRun() {
665
+ const repo = document.getElementById("run-repo").value.trim();
666
+ const provider = document.getElementById("run-provider").value;
667
+ const tokensMode = document.getElementById("run-tokens-mode").value;
668
+ const tokens = tokensMode === "unlimited"
669
+ ? 9007199254740991
670
+ : parseInt(document.getElementById("run-tokens").value, 10);
671
+ const maxTasks = parseInt(document.getElementById("run-max-tasks").value, 10);
672
+ const concurrency = parseInt(document.getElementById("run-concurrency").value, 10) || 2;
673
+ const source = document.getElementById("run-source").value || undefined;
674
+ const errEl = document.getElementById("run-error");
675
+ const btn = document.getElementById("run-start-btn");
676
+
677
+ errEl.textContent = "";
678
+ if (!repo) { errEl.textContent = "Repository is required"; return; }
679
+ if (tokensMode !== "unlimited" && (!tokens || tokens < 1000)) { errEl.textContent = "Token budget must be >= 1000"; return; }
680
+
681
+ btn.disabled = true;
682
+ btn.textContent = "Starting...";
683
+
684
+ try {
685
+ const res = await fetch(API + "/api/v1/runs", {
686
+ method: "POST",
687
+ headers: { "Content-Type": "application/json" },
688
+ body: JSON.stringify({ repo, provider, tokens, maxTasks, concurrency, source }),
689
+ });
690
+ const data = await res.json();
691
+
692
+ if (!res.ok) {
693
+ errEl.textContent = data.error || "Failed to start run";
694
+ btn.disabled = false;
695
+ btn.textContent = "Start Run";
696
+ return;
697
+ }
698
+
699
+ activeRunId = data.runId;
700
+ showProgressCard();
701
+ } catch (e) {
702
+ errEl.textContent = "Network error: " + e.message;
703
+ btn.disabled = false;
704
+ btn.textContent = "Start Run";
705
+ }
706
+ }
707
+
708
+ function showProgressCard() {
709
+ document.getElementById("run-progress-card").classList.remove("hidden");
710
+ initStageList();
711
+ document.getElementById("task-results").innerHTML = "";
712
+ document.getElementById("prog-discovered").textContent = "0";
713
+ document.getElementById("prog-selected").textContent = "0";
714
+ document.getElementById("prog-completed").textContent = "0";
715
+ document.getElementById("prog-failed").textContent = "0";
716
+ document.getElementById("prog-prs").textContent = "0";
717
+ document.getElementById("prog-tokens").textContent = "0";
718
+
719
+ const badge = document.getElementById("status-badge");
720
+ badge.className = "badge badge-running";
721
+ badge.textContent = "running";
722
+ }
723
+
724
+ function initStageList() {
725
+ const pipeline = document.getElementById("stage-pipeline");
726
+ pipeline.innerHTML = "";
727
+ STAGES.forEach((stage, i) => {
728
+ const dot = document.createElement("div");
729
+ dot.className = "stage-dot";
730
+ dot.innerHTML = '<div class="dot" id="dot-' + stage + '"></div><span class="label" id="label-' + stage + '">' + stage + '</span>';
731
+ pipeline.appendChild(dot);
732
+ if (i < STAGES.length - 1) {
733
+ const conn = document.createElement("div");
734
+ conn.className = "stage-connector";
735
+ conn.id = "conn-" + stage;
736
+ pipeline.appendChild(conn);
737
+ }
738
+ });
739
+ }
740
+
741
+ function updateStage(currentStage) {
742
+ let reached = false;
743
+ STAGES.forEach((stage, i) => {
744
+ const dot = document.getElementById("dot-" + stage);
745
+ const label = document.getElementById("label-" + stage);
746
+ const conn = i < STAGES.length - 1 ? document.getElementById("conn-" + stage) : null;
747
+
748
+ if (!dot || !label) return;
749
+
750
+ if (stage === currentStage) {
751
+ reached = true;
752
+ dot.className = currentStage === "completed" ? "dot done" : "dot active";
753
+ label.className = currentStage === "completed" ? "label done" : "label active";
754
+ if (conn && currentStage === "completed") conn.className = "stage-connector done";
755
+ } else if (!reached) {
756
+ dot.className = "dot done";
757
+ label.className = "label done";
758
+ if (conn) conn.className = "stage-connector done";
759
+ } else {
760
+ dot.className = "dot";
761
+ label.className = "label";
762
+ if (conn) conn.className = "stage-connector";
763
+ }
764
+ });
765
+ }
766
+
767
+ function updateProgress(progress) {
768
+ document.getElementById("prog-discovered").textContent = progress.tasksDiscovered || 0;
769
+ document.getElementById("prog-selected").textContent = progress.tasksSelected || 0;
770
+ document.getElementById("prog-completed").textContent = progress.tasksCompleted || 0;
771
+ document.getElementById("prog-failed").textContent = progress.tasksFailed || 0;
772
+ document.getElementById("prog-prs").textContent = progress.prsCreated || 0;
773
+ document.getElementById("prog-tokens").textContent = (progress.tokensUsed || 0).toLocaleString();
774
+ }
775
+
776
+ function addTaskResult(data) {
777
+ const container = document.getElementById("task-results");
778
+ const div = document.createElement("div");
779
+ div.className = "task-result";
780
+ const icon = data.success ? "\\u2705" : "\\u274c";
781
+ let html = '<span class="icon">' + icon + '</span><span>' + escapeHtml(data.title) + '</span>';
782
+ if (data.prUrl) {
783
+ html += ' <a href="' + escapeHtml(data.prUrl) + '" target="_blank">PR \\u2197</a>';
784
+ }
785
+ html += '<span class="meta">' + (data.filesChanged || 0) + ' files</span>';
786
+ div.innerHTML = html;
787
+ container.appendChild(div);
788
+ }
789
+
790
+ function onRunCompleted(isError) {
791
+ const btn = document.getElementById("run-start-btn");
792
+ btn.disabled = false;
793
+ btn.textContent = "Start Run";
794
+ activeRunId = null;
795
+
796
+ const badge = document.getElementById("status-badge");
797
+ if (isError) {
798
+ badge.className = "badge badge-failed";
799
+ badge.textContent = "failed";
800
+ } else {
801
+ badge.className = "badge badge-completed";
802
+ badge.textContent = "completed";
803
+ }
804
+
805
+ // Refresh data
806
+ fetchStatus();
807
+ fetchLogs();
808
+ fetchLeaderboard();
809
+ }
810
+
811
+ function onRunError(errorMsg) {
812
+ const container = document.getElementById("task-results");
813
+ const div = document.createElement("div");
814
+ div.className = "task-result";
815
+ div.innerHTML = '<span class="icon">\\u274c</span><span style="color: var(--red);">Error: ' + escapeHtml(errorMsg) + '</span>';
816
+ container.appendChild(div);
817
+
818
+ // Mark failed stage
819
+ STAGES.forEach((stage) => {
820
+ const dot = document.getElementById("dot-" + stage);
821
+ if (dot && dot.className === "dot active") {
822
+ dot.className = "dot error";
823
+ }
824
+ });
825
+
826
+ onRunCompleted(true);
827
+ }
828
+
829
+ function escapeHtml(str) {
830
+ const div = document.createElement("div");
831
+ div.textContent = str || "";
832
+ return div.innerHTML;
833
+ }
834
+
835
+ // ---- Fetch functions ----
836
+
837
+ async function fetchStatus() {
838
+ try {
839
+ const res = await fetch(API + "/api/v1/status");
840
+ const data = await res.json();
841
+ const badge = document.getElementById("status-badge");
842
+
843
+ if (data.status === "running") {
844
+ badge.className = "badge badge-running";
845
+ badge.textContent = "running";
846
+ // Restore progress card if page was refreshed mid-run
847
+ if (!activeRunId) {
848
+ activeRunId = data.runId;
849
+ showProgressCard();
850
+ if (data.stage) updateStage(data.stage);
851
+ if (data.progress) updateProgress(data.progress);
852
+ }
853
+ } else if (data.status === "idle" && !activeRunId) {
854
+ badge.className = "badge badge-idle";
855
+ badge.textContent = "idle";
856
+ }
857
+
858
+ let html = "";
859
+ const displayKeys = ["status", "stage", "runId", "startedAt", "completedAt", "error"];
860
+ for (const key of displayKeys) {
861
+ if (data[key] !== undefined) {
862
+ html += '<div class="stat-row"><span class="stat-label">' + key + '</span><span class="stat-value">' + escapeHtml(String(data[key])) + '</span></div>';
863
+ }
864
+ }
865
+ if (!html) {
866
+ for (const [key, value] of Object.entries(data)) {
867
+ html += '<div class="stat-row"><span class="stat-label">' + key + '</span><span class="stat-value">' + escapeHtml(String(value)) + '</span></div>';
868
+ }
869
+ }
870
+ document.getElementById("status-content").innerHTML = html || '<div class="empty">No status data</div>';
871
+ } catch (e) {
872
+ document.getElementById("status-content").innerHTML = '<div class="empty">Failed to load status</div>';
873
+ }
874
+ }
875
+
876
+ async function fetchLogs() {
877
+ try {
878
+ const res = await fetch(API + "/api/v1/logs");
879
+ const data = await res.json();
880
+
881
+ if (!data.logs || data.logs.length === 0) {
882
+ document.getElementById("logs-content").innerHTML = '<div class="empty">No contributions yet. Run <code>oac run</code> to start contributing!</div>';
883
+ document.getElementById("stats-content").innerHTML = [
884
+ '<div class="stat-row"><span class="stat-label">Total Runs</span><span class="stat-value">0</span></div>',
885
+ '<div class="stat-row"><span class="stat-label">Total Tasks</span><span class="stat-value">0</span></div>',
886
+ '<div class="stat-row"><span class="stat-label">Total Tokens</span><span class="stat-value">0</span></div>',
887
+ '<div class="stat-row"><span class="stat-label">PRs Created</span><span class="stat-value">0</span></div>',
888
+ ].join("");
889
+ return;
890
+ }
891
+
892
+ const totalRuns = data.logs.length;
893
+ const totalTasks = data.logs.reduce((s, l) => s + (l.tasks?.length || 0), 0);
894
+ const totalTokens = data.logs.reduce((s, l) => s + (l.budget?.totalTokensUsed || 0), 0);
895
+ const totalPRs = data.logs.reduce((s, l) => s + (l.tasks?.filter(t => t.pr).length || 0), 0);
896
+
897
+ document.getElementById("stats-content").innerHTML = [
898
+ '<div class="stat-row"><span class="stat-label">Total Runs</span><span class="stat-value">' + totalRuns + '</span></div>',
899
+ '<div class="stat-row"><span class="stat-label">Total Tasks</span><span class="stat-value">' + totalTasks + '</span></div>',
900
+ '<div class="stat-row"><span class="stat-label">Total Tokens</span><span class="stat-value">' + totalTokens.toLocaleString() + '</span></div>',
901
+ '<div class="stat-row"><span class="stat-label">PRs Created</span><span class="stat-value">' + totalPRs + '</span></div>',
902
+ ].join("");
903
+
904
+ let html = '<table><thead><tr><th>Date</th><th>Repo</th><th>Tasks</th><th>Tokens</th><th>Agent</th></tr></thead><tbody>';
905
+ for (const log of data.logs.slice(0, 20)) {
906
+ html += '<tr>';
907
+ html += '<td>' + formatDate(log.timestamp) + '</td>';
908
+ html += '<td>' + escapeHtml(log.repo?.fullName || log.repoFullName || "-") + '</td>';
909
+ html += '<td>' + (log.tasks?.length || 0) + '</td>';
910
+ html += '<td>' + (log.budget?.totalTokensUsed || 0).toLocaleString() + '</td>';
911
+ html += '<td>' + escapeHtml(log.budget?.provider || log.agentProvider || "-") + '</td>';
912
+ html += '</tr>';
913
+ }
914
+ html += '</tbody></table>';
915
+ document.getElementById("logs-content").innerHTML = html;
916
+ } catch (e) {
917
+ document.getElementById("logs-content").innerHTML = '<div class="empty">Failed to load logs</div>';
918
+ }
919
+ }
920
+
921
+ async function fetchLeaderboard() {
922
+ try {
923
+ const res = await fetch(API + "/api/v1/leaderboard");
924
+ const data = await res.json();
925
+
926
+ if (!data.entries || data.entries.length === 0) {
927
+ document.getElementById("leaderboard-content").innerHTML = '<div class="empty">No contributors yet</div>';
928
+ return;
929
+ }
930
+
931
+ let html = '<table><thead><tr><th>#</th><th>User</th><th>Tasks</th><th>Tokens</th><th>PRs</th><th>Last Active</th></tr></thead><tbody>';
932
+ data.entries.forEach((entry, i) => {
933
+ html += '<tr>';
934
+ html += '<td class="rank">' + (i + 1) + '</td>';
935
+ html += '<td>' + escapeHtml(entry.githubUsername || "anonymous") + '</td>';
936
+ html += '<td>' + (entry.totalTasksCompleted || 0) + '</td>';
937
+ html += '<td>' + (entry.totalTokensDonated || 0).toLocaleString() + '</td>';
938
+ html += '<td>' + (entry.totalPRsCreated || 0) + '</td>';
939
+ html += '<td>' + formatDate(entry.lastContribution) + '</td>';
940
+ html += '</tr>';
941
+ });
942
+ html += '</tbody></table>';
943
+ document.getElementById("leaderboard-content").innerHTML = html;
944
+ } catch (e) {
945
+ document.getElementById("leaderboard-content").innerHTML = '<div class="empty">Failed to load leaderboard</div>';
946
+ }
947
+ }
948
+
949
+ // ---- SSE Connection ----
950
+
951
+ function connectSSE() {
952
+ const indicator = document.getElementById("sse-indicator");
953
+ const log = document.getElementById("event-log");
954
+
955
+ const es = new EventSource(API + "/api/v1/events");
956
+
957
+ es.addEventListener("connected", (e) => {
958
+ indicator.innerHTML = '<span class="connected">\\u25cf connected</span>';
959
+ addEventLine(log, "connected", "SSE stream connected");
960
+ });
961
+
962
+ es.addEventListener("heartbeat", (e) => {
963
+ // Silent heartbeat \u2014 no log spam
964
+ });
965
+
966
+ // Run events
967
+ es.addEventListener("run:stage", (e) => {
968
+ try {
969
+ const data = JSON.parse(e.data);
970
+ updateStage(data.stage);
971
+ addEventLine(log, "stage", data.message || data.stage);
972
+ } catch {} // best-effort: SSE event data may be malformed
973
+ });
974
+
975
+ es.addEventListener("run:progress", (e) => {
976
+ try {
977
+ const data = JSON.parse(e.data);
978
+ updateProgress(data.progress);
979
+ } catch {} // best-effort: SSE event data may be malformed
980
+ });
981
+
982
+ es.addEventListener("run:task-start", (e) => {
983
+ try {
984
+ const data = JSON.parse(e.data);
985
+ addEventLine(log, "task", "Starting: " + data.title);
986
+ } catch {} // best-effort: SSE event data may be malformed
987
+ });
988
+
989
+ es.addEventListener("run:task-done", (e) => {
990
+ try {
991
+ const data = JSON.parse(e.data);
992
+ const status = data.success ? "OK" : "FAILED";
993
+ let msg = "[" + status + "] " + data.title;
994
+ if (data.prUrl) msg += " - PR: " + data.prUrl;
995
+ addEventLine(log, "task", msg);
996
+ addTaskResult(data);
997
+ } catch {} // best-effort: SSE event data may be malformed
998
+ });
999
+
1000
+ es.addEventListener("run:completed", (e) => {
1001
+ try {
1002
+ addEventLine(log, "completed", "Run finished successfully");
1003
+ updateStage("completed");
1004
+ onRunCompleted(false);
1005
+ } catch {} // best-effort: SSE event data may be malformed
1006
+ });
1007
+
1008
+ es.addEventListener("run:error", (e) => {
1009
+ try {
1010
+ const data = JSON.parse(e.data);
1011
+ addEventLine(log, "error", data.error);
1012
+ onRunError(data.error);
1013
+ } catch {} // best-effort: SSE event data may be malformed
1014
+ });
1015
+
1016
+ es.onerror = () => {
1017
+ indicator.innerHTML = '<span style="color: var(--red);">\\u25cf disconnected</span>';
1018
+ addEventLine(log, "error", "SSE disconnected, reconnecting...");
1019
+ };
1020
+
1021
+ es.onmessage = (e) => {
1022
+ addEventLine(log, "message", e.data);
1023
+ };
1024
+ }
1025
+
1026
+ function addEventLine(container, type, data) {
1027
+ const now = new Date().toLocaleTimeString();
1028
+ const line = document.createElement("div");
1029
+ line.className = "event-line";
1030
+ line.innerHTML = '<span class="time">' + now + '</span> <strong>' + type + '</strong> ' + escapeHtml(String(data));
1031
+ container.appendChild(line);
1032
+ container.scrollTop = container.scrollHeight;
1033
+
1034
+ while (container.children.length > 100) {
1035
+ container.removeChild(container.firstChild);
1036
+ }
1037
+ }
1038
+
1039
+ // ---- Init ----
1040
+ fetchStatus();
1041
+ fetchLogs();
1042
+ fetchLeaderboard();
1043
+ connectSSE();
1044
+
1045
+ setInterval(() => { fetchStatus(); fetchLogs(); }, 30000);
1046
+ </script>
1047
+ </body>
1048
+ </html>`;
1049
+ }
1050
+
1051
+ // src/dashboard/server.ts
1052
+ var DEFAULT_OPTIONS = {
1053
+ port: 3141,
1054
+ host: "0.0.0.0",
1055
+ openBrowser: false,
1056
+ oacDir: process.cwd()
1057
+ };
1058
+ var MAX_SSE_CLIENTS = 50;
1059
+ var currentRun = null;
1060
+ var sseClients = /* @__PURE__ */ new Set();
1061
+ function broadcastEvent(event) {
1062
+ for (const send of sseClients) {
1063
+ try {
1064
+ send(event);
1065
+ } catch {
1066
+ }
1067
+ }
1068
+ }
1069
+ async function readContributionLogs(oacDir) {
1070
+ const contributionsPath = resolve(oacDir, ".oac", "contributions");
1071
+ let entries;
1072
+ try {
1073
+ entries = await readdir(contributionsPath, { withFileTypes: true, encoding: "utf8" });
1074
+ } catch {
1075
+ return [];
1076
+ }
1077
+ const files = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name).sort();
1078
+ const results = await Promise.all(
1079
+ files.map(async (fileName) => {
1080
+ try {
1081
+ const content = await readFile(resolve(contributionsPath, fileName), "utf8");
1082
+ const parsed = contributionLogSchema.safeParse(JSON.parse(content));
1083
+ return parsed.success ? parsed.data : null;
1084
+ } catch {
1085
+ return null;
1086
+ }
1087
+ })
1088
+ );
1089
+ return results.filter((log) => log !== null);
1090
+ }
1091
+ async function readRunStatus(oacDir) {
1092
+ if (currentRun) {
1093
+ return currentRun;
1094
+ }
1095
+ try {
1096
+ const content = await readFile(resolve(oacDir, ".oac", "status.json"), "utf8");
1097
+ return JSON.parse(content);
1098
+ } catch {
1099
+ return { status: "idle", message: "No active runs" };
1100
+ }
1101
+ }
1102
+ async function createDashboardServer(options = {}) {
1103
+ const opts = { ...DEFAULT_OPTIONS, ...options };
1104
+ const app = Fastify({ logger: false });
1105
+ await app.register(cors, { origin: true });
1106
+ app.get("/", async (_request, reply) => {
1107
+ reply.type("text/html").send(renderDashboardHtml(opts.port));
1108
+ });
1109
+ app.get("/api/v1/status", async () => {
1110
+ return readRunStatus(opts.oacDir);
1111
+ });
1112
+ app.get("/api/v1/logs", async () => {
1113
+ const logs = await readContributionLogs(opts.oacDir);
1114
+ return { count: logs.length, logs };
1115
+ });
1116
+ app.get("/api/v1/leaderboard", async () => {
1117
+ const leaderboard = await buildLeaderboard(opts.oacDir);
1118
+ return leaderboard;
1119
+ });
1120
+ app.get("/api/v1/config", async () => {
1121
+ return {
1122
+ oacDir: opts.oacDir,
1123
+ port: opts.port,
1124
+ host: opts.host
1125
+ };
1126
+ });
1127
+ app.post("/api/v1/runs", async (request, reply) => {
1128
+ if (currentRun && currentRun.status === "running") {
1129
+ reply.code(409).send({ error: "A run is already in progress", runId: currentRun.runId });
1130
+ return;
1131
+ }
1132
+ const body = request.body;
1133
+ if (!body?.repo || !body.provider || !body.tokens) {
1134
+ reply.code(400).send({ error: "Missing required fields: repo, provider, tokens" });
1135
+ return;
1136
+ }
1137
+ const config = {
1138
+ repo: body.repo,
1139
+ provider: body.provider,
1140
+ tokens: body.tokens,
1141
+ concurrency: typeof body.concurrency === "number" ? body.concurrency : void 0,
1142
+ maxTasks: body.maxTasks,
1143
+ source: body.source
1144
+ };
1145
+ const runId = randomUUID2();
1146
+ currentRun = {
1147
+ runId,
1148
+ status: "running",
1149
+ stage: "resolving",
1150
+ config,
1151
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1152
+ progress: {
1153
+ tasksDiscovered: 0,
1154
+ tasksSelected: 0,
1155
+ tasksCompleted: 0,
1156
+ tasksFailed: 0,
1157
+ prsCreated: 0,
1158
+ tokensUsed: 0,
1159
+ prUrls: []
1160
+ }
1161
+ };
1162
+ executePipeline(config, (event) => {
1163
+ if (event.type === "run:stage" && currentRun) {
1164
+ currentRun.stage = event.stage;
1165
+ }
1166
+ if (event.type === "run:progress" && currentRun) {
1167
+ currentRun.progress = event.progress;
1168
+ }
1169
+ if (event.type === "run:completed" && currentRun) {
1170
+ currentRun.status = "completed";
1171
+ currentRun.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1172
+ }
1173
+ if (event.type === "run:error" && currentRun) {
1174
+ currentRun.status = "failed";
1175
+ currentRun.error = event.error;
1176
+ currentRun.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1177
+ }
1178
+ broadcastEvent(event);
1179
+ }).catch((err) => {
1180
+ if (currentRun) {
1181
+ currentRun.status = "failed";
1182
+ currentRun.error = err instanceof Error ? err.message : String(err);
1183
+ currentRun.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1184
+ }
1185
+ });
1186
+ reply.code(202).send({ runId, status: "started" });
1187
+ });
1188
+ app.get("/api/v1/events", async (_request, reply) => {
1189
+ reply.raw.writeHead(200, {
1190
+ "Content-Type": "text/event-stream",
1191
+ "Cache-Control": "no-cache",
1192
+ Connection: "keep-alive"
1193
+ });
1194
+ reply.raw.write(
1195
+ `event: connected
1196
+ data: ${JSON.stringify({ time: (/* @__PURE__ */ new Date()).toISOString() })}
1197
+
1198
+ `
1199
+ );
1200
+ const sendEvent = (event) => {
1201
+ reply.raw.write(`event: ${event.type}
1202
+ data: ${JSON.stringify(event)}
1203
+
1204
+ `);
1205
+ };
1206
+ if (sseClients.size >= MAX_SSE_CLIENTS) {
1207
+ reply.status(503).send({ error: "Too many SSE connections" });
1208
+ return;
1209
+ }
1210
+ sseClients.add(sendEvent);
1211
+ const interval = setInterval(() => {
1212
+ reply.raw.write(
1213
+ `event: heartbeat
1214
+ data: ${JSON.stringify({ time: (/* @__PURE__ */ new Date()).toISOString() })}
1215
+
1216
+ `
1217
+ );
1218
+ }, 1e4);
1219
+ _request.raw.on("close", () => {
1220
+ clearInterval(interval);
1221
+ sseClients.delete(sendEvent);
1222
+ });
1223
+ });
1224
+ return app;
1225
+ }
1226
+ async function startDashboard(options = {}) {
1227
+ const opts = { ...DEFAULT_OPTIONS, ...options };
1228
+ const app = await createDashboardServer(opts);
1229
+ await app.listen({ port: opts.port, host: opts.host });
1230
+ const url = opts.host === "0.0.0.0" ? `http://localhost:${opts.port}` : `http://${opts.host}:${opts.port}`;
1231
+ console.log(`
1232
+ \u{1F680} OAC Dashboard running at ${url}`);
1233
+ console.log(` Network: http://0.0.0.0:${opts.port}`);
1234
+ console.log(`
1235
+ API: ${url}/api/v1/status`);
1236
+ console.log(` SSE: ${url}/api/v1/events
1237
+ `);
1238
+ if (opts.openBrowser) {
1239
+ const { execFile } = await import("child_process");
1240
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1241
+ execFile(command, [url], (err) => {
1242
+ if (err) {
1243
+ console.warn(`Could not open browser: ${err.message}`);
1244
+ }
1245
+ });
1246
+ }
1247
+ }
1248
+ export {
1249
+ createDashboardServer,
1250
+ renderDashboardHtml,
1251
+ startDashboard
1252
+ };
1253
+ //# sourceMappingURL=index.js.map