stageflow 0.18.0 → 0.23.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 (116) hide show
  1. package/README.md +2 -1
  2. package/dist/agent/activity.d.ts +9 -0
  3. package/dist/agent/activityObserver.d.ts +3 -0
  4. package/dist/agent/activityObserver.js +6 -1
  5. package/dist/agent/claudeAdapter.js +38 -0
  6. package/dist/agent/fakeAgent.js +3 -1
  7. package/dist/agent/piAdapter.js +40 -4
  8. package/dist/agent/port.d.ts +3 -1
  9. package/dist/agent/providerInspect.d.ts +19 -0
  10. package/dist/agent/providerInspect.js +35 -0
  11. package/dist/agent/stageTimeout.d.ts +9 -0
  12. package/dist/agent/stageTimeout.js +24 -0
  13. package/dist/agent/streamLogRedact.d.ts +2 -0
  14. package/dist/agent/streamLogRedact.js +13 -0
  15. package/dist/cli/artifactCommand.js +2 -2
  16. package/dist/cli/envelopeCommand.js +2 -2
  17. package/dist/cli/exportRunCommand.js +2 -2
  18. package/dist/cli/graphCommand.d.ts +15 -0
  19. package/dist/cli/graphCommand.js +79 -0
  20. package/dist/cli/graphRender.d.ts +2 -0
  21. package/dist/cli/graphRender.js +85 -0
  22. package/dist/cli/hostClient.d.ts +43 -0
  23. package/dist/cli/hostClient.js +182 -0
  24. package/dist/cli/providersCommand.js +5 -3
  25. package/dist/cli/runCommand.d.ts +6 -5
  26. package/dist/cli/runCommand.js +23 -31
  27. package/dist/cli/runOutput.d.ts +1 -1
  28. package/dist/cli/runsCommand.d.ts +4 -8
  29. package/dist/cli/runsCommand.js +65 -89
  30. package/dist/cli.js +13 -5
  31. package/dist/config/describePipeline.d.ts +26 -0
  32. package/dist/config/describePipeline.js +37 -0
  33. package/dist/config/loadPipeline.d.ts +13 -1
  34. package/dist/config/loadPipeline.js +34 -4
  35. package/dist/config/mergePipelineIncludes.d.ts +11 -0
  36. package/dist/config/mergePipelineIncludes.js +75 -0
  37. package/dist/config/validateCatalog.d.ts +2 -2
  38. package/dist/config/validateCatalog.js +24 -17
  39. package/dist/mcp/catalogTools.js +146 -52
  40. package/dist/mcp/controlTools.js +94 -19
  41. package/dist/mcp/deps.d.ts +3 -0
  42. package/dist/mcp/projectMcpTools.d.ts +3 -0
  43. package/dist/mcp/projectMcpTools.js +25 -0
  44. package/dist/mcp/providerTools.d.ts +3 -0
  45. package/dist/mcp/providerTools.js +17 -0
  46. package/dist/mcp/readArtifact.d.ts +9 -0
  47. package/dist/mcp/readArtifact.js +11 -0
  48. package/dist/mcp/server.js +9 -1
  49. package/dist/mcp/tailStreamLog.d.ts +8 -0
  50. package/dist/mcp/tailStreamLog.js +28 -0
  51. package/dist/mcp/toolResults.d.ts +17 -0
  52. package/dist/mcp/toolResults.js +15 -0
  53. package/dist/mcp/tools.js +4 -0
  54. package/dist/mcp/waitRun.js +4 -4
  55. package/dist/mcp/waitingGates.d.ts +1 -0
  56. package/dist/mcp/waitingGates.js +3 -2
  57. package/dist/package-meta.d.ts +1 -1
  58. package/dist/package-meta.js +1 -1
  59. package/dist/projection/projectRun.d.ts +3 -0
  60. package/dist/projection/projectRun.js +7 -0
  61. package/dist/runstore/port.d.ts +3 -1
  62. package/dist/runstore/port.js +2 -0
  63. package/dist/runstore/sqlite/SqliteRunStore.d.ts +2 -1
  64. package/dist/runstore/sqlite/SqliteRunStore.js +15 -1
  65. package/dist/runstore/workspaceLayout.d.ts +1 -0
  66. package/dist/runstore/workspaceLayout.js +3 -0
  67. package/dist/runtime/feedbackLoopCoordinator.d.ts +9 -1
  68. package/dist/runtime/feedbackLoopCoordinator.js +50 -20
  69. package/dist/runtime/feedbackLoopDecision.js +33 -1
  70. package/dist/runtime/forkGeneration.d.ts +4 -0
  71. package/dist/runtime/forkGeneration.js +16 -0
  72. package/dist/runtime/pipelineRunner.d.ts +3 -3
  73. package/dist/runtime/pipelineRunner.js +3 -1
  74. package/dist/runtime/pipelineScheduler.js +23 -1
  75. package/dist/runtime/reopenSkipped.d.ts +13 -0
  76. package/dist/runtime/reopenSkipped.js +56 -0
  77. package/dist/runtime/resumeReconstruct.js +2 -2
  78. package/dist/runtime/resumeTimedOut.d.ts +33 -0
  79. package/dist/runtime/resumeTimedOut.js +165 -0
  80. package/dist/runtime/runManager.d.ts +3 -1
  81. package/dist/runtime/runManager.js +110 -14
  82. package/dist/runtime/settingsFile.d.ts +2 -0
  83. package/dist/runtime/settingsFile.js +19 -0
  84. package/dist/runtime/stageAttemptBootstrap.d.ts +3 -0
  85. package/dist/runtime/stageAttemptBootstrap.js +8 -2
  86. package/dist/runtime/stageRecovery.js +2 -2
  87. package/dist/runtime/stageRunner.js +6 -2
  88. package/dist/runtime/stageStreamLog.d.ts +17 -0
  89. package/dist/runtime/stageStreamLog.js +89 -0
  90. package/dist/runtime/stageWorker.js +2 -1
  91. package/dist/server/bootstrap.js +9 -2
  92. package/dist/server/ensureGlobalService.d.ts +35 -0
  93. package/dist/server/ensureGlobalService.js +138 -0
  94. package/dist/server/http.d.ts +20 -1
  95. package/dist/server/http.js +452 -386
  96. package/dist/server/mcpHost.js +7 -8
  97. package/dist/server/operatorResults.d.ts +10 -0
  98. package/dist/server/operatorResults.js +24 -0
  99. package/dist/server/providerRoutes.d.ts +0 -6
  100. package/dist/server/providerRoutes.js +10 -18
  101. package/dist/tools/askOperator.d.ts +1 -0
  102. package/dist/tools/askOperator.js +14 -11
  103. package/dist/tools/emitStageEnvelope.d.ts +1 -0
  104. package/dist/tools/emitStageEnvelope.js +3 -1
  105. package/dist/tools/prepareToolArguments.d.ts +4 -0
  106. package/dist/tools/prepareToolArguments.js +79 -0
  107. package/dist/types/pipeline.d.ts +8 -0
  108. package/dist/ui/assets/{index-DsgVjLUS.css → index-CSsDued1.css} +1 -1
  109. package/dist/ui/assets/index-Da5EHU6M.js +119 -0
  110. package/dist/ui/index.html +2 -2
  111. package/package.json +1 -1
  112. package/skills/stageflow/references/control-surface.md +2 -2
  113. package/skills/stageflow-run/SKILL.md +27 -9
  114. package/skills/stageflow-run/references/mcp-call.md +1 -1
  115. package/skills/stageflow-run/scripts/mcp-call.mjs +9 -0
  116. package/dist/ui/assets/index-DwKGaQvQ.js +0 -119
