bosun 0.40.21 → 0.41.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/agent/agent-custom-tools.mjs +23 -5
- package/agent/agent-pool.mjs +6 -2
- package/agent/primary-agent.mjs +81 -7
- package/bench/swebench/bosun-swebench.mjs +5 -0
- package/cli.mjs +208 -3
- package/config/config-doctor.mjs +51 -2
- package/config/config.mjs +103 -3
- package/github/github-auth-manager.mjs +70 -19
- package/infra/library-manager.mjs +894 -60
- package/infra/monitor.mjs +8 -2
- package/infra/session-tracker.mjs +13 -3
- package/infra/test-runtime.mjs +267 -0
- package/package.json +8 -5
- package/server/setup-web-server.mjs +4 -1
- package/server/ui-server.mjs +1323 -20
- package/task/task-claims.mjs +6 -10
- package/ui/components/chat-view.js +18 -1
- package/ui/components/workspace-switcher.js +321 -9
- package/ui/demo-defaults.js +11746 -9470
- package/ui/demo.html +9 -1
- package/ui/modules/router.js +1 -1
- package/ui/modules/voice-client-sdk.js +1 -1
- package/ui/modules/voice-client.js +33 -2
- package/ui/styles/components.css +514 -1
- package/ui/tabs/library.js +410 -55
- package/ui/tabs/tasks.js +1052 -506
- package/ui/tabs/workflow-canvas-utils.mjs +30 -0
- package/ui/tabs/workflows.js +914 -298
- package/voice/voice-agents-sdk.mjs +1 -1
- package/voice/voice-relay.mjs +24 -16
- package/workflow/project-detection.mjs +559 -0
- package/workflow/workflow-contract.mjs +433 -232
- package/workflow/workflow-engine.mjs +181 -30
- package/workflow/workflow-nodes.mjs +304 -6
- package/workflow/workflow-templates.mjs +92 -16
- package/workflow-templates/agents.mjs +20 -19
- package/workflow-templates/code-quality.mjs +20 -14
- package/workflow-templates/task-batch.mjs +3 -2
- package/workflow-templates/task-execution.mjs +752 -0
- package/workflow-templates/task-lifecycle.mjs +34 -8
- package/workspace/workspace-manager.mjs +151 -0
|
@@ -561,12 +561,30 @@ export async function invokeCustomTool(rootDir, toolId, args = [], opts = {}) {
|
|
|
561
561
|
timeout,
|
|
562
562
|
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
563
563
|
});
|
|
564
|
-
|
|
565
|
-
stderr
|
|
564
|
+
// Node versions/environments may resolve promisified execFile as:
|
|
565
|
+
// - { stdout, stderr } (modern child_process custom promisify)
|
|
566
|
+
// - stdout string/buffer only (legacy/mocked fallback)
|
|
567
|
+
// - [stdout, stderr] tuple (some custom wrappers)
|
|
568
|
+
if (out && typeof out === "object" && !Array.isArray(out)) {
|
|
569
|
+
stdout = String(out.stdout ?? "");
|
|
570
|
+
stderr = String(out.stderr ?? "");
|
|
571
|
+
} else if (Array.isArray(out)) {
|
|
572
|
+
stdout = String(out[0] ?? "");
|
|
573
|
+
stderr = String(out[1] ?? "");
|
|
574
|
+
} else {
|
|
575
|
+
stdout = String(out ?? "");
|
|
576
|
+
stderr = "";
|
|
577
|
+
}
|
|
566
578
|
} catch (err) {
|
|
567
|
-
stdout = err
|
|
568
|
-
stderr = err
|
|
569
|
-
|
|
579
|
+
stdout = String(err?.stdout ?? "");
|
|
580
|
+
stderr = String(err?.stderr ?? err?.message ?? "");
|
|
581
|
+
const numericExit = Number(err?.code);
|
|
582
|
+
const numericStatus = Number(err?.status);
|
|
583
|
+
exitCode = Number.isFinite(numericExit)
|
|
584
|
+
? numericExit
|
|
585
|
+
: Number.isFinite(numericStatus)
|
|
586
|
+
? numericStatus
|
|
587
|
+
: 1;
|
|
570
588
|
}
|
|
571
589
|
|
|
572
590
|
// Record usage non-blocking
|
package/agent/agent-pool.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
streamRetryDelay,
|
|
54
54
|
MAX_STREAM_RETRIES,
|
|
55
55
|
} from "../infra/stream-resilience.mjs";
|
|
56
|
+
import { ensureTestRuntimeSandbox } from "../infra/test-runtime.mjs";
|
|
56
57
|
import { compressAllItems, estimateSavings, estimateContextUsagePct, recordShreddingEvent } from "../workspace/context-cache.mjs";
|
|
57
58
|
import { resolveContextShreddingOptions } from "../config/context-shredding-config.mjs";
|
|
58
59
|
|
|
@@ -2521,7 +2522,10 @@ export async function execPooledPrompt(userMessage, options = {}) {
|
|
|
2521
2522
|
/** @type {Map<string, ThreadRecord>} In-memory registry keyed by taskKey */
|
|
2522
2523
|
const threadRegistry = new Map();
|
|
2523
2524
|
|
|
2524
|
-
const
|
|
2525
|
+
const testSandbox = ensureTestRuntimeSandbox();
|
|
2526
|
+
const THREAD_REGISTRY_FILE = testSandbox?.cacheDir
|
|
2527
|
+
? resolve(testSandbox.cacheDir, "thread-registry.json")
|
|
2528
|
+
: resolve(__dirname, "..", "logs", "thread-registry.json");
|
|
2525
2529
|
const THREAD_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12 hours
|
|
2526
2530
|
|
|
2527
2531
|
/** Maximum turns before a thread is considered exhausted and must be replaced */
|
|
@@ -2707,7 +2711,7 @@ async function loadThreadRegistry() {
|
|
|
2707
2711
|
async function saveThreadRegistry() {
|
|
2708
2712
|
try {
|
|
2709
2713
|
const { writeFile, mkdir } = await import("node:fs/promises");
|
|
2710
|
-
await mkdir(
|
|
2714
|
+
await mkdir(dirname(THREAD_REGISTRY_FILE), { recursive: true });
|
|
2711
2715
|
const obj = Object.fromEntries(threadRegistry);
|
|
2712
2716
|
await writeFile(THREAD_REGISTRY_FILE, JSON.stringify(obj, null, 2), "utf8");
|
|
2713
2717
|
} catch {
|
package/agent/primary-agent.mjs
CHANGED
|
@@ -67,7 +67,9 @@ import {
|
|
|
67
67
|
import { getModelsForExecutor, normalizeExecutorKey } from "../task/task-complexity.mjs";
|
|
68
68
|
|
|
69
69
|
/** Valid agent interaction modes */
|
|
70
|
-
const
|
|
70
|
+
const CORE_MODES = ["ask", "agent", "plan", "web", "instant"];
|
|
71
|
+
/** Custom modes loaded from library */
|
|
72
|
+
const _customModes = new Map();
|
|
71
73
|
|
|
72
74
|
const MODE_ALIASES = Object.freeze({
|
|
73
75
|
code: "agent",
|
|
@@ -116,7 +118,37 @@ function normalizeAgentMode(rawMode, fallback = "agent") {
|
|
|
116
118
|
const normalized = String(rawMode || "").trim().toLowerCase();
|
|
117
119
|
if (!normalized) return fallback;
|
|
118
120
|
const mapped = MODE_ALIASES[normalized] || normalized;
|
|
119
|
-
return
|
|
121
|
+
return getValidModes().includes(mapped) ? mapped : fallback;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get all valid modes including dynamically registered custom modes.
|
|
126
|
+
* @returns {string[]}
|
|
127
|
+
*/
|
|
128
|
+
function getValidModes() {
|
|
129
|
+
return [...CORE_MODES, ..._customModes.keys()];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get mode prefix for a given mode, including custom modes.
|
|
134
|
+
* @param {string} mode
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
function getModePrefix(mode) {
|
|
138
|
+
if (MODE_PREFIXES[mode] !== undefined) return MODE_PREFIXES[mode];
|
|
139
|
+
const custom = _customModes.get(mode);
|
|
140
|
+
return custom?.prefix || "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get execution policy for a given mode, including custom modes.
|
|
145
|
+
* @param {string} mode
|
|
146
|
+
* @returns {object|null}
|
|
147
|
+
*/
|
|
148
|
+
function getModeExecPolicy(mode) {
|
|
149
|
+
if (MODE_EXEC_POLICIES[mode]) return MODE_EXEC_POLICIES[mode];
|
|
150
|
+
const custom = _customModes.get(mode);
|
|
151
|
+
return custom?.execPolicy || null;
|
|
120
152
|
}
|
|
121
153
|
|
|
122
154
|
function normalizeAttachments(input) {
|
|
@@ -908,7 +940,7 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
|
|
|
908
940
|
(options && options.sessionType ? String(options.sessionType) : "") ||
|
|
909
941
|
"primary";
|
|
910
942
|
const effectiveMode = normalizeAgentMode(options.mode || agentMode, agentMode);
|
|
911
|
-
const modePolicy =
|
|
943
|
+
const modePolicy = getModeExecPolicy(effectiveMode);
|
|
912
944
|
const timeoutMs = options.timeoutMs || modePolicy?.timeoutMs || PRIMARY_EXEC_TIMEOUT_MS;
|
|
913
945
|
const maxFailoverAttempts = Number.isInteger(options.maxFailoverAttempts)
|
|
914
946
|
? Math.max(0, Number(options.maxFailoverAttempts))
|
|
@@ -918,7 +950,7 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
|
|
|
918
950
|
const attachmentsAppended = options.attachmentsAppended === true;
|
|
919
951
|
|
|
920
952
|
// Apply mode prefix (options.mode overrides the global setting for this call)
|
|
921
|
-
const modePrefix =
|
|
953
|
+
const modePrefix = getModePrefix(effectiveMode);
|
|
922
954
|
const messageWithAttachments = attachments.length && !attachmentsAppended
|
|
923
955
|
? appendAttachmentsToPrompt(userMessage, attachments).message
|
|
924
956
|
: userMessage;
|
|
@@ -1241,8 +1273,8 @@ export function getAgentMode() {
|
|
|
1241
1273
|
*/
|
|
1242
1274
|
export function setAgentMode(mode) {
|
|
1243
1275
|
const normalized = normalizeAgentMode(mode, "");
|
|
1244
|
-
if (!
|
|
1245
|
-
return { ok: false, mode: agentMode, error: `Invalid mode "${mode}". Valid: ${
|
|
1276
|
+
if (!getValidModes().includes(normalized)) {
|
|
1277
|
+
return { ok: false, mode: agentMode, error: `Invalid mode "${mode}". Valid: ${getValidModes().join(", ")}` };
|
|
1246
1278
|
}
|
|
1247
1279
|
agentMode = normalized;
|
|
1248
1280
|
return { ok: true, mode: agentMode };
|
|
@@ -1254,10 +1286,52 @@ export function setAgentMode(mode) {
|
|
|
1254
1286
|
* @returns {string}
|
|
1255
1287
|
*/
|
|
1256
1288
|
export function applyModePrefix(userMessage) {
|
|
1257
|
-
const prefix =
|
|
1289
|
+
const prefix = getModePrefix(agentMode);
|
|
1258
1290
|
return prefix ? prefix + userMessage : userMessage;
|
|
1259
1291
|
}
|
|
1260
1292
|
|
|
1293
|
+
/**
|
|
1294
|
+
* Register a custom interaction mode at runtime.
|
|
1295
|
+
* Core modes cannot be overridden.
|
|
1296
|
+
* @param {string} id
|
|
1297
|
+
* @param {{ prefix?: string, execPolicy?: object|null, toolFilter?: object|null, description?: string }} config
|
|
1298
|
+
*/
|
|
1299
|
+
export function registerCustomMode(id, config) {
|
|
1300
|
+
if (!id || typeof id !== "string") return;
|
|
1301
|
+
const modeId = id.trim().toLowerCase();
|
|
1302
|
+
if (CORE_MODES.includes(modeId)) return;
|
|
1303
|
+
_customModes.set(modeId, {
|
|
1304
|
+
prefix: config.prefix || "",
|
|
1305
|
+
execPolicy: config.execPolicy || null,
|
|
1306
|
+
toolFilter: config.toolFilter || null,
|
|
1307
|
+
description: config.description || "",
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* List all available modes (core + custom) with metadata.
|
|
1313
|
+
* @returns {Array<{id: string, description: string, core: boolean}>}
|
|
1314
|
+
*/
|
|
1315
|
+
export function listAvailableModes() {
|
|
1316
|
+
const modes = CORE_MODES.map((m) => ({
|
|
1317
|
+
id: m,
|
|
1318
|
+
description: MODE_PREFIXES[m]?.slice(0, 80) || "Full agentic behavior",
|
|
1319
|
+
core: true,
|
|
1320
|
+
}));
|
|
1321
|
+
for (const [id, cfg] of _customModes) {
|
|
1322
|
+
modes.push({ id, description: cfg.description, core: false });
|
|
1323
|
+
}
|
|
1324
|
+
return modes;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Get all registered custom modes.
|
|
1329
|
+
* @returns {Array<{id: string, prefix: string, execPolicy: object|null, toolFilter: object|null, description: string}>}
|
|
1330
|
+
*/
|
|
1331
|
+
export function getCustomModes() {
|
|
1332
|
+
return [..._customModes.entries()].map(([id, cfg]) => ({ id, ...cfg }));
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1261
1335
|
/**
|
|
1262
1336
|
* Get the list of available agent adapters with capabilities.
|
|
1263
1337
|
* @returns {Array<{id:string, name:string, provider:string, available:boolean, busy:boolean, capabilities:object}>}
|
|
@@ -217,6 +217,11 @@ export function sha256File(pathLike) {
|
|
|
217
217
|
|
|
218
218
|
export function safeGit(args, cwd = process.cwd()) {
|
|
219
219
|
try {
|
|
220
|
+
// Block dangerous git arguments that could execute arbitrary commands
|
|
221
|
+
const blocked = ["--upload-pack", "--exec", "-c"];
|
|
222
|
+
for (const a of args) {
|
|
223
|
+
if (blocked.some((b) => String(a).startsWith(b))) return "";
|
|
224
|
+
}
|
|
220
225
|
return execFileSync("git", args, { encoding: "utf8", cwd }).trim();
|
|
221
226
|
} catch {
|
|
222
227
|
return "";
|
package/cli.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url";
|
|
|
28
28
|
import { execFileSync, execSync, fork, spawn } from "node:child_process";
|
|
29
29
|
import os from "node:os";
|
|
30
30
|
import { createDaemonCrashTracker } from "./infra/daemon-restart-policy.mjs";
|
|
31
|
+
import { ensureTestRuntimeSandbox } from "./infra/test-runtime.mjs";
|
|
31
32
|
import {
|
|
32
33
|
applyAllCompatibility,
|
|
33
34
|
detectLegacySetup,
|
|
@@ -140,6 +141,12 @@ function showHelp() {
|
|
|
140
141
|
--workspace-switch <id> Switch active workspace
|
|
141
142
|
--workspace-add-repo Add repo to workspace (interactive)
|
|
142
143
|
--workspace-health Run workspace health diagnostics
|
|
144
|
+
--workspace-pause <id> Pause a workspace (no new workflows)
|
|
145
|
+
--workspace-resume <id> Resume a paused workspace
|
|
146
|
+
--workspace-disable <id> Disable a workspace entirely
|
|
147
|
+
--workspace-status Show state summary of all workspaces
|
|
148
|
+
--workspace-executors <id> Show/set executor config for workspace
|
|
149
|
+
[--max-concurrent N] [--pool shared|dedicated] [--weight N]
|
|
143
150
|
|
|
144
151
|
TASK MANAGEMENT
|
|
145
152
|
task list [--status s] [--json] List tasks with optional filters
|
|
@@ -252,6 +259,9 @@ function resolveConfigDirForCli() {
|
|
|
252
259
|
const repoLocalConfigDir = resolveRepoLocalBosunDir(repoRoot);
|
|
253
260
|
if (repoLocalConfigDir) return repoLocalConfigDir;
|
|
254
261
|
|
|
262
|
+
const sandbox = ensureTestRuntimeSandbox();
|
|
263
|
+
if (sandbox?.configDir) return sandbox.configDir;
|
|
264
|
+
|
|
255
265
|
const preferWindowsDirs =
|
|
256
266
|
process.platform === "win32" && !isWslInteropRuntime();
|
|
257
267
|
const baseDir = preferWindowsDirs
|
|
@@ -368,10 +378,21 @@ function uniqueResolvedPaths(paths) {
|
|
|
368
378
|
return results;
|
|
369
379
|
}
|
|
370
380
|
|
|
381
|
+
function getWorkspaceScopedCacheDirCandidate(repoRootPath) {
|
|
382
|
+
const bosunDir = process.env.BOSUN_DIR || resolveConfigDirForCli();
|
|
383
|
+
if (!bosunDir || !repoRootPath) return null;
|
|
384
|
+
const parts = String(repoRootPath).replace(/\\/g, "/").split("/").filter(Boolean);
|
|
385
|
+
const repoName = parts.at(-1);
|
|
386
|
+
const workspaceName = parts.at(-2);
|
|
387
|
+
if (!repoName || !workspaceName) return null;
|
|
388
|
+
return resolve(bosunDir, "workspaces", workspaceName, repoName, ".cache");
|
|
389
|
+
}
|
|
390
|
+
|
|
371
391
|
function getRuntimeCacheDirCandidates(extraCacheDirs = []) {
|
|
372
392
|
return uniqueResolvedPaths([
|
|
373
393
|
...extraCacheDirs,
|
|
374
394
|
runtimeCacheDir,
|
|
395
|
+
getWorkspaceScopedCacheDirCandidate(runtimeRepoRoot),
|
|
375
396
|
process.env.BOSUN_DIR ? resolve(process.env.BOSUN_DIR, ".cache") : null,
|
|
376
397
|
resolve(__dirname, ".cache"),
|
|
377
398
|
resolve(process.cwd(), ".cache"),
|
|
@@ -625,6 +646,42 @@ function findGhostDaemonPids() {
|
|
|
625
646
|
}
|
|
626
647
|
}
|
|
627
648
|
|
|
649
|
+
function findGhostSentinelPids() {
|
|
650
|
+
if (process.platform === "win32") {
|
|
651
|
+
try {
|
|
652
|
+
const out = execFileSync(
|
|
653
|
+
"powershell",
|
|
654
|
+
[
|
|
655
|
+
"-NoProfile",
|
|
656
|
+
"-Command",
|
|
657
|
+
"Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(node|electron)(\\.exe)?$' -and $_.CommandLine -match 'telegram-sentinel\\.mjs' } | Select-Object -ExpandProperty ProcessId",
|
|
658
|
+
],
|
|
659
|
+
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 },
|
|
660
|
+
).trim();
|
|
661
|
+
if (!out) return [];
|
|
662
|
+
return out
|
|
663
|
+
.split(/\r?\n/)
|
|
664
|
+
.map((s) => parseInt(String(s).trim(), 10))
|
|
665
|
+
.filter((n) => Number.isFinite(n) && n > 0 && n !== process.pid);
|
|
666
|
+
} catch {
|
|
667
|
+
return [];
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
const out = execFileSync(
|
|
672
|
+
"pgrep",
|
|
673
|
+
["-f", "telegram-sentinel\\.mjs"],
|
|
674
|
+
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 },
|
|
675
|
+
).trim();
|
|
676
|
+
return out
|
|
677
|
+
.split("\n")
|
|
678
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
679
|
+
.filter((n) => Number.isFinite(n) && n > 0 && n !== process.pid);
|
|
680
|
+
} catch {
|
|
681
|
+
return [];
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
628
685
|
function writePidFile(pid) {
|
|
629
686
|
try {
|
|
630
687
|
mkdirSync(dirname(DAEMON_PID_FILE), { recursive: true });
|
|
@@ -852,10 +909,14 @@ function daemonStatus() {
|
|
|
852
909
|
} else {
|
|
853
910
|
// Check for ghost daemon-child processes (alive but no PID file)
|
|
854
911
|
const ghosts = findGhostDaemonPids();
|
|
912
|
+
const ghostSentinels = findGhostSentinelPids();
|
|
855
913
|
if (ghosts.length > 0) {
|
|
856
914
|
console.log(` :alert: bosun daemon is NOT tracked (no PID file), but ${ghosts.length} ghost process(es) found: ${ghosts.join(", ")}`);
|
|
857
915
|
console.log(` The daemon is likely running but its PID file was lost.`);
|
|
858
|
-
|
|
916
|
+
if (ghostSentinels.length > 0) {
|
|
917
|
+
console.log(` Ghost sentinel restart owner(s) detected: ${ghostSentinels.join(", ")}`);
|
|
918
|
+
}
|
|
919
|
+
console.log(` Run --terminate to stop restart owners, then --daemon to restart.`);
|
|
859
920
|
} else {
|
|
860
921
|
// Broader scan: portal, monitor, ui-server, etc. (non-daemon bosun processes)
|
|
861
922
|
const allPids = findAllBosunProcessPids();
|
|
@@ -1108,6 +1169,7 @@ async function terminateBosun() {
|
|
|
1108
1169
|
]).map((pidFile) => readAlivePid(pidFile)),
|
|
1109
1170
|
readSentinelPid(),
|
|
1110
1171
|
].filter((pid) => Number.isFinite(pid) && pid > 0);
|
|
1172
|
+
const sentinelGhostPids = findGhostSentinelPids();
|
|
1111
1173
|
const manualStopHoldMs =
|
|
1112
1174
|
Math.max(
|
|
1113
1175
|
0,
|
|
@@ -1119,9 +1181,17 @@ async function terminateBosun() {
|
|
|
1119
1181
|
...daemonPids,
|
|
1120
1182
|
...monitorPids,
|
|
1121
1183
|
...sentinelPids,
|
|
1184
|
+
...sentinelGhostPids,
|
|
1185
|
+
...ghosts,
|
|
1122
1186
|
]);
|
|
1123
1187
|
const restartOwnerPids = Array.from(
|
|
1124
|
-
new Set([
|
|
1188
|
+
new Set([
|
|
1189
|
+
...ancestorPids,
|
|
1190
|
+
...sentinelPids,
|
|
1191
|
+
...sentinelGhostPids,
|
|
1192
|
+
...daemonPids,
|
|
1193
|
+
...ghosts,
|
|
1194
|
+
]),
|
|
1125
1195
|
).filter((pid) => pid !== process.pid);
|
|
1126
1196
|
const tracked = [...restartOwnerPids, ...monitorPids];
|
|
1127
1197
|
const trackedPids = Array.from(new Set([...tracked, ...ghosts])).filter(
|
|
@@ -1742,7 +1812,11 @@ async function main() {
|
|
|
1742
1812
|
console.log("\n Workspaces:");
|
|
1743
1813
|
for (const ws of workspaces) {
|
|
1744
1814
|
const marker = ws.id === active?.id ? " ← active" : "";
|
|
1745
|
-
|
|
1815
|
+
const stateIcon = ws.state === "active" ? "●" : ws.state === "paused" ? "◐" : "○";
|
|
1816
|
+
const stateLabel = ws.state !== "active" ? ` [${ws.state}]` : "";
|
|
1817
|
+
console.log(` ${stateIcon} ${ws.name} (${ws.id})${stateLabel}${marker}`);
|
|
1818
|
+
const ex = ws.executors;
|
|
1819
|
+
console.log(` executors: max=${ex.maxConcurrent}, pool=${ex.pool}, weight=${ex.weight}`);
|
|
1746
1820
|
for (const repo of ws.repos || []) {
|
|
1747
1821
|
const primary = repo.primary ? " [primary]" : "";
|
|
1748
1822
|
const exists = repo.exists ? "✓" : "✗";
|
|
@@ -1831,6 +1905,137 @@ async function main() {
|
|
|
1831
1905
|
process.exit(result.ok ? 0 : 1);
|
|
1832
1906
|
}
|
|
1833
1907
|
|
|
1908
|
+
// Handle --workspace-pause
|
|
1909
|
+
if (args.includes("--workspace-pause") || args.includes("workspace-pause")) {
|
|
1910
|
+
const { pauseWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
|
|
1911
|
+
const configDirArg = getArgValue("--config-dir");
|
|
1912
|
+
const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
|
|
1913
|
+
const wsId = getArgValue("--workspace-pause") || getArgValue("workspace-pause");
|
|
1914
|
+
if (!wsId) {
|
|
1915
|
+
console.error(" Error: workspace ID required. Usage: bosun --workspace-pause <id>");
|
|
1916
|
+
process.exit(1);
|
|
1917
|
+
}
|
|
1918
|
+
try {
|
|
1919
|
+
pauseWorkspace(configDir, wsId);
|
|
1920
|
+
const ws = getWorkspace(configDir, wsId);
|
|
1921
|
+
console.log(`\n ⏸ Workspace "${ws?.name || wsId}" paused — no new workflows will start\n`);
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
console.error(` Error: ${err.message}`);
|
|
1924
|
+
process.exit(1);
|
|
1925
|
+
}
|
|
1926
|
+
process.exit(0);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// Handle --workspace-resume
|
|
1930
|
+
if (args.includes("--workspace-resume") || args.includes("workspace-resume")) {
|
|
1931
|
+
const { resumeWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
|
|
1932
|
+
const configDirArg = getArgValue("--config-dir");
|
|
1933
|
+
const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
|
|
1934
|
+
const wsId = getArgValue("--workspace-resume") || getArgValue("workspace-resume");
|
|
1935
|
+
if (!wsId) {
|
|
1936
|
+
console.error(" Error: workspace ID required. Usage: bosun --workspace-resume <id>");
|
|
1937
|
+
process.exit(1);
|
|
1938
|
+
}
|
|
1939
|
+
try {
|
|
1940
|
+
resumeWorkspace(configDir, wsId);
|
|
1941
|
+
const ws = getWorkspace(configDir, wsId);
|
|
1942
|
+
console.log(`\n ▶ Workspace "${ws?.name || wsId}" resumed — workflows will trigger normally\n`);
|
|
1943
|
+
} catch (err) {
|
|
1944
|
+
console.error(` Error: ${err.message}`);
|
|
1945
|
+
process.exit(1);
|
|
1946
|
+
}
|
|
1947
|
+
process.exit(0);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// Handle --workspace-disable
|
|
1951
|
+
if (args.includes("--workspace-disable") || args.includes("workspace-disable")) {
|
|
1952
|
+
const { disableWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
|
|
1953
|
+
const configDirArg = getArgValue("--config-dir");
|
|
1954
|
+
const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
|
|
1955
|
+
const wsId = getArgValue("--workspace-disable") || getArgValue("workspace-disable");
|
|
1956
|
+
if (!wsId) {
|
|
1957
|
+
console.error(" Error: workspace ID required. Usage: bosun --workspace-disable <id>");
|
|
1958
|
+
process.exit(1);
|
|
1959
|
+
}
|
|
1960
|
+
try {
|
|
1961
|
+
disableWorkspace(configDir, wsId);
|
|
1962
|
+
const ws = getWorkspace(configDir, wsId);
|
|
1963
|
+
console.log(`\n ⏹ Workspace "${ws?.name || wsId}" disabled — no workflows, no executors\n`);
|
|
1964
|
+
} catch (err) {
|
|
1965
|
+
console.error(` Error: ${err.message}`);
|
|
1966
|
+
process.exit(1);
|
|
1967
|
+
}
|
|
1968
|
+
process.exit(0);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// Handle --workspace-status
|
|
1972
|
+
if (args.includes("--workspace-status") || args.includes("workspace-status")) {
|
|
1973
|
+
const { getWorkspaceStateSummary } = await import("./workspace/workspace-manager.mjs");
|
|
1974
|
+
const configDirArg = getArgValue("--config-dir");
|
|
1975
|
+
const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
|
|
1976
|
+
const summary = getWorkspaceStateSummary(configDir);
|
|
1977
|
+
if (summary.length === 0) {
|
|
1978
|
+
console.log("\n No workspaces configured.\n");
|
|
1979
|
+
} else {
|
|
1980
|
+
console.log("\n Workspace Status:");
|
|
1981
|
+
for (const ws of summary) {
|
|
1982
|
+
const stateIcon = ws.state === "active" ? "●" : ws.state === "paused" ? "◐" : "○";
|
|
1983
|
+
const current = ws.isCurrent ? " ← current" : "";
|
|
1984
|
+
console.log(` ${stateIcon} ${ws.name} (${ws.id}) — ${ws.state}${current}`);
|
|
1985
|
+
const ex = ws.executors;
|
|
1986
|
+
console.log(` executors: max=${ex.maxConcurrent}, pool=${ex.pool}, weight=${ex.weight}`);
|
|
1987
|
+
if (ws.disabledWorkflows.length > 0) {
|
|
1988
|
+
console.log(` disabled workflows: ${ws.disabledWorkflows.join(", ")}`);
|
|
1989
|
+
}
|
|
1990
|
+
if (ws.enabledWorkflows.length > 0) {
|
|
1991
|
+
console.log(` enabled workflows: ${ws.enabledWorkflows.join(", ")}`);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
console.log("");
|
|
1995
|
+
}
|
|
1996
|
+
process.exit(0);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// Handle --workspace-executors
|
|
2000
|
+
if (args.includes("--workspace-executors") || args.includes("workspace-executors")) {
|
|
2001
|
+
const { setWorkspaceExecutors, getWorkspace } = await import("./workspace/workspace-manager.mjs");
|
|
2002
|
+
const configDirArg = getArgValue("--config-dir");
|
|
2003
|
+
const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
|
|
2004
|
+
const wsId = getArgValue("--workspace-executors") || getArgValue("workspace-executors");
|
|
2005
|
+
if (!wsId) {
|
|
2006
|
+
console.error(" Error: workspace ID required. Usage: bosun --workspace-executors <id> [--max-concurrent N] [--pool shared|dedicated] [--weight N]");
|
|
2007
|
+
process.exit(1);
|
|
2008
|
+
}
|
|
2009
|
+
const maxConcurrent = getArgValue("--max-concurrent");
|
|
2010
|
+
const pool = getArgValue("--pool");
|
|
2011
|
+
const weight = getArgValue("--weight");
|
|
2012
|
+
const hasUpdate = maxConcurrent || pool || weight;
|
|
2013
|
+
if (hasUpdate) {
|
|
2014
|
+
try {
|
|
2015
|
+
const opts = {};
|
|
2016
|
+
if (maxConcurrent) opts.maxConcurrent = Number(maxConcurrent);
|
|
2017
|
+
if (pool) opts.pool = pool;
|
|
2018
|
+
if (weight) opts.weight = Number(weight);
|
|
2019
|
+
const result = setWorkspaceExecutors(configDir, wsId, opts);
|
|
2020
|
+
console.log(`\n ✓ Executor config updated for "${wsId}":`, JSON.stringify(result), "\n");
|
|
2021
|
+
} catch (err) {
|
|
2022
|
+
console.error(` Error: ${err.message}`);
|
|
2023
|
+
process.exit(1);
|
|
2024
|
+
}
|
|
2025
|
+
} else {
|
|
2026
|
+
const ws = getWorkspace(configDir, wsId);
|
|
2027
|
+
if (!ws) {
|
|
2028
|
+
console.error(` Error: workspace "${wsId}" not found`);
|
|
2029
|
+
process.exit(1);
|
|
2030
|
+
}
|
|
2031
|
+
console.log(`\n Executor config for "${ws.name}":`);
|
|
2032
|
+
console.log(` maxConcurrent: ${ws.executors.maxConcurrent}`);
|
|
2033
|
+
console.log(` pool: ${ws.executors.pool}`);
|
|
2034
|
+
console.log(` weight: ${ws.executors.weight}\n`);
|
|
2035
|
+
}
|
|
2036
|
+
process.exit(0);
|
|
2037
|
+
}
|
|
2038
|
+
|
|
1834
2039
|
// Handle --setup-terminal (legacy terminal wizard)
|
|
1835
2040
|
if (args.includes("--setup-terminal")) {
|
|
1836
2041
|
const configDirArg = getArgValue("--config-dir");
|
package/config/config-doctor.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { resolve, dirname, isAbsolute, relative, join } from "node:path";
|
|
3
3
|
import { execSync, spawnSync } from "node:child_process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
+
import { ensureTestRuntimeSandbox } from "../infra/test-runtime.mjs";
|
|
7
|
+
import { getWorkflowContract } from "../workflow/workflow-contract.mjs";
|
|
6
8
|
|
|
7
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
10
|
const CONFIG_FILES = [
|
|
@@ -10,6 +12,10 @@ const CONFIG_FILES = [
|
|
|
10
12
|
".bosun.json",
|
|
11
13
|
"bosun.json",
|
|
12
14
|
];
|
|
15
|
+
const WORKFLOW_CONTRACT_NODE_TYPES = Object.freeze({
|
|
16
|
+
read: new Set(["read-workflow-contract", "action.read_workflow_contract"]),
|
|
17
|
+
validate: new Set(["workflow-contract-validation", "action.workflow_contract_validation"]),
|
|
18
|
+
});
|
|
13
19
|
|
|
14
20
|
function parseBool(value) {
|
|
15
21
|
return ["1", "true", "yes", "on"].includes(
|
|
@@ -92,6 +98,9 @@ function resolveConfigDir(repoRoot) {
|
|
|
92
98
|
return packageDir;
|
|
93
99
|
}
|
|
94
100
|
|
|
101
|
+
const sandbox = ensureTestRuntimeSandbox();
|
|
102
|
+
if (sandbox?.configDir) return sandbox.configDir;
|
|
103
|
+
|
|
95
104
|
const preferWindowsDirs =
|
|
96
105
|
process.platform === "win32" && !isWslInteropRuntime();
|
|
97
106
|
const baseDir =
|
|
@@ -166,6 +175,36 @@ function findConfigFile(configDir) {
|
|
|
166
175
|
return null;
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
function workflowHasContractNodes(definition) {
|
|
179
|
+
const nodes = Array.isArray(definition?.nodes) ? definition.nodes : [];
|
|
180
|
+
let hasRead = false;
|
|
181
|
+
let hasValidate = false;
|
|
182
|
+
for (const node of nodes) {
|
|
183
|
+
const type = String(node?.type || "").trim();
|
|
184
|
+
if (WORKFLOW_CONTRACT_NODE_TYPES.read.has(type)) hasRead = true;
|
|
185
|
+
if (WORKFLOW_CONTRACT_NODE_TYPES.validate.has(type)) hasValidate = true;
|
|
186
|
+
}
|
|
187
|
+
return hasRead && hasValidate;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function hasEnabledWorkflowContractStep(repoRoot) {
|
|
191
|
+
const workflowDir = resolve(repoRoot, ".bosun", "workflows");
|
|
192
|
+
if (!existsSync(workflowDir)) return false;
|
|
193
|
+
|
|
194
|
+
for (const file of readdirSync(workflowDir)) {
|
|
195
|
+
if (!file.endsWith(".json")) continue;
|
|
196
|
+
try {
|
|
197
|
+
const definition = JSON.parse(readFileSync(resolve(workflowDir, file), "utf8"));
|
|
198
|
+
if (definition?.enabled === false) continue;
|
|
199
|
+
if (workflowHasContractNodes(definition)) return true;
|
|
200
|
+
} catch {
|
|
201
|
+
/* ignore malformed workflow files here; other checks can catch them */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
169
208
|
function validateExecutors(raw, issues) {
|
|
170
209
|
if (!raw) return;
|
|
171
210
|
const entries = String(raw)
|
|
@@ -671,6 +710,17 @@ export function runConfigDoctor(options = {}) {
|
|
|
671
710
|
});
|
|
672
711
|
}
|
|
673
712
|
|
|
713
|
+
const workflowContract = getWorkflowContract(repoRoot);
|
|
714
|
+
if (workflowContract.found && !hasEnabledWorkflowContractStep(repoRoot)) {
|
|
715
|
+
issues.warnings.push({
|
|
716
|
+
code: "WORKFLOW_CONTRACT_STEP_DISABLED",
|
|
717
|
+
message:
|
|
718
|
+
"WORKFLOW.md exists but no enabled workflow includes the read/validate workflow-contract steps.",
|
|
719
|
+
fix:
|
|
720
|
+
"Install or update the session-start workflow (for example template-task-lifecycle) so it includes `read-workflow-contract` and `workflow-contract-validation`.",
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
674
724
|
issues.infos.push({
|
|
675
725
|
code: "PATHS",
|
|
676
726
|
message: `Config directory: ${configDir}`,
|
|
@@ -988,4 +1038,3 @@ export function formatWorkspaceHealthReport(result) {
|
|
|
988
1038
|
return lines.join("\n");
|
|
989
1039
|
}
|
|
990
1040
|
|
|
991
|
-
|