stageflow 0.23.0 → 0.25.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 (44) hide show
  1. package/README.md +15 -3
  2. package/dist/a2a/configDiscovery.d.ts +9 -0
  3. package/dist/a2a/configDiscovery.js +18 -0
  4. package/dist/a2a/contracts.d.ts +93 -0
  5. package/dist/a2a/contracts.js +163 -0
  6. package/dist/a2a/limits.d.ts +14 -0
  7. package/dist/a2a/limits.js +29 -0
  8. package/dist/a2a/registry.d.ts +44 -0
  9. package/dist/a2a/registry.js +159 -0
  10. package/dist/a2a/server.d.ts +29 -0
  11. package/dist/a2a/server.js +341 -0
  12. package/dist/a2a/service.d.ts +97 -0
  13. package/dist/a2a/service.js +496 -0
  14. package/dist/a2a/store.d.ts +106 -0
  15. package/dist/a2a/store.js +245 -0
  16. package/dist/cli/a2aCommand.d.ts +8 -0
  17. package/dist/cli/a2aCommand.js +131 -0
  18. package/dist/cli/runStageCommand.d.ts +33 -0
  19. package/dist/cli/runStageCommand.js +407 -0
  20. package/dist/cli.js +19 -0
  21. package/dist/mcp/catalogTools.js +139 -0
  22. package/dist/package-meta.d.ts +1 -1
  23. package/dist/package-meta.js +1 -1
  24. package/dist/runstore/createStore.d.ts +10 -0
  25. package/dist/runstore/createStore.js +13 -4
  26. package/dist/runstore/port.d.ts +14 -1
  27. package/dist/runstore/sqlite/SqliteRunStore.d.ts +9 -0
  28. package/dist/runstore/sqlite/SqliteRunStore.js +66 -25
  29. package/dist/runstore/sqlite/schema.d.ts +1 -1
  30. package/dist/runstore/sqlite/schema.js +6 -0
  31. package/dist/runstore/submission.d.ts +11 -0
  32. package/dist/runstore/submission.js +7 -0
  33. package/dist/runtime/pipelineRunner.d.ts +2 -0
  34. package/dist/runtime/pipelineRunner.js +3 -0
  35. package/dist/runtime/pipelineScheduler.js +32 -15
  36. package/dist/runtime/reloadRunCatalog.js +7 -1
  37. package/dist/runtime/resolveEnvelopeRef.d.ts +30 -0
  38. package/dist/runtime/resolveEnvelopeRef.js +79 -0
  39. package/dist/runtime/runManager.d.ts +22 -1
  40. package/dist/runtime/runManager.js +48 -4
  41. package/dist/server/bootstrap.d.ts +2 -0
  42. package/dist/server/bootstrap.js +14 -3
  43. package/dist/server/createHttpHost.js +6 -0
  44. package/package.json +2 -1