@@ -2,7 +2,8 @@ import { readFile, access } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { getCredentialSourceSettings, setCredentialSource, } from "../agent/providerAuth.js";
5
- import { handleProviderRoutes, providerAuthErrorBody, } from "./providerRoutes.js";
5
+ import { mapProviderAuthError } from "../agent/providerInspect.js";
6
+ import { handleProviderRoutes } from "./providerRoutes.js";
6
7
  import { handleProjectMcpRoutes } from "./projectMcpRoutes.js";
7
8
  import { createPipeline, parseCreatePipelineBody } from "../config/createPipeline.js";
8
9
  import { createStage, parseCreateStageBody } from "../config/createStage.js";
@@ -12,12 +13,13 @@ import { listSkills } from "../config/listSkills.js";
12
13
  import { artifactMediaType, readRunArtifact, readRunArtifactBytes, } from "../mcp/readArtifact.js";
13
14
  import { readStageVerificationHistory } from "../runstore/verificationHistory.js";
14
15
  import { resolveStageflowContext } from "../project/resolveStageflowContext.js";
16
+ import { PipelineValidationError } from "../runtime/pipelineValidationError.js";
15
17
  import { INVALID_SLOT_COUNT_MESSAGE, parseSlotCount, } from "../runtime/settingsFile.js";
16
18
  import { isTaskFile } from "../runtime/taskInput.js";
17
19
  import { parseAskOperatorAnswer } from "../tools/askOperator.js";
18
20
  import { bootstrapStageflowHost, } from "./bootstrap.js";
19
21
  import { createHttpHost, DEFAULT_PORT, json, } from "./createHttpHost.js";
