@ipul-labs/forge 1.21.2 → 1.35.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 (298) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -78
  3. package/dist/apple-touch-icon.png +0 -0
  4. package/dist/assets/index-BmEuPeOG.js +27 -0
  5. package/dist/assets/index-CSH1OQYQ.css +1 -0
  6. package/dist/boot-check.js +98 -0
  7. package/dist/favicon.svg +8 -0
  8. package/dist/icon-192.png +0 -0
  9. package/dist/icon-512.png +0 -0
  10. package/dist/icon.svg +12 -0
  11. package/dist/index.html +33 -0
  12. package/dist/manifest.webmanifest +18 -0
  13. package/dist/theme-init.js +11 -0
  14. package/lib/bin/cli/analyze.d.ts.map +1 -1
  15. package/lib/bin/cli/analyze.js +3 -44
  16. package/lib/bin/cli/analyze.js.map +1 -1
  17. package/lib/bin/cli/apply.d.ts.map +1 -1
  18. package/lib/bin/cli/apply.js +2 -20
  19. package/lib/bin/cli/apply.js.map +1 -1
  20. package/lib/bin/cli/cifix.d.ts.map +1 -1
  21. package/lib/bin/cli/cifix.js +4 -28
  22. package/lib/bin/cli/cifix.js.map +1 -1
  23. package/lib/bin/cli/close.d.ts.map +1 -1
  24. package/lib/bin/cli/close.js +2 -33
  25. package/lib/bin/cli/close.js.map +1 -1
  26. package/lib/bin/cli/createIssue.d.ts.map +1 -1
  27. package/lib/bin/cli/createIssue.js +3 -12
  28. package/lib/bin/cli/createIssue.js.map +1 -1
  29. package/lib/bin/cli/doctor.d.ts.map +1 -1
  30. package/lib/bin/cli/doctor.js +3 -4
  31. package/lib/bin/cli/doctor.js.map +1 -1
  32. package/lib/bin/cli/init.js +1 -10
  33. package/lib/bin/cli/init.js.map +1 -1
  34. package/lib/bin/cli/issue.d.ts.map +1 -1
  35. package/lib/bin/cli/issue.js +3 -12
  36. package/lib/bin/cli/issue.js.map +1 -1
  37. package/lib/bin/cli/issues.d.ts.map +1 -1
  38. package/lib/bin/cli/issues.js +3 -12
  39. package/lib/bin/cli/issues.js.map +1 -1
  40. package/lib/bin/cli/merge.d.ts.map +1 -1
  41. package/lib/bin/cli/merge.js +4 -20
  42. package/lib/bin/cli/merge.js.map +1 -1
  43. package/lib/bin/cli/refine.d.ts.map +1 -1
  44. package/lib/bin/cli/refine.js +2 -26
  45. package/lib/bin/cli/refine.js.map +1 -1
  46. package/lib/bin/cli/repl.d.ts.map +1 -1
  47. package/lib/bin/cli/repl.js +4 -13
  48. package/lib/bin/cli/repl.js.map +1 -1
  49. package/lib/bin/cli/repos.d.ts.map +1 -1
  50. package/lib/bin/cli/repos.js +1 -11
  51. package/lib/bin/cli/repos.js.map +1 -1
  52. package/lib/bin/cli/resetPassword.d.ts +21 -0
  53. package/lib/bin/cli/resetPassword.d.ts.map +1 -0
  54. package/lib/bin/cli/resetPassword.js +69 -0
  55. package/lib/bin/cli/resetPassword.js.map +1 -0
  56. package/lib/bin/cli/resolution.d.ts.map +1 -1
  57. package/lib/bin/cli/resolution.js +2 -19
  58. package/lib/bin/cli/resolution.js.map +1 -1
  59. package/lib/bin/cli/serverClient.d.ts +2 -0
  60. package/lib/bin/cli/serverClient.d.ts.map +1 -1
  61. package/lib/bin/cli/serverClient.js +8 -4
  62. package/lib/bin/cli/serverClient.js.map +1 -1
  63. package/lib/bin/cli/session.d.ts.map +1 -1
  64. package/lib/bin/cli/session.js.map +1 -1
  65. package/lib/bin/cli/sessionStore.d.ts +1 -1
  66. package/lib/bin/cli/sessionStore.js +2 -2
  67. package/lib/bin/cli/sessionStore.js.map +1 -1
  68. package/lib/bin/cli/ship.d.ts.map +1 -1
  69. package/lib/bin/cli/ship.js +15 -23
  70. package/lib/bin/cli/ship.js.map +1 -1
  71. package/lib/bin/cli/update.d.ts.map +1 -1
  72. package/lib/bin/cli/update.js +34 -28
  73. package/lib/bin/cli/update.js.map +1 -1
  74. package/lib/bin/cli/updatePlan.d.ts +30 -0
  75. package/lib/bin/cli/updatePlan.d.ts.map +1 -0
  76. package/lib/bin/cli/updatePlan.js +50 -0
  77. package/lib/bin/cli/updatePlan.js.map +1 -0
  78. package/lib/bin/cli/utils.d.ts +3 -0
  79. package/lib/bin/cli/utils.d.ts.map +1 -1
  80. package/lib/bin/cli/utils.js +7 -23
  81. package/lib/bin/cli/utils.js.map +1 -1
  82. package/lib/bin/cli/validate.d.ts.map +1 -1
  83. package/lib/bin/cli/validate.js +3 -25
  84. package/lib/bin/cli/validate.js.map +1 -1
  85. package/lib/bin/cli/watch.d.ts.map +1 -1
  86. package/lib/bin/cli/watch.js +3 -12
  87. package/lib/bin/cli/watch.js.map +1 -1
  88. package/lib/bin/forge-agent.js +10 -22
  89. package/lib/bin/forge-agent.js.map +1 -1
  90. package/lib/server/app.d.ts +24 -0
  91. package/lib/server/app.d.ts.map +1 -0
  92. package/lib/server/app.js +58 -0
  93. package/lib/server/app.js.map +1 -0
  94. package/lib/server/forge/analyses/analysisStore.d.ts +53 -0
  95. package/lib/server/forge/analyses/analysisStore.d.ts.map +1 -0
  96. package/lib/server/forge/analyses/analysisStore.js +77 -0
  97. package/lib/server/forge/analyses/analysisStore.js.map +1 -0
  98. package/lib/server/forge/auth/apiTokens.d.ts +34 -0
  99. package/lib/server/forge/auth/apiTokens.d.ts.map +1 -0
  100. package/lib/server/forge/auth/apiTokens.js +44 -0
  101. package/lib/server/forge/auth/apiTokens.js.map +1 -0
  102. package/lib/server/forge/auth/auditLog.d.ts +36 -0
  103. package/lib/server/forge/auth/auditLog.d.ts.map +1 -0
  104. package/lib/server/forge/auth/auditLog.js +56 -0
  105. package/lib/server/forge/auth/auditLog.js.map +1 -0
  106. package/lib/server/forge/auth/authConfig.d.ts +13 -0
  107. package/lib/server/forge/auth/authConfig.d.ts.map +1 -0
  108. package/lib/server/forge/auth/authConfig.js +37 -0
  109. package/lib/server/forge/auth/authConfig.js.map +1 -0
  110. package/lib/server/forge/auth/createAuth.d.ts +130 -0
  111. package/lib/server/forge/auth/createAuth.d.ts.map +1 -0
  112. package/lib/server/forge/auth/createAuth.js +387 -0
  113. package/lib/server/forge/auth/createAuth.js.map +1 -0
  114. package/lib/server/forge/auth/middleware.d.ts +10 -0
  115. package/lib/server/forge/auth/middleware.d.ts.map +1 -0
  116. package/lib/server/forge/auth/middleware.js +22 -0
  117. package/lib/server/forge/auth/middleware.js.map +1 -0
  118. package/lib/server/forge/auth/oidc.d.ts +38 -0
  119. package/lib/server/forge/auth/oidc.d.ts.map +1 -0
  120. package/lib/server/forge/auth/oidc.js +63 -0
  121. package/lib/server/forge/auth/oidc.js.map +1 -0
  122. package/lib/server/forge/auth/passwords.d.ts +7 -0
  123. package/lib/server/forge/auth/passwords.d.ts.map +1 -0
  124. package/lib/server/forge/auth/passwords.js +28 -0
  125. package/lib/server/forge/auth/passwords.js.map +1 -0
  126. package/lib/server/forge/auth/resetPassword.d.ts +14 -0
  127. package/lib/server/forge/auth/resetPassword.d.ts.map +1 -0
  128. package/lib/server/forge/auth/resetPassword.js +29 -0
  129. package/lib/server/forge/auth/resetPassword.js.map +1 -0
  130. package/lib/server/forge/auth/roles.d.ts +7 -0
  131. package/lib/server/forge/auth/roles.d.ts.map +1 -0
  132. package/lib/server/forge/auth/roles.js +15 -0
  133. package/lib/server/forge/auth/roles.js.map +1 -0
  134. package/lib/server/forge/auth/sessions.d.ts +27 -0
  135. package/lib/server/forge/auth/sessions.d.ts.map +1 -0
  136. package/lib/server/forge/auth/sessions.js +37 -0
  137. package/lib/server/forge/auth/sessions.js.map +1 -0
  138. package/lib/server/forge/auth/testHarness.d.ts +104 -0
  139. package/lib/server/forge/auth/testHarness.d.ts.map +1 -0
  140. package/lib/server/forge/auth/testHarness.js +53 -0
  141. package/lib/server/forge/auth/testHarness.js.map +1 -0
  142. package/lib/server/forge/auth/tokenHash.d.ts +3 -0
  143. package/lib/server/forge/auth/tokenHash.d.ts.map +1 -0
  144. package/lib/server/forge/auth/tokenHash.js +4 -0
  145. package/lib/server/forge/auth/tokenHash.js.map +1 -0
  146. package/lib/server/forge/auth/users.d.ts +51 -0
  147. package/lib/server/forge/auth/users.d.ts.map +1 -0
  148. package/lib/server/forge/auth/users.js +118 -0
  149. package/lib/server/forge/auth/users.js.map +1 -0
  150. package/lib/server/forge/core/ForgeAgent.d.ts +58 -1
  151. package/lib/server/forge/core/ForgeAgent.d.ts.map +1 -1
  152. package/lib/server/forge/core/ForgeAgent.js +280 -32
  153. package/lib/server/forge/core/ForgeAgent.js.map +1 -1
  154. package/lib/server/forge/core/agentClient.d.ts +7 -5
  155. package/lib/server/forge/core/agentClient.d.ts.map +1 -1
  156. package/lib/server/forge/core/agentClient.js +11 -15
  157. package/lib/server/forge/core/agentClient.js.map +1 -1
  158. package/lib/server/forge/core/agentMode.d.ts +38 -0
  159. package/lib/server/forge/core/agentMode.d.ts.map +1 -0
  160. package/lib/server/forge/core/agentMode.js +43 -0
  161. package/lib/server/forge/core/agentMode.js.map +1 -0
  162. package/lib/server/forge/core/claudeClient.d.ts +63 -0
  163. package/lib/server/forge/core/claudeClient.d.ts.map +1 -1
  164. package/lib/server/forge/core/claudeClient.js +142 -50
  165. package/lib/server/forge/core/claudeClient.js.map +1 -1
  166. package/lib/server/forge/core/configStore.d.ts +12 -1
  167. package/lib/server/forge/core/configStore.d.ts.map +1 -1
  168. package/lib/server/forge/core/configStore.js +39 -2
  169. package/lib/server/forge/core/configStore.js.map +1 -1
  170. package/lib/server/forge/core/dependencyPaths.d.ts +19 -0
  171. package/lib/server/forge/core/dependencyPaths.d.ts.map +1 -0
  172. package/lib/server/forge/core/dependencyPaths.js +32 -0
  173. package/lib/server/forge/core/dependencyPaths.js.map +1 -0
  174. package/lib/server/forge/core/evidence.d.ts +43 -0
  175. package/lib/server/forge/core/evidence.d.ts.map +1 -0
  176. package/lib/server/forge/core/evidence.js +108 -0
  177. package/lib/server/forge/core/evidence.js.map +1 -0
  178. package/lib/server/forge/core/jobs.d.ts +2 -0
  179. package/lib/server/forge/core/jobs.d.ts.map +1 -1
  180. package/lib/server/forge/core/jobs.js +8 -0
  181. package/lib/server/forge/core/jobs.js.map +1 -1
  182. package/lib/server/forge/core/localRepo.d.ts +48 -7
  183. package/lib/server/forge/core/localRepo.d.ts.map +1 -1
  184. package/lib/server/forge/core/localRepo.js +123 -69
  185. package/lib/server/forge/core/localRepo.js.map +1 -1
  186. package/lib/server/forge/core/prompts.d.ts +20 -0
  187. package/lib/server/forge/core/prompts.d.ts.map +1 -1
  188. package/lib/server/forge/core/prompts.js +59 -0
  189. package/lib/server/forge/core/prompts.js.map +1 -1
  190. package/lib/server/forge/core/providers/azuredevops.d.ts +4 -2
  191. package/lib/server/forge/core/providers/azuredevops.d.ts.map +1 -1
  192. package/lib/server/forge/core/providers/azuredevops.js +12 -2
  193. package/lib/server/forge/core/providers/azuredevops.js.map +1 -1
  194. package/lib/server/forge/core/providers/base.d.ts +29 -4
  195. package/lib/server/forge/core/providers/base.d.ts.map +1 -1
  196. package/lib/server/forge/core/providers/base.js +21 -3
  197. package/lib/server/forge/core/providers/base.js.map +1 -1
  198. package/lib/server/forge/core/providers/github.d.ts +7 -7
  199. package/lib/server/forge/core/providers/github.d.ts.map +1 -1
  200. package/lib/server/forge/core/providers/github.js +13 -4
  201. package/lib/server/forge/core/providers/github.js.map +1 -1
  202. package/lib/server/forge/core/providers/gitlab.d.ts +20 -3
  203. package/lib/server/forge/core/providers/gitlab.d.ts.map +1 -1
  204. package/lib/server/forge/core/providers/gitlab.js +65 -5
  205. package/lib/server/forge/core/providers/gitlab.js.map +1 -1
  206. package/lib/server/forge/core/providers/types.d.ts +20 -0
  207. package/lib/server/forge/core/providers/types.d.ts.map +1 -1
  208. package/lib/server/forge/core/secretBox.d.ts +17 -0
  209. package/lib/server/forge/core/secretBox.d.ts.map +1 -0
  210. package/lib/server/forge/core/secretBox.js +74 -0
  211. package/lib/server/forge/core/secretBox.js.map +1 -0
  212. package/lib/server/forge/core/types.d.ts +8 -1
  213. package/lib/server/forge/core/types.d.ts.map +1 -1
  214. package/lib/server/forge/core/validator/clone.d.ts +5 -4
  215. package/lib/server/forge/core/validator/clone.d.ts.map +1 -1
  216. package/lib/server/forge/core/validator/clone.js +6 -86
  217. package/lib/server/forge/core/validator/clone.js.map +1 -1
  218. package/lib/server/forge/core/validator/cmd.d.ts.map +1 -1
  219. package/lib/server/forge/core/validator/cmd.js +4 -1
  220. package/lib/server/forge/core/validator/cmd.js.map +1 -1
  221. package/lib/server/forge/core/validator/projectType.d.ts.map +1 -1
  222. package/lib/server/forge/core/validator/projectType.js +2 -1
  223. package/lib/server/forge/core/validator/projectType.js.map +1 -1
  224. package/lib/server/forge/core/validator/runners/node.d.ts.map +1 -1
  225. package/lib/server/forge/core/validator/runners/node.js +6 -2
  226. package/lib/server/forge/core/validator/runners/node.js.map +1 -1
  227. package/lib/server/forge/core/validator.d.ts.map +1 -1
  228. package/lib/server/forge/core/validator.js +28 -22
  229. package/lib/server/forge/core/validator.js.map +1 -1
  230. package/lib/server/forge/core/workspaces.d.ts +70 -0
  231. package/lib/server/forge/core/workspaces.d.ts.map +1 -0
  232. package/lib/server/forge/core/workspaces.js +348 -0
  233. package/lib/server/forge/core/workspaces.js.map +1 -0
  234. package/lib/server/forge/db/database.d.ts +22 -0
  235. package/lib/server/forge/db/database.d.ts.map +1 -0
  236. package/lib/server/forge/db/database.js +71 -0
  237. package/lib/server/forge/db/database.js.map +1 -0
  238. package/lib/server/forge/db/migrations.d.ts +11 -0
  239. package/lib/server/forge/db/migrations.d.ts.map +1 -0
  240. package/lib/server/forge/db/migrations.js +87 -0
  241. package/lib/server/forge/db/migrations.js.map +1 -0
  242. package/lib/server/forge/routes.d.ts.map +1 -1
  243. package/lib/server/forge/routes.js +413 -163
  244. package/lib/server/forge/routes.js.map +1 -1
  245. package/lib/server/forge/sse.d.ts +10 -0
  246. package/lib/server/forge/sse.d.ts.map +1 -0
  247. package/lib/server/forge/sse.js +22 -0
  248. package/lib/server/forge/sse.js.map +1 -0
  249. package/lib/server/spa.d.ts +12 -0
  250. package/lib/server/spa.d.ts.map +1 -0
  251. package/lib/server/spa.js +23 -0
  252. package/lib/server/spa.js.map +1 -0
  253. package/lib/server.js +9 -24
  254. package/lib/server.js.map +1 -1
  255. package/lib/shared/events.d.ts +3 -0
  256. package/lib/shared/events.d.ts.map +1 -1
  257. package/lib/shared/events.js +3 -0
  258. package/lib/shared/events.js.map +1 -1
  259. package/lib/shared/packageRoot.d.ts +11 -0
  260. package/lib/shared/packageRoot.d.ts.map +1 -0
  261. package/lib/shared/packageRoot.js +23 -0
  262. package/lib/shared/packageRoot.js.map +1 -0
  263. package/lib/shared/project.d.ts +3 -0
  264. package/lib/shared/project.d.ts.map +1 -0
  265. package/lib/shared/project.js +3 -0
  266. package/lib/shared/project.js.map +1 -0
  267. package/package.json +24 -9
  268. package/lib/bin/cli/improve.d.ts +0 -2
  269. package/lib/bin/cli/improve.d.ts.map +0 -1
  270. package/lib/bin/cli/improve.js +0 -189
  271. package/lib/bin/cli/improve.js.map +0 -1
  272. package/lib/bin/cli/stats.d.ts +0 -2
  273. package/lib/bin/cli/stats.d.ts.map +0 -1
  274. package/lib/bin/cli/stats.js +0 -78
  275. package/lib/bin/cli/stats.js.map +0 -1
  276. package/lib/server/forge/core/k8s.d.ts +0 -18
  277. package/lib/server/forge/core/k8s.d.ts.map +0 -1
  278. package/lib/server/forge/core/k8s.js +0 -49
  279. package/lib/server/forge/core/k8s.js.map +0 -1
  280. package/lib/server/forge/core/openclawClient.d.ts +0 -61
  281. package/lib/server/forge/core/openclawClient.d.ts.map +0 -1
  282. package/lib/server/forge/core/openclawClient.js +0 -111
  283. package/lib/server/forge/core/openclawClient.js.map +0 -1
  284. package/lib/server/forge/core/quotaClient.d.ts +0 -48
  285. package/lib/server/forge/core/quotaClient.d.ts.map +0 -1
  286. package/lib/server/forge/core/quotaClient.js +0 -88
  287. package/lib/server/forge/core/quotaClient.js.map +0 -1
  288. package/lib/server/forge/core/selfImprove.d.ts +0 -43
  289. package/lib/server/forge/core/selfImprove.d.ts.map +0 -1
  290. package/lib/server/forge/core/selfImprove.js +0 -372
  291. package/lib/server/forge/core/selfImprove.js.map +0 -1
  292. package/lib/server/forge/core/telemetry.d.ts +0 -100
  293. package/lib/server/forge/core/telemetry.d.ts.map +0 -1
  294. package/lib/server/forge/core/telemetry.js +0 -273
  295. package/lib/server/forge/core/telemetry.js.map +0 -1
  296. package/scripts/fix-shebang.cjs +0 -11
  297. package/scripts/inject-readme.cjs +0 -23
  298. package/scripts/local-rollout.sh +0 -134