@@ -0,0 +1,407 @@
1
+ import { PACKAGE_VERSION } from "../package-meta.js";
2
+ import { ensureGlobalService, hostBaseUrl, } from "../server/ensureGlobalService.js";
3
+ import { resolveAbsolute } from "./hostClient.js";
4
+ export const RUN_STAGE_USAGE = `Usage:
5
+ sf run-stage (--stage <path> | --stage-inline '<json>') (--task <path> | --task-inline '<json>' | --envelope-ref <runId>:<stageId>[:<attempt>] [--envelope-ref ...]) [--checkout <path>] [--model <id>] [--blocking] [--timeout-ms <n>] [--json]
6
+
7
+ Run a single stage directly against the shared Stageflow service, without authoring a pipeline file. Distinct from the internal-only, worker-process-only \`sf internal run-stage\`.
8
+ --stage <path> Filesystem path to a catalog stage YAML file
9
+ --stage-inline <json> Inline stage body object ({ id, system_prompt, io, ... } — no uses:/route/pipeline wrapper)
10
+ --task <path> Filesystem path to a catalog task YAML file
11
+ --task-inline <json> Inline task object ({ id, goal, ... })
12
+ --envelope-ref <ref> Resolve a previously stored StageEnvelope as this stage's input instead of a task: <runId>:<stageId>[:<attempt>]. Repeat to pass more than one — each resolved payload is namespaced under its stageId in input (disambiguated by runId on a stageId collision), and summaries are combined into goal.
13
+ --checkout <path> Optional checkout to use with --envelope-ref (--task/--task-inline carry their own checkout)
14
+ --model <id> Override the model/backend for this call only, ahead of the stage's own declared model
15
+ --blocking Wait for the run to reach a terminal or waiting state and print the result in this same call, instead of returning immediately with just the run id
16
+ --timeout-ms <n> Wait budget in ms when --blocking is set
17
+ --json Machine-readable JSON output`;
18
+ const defaultIo = {
19
+ log: (line) => console.log(line),
20
+ error: (line) => console.error(line),
21
+ };
22
+ function parseRunStageCliArgs(args) {
23
+ if (args.length === 0) {
24
+ return { help: false, json: false, blocking: false };
25
+ }
26
+ if (args[0] === "--help" || args[0] === "-h") {
27
+ return { help: true, json: false, blocking: false };
28
+ }
29
+ let stage;
30
+ let stageInline;
31
+ let task;
32
+ let taskInline;
33
+ let envelopeRef;
34
+ let checkout;
35
+ let model;
36
+ let timeoutMs;
37
+ let json = false;
38
+ let blocking = false;
39
+ let help = false;
40
+ for (let i = 0; i < args.length; i++) {
41
+ const arg = args[i];
42
+ if (arg === "--help" || arg === "-h") {
43
+ help = true;
44
+ }
45
+ else if (arg === "--stage") {
46
+ const value = args[++i];
47
+ if (value === undefined || value.length === 0) {
48
+ throw new Error("Missing value for --stage");
49
+ }
50
+ stage = value;
51
+ }
52
+ else if (arg === "--stage-inline") {
53
+ const value = args[++i];
54
+ if (value === undefined || value.length === 0) {
55
+ throw new Error("Missing value for --stage-inline");
56
+ }
57
+ stageInline = value;
58
+ }
59
+ else if (arg === "--task") {
60
+ const value = args[++i];
61
+ if (value === undefined || value.length === 0) {
62
+ throw new Error("Missing value for --task");
63
+ }
64
+ task = value;
65
+ }
66
+ else if (arg === "--task-inline") {
67
+ const value = args[++i];
68
+ if (value === undefined || value.length === 0) {
69
+ throw new Error("Missing value for --task-inline");
70
+ }
71
+ taskInline = value;
72
+ }
73
+ else if (arg === "--envelope-ref") {
74
+ const value = args[++i];
75
+ if (value === undefined || value.length === 0) {
76
+ throw new Error("Missing value for --envelope-ref");
77
+ }
78
+ envelopeRef = [...(envelopeRef ?? []), value];
79
+ }
80
+ else if (arg === "--checkout") {
81
+ const value = args[++i];
82
+ if (value === undefined || value.length === 0) {
83
+ throw new Error("Missing value for --checkout");
84
+ }
85
+ checkout = value;
86
+ }
87
+ else if (arg === "--model") {
88
+ const value = args[++i];
89
+ if (value === undefined || value.length === 0) {
90
+ throw new Error("Missing value for --model");
91
+ }
92
+ model = value;
93
+ }
94
+ else if (arg === "--timeout-ms") {
95
+ const value = args[++i];
96
+ if (value === undefined || value.length === 0) {
97
+ throw new Error("Missing value for --timeout-ms");
98
+ }
99
+ const parsed = Number(value);
100
+ if (!Number.isFinite(parsed) || parsed <= 0) {
101
+ throw new Error(`Invalid --timeout-ms: ${value}`);
102
+ }
103
+ timeoutMs = parsed;
104
+ }
105
+ else if (arg === "--blocking") {
106
+ blocking = true;
107
+ }
108
+ else if (arg === "--json") {
109
+ json = true;
110
+ }
111
+ else if (arg.startsWith("-")) {
112
+ throw new Error(`Unknown flag: ${arg}`);
113
+ }
114
+ else {
115
+ throw new Error(`Unexpected argument: ${arg}`);
116
+ }
117
+ }
118
+ return {
119
+ help,
120
+ json,
121
+ blocking,
122
+ stage,
123
+ stageInline,
124
+ task,
125
+ taskInline,
126
+ envelopeRef,
127
+ checkout,
128
+ model,
129
+ timeoutMs,
130
+ };
131
+ }
132
+ function parseEnvelopeRefFlag(raw) {
133
+ const parts = raw.split(":");
134
+ if (parts.length < 2 || parts.length > 3) {
135
+ throw new Error("--envelope-ref must be <runId>:<stageId>[:<attempt>]");
136
+ }
137
+ const [runId, stageId, attemptRaw] = parts;
138
+ if (!runId || !stageId) {
139
+ throw new Error("--envelope-ref must be <runId>:<stageId>[:<attempt>]");
140
+ }
141
+ const ref = { runId, stageId };
142
+ if (attemptRaw !== undefined) {
143
+ const attempt = Number.parseInt(attemptRaw, 10);
144
+ if (!Number.isFinite(attempt) || attempt < 1) {
145
+ throw new Error("--envelope-ref attempt must be a positive integer");
146
+ }
147
+ ref.attempt = attempt;
148
+ }
149
+ return ref;
150
+ }
151
+ function parseJsonFlag(raw, flagName) {
152
+ let parsed;
153
+ try {
154
+ parsed = JSON.parse(raw);
155
+ }
156
+ catch {
157
+ throw new Error(`${flagName} must be valid JSON`);
158
+ }
159
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
160
+ throw new Error(`${flagName} must be a JSON object`);
161
+ }
162
+ return parsed;
163
+ }
164
+ /**
165
+ * Streamable HTTP MCP is stateful by default (see docs/mcp.md): a bare
166
+ * tools/call POST with no session id is rejected with "Session ID
167
+ * required". Do the same initialize -> notifications/initialized ->
168
+ * tools/call handshake a real MCP client would, against the shared global
169
+ * service (started/ensured by the caller), then close the session.
170
+ */
171
+ async function mcpInitializeSession(base) {
172
+ const res = await fetch(`${base}/mcp`, {
173
+ method: "POST",
174
+ headers: {
175
+ "Content-Type": "application/json",
176
+ Accept: "application/json, text/event-stream",
177
+ },
178
+ body: JSON.stringify({
179
+ jsonrpc: "2.0",
180
+ id: 0,
181
+ method: "initialize",
182
+ params: {
183
+ protocolVersion: "2025-03-26",
184
+ capabilities: {},
185
+ clientInfo: { name: "sf-cli", version: PACKAGE_VERSION },
186
+ },
187
+ }),
188
+ });
189
+ const sessionId = res.headers.get("mcp-session-id");
190
+ await res.text().catch(() => undefined);
191
+ if (!sessionId) {
192
+ throw new Error(`Failed to initialize MCP session against ${base}/mcp (status ${res.status})`);
193
+ }
194
+ await fetch(`${base}/mcp`, {
195
+ method: "POST",
196
+ headers: {
197
+ "Content-Type": "application/json",
198
+ Accept: "application/json, text/event-stream",
199
+ "mcp-session-id": sessionId,
200
+ },
201
+ body: JSON.stringify({
202
+ jsonrpc: "2.0",
203
+ method: "notifications/initialized",
204
+ }),
205
+ }).catch(() => undefined);
206
+ return sessionId;
207
+ }
208
+ async function mcpCloseSession(base, sessionId) {
209
+ await fetch(`${base}/mcp`, {
210
+ method: "DELETE",
211
+ headers: { "mcp-session-id": sessionId },
212
+ }).catch(() => undefined);
213
+ }
214
+ async function mcpToolCall(base, sessionId, name, args) {
215
+ const res = await fetch(`${base}/mcp`, {
216
+ method: "POST",
217
+ headers: {
218
+ "Content-Type": "application/json",
219
+ Accept: "application/json, text/event-stream",
220
+ "mcp-session-id": sessionId,
221
+ },
222
+ body: JSON.stringify({
223
+ jsonrpc: "2.0",
224
+ id: 1,
225
+ method: "tools/call",
226
+ params: { name, arguments: args },
227
+ }),
228
+ });
229
+ const text = await res.text();
230
+ const dataLine = text.split("\n").find((line) => line.startsWith("data: "));
231
+ if (!dataLine) {
232
+ throw new Error(`Unexpected MCP response from ${base}/mcp (status ${res.status}): ${text.slice(0, 200)}`);
233
+ }
234
+ const message = JSON.parse(dataLine.slice("data: ".length));
235
+ if (message.error) {
236
+ throw new Error(message.error.message ?? "MCP tool call failed");
237
+ }
238
+ const contentText = message.result?.content?.[0]?.text ?? "";
239
+ return {
240
+ isError: Boolean(message.result?.isError),
241
+ payload: contentText ? JSON.parse(contentText) : null,
242
+ };
243
+ }
244
+ function defaultCallTool(base, ensureService) {
245
+ return async (args) => {
246
+ const ensured = await ensureService();
247
+ if (!ensured.ok) {
248
+ throw new Error(ensured.message);
249
+ }
250
+ const sessionId = await mcpInitializeSession(base);
251
+ try {
252
+ return await mcpToolCall(base, sessionId, "run_stage", args);
253
+ }
254
+ finally {
255
+ await mcpCloseSession(base, sessionId);
256
+ }
257
+ };
258
+ }
259
+ function reportRunStageResult(result, options) {
260
+ const { io, json } = options;
261
+ const payload = result.payload ?? {};
262
+ if (result.isError) {
263
+ if (json) {
264
+ io.log(JSON.stringify(payload, null, 2));
265
+ }
266
+ else {
267
+ const message = typeof payload.error === "string" ? payload.error : "run_stage failed";
268
+ io.error(message);
269
+ }
270
+ return 1;
271
+ }
272
+ const status = payload.status;
273
+ if (status === undefined) {
274
+ // Async mode: { runId, stageId }.
275
+ if (json) {
276
+ io.log(JSON.stringify(payload, null, 2));
277
+ }
278
+ else {
279
+ io.log(`Stage run started: ${payload.runId} (stage: ${payload.stageId})`);
280
+ }
281
+ return 0;
282
+ }
283
+ // Blocking mode: { runId, stageId, status, envelope? | pending_prompt? }.
284
+ if (json) {
285
+ io.log(JSON.stringify(payload, null, 2));
286
+ }
287
+ else if (status === "completed") {
288
+ const envelope = payload.envelope;
289
+ if (envelope?.status === "failure") {
290
+ io.error(`Stage failed: ${envelope.summary ?? ""}`);
291
+ }
292
+ else {
293
+ io.log(`Stage completed: ${envelope?.summary ?? ""}`);
294
+ }
295
+ }
296
+ else if (status === "needs_input") {
297
+ io.error(`Stage waiting for input (run ${payload.runId}, stage ${payload.stageId}).`);
298
+ }
299
+ else if (status === "timeout") {
300
+ io.error(`Timed out waiting for stage to finish (run ${payload.runId}).`);
301
+ }
302
+ if (status === "completed") {
303
+ const envelope = payload.envelope;
304
+ return envelope?.status === "failure" ? 1 : 0;
305
+ }
306
+ if (status === "needs_input" || status === "timeout") {
307
+ return 2;
308
+ }
309
+ return 0;
310
+ }
311
+ export async function runRunStageCommand(args, options = {}) {
312
+ const cwd = options.cwd ?? process.cwd();
313
+ const out = { ...defaultIo, ...options.io };
314
+ const base = options.hostBaseUrl ?? hostBaseUrl();
315
+ let parsed;
316
+ try {
317
+ parsed = parseRunStageCliArgs(args);
318
+ }
319
+ catch (err) {
320
+ const message = err instanceof Error ? err.message : String(err);
321
+ out.error(message);
322
+ out.error(RUN_STAGE_USAGE);
323
+ return 1;
324
+ }
325
+ if (parsed.help) {
326
+ out.error(RUN_STAGE_USAGE);
327
+ return 0;
328
+ }
329
+ if (Boolean(parsed.stage) === Boolean(parsed.stageInline)) {
330
+ out.error("Exactly one of --stage or --stage-inline is required");
331
+ out.error(RUN_STAGE_USAGE);
332
+ return 1;
333
+ }
334
+ const inputFlagCount = [parsed.task, parsed.taskInline, parsed.envelopeRef].filter((v) => v !== undefined).length;
335
+ if (inputFlagCount !== 1) {
336
+ out.error("Exactly one of --task, --task-inline, or --envelope-ref is required");
337
+ out.error(RUN_STAGE_USAGE);
338
+ return 1;
339
+ }
340
+ let stage;
341
+ if (parsed.stage !== undefined) {
342
+ stage = resolveAbsolute(cwd, parsed.stage);
343
+ }
344
+ else {
345
+ try {
346
+ stage = parseJsonFlag(parsed.stageInline, "--stage-inline");
347
+ }
348
+ catch (err) {
349
+ out.error(err instanceof Error ? err.message : String(err));
350
+ return 1;
351
+ }
352
+ }
353
+ let taskPath;
354
+ let task;
355
+ let envelopeRef;
356
+ if (parsed.task !== undefined) {
357
+ taskPath = resolveAbsolute(cwd, parsed.task);
358
+ }
359
+ else if (parsed.taskInline !== undefined) {
360
+ try {
361
+ task = parseJsonFlag(parsed.taskInline, "--task-inline");
362
+ }
363
+ catch (err) {
364
+ out.error(err instanceof Error ? err.message : String(err));
365
+ return 1;
366
+ }
367
+ }
368
+ else {
369
+ try {
370
+ const refs = parsed.envelopeRef.map(parseEnvelopeRefFlag);
371
+ envelopeRef = refs.length === 1 ? refs[0] : refs;
372
+ }
373
+ catch (err) {
374
+ out.error(err instanceof Error ? err.message : String(err));
375
+ return 1;
376
+ }
377
+ }
378
+ const toolArgs = {
379
+ stage,
380
+ ...(taskPath !== undefined ? { task_path: taskPath } : {}),
381
+ ...(task !== undefined ? { task } : {}),
382
+ ...(envelopeRef !== undefined ? { envelope_ref: envelopeRef } : {}),
383
+ ...(parsed.checkout !== undefined
384
+ ? { checkout: resolveAbsolute(cwd, parsed.checkout) }
385
+ : {}),
386
+ ...(parsed.model !== undefined ? { model: parsed.model } : {}),
387
+ ...(parsed.blocking ? { blocking: true } : {}),
388
+ ...(parsed.timeoutMs !== undefined ? { timeout_ms: parsed.timeoutMs } : {}),
389
+ };
390
+ const callTool = options.callTool ??
391
+ defaultCallTool(base, options.ensureService ?? (() => ensureGlobalService()));
392
+ let result;
393
+ try {
394
+ result = await callTool(toolArgs);
395
+ }
396
+ catch (err) {
397
+ const message = err instanceof Error ? err.message : String(err);
398
+ if (parsed.json) {
399
+ out.log(JSON.stringify({ error: message }));
400
+ }
401
+ else {
402
+ out.error(message);
403
+ }
404
+ return 1;
405
+ }
406
+ return reportRunStageResult(result, { json: parsed.json, io: out });
407
+ }
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
+ import { A2A_USAGE, runA2aCommand } from "./cli/a2aCommand.js";
3
4
  import { realpathSync } from "node:fs";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -10,6 +11,7 @@ import { EXPORT_RUN_USAGE, runExportRunCommand, } from "./cli/exportRunCommand.j
