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
@@ -150,6 +150,25 @@ export function writeMaxConcurrentToContext(ctx, maxConcurrent) {
150
150
  export function writeMaxConcurrentToFile(cwd, maxConcurrent) {
151
151
  writeFactorySettings(cwd, { maxConcurrent });
152
152
  }
153
+ export function readMaxConcurrentFromGlobal() {
154
+ return parseFactorySettingsFromRaw(readRawSettingsFromFile(globalSettingsFilePath())).maxConcurrent;
155
+ }
156
+ export function writeMaxConcurrentToGlobal(maxConcurrent) {
157
+ if (parseSlotCount(maxConcurrent) === undefined) {
158
+ throw new Error(INVALID_SLOT_COUNT_MESSAGE);
159
+ }
160
+ const current = parseFactorySettingsFromRaw(readRawSettingsFromFile(globalSettingsFilePath()));
161
+ const next = { ...current, maxConcurrent };
162
+ const out = {};
163
+ if (next.maxConcurrent !== undefined) {
164
+ out.maxConcurrent = next.maxConcurrent;
165
+ }
166
+ if (next.credentialSource !== undefined) {
167
+ out.credentialSource = next.credentialSource;
168
+ }
169
+ mkdirSync(path.dirname(globalSettingsFilePath()), { recursive: true });
170
+ writeFileSync(globalSettingsFilePath(), `${JSON.stringify(out, null, 2)}\n`, "utf8");
171
+ }
153
172
  export function readCredentialSourceFromFile(cwd) {
154
173
  return readFactorySettings(cwd).credentialSource;
155
174
  }
@@ -1,4 +1,5 @@
1
1
  import type { AgentPort, FeedbackLoopContext, StageHandle, StageRunInput, StageSessionMode } from "../agent/port.js";
2
+ import { type StageStreamLogWriter } from "./stageStreamLog.js";
2
3
  import type { RunStore } from "../runstore/port.js";
3
4
  import type { StageEnvelope } from "../types/envelope.js";
4
5
  import type { ResolvedPipelineDag } from "../types/pipeline.js";
@@ -23,6 +24,8 @@ export type StageAttemptOpenInput = {
23
24
  attemptCtx?: StageAttemptContext;
24
25
  operatorCatalog?: OperatorCatalog;
25
26
  onActivity?: StageRunInput["onActivity"];
27
+ /** Test seam; defaults to the real createStageStreamLogWriter. */
28
+ streamLogWriterFactory?: (streamLogPath: string) => StageStreamLogWriter;
26
29
  roots?: StageRoots;
27
30
  resumeToken?: string;
28
31
  sessionMode?: StageSessionMode;
@@ -1,7 +1,8 @@
1
1
  import { resolveSkillByName } from "../config/listSkills.js";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { MCP_CATALOG_FILENAME, STAGEFLOW_STAGE_ARTIFACTS_DIR_ENV, StageMcpError, resolveStageMcpServers, stampStagePromptArtifactsDir, } from "../config/resolveStageMcpServers.js";
4
- import { attemptArtifactsDir } from "../runstore/workspaceLayout.js";
4
+ import { attemptArtifactsDir, attemptStreamLogPath } from "../runstore/workspaceLayout.js";
5
+ import { createStageStreamLogWriter, } from "./stageStreamLog.js";
5
6
  import { resolveCloneEmitContext, resolveForkEmitContext } from "../config/resolveForkEmitContext.js";
6
7
  import { createAttemptQaTrailReader } from "../hitl/qaTrail.js";
7
8
  import { assertPriorInputPayload, payloadInstanceMismatch, } from "../envelope/payloadSchema.js";
@@ -199,6 +200,7 @@ export async function openStageAttempt(input) {
199
200
  return priorInput;
200
201
  const roots = resolveAttemptRoots(input, stageId);
201
202
  const attempt = input.attemptCtx?.attempt ?? 1;
203
+ const streamWriter = (input.streamLogWriterFactory ?? createStageStreamLogWriter)(attemptStreamLogPath(input.workspaceDir, stageId, attempt));
202
204
  const resumeToken = input.resumeToken ??
203
205
  resumeSessionFilePath(input.workspaceDir, stageId, attempt);
204
206
  const forkEmitContext = resolveForkEmitContext(input.dag, definitionId);
@@ -231,7 +233,11 @@ export async function openStageAttempt(input) {
231
233
  : {}),
232
234
  resumeToken,
233
235
  ...(input.sessionMode !== undefined ? { sessionMode: input.sessionMode } : {}),
234
- onActivity: input.onActivity,
236
+ onActivity: (event) => {
237
+ input.onActivity?.(event);
238
+ void streamWriter.flush();
239
+ },
240
+ onAssistantTextDelta: streamWriter.onDelta,
235
241
  forkEmitContext,
236
242
  cloneEmitContext,
237
243
  ...(feedbackLoopEmitContext !== undefined ? { feedbackLoopEmitContext } : {}),
@@ -0,0 +1,17 @@
1
+ /** First line of every stream.log: the logical byte offset of the content that follows. */
2
+ export declare const STREAM_LOG_HEADER_PREFIX = "BASE:";
3
+ export declare function encodeStreamLogHeader(base: number): string;
4
+ /** Split a stream.log buffer into its logical base offset and content bytes. */
5
+ export declare function parseStreamLogHeader(raw: Buffer): {
6
+ base: number;
7
+ headerLength: number;
8
+ };
9
+ export type StageStreamLogWriter = {
10
+ onDelta(delta: string): void;
11
+ /** Idempotent; resolves once any pending buffered text has been written to disk. */
12
+ flush(): Promise<void>;
13
+ };
14
+ export declare function createStageStreamLogWriter(streamLogPath: string, options?: {
15
+ flushThrottleMs?: number;
16
+ capBytes?: number;
17
+ }): StageStreamLogWriter;
@@ -0,0 +1,89 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { STREAM_FLUSH_THROTTLE_MS } from "../agent/activityObserver.js";
4
+ import { redactSecrets } from "../agent/streamLogRedact.js";
5
+ const DEFAULT_CAP_BYTES = 256 * 1024;
6
+ /** First line of every stream.log: the logical byte offset of the content that follows. */
7
+ export const STREAM_LOG_HEADER_PREFIX = "BASE:";
8
+ export function encodeStreamLogHeader(base) {
9
+ return `${STREAM_LOG_HEADER_PREFIX}${base}\n`;
10
+ }
11
+ /** Split a stream.log buffer into its logical base offset and content bytes. */
12
+ export function parseStreamLogHeader(raw) {
13
+ const prefix = Buffer.from(STREAM_LOG_HEADER_PREFIX, "utf8");
14
+ if (raw.subarray(0, prefix.length).equals(prefix)) {
15
+ const newlineIdx = raw.indexOf(0x0a);
16
+ if (newlineIdx !== -1) {
17
+ const baseStr = raw.subarray(prefix.length, newlineIdx).toString("utf8");
18
+ const base = Number(baseStr);
19
+ if (Number.isFinite(base) && base >= 0) {
20
+ return { base, headerLength: newlineIdx + 1 };
21
+ }
22
+ }
23
+ }
24
+ return { base: 0, headerLength: 0 };
25
+ }
26
+ async function appendChunk(streamLogPath, chunk, capBytes) {
27
+ await mkdir(path.dirname(streamLogPath), { recursive: true });
28
+ let base = 0;
29
+ let contentBytes;
30
+ try {
31
+ const raw = await readFile(streamLogPath);
32
+ const parsed = parseStreamLogHeader(raw);
33
+ base = parsed.base;
34
+ contentBytes = raw.subarray(parsed.headerLength);
35
+ }
36
+ catch {
37
+ contentBytes = Buffer.alloc(0);
38
+ }
39
+ contentBytes = Buffer.concat([contentBytes, Buffer.from(chunk, "utf8")]);
40
+ const headerBytes = Buffer.from(encodeStreamLogHeader(base), "utf8");
41
+ if (headerBytes.length + contentBytes.length > capBytes) {
42
+ let dropBytes = Math.floor(contentBytes.length / 2);
43
+ // Never split a multi-byte UTF-8 codepoint: skip past continuation bytes.
44
+ while (dropBytes < contentBytes.length &&
45
+ (contentBytes[dropBytes] & 0xc0) === 0x80) {
46
+ dropBytes++;
47
+ }
48
+ base += dropBytes;
49
+ contentBytes = contentBytes.subarray(dropBytes);
50
+ }
51
+ const finalHeader = Buffer.from(encodeStreamLogHeader(base), "utf8");
52
+ await writeFile(streamLogPath, Buffer.concat([finalHeader, contentBytes]));
53
+ }
54
+ export function createStageStreamLogWriter(streamLogPath, options = {}) {
55
+ const flushThrottleMs = options.flushThrottleMs ?? STREAM_FLUSH_THROTTLE_MS;
56
+ const capBytes = options.capBytes ?? DEFAULT_CAP_BYTES;
57
+ let buffer = "";
58
+ let timer = null;
59
+ // Serializes disk writes so the throttle timer and an eager flush() never race.
60
+ let writeChain = Promise.resolve();
61
+ function flushNow() {
62
+ if (timer !== null) {
63
+ clearTimeout(timer);
64
+ timer = null;
65
+ }
66
+ if (!buffer)
67
+ return writeChain;
68
+ const chunk = redactSecrets(buffer);
69
+ buffer = "";
70
+ writeChain = writeChain.then(() => appendChunk(streamLogPath, chunk, capBytes));
71
+ return writeChain;
72
+ }
73
+ return {
74
+ onDelta(delta) {
75
+ if (!delta)
76
+ return;
77
+ buffer += delta;
78
+ if (timer === null) {
79
+ timer = setTimeout(() => {
80
+ timer = null;
81
+ void flushNow();
82
+ }, flushThrottleMs);
83
+ }
84
+ },
85
+ flush() {
86
+ return flushNow();
87
+ },
88
+ };
89
+ }
@@ -3,6 +3,7 @@ import { asAgentBackendId } from "../agent/agentBackend.js";
3
3
  import { loadStageflowManifestOutcome } from "../config/loadStageflowManifest.js";
4
4
  import { definitionIdForInstance } from "../runstore/stageInstanceId.js";
5
5
  import { createRunStore } from "../runstore/createStore.js";
6
+ import { globalStageflowHome } from "../project/globalHome.js";
6
7
  import { loadRunContext } from "./resumeReconstruct.js";
7
8
  import { bindPiAgentDirEnv, rootsForStageWorker, withResolvedAuthPath, } from "./stageRoots.js";
8
9
  import { runStage, isRunStageWaiting, } from "./stageRunner.js";
@@ -11,7 +12,7 @@ import { attemptContext } from "./stageAttemptContext.js";
11
12
  import { loadActiveFeedbackLoopContext } from "./feedbackLoopCoordinator.js";
12
13
  import { outcomeToWorkerResult, STAGE_WORKER_EXIT, } from "./stageWorkerProtocol.js";
13
14
  export async function runStageWorker(input) {
14
- const store = createRunStore({ rootDir: input.rootDir });
15
+ const store = createRunStore({ rootDir: globalStageflowHome() });
15
16
  const { meta, task, loaded } = await loadRunContext(store, input.runId, input.rootDir);
16
17
  const definitionId = definitionIdForInstance(meta.pipeline_dag, input.stageId);
17
18
  const stage = loaded.stages.find((s) => s.id === definitionId);
@@ -15,7 +15,7 @@ export async function bootstrapStageflowHost(options) {
15
15
  ? findProjectRoot(rootDir) !== null
16
16
  : ctx.isGitProject;
17
17
  const rawStore = options.store ??
18
- createRunStore({ rootDir, kind: options.storeKind });
18
+ createRunStore({ rootDir: ctx.globalHome, kind: options.storeKind });
19
19
  const boundBus = isRunStoreWrapped(rawStore)
20
20
  ? getRunChangeBusFromWrappedStore(rawStore)
21
21
  : undefined;
@@ -41,7 +41,14 @@ export async function bootstrapStageflowHost(options) {
41
41
  const mcpStateless = resolveMcpStateless({
42
42
  mcpStateless: options.mcpStateless,
43
43
  });
44
- const mcpHandler = createMcpHttpHandler({ manager, store, cwd, runChangeBus }, { mcpStateless });
44
+ const mcpHandler = createMcpHttpHandler({
45
+ manager,
46
+ store,
47
+ cwd,
48
+ runChangeBus,
49
+ providerAuthContext: options.providerAuthContext,
50
+ projectRoot: rootDir,
51
+ }, { mcpStateless });
45
52
  return {
46
53
  cwd,
47
54
  agentDir,
@@ -0,0 +1,35 @@
1
+ import { type ChildProcess, spawn as spawnDefault } from "node:child_process";
2
+ /** Coarse up/down probe result, used by CLI code that only needs "is it safe to talk to the host." */
3
+ export type HostProbeResult = "up" | "down";
4
+ /**
5
+ * Finer-grained probe result used to auto-start: "unreachable" (nothing
6
+ * listening, safe to spawn) is distinct from "unhealthy" (something is
7
+ * listening on the port but didn't answer with valid health JSON, so it's
8
+ * almost certainly not our service and spawning would just collide).
9
+ */
10
+ export type ServiceProbeResult = "up" | "unreachable" | "unhealthy";
11
+ export declare function hostBaseUrl(): string;
12
+ export declare function probeGlobalServiceDetailed(): Promise<ServiceProbeResult>;
13
+ export declare function defaultProbeHost(): Promise<HostProbeResult>;
14
+ export type EnsureGlobalServiceResult = {
15
+ ok: true;
16
+ alreadyRunning: boolean;
17
+ } | {
18
+ ok: false;
19
+ reason: "port_occupied" | "spawn_failed" | "timed_out";
20
+ message: string;
21
+ };
22
+ export interface EnsureGlobalServiceOptions {
23
+ /** Path to the CLI entry script to spawn `<entry> mcp` against. Defaults to the running process's own entry (process.argv[1]). */
24
+ cliEntry?: string;
25
+ probeHost?: () => Promise<ServiceProbeResult>;
26
+ spawnFn?: (command: string, args: string[], options: Parameters<typeof spawnDefault>[2]) => ChildProcess;
27
+ pollIntervalMs?: number;
28
+ timeoutMs?: number;
29
+ }
30
+ /**
31
+ * Health-probe-then-spawn: makes sure a global Stageflow service is
32
+ * reachable at the well-known port, starting one headlessly (`<cliEntry>
33
+ * mcp`, detached) if nothing answers yet. No-ops if one is already up.
34
+ */
35
+ export declare function ensureGlobalService(options?: EnsureGlobalServiceOptions): Promise<EnsureGlobalServiceResult>;
@@ -0,0 +1,138 @@
1
+ import { spawn as spawnDefault } from "node:child_process";
2
+ import { openSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { ensureGlobalHome } from "../project/globalHome.js";
6
+ import { DEFAULT_PORT } from "./createHttpHost.js";
7
+ /**
8
+ * The well-known port the global service listens on. Overridable via
9
+ * STAGEFLOW_SERVICE_PORT — real usage never sets this (there's exactly one
10
+ * global service by design), but tests use it to point a spawned `sf run`
11
+ * subprocess at an isolated, already-running test server instead of the
12
+ * real machine's global service.
13
+ */
14
+ function resolveServicePort() {
15
+ const raw = process.env.STAGEFLOW_SERVICE_PORT;
16
+ if (raw === undefined)
17
+ return DEFAULT_PORT;
18
+ const parsed = Number(raw);
19
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PORT;
20
+ }
21
+ /**
22
+ * The published `sf` binary is compiled JS (`dist/cli.js`), directly
23
+ * runnable as `node <entry> mcp`. In dev/test, `cliEntry` is instead raw
24
+ * TypeScript source (`src/cli.ts`), which plain node can't execute — route
25
+ * through the same tsx loader `npm run dev` already uses for that case.
26
+ */
27
+ function resolveSpawnArgs(cliEntry) {
28
+ const portArgs = ["--port", String(resolveServicePort())];
29
+ if (!/\.(?:m|c)?ts$/.test(cliEntry)) {
30
+ return [cliEntry, "mcp", ...portArgs];
31
+ }
32
+ try {
33
+ const tsxCliUrl = import.meta.resolve("tsx/cli");
34
+ return [fileURLToPath(tsxCliUrl), cliEntry, "mcp", ...portArgs];
35
+ }
36
+ catch {
37
+ return [cliEntry, "mcp", ...portArgs];
38
+ }
39
+ }
40
+ export function hostBaseUrl() {
41
+ return `http://127.0.0.1:${resolveServicePort()}`;
42
+ }
43
+ export async function probeGlobalServiceDetailed() {
44
+ const ac = new AbortController();
45
+ const timer = setTimeout(() => ac.abort(), 1500);
46
+ try {
47
+ let res;
48
+ try {
49
+ res = await fetch(`${hostBaseUrl()}/api/health`, { signal: ac.signal });
50
+ }
51
+ catch {
52
+ return "unreachable";
53
+ }
54
+ if (res.status !== 200)
55
+ return "unhealthy";
56
+ try {
57
+ JSON.parse(await res.text());
58
+ }
59
+ catch {
60
+ return "unhealthy";
61
+ }
62
+ return "up";
63
+ }
64
+ finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+ export async function defaultProbeHost() {
69
+ return (await probeGlobalServiceDetailed()) === "up" ? "up" : "down";
70
+ }
71
+ const DEFAULT_POLL_INTERVAL_MS = 200;
72
+ const DEFAULT_TIMEOUT_MS = Number(process.env.STAGEFLOW_AUTOSTART_TIMEOUT_MS) || 10_000;
73
+ function sleep(ms) {
74
+ return new Promise((resolve) => setTimeout(resolve, ms));
75
+ }
76
+ /**
77
+ * Health-probe-then-spawn: makes sure a global Stageflow service is
78
+ * reachable at the well-known port, starting one headlessly (`<cliEntry>
79
+ * mcp`, detached) if nothing answers yet. No-ops if one is already up.
80
+ */
81
+ export async function ensureGlobalService(options = {}) {
82
+ const probeHost = options.probeHost ?? probeGlobalServiceDetailed;
83
+ const spawnFn = options.spawnFn ?? spawnDefault;
84
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
85
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
86
+ const initial = await probeHost();
87
+ if (initial === "up") {
88
+ return { ok: true, alreadyRunning: true };
89
+ }
90
+ if (initial === "unhealthy") {
91
+ return {
92
+ ok: false,
93
+ reason: "port_occupied",
94
+ message: `Port ${resolveServicePort()} is already in use by a process that isn't the Stageflow service (its health check didn't return valid health JSON). Free the port and try again.`,
95
+ };
96
+ }
97
+ const cliEntry = options.cliEntry ?? process.argv[1] ?? "";
98
+ const globalHome = ensureGlobalHome();
99
+ const logPath = path.join(globalHome, "service.log");
100
+ let logFd;
101
+ try {
102
+ logFd = openSync(logPath, "a");
103
+ }
104
+ catch (err) {
105
+ return {
106
+ ok: false,
107
+ reason: "spawn_failed",
108
+ message: `Failed to open service log at ${logPath}: ${err instanceof Error ? err.message : String(err)}`,
109
+ };
110
+ }
111
+ try {
112
+ const spawnArgs = resolveSpawnArgs(cliEntry);
113
+ const child = spawnFn(process.execPath, spawnArgs, {
114
+ detached: true,
115
+ stdio: ["ignore", logFd, logFd],
116
+ });
117
+ child.unref();
118
+ }
119
+ catch (err) {
120
+ return {
121
+ ok: false,
122
+ reason: "spawn_failed",
123
+ message: `Failed to spawn the global Stageflow service: ${err instanceof Error ? err.message : String(err)}`,
124
+ };
125
+ }
126
+ const deadline = Date.now() + timeoutMs;
127
+ while (Date.now() < deadline) {
128
+ await sleep(pollIntervalMs);
129
+ if ((await probeHost()) === "up") {
130
+ return { ok: true, alreadyRunning: false };
131
+ }
132
+ }
133
+ return {
134
+ ok: false,
135
+ reason: "timed_out",
136
+ message: `Timed out after ${timeoutMs}ms waiting for the global Stageflow service to become healthy. Check the log at ${logPath}.`,
137
+ };
138
+ }
@@ -2,8 +2,9 @@ import type { AgentPort } from "../agent/port.js";
2
2
  import { type ProviderAuthContext } from "../agent/providerAuth.js";
3
3
  import type { RunStoreKind } from "../runstore/createStore.js";
4
4
  import type { RunStore } from "../runstore/port.js";
5
+ import type { RunManager } from "../runtime/runManager.js";
5
6
  import type { RunChangeBus } from "../runtime/runChangeBus.js";
6
- import { DEFAULT_PORT, type HttpHostEnvelope } from "./createHttpHost.js";
7
+ import { DEFAULT_PORT, type HttpHostEnvelope, type HttpHostRouteContext } from "./createHttpHost.js";
7
8
  export type UiServerOptions = {
8
9
  agent: AgentPort;
9
10
  cwd?: string;
@@ -20,5 +21,23 @@ export type UiServerOptions = {
20
21
  runChangeBus?: RunChangeBus;
21
22
  };
22
23
  export declare function defaultUiDistDir(): string;
24
+ export type OperatorRouteDeps = {
25
+ manager: RunManager;
26
+ store: RunStore;
27
+ cwd: string;
28
+ agentDir: string;
29
+ rootDir: string;
30
+ providerAuthContext: ProviderAuthContext | undefined;
31
+ /** Omit for a headless service (e.g. `sf mcp`) — GETs outside the API surface just 404. */
32
+ uiDistDir?: string;
33
+ };
34
+ /**
35
+ * The full operator-console route surface: REST API (runs, stages,
36
+ * catalog, settings, providers) plus, when `uiDistDir` is given, static
37
+ * console UI file serving. Shared by `startUiServer` (browser-facing) and
38
+ * `startMcpServer` (headless global-service daemon) — both need the REST
39
+ * API; only the former needs the UI files.
40
+ */
41
+ export declare function createOperatorRoutes(deps: OperatorRouteDeps): (ctx: HttpHostRouteContext) => Promise<boolean | void>;
23
42
  export declare function startUiServer(options: UiServerOptions): Promise<HttpHostEnvelope>;
24
43
  export { DEFAULT_PORT };