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.
Files changed (41) hide show
  1. package/agent/agent-custom-tools.mjs +23 -5
  2. package/agent/agent-pool.mjs +6 -2
  3. package/agent/primary-agent.mjs +81 -7
  4. package/bench/swebench/bosun-swebench.mjs +5 -0
  5. package/cli.mjs +208 -3
  6. package/config/config-doctor.mjs +51 -2
  7. package/config/config.mjs +103 -3
  8. package/github/github-auth-manager.mjs +70 -19
  9. package/infra/library-manager.mjs +894 -60
  10. package/infra/monitor.mjs +8 -2
  11. package/infra/session-tracker.mjs +13 -3
  12. package/infra/test-runtime.mjs +267 -0
  13. package/package.json +8 -5
  14. package/server/setup-web-server.mjs +4 -1
  15. package/server/ui-server.mjs +1323 -20
  16. package/task/task-claims.mjs +6 -10
  17. package/ui/components/chat-view.js +18 -1
  18. package/ui/components/workspace-switcher.js +321 -9
  19. package/ui/demo-defaults.js +11746 -9470
  20. package/ui/demo.html +9 -1
  21. package/ui/modules/router.js +1 -1
  22. package/ui/modules/voice-client-sdk.js +1 -1
  23. package/ui/modules/voice-client.js +33 -2
  24. package/ui/styles/components.css +514 -1
  25. package/ui/tabs/library.js +410 -55
  26. package/ui/tabs/tasks.js +1052 -506
  27. package/ui/tabs/workflow-canvas-utils.mjs +30 -0
  28. package/ui/tabs/workflows.js +914 -298
  29. package/voice/voice-agents-sdk.mjs +1 -1
  30. package/voice/voice-relay.mjs +24 -16
  31. package/workflow/project-detection.mjs +559 -0
  32. package/workflow/workflow-contract.mjs +433 -232
  33. package/workflow/workflow-engine.mjs +181 -30
  34. package/workflow/workflow-nodes.mjs +304 -6
  35. package/workflow/workflow-templates.mjs +92 -16
  36. package/workflow-templates/agents.mjs +20 -19
  37. package/workflow-templates/code-quality.mjs +20 -14
  38. package/workflow-templates/task-batch.mjs +3 -2
  39. package/workflow-templates/task-execution.mjs +752 -0
  40. package/workflow-templates/task-lifecycle.mjs +34 -8
  41. package/workspace/workspace-manager.mjs +151 -0
package/config/config.mjs CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  getModelsForExecutor,
32
32
  MODEL_ALIASES,
33
33
  } from "../task/task-complexity.mjs";
34
+ import { ensureTestRuntimeSandbox } from "../infra/test-runtime.mjs";
34
35
 
35
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
36
37
 
