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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrchestratorExecutor — Runs pipelines by sequentially creating sessions for each stage.
|
|
3
|
+
*
|
|
4
|
+
* Each stage:
|
|
5
|
+
* 1. Creates a new session via the REST API internal helper
|
|
6
|
+
* 2. Sends the stage prompt (optionally with previous stage context)
|
|
7
|
+
* 3. Waits for the session to complete (polls status)
|
|
8
|
+
* 4. Records the result and moves to the next stage
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import type { Pipeline, PipelineRun, StageResult } from "./orchestrator-types.js";
|
|
13
|
+
import { saveRun } from "./orchestrator-store.js";
|
|
14
|
+
|
|
15
|
+
interface SessionCreateFn {
|
|
16
|
+
(opts: {
|
|
17
|
+
cwd: string;
|
|
18
|
+
backend: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
permissionMode?: string;
|
|
21
|
+
prompt?: string;
|
|
22
|
+
}): Promise<{ sessionId: string } | null>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SessionStatusFn {
|
|
26
|
+
(sessionId: string): { status: string; cost?: number; lastMessage?: string } | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Active runs so we can cancel them
|
|
30
|
+
const activeRuns = new Map<string, { cancelled: boolean }>();
|
|
31
|
+
|
|
32
|
+
export function cancelRun(runId: string): boolean {
|
|
33
|
+
const run = activeRuns.get(runId);
|
|
34
|
+
if (run) {
|
|
35
|
+
run.cancelled = true;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isRunActive(runId: string): boolean {
|
|
42
|
+
return activeRuns.has(runId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function executePipeline(
|
|
46
|
+
pipeline: Pipeline,
|
|
47
|
+
createSession: SessionCreateFn,
|
|
48
|
+
getSessionStatus: SessionStatusFn,
|
|
49
|
+
): Promise<PipelineRun> {
|
|
50
|
+
const runId = randomUUID();
|
|
51
|
+
const run: PipelineRun = {
|
|
52
|
+
id: runId,
|
|
53
|
+
pipelineId: pipeline.id,
|
|
54
|
+
pipelineName: pipeline.name,
|
|
55
|
+
cwd: pipeline.cwd,
|
|
56
|
+
status: "running",
|
|
57
|
+
stageResults: pipeline.stages.map((s) => ({
|
|
58
|
+
stageId: s.id,
|
|
59
|
+
status: "pending",
|
|
60
|
+
})),
|
|
61
|
+
startedAt: Date.now(),
|
|
62
|
+
totalCostUsd: 0,
|
|
63
|
+
totalDurationMs: 0,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const control = { cancelled: false };
|
|
67
|
+
activeRuns.set(runId, control);
|
|
68
|
+
saveRun(run);
|
|
69
|
+
|
|
70
|
+
let previousOutput = "";
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < pipeline.stages.length; i++) {
|
|
73
|
+
if (control.cancelled) {
|
|
74
|
+
// Mark remaining stages as skipped
|
|
75
|
+
for (let j = i; j < pipeline.stages.length; j++) {
|
|
76
|
+
run.stageResults[j].status = "skipped";
|
|
77
|
+
}
|
|
78
|
+
run.status = "cancelled";
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const stage = pipeline.stages[i];
|
|
83
|
+
const result = run.stageResults[i];
|
|
84
|
+
result.status = "running";
|
|
85
|
+
result.startedAt = Date.now();
|
|
86
|
+
saveRun(run);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Build the prompt, optionally including previous stage output
|
|
90
|
+
let fullPrompt = stage.prompt;
|
|
91
|
+
if (stage.inheritContext && previousOutput) {
|
|
92
|
+
fullPrompt = `Context from previous stage:\n\n${previousOutput}\n\n---\n\n${stage.prompt}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Create a session for this stage
|
|
96
|
+
const session = await createSession({
|
|
97
|
+
cwd: pipeline.cwd,
|
|
98
|
+
backend: stage.backend,
|
|
99
|
+
model: stage.model,
|
|
100
|
+
permissionMode: stage.permissionMode || "bypassPermissions",
|
|
101
|
+
prompt: fullPrompt,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!session) {
|
|
105
|
+
result.status = "failed";
|
|
106
|
+
result.error = "Failed to create session";
|
|
107
|
+
result.completedAt = Date.now();
|
|
108
|
+
result.durationMs = result.completedAt - (result.startedAt || result.completedAt);
|
|
109
|
+
run.status = "failed";
|
|
110
|
+
saveRun(run);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
result.sessionId = session.sessionId;
|
|
115
|
+
saveRun(run);
|
|
116
|
+
|
|
117
|
+
// Poll for session completion (max 30 minutes per stage)
|
|
118
|
+
const maxWait = 30 * 60 * 1000;
|
|
119
|
+
const pollInterval = 3000;
|
|
120
|
+
const deadline = Date.now() + maxWait;
|
|
121
|
+
|
|
122
|
+
while (Date.now() < deadline && !control.cancelled) {
|
|
123
|
+
await sleep(pollInterval);
|
|
124
|
+
|
|
125
|
+
const status = getSessionStatus(session.sessionId);
|
|
126
|
+
if (!status) continue;
|
|
127
|
+
|
|
128
|
+
if (status.status === "idle" || status.status === "completed") {
|
|
129
|
+
result.status = "completed";
|
|
130
|
+
result.costUsd = status.cost || 0;
|
|
131
|
+
result.outputSummary = status.lastMessage?.slice(0, 500) || "";
|
|
132
|
+
previousOutput = result.outputSummary;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (status.status === "error" || status.status === "failed") {
|
|
137
|
+
result.status = "failed";
|
|
138
|
+
result.error = status.lastMessage || "Session failed";
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (result.status === "running") {
|
|
144
|
+
// Timed out or cancelled
|
|
145
|
+
result.status = control.cancelled ? "skipped" : "failed";
|
|
146
|
+
result.error = control.cancelled ? "Cancelled" : "Timed out (30 min)";
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
result.status = "failed";
|
|
150
|
+
result.error = err instanceof Error ? err.message : String(err);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
result.completedAt = Date.now();
|
|
154
|
+
result.durationMs = result.completedAt - (result.startedAt || result.completedAt);
|
|
155
|
+
run.totalCostUsd += result.costUsd || 0;
|
|
156
|
+
|
|
157
|
+
if (result.status === "failed") {
|
|
158
|
+
// Mark remaining stages as skipped
|
|
159
|
+
for (let j = i + 1; j < pipeline.stages.length; j++) {
|
|
160
|
+
run.stageResults[j].status = "skipped";
|
|
161
|
+
}
|
|
162
|
+
run.status = "failed";
|
|
163
|
+
saveRun(run);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
saveRun(run);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (run.status === "running") {
|
|
171
|
+
run.status = "completed";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
run.completedAt = Date.now();
|
|
175
|
+
run.totalDurationMs = run.completedAt - run.startedAt;
|
|
176
|
+
activeRuns.delete(runId);
|
|
177
|
+
saveRun(run);
|
|
178
|
+
|
|
179
|
+
return run;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function sleep(ms: number): Promise<void> {
|
|
183
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
184
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrchestratorStore — File-based persistence for pipelines and runs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import type { Pipeline, PipelineRun } from "./orchestrator-types.js";
|
|
9
|
+
|
|
10
|
+
const STORE_DIR = join(homedir(), ".campfire", "orchestrator");
|
|
11
|
+
const PIPELINES_DIR = join(STORE_DIR, "pipelines");
|
|
12
|
+
const RUNS_DIR = join(STORE_DIR, "runs");
|
|
13
|
+
|
|
14
|
+
function ensureDirs(): void {
|
|
15
|
+
mkdirSync(PIPELINES_DIR, { recursive: true });
|
|
16
|
+
mkdirSync(RUNS_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── Pipelines ─────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export function listPipelines(): Pipeline[] {
|
|
22
|
+
ensureDirs();
|
|
23
|
+
try {
|
|
24
|
+
const files = readdirSync(PIPELINES_DIR).filter((f) => f.endsWith(".json"));
|
|
25
|
+
return files
|
|
26
|
+
.map((f) => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(join(PIPELINES_DIR, f), "utf-8")) as Pipeline;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.filter((p): p is Pipeline => p !== null)
|
|
34
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getPipeline(id: string): Pipeline | null {
|
|
41
|
+
ensureDirs();
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(readFileSync(join(PIPELINES_DIR, `${id}.json`), "utf-8")) as Pipeline;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function savePipeline(pipeline: Pipeline): void {
|
|
50
|
+
ensureDirs();
|
|
51
|
+
writeFileSync(join(PIPELINES_DIR, `${pipeline.id}.json`), JSON.stringify(pipeline, null, 2));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function deletePipeline(id: string): boolean {
|
|
55
|
+
try {
|
|
56
|
+
unlinkSync(join(PIPELINES_DIR, `${id}.json`));
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Runs ──────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export function listRuns(pipelineId?: string): PipelineRun[] {
|
|
66
|
+
ensureDirs();
|
|
67
|
+
try {
|
|
68
|
+
const files = readdirSync(RUNS_DIR).filter((f) => f.endsWith(".json"));
|
|
69
|
+
return files
|
|
70
|
+
.map((f) => {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(join(RUNS_DIR, f), "utf-8")) as PipelineRun;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
.filter((r): r is PipelineRun => r !== null)
|
|
78
|
+
.filter((r) => !pipelineId || r.pipelineId === pipelineId)
|
|
79
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getRun(id: string): PipelineRun | null {
|
|
86
|
+
ensureDirs();
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(readFileSync(join(RUNS_DIR, `${id}.json`), "utf-8")) as PipelineRun;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function saveRun(run: PipelineRun): void {
|
|
95
|
+
ensureDirs();
|
|
96
|
+
writeFileSync(join(RUNS_DIR, `${run.id}.json`), JSON.stringify(run, null, 2));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function deleteRun(id: string): boolean {
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(join(RUNS_DIR, `${id}.json`));
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator Types — Multi-stage pipeline engine for chaining AI sessions.
|
|
3
|
+
*
|
|
4
|
+
* A Pipeline defines an ordered sequence of Stages (e.g., implement → test → review).
|
|
5
|
+
* Each stage spawns a child session and passes context from the previous stage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PipelineStage {
|
|
9
|
+
/** Unique stage ID (uuid) */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Display name, e.g. "Implement feature" */
|
|
12
|
+
name: string;
|
|
13
|
+
/** Prompt to send to the agent for this stage */
|
|
14
|
+
prompt: string;
|
|
15
|
+
/** Backend to use: "claude" | "codex" | "goose" | "aider" */
|
|
16
|
+
backend: string;
|
|
17
|
+
/** Model override (optional) */
|
|
18
|
+
model?: string;
|
|
19
|
+
/** Permission mode for this stage */
|
|
20
|
+
permissionMode?: string;
|
|
21
|
+
/** Whether to pass the previous stage's output as context */
|
|
22
|
+
inheritContext?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Pipeline {
|
|
26
|
+
/** Unique pipeline ID (uuid) */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Display name */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Description of what this pipeline does */
|
|
31
|
+
description?: string;
|
|
32
|
+
/** Working directory */
|
|
33
|
+
cwd: string;
|
|
34
|
+
/** Ordered list of stages */
|
|
35
|
+
stages: PipelineStage[];
|
|
36
|
+
/** Created timestamp (ms) */
|
|
37
|
+
createdAt: number;
|
|
38
|
+
/** Last modified timestamp (ms) */
|
|
39
|
+
updatedAt: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type StageStatus = "pending" | "running" | "completed" | "failed" | "skipped";
|
|
43
|
+
|
|
44
|
+
export interface StageResult {
|
|
45
|
+
stageId: string;
|
|
46
|
+
status: StageStatus;
|
|
47
|
+
/** Child session ID created for this stage */
|
|
48
|
+
sessionId?: string;
|
|
49
|
+
/** Stage start time (ms) */
|
|
50
|
+
startedAt?: number;
|
|
51
|
+
/** Stage end time (ms) */
|
|
52
|
+
completedAt?: number;
|
|
53
|
+
/** Duration in ms */
|
|
54
|
+
durationMs?: number;
|
|
55
|
+
/** Cost for this stage */
|
|
56
|
+
costUsd?: number;
|
|
57
|
+
/** Error message if failed */
|
|
58
|
+
error?: string;
|
|
59
|
+
/** Summary output from the session (last assistant message) */
|
|
60
|
+
outputSummary?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type PipelineRunStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
|
64
|
+
|
|
65
|
+
export interface PipelineRun {
|
|
66
|
+
/** Unique run ID */
|
|
67
|
+
id: string;
|
|
68
|
+
/** Pipeline ID */
|
|
69
|
+
pipelineId: string;
|
|
70
|
+
/** Pipeline name snapshot */
|
|
71
|
+
pipelineName: string;
|
|
72
|
+
/** Working directory */
|
|
73
|
+
cwd: string;
|
|
74
|
+
/** Overall run status */
|
|
75
|
+
status: PipelineRunStatus;
|
|
76
|
+
/** Per-stage results */
|
|
77
|
+
stageResults: StageResult[];
|
|
78
|
+
/** Run start time */
|
|
79
|
+
startedAt: number;
|
|
80
|
+
/** Run end time */
|
|
81
|
+
completedAt?: number;
|
|
82
|
+
/** Total cost across all stages */
|
|
83
|
+
totalCostUsd: number;
|
|
84
|
+
/** Total duration */
|
|
85
|
+
totalDurationMs: number;
|
|
86
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PATH discovery and binary resolution for service environments.
|
|
3
|
+
*
|
|
4
|
+
* When Campfire runs as a macOS launchd or Linux systemd service, it inherits
|
|
5
|
+
* a restricted PATH that omits directories from version managers (nvm, fnm, volta,
|
|
6
|
+
* mise, etc.) and user-local installs (~/.local/bin, ~/.cargo/bin). This module
|
|
7
|
+
* captures the user's real shell PATH at runtime and provides binary resolution
|
|
8
|
+
* that works regardless of how the server was started.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Capture the user's full interactive shell PATH by spawning a login shell.
|
|
18
|
+
* This picks up all version manager initializations (nvm, fnm, volta, mise, etc.).
|
|
19
|
+
* Falls back to probing common directories if shell sourcing fails.
|
|
20
|
+
*/
|
|
21
|
+
export function captureUserShellPath(): string {
|
|
22
|
+
try {
|
|
23
|
+
const shell = process.env.SHELL || "/bin/bash";
|
|
24
|
+
const captured = execSync(
|
|
25
|
+
`${shell} -lic 'echo "___PATH_START___$PATH___PATH_END___"'`,
|
|
26
|
+
{
|
|
27
|
+
encoding: "utf-8",
|
|
28
|
+
timeout: 10_000,
|
|
29
|
+
env: { HOME: homedir(), USER: process.env.USER, SHELL: shell },
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
const match = captured.match(/___PATH_START___(.+)___PATH_END___/);
|
|
33
|
+
if (match?.[1]) {
|
|
34
|
+
return match[1];
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Shell sourcing failed (timeout, compinit prompt, etc.)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return buildFallbackPath();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a PATH by probing common binary installation directories.
|
|
45
|
+
* Used as fallback when shell-sourcing fails.
|
|
46
|
+
*/
|
|
47
|
+
export function buildFallbackPath(): string {
|
|
48
|
+
const home = homedir();
|
|
49
|
+
const candidates = [
|
|
50
|
+
// Standard system paths
|
|
51
|
+
"/opt/homebrew/bin",
|
|
52
|
+
"/opt/homebrew/sbin",
|
|
53
|
+
"/usr/local/bin",
|
|
54
|
+
"/usr/bin",
|
|
55
|
+
"/bin",
|
|
56
|
+
"/usr/sbin",
|
|
57
|
+
"/sbin",
|
|
58
|
+
// Bun
|
|
59
|
+
join(home, ".bun", "bin"),
|
|
60
|
+
// Claude CLI / user-local installs
|
|
61
|
+
join(home, ".local", "bin"),
|
|
62
|
+
// Cargo / Rust
|
|
63
|
+
join(home, ".cargo", "bin"),
|
|
64
|
+
// Volta (Node version manager)
|
|
65
|
+
join(home, ".volta", "bin"),
|
|
66
|
+
// mise (formerly rtx)
|
|
67
|
+
join(home, ".local", "share", "mise", "shims"),
|
|
68
|
+
// pyenv
|
|
69
|
+
join(home, ".pyenv", "bin"),
|
|
70
|
+
join(home, ".pyenv", "shims"),
|
|
71
|
+
// Go
|
|
72
|
+
join(home, "go", "bin"),
|
|
73
|
+
"/usr/local/go/bin",
|
|
74
|
+
// Deno
|
|
75
|
+
join(home, ".deno", "bin"),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// Probe nvm-managed node versions
|
|
79
|
+
const nvmDir = process.env.NVM_DIR || join(home, ".nvm");
|
|
80
|
+
const nvmVersionsDir = join(nvmDir, "versions", "node");
|
|
81
|
+
if (existsSync(nvmVersionsDir)) {
|
|
82
|
+
try {
|
|
83
|
+
for (const v of readdirSync(nvmVersionsDir)) {
|
|
84
|
+
candidates.push(join(nvmVersionsDir, v, "bin"));
|
|
85
|
+
}
|
|
86
|
+
} catch { /* ignore */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// fnm (Fast Node Manager) — versions stored in fnm multishell or XDG data
|
|
90
|
+
const fnmDir = join(home, "Library", "Application Support", "fnm", "node-versions");
|
|
91
|
+
if (existsSync(fnmDir)) {
|
|
92
|
+
try {
|
|
93
|
+
for (const v of readdirSync(fnmDir)) {
|
|
94
|
+
candidates.push(join(fnmDir, v, "installation", "bin"));
|
|
95
|
+
}
|
|
96
|
+
} catch { /* ignore */ }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return [...new Set(candidates.filter((dir) => existsSync(dir)))].join(":");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Enriched PATH (cached) ───────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
let _cachedPath: string | null = null;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Returns an enriched PATH that merges the user's shell PATH (or probed common
|
|
108
|
+
* directories) with the current process PATH. Deduplicates entries.
|
|
109
|
+
* Result is cached after the first call.
|
|
110
|
+
*/
|
|
111
|
+
export function getEnrichedPath(): string {
|
|
112
|
+
if (_cachedPath) return _cachedPath;
|
|
113
|
+
|
|
114
|
+
const currentPath = process.env.PATH || "";
|
|
115
|
+
const userPath = captureUserShellPath();
|
|
116
|
+
|
|
117
|
+
// Merge: user shell PATH first (takes precedence), then current process PATH
|
|
118
|
+
const allDirs = [...userPath.split(":"), ...currentPath.split(":")];
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
const deduped: string[] = [];
|
|
121
|
+
for (const dir of allDirs) {
|
|
122
|
+
if (dir && !seen.has(dir)) {
|
|
123
|
+
seen.add(dir);
|
|
124
|
+
deduped.push(dir);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_cachedPath = deduped.join(":");
|
|
129
|
+
return _cachedPath;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Reset the cached PATH (for testing). */
|
|
133
|
+
export function _resetPathCache(): void {
|
|
134
|
+
_cachedPath = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Binary resolution ────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a binary name to an absolute path using the enriched PATH.
|
|
141
|
+
* Returns null if the binary is not found anywhere.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveBinary(name: string): string | null {
|
|
144
|
+
if (name.startsWith("/")) {
|
|
145
|
+
return existsSync(name) ? name : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const enrichedPath = getEnrichedPath();
|
|
149
|
+
try {
|
|
150
|
+
const resolved = execSync(`which ${name.replace(/[^a-zA-Z0-9._@/-]/g, "")}`, {
|
|
151
|
+
|
|
152
|
+
encoding: "utf-8",
|
|
153
|
+
timeout: 5_000,
|
|
154
|
+
env: { ...process.env, PATH: enrichedPath },
|
|
155
|
+
}).trim();
|
|
156
|
+
return resolved || null;
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Returns a PATH string suitable for embedding in service definitions
|
|
164
|
+
* (plist/systemd unit). Captures the user's shell PATH at install time.
|
|
165
|
+
*/
|
|
166
|
+
export function getServicePath(): string {
|
|
167
|
+
return getEnrichedPath();
|
|
168
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { fetchPRInfoAsync, computeAdaptiveTTL, type GitHubPRInfo } from "./github-pr.js";
|
|
2
|
+
import type { WsBridge } from "./ws-bridge.js";
|
|
3
|
+
|
|
4
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface WatchedPR {
|
|
7
|
+
cwd: string;
|
|
8
|
+
branch: string;
|
|
9
|
+
/** Sessions interested in this PR (same cwd:branch may be shared) */
|
|
10
|
+
sessionIds: Set<string>;
|
|
11
|
+
lastData: GitHubPRInfo | null;
|
|
12
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
13
|
+
lastFetchTime: number;
|
|
14
|
+
currentInterval: number;
|
|
15
|
+
fetching: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── PR Poller ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Server-side poller that fetches GitHub PR status at adaptive intervals
|
|
22
|
+
* and pushes updates to browsers via WebSocket.
|
|
23
|
+
*
|
|
24
|
+
* One timer per unique cwd:branch — shared across sessions on the same branch.
|
|
25
|
+
*/
|
|
26
|
+
export class PRPoller {
|
|
27
|
+
private watched = new Map<string, WatchedPR>();
|
|
28
|
+
private wsBridge: WsBridge;
|
|
29
|
+
/** Reverse index: sessionId → cwd:branch key (a session can only watch one PR at a time) */
|
|
30
|
+
private sessionToKey = new Map<string, string>();
|
|
31
|
+
|
|
32
|
+
constructor(wsBridge: WsBridge) {
|
|
33
|
+
this.wsBridge = wsBridge;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start watching a PR for a session.
|
|
38
|
+
* Returns cached data immediately if available.
|
|
39
|
+
* Triggers an async fetch if cache is stale or missing.
|
|
40
|
+
*/
|
|
41
|
+
watch(sessionId: string, cwd: string, branch: string): GitHubPRInfo | null {
|
|
42
|
+
const key = `${cwd}:${branch}`;
|
|
43
|
+
|
|
44
|
+
// If this session was watching a different PR, unregister from the old one
|
|
45
|
+
const prevKey = this.sessionToKey.get(sessionId);
|
|
46
|
+
if (prevKey && prevKey !== key) {
|
|
47
|
+
this.unwatchKey(sessionId, prevKey);
|
|
48
|
+
}
|
|
49
|
+
this.sessionToKey.set(sessionId, key);
|
|
50
|
+
|
|
51
|
+
const existing = this.watched.get(key);
|
|
52
|
+
if (existing) {
|
|
53
|
+
existing.sessionIds.add(sessionId);
|
|
54
|
+
// If cache is stale, trigger a refresh
|
|
55
|
+
if (Date.now() - existing.lastFetchTime > existing.currentInterval) {
|
|
56
|
+
this.fetchAndBroadcast(key);
|
|
57
|
+
}
|
|
58
|
+
return existing.lastData;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// New watch — create entry and fetch immediately
|
|
62
|
+
const entry: WatchedPR = {
|
|
63
|
+
cwd,
|
|
64
|
+
branch,
|
|
65
|
+
sessionIds: new Set([sessionId]),
|
|
66
|
+
lastData: null,
|
|
67
|
+
timer: null,
|
|
68
|
+
lastFetchTime: 0,
|
|
69
|
+
currentInterval: 10_000, // start aggressive for fast initial load
|
|
70
|
+
fetching: false,
|
|
71
|
+
};
|
|
72
|
+
this.watched.set(key, entry);
|
|
73
|
+
this.fetchAndBroadcast(key);
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Stop watching for a specific session. */
|
|
79
|
+
unwatch(sessionId: string): void {
|
|
80
|
+
const key = this.sessionToKey.get(sessionId);
|
|
81
|
+
if (!key) return;
|
|
82
|
+
this.sessionToKey.delete(sessionId);
|
|
83
|
+
this.unwatchKey(sessionId, key);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Get current cached data for a cwd:branch pair (for REST fallback). */
|
|
87
|
+
getCached(cwd: string, branch: string): { available: boolean; pr: GitHubPRInfo | null } | null {
|
|
88
|
+
const key = `${cwd}:${branch}`;
|
|
89
|
+
const entry = this.watched.get(key);
|
|
90
|
+
if (!entry) return null;
|
|
91
|
+
return { available: true, pr: entry.lastData };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Stop all timers (for testing / cleanup). */
|
|
95
|
+
destroy(): void {
|
|
96
|
+
for (const entry of this.watched.values()) {
|
|
97
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
98
|
+
}
|
|
99
|
+
this.watched.clear();
|
|
100
|
+
this.sessionToKey.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Internal ──────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
private unwatchKey(sessionId: string, key: string): void {
|
|
106
|
+
const entry = this.watched.get(key);
|
|
107
|
+
if (!entry) return;
|
|
108
|
+
entry.sessionIds.delete(sessionId);
|
|
109
|
+
if (entry.sessionIds.size === 0) {
|
|
110
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
111
|
+
this.watched.delete(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async fetchAndBroadcast(key: string): Promise<void> {
|
|
116
|
+
const entry = this.watched.get(key);
|
|
117
|
+
if (!entry) return;
|
|
118
|
+
|
|
119
|
+
// Prevent concurrent fetches for the same key
|
|
120
|
+
if (entry.fetching) return;
|
|
121
|
+
entry.fetching = true;
|
|
122
|
+
|
|
123
|
+
// Clear existing timer (will be rescheduled after fetch)
|
|
124
|
+
if (entry.timer) {
|
|
125
|
+
clearTimeout(entry.timer);
|
|
126
|
+
entry.timer = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const prInfo = await fetchPRInfoAsync(entry.cwd, entry.branch);
|
|
131
|
+
// Re-check entry still exists (may have been unwatched during async fetch)
|
|
132
|
+
const current = this.watched.get(key);
|
|
133
|
+
if (!current) return;
|
|
134
|
+
|
|
135
|
+
current.lastData = prInfo;
|
|
136
|
+
current.lastFetchTime = Date.now();
|
|
137
|
+
current.currentInterval = computeAdaptiveTTL(prInfo);
|
|
138
|
+
|
|
139
|
+
// Push to all sessions watching this PR
|
|
140
|
+
for (const sessionId of current.sessionIds) {
|
|
141
|
+
this.wsBridge.broadcastToSession(sessionId, {
|
|
142
|
+
type: "pr_status_update",
|
|
143
|
+
pr: prInfo,
|
|
144
|
+
available: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// On error, use a moderate interval
|
|
149
|
+
const current = this.watched.get(key);
|
|
150
|
+
if (current) {
|
|
151
|
+
current.currentInterval = 30_000;
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
const current = this.watched.get(key);
|
|
155
|
+
if (current) {
|
|
156
|
+
current.fetching = false;
|
|
157
|
+
// Schedule next fetch
|
|
158
|
+
current.timer = setTimeout(() => this.fetchAndBroadcast(key), current.currentInterval);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|