the-campfire 0.1.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.
- package/bin/cli.ts +182 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
- package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
- package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
- package/dist/assets/arc-D9SEA8dX.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
- package/dist/assets/channel-CxhcHn7S.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
- package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
- package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
- package/dist/assets/clone-C6kt1rE7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
- package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
- package/dist/assets/graph-DKRbrxcs.js +1 -0
- package/dist/assets/index-Byjg9zgI.css +32 -0
- package/dist/assets/index-Cwj3m0hT.js +284 -0
- package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
- package/dist/assets/katex-BlHpptmG.js +261 -0
- package/dist/assets/layout-DnxSwLDJ.js +1 -0
- package/dist/assets/linear-CWbbIT8l.js +1 -0
- package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
- package/dist/assets/ordinal-BENe2yWM.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
- package/dist/favicon.svg +3 -0
- package/dist/fonts/Geist-Variable.woff2 +0 -0
- package/dist/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +33 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo.svg +3 -0
- package/dist/manifest.json +26 -0
- package/dist/og-image.png +0 -0
- package/dist/sw.js +130 -0
- package/package.json +92 -0
- package/server/ADAPTERS.md +121 -0
- package/server/adapter-registry-types.ts +28 -0
- package/server/adapter-registry.ts +293 -0
- package/server/adapter-types.ts +51 -0
- package/server/agent-executor.ts +249 -0
- package/server/agent-mcp-bridge.ts +148 -0
- package/server/agent-mcp-stdio.ts +171 -0
- package/server/agent-mcp-tools.ts +76 -0
- package/server/agent-store.ts +183 -0
- package/server/agent-types.ts +79 -0
- package/server/aider-adapter.ts +525 -0
- package/server/auth.ts +153 -0
- package/server/auto-namer.ts +132 -0
- package/server/capability-discovery.ts +431 -0
- package/server/claude-container-auth.ts +40 -0
- package/server/claude-stdio-adapter.ts +452 -0
- package/server/clawhub-export.ts +218 -0
- package/server/cli-launcher.ts +1613 -0
- package/server/codex-adapter.ts +1987 -0
- package/server/codex-container-auth.ts +40 -0
- package/server/codex-home.ts +26 -0
- package/server/collective-intelligence.ts +401 -0
- package/server/commands-discovery.ts +160 -0
- package/server/constants.ts +5 -0
- package/server/container-manager.ts +328 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +149 -0
- package/server/cron-types.ts +63 -0
- package/server/deliberation-engine.ts +323 -0
- package/server/embedding.ts +89 -0
- package/server/env-manager.ts +146 -0
- package/server/environment-detector.ts +106 -0
- package/server/environment-rules.ts +106 -0
- package/server/gallery-store.ts +216 -0
- package/server/gallery-types.ts +52 -0
- package/server/gallery-votes.ts +101 -0
- package/server/git-utils.ts +384 -0
- package/server/github-pr.ts +379 -0
- package/server/goose-adapter.ts +929 -0
- package/server/image-pull-manager.ts +144 -0
- package/server/index.ts +363 -0
- package/server/linear-project-manager.ts +78 -0
- package/server/moltbook-client.ts +160 -0
- package/server/openclaw-adapter.ts +916 -0
- package/server/openclaw-channel/channel.ts +188 -0
- package/server/openclaw-channel/index.ts +68 -0
- package/server/openclaw-channel/package.json +19 -0
- package/server/openclaw-channel/types.ts +93 -0
- package/server/opencode-adapter.ts +951 -0
- package/server/openhands-adapter.ts +775 -0
- package/server/orchestrator-executor.ts +184 -0
- package/server/orchestrator-store.ts +106 -0
- package/server/orchestrator-types.ts +86 -0
- package/server/path-resolver.ts +168 -0
- package/server/pr-poller.ts +162 -0
- package/server/proactive-keepalive.ts +121 -0
- package/server/prompt-manager.ts +103 -0
- package/server/protocol-monitor.ts +197 -0
- package/server/race-controller.ts +295 -0
- package/server/race-store.ts +82 -0
- package/server/recorder.ts +359 -0
- package/server/recording-hub/compat-validator.ts +122 -0
- package/server/recording-hub/diagnostics.ts +289 -0
- package/server/recording-hub/hub-routes.ts +146 -0
- package/server/recording-hub/hub-store.ts +224 -0
- package/server/replay.ts +78 -0
- package/server/routes/adapter-routes.ts +31 -0
- package/server/routes/agent-mcp-routes.ts +35 -0
- package/server/routes/agent-routes.ts +185 -0
- package/server/routes/auth-routes.ts +101 -0
- package/server/routes/ci-routes.ts +165 -0
- package/server/routes/commands-routes.ts +94 -0
- package/server/routes/cron-routes.ts +100 -0
- package/server/routes/env-routes.ts +50 -0
- package/server/routes/folder-routes.ts +56 -0
- package/server/routes/fs-routes.ts +403 -0
- package/server/routes/gallery-routes.ts +410 -0
- package/server/routes/git-routes.ts +108 -0
- package/server/routes/index.ts +62 -0
- package/server/routes/linear-routes.ts +254 -0
- package/server/routes/monitor-routes.ts +11 -0
- package/server/routes/orchestrator-routes.ts +210 -0
- package/server/routes/prompt-routes.ts +47 -0
- package/server/routes/race-routes.ts +103 -0
- package/server/routes/recording-routes.ts +101 -0
- package/server/routes/route-deps.ts +32 -0
- package/server/routes/session-routes.ts +560 -0
- package/server/routes/settings-routes.ts +108 -0
- package/server/routes/skills-routes.ts +52 -0
- package/server/routes/system-routes.ts +243 -0
- package/server/routes/webhook-routes.ts +135 -0
- package/server/routes.ts +44 -0
- package/server/security-middleware.ts +100 -0
- package/server/semantic-memory.ts +389 -0
- package/server/service.ts +733 -0
- package/server/session-folders.ts +107 -0
- package/server/session-git-info.ts +89 -0
- package/server/session-linear-issues.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-store.ts +169 -0
- package/server/session-types.ts +415 -0
- package/server/settings-manager.ts +120 -0
- package/server/shared-context.ts +401 -0
- package/server/skills-manager.ts +250 -0
- package/server/sub-session-manager.ts +263 -0
- package/server/terminal-manager.ts +185 -0
- package/server/update-checker.ts +114 -0
- package/server/usage-limits.ts +192 -0
- package/server/webhook-manager.ts +228 -0
- package/server/webhook-store.ts +168 -0
- package/server/webhook-types.ts +66 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-bridge.ts +2322 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { CliLauncher } from "./cli-launcher.js";
|
|
4
|
+
import type { WsBridge } from "./ws-bridge.js";
|
|
5
|
+
import type { BackendType, BrowserIncomingMessage } from "./session-types.js";
|
|
6
|
+
import * as gitUtils from "./git-utils.js";
|
|
7
|
+
import * as envManager from "./env-manager.js";
|
|
8
|
+
import { getSettings } from "./settings-manager.js";
|
|
9
|
+
import { getRace, saveRace, type RaceEntry, type RaceResult } from "./race-store.js";
|
|
10
|
+
|
|
11
|
+
interface StartRaceOptions {
|
|
12
|
+
prompt: string;
|
|
13
|
+
backends: BackendType[];
|
|
14
|
+
repoRoot: string;
|
|
15
|
+
baseBranch?: string;
|
|
16
|
+
modelByBackend?: Partial<Record<BackendType, string>>;
|
|
17
|
+
envSlug?: string;
|
|
18
|
+
env?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const POLL_MS = 1000;
|
|
22
|
+
const ENTRY_TIMEOUT_MS = 30 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
function sleep(ms: number): Promise<void> {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function git(args: string[], cwd: string): string {
|
|
29
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", timeout: 20_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function gitSafe(args: string[], cwd: string): string {
|
|
33
|
+
try {
|
|
34
|
+
return git(args, cwd);
|
|
35
|
+
} catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractAssistantText(messages: BrowserIncomingMessage[]): string {
|
|
41
|
+
const parts: string[] = [];
|
|
42
|
+
for (const msg of messages) {
|
|
43
|
+
if (msg.type !== "assistant") continue;
|
|
44
|
+
for (const block of msg.message.content) {
|
|
45
|
+
if (block.type === "text") parts.push(block.text);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return parts.join("\n\n").trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function findResult(messages: BrowserIncomingMessage[], afterIndex: number) {
|
|
52
|
+
const result = messages.slice(afterIndex).find((msg) => msg.type === "result");
|
|
53
|
+
return result?.type === "result" ? result.data : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collectMetrics(worktreePath: string, startedAt: number, costUsd: number): RaceEntry["metrics"] {
|
|
57
|
+
const changedFiles = collectChangedFiles(worktreePath);
|
|
58
|
+
const numstat = gitSafe(["diff", "--numstat"], worktreePath);
|
|
59
|
+
let linesAdded = 0;
|
|
60
|
+
let linesRemoved = 0;
|
|
61
|
+
for (const line of numstat.split(/\r?\n/)) {
|
|
62
|
+
const [added, removed] = line.split(/\s+/);
|
|
63
|
+
const addNum = Number(added);
|
|
64
|
+
const removeNum = Number(removed);
|
|
65
|
+
if (Number.isFinite(addNum)) linesAdded += addNum;
|
|
66
|
+
if (Number.isFinite(removeNum)) linesRemoved += removeNum;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
wallClockMs: Date.now() - startedAt,
|
|
70
|
+
costUsd,
|
|
71
|
+
filesChanged: changedFiles.length,
|
|
72
|
+
linesAdded,
|
|
73
|
+
linesRemoved,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveEnvVars(options: StartRaceOptions, backend: BackendType): Record<string, string> | undefined {
|
|
78
|
+
let envVars: Record<string, string> | undefined = options.env ? { ...options.env } : undefined;
|
|
79
|
+
|
|
80
|
+
if (options.envSlug) {
|
|
81
|
+
const campfireEnv = envManager.getEnv(options.envSlug);
|
|
82
|
+
if (campfireEnv) {
|
|
83
|
+
envVars = { ...campfireEnv.variables, ...envVars };
|
|
84
|
+
} else {
|
|
85
|
+
console.warn(`[race-controller] Environment "${options.envSlug}" not found, ignoring`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const globalSettings = getSettings();
|
|
90
|
+
if (backend === "claude" && globalSettings.claudeOAuthToken && !envVars?.["CLAUDE_CODE_OAUTH_TOKEN"]) {
|
|
91
|
+
envVars = { ...envVars, CLAUDE_CODE_OAUTH_TOKEN: globalSettings.claudeOAuthToken };
|
|
92
|
+
}
|
|
93
|
+
if (backend === "codex" && globalSettings.openaiApiKey && !envVars?.["OPENAI_API_KEY"]) {
|
|
94
|
+
envVars = { ...envVars, OPENAI_API_KEY: globalSettings.openaiApiKey };
|
|
95
|
+
}
|
|
96
|
+
if (globalSettings.anthropicApiKey && !envVars?.["ANTHROPIC_API_KEY"]) {
|
|
97
|
+
envVars = { ...envVars, ANTHROPIC_API_KEY: globalSettings.anthropicApiKey };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return envVars;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function collectChangedFiles(worktreePath: string): string[] {
|
|
104
|
+
const changed = gitSafe(["diff", "--name-only"], worktreePath).split(/\r?\n/).filter(Boolean);
|
|
105
|
+
const staged = gitSafe(["diff", "--name-only", "--cached"], worktreePath).split(/\r?\n/).filter(Boolean);
|
|
106
|
+
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], worktreePath).split(/\r?\n/).filter(Boolean);
|
|
107
|
+
return Array.from(new Set([...changed, ...staged, ...untracked])).sort();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class RaceController {
|
|
111
|
+
private readonly active = new Map<string, { cancelled: boolean }>();
|
|
112
|
+
|
|
113
|
+
constructor(
|
|
114
|
+
private readonly launcher: CliLauncher,
|
|
115
|
+
private readonly wsBridge: WsBridge,
|
|
116
|
+
) {}
|
|
117
|
+
|
|
118
|
+
startRace(options: StartRaceOptions): RaceResult {
|
|
119
|
+
if (!options.prompt.trim()) throw new Error("prompt is required");
|
|
120
|
+
if (options.backends.length < 2) throw new Error("At least two backends are required");
|
|
121
|
+
|
|
122
|
+
const repoInfo = gitUtils.getRepoInfo(options.repoRoot);
|
|
123
|
+
const baseBranch = options.baseBranch || repoInfo?.currentBranch || repoInfo?.defaultBranch || "main";
|
|
124
|
+
const raceId = randomUUID();
|
|
125
|
+
const race: RaceResult = {
|
|
126
|
+
raceId,
|
|
127
|
+
prompt: options.prompt,
|
|
128
|
+
repoRoot: options.repoRoot,
|
|
129
|
+
baseBranch,
|
|
130
|
+
status: "running",
|
|
131
|
+
createdAt: Date.now(),
|
|
132
|
+
entries: [],
|
|
133
|
+
};
|
|
134
|
+
saveRace(race);
|
|
135
|
+
void this.runRace(raceId, options);
|
|
136
|
+
return race;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async cancelRace(raceId: string): Promise<RaceResult | null> {
|
|
140
|
+
const race = getRace(raceId);
|
|
141
|
+
if (!race) return null;
|
|
142
|
+
const active = this.active.get(raceId);
|
|
143
|
+
if (active) active.cancelled = true;
|
|
144
|
+
await Promise.all(race.entries.map((entry) => this.launcher.kill(entry.sessionId).catch(() => false)));
|
|
145
|
+
for (const entry of race.entries) {
|
|
146
|
+
gitUtils.removeWorktree(race.repoRoot, entry.worktreePath, { force: true, branchToDelete: entry.branch });
|
|
147
|
+
if (entry.status === "running" || entry.status === "pending") entry.status = "failed";
|
|
148
|
+
}
|
|
149
|
+
race.status = "cancelled";
|
|
150
|
+
race.completedAt = Date.now();
|
|
151
|
+
saveRace(race);
|
|
152
|
+
return race;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
pickWinner(raceId: string, sessionId: string): RaceResult | null {
|
|
156
|
+
const race = getRace(raceId);
|
|
157
|
+
if (!race) return null;
|
|
158
|
+
const winner = race.entries.find((entry) => entry.sessionId === sessionId);
|
|
159
|
+
if (!winner) throw new Error("Winner session is not part of this race");
|
|
160
|
+
|
|
161
|
+
git(["merge", "--no-ff", winner.branch], race.repoRoot);
|
|
162
|
+
race.winnerId = sessionId;
|
|
163
|
+
race.status = "completed";
|
|
164
|
+
race.completedAt = Date.now();
|
|
165
|
+
|
|
166
|
+
for (const entry of race.entries) {
|
|
167
|
+
if (entry.sessionId !== sessionId) {
|
|
168
|
+
gitUtils.removeWorktree(race.repoRoot, entry.worktreePath, { force: true, branchToDelete: entry.branch });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
saveRace(race);
|
|
172
|
+
return race;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async runRace(raceId: string, options: StartRaceOptions): Promise<void> {
|
|
176
|
+
const control = { cancelled: false };
|
|
177
|
+
this.active.set(raceId, control);
|
|
178
|
+
const race = getRace(raceId);
|
|
179
|
+
if (!race) return;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const entries = options.backends.map((backendType) => this.createEntry(race, backendType, options));
|
|
183
|
+
race.entries = entries;
|
|
184
|
+
saveRace(race);
|
|
185
|
+
await Promise.all(entries.map((entry) => this.runEntry(raceId, entry, options.prompt, control)));
|
|
186
|
+
const latest = getRace(raceId);
|
|
187
|
+
if (latest && latest.status === "running") {
|
|
188
|
+
latest.status = latest.entries.some((entry) => entry.status === "failed" || entry.status === "timeout") ? "failed" : "completed";
|
|
189
|
+
latest.completedAt = Date.now();
|
|
190
|
+
saveRace(latest);
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const latest = getRace(raceId) ?? race;
|
|
194
|
+
latest.status = "failed";
|
|
195
|
+
latest.completedAt = Date.now();
|
|
196
|
+
latest.error = err instanceof Error ? err.message : String(err);
|
|
197
|
+
saveRace(latest);
|
|
198
|
+
} finally {
|
|
199
|
+
this.active.delete(raceId);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private createEntry(race: RaceResult, backendType: BackendType, options: StartRaceOptions): RaceEntry {
|
|
204
|
+
const branch = `race-${backendType}-${race.raceId.slice(0, 8)}`;
|
|
205
|
+
const wt = gitUtils.ensureWorktree(race.repoRoot, branch, {
|
|
206
|
+
baseBranch: race.baseBranch,
|
|
207
|
+
createBranch: true,
|
|
208
|
+
forceNew: true,
|
|
209
|
+
});
|
|
210
|
+
const session = this.launcher.launch({
|
|
211
|
+
cwd: wt.worktreePath,
|
|
212
|
+
backendType,
|
|
213
|
+
model: options.modelByBackend?.[backendType],
|
|
214
|
+
permissionMode: "bypassPermissions",
|
|
215
|
+
env: resolveEnvVars(options, backendType),
|
|
216
|
+
orchestrationRole: "race_entry",
|
|
217
|
+
worktreeInfo: {
|
|
218
|
+
isWorktree: true,
|
|
219
|
+
repoRoot: race.repoRoot,
|
|
220
|
+
branch,
|
|
221
|
+
actualBranch: wt.actualBranch,
|
|
222
|
+
worktreePath: wt.worktreePath,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
this.wsBridge.markSessionOrchestration(session.sessionId, {
|
|
226
|
+
role: "race_entry",
|
|
227
|
+
detectedEnvironment: session.detectedEnvironment,
|
|
228
|
+
});
|
|
229
|
+
return {
|
|
230
|
+
id: randomUUID(),
|
|
231
|
+
sessionId: session.sessionId,
|
|
232
|
+
backendType,
|
|
233
|
+
model: options.modelByBackend?.[backendType],
|
|
234
|
+
worktreePath: wt.worktreePath,
|
|
235
|
+
branch: wt.actualBranch,
|
|
236
|
+
status: "pending",
|
|
237
|
+
startedAt: Date.now(),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async runEntry(raceId: string, entry: RaceEntry, prompt: string, control: { cancelled: boolean }): Promise<void> {
|
|
242
|
+
entry.status = "running";
|
|
243
|
+
entry.startedAt = Date.now();
|
|
244
|
+
this.updateEntry(raceId, entry);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
await this.waitForConnection(entry.sessionId, 20_000);
|
|
248
|
+
const session = this.wsBridge.getSession(entry.sessionId);
|
|
249
|
+
const startIndex = session?.messageHistory.length ?? 0;
|
|
250
|
+
this.wsBridge.injectUserMessage(entry.sessionId, prompt);
|
|
251
|
+
const result = await this.waitForResult(entry.sessionId, startIndex, control);
|
|
252
|
+
const finalSession = this.wsBridge.getSession(entry.sessionId);
|
|
253
|
+
entry.completedAt = Date.now();
|
|
254
|
+
entry.status = result.is_error ? "failed" : "completed";
|
|
255
|
+
entry.error = result.is_error ? result.errors?.join("\n") || result.result || "Race entry failed" : undefined;
|
|
256
|
+
entry.outputSummary = extractAssistantText((finalSession?.messageHistory ?? []) as BrowserIncomingMessage[]).slice(0, 1000);
|
|
257
|
+
entry.filesChanged = collectChangedFiles(entry.worktreePath);
|
|
258
|
+
entry.metrics = collectMetrics(entry.worktreePath, entry.startedAt, result.total_cost_usd || finalSession?.state.total_cost_usd || 0);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
entry.completedAt = Date.now();
|
|
261
|
+
entry.status = err instanceof Error && err.message.includes("Timed out") ? "timeout" : "failed";
|
|
262
|
+
entry.error = err instanceof Error ? err.message : String(err);
|
|
263
|
+
entry.filesChanged = collectChangedFiles(entry.worktreePath);
|
|
264
|
+
entry.metrics = collectMetrics(entry.worktreePath, entry.startedAt, 0);
|
|
265
|
+
}
|
|
266
|
+
this.updateEntry(raceId, entry);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private updateEntry(raceId: string, entry: RaceEntry): void {
|
|
270
|
+
const race = getRace(raceId);
|
|
271
|
+
if (!race) return;
|
|
272
|
+
race.entries = race.entries.map((existing) => existing.id === entry.id ? entry : existing);
|
|
273
|
+
saveRace(race);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async waitForConnection(sessionId: string, timeoutMs: number): Promise<void> {
|
|
277
|
+
const deadline = Date.now() + timeoutMs;
|
|
278
|
+
while (Date.now() < deadline) {
|
|
279
|
+
if (this.wsBridge.isCliConnected(sessionId)) return;
|
|
280
|
+
await sleep(POLL_MS);
|
|
281
|
+
}
|
|
282
|
+
throw new Error(`Timed out waiting for race session ${sessionId} to connect.`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async waitForResult(sessionId: string, startIndex: number, control: { cancelled: boolean }) {
|
|
286
|
+
const deadline = Date.now() + ENTRY_TIMEOUT_MS;
|
|
287
|
+
while (Date.now() < deadline && !control.cancelled) {
|
|
288
|
+
const session = this.wsBridge.getSession(sessionId);
|
|
289
|
+
const result = session ? findResult(session.messageHistory as BrowserIncomingMessage[], startIndex) : null;
|
|
290
|
+
if (result) return result;
|
|
291
|
+
await sleep(POLL_MS);
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`Timed out waiting for race session ${sessionId} to finish.`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { BackendType } from "./session-types.js";
|
|
5
|
+
|
|
6
|
+
export type RaceStatus = "running" | "completed" | "failed" | "cancelled";
|
|
7
|
+
export type RaceEntryStatus = "pending" | "running" | "completed" | "failed" | "timeout";
|
|
8
|
+
|
|
9
|
+
export interface RaceEntry {
|
|
10
|
+
id: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
backendType: BackendType;
|
|
13
|
+
model?: string;
|
|
14
|
+
worktreePath: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
status: RaceEntryStatus;
|
|
17
|
+
startedAt: number;
|
|
18
|
+
completedAt?: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
outputSummary?: string;
|
|
21
|
+
filesChanged?: string[];
|
|
22
|
+
metrics?: {
|
|
23
|
+
wallClockMs: number;
|
|
24
|
+
costUsd: number;
|
|
25
|
+
filesChanged: number;
|
|
26
|
+
linesAdded: number;
|
|
27
|
+
linesRemoved: number;
|
|
28
|
+
testsPassed?: number;
|
|
29
|
+
testsFailed?: number;
|
|
30
|
+
lintWarnings?: number;
|
|
31
|
+
lintErrors?: number;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RaceResult {
|
|
36
|
+
raceId: string;
|
|
37
|
+
prompt: string;
|
|
38
|
+
repoRoot: string;
|
|
39
|
+
baseBranch: string;
|
|
40
|
+
status: RaceStatus;
|
|
41
|
+
createdAt: number;
|
|
42
|
+
completedAt?: number;
|
|
43
|
+
entries: RaceEntry[];
|
|
44
|
+
winnerId?: string;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const RACES_DIR = join(homedir(), ".campfire", "races");
|
|
49
|
+
|
|
50
|
+
function ensureDir(): void {
|
|
51
|
+
mkdirSync(RACES_DIR, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function listRaces(): RaceResult[] {
|
|
55
|
+
ensureDir();
|
|
56
|
+
return readdirSync(RACES_DIR)
|
|
57
|
+
.filter((file) => file.endsWith(".json"))
|
|
58
|
+
.map((file) => getRace(file.replace(/\.json$/, "")))
|
|
59
|
+
.filter((race): race is RaceResult => race !== null)
|
|
60
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getRace(id: string): RaceResult | null {
|
|
64
|
+
ensureDir();
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(join(RACES_DIR, `${id}.json`), "utf-8")) as RaceResult;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function saveRace(race: RaceResult): void {
|
|
73
|
+
ensureDir();
|
|
74
|
+
writeFileSync(join(RACES_DIR, `${race.raceId}.json`), JSON.stringify(race, null, 2), "utf-8");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function deleteRace(id: string): boolean {
|
|
78
|
+
const path = join(RACES_DIR, `${id}.json`);
|
|
79
|
+
if (!existsSync(path)) return false;
|
|
80
|
+
unlinkSync(path);
|
|
81
|
+
return true;
|
|
82
|
+
}
|