@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,405 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, statSync, readFileSync } from "node:fs";
|
|
3
|
+
import { readdir, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
loadSharedWorkspaceRegistry,
|
|
7
|
+
saveSharedWorkspaceRegistry,
|
|
8
|
+
sweepExpiredLeases,
|
|
9
|
+
} from "./shared-workspace-registry.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* workspace-reaper.mjs
|
|
13
|
+
*
|
|
14
|
+
* Cleans up expired workspace leases and orphaned git worktrees after crashes.
|
|
15
|
+
* Implements safety checks to avoid deleting active workspaces.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DEFAULT_ORPHAN_THRESHOLD_HOURS = 24;
|
|
19
|
+
const DEFAULT_AGGRESSIVE_THRESHOLD_HOURS = 1; // For completed task cleanup
|
|
20
|
+
const DEFAULT_PROCESS_CHECK_RETRIES = 3;
|
|
21
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
22
|
+
|
|
23
|
+
function buildGitEnv() {
|
|
24
|
+
const env = { ...process.env };
|
|
25
|
+
delete env.GIT_DIR;
|
|
26
|
+
delete env.GIT_WORK_TREE;
|
|
27
|
+
delete env.GIT_INDEX_FILE;
|
|
28
|
+
return env;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureIso(date) {
|
|
32
|
+
return new Date(date).toISOString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a process is running by PID
|
|
37
|
+
*/
|
|
38
|
+
function isProcessRunning(pid) {
|
|
39
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract PIDs from common lock files or process indicators
|
|
50
|
+
*/
|
|
51
|
+
function extractPidsFromPath(worktreePath) {
|
|
52
|
+
const pids = new Set();
|
|
53
|
+
const lockPaths = [
|
|
54
|
+
resolve(worktreePath, ".git", "index.lock"),
|
|
55
|
+
resolve(worktreePath, ".openfleet.pid"),
|
|
56
|
+
resolve(worktreePath, ".vk-executor.pid"),
|
|
57
|
+
];
|
|
58
|
+
for (const lockPath of lockPaths) {
|
|
59
|
+
if (!existsSync(lockPath)) continue;
|
|
60
|
+
try {
|
|
61
|
+
const content = readFileSync(lockPath, "utf8").trim();
|
|
62
|
+
const pidMatch = /^\d+$/.test(content) ? Number(content) : null;
|
|
63
|
+
if (pidMatch && pidMatch > 0) {
|
|
64
|
+
pids.add(pidMatch);
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Ignore errors reading lock files
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return Array.from(pids);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cross-platform sleep
|
|
75
|
+
*/
|
|
76
|
+
function sleep(ms) {
|
|
77
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a worktree path has any active processes
|
|
82
|
+
*/
|
|
83
|
+
async function hasActiveProcesses(worktreePath, options = {}) {
|
|
84
|
+
const retries = options.retries || DEFAULT_PROCESS_CHECK_RETRIES;
|
|
85
|
+
const pids = extractPidsFromPath(worktreePath);
|
|
86
|
+
if (pids.length === 0) return false;
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < retries; i++) {
|
|
89
|
+
for (const pid of pids) {
|
|
90
|
+
if (isProcessRunning(pid)) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (i < retries - 1) {
|
|
95
|
+
// Wait a bit before retry
|
|
96
|
+
await sleep(1000);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get last modified time of most recently modified file in directory
|
|
104
|
+
*/
|
|
105
|
+
async function getLastModifiedTime(dirPath) {
|
|
106
|
+
try {
|
|
107
|
+
if (!existsSync(dirPath)) return null;
|
|
108
|
+
let latestMtime = null;
|
|
109
|
+
const entries = await readdir(dirPath, { withFileTypes: true, recursive: false });
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (entry.name === ".git") {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const fullPath = resolve(dirPath, entry.name);
|
|
115
|
+
try {
|
|
116
|
+
const stats = await stat(fullPath);
|
|
117
|
+
if (!latestMtime || stats.mtime > latestMtime) {
|
|
118
|
+
latestMtime = stats.mtime;
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Ignore errors on individual files
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return latestMtime;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check if a worktree is truly orphaned (safe to delete)
|
|
132
|
+
*/
|
|
133
|
+
async function isWorktreeOrphaned(worktreePath, options = {}) {
|
|
134
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
135
|
+
const thresholdHours = options.orphanThresholdHours || DEFAULT_ORPHAN_THRESHOLD_HOURS;
|
|
136
|
+
|
|
137
|
+
// Safety check 1: Path must exist
|
|
138
|
+
if (!existsSync(worktreePath)) {
|
|
139
|
+
return { orphaned: false, reason: "path_not_found" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Safety check 2: No active processes
|
|
143
|
+
if (await hasActiveProcesses(worktreePath, options)) {
|
|
144
|
+
return { orphaned: false, reason: "active_process" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Safety check 3: Check last modified time
|
|
148
|
+
const lastModified = await getLastModifiedTime(worktreePath);
|
|
149
|
+
if (lastModified) {
|
|
150
|
+
const ageHours = (now.getTime() - lastModified.getTime()) / (1000 * 60 * 60);
|
|
151
|
+
if (ageHours < thresholdHours) {
|
|
152
|
+
return { orphaned: false, reason: "recently_modified", ageHours };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Safety check 4: Check git status for uncommitted work
|
|
157
|
+
// Only check git status if .git is a FILE (proper worktree), not a directory
|
|
158
|
+
const gitPath = resolve(worktreePath, ".git");
|
|
159
|
+
const isProperWorktree = existsSync(gitPath) && !statSync(gitPath).isDirectory();
|
|
160
|
+
|
|
161
|
+
if (isProperWorktree) {
|
|
162
|
+
try {
|
|
163
|
+
const cmd = IS_WINDOWS
|
|
164
|
+
? `cd /d "${worktreePath}" && git status --porcelain`
|
|
165
|
+
: `cd "${worktreePath}" && git status --porcelain`;
|
|
166
|
+
const gitStatus = execSync(cmd, {
|
|
167
|
+
encoding: "utf8",
|
|
168
|
+
env: buildGitEnv(),
|
|
169
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
170
|
+
}).trim();
|
|
171
|
+
if (gitStatus.length > 0) {
|
|
172
|
+
return { orphaned: false, reason: "uncommitted_changes" };
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// If git command fails, worktree might be corrupted - consider orphaned
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { orphaned: true, lastModified, ageHours: lastModified ? (now.getTime() - lastModified.getTime()) / (1000 * 60 * 60) : null };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Find git worktrees in a given directory
|
|
184
|
+
*/
|
|
185
|
+
async function findGitWorktrees(searchPath) {
|
|
186
|
+
const worktrees = [];
|
|
187
|
+
try {
|
|
188
|
+
if (!existsSync(searchPath)) return worktrees;
|
|
189
|
+
const entries = await readdir(searchPath, { withFileTypes: true });
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
if (!entry.isDirectory()) continue;
|
|
192
|
+
const fullPath = resolve(searchPath, entry.name);
|
|
193
|
+
const gitPath = resolve(fullPath, ".git");
|
|
194
|
+
if (existsSync(gitPath)) {
|
|
195
|
+
worktrees.push({
|
|
196
|
+
path: fullPath,
|
|
197
|
+
name: entry.name,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.warn(`[workspace-reaper] failed to scan ${searchPath}: ${err.message || err}`);
|
|
203
|
+
}
|
|
204
|
+
return worktrees;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Clean orphaned worktrees with safety checks
|
|
209
|
+
*/
|
|
210
|
+
export async function cleanOrphanedWorktrees(options = {}) {
|
|
211
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
212
|
+
const searchPaths = Array.isArray(options.searchPaths)
|
|
213
|
+
? options.searchPaths
|
|
214
|
+
: [process.env.VK_WORKTREE_BASE || "/tmp/vibe-kanban/worktrees"];
|
|
215
|
+
|
|
216
|
+
const results = {
|
|
217
|
+
scanned: 0,
|
|
218
|
+
cleaned: 0,
|
|
219
|
+
skipped: 0,
|
|
220
|
+
errors: [],
|
|
221
|
+
cleaned_paths: [],
|
|
222
|
+
skipped_reasons: {},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
for (const searchPath of searchPaths) {
|
|
226
|
+
const worktrees = await findGitWorktrees(searchPath);
|
|
227
|
+
for (const worktree of worktrees) {
|
|
228
|
+
results.scanned++;
|
|
229
|
+
const orphanCheck = await isWorktreeOrphaned(worktree.path, options);
|
|
230
|
+
|
|
231
|
+
if (!orphanCheck.orphaned) {
|
|
232
|
+
results.skipped++;
|
|
233
|
+
const reason = orphanCheck.reason || "unknown";
|
|
234
|
+
results.skipped_reasons[reason] = (results.skipped_reasons[reason] || 0) + 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Safe to delete
|
|
239
|
+
if (options.dryRun) {
|
|
240
|
+
results.cleaned_paths.push({
|
|
241
|
+
path: worktree.path,
|
|
242
|
+
dryRun: true,
|
|
243
|
+
ageHours: orphanCheck.ageHours,
|
|
244
|
+
});
|
|
245
|
+
results.cleaned++;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
await rm(worktree.path, { recursive: true, force: true });
|
|
251
|
+
results.cleaned++;
|
|
252
|
+
results.cleaned_paths.push({
|
|
253
|
+
path: worktree.path,
|
|
254
|
+
ageHours: orphanCheck.ageHours,
|
|
255
|
+
cleanedAt: ensureIso(now),
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
results.errors.push({
|
|
259
|
+
path: worktree.path,
|
|
260
|
+
error: err.message || String(err),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return results;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Prune git worktree metadata
|
|
271
|
+
* Removes stale entries from .git/worktrees/
|
|
272
|
+
*/
|
|
273
|
+
export async function pruneWorktreeMetadata(repoPath, options = {}) {
|
|
274
|
+
const results = {
|
|
275
|
+
pruned: 0,
|
|
276
|
+
errors: [],
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const gitEnv = buildGitEnv();
|
|
281
|
+
execSync('git worktree prune', {
|
|
282
|
+
cwd: repoPath,
|
|
283
|
+
stdio: options.verbose ? 'inherit' : 'pipe',
|
|
284
|
+
env: { ...gitEnv, GIT_EDITOR: ':', GIT_MERGE_AUTOEDIT: 'no' },
|
|
285
|
+
});
|
|
286
|
+
results.pruned++;
|
|
287
|
+
} catch (err) {
|
|
288
|
+
results.errors.push(err.message || String(err));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return results;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get worktree count for monitoring
|
|
296
|
+
*/
|
|
297
|
+
export async function getWorktreeCount(repoPath) {
|
|
298
|
+
try {
|
|
299
|
+
const gitEnv = buildGitEnv();
|
|
300
|
+
const output = execSync('git worktree list --porcelain', {
|
|
301
|
+
cwd: repoPath,
|
|
302
|
+
encoding: 'utf8',
|
|
303
|
+
env: gitEnv,
|
|
304
|
+
});
|
|
305
|
+
// Count "worktree" lines (one per worktree including main)
|
|
306
|
+
const count = (output.match(/^worktree /gm) || []).length;
|
|
307
|
+
return count - 1; // Subtract 1 for main worktree
|
|
308
|
+
} catch {
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Run full reaper sweep: expired leases + orphaned worktrees
|
|
315
|
+
*/
|
|
316
|
+
export async function runReaperSweep(options = {}) {
|
|
317
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
318
|
+
const results = {
|
|
319
|
+
timestamp: ensureIso(now),
|
|
320
|
+
leases: {
|
|
321
|
+
expired: 0,
|
|
322
|
+
cleaned: 0,
|
|
323
|
+
errors: [],
|
|
324
|
+
},
|
|
325
|
+
worktrees: {
|
|
326
|
+
scanned: 0,
|
|
327
|
+
cleaned: 0,
|
|
328
|
+
skipped: 0,
|
|
329
|
+
errors: [],
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Step 1: Sweep expired leases
|
|
334
|
+
try {
|
|
335
|
+
const leaseResult = await sweepExpiredLeases({ ...options, now });
|
|
336
|
+
results.leases.expired = leaseResult.expired?.length || 0;
|
|
337
|
+
results.leases.cleaned = leaseResult.expired?.length || 0;
|
|
338
|
+
} catch (err) {
|
|
339
|
+
results.leases.errors.push(err.message || String(err));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Step 2: Clean orphaned worktrees
|
|
343
|
+
try {
|
|
344
|
+
const worktreeResult = await cleanOrphanedWorktrees({ ...options, now });
|
|
345
|
+
results.worktrees.scanned = worktreeResult.scanned;
|
|
346
|
+
results.worktrees.cleaned = worktreeResult.cleaned;
|
|
347
|
+
results.worktrees.skipped = worktreeResult.skipped;
|
|
348
|
+
results.worktrees.errors = worktreeResult.errors || [];
|
|
349
|
+
results.worktrees.skipped_reasons = worktreeResult.skipped_reasons || {};
|
|
350
|
+
results.worktrees.cleaned_paths = worktreeResult.cleaned_paths || [];
|
|
351
|
+
} catch (err) {
|
|
352
|
+
results.worktrees.errors.push({ error: err.message || String(err) });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return results;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Format reaper results for logging
|
|
360
|
+
*/
|
|
361
|
+
export function formatReaperResults(results) {
|
|
362
|
+
const lines = [];
|
|
363
|
+
lines.push(`[workspace-reaper] Sweep completed at ${results.timestamp}`);
|
|
364
|
+
|
|
365
|
+
if (results.leases) {
|
|
366
|
+
lines.push(` Leases: ${results.leases.expired} expired, ${results.leases.cleaned} cleaned`);
|
|
367
|
+
if (results.leases.errors.length > 0) {
|
|
368
|
+
lines.push(` Lease errors: ${results.leases.errors.length}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (results.worktrees) {
|
|
373
|
+
lines.push(
|
|
374
|
+
` Worktrees: ${results.worktrees.scanned} scanned, ${results.worktrees.cleaned} cleaned, ${results.worktrees.skipped} skipped`,
|
|
375
|
+
);
|
|
376
|
+
if (results.worktrees.skipped_reasons) {
|
|
377
|
+
for (const [reason, count] of Object.entries(results.worktrees.skipped_reasons)) {
|
|
378
|
+
lines.push(` - ${reason}: ${count}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (results.worktrees.errors.length > 0) {
|
|
382
|
+
lines.push(` Worktree errors: ${results.worktrees.errors.length}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return lines.join("\n");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Calculate reaper metrics for monitoring
|
|
391
|
+
*/
|
|
392
|
+
export function calculateReaperMetrics(results) {
|
|
393
|
+
return {
|
|
394
|
+
timestamp: results.timestamp,
|
|
395
|
+
leases_expired: results.leases?.expired || 0,
|
|
396
|
+
leases_cleaned: results.leases?.cleaned || 0,
|
|
397
|
+
lease_errors: results.leases?.errors?.length || 0,
|
|
398
|
+
worktrees_scanned: results.worktrees?.scanned || 0,
|
|
399
|
+
worktrees_cleaned: results.worktrees?.cleaned || 0,
|
|
400
|
+
worktrees_skipped: results.worktrees?.skipped || 0,
|
|
401
|
+
worktree_errors: results.worktrees?.errors?.length || 0,
|
|
402
|
+
total_cleaned: (results.leases?.cleaned || 0) + (results.worktrees?.cleaned || 0),
|
|
403
|
+
total_errors: (results.leases?.errors?.length || 0) + (results.worktrees?.errors?.length || 0),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
7
|
+
|
|
8
|
+
const DEFAULT_REGISTRY = {
|
|
9
|
+
version: 1,
|
|
10
|
+
default_workspace: "primary",
|
|
11
|
+
workspaces: [
|
|
12
|
+
{
|
|
13
|
+
id: "primary",
|
|
14
|
+
name: "Primary Coordinator",
|
|
15
|
+
host: "localhost",
|
|
16
|
+
role: "coordinator",
|
|
17
|
+
capabilities: ["planning", "triage", "routing"],
|
|
18
|
+
model_priorities: ["CODEX:DEFAULT"],
|
|
19
|
+
vk_workspace_id: "primary",
|
|
20
|
+
mentions: ["primary", "coord", "coordinator"],
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function normalizeId(value) {
|
|
26
|
+
return String(value || "")
|
|
27
|
+
.trim()
|
|
28
|
+
.toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeExecutorProfile(profile) {
|
|
32
|
+
if (!profile) return null;
|
|
33
|
+
if (typeof profile === "string") {
|
|
34
|
+
const [executor, variant] = profile.split(":");
|
|
35
|
+
if (!executor) return null;
|
|
36
|
+
return {
|
|
37
|
+
executor: executor.trim().toUpperCase(),
|
|
38
|
+
variant: (variant || "DEFAULT").trim().toUpperCase(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (profile.executor && profile.variant) {
|
|
42
|
+
return {
|
|
43
|
+
executor: String(profile.executor).toUpperCase(),
|
|
44
|
+
variant: String(profile.variant).toUpperCase(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (profile.executor_profile_id) {
|
|
48
|
+
return normalizeExecutorProfile(profile.executor_profile_id);
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeWorkspace(workspace) {
|
|
54
|
+
if (!workspace) return null;
|
|
55
|
+
const id = normalizeId(workspace.id);
|
|
56
|
+
if (!id) return null;
|
|
57
|
+
const mentions = Array.isArray(workspace.mentions)
|
|
58
|
+
? workspace.mentions.map(normalizeId).filter(Boolean)
|
|
59
|
+
: [];
|
|
60
|
+
const aliases = Array.isArray(workspace.aliases)
|
|
61
|
+
? workspace.aliases.map(normalizeId).filter(Boolean)
|
|
62
|
+
: [];
|
|
63
|
+
return {
|
|
64
|
+
...workspace,
|
|
65
|
+
id,
|
|
66
|
+
mentions,
|
|
67
|
+
aliases,
|
|
68
|
+
role: workspace.role || "workspace",
|
|
69
|
+
model_priorities: Array.isArray(workspace.model_priorities)
|
|
70
|
+
? workspace.model_priorities
|
|
71
|
+
: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function loadWorkspaceRegistry(options = {}) {
|
|
76
|
+
const registryPath = options.registryPath
|
|
77
|
+
? resolve(options.registryPath)
|
|
78
|
+
: resolve(__dirname, "workspaces.json");
|
|
79
|
+
let registry = null;
|
|
80
|
+
const errors = [];
|
|
81
|
+
const warnings = [];
|
|
82
|
+
if (existsSync(registryPath)) {
|
|
83
|
+
try {
|
|
84
|
+
const raw = await readFile(registryPath, "utf8");
|
|
85
|
+
registry = JSON.parse(raw);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
errors.push(`Failed to read ${registryPath}: ${err.message || err}`);
|
|
88
|
+
console.warn(
|
|
89
|
+
`[workspace-registry] failed to read ${registryPath}: ${err.message || err}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
warnings.push(`Registry file not found: ${registryPath} — using defaults`);
|
|
94
|
+
}
|
|
95
|
+
if (!registry) {
|
|
96
|
+
registry = { ...DEFAULT_REGISTRY };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const workspaces = Array.isArray(registry.workspaces)
|
|
100
|
+
? registry.workspaces.map(normalizeWorkspace).filter(Boolean)
|
|
101
|
+
: [];
|
|
102
|
+
const defaultWorkspace =
|
|
103
|
+
normalizeId(registry.default_workspace) ||
|
|
104
|
+
DEFAULT_REGISTRY.default_workspace;
|
|
105
|
+
|
|
106
|
+
if (workspaces.length === 0) {
|
|
107
|
+
warnings.push("No workspaces configured — using built-in defaults");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
registry: {
|
|
112
|
+
version: registry.version || DEFAULT_REGISTRY.version,
|
|
113
|
+
default_workspace: defaultWorkspace,
|
|
114
|
+
workspaces,
|
|
115
|
+
registry_path: registryPath,
|
|
116
|
+
},
|
|
117
|
+
// Also spread top-level for backward compat
|
|
118
|
+
version: registry.version || DEFAULT_REGISTRY.version,
|
|
119
|
+
default_workspace: defaultWorkspace,
|
|
120
|
+
workspaces,
|
|
121
|
+
registry_path: registryPath,
|
|
122
|
+
errors,
|
|
123
|
+
warnings,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function resolveWorkspace(registry, candidateId) {
|
|
128
|
+
if (!registry || !Array.isArray(registry.workspaces)) return null;
|
|
129
|
+
const target = normalizeId(candidateId);
|
|
130
|
+
if (!target) return null;
|
|
131
|
+
return (
|
|
132
|
+
registry.workspaces.find((w) => w.id === target) ||
|
|
133
|
+
registry.workspaces.find((w) => w.aliases?.includes(target)) ||
|
|
134
|
+
registry.workspaces.find((w) => w.mentions?.includes(target)) ||
|
|
135
|
+
null
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getLocalWorkspace(registry, envWorkspaceId) {
|
|
140
|
+
if (!registry || !Array.isArray(registry.workspaces)) return null;
|
|
141
|
+
const explicit = normalizeId(envWorkspaceId);
|
|
142
|
+
const defaultId = registry.default_workspace;
|
|
143
|
+
const id = explicit || defaultId || "primary";
|
|
144
|
+
return (
|
|
145
|
+
resolveWorkspace(registry, id) ||
|
|
146
|
+
registry.workspaces[0] ||
|
|
147
|
+
normalizeWorkspace({ id })
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function listWorkspaceIds(registry) {
|
|
152
|
+
if (!registry || !Array.isArray(registry.workspaces)) return [];
|
|
153
|
+
return registry.workspaces.map((w) => w.id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function selectExecutorProfile(workspace, override) {
|
|
157
|
+
const overrideProfile = normalizeExecutorProfile(override);
|
|
158
|
+
if (overrideProfile) {
|
|
159
|
+
return overrideProfile;
|
|
160
|
+
}
|
|
161
|
+
if (!workspace) {
|
|
162
|
+
return { executor: "CODEX", variant: "DEFAULT" };
|
|
163
|
+
}
|
|
164
|
+
for (const entry of workspace.model_priorities || []) {
|
|
165
|
+
const profile = normalizeExecutorProfile(entry);
|
|
166
|
+
if (profile) return profile;
|
|
167
|
+
}
|
|
168
|
+
return { executor: "CODEX", variant: "DEFAULT" };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function parseWorkspaceMentions(text, registry) {
|
|
172
|
+
const targets = new Set();
|
|
173
|
+
const normalizedText = String(text || "");
|
|
174
|
+
const mentionMatches = normalizedText.matchAll(/@([A-Za-z0-9_-]+)/g);
|
|
175
|
+
for (const match of mentionMatches) {
|
|
176
|
+
const id = match[1];
|
|
177
|
+
if (id && id.toLowerCase() === "all") {
|
|
178
|
+
return { targets, broadcast: true };
|
|
179
|
+
}
|
|
180
|
+
const workspace = resolveWorkspace(registry, id);
|
|
181
|
+
if (workspace) {
|
|
182
|
+
targets.add(workspace.id);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const prefixMatches = normalizedText.matchAll(/\[ws:([A-Za-z0-9_-]+)\]/gi);
|
|
186
|
+
for (const match of prefixMatches) {
|
|
187
|
+
const id = match[1];
|
|
188
|
+
if (id && id.toLowerCase() === "all") {
|
|
189
|
+
return { targets, broadcast: true };
|
|
190
|
+
}
|
|
191
|
+
const workspace = resolveWorkspace(registry, id);
|
|
192
|
+
if (workspace) {
|
|
193
|
+
targets.add(workspace.id);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { targets, broadcast: false };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function stripWorkspaceMentions(text, registry) {
|
|
200
|
+
const ids = listWorkspaceIds(registry);
|
|
201
|
+
if (!ids.length) return String(text || "").trim();
|
|
202
|
+
let result = String(text || "");
|
|
203
|
+
for (const id of ids) {
|
|
204
|
+
const mention = new RegExp(`@${id}\\b`, "gi");
|
|
205
|
+
const prefix = new RegExp(`\\[ws:${id}\\]`, "gi");
|
|
206
|
+
result = result.replace(mention, "").replace(prefix, "");
|
|
207
|
+
}
|
|
208
|
+
return result.replace(/\s{2,}/g, " ").trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function formatBusMessage({ workspaceId, type, text }) {
|
|
212
|
+
const prefixId = workspaceId || "unknown";
|
|
213
|
+
const typeTag = type ? `[${type}]` : "";
|
|
214
|
+
const prefix = `[ws:${prefixId}]${typeTag}`;
|
|
215
|
+
const lines = String(text || "").split(/\r?\n/);
|
|
216
|
+
if (lines.length === 0) {
|
|
217
|
+
return `${prefix}`;
|
|
218
|
+
}
|
|
219
|
+
lines[0] = `${prefix} ${lines[0]}`.trim();
|
|
220
|
+
return lines.join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function formatRegistryDiagnostics(errors, warnings) {
|
|
224
|
+
const parts = [];
|
|
225
|
+
if (errors && errors.length > 0) {
|
|
226
|
+
parts.push(
|
|
227
|
+
`❌ Registry errors:\n${errors.map((e) => ` • ${e}`).join("\n")}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (warnings && warnings.length > 0) {
|
|
231
|
+
parts.push(`⚠️ ${warnings.map((w) => w).join("\n⚠️ ")}`);
|
|
232
|
+
}
|
|
233
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function getDefaultModelPriority() {
|
|
237
|
+
return ["CODEX:DEFAULT", "COPILOT:CLAUDE_OPUS_4_6"];
|
|
238
|
+
}
|