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