10
11
  import { INIT_USAGE, runInitCommand } from "./cli/initCommand.js";
11
12
  import { PROVIDERS_USAGE, runProvidersCommand } from "./cli/providersCommand.js";
12
13
  import { RUN_USAGE, runRunCommand } from "./cli/runCommand.js";
14
+ import { RUN_STAGE_USAGE, runRunStageCommand } from "./cli/runStageCommand.js";
13
15
  import { RUNS_USAGE, runRunsCommand } from "./cli/runsCommand.js";
14
16
  import { resolveOperatorCatalog } from "./cli/operatorCatalog.js";
15
17
  import { SKILLS_USAGE, runSkillsCommand } from "./cli/skillsCommand.js";
@@ -27,6 +29,7 @@ import { PACKAGE_VERSION } from "./package-meta.js";
27
29
  const USAGE = `Usage:
28
30
  sf init
29
31
  sf run --task <path> --pipeline <path> [--checkout <path>] [--json] [--include stages] [--skip-gates] [--git-sha <sha>] [--ci-pr-url <url>] [--ci-job-url <url>] [--operator-cwd <path>] [--operator-agent-dir <path>]
32
+ sf run-stage (--stage <path> | --stage-inline '<json>') (--task <path> | --task-inline '<json>' | --envelope-ref <runId>:<stageId>[:<attempt>]) [--checkout <path>] [--model <id>] [--blocking] [--timeout-ms <n>] [--json]
30
33
  sf validate [--pipeline <path>] [--task <path>] [--strict] [--json]
31
34
  sf graph --pipeline <path> [--json]
32
35
  sf migrate-yaml [path] [--root <path>] [--write] [--json] [--force]
@@ -58,6 +61,8 @@ const USAGE = `Usage:
58
61
  sf --version
