stageflow 0.12.0 → 0.13.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 +5 -1
- package/dist/agent/claudeAdapter.js +31 -1
- package/dist/agent/piAdapter.d.ts +6 -4
- package/dist/agent/piAdapter.js +66 -24
- package/dist/agent/piIsolatedMcp.d.ts +23 -0
- package/dist/agent/piIsolatedMcp.js +223 -0
- package/dist/agent/piIsolatedMcpProbe.d.ts +15 -0
- package/dist/agent/piIsolatedMcpProbe.js +292 -0
- package/dist/agent/port.d.ts +2 -0
- package/dist/agent/port.js +1 -1
- package/dist/config/loadPipeline.js +1 -0
- package/dist/config/loadStage.d.ts +1 -0
- package/dist/config/loadStage.js +69 -0
- package/dist/config/normalizePipelineStageEntry.js +7 -1
- package/dist/config/pipelineStageKeys.js +1 -0
- package/dist/config/resolveStageMcpServers.d.ts +35 -0
- package/dist/config/resolveStageMcpServers.js +207 -0
- package/dist/config/validateCatalog.d.ts +1 -1
- package/dist/config/validateCatalog.js +51 -0
- package/dist/mcp/readArtifact.d.ts +2 -0
- package/dist/mcp/readArtifact.js +25 -1
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/runtime/stageAttemptBootstrap.js +38 -2
- package/dist/runtime/verifiedStageExecution.d.ts +2 -0
- package/dist/runtime/verifiedStageExecution.js +10 -2
- package/dist/server/http.js +23 -4
- package/dist/server/projectMcpRoutes.d.ts +6 -0
- package/dist/server/projectMcpRoutes.js +38 -0
- package/dist/types/pipeline.d.ts +2 -0
- package/dist/types/stage.d.ts +2 -1
- package/dist/ui/assets/index-Yzh42vhf.js +118 -0
- package/dist/ui/index.html +1 -1
- package/package.json +4 -1
- package/skills/stageflow-author/SKILL.md +20 -2
- package/skills/stageflow-author/references/catalog-mapping.md +63 -2
- package/skills/stageflow-author/references/stage-prompt-template.md +63 -4
- package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -1
- package/dist/ui/assets/index-CXGz4nL7.js +0 -118
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { STAGEFLOW_MCP_SERVER_NAME } from "../agent/claudeTools.js";
|
|
4
|
+
export class StageMcpError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(message, code) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "StageMcpError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const MCP_CATALOG_FILENAME = ".mcp.json";
|
|
13
|
+
export const STAGEFLOW_STAGE_ARTIFACTS_DIR_ENV = "STAGEFLOW_STAGE_ARTIFACTS_DIR";
|
|
14
|
+
const STAGEFLOW_STAGE_ARTIFACTS_DIR_TOKEN = `\${${STAGEFLOW_STAGE_ARTIFACTS_DIR_ENV}}`;
|
|
15
|
+
export function stampStagePromptArtifactsDir(prompt, artifactsDir) {
|
|
16
|
+
return prompt.replaceAll(STAGEFLOW_STAGE_ARTIFACTS_DIR_TOKEN, artifactsDir);
|
|
17
|
+
}
|
|
18
|
+
function isPlainObject(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
export function mcpCatalogPath(projectRoot) {
|
|
22
|
+
return path.join(projectRoot, MCP_CATALOG_FILENAME);
|
|
23
|
+
}
|
|
24
|
+
export function parseMcpCatalog(raw, catalogPath = MCP_CATALOG_FILENAME) {
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new StageMcpError(`MCP catalog "${catalogPath}" is not valid JSON`, "invalid_config");
|
|
31
|
+
}
|
|
32
|
+
if (!isPlainObject(parsed)) {
|
|
33
|
+
throw new StageMcpError(`MCP catalog "${catalogPath}" must be a JSON object`, "invalid_config");
|
|
34
|
+
}
|
|
35
|
+
if (!Object.hasOwn(parsed, "mcpServers")) {
|
|
36
|
+
throw new StageMcpError(`MCP catalog "${catalogPath}" is missing mcpServers`, "invalid_config");
|
|
37
|
+
}
|
|
38
|
+
const mcpServers = parsed.mcpServers;
|
|
39
|
+
if (!isPlainObject(mcpServers)) {
|
|
40
|
+
throw new StageMcpError(`MCP catalog "${catalogPath}" mcpServers must be an object`, "invalid_config");
|
|
41
|
+
}
|
|
42
|
+
const servers = {};
|
|
43
|
+
for (const [name, entry] of Object.entries(mcpServers)) {
|
|
44
|
+
if (name === STAGEFLOW_MCP_SERVER_NAME) {
|
|
45
|
+
throw new StageMcpError(`MCP catalog must not include reserved name "${STAGEFLOW_MCP_SERVER_NAME}"`, "reserved_name");
|
|
46
|
+
}
|
|
47
|
+
if (!isPlainObject(entry)) {
|
|
48
|
+
throw new StageMcpError(`MCP catalog server "${name}" must be an object`, "invalid_config");
|
|
49
|
+
}
|
|
50
|
+
servers[name] = entry;
|
|
51
|
+
}
|
|
52
|
+
return servers;
|
|
53
|
+
}
|
|
54
|
+
export async function loadMcpCatalog(projectRoot) {
|
|
55
|
+
const catalogPath = mcpCatalogPath(projectRoot);
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = await readFile(catalogPath, "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
const code = err.code;
|
|
62
|
+
if (code === "ENOENT") {
|
|
63
|
+
throw new StageMcpError(`MCP catalog "${MCP_CATALOG_FILENAME}" is missing`, "missing_catalog");
|
|
64
|
+
}
|
|
65
|
+
throw new StageMcpError(`MCP catalog "${MCP_CATALOG_FILENAME}" could not be read`, "invalid_config");
|
|
66
|
+
}
|
|
67
|
+
return { path: catalogPath, servers: parseMcpCatalog(raw, catalogPath) };
|
|
68
|
+
}
|
|
69
|
+
function coarseMcpTransport(entry) {
|
|
70
|
+
if (Object.hasOwn(entry, "command")) {
|
|
71
|
+
return "stdio";
|
|
72
|
+
}
|
|
73
|
+
if (Object.hasOwn(entry, "url")) {
|
|
74
|
+
return "http";
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
export async function listProjectMcpCatalog(projectRoot) {
|
|
79
|
+
try {
|
|
80
|
+
const catalog = await loadMcpCatalog(projectRoot);
|
|
81
|
+
const servers = [];
|
|
82
|
+
for (const [name, entry] of Object.entries(catalog.servers)) {
|
|
83
|
+
const transport = coarseMcpTransport(entry);
|
|
84
|
+
if (transport === undefined) {
|
|
85
|
+
return { status: "invalid_config", servers: [] };
|
|
86
|
+
}
|
|
87
|
+
servers.push({ name, transport });
|
|
88
|
+
}
|
|
89
|
+
return { status: "ok", servers };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (err instanceof StageMcpError) {
|
|
93
|
+
if (err.code === "missing_catalog") {
|
|
94
|
+
return { status: "missing_catalog", servers: [] };
|
|
95
|
+
}
|
|
96
|
+
return { status: "invalid_config", servers: [] };
|
|
97
|
+
}
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function assertMcpAllowlistKnown(servers, allowlist) {
|
|
102
|
+
for (const name of allowlist) {
|
|
103
|
+
if (!Object.hasOwn(servers, name)) {
|
|
104
|
+
throw new StageMcpError(`Unknown MCP server "${name}"`, "unknown_server");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const INTERPOLATION_TOKEN = /\$\{([^}]*)\}/g;
|
|
109
|
+
const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
110
|
+
const ENV_VAR_WITH_DEFAULT = /^([A-Za-z_][A-Za-z0-9_]*):-(.*)$/;
|
|
111
|
+
function interpolateString(value, env, serverName) {
|
|
112
|
+
return value.replace(INTERPOLATION_TOKEN, (_match, inner) => {
|
|
113
|
+
const withDefault = ENV_VAR_WITH_DEFAULT.exec(inner);
|
|
114
|
+
if (withDefault) {
|
|
115
|
+
const found = env[withDefault[1]];
|
|
116
|
+
return found !== undefined ? found : withDefault[2];
|
|
117
|
+
}
|
|
118
|
+
if (ENV_VAR_NAME.test(inner)) {
|
|
119
|
+
const found = env[inner];
|
|
120
|
+
if (found !== undefined) {
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
throw new StageMcpError(`Unresolved MCP catalog variable "${inner}"`, "unresolved_var");
|
|
124
|
+
}
|
|
125
|
+
throw new StageMcpError(`MCP catalog server "${serverName}" has an invalid interpolation form`, "invalid_config");
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function interpolateField(key, value, env, serverName) {
|
|
129
|
+
if ((key === "command" || key === "url" || key === "cwd") && typeof value === "string") {
|
|
130
|
+
return interpolateString(value, env, serverName);
|
|
131
|
+
}
|
|
132
|
+
if (key === "args" && Array.isArray(value)) {
|
|
133
|
+
return value.map((item) => typeof item === "string" ? interpolateString(item, env, serverName) : item);
|
|
134
|
+
}
|
|
135
|
+
if ((key === "env" || key === "headers") && isPlainObject(value)) {
|
|
136
|
+
const copied = {};
|
|
137
|
+
for (const [field, fieldValue] of Object.entries(value)) {
|
|
138
|
+
copied[field] =
|
|
139
|
+
typeof fieldValue === "string"
|
|
140
|
+
? interpolateString(fieldValue, env, serverName)
|
|
141
|
+
: fieldValue;
|
|
142
|
+
}
|
|
143
|
+
return copied;
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
function hasPathSeparator(value) {
|
|
148
|
+
return value.includes("/") || value.includes("\\");
|
|
149
|
+
}
|
|
150
|
+
function isRelativeFsPath(value) {
|
|
151
|
+
if (value.length === 0 || path.isAbsolute(value) || value.startsWith("-") || value.startsWith("@")) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
return hasPathSeparator(value) || value.startsWith(".");
|
|
155
|
+
}
|
|
156
|
+
function isInsideProjectRoot(projectRoot, candidate) {
|
|
157
|
+
const root = path.resolve(projectRoot);
|
|
158
|
+
const resolved = path.resolve(candidate);
|
|
159
|
+
const rel = path.relative(root, resolved);
|
|
160
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
161
|
+
}
|
|
162
|
+
function stampSpawnRoot(resolved, projectRoot, serverName) {
|
|
163
|
+
const command = resolved.command;
|
|
164
|
+
if (typeof command === "string" && hasPathSeparator(command) && !path.isAbsolute(command)) {
|
|
165
|
+
resolved.command = path.resolve(projectRoot, command);
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(resolved.args)) {
|
|
168
|
+
resolved.args = resolved.args.map((item) => typeof item === "string" && isRelativeFsPath(item)
|
|
169
|
+
? path.resolve(projectRoot, item)
|
|
170
|
+
: item);
|
|
171
|
+
}
|
|
172
|
+
const catalogCwd = resolved.cwd;
|
|
173
|
+
if (catalogCwd !== undefined) {
|
|
174
|
+
if (typeof catalogCwd !== "string" || catalogCwd.length === 0 || !path.isAbsolute(catalogCwd)) {
|
|
175
|
+
throw new StageMcpError(`MCP catalog server "${serverName}" cwd must be an absolute path inside the project root`, "invalid_config");
|
|
176
|
+
}
|
|
177
|
+
const canonical = path.resolve(catalogCwd);
|
|
178
|
+
if (!isInsideProjectRoot(projectRoot, canonical)) {
|
|
179
|
+
throw new StageMcpError(`MCP catalog server "${serverName}" cwd is outside the project root`, "invalid_config");
|
|
180
|
+
}
|
|
181
|
+
resolved.cwd = canonical;
|
|
182
|
+
}
|
|
183
|
+
else if (typeof command === "string") {
|
|
184
|
+
resolved.cwd = path.resolve(projectRoot);
|
|
185
|
+
}
|
|
186
|
+
return resolved;
|
|
187
|
+
}
|
|
188
|
+
function interpolateServer(entry, env, serverName, projectRoot) {
|
|
189
|
+
const resolved = {};
|
|
190
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
191
|
+
resolved[key] = interpolateField(key, value, env, serverName);
|
|
192
|
+
}
|
|
193
|
+
return stampSpawnRoot(resolved, projectRoot, serverName);
|
|
194
|
+
}
|
|
195
|
+
export async function resolveStageMcpServers(options) {
|
|
196
|
+
const allowlist = options.allowlist ?? [];
|
|
197
|
+
if (allowlist.length === 0) {
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
const catalog = await loadMcpCatalog(options.projectRoot);
|
|
201
|
+
assertMcpAllowlistKnown(catalog.servers, allowlist);
|
|
202
|
+
const resolved = {};
|
|
203
|
+
for (const name of allowlist) {
|
|
204
|
+
resolved[name] = interpolateServer(catalog.servers[name], options.env, name, options.projectRoot);
|
|
205
|
+
}
|
|
206
|
+
return resolved;
|
|
207
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { LoadedPipeline } from "../types/pipeline.js";
|
|
|
2
2
|
import type { LoadIssue } from "./loadOutcome.js";
|
|
3
3
|
export type ValidationSeverity = "error" | "warning";
|
|
4
4
|
export type ValidationCategory = "pipeline" | "stage" | "catalog" | "task";
|
|
5
|
-
export type ValidationFindingCode = "pipeline.invalid_shape" | "pipeline.dag_error" | "pipeline.missing_stage" | "pipeline.load_error" | "pipeline.string_stage_ref" | "pipeline.stage_uses_inline_conflict" | "pipeline.stage_missing_body" | "pipeline.stage_id_mismatch" | "pipeline.invalid_completion" | "pipeline.invalid_recovery" | "pipeline.include_cycle" | "pipeline.include_invalid" | "pipeline.include_duplicate_stage" | "pipeline.invalid_agent" | "stage.invalid_shape" | "stage.invalid_payload_schema" | "stage.invalid_clone_input_schema" | "stage.invalid_clone_actions" | "stage.invalid_gate_kinds" | "stage.invalid_pre_emit_checks" | "stage.invalid_timeout_ms" | "stage.invalid_skill" | "stage.invalid_agent" | "stage.load_error" | "stage.id_filename_mismatch" | "task.invalid_shape" | "task.load_error" | "catalog.duplicate_pipeline_id" | "catalog.manifest_missing" | "catalog.manifest_invalid" | "catalog.empty_catalog" | "catalog.manifest_load_error";
|
|
5
|
+
export type ValidationFindingCode = "pipeline.invalid_shape" | "pipeline.dag_error" | "pipeline.missing_stage" | "pipeline.load_error" | "pipeline.string_stage_ref" | "pipeline.stage_uses_inline_conflict" | "pipeline.stage_missing_body" | "pipeline.stage_id_mismatch" | "pipeline.invalid_completion" | "pipeline.invalid_recovery" | "pipeline.include_cycle" | "pipeline.include_invalid" | "pipeline.include_duplicate_stage" | "pipeline.invalid_agent" | "stage.invalid_shape" | "stage.invalid_payload_schema" | "stage.invalid_clone_input_schema" | "stage.invalid_clone_actions" | "stage.invalid_gate_kinds" | "stage.invalid_pre_emit_checks" | "stage.invalid_timeout_ms" | "stage.invalid_skill" | "stage.invalid_mcp" | "stage.invalid_agent" | "stage.load_error" | "stage.id_filename_mismatch" | "task.invalid_shape" | "task.load_error" | "catalog.duplicate_pipeline_id" | "catalog.manifest_missing" | "catalog.manifest_invalid" | "catalog.empty_catalog" | "catalog.manifest_load_error" | "catalog.invalid_mcp";
|
|
6
6
|
export type ValidationFinding = {
|
|
7
7
|
severity: ValidationSeverity;
|
|
8
8
|
code: ValidationFindingCode | string;
|
|
@@ -6,6 +6,7 @@ import { readYamlObject } from "./readYamlObject.js";
|
|
|
6
6
|
import { resolveCatalogContext } from "./resolveCatalogContext.js";
|
|
7
7
|
import { getCatalogScanPaths } from "./browseCatalog.js";
|
|
8
8
|
import { manifestPathForProject } from "./loadStageflowManifest.js";
|
|
9
|
+
import { MCP_CATALOG_FILENAME, StageMcpError, assertMcpAllowlistKnown, loadMcpCatalog, mcpCatalogPath, } from "./resolveStageMcpServers.js";
|
|
9
10
|
export function relPath(cwd, absPath) {
|
|
10
11
|
return path.relative(cwd, absPath).replace(/\\/g, "/");
|
|
11
12
|
}
|
|
@@ -171,6 +172,55 @@ async function validateStageFile(cwd, stagePath) {
|
|
|
171
172
|
}
|
|
172
173
|
return findings;
|
|
173
174
|
}
|
|
175
|
+
function collectMcpAllowlistNames(loaded) {
|
|
176
|
+
const names = new Set();
|
|
177
|
+
for (const stage of loaded.stages) {
|
|
178
|
+
for (const name of stage.mcp ?? []) {
|
|
179
|
+
names.add(name);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return [...names];
|
|
183
|
+
}
|
|
184
|
+
async function findingsForStageMcpCatalog(cwd, loaded) {
|
|
185
|
+
const allowlist = collectMcpAllowlistNames(loaded);
|
|
186
|
+
const ctx = await resolveCatalogContext(cwd);
|
|
187
|
+
const projectRoot = ctx.projectRoot;
|
|
188
|
+
const catalogAbsPath = projectRoot
|
|
189
|
+
? mcpCatalogPath(projectRoot)
|
|
190
|
+
: path.resolve(cwd, MCP_CATALOG_FILENAME);
|
|
191
|
+
if (!projectRoot) {
|
|
192
|
+
if (allowlist.length === 0)
|
|
193
|
+
return [];
|
|
194
|
+
return [
|
|
195
|
+
findingCatalog(cwd, catalogAbsPath, `MCP catalog "${MCP_CATALOG_FILENAME}" is missing`, "catalog.invalid_mcp", "error"),
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
let catalog;
|
|
199
|
+
try {
|
|
200
|
+
catalog = await loadMcpCatalog(projectRoot);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
if (!(err instanceof StageMcpError))
|
|
204
|
+
throw err;
|
|
205
|
+
if (err.code === "missing_catalog" && allowlist.length === 0)
|
|
206
|
+
return [];
|
|
207
|
+
return [
|
|
208
|
+
findingCatalog(cwd, catalogAbsPath, err.message, "catalog.invalid_mcp", "error"),
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
assertMcpAllowlistKnown(catalog.servers, allowlist);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
if (err instanceof StageMcpError) {
|
|
216
|
+
return [
|
|
217
|
+
findingCatalog(cwd, catalogAbsPath, err.message, "catalog.invalid_mcp", "error"),
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
174
224
|
async function runPipelineValidation(nameOrPath, options) {
|
|
175
225
|
const { cwd, validateStages } = options;
|
|
176
226
|
let pipelinePath;
|
|
@@ -196,6 +246,7 @@ async function runPipelineValidation(nameOrPath, options) {
|
|
|
196
246
|
findings.push(...findingsFromLoadIssues(cwd, pipelinePath, outcome.issues));
|
|
197
247
|
return { ok: false, findings };
|
|
198
248
|
}
|
|
249
|
+
findings.push(...(await findingsForStageMcpCatalog(cwd, outcome.value)));
|
|
199
250
|
if (validateStages && outcome.value.stageSources) {
|
|
200
251
|
for (const source of Object.values(outcome.value.stageSources)) {
|
|
201
252
|
if (source.kind === "file") {
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { RunStore } from "../runstore/port.js";
|
|
2
|
+
export declare function artifactMediaType(relativePath: string): string | undefined;
|
|
2
3
|
export declare function readRunArtifact(store: RunStore, runId: string, relativePath: string): Promise<string>;
|
|
4
|
+
export declare function readRunArtifactBytes(store: RunStore, runId: string, relativePath: string): Promise<Buffer>;
|
package/dist/mcp/readArtifact.js
CHANGED
|
@@ -12,7 +12,23 @@ function assertSafeRunId(runId) {
|
|
|
12
12
|
throw new Error("runId must not contain path separators or ..");
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
-
export
|
|
15
|
+
export function artifactMediaType(relativePath) {
|
|
16
|
+
const ext = path.extname(relativePath).toLowerCase();
|
|
17
|
+
switch (ext) {
|
|
18
|
+
case ".png":
|
|
19
|
+
return "image/png";
|
|
20
|
+
case ".jpg":
|
|
21
|
+
case ".jpeg":
|
|
22
|
+
return "image/jpeg";
|
|
23
|
+
case ".gif":
|
|
24
|
+
return "image/gif";
|
|
25
|
+
case ".webp":
|
|
26
|
+
return "image/webp";
|
|
27
|
+
default:
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function resolveRunArtifactFile(store, runId, relativePath) {
|
|
16
32
|
assertSafeRunId(runId);
|
|
17
33
|
if (typeof relativePath !== "string" || relativePath.trim().length === 0) {
|
|
18
34
|
throw new Error("path must be a non-empty string");
|
|
@@ -42,5 +58,13 @@ export async function readRunArtifact(store, runId, relativePath) {
|
|
|
42
58
|
if (!isInsideDir(fileReal, workspaceReal)) {
|
|
43
59
|
throw new Error("path escapes the run workspace");
|
|
44
60
|
}
|
|
61
|
+
return fileReal;
|
|
62
|
+
}
|
|
63
|
+
export async function readRunArtifact(store, runId, relativePath) {
|
|
64
|
+
const fileReal = await resolveRunArtifactFile(store, runId, relativePath);
|
|
45
65
|
return readFile(fileReal, "utf8");
|
|
46
66
|
}
|
|
67
|
+
export async function readRunArtifactBytes(store, runId, relativePath) {
|
|
68
|
+
const fileReal = await resolveRunArtifactFile(store, runId, relativePath);
|
|
69
|
+
return readFile(fileReal);
|
|
70
|
+
}
|
package/dist/package-meta.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export declare const PACKAGE_NAME = "stageflow";
|
|
2
|
-
export declare const PACKAGE_VERSION = "0.
|
|
2
|
+
export declare const PACKAGE_VERSION = "0.13.0";
|
package/dist/package-meta.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export const PACKAGE_NAME = "stageflow";
|
|
2
|
-
export const PACKAGE_VERSION = "0.
|
|
2
|
+
export const PACKAGE_VERSION = "0.13.0";
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { resolveSkillByName } from "../config/listSkills.js";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { MCP_CATALOG_FILENAME, STAGEFLOW_STAGE_ARTIFACTS_DIR_ENV, StageMcpError, resolveStageMcpServers, stampStagePromptArtifactsDir, } from "../config/resolveStageMcpServers.js";
|
|
4
|
+
import { attemptArtifactsDir } from "../runstore/workspaceLayout.js";
|
|
2
5
|
import { resolveCloneEmitContext, resolveForkEmitContext } from "../config/resolveForkEmitContext.js";
|
|
3
6
|
import { createAttemptQaTrailReader } from "../hitl/qaTrail.js";
|
|
4
7
|
import { buildCompletedEnvelopesFromRun, resolvePriorEnvelope, } from "./envelopeRouting.js";
|
|
@@ -20,16 +23,49 @@ async function resolveStageSkillForRun(stage, catalog) {
|
|
|
20
23
|
}
|
|
21
24
|
return { ok: true, skillFilePath: resolved.filePath };
|
|
22
25
|
}
|
|
23
|
-
async function
|
|
26
|
+
async function resolveAttemptMcpServers(allowlist, factoryCwd, artifactsDir) {
|
|
27
|
+
const names = allowlist ?? [];
|
|
28
|
+
if (names.length === 0)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (factoryCwd === undefined) {
|
|
31
|
+
throw new StageMcpError(`MCP catalog "${MCP_CATALOG_FILENAME}" is missing`, "missing_catalog");
|
|
32
|
+
}
|
|
33
|
+
await mkdir(artifactsDir, { recursive: true });
|
|
34
|
+
const resolved = await resolveStageMcpServers({
|
|
35
|
+
projectRoot: factoryCwd,
|
|
36
|
+
allowlist: names,
|
|
37
|
+
env: {
|
|
38
|
+
...process.env,
|
|
39
|
+
[STAGEFLOW_STAGE_ARTIFACTS_DIR_ENV]: artifactsDir,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
|
43
|
+
}
|
|
44
|
+
async function openStageWithOperatorCatalog(agent, input, catalog, factoryCwd, artifactsDir) {
|
|
24
45
|
const skill = await resolveStageSkillForRun(input.stage, catalog);
|
|
25
46
|
if (!skill.ok)
|
|
26
47
|
return skill;
|
|
48
|
+
let resolvedMcpServers;
|
|
49
|
+
try {
|
|
50
|
+
resolvedMcpServers = await resolveAttemptMcpServers(input.stage.mcp, factoryCwd, artifactsDir);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err instanceof StageMcpError) {
|
|
54
|
+
return { ok: false, reason: err.message };
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
27
58
|
try {
|
|
28
59
|
const handle = agent.openStage({
|
|
29
60
|
...input,
|
|
61
|
+
stage: {
|
|
62
|
+
...input.stage,
|
|
63
|
+
system_prompt: stampStagePromptArtifactsDir(input.stage.system_prompt, artifactsDir),
|
|
64
|
+
},
|
|
30
65
|
...(skill.skillFilePath !== undefined
|
|
31
66
|
? { skillFilePath: skill.skillFilePath }
|
|
32
67
|
: {}),
|
|
68
|
+
...(resolvedMcpServers !== undefined ? { resolvedMcpServers } : {}),
|
|
33
69
|
});
|
|
34
70
|
return { ok: true, handle };
|
|
35
71
|
}
|
|
@@ -159,7 +195,7 @@ export async function openStageAttempt(input) {
|
|
|
159
195
|
...(input.stage.timeout_ms !== undefined
|
|
160
196
|
? { timeoutMs: input.stage.timeout_ms }
|
|
161
197
|
: {}),
|
|
162
|
-
}, input.operatorCatalog);
|
|
198
|
+
}, input.operatorCatalog, input.factoryCwd, attemptArtifactsDir(input.workspaceDir, stageId, attempt));
|
|
163
199
|
if (!opened.ok)
|
|
164
200
|
return opened;
|
|
165
201
|
return {
|
|
@@ -3,6 +3,8 @@ import type { RunStore } from "../runstore/port.js";
|
|
|
3
3
|
import type { ResolvedPipelineDag } from "../types/pipeline.js";
|
|
4
4
|
import type { StageConfig } from "../types/stage.js";
|
|
5
5
|
import type { StageRoots } from "./stageRoots.js";
|
|
6
|
+
/** Sidecar written before completion command checks so they can inspect fork_choice. */
|
|
7
|
+
export declare const COMPLETION_CANDIDATE_ENVELOPE_FILENAME = "completion-candidate-envelope.json";
|
|
6
8
|
export type VerifiedStageExecution = {
|
|
7
9
|
/** Establish durable observation before agent work begins. */
|
|
8
10
|
prepare(): Promise<void>;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { listQaExchanges } from "../hitl/qaTrail.js";
|
|
2
|
-
import { attemptArtifactsDir } from "../runstore/workspaceLayout.js";
|
|
4
|
+
import { attemptArtifactsDir, attemptWorkspaceDir, } from "../runstore/workspaceLayout.js";
|
|
3
5
|
import { COMPLETION_VERIFICATION_FAILURE_PREFIX, } from "../types/completion.js";
|
|
4
6
|
import { runCompletionContract, } from "./completionCheckRunner.js";
|
|
5
7
|
import { readCompletionCheckoutBaseline, writeCompletionCheckoutBaseline, } from "./completionCheckoutBaseline.js";
|
|
6
8
|
import { createGitCheckoutCapability } from "./gitCheckoutCapability.js";
|
|
9
|
+
/** Sidecar written before completion command checks so they can inspect fork_choice. */
|
|
10
|
+
export const COMPLETION_CANDIDATE_ENVELOPE_FILENAME = "completion-candidate-envelope.json";
|
|
7
11
|
export function completionVerificationFailureReason(failedCheckIds) {
|
|
8
12
|
return `${COMPLETION_VERIFICATION_FAILURE_PREFIX} ${failedCheckIds.join(", ")}`;
|
|
9
13
|
}
|
|
@@ -65,11 +69,15 @@ export function createVerifiedStageExecution(options) {
|
|
|
65
69
|
if (contract === undefined)
|
|
66
70
|
return candidate;
|
|
67
71
|
try {
|
|
72
|
+
const artifactsDir = attemptArtifactsDir(roots.runWorkspaceDir, stageId, attempt);
|
|
73
|
+
const attemptDir = attemptWorkspaceDir(roots.runWorkspaceDir, stageId, attempt);
|
|
74
|
+
await mkdir(attemptDir, { recursive: true });
|
|
75
|
+
await writeFile(path.join(attemptDir, COMPLETION_CANDIDATE_ENVELOPE_FILENAME), `${JSON.stringify(candidate.envelope, null, 2)}\n`, "utf8");
|
|
68
76
|
const verification = await runCompletionContract({
|
|
69
77
|
contract,
|
|
70
78
|
envelope: candidate.envelope,
|
|
71
79
|
payloadSchema: stage.payload_schema,
|
|
72
|
-
artifactsDir
|
|
80
|
+
artifactsDir,
|
|
73
81
|
commandWorkingDirectory: roots.cwd,
|
|
74
82
|
gates: gateDecisionsForAttempt({ store, runId, stageId, attempt }),
|
|
75
83
|
checkout,
|
package/dist/server/http.js
CHANGED
|
@@ -3,12 +3,13 @@ import path from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { getCredentialSourceSettings, setCredentialSource, } from "../agent/providerAuth.js";
|
|
5
5
|
import { handleProviderRoutes, providerAuthErrorBody, } from "./providerRoutes.js";
|
|
6
|
+
import { handleProjectMcpRoutes } from "./projectMcpRoutes.js";
|
|
6
7
|
import { createPipeline, parseCreatePipelineBody } from "../config/createPipeline.js";
|
|
7
8
|
import { createStage, parseCreateStageBody } from "../config/createStage.js";
|
|
8
9
|
import { browseCatalog } from "../config/browseCatalog.js";
|
|
9
10
|
import { listExtensions } from "../config/listExtensions.js";
|
|
10
11
|
import { listSkills } from "../config/listSkills.js";
|
|
11
|
-
import { readRunArtifact } from "../mcp/readArtifact.js";
|
|
12
|
+
import { artifactMediaType, readRunArtifact, readRunArtifactBytes, } from "../mcp/readArtifact.js";
|
|
12
13
|
import { readStageVerificationHistory } from "../runstore/verificationHistory.js";
|
|
13
14
|
import { resolveStageflowContext } from "../project/resolveStageflowContext.js";
|
|
14
15
|
import { INVALID_SLOT_COUNT_MESSAGE, parseSlotCount, } from "../runtime/settingsFile.js";
|
|
@@ -96,7 +97,8 @@ function isMutatingApi(method, pathname) {
|
|
|
96
97
|
/^\/api\/providers\/[^/]+\/login$/.test(pathname) ||
|
|
97
98
|
/^\/api\/providers\/[^/]+\/login\/[^/]+\/answer$/.test(pathname) ||
|
|
98
99
|
/^\/api\/providers\/[^/]+\/login\/[^/]+\/cancel$/.test(pathname) ||
|
|
99
|
-
/^\/api\/providers\/[^/]+\/logout$/.test(pathname)
|
|
100
|
+
/^\/api\/providers\/[^/]+\/logout$/.test(pathname) ||
|
|
101
|
+
/^\/api\/project-mcp\/[^/]+\/probe$/.test(pathname));
|
|
100
102
|
}
|
|
101
103
|
function isCredentialMutatingApi(method, pathname) {
|
|
102
104
|
if (method !== "POST")
|
|
@@ -210,8 +212,19 @@ export async function startUiServer(options) {
|
|
|
210
212
|
return true;
|
|
211
213
|
}
|
|
212
214
|
try {
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
+
const mediaType = artifactMediaType(artifactPath);
|
|
216
|
+
if (mediaType !== undefined) {
|
|
217
|
+
const bytes = await readRunArtifactBytes(store, runId, artifactPath);
|
|
218
|
+
res.writeHead(200, {
|
|
219
|
+
"Content-Type": mediaType,
|
|
220
|
+
"Content-Length": bytes.length,
|
|
221
|
+
});
|
|
222
|
+
res.end(bytes);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const content = await readRunArtifact(store, runId, artifactPath);
|
|
226
|
+
textPlain(res, 200, content);
|
|
227
|
+
}
|
|
215
228
|
}
|
|
216
229
|
catch (err) {
|
|
217
230
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -527,6 +540,12 @@ export async function startUiServer(options) {
|
|
|
527
540
|
})) {
|
|
528
541
|
return true;
|
|
529
542
|
}
|
|
543
|
+
if (await handleProjectMcpRoutes(req, res, {
|
|
544
|
+
projectRoot: boot.rootDir,
|
|
545
|
+
json,
|
|
546
|
+
})) {
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
530
549
|
if (method === "GET" && pathname === "/api/skills") {
|
|
531
550
|
json(res, 200, await listSkills({ cwd, agentDir }));
|
|
532
551
|
return true;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export type ProjectMcpRoutesCtx = {
|
|
3
|
+
projectRoot: string;
|
|
4
|
+
json: (res: ServerResponse, status: number, body: unknown) => void;
|
|
5
|
+
};
|
|
6
|
+
export declare function handleProjectMcpRoutes(req: IncomingMessage, res: ServerResponse, ctx: ProjectMcpRoutesCtx): Promise<boolean>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { probeProjectMcpServer } from "../agent/piIsolatedMcpProbe.js";
|
|
2
|
+
import { listProjectMcpCatalog } from "../config/resolveStageMcpServers.js";
|
|
3
|
+
function requestAbortSignal(req) {
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
const abort = () => {
|
|
6
|
+
if (!controller.signal.aborted)
|
|
7
|
+
controller.abort();
|
|
8
|
+
};
|
|
9
|
+
if (req.aborted || req.destroyed) {
|
|
10
|
+
abort();
|
|
11
|
+
return controller.signal;
|
|
12
|
+
}
|
|
13
|
+
req.once("aborted", abort);
|
|
14
|
+
req.once("close", abort);
|
|
15
|
+
return controller.signal;
|
|
16
|
+
}
|
|
17
|
+
export async function handleProjectMcpRoutes(req, res, ctx) {
|
|
18
|
+
const { projectRoot, json } = ctx;
|
|
19
|
+
const method = req.method ?? "GET";
|
|
20
|
+
const rawUrl = req.url ?? "/";
|
|
21
|
+
const pathname = new URL(rawUrl, "http://localhost").pathname;
|
|
22
|
+
if (method === "GET" && pathname === "/api/project-mcp") {
|
|
23
|
+
json(res, 200, await listProjectMcpCatalog(projectRoot));
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
const probeMatch = pathname.match(/^\/api\/project-mcp\/([^/]+)\/probe$/);
|
|
27
|
+
if (method === "POST" && probeMatch) {
|
|
28
|
+
const name = decodeURIComponent(probeMatch[1] ?? "");
|
|
29
|
+
const result = await probeProjectMcpServer({
|
|
30
|
+
projectRoot,
|
|
31
|
+
name,
|
|
32
|
+
signal: requestAbortSignal(req),
|
|
33
|
+
});
|
|
34
|
+
json(res, 200, result);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
package/dist/types/pipeline.d.ts
CHANGED
|
@@ -52,6 +52,7 @@ export type PipelineStageYamlEntry = PipelineStageRef & {
|
|
|
52
52
|
clone_input_schema?: unknown;
|
|
53
53
|
clone_actions?: CloneAction[];
|
|
54
54
|
skill?: string;
|
|
55
|
+
mcp?: string[];
|
|
55
56
|
};
|
|
56
57
|
export type PipelineIncludeEntry = {
|
|
57
58
|
local: string;
|
|
@@ -74,6 +75,7 @@ export type NormalizedPipelineStageEntry = {
|
|
|
74
75
|
feedback_loop?: FeedbackLoopConfig;
|
|
75
76
|
replay_safe?: boolean;
|
|
76
77
|
skill?: string;
|
|
78
|
+
mcp?: string[];
|
|
77
79
|
body: {
|
|
78
80
|
kind: "inline";
|
|
79
81
|
raw: Record<string, unknown>;
|
package/dist/types/stage.d.ts
CHANGED
|
@@ -14,9 +14,10 @@ export type StageConfig = {
|
|
|
14
14
|
pre_emit_checks?: PreEmitCheck[];
|
|
15
15
|
clone_input_schema?: unknown;
|
|
16
16
|
clone_actions?: CloneAction[];
|
|
17
|
-
/** Optional stage wall-clock timeout in milliseconds (default
|
|
17
|
+
/** Optional stage wall-clock timeout in milliseconds (default 60 minutes). */
|
|
18
18
|
timeout_ms?: number;
|
|
19
19
|
skill?: string;
|
|
20
|
+
mcp?: string[];
|
|
20
21
|
/**
|
|
21
22
|
* Selects the AgentPort backend for this stage, overriding pipeline/global.
|
|
22
23
|
* Parsed but not yet consulted — see STAGE_LEVEL_AGENT_OVERRIDE_ENABLED.
|