@@ -16,40 +16,21 @@
16
16
  * in the query string, or as a JSON array in a POST body.
17
17
  */
18
18
  import { Router } from "express";
19
+ import { openEventStream } from "./sse.js";
19
20
  import { parseCiTrace } from "./core/ciTraceParser.js";
20
21
  import * as configStore from "./core/configStore.js";
21
- import { listDeployments } from "./core/k8s.js";
22
22
  import { ForgeAgent } from "./core/ForgeAgent.js";
23
- import crypto from "node:crypto";
24
- import { probeQuota } from "./core/quotaClient.js";
25
23
  import { validate as runValidation } from "./core/validator.js";
26
24
  import { createProvider } from "./core/providers/index.js";
27
25
  import * as jobStore from "./core/jobs.js";
26
+ import { workspaces } from "./core/localRepo.js";
27
+ import { WorkspaceBusyError } from "./core/workspaces.js";
28
28
  import { FORGE_EVENTS } from "../../shared/events.js";
29
+ import { requirePrincipal, requireRole } from "./auth/middleware.js";
29
30
  const router = Router();
30
- // ─── Auth middleware ──────────────────────────────────────────────────────────
31
- function requireAuth(req, res, next) {
32
- const token = process.env.FORGE_TOKEN;
33
- if (!token) {
34
- res.status(503).json({
35
- error: "Forge is not configured. Set FORGE_TOKEN env var.",
36
- });
37
- return;
38
- }
39
- const auth = req.headers.authorization
40
- ?? (req.query.token ? `Bearer ${req.query.token}` : null);
41
- if (!auth?.startsWith("Bearer ")) {
42
- res.status(401).json({ error: "Missing Authorization header." });
43
- return;
44
- }
45
- const provided = Buffer.from(auth.slice(7));
46
- const expected = Buffer.from(token);
47
- if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
48
- res.status(403).json({ error: "Invalid token." });
49
- return;
50
- }
51
- next();
52
- }
31
+ // Authentication happens upstream (server.ts mounts createAuth's stack);
32
+ // this router only enforces that it ran, plus per-route minimum roles.
33
+ router.use(requirePrincipal);
53
34
  // ─── Error handler ────────────────────────────────────────────────────────────