20
- import { mapRetryStageFailure, mapStartFailure } from "./operatorResults.js";
22
+ import { mapRetryStageFailure, mapStartFailure, mapStoreLookupError, } from "./operatorResults.js";
21
23
  function textPlain(res, status, body) {
22
24
  res.writeHead(status, {
23
25
  "Content-Type": "text/plain; charset=utf-8",
@@ -91,6 +93,7 @@ function isMutatingApi(method, pathname) {
91
93
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/answer$/.test(pathname) ||
92
94
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/feedback-decision$/.test(pathname) ||
93
95
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/retry$/.test(pathname) ||
96
+ /^\/api\/runs\/[^/]+\/stages\/[^/]+\/resume$/.test(pathname) ||
94
97
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/recovery$/.test(pathname) ||
95
98
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/recovery\/stop$/.test(pathname) ||
96
99
  /^\/api\/runs\/[^/]+\/stages\/[^/]+\/abandon$/.test(pathname) ||
@@ -177,463 +180,526 @@ export function defaultUiDistDir() {
177
180
  }
178
181
  return path.resolve(here, "../ui");
179
182
  }
180
- export async function startUiServer(options) {
181
- const host = options.host ?? "127.0.0.1";
182
- const port = options.port ?? DEFAULT_PORT;
183
- const uiDistDir = options.uiDistDir ?? defaultUiDistDir();
184
- const boot = await bootstrapStageflowHost(options);
185
- const { manager, store, cwd, agentDir } = boot;
186
- const providerAuthContext = boot.providerAuthContext;
187
- return createHttpHost({
188
- boot,
189
- host,
190
- port,
191
- routes: async ({ req, res, url, pathname, method }) => {
192
- if (isMutatingApi(method, pathname)) {
193
- if (!assertLoopbackHttpAccess(req, res)) {
194
- return true;
195
- }
196
- if (isCredentialMutatingApi(method, pathname) &&
197
- !assertCredentialMutatingOrigin(req, res)) {
198
- return true;
199
- }
183
+ /**
184
+ * The full operator-console route surface: REST API (runs, stages,
185
+ * catalog, settings, providers) plus, when `uiDistDir` is given, static
186
+ * console UI file serving. Shared by `startUiServer` (browser-facing) and
187
+ * `startMcpServer` (headless global-service daemon) — both need the REST
188
+ * API; only the former needs the UI files.
189
+ */
190
+ export function createOperatorRoutes(deps) {
191
+ const { manager, store, cwd, agentDir, rootDir, providerAuthContext, uiDistDir } = deps;
192
+ return async ({ req, res, url, pathname, method }) => {
193
+ if (isMutatingApi(method, pathname)) {
194
+ if (!assertLoopbackHttpAccess(req, res)) {
195
+ return true;
196
+ }
197
+ if (isCredentialMutatingApi(method, pathname) &&
198
+ !assertCredentialMutatingOrigin(req, res)) {
199
+ return true;
200
+ }
201
+ }
202
+ try {
203
+ if (method === "GET" && pathname === "/api/runs") {
204
+ json(res, 200, { runs: await store.listRuns() });
205
+ return true;
200
206
  }
201
- try {
202
- if (method === "GET" && pathname === "/api/runs") {
203
- json(res, 200, { runs: await store.listRuns() });
207
+ const artifactMatch = pathname.match(/^\/api\/runs\/([^/]+)\/artifact$/);
208
+ if (method === "GET" && artifactMatch) {
209
+ const runId = decodeURIComponent(artifactMatch[1] ?? "");
210
+ const artifactPath = url.searchParams.get("path");
211
+ if (artifactPath === null || artifactPath.trim().length === 0) {
212
+ json(res, 400, { error: "path query parameter is required" });
204
213
  return true;
205
214
  }
206
- const artifactMatch = pathname.match(/^\/api\/runs\/([^/]+)\/artifact$/);
207
- if (method === "GET" && artifactMatch) {
208
- const runId = decodeURIComponent(artifactMatch[1] ?? "");
209
- const artifactPath = url.searchParams.get("path");
210
- if (artifactPath === null || artifactPath.trim().length === 0) {
211
- json(res, 400, { error: "path query parameter is required" });
212
- return true;
215
+ try {
216
+ const mediaType = artifactMediaType(artifactPath);
217
+ if (mediaType !== undefined) {
218
+ const bytes = await readRunArtifactBytes(store, runId, artifactPath);
219
+ res.writeHead(200, {
220
+ "Content-Type": mediaType,
221
+ "Content-Length": bytes.length,
222
+ });
223
+ res.end(bytes);
213
224
  }
214
- try {
215
- const mediaType = artifactMediaType(artifactPath);
216
- if (mediaType !== undefined) {
217
- const bytes = await readRunArtifactBytes(store, runId, artifactPath);
218
- res.writeHead(200, {
219
- "Content-Type": mediaType,
220
- "Content-Length": bytes.length,
221
- });
222
- res.end(bytes);
223
- }
224
- else {
225
- const content = await readRunArtifact(store, runId, artifactPath);
226
- textPlain(res, 200, content);
227
- }
225
+ else {
226
+ const content = await readRunArtifact(store, runId, artifactPath);
227
+ textPlain(res, 200, content);
228
228
  }
229
- catch (err) {
230
- const message = err instanceof Error ? err.message : String(err);
231
- if (message === "Artifact path denied") {
232
- json(res, 403, { error: message });
233
- return true;
234
- }
235
- const notFound = message.startsWith("Run not found") ||
236
- message.startsWith("Artifact not found") ||
237
- /no such|not found/i.test(message);
238
- json(res, notFound ? 404 : 400, { error: message });
239
- }
240
- return true;
241
229
  }
242
- const verificationMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/verification$/);
243
- if (method === "GET" && verificationMatch) {
244
- const runId = decodeURIComponent(verificationMatch[1] ?? "");
245
- const stageId = decodeURIComponent(verificationMatch[2] ?? "");
230
+ catch (err) {
231
+ const mapped = mapStoreLookupError(err, { policy: "artifact" });
232
+ const status = mapped.kind === "denied" ? 403 : mapped.status;
233
+ json(res, status, { error: mapped.error });
234
+ }
235
+ return true;
236
+ }
237
+ const verificationMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/verification$/);
238
+ if (method === "GET" && verificationMatch) {
239
+ const runId = decodeURIComponent(verificationMatch[1] ?? "");
240
+ const stageId = decodeURIComponent(verificationMatch[2] ?? "");
241
+ try {
242
+ json(res, 200, await readStageVerificationHistory(store, runId, stageId));
243
+ }
244
+ catch (err) {
245
+ const mapped = mapStoreLookupError(err, { policy: "run" });
246
+ json(res, mapped.status, { error: mapped.error });
247
+ }
248
+ return true;
249
+ }
250
+ if (method === "GET" && pathname.startsWith("/api/runs/")) {
251
+ const rest = pathname.slice("/api/runs/".length);
252
+ if (rest && !rest.includes("/")) {
246
253
  try {
247
- json(res, 200, await readStageVerificationHistory(store, runId, stageId));
254
+ json(res, 200, await store.readRun(decodeURIComponent(rest)));
248
255
  }
249
256
  catch (err) {
250
- const message = err instanceof Error ? err.message : String(err);
251
- const notFound = /not found|no such/i.test(message);
252
- json(res, notFound ? 404 : 500, { error: message });
257
+ const mapped = mapStoreLookupError(err, { policy: "run" });
258
+ json(res, 404, { error: mapped.error });
253
259
  }
254
260
  return true;
255
261
  }
256
- if (method === "GET" && pathname.startsWith("/api/runs/")) {
257
- const rest = pathname.slice("/api/runs/".length);
258
- if (rest && !rest.includes("/")) {
259
- try {
260
- json(res, 200, await store.readRun(decodeURIComponent(rest)));
261
- }
262
- catch (err) {
263
- json(res, 404, {
264
- error: err instanceof Error ? err.message : String(err),
265
- });
266
- }
267
- return true;
268
- }
262
+ }
263
+ if (method === "POST" && pathname === "/api/runs") {
264
+ const body = (await readJsonBody(req));
265
+ if (typeof body.pipeline !== "string" ||
266
+ !body.pipeline.trim() ||
267
+ body.task === undefined) {
268
+ json(res, 400, { error: "task and pipeline are required" });
269
+ return true;
269
270
  }
270
- if (method === "POST" && pathname === "/api/runs") {
271
- const body = (await readJsonBody(req));
272
- if (typeof body.pipeline !== "string" ||
273
- !body.pipeline.trim() ||
274
- body.task === undefined) {
275
- json(res, 400, { error: "task and pipeline are required" });
276
- return true;
277
- }
278
- if (typeof body.task !== "string" && !isTaskFile(body.task)) {
279
- json(res, 400, {
280
- error: "task must be a path string or TaskFile object (id and goal required)",
281
- });
282
- return true;
283
- }
284
- const result = await manager.startRun({
285
- task: body.task,
286
- pipeline: body.pipeline.trim(),
271
+ if (typeof body.task !== "string" && !isTaskFile(body.task)) {
272
+ json(res, 400, {
273
+ error: "task must be a path string or TaskFile object (id and goal required)",
287
274
  });
288
- if (!result.ok) {
289
- json(res, result.status ?? 500, mapStartFailure(result));
290
- return true;
291
- }
292
- json(res, 202, { runId: result.runId });
293
275
  return true;
294
276
  }
295
- if (method === "POST" && pathname.match(/^\/api\/runs\/[^/]+\/rerun$/)) {
296
- const runId = decodeURIComponent(pathname.split("/")[3] ?? "");
297
- const result = await manager.rerun(runId);
298
- if (!result.ok) {
299
- json(res, result.status ?? 500, mapStartFailure(result));
300
- return true;
301
- }
302
- json(res, 202, { runId: result.runId });
277
+ if (body.checkoutOverride !== undefined &&
278
+ typeof body.checkoutOverride !== "string") {
279
+ json(res, 400, { error: "checkoutOverride must be a string" });
303
280
  return true;
304
281
  }
305
- const answerMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/answer$/);
306
- if (method === "POST" && answerMatch) {
307
- const runId = decodeURIComponent(answerMatch[1] ?? "");
308
- const stageId = decodeURIComponent(answerMatch[2] ?? "");
309
- let answer;
310
- try {
311
- answer = parseAskOperatorAnswer(await readJsonBody(req));
312
- }
313
- catch (err) {
314
- json(res, 400, {
315
- error: err instanceof Error ? err.message : String(err),
316
- });
317
- return true;
318
- }
319
- const result = await manager.deliverAnswer(runId, stageId, answer);
320
- if (!result.ok) {
321
- json(res, result.status, { error: result.reason });
322
- return true;
323
- }
324
- json(res, 202, { ok: true });
282
+ if (body.skipGates !== undefined && typeof body.skipGates !== "boolean") {
283
+ json(res, 400, { error: "skipGates must be a boolean" });
325
284
  return true;
326
285
  }
327
- const feedbackDecisionMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/feedback-decision$/);
328
- if (method === "POST" && feedbackDecisionMatch) {
329
- const runId = decodeURIComponent(feedbackDecisionMatch[1] ?? "");
330
- const stageId = decodeURIComponent(feedbackDecisionMatch[2] ?? "");
331
- let body;
332
- try {
333
- body = await readJsonBody(req);
334
- }
335
- catch {
336
- json(res, 400, { error: "Invalid JSON body" });
337
- return true;
338
- }
339
- if (body === null || typeof body !== "object" || Array.isArray(body)) {
340
- json(res, 400, { error: "Body must be a JSON object" });
286
+ for (const field of ["gitSha", "ciPrUrl", "ciJobUrl"]) {
287
+ if (body[field] !== undefined && typeof body[field] !== "string") {
288
+ json(res, 400, { error: `${field} must be a string` });
341
289
  return true;
342
290
  }
343
- const raw = body;
344
- if (raw.decision !== "extend" &&
345
- raw.decision !== "continue" &&
346
- raw.decision !== "abandon") {
291
+ }
292
+ let result;
293
+ try {
294
+ result = await manager.startRun({
295
+ task: body.task,
296
+ pipeline: body.pipeline.trim(),
297
+ ...(body.checkoutOverride !== undefined
298
+ ? { checkoutOverride: body.checkoutOverride }
299
+ : {}),
300
+ ...(body.skipGates !== undefined ? { skipGates: body.skipGates } : {}),
301
+ ...(body.gitSha !== undefined ? { gitSha: body.gitSha } : {}),
302
+ ...(body.ciPrUrl !== undefined ? { ciPrUrl: body.ciPrUrl } : {}),
303
+ ...(body.ciJobUrl !== undefined ? { ciJobUrl: body.ciJobUrl } : {}),
304
+ });
305
+ }
306
+ catch (err) {
307
+ if (err instanceof PipelineValidationError) {
347
308
  json(res, 400, {
348
- error: "decision must be extend, continue, or abandon",
309
+ error: "Pipeline validation failed",
310
+ validation: err.result,
349
311
  });
350
312
  return true;
351
313
  }
352
- if (raw.loopId !== undefined && typeof raw.loopId !== "string") {
353
- json(res, 400, { error: "loopId must be a string" });
354
- return true;
355
- }
356
- if (raw.reason !== undefined && typeof raw.reason !== "string") {
357
- json(res, 400, { error: "reason must be a string" });
358
- return true;
359
- }
360
- const result = await manager.decideFeedbackLoop(runId, stageId, {
361
- decision: raw.decision,
362
- ...(raw.loopId !== undefined ? { loopId: raw.loopId } : {}),
363
- ...(raw.reason !== undefined ? { reason: raw.reason } : {}),
364
- });
365
- if (!result.ok) {
366
- json(res, result.status ?? 500, { error: result.reason });
367
- return true;
368
- }
369
- json(res, 202, {
370
- ok: true,
371
- effect: result.effect,
372
- loopId: result.loopId,
373
- });
314
+ throw err;
315
+ }
316
+ if (!result.ok) {
317
+ json(res, result.status ?? 500, mapStartFailure(result));
374
318
  return true;
375
319
  }
376
- const retryMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/retry$/);
377
- if (method === "POST" && retryMatch) {
378
- await readJsonBody(req);
379
- const runId = decodeURIComponent(retryMatch[1] ?? "");
380
- const stageId = decodeURIComponent(retryMatch[2] ?? "");
381
- const result = await manager.retryStage(runId, stageId);
382
- if (!result.ok) {
383
- json(res, result.status ?? 500, mapRetryStageFailure(result));
384
- return true;
385
- }
386
- json(res, 202, {
387
- runId: result.runId,
388
- stageId: result.stageId,
389
- attemptIndex: result.attemptIndex,
390
- });
320
+ json(res, 202, { runId: result.runId });
321
+ return true;
322
+ }
323
+ if (method === "POST" && pathname.match(/^\/api\/runs\/[^/]+\/rerun$/)) {
324
+ const runId = decodeURIComponent(pathname.split("/")[3] ?? "");
325
+ const result = await manager.rerun(runId);
326
+ if (!result.ok) {
327
+ json(res, result.status ?? 500, mapStartFailure(result));
391
328
  return true;
392
329
  }
393
- const recoverMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/recovery$/);
394
- if (method === "POST" && recoverMatch) {
395
- let body;
396
- try {
397
- body = await readJsonBody(req);
398
- }
399
- catch {
400
- json(res, 400, { error: "Invalid JSON body" });
401
- return true;
402
- }
403
- const guidance = body !== null && typeof body === "object" && !Array.isArray(body)
404
- ? body.guidance
405
- : undefined;
406
- if (guidance !== undefined && typeof guidance !== "string") {
407
- json(res, 400, { error: "guidance must be a string" });
408
- return true;
409
- }
410
- const runId = decodeURIComponent(recoverMatch[1] ?? "");
411
- const stageId = decodeURIComponent(recoverMatch[2] ?? "");
412
- const result = await manager.recoverManualStage(runId, stageId, guidance);
413
- if (!result.ok) {
414
- json(res, result.status ?? 500, mapRetryStageFailure(result));
415
- return true;
416
- }
417
- json(res, 202, {
418
- runId: result.runId,
419
- stageId: result.stageId,
420
- attemptIndex: result.attemptIndex,
330
+ json(res, 202, { runId: result.runId });
331
+ return true;
332
+ }
333
+ const answerMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/answer$/);
334
+ if (method === "POST" && answerMatch) {
335
+ const runId = decodeURIComponent(answerMatch[1] ?? "");
336
+ const stageId = decodeURIComponent(answerMatch[2] ?? "");
337
+ let answer;
338
+ try {
339
+ answer = parseAskOperatorAnswer(await readJsonBody(req));
340
+ }
341
+ catch (err) {
342
+ json(res, 400, {
343
+ error: err instanceof Error ? err.message : String(err),
421
344
  });
422
345
  return true;
423
346
  }
424
- const stopRecoveryMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/recovery\/stop$/);
425
- if (method === "POST" && stopRecoveryMatch) {
426
- await readJsonBody(req);
427
- const runId = decodeURIComponent(stopRecoveryMatch[1] ?? "");
428
- const stageId = decodeURIComponent(stopRecoveryMatch[2] ?? "");
429
- const result = await manager.stopManualRecovery(runId, stageId);
430
- if (!result.ok) {
431
- json(res, result.status ?? 500, { error: result.reason });
432
- return true;
433
- }
434
- json(res, 202, result);
435
- return true;
436
- }
437
- const abandonMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/abandon$/);
438
- if (method === "POST" && abandonMatch) {
439
- await readJsonBody(req);
440
- const runId = decodeURIComponent(abandonMatch[1] ?? "");
441
- const stageId = decodeURIComponent(abandonMatch[2] ?? "");
442
- const result = await manager.abandonStage(runId, stageId);
443
- if (!result.ok) {
444
- json(res, result.status ?? 500, {
445
- error: result.reason,
446
- });
447
- return true;
448
- }
449
- json(res, 202, {
450
- ok: true,
451
- runId: result.runId,
452
- stageId: result.stageId,
453
- });
347
+ const result = await manager.deliverAnswer(runId, stageId, answer);
348
+ if (!result.ok) {
349
+ json(res, result.status, { error: result.reason });
454
350
  return true;
455
351
  }
456
- if (method === "GET" && pathname === "/api/tasks") {
457
- const catalog = await browseCatalog(cwd);
458
- json(res, 200, { tasks: catalog.tasks });
352
+ json(res, 202, { ok: true });
353
+ return true;
354
+ }
355
+ const feedbackDecisionMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/feedback-decision$/);
356
+ if (method === "POST" && feedbackDecisionMatch) {
357
+ const runId = decodeURIComponent(feedbackDecisionMatch[1] ?? "");
358
+ const stageId = decodeURIComponent(feedbackDecisionMatch[2] ?? "");
359
+ let body;
360
+ try {
361
+ body = await readJsonBody(req);
362
+ }
363
+ catch {
364
+ json(res, 400, { error: "Invalid JSON body" });
459
365
  return true;
460
366
  }
461
- if (method === "GET" && pathname === "/api/pipelines") {
462
- const catalog = await browseCatalog(cwd);
463
- json(res, 200, { pipelines: catalog.pipelines });
367
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
368
+ json(res, 400, { error: "Body must be a JSON object" });
464
369
  return true;
465
370
  }
466
- if (method === "GET" && pathname === "/api/stages") {
467
- json(res, 404, {
468
- error: "Global stage library removed; stages are pipeline-scoped",
371
+ const raw = body;
372
+ if (raw.decision !== "extend" &&
373
+ raw.decision !== "continue" &&
374
+ raw.decision !== "abandon") {
375
+ json(res, 400, {
376
+ error: "decision must be extend, continue, or abandon",
469
377
  });
470
378
  return true;
471
379
  }
472
- if (method === "POST" && pathname === "/api/stages") {
473
- let body;
474
- try {
475
- body = await readJsonBody(req);
476
- }
477
- catch {
478
- json(res, 400, { error: "Invalid JSON body" });
479
- return true;
480
- }
481
- const parsed = parseCreateStageBody(body);
482
- if ("ok" in parsed) {
483
- json(res, parsed.status, { error: parsed.error });
484
- return true;
485
- }
486
- const ctx = await resolveStageflowContext(cwd);
487
- if (!ctx.isGitProject) {
488
- json(res, 400, {
489
- error: "Project root not found; initialize stageflow.yaml in a git repo",
490
- });
491
- return true;
492
- }
493
- const result = await createStage(ctx.projectRoot, parsed);
494
- if (!result.ok) {
495
- json(res, result.status, { error: result.error });
496
- return true;
497
- }
498
- json(res, 201, result.stage);
380
+ if (raw.loopId !== undefined && typeof raw.loopId !== "string") {
381
+ json(res, 400, { error: "loopId must be a string" });
499
382
  return true;
500
383
  }
501
- if (method === "POST" && pathname === "/api/pipelines") {
502
- let body;
503
- try {
504
- body = await readJsonBody(req);
505
- }
506
- catch {
507
- json(res, 400, { error: "Invalid JSON body" });
508
- return true;
509
- }
510
- const parsed = parseCreatePipelineBody(body);
511
- if ("ok" in parsed) {
512
- json(res, parsed.status, { error: parsed.error });
513
- return true;
514
- }
515
- const ctx = await resolveStageflowContext(cwd);
516
- if (!ctx.isGitProject) {
517
- json(res, 400, {
518
- error: "Project root not found; initialize stageflow.yaml in a git repo",
519
- });
520
- return true;
521
- }
522
- const result = await createPipeline(ctx.projectRoot, parsed);
523
- if (!result.ok) {
524
- json(res, result.status, { error: result.error });
525
- return true;
526
- }
527
- json(res, 201, result.pipeline);
384
+ if (raw.reason !== undefined && typeof raw.reason !== "string") {
385
+ json(res, 400, { error: "reason must be a string" });
528
386
  return true;
529
387
  }
530
- if (method === "GET" && pathname === "/api/models") {
531
- const catalog = await browseCatalog(cwd);
532
- json(res, 200, { models: catalog.models });
388
+ const result = await manager.decideFeedbackLoop(runId, stageId, {
389
+ decision: raw.decision,
390
+ ...(raw.loopId !== undefined ? { loopId: raw.loopId } : {}),
391
+ ...(raw.reason !== undefined ? { reason: raw.reason } : {}),
392
+ });
393
+ if (!result.ok) {
394
+ json(res, result.status ?? 500, { error: result.reason });
395
+ return true;
396
+ }
397
+ json(res, 202, {
398
+ ok: true,
399
+ effect: result.effect,
400
+ loopId: result.loopId,
401
+ });
402
+ return true;
403
+ }
404
+ const retryMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/retry$/);
405
+ if (method === "POST" && retryMatch) {
406
+ await readJsonBody(req);
407
+ const runId = decodeURIComponent(retryMatch[1] ?? "");
408
+ const stageId = decodeURIComponent(retryMatch[2] ?? "");
409
+ const result = await manager.retryStage(runId, stageId);
410
+ if (!result.ok) {
411
+ json(res, result.status ?? 500, mapRetryStageFailure(result));
533
412
  return true;
534
413
  }
535
- if (await handleProviderRoutes(req, res, {
536
- cwd,
537
- readJsonBody,
538
- json,
539
- providerAuthContext,
540
- })) {
414
+ json(res, 202, {
415
+ runId: result.runId,
416
+ stageId: result.stageId,
417
+ attemptIndex: result.attemptIndex,
418
+ });
419
+ return true;
420
+ }
421
+ const resumeMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/resume$/);
422
+ if (method === "POST" && resumeMatch) {
423
+ await readJsonBody(req);
424
+ const runId = decodeURIComponent(resumeMatch[1] ?? "");
425
+ const stageId = decodeURIComponent(resumeMatch[2] ?? "");
426
+ const result = await manager.resumeTimedOutStage(runId, stageId);
427
+ if (!result.ok) {
428
+ json(res, result.status ?? 500, mapRetryStageFailure(result));
541
429
  return true;
542
430
  }
543
- if (await handleProjectMcpRoutes(req, res, {
544
- projectRoot: boot.rootDir,
545
- json,
546
- })) {
431
+ json(res, 202, {
432
+ runId: result.runId,
433
+ stageId: result.stageId,
434
+ attemptIndex: result.attemptIndex,
435
+ });
436
+ return true;
437
+ }
438
+ const recoverMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/recovery$/);
439
+ if (method === "POST" && recoverMatch) {
440
+ let body;
441
+ try {
442
+ body = await readJsonBody(req);
443
+ }
444
+ catch {
445
+ json(res, 400, { error: "Invalid JSON body" });
547
446
  return true;
548
447
  }
549
- if (method === "GET" && pathname === "/api/skills") {
550
- json(res, 200, await listSkills({ cwd, agentDir }));
448
+ const guidance = body !== null && typeof body === "object" && !Array.isArray(body)
449
+ ? body.guidance
450
+ : undefined;
451
+ if (guidance !== undefined && typeof guidance !== "string") {
452
+ json(res, 400, { error: "guidance must be a string" });
551
453
  return true;
552
454
  }
553
- if (method === "GET" && pathname === "/api/extensions") {
554
- json(res, 200, await listExtensions({ cwd, agentDir }));
455
+ const runId = decodeURIComponent(recoverMatch[1] ?? "");
456
+ const stageId = decodeURIComponent(recoverMatch[2] ?? "");
457
+ const result = await manager.recoverManualStage(runId, stageId, guidance);
458
+ if (!result.ok) {
459
+ json(res, result.status ?? 500, mapRetryStageFailure(result));
555
460
  return true;
556
461
  }
557
- if (method === "GET" && pathname === "/api/health") {
558
- json(res, 200, manager.getHealth());
462
+ json(res, 202, {
463
+ runId: result.runId,
464
+ stageId: result.stageId,
465
+ attemptIndex: result.attemptIndex,
466
+ });
467
+ return true;
468
+ }
469
+ const stopRecoveryMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/recovery\/stop$/);
470
+ if (method === "POST" && stopRecoveryMatch) {
471
+ await readJsonBody(req);
472
+ const runId = decodeURIComponent(stopRecoveryMatch[1] ?? "");
473
+ const stageId = decodeURIComponent(stopRecoveryMatch[2] ?? "");
474
+ const result = await manager.stopManualRecovery(runId, stageId);
475
+ if (!result.ok) {
476
+ json(res, result.status ?? 500, { error: result.reason });
559
477
  return true;
560
478
  }
561
- if (method === "GET" && pathname === "/api/settings") {
562
- const health = manager.getHealth();
563
- const credential = getCredentialSourceSettings(cwd);
564
- json(res, 200, {
565
- maxConcurrent: health.maxConcurrent,
566
- ...credential,
479
+ json(res, 202, result);
480
+ return true;
481
+ }
482
+ const abandonMatch = pathname.match(/^\/api\/runs\/([^/]+)\/stages\/([^/]+)\/abandon$/);
483
+ if (method === "POST" && abandonMatch) {
484
+ await readJsonBody(req);
485
+ const runId = decodeURIComponent(abandonMatch[1] ?? "");
486
+ const stageId = decodeURIComponent(abandonMatch[2] ?? "");
487
+ const result = await manager.abandonStage(runId, stageId);
488
+ if (!result.ok) {
489
+ json(res, result.status ?? 500, {
490
+ error: result.reason,
567
491
  });
568
492
  return true;
569
493
  }
570
- if (method === "POST" && pathname === "/api/settings") {
571
- let body;
572
- try {
573
- body = await readJsonBody(req);
574
- }
575
- catch {
576
- json(res, 400, { error: "Invalid JSON body" });
577
- return true;
578
- }
579
- const record = body !== null && typeof body === "object" && !Array.isArray(body)
580
- ? body
581
- : {};
582
- const hasMax = Object.prototype.hasOwnProperty.call(record, "maxConcurrent");
583
- const hasCredentialSource = Object.prototype.hasOwnProperty.call(record, "credentialSource");
584
- if (!hasMax && !hasCredentialSource) {
585
- json(res, 400, {
586
- error: "maxConcurrent or credentialSource is required",
587
- });
588
- return true;
589
- }
590
- let health = manager.getHealth();
591
- if (hasMax) {
592
- const n = parseSlotCount(record.maxConcurrent);
593
- if (n === undefined) {
594
- json(res, 400, { error: INVALID_SLOT_COUNT_MESSAGE });
595
- return true;
596
- }
597
- health = manager.setMaxConcurrent(n);
598
- }
599
- let credential = getCredentialSourceSettings(cwd);
600
- if (hasCredentialSource) {
601
- try {
602
- credential = setCredentialSource(cwd, record.credentialSource);
603
- }
604
- catch (err) {
605
- const mapped = providerAuthErrorBody(err);
606
- json(res, mapped.status, mapped.body);
607
- return true;
608
- }
609
- }
610
- json(res, 200, {
611
- ...health,
612
- ...credential,
494
+ json(res, 202, {
495
+ ok: true,
496
+ runId: result.runId,
497
+ stageId: result.stageId,
498
+ });
499
+ return true;
500
+ }
501
+ if (method === "GET" && pathname === "/api/tasks") {
502
+ const catalog = await browseCatalog(cwd);
503
+ json(res, 200, { tasks: catalog.tasks });
504
+ return true;
505
+ }
506
+ if (method === "GET" && pathname === "/api/pipelines") {
507
+ const catalog = await browseCatalog(cwd);
508
+ json(res, 200, { pipelines: catalog.pipelines });
509
+ return true;
510
+ }
511
+ if (method === "GET" && pathname === "/api/stages") {
512
+ json(res, 404, {
513
+ error: "Global stage library removed; stages are pipeline-scoped",
514
+ });
515
+ return true;
516
+ }
517
+ if (method === "POST" && pathname === "/api/stages") {
518
+ let body;
519
+ try {
520
+ body = await readJsonBody(req);
521
+ }
522
+ catch {
523
+ json(res, 400, { error: "Invalid JSON body" });
524
+ return true;
525
+ }
526
+ const parsed = parseCreateStageBody(body);
527
+ if ("ok" in parsed) {
528
+ json(res, parsed.status, { error: parsed.error });
529
+ return true;
530
+ }
531
+ const ctx = await resolveStageflowContext(cwd);
532
+ if (!ctx.isGitProject) {
533
+ json(res, 400, {
534
+ error: "Project root not found; initialize stageflow.yaml in a git repo",
613
535
  });
614
536
  return true;
615
537
  }
616
- if (method === "GET") {
617
- const served = await serveStatic(res, uiDistDir, pathname);
618
- if (served)
619
- return true;
620
- json(res, 404, {
621
- error: "UI not built. Run npm run ui:build, or use Vite dev proxy.",
538
+ const result = await createStage(ctx.projectRoot, parsed);
539
+ if (!result.ok) {
540
+ json(res, result.status, { error: result.error });
541
+ return true;
542
+ }
543
+ json(res, 201, result.stage);
544
+ return true;
545
+ }
546
+ if (method === "POST" && pathname === "/api/pipelines") {
547
+ let body;
548
+ try {
549
+ body = await readJsonBody(req);
550
+ }
551
+ catch {
552
+ json(res, 400, { error: "Invalid JSON body" });
553
+ return true;
554
+ }
555
+ const parsed = parseCreatePipelineBody(body);
556
+ if ("ok" in parsed) {
557
+ json(res, parsed.status, { error: parsed.error });
558
+ return true;
559
+ }
560
+ const ctx = await resolveStageflowContext(cwd);
561
+ if (!ctx.isGitProject) {
562
+ json(res, 400, {
563
+ error: "Project root not found; initialize stageflow.yaml in a git repo",
622
564
  });
623
565
  return true;
624
566
  }
625
- json(res, 404, { error: "Not found" });
567
+ const result = await createPipeline(ctx.projectRoot, parsed);
568
+ if (!result.ok) {
569
+ json(res, result.status, { error: result.error });
570
+ return true;
571
+ }
572
+ json(res, 201, result.pipeline);
626
573
  return true;
627
574
  }
628
- catch (err) {
629
- if (!res.headersSent) {
630
- json(res, 500, {
631
- error: err instanceof Error ? err.message : String(err),
575
+ if (method === "GET" && pathname === "/api/models") {
576
+ const catalog = await browseCatalog(cwd);
577
+ json(res, 200, { models: catalog.models });
578
+ return true;
579
+ }
580
+ if (await handleProviderRoutes(req, res, {
581
+ cwd,
582
+ readJsonBody,
583
+ json,
584
+ providerAuthContext,
585
+ })) {
586
+ return true;
587
+ }
588
+ if (await handleProjectMcpRoutes(req, res, {
589
+ projectRoot: rootDir,
590
+ json,
591
+ })) {
592
+ return true;
593
+ }
594
+ if (method === "GET" && pathname === "/api/skills") {
595
+ json(res, 200, await listSkills({ cwd, agentDir }));
596
+ return true;
597
+ }
598
+ if (method === "GET" && pathname === "/api/extensions") {
599
+ json(res, 200, await listExtensions({ cwd, agentDir }));
600
+ return true;
601
+ }
602
+ if (method === "GET" && pathname === "/api/health") {
603
+ json(res, 200, manager.getHealth());
604
+ return true;
605
+ }
606
+ if (method === "GET" && pathname === "/api/settings") {
607
+ const health = manager.getHealth();
608
+ const credential = getCredentialSourceSettings(cwd);
609
+ json(res, 200, {
610
+ maxConcurrent: health.maxConcurrent,
611
+ ...credential,
612
+ });
613
+ return true;
614
+ }
615
+ if (method === "POST" && pathname === "/api/settings") {
616
+ let body;
617
+ try {
618
+ body = await readJsonBody(req);
619
+ }
620
+ catch {
621
+ json(res, 400, { error: "Invalid JSON body" });
622
+ return true;
623
+ }
624
+ const record = body !== null && typeof body === "object" && !Array.isArray(body)
625
+ ? body
626
+ : {};
627
+ const hasMax = Object.prototype.hasOwnProperty.call(record, "maxConcurrent");
628
+ const hasCredentialSource = Object.prototype.hasOwnProperty.call(record, "credentialSource");
629
+ if (!hasMax && !hasCredentialSource) {
630
+ json(res, 400, {
631
+ error: "maxConcurrent or credentialSource is required",
632
632
  });
633
+ return true;
634
+ }
635
+ let health = manager.getHealth();
636
+ if (hasMax) {
637
+ const n = parseSlotCount(record.maxConcurrent);
638
+ if (n === undefined) {
639
+ json(res, 400, { error: INVALID_SLOT_COUNT_MESSAGE });
640
+ return true;
641
+ }
642
+ health = manager.setMaxConcurrent(n);
643
+ }
644
+ let credential = getCredentialSourceSettings(cwd);
645
+ if (hasCredentialSource) {
646
+ try {
647
+ credential = setCredentialSource(cwd, record.credentialSource);
648
+ }
649
+ catch (err) {
650
+ const mapped = mapProviderAuthError(err);
651
+ json(res, mapped.status, mapped.body);
652
+ return true;
653
+ }
633
654
  }
655
+ json(res, 200, {
656
+ ...health,
657
+ ...credential,
658
+ });
634
659
  return true;
635
660
  }
636
- },
661
+ if (method === "GET" && uiDistDir !== undefined) {
662
+ const served = await serveStatic(res, uiDistDir, pathname);
663
+ if (served)
664
+ return true;
665
+ json(res, 404, {
666
+ error: "UI not built. Run npm run ui:build, or use Vite dev proxy.",
667
+ });
668
+ return true;
669
+ }
670
+ json(res, 404, { error: "Not found" });
671
+ return true;
672
+ }
673
+ catch (err) {
674
+ if (!res.headersSent) {
675
+ json(res, 500, {
676
+ error: err instanceof Error ? err.message : String(err),
677
+ });
678
+ }
679
+ return true;
680
+ }
681
+ };
682
+ }
683
+ export async function startUiServer(options) {
684
+ const host = options.host ?? "127.0.0.1";
685
+ const port = options.port ?? DEFAULT_PORT;
686
+ const uiDistDir = options.uiDistDir ?? defaultUiDistDir();
687
+ const boot = await bootstrapStageflowHost(options);
688
+ const { manager, store, cwd, agentDir, rootDir } = boot;
689
+ const providerAuthContext = boot.providerAuthContext;
690
+ return createHttpHost({
691
+ boot,
692
+ host,
693
+ port,
694
+ routes: createOperatorRoutes({
695
+ manager,
696
+ store,
697
+ cwd,
698
+ agentDir,
699
+ rootDir,
700
+ providerAuthContext,
701
+ uiDistDir,
702
+ }),
637
703
  });
638
704
  }
639
705
  export { DEFAULT_PORT };