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.
- package/README.md +2 -1
- package/dist/agent/activity.d.ts +5 -0
- package/dist/agent/activityObserver.d.ts +3 -0
- package/dist/agent/activityObserver.js +6 -1
- package/dist/agent/piAdapter.js +7 -3
- package/dist/agent/port.d.ts +2 -0
- package/dist/agent/providerInspect.d.ts +19 -0
- package/dist/agent/providerInspect.js +35 -0
- package/dist/agent/streamLogRedact.d.ts +2 -0
- package/dist/agent/streamLogRedact.js +13 -0
- package/dist/cli/artifactCommand.js +2 -2
- package/dist/cli/envelopeCommand.js +2 -2
- package/dist/cli/exportRunCommand.js +2 -2
- package/dist/cli/hostClient.d.ts +43 -0
- package/dist/cli/hostClient.js +182 -0
- package/dist/cli/providersCommand.js +5 -3
- package/dist/cli/runCommand.d.ts +6 -5
- package/dist/cli/runCommand.js +23 -31
- package/dist/cli/runOutput.d.ts +1 -1
- package/dist/cli/runsCommand.d.ts +3 -7
- package/dist/cli/runsCommand.js +33 -94
- package/dist/cli.js +1 -5
- package/dist/config/describePipeline.d.ts +26 -0
- package/dist/config/describePipeline.js +37 -0
- package/dist/config/loadPipeline.d.ts +13 -1
- package/dist/config/loadPipeline.js +34 -4
- package/dist/config/mergePipelineIncludes.d.ts +11 -0
- package/dist/config/mergePipelineIncludes.js +75 -0
- package/dist/config/validateCatalog.d.ts +2 -2
- package/dist/config/validateCatalog.js +24 -17
- package/dist/mcp/catalogTools.js +146 -52
- package/dist/mcp/controlTools.js +77 -19
- package/dist/mcp/deps.d.ts +3 -0
- package/dist/mcp/projectMcpTools.d.ts +3 -0
- package/dist/mcp/projectMcpTools.js +25 -0
- package/dist/mcp/providerTools.d.ts +3 -0
- package/dist/mcp/providerTools.js +17 -0
- package/dist/mcp/readArtifact.d.ts +9 -0
- package/dist/mcp/readArtifact.js +11 -0
- package/dist/mcp/server.js +9 -1
- package/dist/mcp/tailStreamLog.d.ts +8 -0
- package/dist/mcp/tailStreamLog.js +28 -0
- package/dist/mcp/toolResults.d.ts +17 -0
- package/dist/mcp/toolResults.js +15 -0
- package/dist/mcp/tools.js +4 -0
- package/dist/mcp/waitRun.js +4 -4
- package/dist/mcp/waitingGates.d.ts +1 -0
- package/dist/mcp/waitingGates.js +3 -2
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/projection/projectRun.d.ts +3 -0
- package/dist/projection/projectRun.js +7 -0
- package/dist/runstore/port.d.ts +3 -1
- package/dist/runstore/sqlite/SqliteRunStore.d.ts +2 -1
- package/dist/runstore/sqlite/SqliteRunStore.js +15 -1
- package/dist/runstore/workspaceLayout.d.ts +1 -0
- package/dist/runstore/workspaceLayout.js +3 -0
- package/dist/runtime/feedbackLoopCoordinator.d.ts +9 -1
- package/dist/runtime/feedbackLoopCoordinator.js +50 -20
- package/dist/runtime/feedbackLoopDecision.js +33 -1
- package/dist/runtime/pipelineRunner.d.ts +3 -3
- package/dist/runtime/pipelineRunner.js +3 -1
- package/dist/runtime/resumeReconstruct.js +2 -2
- package/dist/runtime/runManager.d.ts +2 -1
- package/dist/runtime/runManager.js +36 -16
- package/dist/runtime/settingsFile.d.ts +2 -0
- package/dist/runtime/settingsFile.js +19 -0
- package/dist/runtime/stageAttemptBootstrap.d.ts +3 -0
- package/dist/runtime/stageAttemptBootstrap.js +8 -2
- package/dist/runtime/stageStreamLog.d.ts +17 -0
- package/dist/runtime/stageStreamLog.js +89 -0
- package/dist/runtime/stageWorker.js +2 -1
- package/dist/server/bootstrap.js +9 -2
- package/dist/server/ensureGlobalService.d.ts +35 -0
- package/dist/server/ensureGlobalService.js +138 -0
- package/dist/server/http.d.ts +20 -1
- package/dist/server/http.js +450 -402
- package/dist/server/mcpHost.js +7 -8
- package/dist/server/operatorResults.d.ts +10 -0
- package/dist/server/operatorResults.js +24 -0
- package/dist/server/providerRoutes.d.ts +0 -6
- package/dist/server/providerRoutes.js +10 -18
- package/dist/types/pipeline.d.ts +8 -0
- package/dist/ui/assets/{index-DMVdl1qB.js → index-Da5EHU6M.js} +5 -5
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/skills/stageflow/references/control-surface.md +2 -2
- package/skills/stageflow-run/SKILL.md +27 -9
- package/skills/stageflow-run/references/mcp-call.md +1 -1
- 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:
|
|
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:
|
|
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);
|
package/dist/server/bootstrap.js
CHANGED
|
@@ -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({
|
|
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
|
+
}
|
package/dist/server/http.d.ts
CHANGED
|
@@ -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 };
|