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,328 @@
|
|
|
1
|
+
import { execSync, type ExecSyncOptionsWithStringEncoding } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Types
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export interface ContainerConfig {
|
|
8
|
+
/** Docker image to use (e.g. "campfire-dev", "node:22-slim") */
|
|
9
|
+
image: string;
|
|
10
|
+
/** Container ports to expose (e.g. [3000, 8080]) */
|
|
11
|
+
ports: number[];
|
|
12
|
+
/** Extra volume mounts in "host:container[:opts]" format */
|
|
13
|
+
volumes?: string[];
|
|
14
|
+
/** Extra env vars to inject into the container */
|
|
15
|
+
env?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PortMapping {
|
|
19
|
+
containerPort: number;
|
|
20
|
+
hostPort: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ContainerInfo {
|
|
24
|
+
containerId: string;
|
|
25
|
+
name: string;
|
|
26
|
+
image: string;
|
|
27
|
+
portMappings: PortMapping[];
|
|
28
|
+
hostCwd: string;
|
|
29
|
+
containerCwd: string;
|
|
30
|
+
state: "creating" | "running" | "stopped" | "removed";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const EXEC_OPTS: ExecSyncOptionsWithStringEncoding = {
|
|
38
|
+
encoding: "utf-8",
|
|
39
|
+
timeout: 30_000,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function exec(cmd: string, opts?: ExecSyncOptionsWithStringEncoding): string {
|
|
43
|
+
return execSync(cmd, { ...EXEC_OPTS, ...opts }).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// ContainerManager
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export class ContainerManager {
|
|
51
|
+
private containers = new Map<string, ContainerInfo>();
|
|
52
|
+
|
|
53
|
+
/** Check whether Docker daemon is reachable. */
|
|
54
|
+
checkDocker(): boolean {
|
|
55
|
+
try {
|
|
56
|
+
exec("docker info --format '{{.ServerVersion}}'");
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Return Docker version string, or null if unavailable. */
|
|
64
|
+
getDockerVersion(): string | null {
|
|
65
|
+
try {
|
|
66
|
+
return exec("docker version --format '{{.Server.Version}}'");
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** List images available locally. Returns image:tag strings. */
|
|
73
|
+
listImages(): string[] {
|
|
74
|
+
try {
|
|
75
|
+
const raw = exec("docker images --format '{{.Repository}}:{{.Tag}}'");
|
|
76
|
+
if (!raw) return [];
|
|
77
|
+
return raw
|
|
78
|
+
.split("\n")
|
|
79
|
+
.filter((l) => l && !l.startsWith("<none>"))
|
|
80
|
+
.sort();
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Check if a specific image exists locally. */
|
|
87
|
+
imageExists(image: string): boolean {
|
|
88
|
+
try {
|
|
89
|
+
exec(`docker image inspect ${shellEscape(image)}`);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create and start a container for a session.
|
|
98
|
+
*
|
|
99
|
+
* - Mounts `~/.claude` read-only (auth)
|
|
100
|
+
* - Mounts `hostCwd` at `/workspace`
|
|
101
|
+
* - Publishes requested ports with auto-assigned host ports (`-p 0:PORT`)
|
|
102
|
+
*/
|
|
103
|
+
createContainer(
|
|
104
|
+
sessionId: string,
|
|
105
|
+
hostCwd: string,
|
|
106
|
+
config: ContainerConfig,
|
|
107
|
+
): ContainerInfo {
|
|
108
|
+
const name = `campfire-${sessionId.slice(0, 8)}`;
|
|
109
|
+
const homedir = process.env.HOME || process.env.USERPROFILE || "/root";
|
|
110
|
+
|
|
111
|
+
// Validate port numbers
|
|
112
|
+
for (const port of config.ports) {
|
|
113
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
114
|
+
throw new Error(`Invalid port number: ${port} (must be 1-65535)`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Build docker create args
|
|
119
|
+
const args: string[] = [
|
|
120
|
+
"docker", "create",
|
|
121
|
+
"--name", name,
|
|
122
|
+
// Ensure host.docker.internal resolves (automatic on Mac/Win Docker
|
|
123
|
+
// Desktop, but required explicitly on Linux)
|
|
124
|
+
"--add-host=host.docker.internal:host-gateway",
|
|
125
|
+
// Note: auth is seeded via `docker cp` (see claude-container-auth.ts /
|
|
126
|
+
// codex-container-auth.ts) rather than a bind mount, so the container
|
|
127
|
+
// gets a writable snapshot and cannot mutate the host's ~/.claude.
|
|
128
|
+
"-v", `${hostCwd}:/workspace`,
|
|
129
|
+
"-w", "/workspace",
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// Port mappings: -p 0:{containerPort}
|
|
133
|
+
for (const port of config.ports) {
|
|
134
|
+
args.push("-p", `0:${port}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Extra volumes
|
|
138
|
+
if (config.volumes) {
|
|
139
|
+
for (const vol of config.volumes) {
|
|
140
|
+
args.push("-v", vol);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Environment variables
|
|
145
|
+
if (config.env) {
|
|
146
|
+
for (const [k, v] of Object.entries(config.env)) {
|
|
147
|
+
args.push("-e", `${k}=${v}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Image + default command (keep container alive)
|
|
152
|
+
args.push(config.image, "sleep", "infinity");
|
|
153
|
+
|
|
154
|
+
const info: ContainerInfo = {
|
|
155
|
+
containerId: "",
|
|
156
|
+
name,
|
|
157
|
+
image: config.image,
|
|
158
|
+
portMappings: [],
|
|
159
|
+
hostCwd,
|
|
160
|
+
containerCwd: "/workspace",
|
|
161
|
+
state: "creating",
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// Create
|
|
166
|
+
const containerId = exec(args.map(shellEscape).join(" "));
|
|
167
|
+
info.containerId = containerId;
|
|
168
|
+
|
|
169
|
+
// Start
|
|
170
|
+
exec(`docker start ${shellEscape(containerId)}`);
|
|
171
|
+
info.state = "running";
|
|
172
|
+
|
|
173
|
+
// Resolve actual port mappings
|
|
174
|
+
info.portMappings = this.resolvePortMappings(containerId, config.ports);
|
|
175
|
+
|
|
176
|
+
this.containers.set(sessionId, info);
|
|
177
|
+
console.log(
|
|
178
|
+
`[container-manager] Created container ${name} (${containerId.slice(0, 12)}) ` +
|
|
179
|
+
`ports: ${info.portMappings.map((p) => `${p.containerPort}->${p.hostPort}`).join(", ")}`,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
return info;
|
|
183
|
+
} catch (e) {
|
|
184
|
+
// Cleanup partial creation
|
|
185
|
+
try { exec(`docker rm -f ${shellEscape(name)}`); } catch { /* ignore */ }
|
|
186
|
+
info.state = "removed";
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Failed to create container: ${e instanceof Error ? e.message : String(e)}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Parse `docker port` output to get host port mappings. */
|
|
194
|
+
private resolvePortMappings(containerId: string, ports: number[]): PortMapping[] {
|
|
195
|
+
const mappings: PortMapping[] = [];
|
|
196
|
+
for (const containerPort of ports) {
|
|
197
|
+
try {
|
|
198
|
+
const raw = exec(
|
|
199
|
+
`docker port ${shellEscape(containerId)} ${containerPort}`,
|
|
200
|
+
);
|
|
201
|
+
// Output like "0.0.0.0:49152" or "[::]:49152"
|
|
202
|
+
const match = raw.match(/:(\d+)$/m);
|
|
203
|
+
if (match) {
|
|
204
|
+
mappings.push({
|
|
205
|
+
containerPort,
|
|
206
|
+
hostPort: parseInt(match[1], 10),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
console.warn(
|
|
211
|
+
`[container-manager] Could not resolve port ${containerPort} for ${containerId.slice(0, 12)}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return mappings;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Re-track a container under a new key (e.g. when the real sessionId
|
|
220
|
+
* is assigned after container creation).
|
|
221
|
+
*/
|
|
222
|
+
retrack(containerId: string, newSessionId: string): void {
|
|
223
|
+
for (const [oldKey, info] of this.containers) {
|
|
224
|
+
if (info.containerId === containerId) {
|
|
225
|
+
this.containers.delete(oldKey);
|
|
226
|
+
this.containers.set(newSessionId, info);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Stop and remove a container. */
|
|
233
|
+
removeContainer(sessionId: string): void {
|
|
234
|
+
const info = this.containers.get(sessionId);
|
|
235
|
+
if (!info) return;
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
exec(`docker rm -f ${shellEscape(info.containerId)}`);
|
|
239
|
+
info.state = "removed";
|
|
240
|
+
console.log(
|
|
241
|
+
`[container-manager] Removed container ${info.name} (${info.containerId.slice(0, 12)})`,
|
|
242
|
+
);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
console.warn(
|
|
245
|
+
`[container-manager] Failed to remove container ${info.name}:`,
|
|
246
|
+
e instanceof Error ? e.message : String(e),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
this.containers.delete(sessionId);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Get container info for a session. */
|
|
253
|
+
getContainer(sessionId: string): ContainerInfo | undefined {
|
|
254
|
+
return this.containers.get(sessionId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** List all tracked containers. */
|
|
258
|
+
listContainers(): ContainerInfo[] {
|
|
259
|
+
return Array.from(this.containers.values());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Re-register a container that was persisted across a server restart.
|
|
264
|
+
* Verifies the container still exists in Docker before tracking it.
|
|
265
|
+
*/
|
|
266
|
+
restoreContainer(sessionId: string, info: ContainerInfo): boolean {
|
|
267
|
+
try {
|
|
268
|
+
const state = exec(
|
|
269
|
+
`docker inspect --format '{{.State.Running}}' ${shellEscape(info.containerId)}`,
|
|
270
|
+
);
|
|
271
|
+
if (state === "true") {
|
|
272
|
+
info.state = "running";
|
|
273
|
+
} else {
|
|
274
|
+
info.state = "stopped";
|
|
275
|
+
}
|
|
276
|
+
this.containers.set(sessionId, info);
|
|
277
|
+
console.log(
|
|
278
|
+
`[container-manager] Restored container ${info.name} (${info.containerId.slice(0, 12)}) state=${info.state}`,
|
|
279
|
+
);
|
|
280
|
+
return true;
|
|
281
|
+
} catch {
|
|
282
|
+
// Container no longer exists in Docker
|
|
283
|
+
console.warn(
|
|
284
|
+
`[container-manager] Container ${info.name} (${info.containerId.slice(0, 12)}) no longer exists, skipping restore`,
|
|
285
|
+
);
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build the campfire-dev Docker image from the Dockerfile.dev.
|
|
292
|
+
* Returns the build output log. Throws on failure.
|
|
293
|
+
*/
|
|
294
|
+
buildImage(dockerfilePath: string, tag: string = "campfire-dev:latest"): string {
|
|
295
|
+
const contextDir = dockerfilePath.replace(/\/[^/]+$/, "") || ".";
|
|
296
|
+
try {
|
|
297
|
+
const output = exec(
|
|
298
|
+
`docker build -t ${shellEscape(tag)} -f ${shellEscape(dockerfilePath)} ${shellEscape(contextDir)}`,
|
|
299
|
+
{ encoding: "utf-8", timeout: 300_000 }, // 5 min for image builds
|
|
300
|
+
);
|
|
301
|
+
console.log(`[container-manager] Built image ${tag}`);
|
|
302
|
+
return output;
|
|
303
|
+
} catch (e) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Failed to build image ${tag}: ${e instanceof Error ? e.message : String(e)}`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Clean up all tracked containers (e.g. on server shutdown). */
|
|
311
|
+
cleanupAll(): void {
|
|
312
|
+
for (const [sessionId] of this.containers) {
|
|
313
|
+
this.removeContainer(sessionId);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Shell escape helper
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function shellEscape(s: string): string {
|
|
323
|
+
if (/^[a-zA-Z0-9._\-/:=@]+$/.test(s)) return s;
|
|
324
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Singleton
|
|
328
|
+
export const containerManager = new ContainerManager();
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { Cron } from "croner";
|
|
2
|
+
import type { CronJob, CronJobExecution } from "./cron-types.js";
|
|
3
|
+
import type { CliLauncher, SdkSessionInfo } from "./cli-launcher.js";
|
|
4
|
+
import type { WsBridge } from "./ws-bridge.js";
|
|
5
|
+
import * as cronStore from "./cron-store.js";
|
|
6
|
+
import * as envManager from "./env-manager.js";
|
|
7
|
+
import * as sessionNames from "./session-names.js";
|
|
8
|
+
|
|
9
|
+
/** Max consecutive failures before auto-disabling a job */
|
|
10
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
11
|
+
/** Max time to wait for CLI to connect (ms) */
|
|
12
|
+
const CLI_CONNECT_TIMEOUT_MS = 30_000;
|
|
13
|
+
/** Poll interval when waiting for CLI connection */
|
|
14
|
+
const CLI_CONNECT_POLL_MS = 500;
|
|
15
|
+
|
|
16
|
+
export class CronScheduler {
|
|
17
|
+
private timers = new Map<string, Cron>();
|
|
18
|
+
private launcher: CliLauncher;
|
|
19
|
+
private wsBridge: WsBridge;
|
|
20
|
+
/** In-memory execution history (last N per job) */
|
|
21
|
+
private executions = new Map<string, CronJobExecution[]>();
|
|
22
|
+
private static readonly MAX_EXECUTIONS_PER_JOB = 50;
|
|
23
|
+
|
|
24
|
+
constructor(launcher: CliLauncher, wsBridge: WsBridge) {
|
|
25
|
+
this.launcher = launcher;
|
|
26
|
+
this.wsBridge = wsBridge;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Start all enabled jobs from disk. Called once at server startup. */
|
|
30
|
+
startAll(): void {
|
|
31
|
+
const jobs = cronStore.listJobs();
|
|
32
|
+
let started = 0;
|
|
33
|
+
for (const job of jobs) {
|
|
34
|
+
if (job.enabled) {
|
|
35
|
+
this.scheduleJob(job);
|
|
36
|
+
started++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (started > 0) {
|
|
40
|
+
console.log(`[cron-scheduler] Started ${started} cron job(s)`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Schedule (or reschedule) a single job. */
|
|
45
|
+
scheduleJob(job: CronJob): void {
|
|
46
|
+
this.stopJob(job.id);
|
|
47
|
+
|
|
48
|
+
if (!job.enabled) return;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (job.recurring) {
|
|
52
|
+
const cronTask = new Cron(job.schedule, {}, () => {
|
|
53
|
+
this.executeJob(job.id).catch((err) => {
|
|
54
|
+
console.error(`[cron-scheduler] Unhandled error in job "${job.name}":`, err);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
this.timers.set(job.id, cronTask);
|
|
58
|
+
console.log(`[cron-scheduler] Scheduled "${job.name}" with cron "${job.schedule}"`);
|
|
59
|
+
} else {
|
|
60
|
+
// One-shot: schedule for the specified datetime
|
|
61
|
+
const targetTime = new Date(job.schedule);
|
|
62
|
+
if (targetTime.getTime() > Date.now()) {
|
|
63
|
+
const cronTask = new Cron(targetTime, () => {
|
|
64
|
+
this.executeJob(job.id)
|
|
65
|
+
.then(() => {
|
|
66
|
+
// Auto-disable after one-shot execution
|
|
67
|
+
cronStore.updateJob(job.id, { enabled: false });
|
|
68
|
+
this.timers.delete(job.id);
|
|
69
|
+
})
|
|
70
|
+
.catch((err) => {
|
|
71
|
+
console.error(`[cron-scheduler] Unhandled error in one-shot job "${job.name}":`, err);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
this.timers.set(job.id, cronTask);
|
|
75
|
+
console.log(`[cron-scheduler] Scheduled one-shot "${job.name}" at ${targetTime.toISOString()}`);
|
|
76
|
+
} else {
|
|
77
|
+
console.log(`[cron-scheduler] Skipping one-shot "${job.name}" — target time is in the past`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error(`[cron-scheduler] Failed to schedule "${job.name}":`, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Stop a job's timer. */
|
|
86
|
+
stopJob(jobId: string): void {
|
|
87
|
+
const timer = this.timers.get(jobId);
|
|
88
|
+
if (timer) {
|
|
89
|
+
timer.stop();
|
|
90
|
+
this.timers.delete(jobId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Execute a job: create a session, send the prompt, track the result. */
|
|
95
|
+
async executeJob(jobId: string, opts?: { force?: boolean }): Promise<void> {
|
|
96
|
+
const job = cronStore.getJob(jobId);
|
|
97
|
+
if (!job) return;
|
|
98
|
+
if (!job.enabled && !opts?.force) return;
|
|
99
|
+
|
|
100
|
+
// Overlap prevention: skip if previous execution is still running
|
|
101
|
+
if (job.lastSessionId && this.launcher.isAlive(job.lastSessionId)) {
|
|
102
|
+
console.log(`[cron-scheduler] Skipping "${job.name}" — previous execution still running (${job.lastSessionId})`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log(`[cron-scheduler] Executing job "${job.name}" (${jobId})`);
|
|
107
|
+
|
|
108
|
+
const execution: CronJobExecution = {
|
|
109
|
+
sessionId: "",
|
|
110
|
+
jobId,
|
|
111
|
+
startedAt: Date.now(),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// Resolve environment variables
|
|
116
|
+
let envVars: Record<string, string> | undefined;
|
|
117
|
+
if (job.envSlug) {
|
|
118
|
+
const env = envManager.getEnv(job.envSlug);
|
|
119
|
+
if (env) envVars = env.variables;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Launch the session via CliLauncher
|
|
123
|
+
// For Codex, explicitly set sandbox and internet access for full autonomy
|
|
124
|
+
const sessionInfo = this.launcher.launch({
|
|
125
|
+
model: job.model,
|
|
126
|
+
permissionMode: job.permissionMode,
|
|
127
|
+
cwd: job.cwd,
|
|
128
|
+
env: envVars,
|
|
129
|
+
backendType: job.backendType,
|
|
130
|
+
codexInternetAccess: job.backendType === "codex" ? (job.codexInternetAccess ?? true) : undefined,
|
|
131
|
+
codexSandbox: job.backendType === "codex"
|
|
132
|
+
? (job.permissionMode === "bypassPermissions" ? "danger-full-access" : "workspace-write")
|
|
133
|
+
: undefined,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
execution.sessionId = sessionInfo.sessionId;
|
|
137
|
+
|
|
138
|
+
// Tag the session as cron-originated
|
|
139
|
+
sessionInfo.cronJobId = jobId;
|
|
140
|
+
sessionInfo.cronJobName = job.name;
|
|
141
|
+
|
|
142
|
+
// Set the session name
|
|
143
|
+
const runLabel = `⏰ ${job.name}`;
|
|
144
|
+
sessionNames.setName(sessionInfo.sessionId, runLabel);
|
|
145
|
+
|
|
146
|
+
// Wait for CLI to connect, then send the prompt
|
|
147
|
+
await this.waitForCLIConnection(sessionInfo.sessionId);
|
|
148
|
+
|
|
149
|
+
// Send the prompt with cron prefix for traceability
|
|
150
|
+
const fullPrompt = `[cron:${job.id} ${job.name}]\n\n${job.prompt}`;
|
|
151
|
+
this.wsBridge.injectUserMessage(sessionInfo.sessionId, fullPrompt);
|
|
152
|
+
|
|
153
|
+
// Update job tracking
|
|
154
|
+
cronStore.updateJob(jobId, {
|
|
155
|
+
lastRunAt: Date.now(),
|
|
156
|
+
lastSessionId: sessionInfo.sessionId,
|
|
157
|
+
totalRuns: job.totalRuns + 1,
|
|
158
|
+
consecutiveFailures: 0,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
execution.success = true;
|
|
162
|
+
this.addExecution(jobId, execution);
|
|
163
|
+
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error(`[cron-scheduler] Job "${job.name}" failed:`, err);
|
|
166
|
+
execution.error = err instanceof Error ? err.message : String(err);
|
|
167
|
+
execution.completedAt = Date.now();
|
|
168
|
+
this.addExecution(jobId, execution);
|
|
169
|
+
|
|
170
|
+
const failures = job.consecutiveFailures + 1;
|
|
171
|
+
const updates: Partial<CronJob> = {
|
|
172
|
+
consecutiveFailures: failures,
|
|
173
|
+
lastRunAt: Date.now(),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Auto-disable after too many failures
|
|
177
|
+
if (failures >= MAX_CONSECUTIVE_FAILURES) {
|
|
178
|
+
updates.enabled = false;
|
|
179
|
+
this.stopJob(jobId);
|
|
180
|
+
console.warn(`[cron-scheduler] Job "${job.name}" disabled after ${failures} consecutive failures`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
cronStore.updateJob(jobId, updates);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Manual trigger (run now regardless of schedule, bypasses enabled check). */
|
|
188
|
+
executeJobManually(jobId: string): void {
|
|
189
|
+
this.executeJob(jobId, { force: true }).catch((err) => {
|
|
190
|
+
console.error(`[cron-scheduler] Manual execution of job "${jobId}" failed:`, err);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Wait for CLI to be connected (poll up to timeout). */
|
|
195
|
+
private async waitForCLIConnection(sessionId: string): Promise<void> {
|
|
196
|
+
const start = Date.now();
|
|
197
|
+
|
|
198
|
+
while (Date.now() - start < CLI_CONNECT_TIMEOUT_MS) {
|
|
199
|
+
const info = this.launcher.getSession(sessionId);
|
|
200
|
+
if (info && (info.state === "connected" || info.state === "running")) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (info?.state === "exited") {
|
|
204
|
+
throw new Error(`CLI process exited before connecting (exit code: ${info.exitCode})`);
|
|
205
|
+
}
|
|
206
|
+
await new Promise((r) => setTimeout(r, CLI_CONNECT_POLL_MS));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
throw new Error(`CLI process did not connect within ${CLI_CONNECT_TIMEOUT_MS / 1000}s`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Get next run time for a job. */
|
|
213
|
+
getNextRunTime(jobId: string): Date | null {
|
|
214
|
+
const timer = this.timers.get(jobId);
|
|
215
|
+
if (!timer) return null;
|
|
216
|
+
return timer.nextRun() || null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Get recent executions for a job. */
|
|
220
|
+
getExecutions(jobId: string): CronJobExecution[] {
|
|
221
|
+
return this.executions.get(jobId) || [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private addExecution(jobId: string, execution: CronJobExecution): void {
|
|
225
|
+
if (!this.executions.has(jobId)) {
|
|
226
|
+
this.executions.set(jobId, []);
|
|
227
|
+
}
|
|
228
|
+
const list = this.executions.get(jobId)!;
|
|
229
|
+
list.push(execution);
|
|
230
|
+
if (list.length > CronScheduler.MAX_EXECUTIONS_PER_JOB) {
|
|
231
|
+
list.splice(0, list.length - CronScheduler.MAX_EXECUTIONS_PER_JOB);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Stop all timers (for graceful shutdown). */
|
|
236
|
+
destroy(): void {
|
|
237
|
+
for (const timer of this.timers.values()) {
|
|
238
|
+
timer.stop();
|
|
239
|
+
}
|
|
240
|
+
this.timers.clear();
|
|
241
|
+
this.executions.clear();
|
|
242
|
+
}
|
|
243
|
+
}
|