59
62
  sf -V
60
63
  sf --help
64
+ sf a2a validate|list [--config <path>]
65
+ sf a2a add-caller <id> [--config <path>] [--token-env <NAME>]
61
66
 
62
67
  Stageflow (sf) runs YAML-defined automatic stage pipelines.
63
68
 
@@ -67,10 +72,14 @@ ${INIT_USAGE}
67
72
 
68
73
  ${RUN_USAGE}
69
74
 
75
+ ${RUN_STAGE_USAGE}
76
+
70
77
  ${VALIDATE_USAGE}
71
78
 
72
79
  ${GRAPH_USAGE}
73
80
 
81
+ ${A2A_USAGE}
82
+
74
83
  ${MIGRATE_YAML_USAGE}
75
84
 
76
85
  ${ARTIFACT_USAGE}
@@ -103,8 +112,10 @@ function parseArgs(argv) {
103
112
  if (command === "providers" ||
104
113
  command === "validate" ||
105
114
  command === "graph" ||
115
+ command === "a2a" ||
106
116
  command === "migrate-yaml" ||
107
117
  command === "run" ||
118
+ command === "run-stage" ||
108
119
  command === "init" ||
109
120
  command === "artifact" ||
110
121
  command === "envelope" ||
@@ -282,6 +293,9 @@ async function main(argv) {
282
293
  return 0;
283
294
  }
284
295
  const ctx = await resolveStageflowContext(process.cwd());
296
+ if (parsed.command === "a2a") {
297
+ return runA2aCommand(argv.slice(3), { cwd: ctx.invocationCwd, projectRoot: ctx.projectRoot });
298
+ }
285
299
  if (parsed.command === "init") {
286
300
  return runInitCommand(argv.slice(3), { cwd: ctx.invocationCwd });
287
301
  }
@@ -308,6 +322,11 @@ async function main(argv) {
308
322
  cwd: ctx.invocationCwd,
309
323
  });
310
324
  }