54
35
  function redactSecrets(text) {
55
36
  return text
@@ -121,13 +102,17 @@ const FALLBACK_MODELS = [
121
102
  { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", provider: "claude" },
122
103
  { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", provider: "claude" },
123
104
  ];
105
+ // A workspace key from Settings wins; ANTHROPIC_API_KEY covers self-hosted installs.
106
+ function modelListKey() {
107
+ return configStore.getAnthropicKey() || process.env.ANTHROPIC_API_KEY || "";
108
+ }
124
109
  async function fetchAnthropicModels() {
125
110
  const controller = new AbortController();
126
111
  const timeout = setTimeout(() => controller.abort(), 5_000);
127
112
  try {
128
113
  const res = await fetch("https://api.anthropic.com/v1/models?limit=100", {
129
114
  headers: {
130
- "x-api-key": process.env.ANTHROPIC_API_KEY ?? "",
115
+ "x-api-key": modelListKey(),
131
116
  "anthropic-version": "2023-06-01",
132
117
  },
133
118
  signal: controller.signal,
@@ -148,13 +133,13 @@ async function fetchAnthropicModels() {
148
133
  clearTimeout(timeout);
149
134
  }
150
135
  }
151
- router.get("/status", requireAuth, async (_req, res) => {
136
+ router.get("/status", async (_req, res) => {
152
137
  let models = FALLBACK_MODELS.map((m) => ({
153
138
  ...m,
154
139
  ...(MODEL_RATES[m.id] ? { rates: MODEL_RATES[m.id] } : {}),
155
140
  }));
156
141
  try {
157
- if (process.env.ANTHROPIC_API_KEY) {
142
+ if (modelListKey()) {
158
143
  models = await fetchAnthropicModels();
159
144
  }
160
145
  }
@@ -163,14 +148,7 @@ router.get("/status", requireAuth, async (_req, res) => {
163
148
  console.warn("[forge] fetchAnthropicModels failed, using fallback:", err?.message);
164
149
  }
165
150
  const repos = getAvailableRepos();
166
- const agents = [
167
- { id: "claude", label: "Claude", configured: true },
168
- {
169
- id: "openclaw",
170
- label: "OpenClaw",
171
- configured: Boolean(process.env.OPENCLAW_GATEWAY_URL),
172
- },
173
- ];
151
+ const agents = [{ id: "claude", label: "Claude", configured: true }];
174
152
  res.json({
175
153
  ok: true,
176
154
  project: configStore.getPrimaryProject(),
@@ -180,8 +158,8 @@ router.get("/status", requireAuth, async (_req, res) => {
180
158
  defaultModel: process.env.FORGE_MODEL ?? "claude-sonnet-4-6",
181
159
  models,
182
160
  agents,
183
- defaultAgent: process.env.OPENCLAW_GATEWAY_URL ? "openclaw" : "claude",
184
- hasApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
161
+ defaultAgent: "claude",
162
+ hasApiKey: Boolean(modelListKey()),
185
163
  // Test-deploy URL template stored in configStore.defaults — editable
186
164
  // from Settings → Defaults. Supports "{branch}" / "{slug}" placeholders
187
165
  // for ephemeral per-branch deploys. Empty string means "no link".
@@ -189,12 +167,11 @@ router.get("/status", requireAuth, async (_req, res) => {
189
167
  maxCiFixAttempts: configStore.get().defaults?.maxCiFixAttempts ?? 3,
190
168
  });
191
169
  });
192
- router.use(requireAuth);
193
170
  // ─── Config ───────────────────────────────────────────────────────────────────
194
171
  router.get("/config", (_req, res) => {
195
172
  res.json(configStore.getPublic());
196
173
  });
197
- router.put("/config", async (req, res) => {
174
+ router.put("/config", requireRole("admin"), async (req, res) => {
198
175
  try {
199
176
  await configStore.patch(req.body ?? {});
200
177
  res.json(configStore.getPublic());
@@ -203,6 +180,52 @@ router.put("/config", async (req, res) => {
203
180
  handleError(res, err, "updating config");
204
181
  }
205
182
  });
183
+ // ─── Workspaces ───────────────────────────────────────────────────────────────
184
+ // Local checkouts (base clones + per-issue worktrees) under the cache root.
185
+ // Admin-only: they expose this machine's disk and deleting one is permanent.
186
+ function workspaceError(res, err, operation) {
187
+ if (err instanceof WorkspaceBusyError) {
188
+ res.status(409).json({ error: err.message });
189
+ }
190
+ else if (err instanceof Error && /^Unknown workspace/.test(err.message)) {
191
+ res.status(404).json({ error: err.message });
192
+ }
193
+ else {
194
+ handleError(res, err, operation);
195
+ }
196
+ }
197
+ router.get("/workspaces", requireRole("admin"), async (_req, res) => {
198
+ try {
199
+ const list = await workspaces.list();
200
+ // Ids are root-relative; the absolute path stays on the server.
201
+ res.json({ workspaces: list.map(({ path: _path, ...rest }) => rest) });
202
+ }
203
+ catch (err) {
204
+ workspaceError(res, err, "listing workspaces");
205
+ }
206
+ });
207
+ router.delete("/workspaces", requireRole("admin"), async (req, res) => {
208
+ const id = typeof req.query.id === "string" ? req.query.id : "";
209
+ if (!id) {
210
+ res.status(400).json({ error: "Workspace id is required." });
211
+ return;
212
+ }
213
+ try {
214
+ await workspaces.remove(id);
215
+ res.json({ removed: id });
216
+ }
217
+ catch (err) {
218
+ workspaceError(res, err, "deleting workspace");
219
+ }
220
+ });
221
+ router.post("/workspaces/purge", requireRole("admin"), async (req, res) => {
222
+ try {
223
+ res.json(await workspaces.purge({ includeBases: req.body?.includeBases === true }));
224
+ }
225
+ catch (err) {
226
+ workspaceError(res, err, "purging workspaces");
227
+ }
228
+ });
206
229
  // ─── Issues ───────────────────────────────────────────────────────────────────
207
230
  router.get("/issues", async (req, res) => {
208
231
  try {
@@ -279,69 +302,187 @@ router.get("/issues", async (req, res) => {
279
302
  handleError(res, error, "listOpenIssues");
280
303
  }
281
304
  });
282
- router.post("/issues", async (req, res) => {
305
+ const MAX_LABELS = 50;
306
+ const MAX_LABEL_LENGTH = 255;
307
+ /**
308
+ * Validates an optional `labels` body field: `labels` is undefined when the
309
+ * field was absent, the trimmed, de-duplicated list otherwise. Commas are
310
+ * refused because GitLab sends labels as one comma-separated string.
311
+ */
312
+ function parseLabels(raw) {
313
+ if (raw === undefined)
314
+ return {};
315
+ if (!Array.isArray(raw) || raw.length > MAX_LABELS)
316
+ return { error: `labels must be an array of at most ${MAX_LABELS} strings.` };
317
+ const labels = [];
318
+ for (const item of raw) {
319
+ if (typeof item !== "string")
320
+ return { error: "labels must be strings." };
321
+ const label = item.trim();
322
+ if (!label)
323
+ continue;
324
+ if (label.length > MAX_LABEL_LENGTH || label.includes(","))
325
+ return { error: `Invalid label: ${label.slice(0, 40)}` };
326
+ if (!labels.includes(label))
327
+ labels.push(label);
328
+ }
329
+ return { labels };
330
+ }
331
+ router.post("/issues", requireRole("operator"), async (req, res) => {
283
332
  const { title, description = "" } = req.body;
284
333
  if (!title?.trim()) {
285
334
  res.status(400).json({ error: "title is required." });
286
335
  return;
287
336
  }
337
+ const { labels, error: labelsError } = parseLabels(req.body.labels);
338
+ if (labelsError) {
339
+ res.status(400).json({ error: labelsError });
340
+ return;
341
+ }
288
342
  try {
289
- const issue = await createProvider(parseRepo(req)).createIssue(title.trim(), description.trim());
343
+ const issue = await createProvider(parseRepo(req)).createIssue(title.trim(), description.trim(), labels);
290
344
  res.json({ issue });
291
- broadcast({ type: FORGE_EVENTS.ISSUE_CREATED, iid: issue.iid });
345
+ broadcastFor(req, { type: FORGE_EVENTS.ISSUE_CREATED, iid: issue.iid });
292
346
  }
293
347
  catch (error) {
294
348
  handleError(res, error, "createIssue");
295
349
  }
296
350
  });
297
- router.put("/issues/:iid", async (req, res) => {
298
- const { title, description = "" } = req.body;
299
- if (!title?.trim()) {
300
- res.status(400).json({ error: "title is required." });
351
+ /** Partial update: only the fields present in the body change. */
352
+ router.put("/issues/:iid", requireRole("operator"), async (req, res) => {
353
+ const { title, description } = req.body;
354
+ if (title !== undefined && !(typeof title === "string" && title.trim())) {
355
+ res.status(400).json({ error: "title can't be blank." });
356
+ return;
357
+ }
358
+ if (description !== undefined && typeof description !== "string") {
359
+ res.status(400).json({ error: "description must be a string." });
360
+ return;
361
+ }
362
+ const { labels, error: labelsError } = parseLabels(req.body.labels);
363
+ if (labelsError) {
364
+ res.status(400).json({ error: labelsError });
365
+ return;
366
+ }
367
+ const changes = {};
368
+ if (title !== undefined)
369
+ changes.title = title.trim();
370
+ if (description !== undefined)
371
+ changes.description = description.trim();
372
+ if (labels !== undefined)
373
+ changes.labels = labels;
374
+ if (Object.keys(changes).length === 0) {
375
+ res.status(400).json({ error: "Nothing to update." });
301
376
  return;
302
377
  }
303
378
  try {
304
- const issue = await createProvider(parseRepo(req)).updateIssue(Number(req.params.iid), { title: title.trim(), description: description.trim() });
379
+ const iid = Number(req.params.iid);
380
+ const issue = await createProvider(parseRepo(req)).updateIssue(iid, changes);
305
381
  res.json({ issue });
382
+ broadcastFor(req, { type: FORGE_EVENTS.ISSUE_UPDATED, iid });
306
383
  }
307
384
  catch (error) {
308
385
  handleError(res, error, "updateIssue");
309
386
  }
310
387
  });
311
- router.post("/issues/:iid/close", async (req, res) => {
388
+ /** What closing or deleting an issue would tear down. Lookups are best-effort. */
389
+ async function issueFootprint(provider, iid) {
390
+ const [openMrs, prefixed] = await Promise.all([
391
+ provider.listForgeMRsForIssue(iid).catch(() => []),
392
+ provider.listBranchesByPrefix(`forge/issue-${iid}-`).catch(() => []),
393
+ ]);
394
+ const environments = (await Promise.all(teardownBranches(prefixed, iid).map((b) => provider.listEnvironmentsForBranch(b).catch(() => [])))).flat();
395
+ return { openMrs, branches: prefixed, environments };
396
+ }
397
+ /** Branches to tear down: the suffixed ones plus the legacy `forge/issue-:iid`, which may not exist. */
398
+ function teardownBranches(branches, iid) {
399
+ return [...branches, `forge/issue-${iid}`];
400
+ }
401
+ /**
402
+ * Shared teardown before an issue is closed or deleted. Refuses (409) while a
403
+ * Forge MR is open — the user should merge or close it first; the frontend
404
+ * surfaces the message verbatim. Otherwise stops the issue's ephemeral
405
+ * environments and deletes its forge branches. Returns the stopped-environment
406
+ * count, or null when it already answered.
407
+ */
408
+ async function retireIssueWork(res, provider, iid, verb) {
409
+ const { openMrs, branches, environments } = await issueFootprint(provider, iid);
410
+ if (openMrs.length > 0) {
411
+ const mr = openMrs[0];
412
+ res.status(409).json({
413
+ error: `Cannot ${verb}: active MR !${mr.iid} is still open. Merge or close it first.`,
414
+ openMr: { iid: mr.iid, web_url: mr.web_url, title: mr.title },
415
+ });
416
+ return null;
417
+ }
418
+ // Stop environments BEFORE deleting branches — on most providers deleting
419
+ // the branch orphans the environment rather than tearing it down. Each
420
+ // stop is best-effort (already-stopped envs 4xx).
421
+ await Promise.all(environments.map((e) => provider.stopEnvironment(e.id).catch(() => { })));
422
+ // Now delete the branches. Also best-effort.
423
+ await Promise.all(teardownBranches(branches, iid).map((b) => provider.deleteBranch(b).catch(() => { })));
424
+ return environments.length;
425
+ }
426
+ // Preview of what closing or deleting the issue would do, so the confirm
427
+ // dialog can say it (and name a blocking MR) before anything happens.
428
+ router.get("/issues/:iid/footprint", async (req, res) => {
429
+ const iid = Number(req.params.iid);
430
+ if (!Number.isInteger(iid) || iid <= 0) {
431
+ res.status(400).json({ error: "Invalid issue id." });
432
+ return;
433
+ }
434
+ try {
435
+ const { openMrs, branches, environments } = await issueFootprint(createProvider(parseRepo(req)), iid);
436
+ const mr = openMrs[0];
437
+ res.json({
438
+ openMr: mr ? { iid: mr.iid, web_url: mr.web_url, title: mr.title } : null,
439
+ branches: branches.length,
440
+ environments: environments.length,
441
+ });
442
+ }
443
+ catch (error) {
444
+ handleError(res, error, "getIssueFootprint");
445
+ }
446
+ });
447
+ router.post("/issues/:iid/close", requireRole("operator"), async (req, res) => {
312
448
  try {
313
449
  const iid = Number(req.params.iid);
314
450
  const provider = createProvider(parseRepo(req));
315
- // Refuse if there's an open Forge MR — the user should merge or close the
316
- // MR first. The frontend surfaces this message verbatim.
317
- const openMrs = await provider.listForgeMRsForIssue(iid).catch(() => []);
318
- if (openMrs.length > 0) {
319
- const mr = openMrs[0];
320
- res.status(409).json({
321
- error: `Cannot close: active MR !${mr.iid} is still open. Merge or close it first.`,
322
- openMr: { iid: mr.iid, web_url: mr.web_url, title: mr.title },
323
- });
451
+ const stoppedEnvironments = await retireIssueWork(res, provider, iid, "close");
452
+ if (stoppedEnvironments === null)
324
453
  return;
325
- }
326
- // Stop any ephemeral environments attached to forge branches for this
327
- // issue BEFORE deleting branches — on most providers deleting the
328
- // branch orphans the environment rather than tearing it down. Each
329
- // stop is best-effort (already-stopped envs 4xx).
330
- const branches = await provider.listBranchesByPrefix(`forge/issue-${iid}-`).catch(() => []);
331
- const baseBranch = `forge/issue-${iid}`;
332
- const all = [...branches, baseBranch];
333
- const envs = (await Promise.all(all.map((b) => provider.listEnvironmentsForBranch(b).catch(() => [])))).flat();
334
- await Promise.all(envs.map((e) => provider.stopEnvironment(e.id).catch(() => { })));
335
- // Now delete the branches. Also best-effort.
336
- await Promise.all(all.map((b) => provider.deleteBranch(b).catch(() => { })));
337
454
  const issue = await provider.closeIssue(iid);
338
- res.json({ issue, stoppedEnvironments: envs.length });
339
- broadcast({ type: FORGE_EVENTS.ISSUE_CLOSED, iid });
455
+ res.json({ issue, stoppedEnvironments });
456
+ broadcastFor(req, { type: FORGE_EVENTS.ISSUE_CLOSED, iid });
340
457
  }
341
458
  catch (error) {
342
459
  handleError(res, error, "closeIssue");
343
460
  }
344
461
  });
462
+ // Permanent and irreversible on GitLab, so it needs approver like merging.
463
+ router.delete("/issues/:iid", requireRole("approver"), async (req, res) => {
464
+ try {
465
+ const iid = Number(req.params.iid);
466
+ const provider = createProvider(parseRepo(req));
467
+ const stoppedEnvironments = await retireIssueWork(res, provider, iid, "delete");
468
+ if (stoppedEnvironments === null)
469
+ return;
470
+ await provider.deleteIssue(iid);
471
+ res.json({ deleted: true, iid, stoppedEnvironments });
472
+ broadcastFor(req, { type: FORGE_EVENTS.ISSUE_DELETED, iid });
473
+ }
474
+ catch (error) {
475
+ handleError(res, error, "deleteIssue");
476
+ }
477
+ });
478
+ router.get("/labels", async (req, res) => {
479
+ try {
480
+ res.json({ labels: await createProvider(parseRepo(req)).listLabels() });
481
+ }
482
+ catch (error) {
483
+ handleError(res, error, "listLabels");
484
+ }
485
+ });
345
486
  router.get("/issues/:iid", async (req, res) => {
346
487
  try {
347
488
  const issue = await createProvider(parseRepo(req)).getIssue(Number(req.params.iid));
@@ -453,7 +594,7 @@ router.get("/mr/:mrIid/detail", async (req, res) => {
453
594
  handleError(res, error, "getMR");
454
595
  }
455
596
  });
456
- router.post("/mr/:mrIid/rebase", async (req, res) => {
597
+ router.post("/mr/:mrIid/rebase", requireRole("operator"), async (req, res) => {
457
598
  try {
458
599
  const provider = createProvider(parseRepo(req));
459
600
  if (!provider.supportsRebase) {
@@ -476,7 +617,7 @@ router.get("/mr/:mrIid/diffs", async (req, res) => {
476
617
  handleError(res, error, "getMRDiffs");
477
618
  }
478
619
  });
479
- router.post("/mr/:mrIid/auto-merge", async (req, res) => {
620
+ router.post("/mr/:mrIid/auto-merge", requireRole("approver"), async (req, res) => {
480
621
  try {
481
622
  const provider = createProvider(parseRepo(req));
482
623
  await provider.setAutoMergeMR(Number(req.params.mrIid));
@@ -486,7 +627,7 @@ router.post("/mr/:mrIid/auto-merge", async (req, res) => {
486
627
  handleError(res, error, "setAutoMergeMR");
487
628
  }
488
629
  });
489
- router.post("/mr/:mrIid/cancel-auto-merge", async (req, res) => {
630
+ router.post("/mr/:mrIid/cancel-auto-merge", requireRole("approver"), async (req, res) => {
490
631
  try {
491
632
  const provider = createProvider(parseRepo(req));
492
633
  await provider.cancelAutoMergeMR(Number(req.params.mrIid));
@@ -496,29 +637,29 @@ router.post("/mr/:mrIid/cancel-auto-merge", async (req, res) => {
496
637
  handleError(res, error, "cancelAutoMergeMR");
497
638
  }
498
639
  });
499
- router.post("/mr/:mrIid/merge", async (req, res) => {
640
+ router.post("/mr/:mrIid/merge", requireRole("approver"), async (req, res) => {
500
641
  try {
501
642
  const provider = createProvider(parseRepo(req));
502
643
  await provider.mergeMR(Number(req.params.mrIid));
503
644
  res.json({ ok: true });
504
- broadcast({ type: FORGE_EVENTS.MR_MERGED, mrIid: Number(req.params.mrIid) });
645
+ broadcastFor(req, { type: FORGE_EVENTS.MR_MERGED, mrIid: Number(req.params.mrIid) });
505
646
  }
506
647
  catch (error) {
507
648
  handleError(res, error, "mergeMR");
508
649
  }
509
650
  });
510
- router.post("/mr/:mrIid/close", async (req, res) => {
651
+ router.post("/mr/:mrIid/close", requireRole("approver"), async (req, res) => {
511
652
  try {
512
653
  const provider = createProvider(parseRepo(req));
513
654
  await provider.closeMR(Number(req.params.mrIid));
514
655
  res.json({ ok: true });
515
- broadcast({ type: FORGE_EVENTS.MR_CLOSED, mrIid: Number(req.params.mrIid) });
656
+ broadcastFor(req, { type: FORGE_EVENTS.MR_CLOSED, mrIid: Number(req.params.mrIid) });
516
657
  }
517
658
  catch (error) {
518
659
  handleError(res, error, "closeMR");
519
660
  }
520
661
  });
521
- router.post("/mr/:mrIid/reopen", async (req, res) => {
662
+ router.post("/mr/:mrIid/reopen", requireRole("approver"), async (req, res) => {
522
663
  try {
523
664
  const provider = createProvider(parseRepo(req));
524
665
  await provider.reopenMR(Number(req.params.mrIid));
@@ -528,7 +669,7 @@ router.post("/mr/:mrIid/reopen", async (req, res) => {
528
669
  handleError(res, error, "reopenMR");
529
670
  }
530
671
  });
531
- router.post("/mr/:mrIid/draft", async (req, res) => {
672
+ router.post("/mr/:mrIid/draft", requireRole("operator"), async (req, res) => {
532
673
  try {
533
674
  const draft = Boolean(req.body?.draft);
534
675
  const provider = createProvider(parseRepo(req));
@@ -543,7 +684,7 @@ router.post("/mr/:mrIid/draft", async (req, res) => {
543
684
  // The job is kicked off synchronously and runs to completion regardless of
544
685
  // whether a client is listening. The client opens /jobs/:jobId/stream to
545
686
  // attach (or reattach after a reload).
546
- router.post("/issues/:iid/analyze", (req, res) => {
687
+ router.post("/issues/:iid/analyze", requireRole("operator"), (req, res) => {
547
688
  const { iid } = req.params;
548
689
  const primary = parseRepo(req);
549
690
  const secondaryProjects = parseSecondaryRepos(req, primary);
@@ -556,13 +697,83 @@ router.post("/issues/:iid/analyze", (req, res) => {
556
697
  ? req.body.kind
557
698
  : "fresh";
558
699
  const previousChanges = Array.isArray(req.body.previousChanges) ? req.body.previousChanges : null;
559
- const jobId = jobStore.createJob("analyze", (send, signal) => agent.analyzeIssue(Number(iid), send, req.body.model || undefined, undefined, req.body.prompt || undefined, kind, { signal }, previousChanges), { meta: { iid: Number(iid) }, onComplete: () => broadcast({ type: FORGE_EVENTS.ANALYSIS_DONE, iid: Number(iid) }) });
560
- broadcast({ type: FORGE_EVENTS.ANALYSIS_STARTED, iid: Number(iid), jobId });
700
+ const analyses = res.locals.analyses;
701
+ const repoKey = analysisRepoKey(req);
702
+ const existingMrIid = Number.isInteger(req.body.existingMrIid) ? req.body.existingMrIid : undefined;
703
+ const jobId = jobStore.createJob("analyze", async (send, signal) => {
704
+ let result = null;
705
+ const capture = (event) => {
706
+ if (event.type === "result")
707
+ result = event.data;
708
+ send(event);
709
+ };
710
+ const out = await agent.analyzeIssue(Number(iid), capture, req.body.model || undefined, undefined, req.body.prompt || undefined, kind, { signal, agentMode: req.body.agentMode }, previousChanges);
711
+ // Saved before `done`, so the result outlives this job's stream and buffer:
712
+ // any browser, any time later, gets it from GET /issues/:iid/analyses.
713
+ if (result && analyses && !signal.aborted) {
714
+ const analysis = await analyses.create({
715
+ repo: repoKey,
716
+ issueIid: Number(iid),
717
+ id: jobId,
718
+ result,
719
+ model: req.body.model || "",
720
+ mrIid: existingMrIid,
721
+ prompt: req.body.prompt,
722
+ });
723
+ send({ type: "analysis_saved", analysis: { ...analysis } });
724
+ }
725
+ return out;
726
+ }, { meta: { iid: Number(iid) }, onComplete: () => broadcastFor(req, { type: FORGE_EVENTS.ANALYSIS_DONE, iid: Number(iid) }) });
727
+ broadcastFor(req, { type: FORGE_EVENTS.ANALYSIS_STARTED, iid: Number(iid), jobId });
561
728
  res.json({ jobId });
562
729
  });
730
+ // ─── Saved analyses ───────────────────────────────────────────────────────────
731
+ // Same repo key the browser cache uses: the selected repo, or "default".
732
+ function analysisRepoKey(req) {
733
+ const fromQuery = typeof req.query.repo === "string" ? req.query.repo : "";
734
+ const fromBody = typeof req.body?.repo === "string" ? req.body.repo : "";
735
+ return fromQuery || fromBody || "default";
736
+ }
737
+ function analysisStore(res) {
738
+ const store = res.locals.analyses;
739
+ if (!store)
740
+ res.status(503).json({ error: "Saved analyses aren't available." });
741
+ return store;
742
+ }
743
+ router.get("/issues/:iid/analyses", async (req, res) => {
744
+ const store = analysisStore(res);
745
+ if (!store)
746
+ return;
747
+ res.json({ analyses: await store.list(analysisRepoKey(req), Number(req.params.iid)) });
748
+ });
749
+ router.put("/issues/:iid/analyses/:id", requireRole("operator"), async (req, res) => {
750
+ const store = analysisStore(res);
751
+ if (!store)
752
+ return;
753
+ const entry = req.body?.analysis;
754
+ if (!entry || entry.id !== req.params.id || typeof entry.result !== "object" || entry.result === null) {
755
+ res.status(400).json({ error: "analysis with a matching id and a result is required." });
756
+ return;
757
+ }
758
+ res.json({ analysis: await store.put(analysisRepoKey(req), Number(req.params.iid), entry) });
759
+ });
760
+ router.delete("/issues/:iid/analyses/:id", requireRole("operator"), async (req, res) => {
761
+ const store = analysisStore(res);
762
+ if (!store)
763
+ return;
764
+ await store.remove(analysisRepoKey(req), Number(req.params.iid), req.params.id);
765
+ res.json({ deleted: true });
766
+ });
767
+ router.delete("/issues/:iid/analyses", requireRole("operator"), async (req, res) => {
768
+ const store = analysisStore(res);
769
+ if (!store)
770
+ return;
771
+ await store.clear(analysisRepoKey(req), Number(req.params.iid));
772
+ res.json({ deleted: true });
773
+ });
563
774
  // ─── Refine ───────────────────────────────────────────────────────────────────
564
775
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
565
- router.post("/issues/:iid/refine", (req, res) => {
776
+ router.post("/issues/:iid/refine", requireRole("operator"), (req, res) => {
566
777
  const { iid } = req.params;
567
778
  const { feedback, previousChanges, ciContext, validationState, claudeSessionId } = req.body;
568
779
  if (!feedback?.trim() && !ciContext?.trim()) {
@@ -586,10 +797,10 @@ router.post("/issues/:iid/refine", (req, res) => {
586
797
  secondaryProjects: parseSecondaryRepos(req, primary),
587
798
  agent: req.body.agent || "claude",
588
799
  });
589
- const jobId = jobStore.createJob("refine", (send, signal) => agent.refineIssue(Number(iid), previousChanges, feedback?.trim() ?? "", send, undefined, ciContext?.trim(), validationState ?? null, safeSessionId, { signal }), { onComplete: () => broadcast({ type: FORGE_EVENTS.REFINE_DONE, iid: Number(iid) }) });
800
+ const jobId = jobStore.createJob("refine", (send, signal) => agent.refineIssue(Number(iid), previousChanges, feedback?.trim() ?? "", send, undefined, ciContext?.trim(), validationState ?? null, safeSessionId, { signal }), { onComplete: () => broadcastFor(req, { type: FORGE_EVENTS.REFINE_DONE, iid: Number(iid) }) });
590
801
  res.json({ jobId });
591
802
  });
592
- router.post("/issues/:iid/ci-fix", (req, res) => {
803
+ router.post("/issues/:iid/ci-fix", requireRole("operator"), (req, res) => {
593
804
  const { iid } = req.params;
594
805
  const { ciContext, model } = req.body;
595
806
  if (!ciContext?.trim()) {
@@ -602,7 +813,7 @@ router.post("/issues/:iid/ci-fix", (req, res) => {
602
813
  secondaryProjects: parseSecondaryRepos(req, primary),
603
814
  agent: req.body.agent || "claude",
604
815
  });
605
- const jobId = jobStore.createJob("ci-fix", (send, signal) => agent.ciFixIssue(Number(iid), ciContext.trim(), send, model, { signal }), { onComplete: () => broadcast({ type: FORGE_EVENTS.CI_FIX_DONE, iid: Number(iid) }) });
816
+ const jobId = jobStore.createJob("ci-fix", (send, signal) => agent.ciFixIssue(Number(iid), ciContext.trim(), send, model, { signal }), { onComplete: () => broadcastFor(req, { type: FORGE_EVENTS.CI_FIX_DONE, iid: Number(iid) }) });
606
817
  res.json({ jobId });
607
818
  });
608
819
  router.get("/jobs/:jobId/trace", async (req, res) => {
@@ -641,10 +852,7 @@ router.get("/jobs/:jobId/stream", (req, res) => {
641
852
  res.status(404).json({ error: "Job not found." });
642
853
  return;
643
854
  }
644
- res.setHeader("Content-Type", "text/event-stream");
645
- res.setHeader("Cache-Control", "no-cache");
646
- res.setHeader("Connection", "keep-alive");
647
- res.flushHeaders();
855
+ openEventStream(req, res);
648
856
  let unsubscribe = () => { };
649
857
  const write = (event) => {
650
858
  if (res.writableEnded)
@@ -668,7 +876,7 @@ router.get("/jobs/:jobId/stream", (req, res) => {
668
876
  res.end();
669
877
  });
670
878
  });
671
- router.post("/jobs/:jobId/cancel", (req, res) => {
879
+ router.post("/jobs/:jobId/cancel", requireRole("operator"), (req, res) => {
672
880
  const jobId = req.params.jobId;
673
881
  if (!jobStore.getJob(jobId)) {
674
882
  res.status(404).json({ error: "Job not found." });
@@ -682,7 +890,7 @@ router.post("/jobs/:jobId/cancel", (req, res) => {
682
890
  // Pre-flight check: overlay the proposed changes on a cached clone of the
683
891
  // target repo, run lint/typecheck/test, and report results. Nothing is pushed
684
892
  // to GitLab — this is a sandbox check to prevent CI errors.
685
- router.post("/validate", (req, res) => {
893
+ router.post("/validate", requireRole("operator"), (req, res) => {
686
894
  const { analysis } = req.body;
687
895
  if (!analysis?.changes?.length) {
688
896
  res.status(400).json({ error: "analysis with changes is required." });
@@ -695,10 +903,17 @@ router.post("/validate", (req, res) => {
695
903
  const secondaryRepos = parseSecondaryRepos(req, primary);
696
904
  const repos = [primary, ...secondaryRepos].filter(Boolean);
697
905
  const enrichedAnalysis = repos.length ? { ...analysis, repos } : analysis;
698
- const jobId = jobStore.createJob("validate", (send) => runValidation(enrichedAnalysis, send), { idPrefix: "val" });
906
+ const iid = Number(analysis.issue?.iid) || null;
907
+ const jobId = jobStore.createJob("validate", (send) => runValidation(enrichedAnalysis, send), {
908
+ // No meta.iid: /issues/:iid/active-job would take it for an analysis.
909
+ idPrefix: "val",
910
+ // Lets a client that stopped watching clear its "validating" badge.
911
+ onComplete: () => { if (iid)
912
+ broadcastFor(req, { type: FORGE_EVENTS.VALIDATION_DONE, iid, jobId }); },
913
+ });
699
914
  res.json({ jobId });
700
915
  });
701
- router.post("/apply", async (req, res) => {
916
+ router.post("/apply", requireRole("operator"), async (req, res) => {
702
917
  const { analysis } = req.body;
703
918
  if (!analysis?.issue || !analysis?.changes?.length) {
704
919
  res.status(400).json({ error: "Invalid or missing analysis payload." });
@@ -710,13 +925,13 @@ router.post("/apply", async (req, res) => {
710
925
  const agent = new ForgeAgent({ project: primary, secondaryProjects });
711
926
  const result = await agent.applyFix(analysis);
712
927
  res.json(result);
713
- broadcast({ type: FORGE_EVENTS.FIX_APPLIED, iid: analysis.issue?.iid });
928
+ broadcastFor(req, { type: FORGE_EVENTS.FIX_APPLIED, iid: analysis.issue?.iid });
714
929
  }
715
930
  catch (error) {
716
931
  handleError(res, error, "applyFix");
717
932
  }
718
933
  });
719
- router.post("/commit-to-branch", async (req, res) => {
934
+ router.post("/commit-to-branch", requireRole("operator"), async (req, res) => {
720
935
  const { branchName, analysis, repo: changeRepo } = req.body;
721
936
  if (!branchName?.trim()) {
722
937
  res.status(400).json({ error: "branchName is required." });
@@ -740,7 +955,7 @@ router.post("/commit-to-branch", async (req, res) => {
740
955
  handleError(res, error, "commitToBranch");
741
956
  }
742
957
  });
743
- router.post("/promote-branch", async (req, res) => {
958
+ router.post("/promote-branch", requireRole("operator"), async (req, res) => {
744
959
  const { branchName, repo: targetRepo, issueIid } = req.body;
745
960
  if (!branchName?.trim()) {
746
961
  res.status(400).json({ error: "branchName is required." });
@@ -761,7 +976,7 @@ router.post("/promote-branch", async (req, res) => {
761
976
  handleError(res, error, "promoteBranchToMR");
762
977
  }
763
978
  });
764
- router.post("/deploy-branch", async (req, res) => {
979
+ router.post("/deploy-branch", requireRole("operator"), async (req, res) => {
765
980
  const { analysis } = req.body;
766
981
  if (!analysis?.issue || !analysis?.changes?.length) {
767
982
  res.status(400).json({ error: "Invalid or missing analysis payload." });
@@ -798,6 +1013,70 @@ router.get("/mr/:mrIid/pipeline", async (req, res) => {
798
1013
  handleError(res, error, "getPipelineStatus");
799
1014
  }
800
1015
  });
1016
+ const PIPELINE_HISTORY_PAGE_SIZE = 30;
1017
+ // Pipeline history for the repo: every ref and trigger (main, MRs, branches),
1018
+ // newest first. `nextPage` is null once a short page shows there's no more.
1019
+ router.get("/pipelines", async (req, res) => {
1020
+ const requested = Number(req.query.page);
1021
+ const page = Number.isInteger(requested) && requested > 0 ? requested : 1;
1022
+ try {
1023
+ const pipelines = await createProvider(parseRepo(req)).listRecentPipelines({
1024
+ page,
1025
+ perPage: PIPELINE_HISTORY_PAGE_SIZE,
1026
+ });
1027
+ res.json({ pipelines, nextPage: pipelines.length === PIPELINE_HISTORY_PAGE_SIZE ? page + 1 : null });
1028
+ }
1029
+ catch (error) {
1030
+ handleError(res, error, "listRecentPipelines");
1031
+ }
1032
+ });
1033
+ // The first environment a reviewer can actually open, or null.
1034
+ function firstLiveEnvironment(envs) {
1035
+ return envs.find((e) => e.state === "available" && e.external_url) ?? null;
1036
+ }
1037
+ // Git refuses longer ref names on most hosts; anything past this is noise.
1038
+ const MAX_BRANCH_LENGTH = 255;
1039
+ // The MR's live review environment (per-MR ephemeral deploy, or a deploy from
1040
+ // its source branch), or null when the pipeline hasn't deployed one. Lookup
1041
+ // failures read as "none" — the UI only shows the link when there's something
1042
+ // to open, and keeps polling until there is.
1043
+ router.get("/mr/:mrIid/environment", async (req, res) => {
1044
+ const mrIid = Number(req.params.mrIid);
1045
+ if (!Number.isInteger(mrIid) || mrIid <= 0) {
1046
+ res.status(400).json({ error: "Invalid MR id." });
1047
+ return;
1048
+ }
1049
+ try {
1050
+ const provider = createProvider(parseRepo(req));
1051
+ const sourceBranch = await provider
1052
+ .getMR(mrIid)
1053
+ .then((mr) => mr.source_branch || undefined, () => undefined);
1054
+ const envs = await provider
1055
+ .listEnvironmentsForMR(mrIid, sourceBranch)
1056
+ .catch(() => []);
1057
+ res.json({ environment: firstLiveEnvironment(envs) });
1058
+ }
1059
+ catch (error) {
1060
+ handleError(res, error, "getMREnvironment");
1061
+ }
1062
+ });
1063
+ // Same as above for a branch with no MR (Deploy-to-Test branches).
1064
+ router.get("/branch-environment", async (req, res) => {
1065
+ const branch = req.query.branch;
1066
+ if (typeof branch !== "string" || !branch || branch.length > MAX_BRANCH_LENGTH) {
1067
+ res.status(400).json({ error: "branch is required" });
1068
+ return;
1069
+ }
1070
+ try {
1071
+ const envs = await createProvider(parseRepo(req))
1072
+ .listEnvironmentsForBranch(branch)
1073
+ .catch(() => []);
1074
+ res.json({ environment: firstLiveEnvironment(envs) });
1075
+ }
1076
+ catch (error) {
1077
+ handleError(res, error, "getBranchEnvironment");
1078
+ }
1079
+ });
801
1080
  // Returns the latest branch pipeline (and its jobs) for any ref.
802
1081
  // Used by Deploy-to-Test, which creates a branch with no MR, so MR-scoped
803
1082
  // pipeline lookups don't apply.
@@ -861,20 +1140,33 @@ router.get("/issues/:iid/deploy-branches", async (req, res) => {
861
1140
  handleError(res, error, "listDeployBranches");
862
1141
  }
863
1142
  });
864
- // Ephemeral environments (review apps) attached to any forge/issue-:iid-*
865
- // branch. Returns a flat list — each entry carries the branch it's tied to
866
- // so the UI can colocate it with its pipeline.
1143
+ // Ephemeral environments (review apps) for an issue: those deployed from any
1144
+ // forge/issue-:iid-* branch, plus those its open Forge MRs' pipelines deployed
1145
+ // (MR pipelines deploy from the merge-request ref, not the branch). Returns a
1146
+ // flat list — each entry carries the branch it's tied to so the UI can
1147
+ // colocate it with its pipeline.
867
1148
  router.get("/issues/:iid/environments", async (req, res) => {
868
1149
  try {
869
1150
  const iid = Number(req.params.iid);
870
1151
  const provider = createProvider(parseRepo(req));
871
- const branches = await provider.listBranchesByPrefix(`forge/issue-${iid}-`).catch(() => []);
1152
+ const [branches, openMrs] = await Promise.all([
1153
+ provider.listBranchesByPrefix(`forge/issue-${iid}-`).catch(() => []),
1154
+ provider.listForgeMRsForIssue(iid).catch(() => []),
1155
+ ]);
872
1156
  const all = [...branches, `forge/issue-${iid}`];
873
- const envsByBranch = await Promise.all(all.map(async (branch) => {
874
- const envs = await provider.listEnvironmentsForBranch(branch).catch(() => []);
875
- return envs.map((e) => ({ ...e, branchName: branch }));
876
- }));
877
- res.json({ environments: envsByBranch.flat() });
1157
+ const envLists = await Promise.all([
1158
+ ...all.map(async (branch) => {
1159
+ const envs = await provider.listEnvironmentsForBranch(branch).catch(() => []);
1160
+ return envs.map((e) => ({ ...e, branchName: branch }));
1161
+ }),
1162
+ ...openMrs.map(async (mr) => {
1163
+ const envs = await provider.listEnvironmentsForMR(mr.iid).catch(() => []);
1164
+ return envs.map((e) => ({ ...e, branchName: mr.source_branch }));
1165
+ }),
1166
+ ]);
1167
+ const seen = new Set();
1168
+ const environments = envLists.flat().filter((e) => !seen.has(e.id) && seen.add(e.id));
1169
+ res.json({ environments });
878
1170
  }
879
1171
  catch (error) {
880
1172
  handleError(res, error, "listEnvironments");
@@ -910,7 +1202,7 @@ router.get("/issues/:iid/post-merge-pipeline", async (req, res) => {
910
1202
  }
911
1203
  });
912
1204
  // ─── Resolution check ─────────────────────────────────────────────────────────
913
- router.post("/issues/:iid/resolution-check", async (req, res) => {
1205
+ router.post("/issues/:iid/resolution-check", requireRole("operator"), async (req, res) => {
914
1206
  try {
915
1207
  const primary = parseRepo(req);
916
1208
  const secondaryProjects = parseSecondaryRepos(req, primary);
@@ -928,57 +1220,18 @@ router.post("/issues/:iid/resolution-check", async (req, res) => {
928
1220
  handleError(res, error, "checkResolution");
929
1221
  }
930
1222
  });
931
- // ─── Rollout status ───────────────────────────────────────────────────────────
932
- router.get("/rollout", async (_req, res) => {
933
- try {
934
- const deployments = await listDeployments("test");
935
- res.json({ deployments });
936
- }
937
- catch (error) {
938
- handleError(res, error, "rollout");
939
- }
940
- });
941
- // ─── Quota probe ──────────────────────────────────────────────────────────────
942
- // User-triggered: hits the Anthropic Messages API once with a 1-token request
943
- // just to read `anthropic-ratelimit-*` response headers. All real Forge work
944
- // still runs through the `claude` CLI (subscription).
945
- router.get("/quota", async (_req, res) => {
946
- try {
947
- const result = await probeQuota();
948
- res.json(result);
949
- }
950
- catch (e) {
951
- // Propagate the Anthropic status code if the probe itself failed,
952
- // but still return whatever rate-limit headers came back.
953
- const error = e;
954
- const status = error?.status ?? 500;
955
- res.status(status).json({
956
- error: error?.message ?? "Quota probe failed",
957
- rateLimit: error?.rateLimit ?? null,
958
- operation: "quota",
959
- });
960
- }
961
- });
962
- // ─── Telemetry ───────────────────────────────────────────────────────────────
963
- router.get("/telemetry", async (_req, res) => {
964
- try {
965
- const { getSummary, getFailurePatterns, getRecentEvents } = await import("./core/telemetry.js");
966
- const [summary, patterns, recent] = await Promise.all([
967
- getSummary(),
968
- getFailurePatterns(),
969
- getRecentEvents(50),
970
- ]);
971
- res.json({ summary, patterns, recentEvents: recent });
972
- }
973
- catch (error) {
974
- handleError(res, error, "getTelemetry");
975
- }
976
- });
977
1223
  // ─── Live event bus ──────────────────────────────────────────────────────────
978
1224
  // SSE channel that broadcasts state-change events so the UI can refetch
979
1225
  // without polling. The CLI and API endpoints call broadcast() after any
980
1226
  // mutating operation (analyze, apply, merge, close, etc.).
981
1227
  const sseClients = new Set();
1228
+ /**
1229
+ * Tags an event with the repo the request was for, so a browser looking at
1230
+ * another repo ignores it (issue numbers repeat across repos).
1231
+ */
1232
+ function broadcastFor(req, event) {
1233
+ broadcast({ ...event, repo: parseRepo(req) });
1234
+ }
982
1235
  function broadcast(event) {
983
1236
  const data = `data: ${JSON.stringify(event)}\n\n`;
984
1237
  for (const res of sseClients) {
@@ -987,21 +1240,18 @@ function broadcast(event) {
987
1240
  }
988
1241
  }
989
1242
  router.get("/events", (req, res) => {
990
- res.setHeader("Content-Type", "text/event-stream");
991
- res.setHeader("Cache-Control", "no-cache");
992
- res.setHeader("Connection", "keep-alive");
993
- res.flushHeaders();
1243
+ openEventStream(req, res);
994
1244
  res.write(":\n\n");
995
1245
  sseClients.add(res);
996
1246
  req.on("close", () => sseClients.delete(res));
997
1247
  });
998
- router.post("/events", (req, res) => {
1248
+ router.post("/events", requireRole("operator"), (req, res) => {
999
1249
  const { type, iid, mrIid } = req.body ?? {};
1000
1250
  if (!type) {
1001
1251
  res.status(400).json({ error: "type is required." });
1002
1252
  return;
1003
1253
  }
1004
- broadcast({ type, ...(iid != null && { iid }), ...(mrIid != null && { mrIid }) });
1254
+ broadcastFor(req, { type, ...(iid != null && { iid }), ...(mrIid != null && { mrIid }) });
1005
1255
  res.json({ ok: true });
1006
1256
  });
1007
1257
  export { broadcast };