@virtengine/openfleet 0.25.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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { resolvePromptTemplate } from "./agent-prompts.mjs";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_AUTO_RESOLVE_THEIRS = [
|
|
4
|
+
"pnpm-lock.yaml",
|
|
5
|
+
"package-lock.json",
|
|
6
|
+
"yarn.lock",
|
|
7
|
+
"go.sum",
|
|
8
|
+
];
|
|
9
|
+
const DEFAULT_AUTO_RESOLVE_OURS = [
|
|
10
|
+
"CHANGELOG.md",
|
|
11
|
+
"coverage.txt",
|
|
12
|
+
"results.txt",
|
|
13
|
+
];
|
|
14
|
+
const DEFAULT_AUTO_RESOLVE_LOCK_EXTENSIONS = [".lock"];
|
|
15
|
+
|
|
16
|
+
export const DIRTY_TASK_DEFAULTS = {
|
|
17
|
+
maxAgeHours: 24,
|
|
18
|
+
minCountToReserve: 1,
|
|
19
|
+
maxCandidates: 5,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function normalizeTimestamp(value) {
|
|
23
|
+
if (!value) return null;
|
|
24
|
+
const time = new Date(value).getTime();
|
|
25
|
+
return Number.isFinite(time) ? time : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function classifyConflictedFiles(files) {
|
|
29
|
+
const manualFiles = [];
|
|
30
|
+
const strategies = [];
|
|
31
|
+
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
const fileName = file.split("/").pop();
|
|
34
|
+
let strategy = null;
|
|
35
|
+
|
|
36
|
+
if (DEFAULT_AUTO_RESOLVE_THEIRS.includes(fileName)) {
|
|
37
|
+
strategy = "theirs";
|
|
38
|
+
} else if (DEFAULT_AUTO_RESOLVE_OURS.includes(fileName)) {
|
|
39
|
+
strategy = "ours";
|
|
40
|
+
} else if (
|
|
41
|
+
DEFAULT_AUTO_RESOLVE_LOCK_EXTENSIONS.some((ext) => fileName.endsWith(ext))
|
|
42
|
+
) {
|
|
43
|
+
strategy = "theirs";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (strategy) {
|
|
47
|
+
strategies.push(`${fileName}→${strategy}`);
|
|
48
|
+
} else {
|
|
49
|
+
manualFiles.push(file);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
allResolvable: manualFiles.length === 0,
|
|
55
|
+
manualFiles,
|
|
56
|
+
summary: strategies.join(", ") || "none",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getDirtyTasks(attempts = [], opts = {}) {
|
|
61
|
+
if (!Array.isArray(attempts)) return [];
|
|
62
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
63
|
+
const maxAgeMs =
|
|
64
|
+
(opts.maxAgeHours ?? DIRTY_TASK_DEFAULTS.maxAgeHours) * 60 * 60 * 1000;
|
|
65
|
+
|
|
66
|
+
return attempts.filter((attempt) => {
|
|
67
|
+
if (!attempt || !attempt.branch) return false;
|
|
68
|
+
const updatedAt =
|
|
69
|
+
normalizeTimestamp(attempt.updated_at) ??
|
|
70
|
+
normalizeTimestamp(attempt.updatedAt) ??
|
|
71
|
+
normalizeTimestamp(attempt.last_process_completed_at);
|
|
72
|
+
if (!updatedAt) return false;
|
|
73
|
+
return nowMs - updatedAt <= maxAgeMs;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function prioritizeDirtyTasks(tasks = [], opts = {}) {
|
|
78
|
+
if (!Array.isArray(tasks)) return [];
|
|
79
|
+
const maxCandidates = opts.maxCandidates ?? DIRTY_TASK_DEFAULTS.maxCandidates;
|
|
80
|
+
|
|
81
|
+
return [...tasks]
|
|
82
|
+
.sort((a, b) => {
|
|
83
|
+
const aPriority = Number(a?.priority ?? 0);
|
|
84
|
+
const bPriority = Number(b?.priority ?? 0);
|
|
85
|
+
if (aPriority !== bPriority) return bPriority - aPriority;
|
|
86
|
+
const aUpdated =
|
|
87
|
+
normalizeTimestamp(a?.updated_at) ??
|
|
88
|
+
normalizeTimestamp(a?.updatedAt) ??
|
|
89
|
+
0;
|
|
90
|
+
const bUpdated =
|
|
91
|
+
normalizeTimestamp(b?.updated_at) ??
|
|
92
|
+
normalizeTimestamp(b?.updatedAt) ??
|
|
93
|
+
0;
|
|
94
|
+
return bUpdated - aUpdated;
|
|
95
|
+
})
|
|
96
|
+
.slice(0, maxCandidates);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function shouldReserveDirtySlot(tasks = [], opts = {}) {
|
|
100
|
+
const minCount =
|
|
101
|
+
opts.minCountToReserve ?? DIRTY_TASK_DEFAULTS.minCountToReserve;
|
|
102
|
+
return Array.isArray(tasks) && tasks.length >= minCount;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getDirtySlotReservation(tasks = [], opts = {}) {
|
|
106
|
+
return {
|
|
107
|
+
reserved: shouldReserveDirtySlot(tasks, opts),
|
|
108
|
+
count: Array.isArray(tasks) ? tasks.length : 0,
|
|
109
|
+
reason: Array.isArray(tasks) && tasks.length > 0 ? "dirty-tasks" : "none",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function buildConflictResolutionPrompt({
|
|
114
|
+
conflictFiles = [],
|
|
115
|
+
upstreamBranch = "origin/main",
|
|
116
|
+
template = "",
|
|
117
|
+
} = {}) {
|
|
118
|
+
const files = Array.isArray(conflictFiles) ? conflictFiles : [];
|
|
119
|
+
const classification = classifyConflictedFiles(files);
|
|
120
|
+
const lines = [
|
|
121
|
+
`Conflicts detected while rebasing onto ${upstreamBranch}.`,
|
|
122
|
+
`Auto-resolve summary: ${classification.summary}.`,
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
if (classification.manualFiles.length) {
|
|
126
|
+
lines.push("Manual conflicts remain:");
|
|
127
|
+
lines.push(...classification.manualFiles.map((file) => `- ${file}`));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
lines.push(
|
|
131
|
+
`Use 'git checkout --theirs <file>' for lockfiles and 'git checkout --ours <file>' for CHANGELOG.md/coverage.txt/results.txt.`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const fallback = lines.join("\n");
|
|
135
|
+
const manualSection = classification.manualFiles.length
|
|
136
|
+
? ["Manual conflicts remain:", ...classification.manualFiles.map((f) => `- ${f}`)].join("\n")
|
|
137
|
+
: "Manual conflicts remain: none";
|
|
138
|
+
return resolvePromptTemplate(
|
|
139
|
+
template,
|
|
140
|
+
{
|
|
141
|
+
UPSTREAM_BRANCH: upstreamBranch,
|
|
142
|
+
AUTO_RESOLVE_SUMMARY: classification.summary,
|
|
143
|
+
MANUAL_CONFLICTS_SECTION: manualSection,
|
|
144
|
+
},
|
|
145
|
+
fallback,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function isFileOverlapWithDirtyPR(files = [], dirtyFiles = []) {
|
|
150
|
+
const left = new Set((files || []).map((file) => file.toLowerCase()));
|
|
151
|
+
return (dirtyFiles || []).some((file) =>
|
|
152
|
+
left.has(String(file).toLowerCase()),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── In-memory dirty task registry ────────────────────────────────────────────
|
|
157
|
+
// Tracks tasks whose PRs have merge conflicts so the monitor can reserve
|
|
158
|
+
// executor slots for conflict resolution and avoid file-overlap collisions.
|
|
159
|
+
|
|
160
|
+
const _dirtyTaskRegistry = new Map();
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Register a task as "dirty" (PR has merge conflicts).
|
|
164
|
+
* @param {{ taskId: string, prNumber?: number, branch?: string, title?: string, files?: string[] }} entry
|
|
165
|
+
*/
|
|
166
|
+
export function registerDirtyTask({
|
|
167
|
+
taskId,
|
|
168
|
+
prNumber,
|
|
169
|
+
branch,
|
|
170
|
+
title,
|
|
171
|
+
files,
|
|
172
|
+
} = {}) {
|
|
173
|
+
if (!taskId) return;
|
|
174
|
+
_dirtyTaskRegistry.set(taskId, {
|
|
175
|
+
taskId,
|
|
176
|
+
prNumber: prNumber ?? null,
|
|
177
|
+
branch: branch ?? null,
|
|
178
|
+
title: title ?? "",
|
|
179
|
+
files: Array.isArray(files) ? files : [],
|
|
180
|
+
registeredAt: Date.now(),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Remove a task from the dirty registry (e.g. after successful merge or resolution).
|
|
186
|
+
* @param {string} taskId
|
|
187
|
+
*/
|
|
188
|
+
export function clearDirtyTask(taskId) {
|
|
189
|
+
_dirtyTaskRegistry.delete(taskId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Check whether a task is currently registered as dirty.
|
|
194
|
+
* @param {string} taskId
|
|
195
|
+
* @returns {boolean}
|
|
196
|
+
*/
|
|
197
|
+
export function isDirtyTask(taskId) {
|
|
198
|
+
return _dirtyTaskRegistry.has(taskId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Return a HIGH complexity tier override for dirty/conflict tasks.
|
|
203
|
+
* @returns {{ tier: string, reason: string }}
|
|
204
|
+
*/
|
|
205
|
+
export function getHighTierForDirty() {
|
|
206
|
+
return { tier: "HIGH", reason: "dirty-conflict-override" };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Resolution cooldown tracking ─────────────────────────────────────────────
|
|
210
|
+
// Prevents the monitor from re-triggering conflict resolution too quickly
|
|
211
|
+
// for the same task.
|
|
212
|
+
|
|
213
|
+
const _resolutionAttempts = new Map();
|
|
214
|
+
const RESOLUTION_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Record that a conflict-resolution attempt was made for a task.
|
|
218
|
+
* @param {string} taskId
|
|
219
|
+
*/
|
|
220
|
+
export function recordResolutionAttempt(taskId) {
|
|
221
|
+
_resolutionAttempts.set(taskId, Date.now());
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Check whether a task is still within the resolution cooldown window.
|
|
226
|
+
* @param {string} taskId
|
|
227
|
+
* @param {{ cooldownMs?: number }} opts
|
|
228
|
+
* @returns {boolean}
|
|
229
|
+
*/
|
|
230
|
+
export function isOnResolutionCooldown(taskId, opts = {}) {
|
|
231
|
+
const lastAttempt = _resolutionAttempts.get(taskId);
|
|
232
|
+
if (!lastAttempt) return false;
|
|
233
|
+
const cooldown = opts.cooldownMs ?? RESOLUTION_COOLDOWN_MS;
|
|
234
|
+
return Date.now() - lastAttempt < cooldown;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Return a human-readable summary of the current dirty task state.
|
|
239
|
+
* @returns {string}
|
|
240
|
+
*/
|
|
241
|
+
export function formatDirtyTaskSummary() {
|
|
242
|
+
const count = _dirtyTaskRegistry.size;
|
|
243
|
+
if (count === 0) return "Dirty tasks: 0";
|
|
244
|
+
const entries = [..._dirtyTaskRegistry.values()]
|
|
245
|
+
.map((e) => `${e.title || e.taskId} (PR #${e.prNumber ?? "?"})`)
|
|
246
|
+
.join(", ");
|
|
247
|
+
return `Dirty tasks: ${count} — ${entries}`;
|
|
248
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* container-runner.mjs — Optional container isolation for agent execution.
|
|
3
|
+
*
|
|
4
|
+
* When CONTAINER_ENABLED=1, agent tasks run inside Docker containers for
|
|
5
|
+
* security isolation. Inspired by nanoclaw's Apple Container architecture
|
|
6
|
+
* but using Docker (cross-platform: Linux, macOS, Windows).
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Docker container isolation for agent execution
|
|
10
|
+
* - Volume mount security (allowlist-based)
|
|
11
|
+
* - Configurable timeouts and resource limits
|
|
12
|
+
* - Output streaming via sentinel markers
|
|
13
|
+
* - Graceful shutdown with container cleanup
|
|
14
|
+
*
|
|
15
|
+
* The container mounts the workspace read-only and a scratch directory
|
|
16
|
+
* read-write, then runs the agent inside the container.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawn, execSync } from "node:child_process";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { resolve, basename } from "node:path";
|
|
22
|
+
|
|
23
|
+
// ── Configuration ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const containerEnabled = ["1", "true", "yes"].includes(
|
|
26
|
+
String(process.env.CONTAINER_ENABLED || "").toLowerCase(),
|
|
27
|
+
);
|
|
28
|
+
const containerRuntime = process.env.CONTAINER_RUNTIME || "docker"; // docker | podman | container (macOS)
|
|
29
|
+
const containerImage = process.env.CONTAINER_IMAGE || "node:22-slim";
|
|
30
|
+
const containerTimeout = parseInt(
|
|
31
|
+
process.env.CONTAINER_TIMEOUT_MS || "1800000",
|
|
32
|
+
10,
|
|
33
|
+
); // 30 min default
|
|
34
|
+
const containerMaxOutput = parseInt(
|
|
35
|
+
process.env.CONTAINER_MAX_OUTPUT_SIZE || "10485760",
|
|
36
|
+
10,
|
|
37
|
+
); // 10MB
|
|
38
|
+
const maxConcurrentContainers = Math.max(
|
|
39
|
+
1,
|
|
40
|
+
parseInt(process.env.MAX_CONCURRENT_CONTAINERS || "3", 10),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Sentinel markers for output parsing (protocol compatible with nanoclaw)
|
|
44
|
+
const OUTPUT_START_MARKER = "---CODEXMON_OUTPUT_START---";
|
|
45
|
+
const OUTPUT_END_MARKER = "---CODEXMON_OUTPUT_END---";
|
|
46
|
+
|
|
47
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const activeContainers = new Map(); // containerName → { proc, startTime, taskId }
|
|
50
|
+
let containerIdCounter = 0;
|
|
51
|
+
|
|
52
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if container mode is enabled and runtime is available.
|
|
56
|
+
*/
|
|
57
|
+
export function isContainerEnabled() {
|
|
58
|
+
return containerEnabled;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get container subsystem status.
|
|
63
|
+
*/
|
|
64
|
+
export function getContainerStatus() {
|
|
65
|
+
return {
|
|
66
|
+
enabled: containerEnabled,
|
|
67
|
+
runtime: containerRuntime,
|
|
68
|
+
image: containerImage,
|
|
69
|
+
timeout: containerTimeout,
|
|
70
|
+
maxConcurrent: maxConcurrentContainers,
|
|
71
|
+
active: activeContainers.size,
|
|
72
|
+
containers: [...activeContainers.entries()].map(([name, info]) => ({
|
|
73
|
+
name,
|
|
74
|
+
taskId: info.taskId,
|
|
75
|
+
uptime: Date.now() - info.startTime,
|
|
76
|
+
})),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if the container runtime is installed and running.
|
|
82
|
+
*/
|
|
83
|
+
export function checkContainerRuntime() {
|
|
84
|
+
try {
|
|
85
|
+
if (containerRuntime === "container") {
|
|
86
|
+
// macOS Apple Container
|
|
87
|
+
execSync("container system status", { stdio: "pipe" });
|
|
88
|
+
return { available: true, runtime: "container", platform: "macos" };
|
|
89
|
+
}
|
|
90
|
+
// Docker or Podman
|
|
91
|
+
execSync(`${containerRuntime} info`, { stdio: "pipe", timeout: 10000 });
|
|
92
|
+
return {
|
|
93
|
+
available: true,
|
|
94
|
+
runtime: containerRuntime,
|
|
95
|
+
platform: process.platform,
|
|
96
|
+
};
|
|
97
|
+
} catch {
|
|
98
|
+
return {
|
|
99
|
+
available: false,
|
|
100
|
+
runtime: containerRuntime,
|
|
101
|
+
platform: process.platform,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ensure the container runtime is ready (start if needed for macOS).
|
|
108
|
+
*/
|
|
109
|
+
export function ensureContainerRuntime() {
|
|
110
|
+
if (containerRuntime === "container") {
|
|
111
|
+
// macOS Apple Container — may need explicit start
|
|
112
|
+
try {
|
|
113
|
+
execSync("container system status", { stdio: "pipe" });
|
|
114
|
+
} catch {
|
|
115
|
+
console.log("[container] Starting Apple Container system...");
|
|
116
|
+
try {
|
|
117
|
+
execSync("container system start", { stdio: "pipe", timeout: 30000 });
|
|
118
|
+
console.log("[container] Apple Container system started");
|
|
119
|
+
} catch (err) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Apple Container failed to start: ${err.message}\n` +
|
|
122
|
+
"Install from: https://github.com/apple/container/releases",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Docker/Podman — just verify it's running
|
|
130
|
+
const check = checkContainerRuntime();
|
|
131
|
+
if (!check.available) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`${containerRuntime} is not available. Install it or set CONTAINER_RUNTIME to an available runtime.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build volume mount arguments for the container.
|
|
140
|
+
* @param {string} workspacePath - Path to the workspace/repo
|
|
141
|
+
* @param {string} scratchDir - Path to scratch directory for container writes
|
|
142
|
+
* @param {object} options - Additional mount options
|
|
143
|
+
*/
|
|
144
|
+
function buildMountArgs(workspacePath, scratchDir, options = {}) {
|
|
145
|
+
const args = [];
|
|
146
|
+
|
|
147
|
+
if (containerRuntime === "container") {
|
|
148
|
+
// Apple Container uses --mount and -v syntax
|
|
149
|
+
args.push(
|
|
150
|
+
"--mount",
|
|
151
|
+
`type=bind,source=${workspacePath},target=/workspace,readonly`,
|
|
152
|
+
);
|
|
153
|
+
args.push("-v", `${scratchDir}:/scratch`);
|
|
154
|
+
} else {
|
|
155
|
+
// Docker/Podman
|
|
156
|
+
args.push("-v", `${workspacePath}:/workspace:ro`);
|
|
157
|
+
args.push("-v", `${scratchDir}:/scratch:rw`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Additional mounts
|
|
161
|
+
if (options.additionalMounts) {
|
|
162
|
+
for (const mount of options.additionalMounts) {
|
|
163
|
+
const target =
|
|
164
|
+
mount.containerPath || `/workspace/extra/${basename(mount.hostPath)}`;
|
|
165
|
+
const ro = mount.readonly !== false ? ":ro" : "";
|
|
166
|
+
if (containerRuntime === "container") {
|
|
167
|
+
if (mount.readonly !== false) {
|
|
168
|
+
args.push(
|
|
169
|
+
"--mount",
|
|
170
|
+
`type=bind,source=${mount.hostPath},target=${target},readonly`,
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
args.push("-v", `${mount.hostPath}:${target}`);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
args.push("-v", `${mount.hostPath}:${target}${ro}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return args;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Run an agent command inside a container.
|
|
186
|
+
*
|
|
187
|
+
* @param {object} options
|
|
188
|
+
* @param {string} options.workspacePath - Path to workspace/repo to mount
|
|
189
|
+
* @param {string} options.command - Command to run inside container
|
|
190
|
+
* @param {string[]} [options.args] - Command arguments
|
|
191
|
+
* @param {object} [options.env] - Environment variables for the container
|
|
192
|
+
* @param {string} [options.taskId] - Task identifier for tracking
|
|
193
|
+
* @param {number} [options.timeout] - Override timeout in ms
|
|
194
|
+
* @param {object} [options.mountOptions] - Additional mount configuration
|
|
195
|
+
* @param {function} [options.onOutput] - Streaming output callback
|
|
196
|
+
* @returns {Promise<{status: string, stdout: string, stderr: string, exitCode: number}>}
|
|
197
|
+
*/
|
|
198
|
+
export async function runInContainer(options) {
|
|
199
|
+
if (!containerEnabled) {
|
|
200
|
+
throw new Error("Container mode is not enabled (set CONTAINER_ENABLED=1)");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (activeContainers.size >= maxConcurrentContainers) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Max concurrent containers reached (${maxConcurrentContainers}). Wait for a slot.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const {
|
|
210
|
+
workspacePath,
|
|
211
|
+
command,
|
|
212
|
+
args = [],
|
|
213
|
+
env = {},
|
|
214
|
+
taskId = "unknown",
|
|
215
|
+
timeout = containerTimeout,
|
|
216
|
+
mountOptions = {},
|
|
217
|
+
onOutput,
|
|
218
|
+
} = options;
|
|
219
|
+
|
|
220
|
+
// Create scratch directory for container writes
|
|
221
|
+
const scratchDir = resolve(
|
|
222
|
+
workspacePath,
|
|
223
|
+
".cache",
|
|
224
|
+
"container-scratch",
|
|
225
|
+
`task-${Date.now()}`,
|
|
226
|
+
);
|
|
227
|
+
mkdirSync(scratchDir, { recursive: true });
|
|
228
|
+
|
|
229
|
+
const containerName = `codexmon-${taskId.replace(/[^a-zA-Z0-9-]/g, "-")}-${++containerIdCounter}`;
|
|
230
|
+
const mountArgs = buildMountArgs(workspacePath, scratchDir, mountOptions);
|
|
231
|
+
|
|
232
|
+
// Build container run command
|
|
233
|
+
const containerArgs = [
|
|
234
|
+
"run",
|
|
235
|
+
"--rm",
|
|
236
|
+
"--name",
|
|
237
|
+
containerName,
|
|
238
|
+
"-w",
|
|
239
|
+
"/workspace",
|
|
240
|
+
...mountArgs,
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
// Add environment variables
|
|
244
|
+
for (const [key, value] of Object.entries(env)) {
|
|
245
|
+
containerArgs.push("-e", `${key}=${value}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Resource limits (Docker/Podman only)
|
|
249
|
+
if (containerRuntime !== "container") {
|
|
250
|
+
const memLimit = process.env.CONTAINER_MEMORY_LIMIT || "4g";
|
|
251
|
+
const cpuLimit = process.env.CONTAINER_CPU_LIMIT || "2";
|
|
252
|
+
containerArgs.push("--memory", memLimit);
|
|
253
|
+
containerArgs.push("--cpus", cpuLimit);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Image and command
|
|
257
|
+
containerArgs.push(containerImage);
|
|
258
|
+
if (command) {
|
|
259
|
+
containerArgs.push(command, ...args);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log(
|
|
263
|
+
`[container] spawning ${containerName} (image: ${containerImage}, task: ${taskId})`,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return new Promise((resolvePromise) => {
|
|
267
|
+
const proc = spawn(containerRuntime, containerArgs, {
|
|
268
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const startTime = Date.now();
|
|
272
|
+
activeContainers.set(containerName, { proc, startTime, taskId });
|
|
273
|
+
|
|
274
|
+
let stdout = "";
|
|
275
|
+
let stderr = "";
|
|
276
|
+
let timedOut = false;
|
|
277
|
+
let parseBuffer = "";
|
|
278
|
+
|
|
279
|
+
proc.stdout.on("data", (data) => {
|
|
280
|
+
const chunk = data.toString();
|
|
281
|
+
if (stdout.length + chunk.length <= containerMaxOutput) {
|
|
282
|
+
stdout += chunk;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Stream-parse for output markers
|
|
286
|
+
if (onOutput) {
|
|
287
|
+
parseBuffer += chunk;
|
|
288
|
+
let startIdx;
|
|
289
|
+
while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
|
|
290
|
+
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
|
|
291
|
+
if (endIdx === -1) break;
|
|
292
|
+
const jsonStr = parseBuffer
|
|
293
|
+
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
|
294
|
+
.trim();
|
|
295
|
+
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
|
|
296
|
+
try {
|
|
297
|
+
const parsed = JSON.parse(jsonStr);
|
|
298
|
+
onOutput(parsed);
|
|
299
|
+
} catch {
|
|
300
|
+
/* ignore parse errors */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
proc.stderr.on("data", (data) => {
|
|
307
|
+
const chunk = data.toString();
|
|
308
|
+
if (stderr.length + chunk.length <= containerMaxOutput) {
|
|
309
|
+
stderr += chunk;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const timer = setTimeout(() => {
|
|
314
|
+
timedOut = true;
|
|
315
|
+
console.warn(
|
|
316
|
+
`[container] ${containerName} timed out after ${timeout}ms, stopping`,
|
|
317
|
+
);
|
|
318
|
+
try {
|
|
319
|
+
execSync(`${containerRuntime} stop ${containerName}`, {
|
|
320
|
+
stdio: "pipe",
|
|
321
|
+
timeout: 15000,
|
|
322
|
+
});
|
|
323
|
+
} catch {
|
|
324
|
+
proc.kill("SIGKILL");
|
|
325
|
+
}
|
|
326
|
+
}, timeout);
|
|
327
|
+
|
|
328
|
+
proc.on("close", (code) => {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
activeContainers.delete(containerName);
|
|
331
|
+
const duration = Date.now() - startTime;
|
|
332
|
+
|
|
333
|
+
console.log(
|
|
334
|
+
`[container] ${containerName} exited (code: ${code}, duration: ${Math.round(duration / 1000)}s, timedOut: ${timedOut})`,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
resolvePromise({
|
|
338
|
+
status: timedOut ? "timeout" : code === 0 ? "success" : "error",
|
|
339
|
+
stdout,
|
|
340
|
+
stderr,
|
|
341
|
+
exitCode: code,
|
|
342
|
+
duration,
|
|
343
|
+
containerName,
|
|
344
|
+
scratchDir,
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
proc.on("error", (err) => {
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
activeContainers.delete(containerName);
|
|
351
|
+
console.error(`[container] ${containerName} spawn error: ${err.message}`);
|
|
352
|
+
resolvePromise({
|
|
353
|
+
status: "error",
|
|
354
|
+
stdout,
|
|
355
|
+
stderr: err.message,
|
|
356
|
+
exitCode: -1,
|
|
357
|
+
duration: Date.now() - startTime,
|
|
358
|
+
containerName,
|
|
359
|
+
scratchDir,
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Stop all running containers (graceful shutdown).
|
|
367
|
+
*/
|
|
368
|
+
export async function stopAllContainers(timeoutMs = 10000) {
|
|
369
|
+
const names = [...activeContainers.keys()];
|
|
370
|
+
if (names.length === 0) return;
|
|
371
|
+
|
|
372
|
+
console.log(`[container] stopping ${names.length} active containers...`);
|
|
373
|
+
|
|
374
|
+
for (const name of names) {
|
|
375
|
+
try {
|
|
376
|
+
execSync(`${containerRuntime} stop ${name}`, {
|
|
377
|
+
stdio: "pipe",
|
|
378
|
+
timeout: timeoutMs,
|
|
379
|
+
});
|
|
380
|
+
} catch {
|
|
381
|
+
// Try force kill
|
|
382
|
+
try {
|
|
383
|
+
execSync(`${containerRuntime} kill ${name}`, { stdio: "pipe" });
|
|
384
|
+
} catch {
|
|
385
|
+
/* already stopped */
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
activeContainers.clear();
|
|
391
|
+
console.log("[container] all containers stopped");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Clean up orphaned containers from previous runs.
|
|
396
|
+
*/
|
|
397
|
+
export function cleanupOrphanedContainers() {
|
|
398
|
+
try {
|
|
399
|
+
let output;
|
|
400
|
+
if (containerRuntime === "container") {
|
|
401
|
+
output = execSync("container ls --format json", {
|
|
402
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
403
|
+
encoding: "utf-8",
|
|
404
|
+
});
|
|
405
|
+
const containers = JSON.parse(output || "[]");
|
|
406
|
+
const orphans = containers
|
|
407
|
+
.filter(
|
|
408
|
+
(c) =>
|
|
409
|
+
c.status === "running" &&
|
|
410
|
+
c.configuration?.id?.startsWith("codexmon-"),
|
|
411
|
+
)
|
|
412
|
+
.map((c) => c.configuration.id);
|
|
413
|
+
for (const name of orphans) {
|
|
414
|
+
try {
|
|
415
|
+
execSync(`container stop ${name}`, { stdio: "pipe" });
|
|
416
|
+
} catch {
|
|
417
|
+
/* already stopped */
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (orphans.length > 0) {
|
|
421
|
+
console.log(
|
|
422
|
+
`[container] cleaned up ${orphans.length} orphaned containers`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
output = execSync(
|
|
427
|
+
`${containerRuntime} ps --filter "name=codexmon-" --format "{{.Names}}"`,
|
|
428
|
+
{ stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8" },
|
|
429
|
+
);
|
|
430
|
+
const orphans = output
|
|
431
|
+
.trim()
|
|
432
|
+
.split("\n")
|
|
433
|
+
.filter((n) => n);
|
|
434
|
+
for (const name of orphans) {
|
|
435
|
+
try {
|
|
436
|
+
execSync(`${containerRuntime} stop ${name}`, { stdio: "pipe" });
|
|
437
|
+
} catch {
|
|
438
|
+
/* already stopped */
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (orphans.length > 0) {
|
|
442
|
+
console.log(
|
|
443
|
+
`[container] cleaned up ${orphans.length} orphaned containers`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
/* no orphans or runtime not available */
|
|
449
|
+
}
|
|
450
|
+
}
|