325
+ if (parsed.command === "run-stage") {
326
+ return runRunStageCommand(argv.slice(3), {
327
+ cwd: ctx.invocationCwd,
328
+ });
329
+ }
311
330
  if (parsed.command === "artifact") {
312
331
  return runArtifactCommand(argv.slice(3), {
313
332
  cwd: ctx.invocationCwd,
@@ -3,10 +3,13 @@ import { z } from "zod";
3
3
  import { browseCatalog, } from "../config/browseCatalog.js";
4
4
  import { describePipeline } from "../config/describePipeline.js";
5
5
  import { loadPipeline } from "../config/loadPipeline.js";
6
+ import { readYamlObject } from "../config/readYamlObject.js";
6
7
  import { validateCatalog } from "../config/validateCatalog.js";
7
8
  import { PACKAGE_VERSION } from "../package-meta.js";
8
9
  import { findProjectRoot } from "../project/findProjectRoot.js";
9
10
  import { PipelineValidationError } from "../runtime/pipelineValidationError.js";
11
+ import { EnvelopeRefError, resolveEnvelopeRefsToTask, } from "../runtime/resolveEnvelopeRef.js";
12
+ import { isTerminalProjection, isWaitingProjection, waitRun } from "./waitRun.js";
10
13
  import { mapStoreLookupError } from "../server/operatorResults.js";
11
14
  import { projectRunForMcp } from "./projectRun.js";
12
15
  import { classifyArtifactContent, readRunArtifactBytes } from "./readArtifact.js";
@@ -60,6 +63,45 @@ const startRunSchema = z
60
63
  .refine((data) => Boolean(data.task_path) !== Boolean(data.task), {
61
64
  message: "Exactly one of task_path or task is required",
62
65
  });
66
+ /**
67
+ * Deliberately thin, same reasoning as inlinePipelineSchema above: real
68
+ * structural validation of the stage body happens downstream through the
69
+ * same stage loader a pipeline's inline stage goes through.
70
+ */
71
+ const envelopeRefSchema = z.object({
72
+ runId: z.string(),
73
+ stageId: z.string(),
74
+ attempt: z.number().int().positive().optional(),
75
+ });
76
+ const runStageSchema = z
77
+ .object({
78
+ stage: z
79
+ .union([z.string(), z.record(z.string(), z.unknown())])
80
+ .describe("Filesystem path to a stage YAML file, or a bare inline stage body object ({ id, system_prompt, io, model?, gate_kinds?, mcp?, verify?, timeout_ms? } — no uses:/route/pipeline wrapper) authored directly in this call"),
81
+ task_path: z.string().optional(),
82
+ task: taskFileSchema.optional(),
83
+ envelope_ref: z
84
+ .union([envelopeRefSchema, z.array(envelopeRefSchema).min(1)])
85
+ .optional()
86
+ .describe("Resolve one or more previously stored StageEnvelopes (from another run_stage call or from any stage inside a full pipeline run) and use them as this stage's input, instead of an inline task/task_path. A single ref: its payload becomes input, its summary becomes goal. Multiple refs (an array): each resolved payload is namespaced under its stageId in input (disambiguated by runId on a stageId collision), and summaries are combined into goal."),
87
+ checkout: z
88
+ .string()
89
+ .optional()
90
+ .describe("Optional checkout to use with envelope_ref (ignored with task/task_path, which carry their own checkout). Resolving envelope_ref never implies a checkout on its own."),
91
+ model: z
92
+ .string()
93
+ .optional()
94
+ .describe("Override the model/backend for this call only, taking precedence over the stage's own declared model and the global default. Omit to use the stage's own model (or the global default)."),
95
+ blocking: z
96
+ .boolean()
97
+ .optional()
98
+ .describe("When true, wait for the run to reach a terminal or waiting state and return the result in this same call, instead of returning immediately with just { runId }"),
99
+ timeout_ms: z
100
+ .number()
101
+ .optional()
102
+ .describe("Wait budget in ms when blocking is true (same bounds as wait_run)"),
103
+ })
104
+ .refine((data) => [data.task_path, data.task, data.envelope_ref].filter(Boolean).length === 1, { message: "Exactly one of task_path, task, or envelope_ref is required" });
63
105
  const runStatusSchema = z.enum(["created", "running", "succeeded", "failed"]);
64
106
  export function registerCatalogTools(server, deps) {
65
107
  const { manager, store, cwd } = deps;
@@ -161,6 +203,103 @@ export function registerCatalogTools(server, deps) {
161
203
  }
162
204
  return textResult({ runId: result.runId });
163
205
  });
206
+ server.registerTool("run_stage", {
207
+ description: "Run a single stage directly, without authoring a pipeline. `stage` is a filesystem path to a stage YAML file, or a bare inline stage body ({ id, system_prompt, io, model?, gate_kinds?, mcp?, verify?, timeout_ms? } — no uses:/route/pipeline wrapper), and exactly one of task_path, an inline task, or envelope_ref ({ runId, stageId, attempt? }) to resolve a previously stored envelope — from another run_stage call or any stage in a full pipeline run — as this stage's input. Internally this synthesizes a one-stage pipeline and executes it through the normal run path, so it shows up in list_runs/get_run and is polled with wait_run / get_envelope exactly like any other run. By default returns { runId, stageId } immediately (async); pass blocking:true to wait in this same call and get back { runId, stageId, status: \"completed\"|\"needs_input\"|\"timeout\", envelope? , pending_prompt? }.",
208
+ inputSchema: runStageSchema,
209
+ }, async ({ stage, task_path, task, envelope_ref, checkout, model, blocking, timeout_ms }) => {
210
+ let taskInput = task_path ?? task;
211
+ if (envelope_ref) {
212
+ try {
213
+ taskInput = await resolveEnvelopeRefsToTask(store, envelope_ref, checkout);
214
+ }
215
+ catch (err) {
216
+ if (err instanceof EnvelopeRefError) {
217
+ return textResult({ error: err.message, status: err.status }, true);
218
+ }
219
+ throw err;
220
+ }
221
+ }
222
+ if (taskInput === undefined) {
223
+ return textResult({ error: "Exactly one of task_path, task, or envelope_ref is required" }, true);
224
+ }
225
+ let stageBody;
226
+ if (typeof stage === "string") {
227
+ if (!stage.trim()) {
228
+ return textResult({ error: "stage is required" }, true);
229
+ }
230
+ const absPath = path.resolve(cwd, stage);
231
+ try {
232
+ stageBody = await readYamlObject(absPath);
233
+ }
234
+ catch (err) {
235
+ const message = err instanceof Error ? err.message : String(err);
236
+ return textResult({ error: `Failed to read stage file: ${message}` }, true);
237
+ }
238
+ }
239
+ else {
240
+ stageBody = stage;
241
+ }
242
+ if (typeof stageBody.id !== "string" || !stageBody.id.trim()) {
243
+ return textResult({ error: "stage.id is required" }, true);
244
+ }
245
+ const stageId = stageBody.id;
246
+ // A call-level model override wins over both the stage's own declared
247
+ // model and the global default: it replaces the field the stage body
248
+ // itself would otherwise resolve through, rather than adding a new
249
+ // tier to resolveModel's stage > pipeline > global chain.
250
+ const effectiveStageBody = model !== undefined ? { ...stageBody, model } : stageBody;
251
+ const pipeline = {
252
+ id: `standalone-${stageId}`,
253
+ stages: [effectiveStageBody],
254
+ };
255
+ let result;
256
+ try {
257
+ result = await manager.startRun({ pipeline, task: taskInput });
258
+ }
259
+ catch (err) {
260
+ if (err instanceof PipelineValidationError) {
261
+ return textResult({ error: "Stage validation failed", validation: err.result }, true);
262
+ }
263
+ throw err;
264
+ }
265
+ if (!result.ok) {
266
+ const { ok: _ok, reason, ...rest } = result;
267
+ return textResult({ error: reason, ...rest }, true);
268
+ }
269
+ const runId = result.runId;
270
+ if (!blocking) {
271
+ return textResult({ runId, stageId });
272
+ }
273
+ const waited = await waitRun({ store, runId, timeoutMs: timeout_ms, until: "any" });
274
+ if (!waited.ok) {
275
+ return textResult({
276
+ error: waited.error,
277
+ ...(waited.status !== undefined ? { status: waited.status } : {}),
278
+ ...(waited.code !== undefined ? { code: waited.code } : {}),
279
+ }, true);
280
+ }
281
+ const stageProjection = waited.run.stages.find((s) => s.stage_id === stageId);
282
+ if (isTerminalProjection(waited.run)) {
283
+ return textResult({
284
+ runId,
285
+ stageId,
286
+ status: "completed",
287
+ envelope: stageProjection?.envelope ?? null,
288
+ });
289
+ }
290
+ if (isWaitingProjection(waited.run)) {
291
+ return textResult({
292
+ runId,
293
+ stageId,
294
+ status: "needs_input",
295
+ pending_prompt: stageProjection?.pending_prompt,
296
+ waiting_kind: waited.run.waiting_kind,
297
+ waiting_prompt_id: waited.run.waiting_prompt_id,
298
+ waiting_summary: waited.run.waiting_summary,
299
+ });
300
+ }
301
+ return textResult({ runId, stageId, status: "timeout" });
302
+ });
164
303
  server.registerTool("get_run", {
165
304
  description: "Poll a run: status, stage statuses, waiting_* / pending_prompt HITL fields, and envelope summary/payload/artifact paths (no events). Use list_stage_events, get_envelope, or get_stage_verification for stage detail.",
166
305
  inputSchema: z.object({
@@ -1,2 +1,2 @@
1
1
  export declare const PACKAGE_NAME = "stageflow";
2
- export declare const PACKAGE_VERSION = "0.23.0";
2
+ export declare const PACKAGE_VERSION = "0.25.0";
@@ -1,2 +1,2 @@
1
1
  export const PACKAGE_NAME = "stageflow";
2
- export const PACKAGE_VERSION = "0.23.0";
2
+ export const PACKAGE_VERSION = "0.25.0";
@@ -1,3 +1,4 @@
1
+ import type Database from "better-sqlite3";
1
2
  import type { RunStore } from "./port.js";
2
3
  export type RunStoreKind = "sqlite";
3
4
  export type RunStoreConfig = {
@@ -7,6 +8,15 @@ export type RunStoreConfig = {
7
8
  kind?: RunStoreKind | "disk";
8
9
  };
9
10
  export declare const DISK_STORE_REJECTED = "SF_STORE=disk is no longer a live RunStore. SQLite is the only adapter. Disk-era .stageflow/runs trees import automatically when the SQLite store is empty.";
11
+ /**
12
+ * Composition-root factory: hands back the concrete SQLite connection alongside the `RunStore`,
13
+ * for the one caller wiring a second, colocated store (A2A's tables) into the same `state.db`
14
+ * file. Ordinary call sites use `createRunStore` below and never see this.
15
+ */
16
+ export declare function createRunStoreWithConnection(config: RunStoreConfig): {
17
+ store: RunStore;
18
+ connection: Database.Database;
19
+ };
10
20
  /**
11
21
  * Factory at the application edge. Call sites should receive the returned `RunStore`
12
22
  * and must not branch on backend kind.