@@ -133,7 +134,11 @@ function resolveConfigDir(repoRoot) {
133
134
  const repoLocalConfigDir = resolveRepoLocalBosunDir(repoRoot);
134
135
  if (repoLocalConfigDir) return repoLocalConfigDir;
135
136
 
136
- // 3. Platform-aware user home
137
+ // 3. Tests must not fall through to the user's real global Bosun home.
138
+ const sandbox = ensureTestRuntimeSandbox();
139
+ if (sandbox?.configDir) return sandbox.configDir;
140
+
141
+ // 4. Platform-aware user home
137
142
  const preferWindowsDirs =
138
143
  process.platform === "win32" && !isWslInteropRuntime();
139
144
  const baseDir = preferWindowsDirs
@@ -1000,10 +1005,89 @@ class ExecutorScheduler {
1000
1005
  this._roundRobinIndex = 0;
1001
1006
  this._failureCounts = new Map(); // name → consecutive failures
1002
1007
  this._disabledUntil = new Map(); // name → timestamp
1008
+ this._workspaceActiveCount = new Map(); // workspaceId → current active executor count
1009
+ this._workspaceConfigs = new Map(); // workspaceId → { maxConcurrent, pool, weight }
1010
+ }
1011
+
1012
+ /**
1013
+ * Register workspace executor config for concurrency tracking.
1014
+ * @param {string} workspaceId
1015
+ * @param {{ maxConcurrent?: number, pool?: string, weight?: number }} wsExecutorConfig
1016
+ */
1017
+ registerWorkspace(workspaceId, wsExecutorConfig = {}) {
1018
+ if (!workspaceId) return;
1019
+ this._workspaceConfigs.set(workspaceId, {
1020
+ maxConcurrent: wsExecutorConfig.maxConcurrent ?? 3,
1021
+ pool: wsExecutorConfig.pool ?? "shared",
1022
+ weight: wsExecutorConfig.weight ?? 1.0,
1023
+ executors: wsExecutorConfig.executors ?? null,
1024
+ });
1025
+ if (!this._workspaceActiveCount.has(workspaceId)) {
1026
+ this._workspaceActiveCount.set(workspaceId, 0);
1027
+ }
1028
+ }
1029
+
1030
+ /**
1031
+ * Check if a workspace has available executor slots.
1032
+ * @param {string} [workspaceId]
1033
+ * @returns {boolean}
1034
+ */
1035
+ hasAvailableSlot(workspaceId) {
1036
+ if (!workspaceId) return true; // no workspace scope — always available
1037
+ const config = this._workspaceConfigs.get(workspaceId);
1038
+ if (!config) return true; // no config registered — no limit
1039
+ const active = this._workspaceActiveCount.get(workspaceId) || 0;
1040
+ return active < config.maxConcurrent;
1041
+ }
1042
+
1043
+ /**
1044
+ * Acquire an executor slot for a workspace.
1045
+ * @param {string} [workspaceId]
1046
+ * @returns {boolean} true if slot acquired, false if at limit
1047
+ */
1048
+ acquireSlot(workspaceId) {
1049
+ if (!workspaceId) return true;
1050
+ if (!this.hasAvailableSlot(workspaceId)) return false;
1051
+ this._workspaceActiveCount.set(
1052
+ workspaceId,
1053
+ (this._workspaceActiveCount.get(workspaceId) || 0) + 1,
1054
+ );
1055
+ return true;
1056
+ }
1057
+
1058
+ /**
1059
+ * Release an executor slot for a workspace.
1060
+ * @param {string} [workspaceId]
1061
+ */
1062
+ releaseSlot(workspaceId) {
1063
+ if (!workspaceId) return;
1064
+ const current = this._workspaceActiveCount.get(workspaceId) || 0;
1065
+ this._workspaceActiveCount.set(workspaceId, Math.max(0, current - 1));
1066
+ }
1067
+
1068
+ /**
1069
+ * Get workspace executor usage summary.
1070
+ * @returns {Array<{ workspaceId: string, active: number, maxConcurrent: number, pool: string, weight: number }>}
1071
+ */
1072
+ getWorkspaceSummary() {
1073
+ const result = [];
1074
+ for (const [wsId, config] of this._workspaceConfigs) {
1075
+ result.push({
1076
+ workspaceId: wsId,
1077
+ active: this._workspaceActiveCount.get(wsId) || 0,
1078
+ ...config,
1079
+ });
1080
+ }
1081
+ return result;
1003
1082
  }
1004
1083
 
1005
1084
  /** Get the next executor based on distribution strategy */
1006
- next() {
1085
+ next(workspaceId) {
1086
+ // Check workspace slot availability before selecting
1087
+ if (workspaceId && !this.hasAvailableSlot(workspaceId)) {
1088
+ return null; // workspace at executor capacity
1089
+ }
1090
+
1007
1091
  const available = this._getAvailable();
1008
1092
  if (!available.length) {
1009
1093
  // All disabled — reset and use primary
@@ -1012,6 +1096,23 @@ class ExecutorScheduler {
1012
1096
  return this.executors[0];
1013
1097
  }
1014
1098
 
1099
+ // For dedicated pools, filter to workspace-assigned executors
1100
+ if (workspaceId) {
1101
+ const wsConfig = this._workspaceConfigs.get(workspaceId);
1102
+ if (wsConfig?.pool === "dedicated" && wsConfig.executors) {
1103
+ const dedicated = available.filter((e) =>
1104
+ wsConfig.executors.includes(e.name),
1105
+ );
1106
+ if (dedicated.length) {
1107
+ return this._selectByStrategy(dedicated);
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ return this._selectByStrategy(available);
1113
+ }
1114
+
1115
+ _selectByStrategy(available) {
1015
1116
  switch (this.distribution) {
1016
1117
  case "round-robin":
1017
1118
  return this._roundRobin(available);
@@ -2419,4 +2520,3 @@ export {
2419
2520
  resolveAgentRepoRoot,
2420
2521
  };
2421
2522
  export default loadConfig;
2422
-
@@ -21,6 +21,10 @@ import {
21
21
 
22
22
  const BOSUN_AUTH_STATE_PATH = join(homedir(), ".bosun", "github-auth-state.json");
23
23
 
24
+ /** Set of token types that have been invalidated due to 401 errors (resets after 5 min). */
25
+ const _invalidatedTypes = new Map(); // type → expiry timestamp
26
+ const INVALIDATION_TTL_MS = 5 * 60 * 1000;
27
+
24
28
  // ── OAuth state loader ────────────────────────────────────────────────────────
25
29
 
26
30
  /**
@@ -30,13 +34,13 @@ const BOSUN_AUTH_STATE_PATH = join(homedir(), ".bosun", "github-auth-state.json"
30
34
  async function loadSavedOAuthToken() {
31
35
  // First check env override
32
36
  const envToken = process.env.BOSUN_GITHUB_USER_TOKEN;
33
- if (envToken) return envToken;
37
+ if (envToken && looksLikeRealToken(envToken)) return envToken;
34
38
 
35
39
  try {
36
40
  const raw = await readFile(BOSUN_AUTH_STATE_PATH, "utf8");
37
41
  const data = JSON.parse(raw);
38
42
  const token = data?.accessToken || data?.access_token || null;
39
- if (!token) return null;
43
+ if (!token || !looksLikeRealToken(token)) return null;
40
44
 
41
45
  // If there's an expiry, check it
42
46
  if (data.expiresAt || data.expires_at) {
@@ -53,6 +57,23 @@ async function loadSavedOAuthToken() {
53
57
  }
54
58
  }
55
59
 
60
+ /**
61
+ * Sanity-check that a token string looks like a real GitHub token.
62
+ * Rejects placeholder values, very short strings, and obvious non-tokens.
63
+ */
64
+ function looksLikeRealToken(token) {
65
+ if (!token || typeof token !== "string") return false;
66
+ const t = token.trim();
67
+ if (t.length < 8) return false;
68
+ // Reject common placeholder values
69
+ const PLACEHOLDERS = [
70
+ "oauth-token", "your-token", "token-here", "placeholder",
71
+ "xxxx", "test-token", "fake-token", "replace-me", "changeme",
72
+ ];
73
+ if (PLACEHOLDERS.includes(t.toLowerCase())) return false;
74
+ return true;
75
+ }
76
+
56
77
  // ── gh CLI fallback ───────────────────────────────────────────────────────────
57
78
 
58
79
  /**
@@ -97,27 +118,41 @@ async function verifyToken(token) {
97
118
 
98
119
  /**
99
120
  * Get the best available token for GitHub API calls.
121
+ * Skips token sources that have been recently invalidated (401 errors).
100
122
  *
101
123
  * @param {Object} [options]
102
124
  * @param {string} [options.owner] - repo owner (for installation token resolution)
103
125
  * @param {string} [options.repo] - repo name (for installation token resolution)
104
126
  * @param {boolean} [options.verify] - verify token via /user API (default: false for perf)
127
+ * @param {string} [options.skipType] - skip a specific token type (used during retry)
105
128
  * @returns {Promise<{token: string, type: 'oauth'|'installation'|'gh-cli'|'env', login?: string}>}
106
129
  */
107
130
  export async function getGitHubToken(options = {}) {
108
- const { owner, repo, verify = false } = options;
131
+ const { owner, repo, verify = false, skipType } = options;
132
+ const now = Date.now();
133
+
134
+ // Purge expired invalidations
135
+ for (const [key, expiry] of _invalidatedTypes) {
136
+ if (now >= expiry) _invalidatedTypes.delete(key);
137
+ }
138
+
139
+ const isSkipped = (type) =>
140
+ type === skipType || _invalidatedTypes.has(type);
109
141
 
110
142
  // ── 1. OAuth user token ───────────────────────────────────────────────────
111
- const oauthToken = await loadSavedOAuthToken();
112
- if (oauthToken) {
113
- const login = verify ? await verifyToken(oauthToken) : undefined;
114
- if (!verify || login) {
115
- return { token: oauthToken, type: "oauth", login: login ?? undefined };
143
+ if (!isSkipped("oauth")) {
144
+ const oauthToken = await loadSavedOAuthToken();
145
+ if (oauthToken) {
146
+ const login = verify ? await verifyToken(oauthToken) : undefined;
147
+ if (!verify || login) {
148
+ return { token: oauthToken, type: "oauth", login: login ?? undefined };
149
+ }
150
+ // verify failed — skip oauth for this resolution cycle
116
151
  }
117
152
  }
118
153
 
119
154
  // ── 2. GitHub App installation token ─────────────────────────────────────
120
- if (owner && repo && isAppConfigured()) {
155
+ if (!isSkipped("installation") && owner && repo && isAppConfigured()) {
121
156
  try {
122
157
  const { token } = await getInstallationTokenForRepo(owner, repo);
123
158
  if (token) {
@@ -129,19 +164,23 @@ export async function getGitHubToken(options = {}) {
129
164
  }
130
165
 
131
166
  // ── 3. gh CLI token ───────────────────────────────────────────────────────
132
- const ghCliToken = await getGhCliToken();
133
- if (ghCliToken) {
134
- return { token: ghCliToken, type: "gh-cli" };
167
+ if (!isSkipped("gh-cli")) {
168
+ const ghCliToken = await getGhCliToken();
169
+ if (ghCliToken) {
170
+ return { token: ghCliToken, type: "gh-cli" };
171
+ }
135
172
  }
136
173
 
137
174
  // ── 4. Environment variable fallback ─────────────────────────────────────
138
- const envToken =
139
- process.env.GITHUB_TOKEN ||
140
- process.env.GH_TOKEN ||
141
- process.env.GITHUB_PAT ||
142
- "";
143
- if (envToken) {
144
- return { token: envToken, type: "env" };
175
+ if (!isSkipped("env")) {
176
+ const envToken =
177
+ process.env.GITHUB_TOKEN ||
178
+ process.env.GH_TOKEN ||
179
+ process.env.GITHUB_PAT ||
180
+ "";
181
+ if (envToken) {
182
+ return { token: envToken, type: "env" };
183
+ }
145
184
  }
146
185
 
147
186
  throw new Error(
@@ -263,3 +302,15 @@ export async function getAuthStatus() {
263
302
  message: `No GitHub authentication available. ${hints.join(". ")}`,
264
303
  };
265
304
  }
305
+
306
+ /**
307
+ * Mark a token type as invalid for 5 minutes so getGitHubToken() will skip it.
308
+ * Call this when a GitHub API call returns 401 to trigger fallback to the next source.
309
+ *
310
+ * @param {string} type - 'oauth' | 'installation' | 'gh-cli' | 'env'
311
+ */
312
+ export function invalidateTokenType(type) {
313
+ if (type) {
314
+ _invalidatedTypes.set(type, Date.now() + INVALIDATION_TTL_MS);
315
+ }
316
+ }