stageflow 0.2.0 → 0.3.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 (113) hide show
  1. package/README.md +158 -14
  2. package/dist/agent/activity.d.ts +10 -1
  3. package/dist/agent/activity.js +37 -2
  4. package/dist/agent/activityObserver.d.ts +5 -1
  5. package/dist/agent/activityObserver.js +174 -11
  6. package/dist/agent/fakeAgent.js +4 -0
  7. package/dist/agent/piAdapter.d.ts +16 -0
  8. package/dist/agent/piAdapter.js +88 -21
  9. package/dist/agent/port.d.ts +2 -0
  10. package/dist/agent/providerAuth.js +12 -7
  11. package/dist/catalog/displayCatalogPath.d.ts +12 -0
  12. package/dist/catalog/displayCatalogPath.js +28 -0
  13. package/dist/cli/initCommand.d.ts +9 -0
  14. package/dist/cli/initCommand.js +71 -0
  15. package/dist/cli/initTemplates.d.ts +3 -0
  16. package/dist/cli/initTemplates.js +19 -0
  17. package/dist/cli/operatorCatalog.d.ts +10 -0
  18. package/dist/cli/operatorCatalog.js +16 -0
  19. package/dist/cli/runCommand.d.ts +3 -1
  20. package/dist/cli/runCommand.js +48 -7
  21. package/dist/cli/validateCommand.d.ts +1 -1
  22. package/dist/cli/validateCommand.js +20 -3
  23. package/dist/cli.d.ts +1 -0
  24. package/dist/cli.js +52 -19
  25. package/dist/config/browseCatalog.d.ts +45 -0
  26. package/dist/config/browseCatalog.js +170 -0
  27. package/dist/config/createPipeline.d.ts +12 -3
  28. package/dist/config/createPipeline.js +292 -100
  29. package/dist/config/createStage.d.ts +10 -4
  30. package/dist/config/createStage.js +44 -14
  31. package/dist/config/listConfig.d.ts +14 -19
  32. package/dist/config/listConfig.js +26 -191
  33. package/dist/config/listModelsFromManifest.d.ts +2 -0
  34. package/dist/config/listModelsFromManifest.js +4 -0
  35. package/dist/config/loadOutcome.d.ts +4 -0
  36. package/dist/config/loadPipeline.d.ts +1 -15
  37. package/dist/config/loadPipeline.js +84 -142
  38. package/dist/config/loadStage.d.ts +4 -0
  39. package/dist/config/loadStage.js +60 -32
  40. package/dist/config/loadStageflowManifest.d.ts +5 -0
  41. package/dist/config/loadStageflowManifest.js +157 -0
  42. package/dist/config/mergePipelineIncludes.d.ts +9 -0
  43. package/dist/config/mergePipelineIncludes.js +141 -0
  44. package/dist/config/normalizePipelineStageEntry.d.ts +16 -0
  45. package/dist/config/normalizePipelineStageEntry.js +186 -0
  46. package/dist/config/pipelineStageKeys.d.ts +4 -0
  47. package/dist/config/pipelineStageKeys.js +14 -0
  48. package/dist/config/resolveCatalogContext.d.ts +13 -0
  49. package/dist/config/resolveCatalogContext.js +13 -0
  50. package/dist/config/resolveForkEmitContext.d.ts +3 -0
  51. package/dist/config/resolveForkEmitContext.js +12 -0
  52. package/dist/config/resolvePipelineDag.d.ts +5 -1
  53. package/dist/config/resolvePipelineDag.js +54 -36
  54. package/dist/config/scanCatalogPaths.d.ts +4 -0
  55. package/dist/config/scanCatalogPaths.js +83 -0
  56. package/dist/config/validateCatalog.d.ts +22 -4
  57. package/dist/config/validateCatalog.js +169 -132
  58. package/dist/envelope/check.js +7 -0
  59. package/dist/envelope/forkChoice.d.ts +3 -0
  60. package/dist/envelope/forkChoice.js +47 -0
  61. package/dist/index.d.ts +4 -0
  62. package/dist/index.js +4 -0
  63. package/dist/mcp/projectRun.d.ts +3 -0
  64. package/dist/mcp/projectRun.js +7 -0
  65. package/dist/mcp/tools.js +28 -11
  66. package/dist/project/findProjectRoot.d.ts +2 -0
  67. package/dist/project/findProjectRoot.js +51 -0
  68. package/dist/project/globalHome.d.ts +2 -0
  69. package/dist/project/globalHome.js +19 -0
  70. package/dist/project/resolveProjectContext.d.ts +9 -0
  71. package/dist/project/resolveProjectContext.js +38 -0
  72. package/dist/project/resolveStageflowContext.d.ts +11 -0
  73. package/dist/project/resolveStageflowContext.js +66 -0
  74. package/dist/runstore/normalizeCatalogPath.d.ts +1 -0
  75. package/dist/runstore/normalizeCatalogPath.js +4 -0
  76. package/dist/runstore/port.d.ts +9 -0
  77. package/dist/runstore/runProjection.js +11 -1
  78. package/dist/runstore/sqlite/SqliteRunStore.js +30 -4
  79. package/dist/runstore/sqlite/schema.d.ts +1 -1
  80. package/dist/runstore/sqlite/schema.js +4 -1
  81. package/dist/runtime/credentialBinding.d.ts +8 -6
  82. package/dist/runtime/credentialBinding.js +18 -24
  83. package/dist/runtime/pipelineRunner.d.ts +3 -0
  84. package/dist/runtime/pipelineRunner.js +15 -1
  85. package/dist/runtime/pipelineScheduler.d.ts +2 -0
  86. package/dist/runtime/pipelineScheduler.js +51 -11
  87. package/dist/runtime/reloadRunCatalog.d.ts +5 -0
  88. package/dist/runtime/reloadRunCatalog.js +44 -0
  89. package/dist/runtime/resumeReconstruct.d.ts +1 -0
  90. package/dist/runtime/resumeReconstruct.js +23 -7
  91. package/dist/runtime/runManager.d.ts +4 -0
  92. package/dist/runtime/runManager.js +36 -16
  93. package/dist/runtime/settingsFile.d.ts +12 -0
  94. package/dist/runtime/settingsFile.js +87 -6
  95. package/dist/runtime/stageAttemptBootstrap.js +3 -0
  96. package/dist/runtime/stageProcessLauncher.js +3 -0
  97. package/dist/runtime/stageRecovery.js +3 -1
  98. package/dist/runtime/stageWorker.js +2 -6
  99. package/dist/server/http.js +33 -9
  100. package/dist/tools/emitStageEnvelope.d.ts +3 -1
  101. package/dist/tools/emitStageEnvelope.js +11 -1
  102. package/dist/types/envelope.d.ts +1 -0
  103. package/dist/types/forkChoice.d.ts +8 -0
  104. package/dist/types/forkChoice.js +1 -0
  105. package/dist/types/pipeline.d.ts +51 -2
  106. package/dist/types/stageflowManifest.d.ts +23 -0
  107. package/dist/types/stageflowManifest.js +1 -0
  108. package/dist/ui/assets/index-CFSzDZje.js +118 -0
  109. package/dist/ui/assets/{index-DCsDopak.css → index-DefBlEvN.css} +1 -1
  110. package/dist/ui/index.html +3 -2
  111. package/dist/ui/stageflow-icon.svg +12 -0
  112. package/package.json +7 -5
  113. package/dist/ui/assets/index-Cry0Tpfx.js +0 -118
@@ -1,12 +1,18 @@
1
- import { readdir } from "node:fs/promises";
2
1
  import path from "node:path";
3
- import { loadPipelineOutcome, loadPipelineValidated } from "./loadPipeline.js";
2
+ import { loadPipelineOutcome, resolvePipelinePath } from "./loadPipeline.js";
4
3
  import { loadStageOutcome } from "./loadStage.js";
4
+ import { loadTaskOutcome } from "./loadTask.js";
5
5
  import { readYamlObject } from "./readYamlObject.js";
6
- import { extractPipelineStageIds } from "./resolvePipelineDag.js";
6
+ import { resolveCatalogContext } from "./resolveCatalogContext.js";
7
+ import { getCatalogScanPaths } from "./browseCatalog.js";
8
+ import { manifestPathForProject } from "./loadStageflowManifest.js";
7
9
  export function relPath(cwd, absPath) {
8
- return path.relative(cwd, absPath);
10
+ return path.relative(cwd, absPath).replace(/\\/g, "/");
9
11
  }
12
+ const STRICT_CATALOG_WARNINGS = new Set([
13
+ "catalog.manifest_missing",
14
+ "catalog.empty_catalog",
15
+ ]);
10
16
  const SEVERITY_RANK = {
11
17
  error: 0,
12
18
  warning: 1,
@@ -28,7 +34,7 @@ function sortFindings(findings) {
28
34
  export function effectiveSeverity(finding, strict) {
29
35
  if (strict &&
30
36
  finding.severity === "warning" &&
31
- finding.code === "catalog.orphan_stage") {
37
+ STRICT_CATALOG_WARNINGS.has(finding.code)) {
32
38
  return "error";
33
39
  }
34
40
  return finding.severity;
@@ -82,6 +88,24 @@ function findingStageError(cwd, absPath, message, code, stageId) {
82
88
  stageId,
83
89
  }, "error");
84
90
  }
91
+ function findingTaskError(cwd, absPath, message, code) {
92
+ return baseFinding({
93
+ cwd,
94
+ absPath,
95
+ message,
96
+ code,
97
+ category: "task",
98
+ }, "error");
99
+ }
100
+ function findingCatalog(cwd, absPath, message, code, severity) {
101
+ return baseFinding({
102
+ cwd,
103
+ absPath,
104
+ message,
105
+ code,
106
+ category: "catalog",
107
+ }, severity);
108
+ }
85
109
  export function findingsFromLoadIssues(cwd, absPath, issues) {
86
110
  return issues.flatMap((issue) => {
87
111
  if (issue.category === "pipeline") {
@@ -94,6 +118,18 @@ export function findingsFromLoadIssues(cwd, absPath, issues) {
94
118
  findingStageError(cwd, absPath, issue.message, issue.code, issue.stageId),
95
119
  ];
96
120
  }
121
+ if (issue.category === "task") {
122
+ return [
123
+ findingTaskError(cwd, absPath, issue.message, issue.code),
124
+ ];
125
+ }
126
+ if (issue.category === "catalog") {
127
+ const code = issue.code;
128
+ const severity = code === "catalog.manifest_missing" || code === "catalog.empty_catalog"
129
+ ? "warning"
130
+ : "error";
131
+ return [findingCatalog(cwd, absPath, issue.message, code, severity)];
132
+ }
97
133
  return [];
98
134
  });
99
135
  }
@@ -107,40 +143,6 @@ export function findingStageIdFilenameMismatch(cwd, absPath, fileStem, declaredI
107
143
  stageId: declaredId,
108
144
  }, "error");
109
145
  }
110
- function findingCatalogDuplicatePipelineId(cwd, absPath, pipelineId, conflictingPaths) {
111
- const relConflicts = conflictingPaths.map((p) => relPath(cwd, p));
112
- return baseFinding({
113
- cwd,
114
- absPath,
115
- message: `Duplicate pipeline id "${pipelineId}" declared in: ${relConflicts.join(", ")}`,
116
- code: "catalog.duplicate_pipeline_id",
117
- category: "catalog",
118
- pipelineId,
119
- }, "error");
120
- }
121
- function findingCatalogOrphanStage(cwd, absPath, stageId) {
122
- return baseFinding({
123
- cwd,
124
- absPath,
125
- message: `Stage "${stageId}" is not referenced by any pipeline`,
126
- code: "catalog.orphan_stage",
127
- category: "catalog",
128
- stageId,
129
- }, "warning");
130
- }
131
- async function listYamlFiles(dir) {
132
- let entries;
133
- try {
134
- entries = await readdir(dir, { withFileTypes: true });
135
- }
136
- catch {
137
- return [];
138
- }
139
- return entries
140
- .filter((entry) => entry.isFile() && entry.name.endsWith(".yaml"))
141
- .map((entry) => path.join(dir, entry.name))
142
- .sort((a, b) => a.localeCompare(b));
143
- }
144
146
  async function checkStageIdFilename(cwd, stagePath) {
145
147
  const fileStem = path.basename(stagePath, ".yaml");
146
148
  try {
@@ -169,52 +171,79 @@ async function validateStageFile(cwd, stagePath) {
169
171
  }
170
172
  return findings;
171
173
  }
172
- async function validatePipelineFile(pipelinePath, options) {
173
- const { cwd, stagesDir, validateStages } = options;
174
- const findings = [];
175
- const referencedStageIds = new Set();
176
- let pipelineId;
174
+ async function runPipelineValidation(nameOrPath, options) {
175
+ const { cwd, validateStages } = options;
176
+ let pipelinePath;
177
177
  try {
178
- const raw = await readYamlObject(pipelinePath);
179
- if (typeof raw?.id === "string") {
180
- pipelineId = raw.id;
181
- if (Array.isArray(raw.stages)) {
182
- const stageIds = extractPipelineStageIds(raw.stages);
183
- if (stageIds) {
184
- for (const stageId of stageIds) {
185
- referencedStageIds.add(stageId);
186
- }
187
- }
188
- }
189
- }
178
+ pipelinePath = await resolvePipelinePath(nameOrPath, cwd);
190
179
  }
191
- catch {
192
- // loadPipelineOutcome will report read errors
180
+ catch (err) {
181
+ const message = err instanceof Error ? err.message : String(err);
182
+ return {
183
+ ok: false,
184
+ findings: findingsFromLoadIssues(cwd, path.resolve(cwd, nameOrPath), [
185
+ {
186
+ code: "pipeline.load_error",
187
+ message,
188
+ category: "pipeline",
189
+ },
190
+ ]),
191
+ };
193
192
  }
194
- const outcome = await loadPipelineOutcome(pipelinePath, { cwd, stagesDir });
193
+ const outcome = await loadPipelineOutcome(pipelinePath, { cwd });
194
+ const findings = [];
195
195
  if (!outcome.ok) {
196
196
  findings.push(...findingsFromLoadIssues(cwd, pipelinePath, outcome.issues));
197
- if (validateStages) {
198
- for (const stageId of referencedStageIds) {
199
- const stagePath = path.join(stagesDir, `${stageId}.yaml`);
200
- findings.push(...(await validateStageFile(cwd, stagePath)));
197
+ return { ok: false, findings };
198
+ }
199
+ if (validateStages && outcome.value.stageSources) {
200
+ for (const source of Object.values(outcome.value.stageSources)) {
201
+ if (source.kind === "file") {
202
+ findings.push(...(await validateStageFile(cwd, source.path)));
201
203
  }
202
204
  }
203
- return { findings, pipelineId, referencedStageIds };
204
205
  }
205
- for (const stageId of outcome.value.pipeline.stages) {
206
- referencedStageIds.add(stageId);
207
- if (validateStages) {
208
- const stagePath = path.join(stagesDir, `${stageId}.yaml`);
209
- findings.push(...(await validateStageFile(cwd, stagePath)));
210
- }
206
+ if (findings.some((finding) => finding.severity === "error")) {
207
+ return { ok: false, findings };
211
208
  }
212
- return { findings, pipelineId: outcome.value.pipeline.id, referencedStageIds };
209
+ return { ok: true, loaded: outcome.value, findings };
210
+ }
211
+ export async function validatePipeline(nameOrPath, options = {}) {
212
+ const cwd = options.cwd ?? process.cwd();
213
+ const validateStages = options.validateStages ?? true;
214
+ const strict = options.strict ?? false;
215
+ const core = await runPipelineValidation(nameOrPath, { cwd, validateStages });
216
+ return buildValidationResult("pipeline", core.findings, strict);
213
217
  }
214
- async function collectPipelineIds(pipelinesDir) {
215
- const files = await listYamlFiles(pipelinesDir);
218
+ export async function loadPipelineValidated(nameOrPath, options = {}) {
219
+ const cwd = options.cwd ?? process.cwd();
220
+ const validateStages = options.validateStages ?? true;
221
+ const core = await runPipelineValidation(nameOrPath, { cwd, validateStages });
222
+ if (core.ok) {
223
+ return { ok: true, loaded: core.loaded };
224
+ }
225
+ return { ok: false, findings: core.findings };
226
+ }
227
+ function findingCatalogDuplicatePipelineId(cwd, absPath, pipelineId, conflictingPaths) {
228
+ const relConflicts = conflictingPaths.map((p) => relPath(cwd, p));
229
+ return baseFinding({
230
+ cwd,
231
+ absPath,
232
+ message: `Duplicate pipeline id "${pipelineId}" declared in: ${relConflicts.join(", ")}`,
233
+ code: "catalog.duplicate_pipeline_id",
234
+ category: "catalog",
235
+ pipelineId,
236
+ }, "error");
237
+ }
238
+ function findingManifestMissing(cwd, projectRoot) {
239
+ return findingCatalog(cwd, manifestPathForProject(projectRoot), "No stageflow.yaml manifest found at project root", "catalog.manifest_missing", "warning");
240
+ }
241
+ function findingEmptyCatalog(cwd, manifestPath, kind) {
242
+ return findingCatalog(cwd, manifestPath, `Manifest catalog.${kind} is empty`, "catalog.empty_catalog", "warning");
243
+ }
244
+ async function collectPipelineIdsFromPaths(pipelinePaths) {
216
245
  const idToPaths = new Map();
217
- for (const filePath of files) {
246
+ for (const filePath of pipelinePaths) {
218
247
  try {
219
248
  const raw = await readYamlObject(filePath);
220
249
  if (typeof raw?.id !== "string")
@@ -240,83 +269,91 @@ function findingsForDuplicatePipelineIds(cwd, idToPaths) {
240
269
  }
241
270
  return findings;
242
271
  }
243
- async function collectReferencedStageIds(pipelinesDir) {
244
- const files = await listYamlFiles(pipelinesDir);
245
- const referenced = new Set();
246
- for (const filePath of files) {
247
- try {
248
- const raw = await readYamlObject(filePath);
249
- if (typeof raw?.id !== "string" || !Array.isArray(raw.stages))
250
- continue;
251
- const stageIds = extractPipelineStageIds(raw.stages);
252
- if (!stageIds)
253
- continue;
254
- for (const stageId of stageIds) {
255
- referenced.add(stageId);
256
- }
272
+ async function validateManifestAll(invocationCwd) {
273
+ const ctx = await resolveCatalogContext(invocationCwd);
274
+ const findings = [];
275
+ if (ctx.manifestStatus === "not_git" || ctx.manifestStatus === "missing") {
276
+ if (ctx.projectRoot) {
277
+ findings.push(findingManifestMissing(invocationCwd, ctx.projectRoot));
257
278
  }
258
- catch {
259
- // skip unreadable
279
+ else {
280
+ findings.push(findingCatalog(invocationCwd, path.resolve(invocationCwd), "Not inside a git repository; no catalog manifest", "catalog.manifest_missing", "warning"));
260
281
  }
282
+ return findings;
261
283
  }
262
- return referenced;
263
- }
264
- async function findingsForOrphanStages(cwd, stagesDir, referencedStageIds) {
265
- const findings = [];
266
- const stageFiles = await listYamlFiles(stagesDir);
267
- for (const stagePath of stageFiles) {
268
- const stageId = path.basename(stagePath, ".yaml");
269
- if (referencedStageIds.has(stageId))
270
- continue;
271
- findings.push(findingCatalogOrphanStage(cwd, stagePath, stageId));
272
- findings.push(...(await validateStageFile(cwd, stagePath)));
284
+ if (ctx.manifestStatus === "invalid") {
285
+ const manifestPath = ctx.projectRoot
286
+ ? manifestPathForProject(ctx.projectRoot)
287
+ : path.resolve(invocationCwd, "stageflow.yaml");
288
+ findings.push(...findingsFromLoadIssues(invocationCwd, manifestPath, ctx.issues));
289
+ return findings;
290
+ }
291
+ const { projectRoot, manifest } = ctx;
292
+ if (!projectRoot || !manifest) {
293
+ return findings;
294
+ }
295
+ if (manifest.manifest.catalog.pipelines.length === 0) {
296
+ findings.push(findingEmptyCatalog(invocationCwd, manifest.path, "pipelines"));
297
+ }
298
+ if (manifest.manifest.catalog.tasks.length === 0) {
299
+ findings.push(findingEmptyCatalog(invocationCwd, manifest.path, "tasks"));
300
+ }
301
+ const scanPaths = await getCatalogScanPaths(ctx);
302
+ if (!scanPaths) {
303
+ return findings;
304
+ }
305
+ const { pipelinePaths, taskPaths } = scanPaths;
306
+ for (const pipelinePath of pipelinePaths) {
307
+ const core = await runPipelineValidation(pipelinePath, {
308
+ cwd: projectRoot,
309
+ validateStages: true,
310
+ });
311
+ if (!core.ok) {
312
+ findings.push(...core.findings);
313
+ }
273
314
  }
315
+ for (const taskPath of taskPaths) {
316
+ const outcome = await loadTaskOutcome(taskPath);
317
+ if (!outcome.ok) {
318
+ findings.push(...findingsFromLoadIssues(projectRoot, taskPath, outcome.issues));
319
+ }
320
+ }
321
+ const idToPaths = await collectPipelineIdsFromPaths(pipelinePaths);
322
+ findings.push(...findingsForDuplicatePipelineIds(projectRoot, idToPaths));
274
323
  return findings;
275
324
  }
325
+ async function validateSingleTask(invocationCwd, taskArg) {
326
+ const ctx = await resolveCatalogContext(invocationCwd);
327
+ const relCwd = ctx.projectRoot ?? invocationCwd;
328
+ const taskPath = path.resolve(invocationCwd, taskArg);
329
+ const outcome = await loadTaskOutcome(taskPath);
330
+ if (!outcome.ok) {
331
+ return findingsFromLoadIssues(relCwd, taskPath, outcome.issues);
332
+ }
333
+ return [];
334
+ }
276
335
  export async function validateCatalog(options) {
277
336
  const cwd = options.cwd ?? process.cwd();
278
- const stagesDir = options.stagesDir ?? path.join(cwd, "stages");
279
337
  const strict = options.strict ?? false;
280
- const pipelinesDir = path.join(cwd, "pipelines");
281
338
  if (options.scope === "pipeline" && !options.pipeline) {
282
339
  throw new Error("validateCatalog: pipeline is required when scope is \"pipeline\"");
283
340
  }
341
+ if (options.scope === "task" && !options.task) {
342
+ throw new Error("validateCatalog: task is required when scope is \"task\"");
343
+ }
284
344
  const allFindings = [];
285
345
  if (options.scope === "pipeline") {
286
- const result = await loadPipelineValidated(options.pipeline, {
346
+ return validatePipeline(options.pipeline, {
287
347
  cwd,
288
- stagesDir,
289
348
  validateStages: true,
349
+ strict,
290
350
  });
291
- if (!result.ok) {
292
- allFindings.push(...result.findings);
293
- }
351
+ }
352
+ else if (options.scope === "task") {
353
+ allFindings.push(...(await validateSingleTask(cwd, options.task)));
294
354
  }
295
355
  else {
296
- const referencedStageIds = await collectReferencedStageIds(pipelinesDir);
297
- const pipelineFiles = await listYamlFiles(pipelinesDir);
298
- for (const pipelinePath of pipelineFiles) {
299
- const result = await validatePipelineFile(pipelinePath, {
300
- cwd,
301
- stagesDir,
302
- validateStages: false,
303
- });
304
- allFindings.push(...result.findings);
305
- for (const stageId of result.referencedStageIds) {
306
- referencedStageIds.add(stageId);
307
- }
308
- }
309
- const validatedStages = new Set();
310
- for (const stageId of referencedStageIds) {
311
- if (validatedStages.has(stageId))
312
- continue;
313
- validatedStages.add(stageId);
314
- const stagePath = path.join(stagesDir, `${stageId}.yaml`);
315
- allFindings.push(...(await validateStageFile(cwd, stagePath)));
316
- }
317
- const idToPaths = await collectPipelineIds(pipelinesDir);
318
- allFindings.push(...findingsForDuplicatePipelineIds(cwd, idToPaths));
319
- allFindings.push(...(await findingsForOrphanStages(cwd, stagesDir, referencedStageIds)));
356
+ allFindings.push(...(await validateManifestAll(cwd)));
320
357
  }
321
358
  return buildValidationResult(options.scope, allFindings, strict);
322
359
  }
@@ -25,6 +25,13 @@ export function assertRequiredEnvelope(value) {
25
25
  summary: record.summary,
26
26
  artifacts: record.artifacts,
27
27
  };
28
+ if (record.fork_choice !== undefined) {
29
+ if (!Array.isArray(record.fork_choice) ||
30
+ !record.fork_choice.every((item) => typeof item === "string")) {
31
+ throw new EnvelopeError("fork_choice must be an array of strings");
32
+ }
33
+ envelope.fork_choice = record.fork_choice;
34
+ }
28
35
  if (record.payload !== undefined) {
29
36
  if (record.payload === null ||
30
37
  typeof record.payload !== "object" ||
@@ -0,0 +1,3 @@
1
+ import type { ForkEmitContext } from "../types/forkChoice.js";
2
+ export declare function assertForkEnvelope(params: unknown, forkEmitContext: ForkEmitContext): void;
3
+ export declare function normalizeForkChoice(raw: string[] | undefined, mode: "emit" | "stored", forkEmitContext?: ForkEmitContext): Set<string>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Emit mode rejects missing fork_choice on fork success; stored mode treats
3
+ * undefined as an empty choice set (scheduler defensive path).
4
+ */
5
+ import { EnvelopeError } from "../types/envelope.js";
6
+ function assertForkChoiceLegality(choices, forkEmitContext) {
7
+ const { immediateSuccessorIds, forkShape } = forkEmitContext;
8
+ for (const id of choices) {
9
+ if (!immediateSuccessorIds.includes(id)) {
10
+ throw new EnvelopeError(`fork_choice contains non-immediate successor: ${id}`);
11
+ }
12
+ }
13
+ if (choices.length === 0) {
14
+ if (forkShape === null || !forkShape.allowNone) {
15
+ throw new EnvelopeError("fork_choice must not be empty");
16
+ }
17
+ }
18
+ if (forkShape !== null && forkShape.cardinality === "one" && choices.length !== 1) {
19
+ throw new EnvelopeError("fork shape requires exactly one choice");
20
+ }
21
+ }
22
+ export function assertForkEnvelope(params, forkEmitContext) {
23
+ const record = params;
24
+ if (record.status === "failure")
25
+ return;
26
+ if (record.fork_choice === undefined) {
27
+ throw new EnvelopeError("fork stage must include fork_choice on success");
28
+ }
29
+ assertForkChoiceLegality(record.fork_choice, forkEmitContext);
30
+ }
31
+ export function normalizeForkChoice(raw, mode, forkEmitContext) {
32
+ if (mode === "stored") {
33
+ if (raw === undefined)
34
+ return new Set();
35
+ return new Set(raw);
36
+ }
37
+ if (forkEmitContext === undefined) {
38
+ if (raw === undefined)
39
+ return new Set();
40
+ return new Set(raw);
41
+ }
42
+ if (raw === undefined) {
43
+ throw new EnvelopeError("fork stage must include fork_choice on success");
44
+ }
45
+ assertForkChoiceLegality(raw, forkEmitContext);
46
+ return new Set(raw);
47
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,7 @@
1
+ export { findProjectRoot, clearFindProjectRootCacheForTests, } from "./project/findProjectRoot.js";
2
+ export { globalStageflowHome, ensureGlobalHome, } from "./project/globalHome.js";
3
+ export { resolveProjectContext, legacyProjectContext, type ProjectContext, } from "./project/resolveProjectContext.js";
4
+ export { resolveStageflowContext, projectContextFromStageflow, type StageflowContext, type CatalogManifestStatus, } from "./project/resolveStageflowContext.js";
1
5
  export { PACKAGE_NAME } from "./package-meta.js";
2
6
  export type { StageEnvelope } from "./types/envelope.js";
3
7
  export { assertRequiredEnvelope, isAdvancingEnvelope, } from "./envelope/check.js";
package/dist/index.js CHANGED
@@ -1,3 +1,7 @@
1
+ export { findProjectRoot, clearFindProjectRootCacheForTests, } from "./project/findProjectRoot.js";
2
+ export { globalStageflowHome, ensureGlobalHome, } from "./project/globalHome.js";
3
+ export { resolveProjectContext, legacyProjectContext, } from "./project/resolveProjectContext.js";
4
+ export { resolveStageflowContext, projectContextFromStageflow, } from "./project/resolveStageflowContext.js";
1
5
  export { PACKAGE_NAME } from "./package-meta.js";
2
6
  export { assertRequiredEnvelope, isAdvancingEnvelope, } from "./envelope/check.js";
3
7
  export { runPipeline, startPipeline } from "./runtime/pipelineRunner.js";
@@ -19,6 +19,9 @@ export type McpRunProjection = {
19
19
  run_id: string;
20
20
  pipeline_id: string;
21
21
  task_id?: string;
22
+ pipeline_path?: string;
23
+ task_path?: string;
24
+ project_root?: string;
22
25
  status: RunDetail["status"];
23
26
  created_at: string;
24
27
  updated_at?: string;
@@ -3,6 +3,13 @@ export function projectRunForMcp(detail) {
3
3
  run_id: detail.run_id,
4
4
  pipeline_id: detail.pipeline_id,
5
5
  task_id: detail.task_id,
6
+ ...(detail.pipeline_path !== undefined
7
+ ? { pipeline_path: detail.pipeline_path }
8
+ : {}),
9
+ ...(detail.task_path !== undefined ? { task_path: detail.task_path } : {}),
10
+ ...(detail.project_root !== undefined
11
+ ? { project_root: detail.project_root }
12
+ : {}),
6
13
  status: detail.status,
7
14
  created_at: detail.created_at,
8
15
  updated_at: detail.updated_at,
package/dist/mcp/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { listPipelines } from "../config/listConfig.js";
2
+ import { browseCatalog } from "../config/browseCatalog.js";
3
3
  import { projectRunForMcp } from "./projectRun.js";
4
4
  import { readRunArtifact } from "./readArtifact.js";
5
5
  const taskFileSchema = z.object({
@@ -9,6 +9,15 @@ const taskFileSchema = z.object({
9
9
  constraints: z.string().optional(),
10
10
  checkout: z.string().optional(),
11
11
  });
12
+ const startRunSchema = z
13
+ .object({
14
+ pipeline: z.string().describe("Filesystem path to a pipeline YAML file"),
15
+ task_path: z.string().optional(),
16
+ task: taskFileSchema.optional(),
17
+ })
18
+ .refine((data) => Boolean(data.task_path) !== Boolean(data.task), {
19
+ message: "Exactly one of task_path or task is required",
20
+ });
12
21
  function textResult(data, isError = false) {
13
22
  return {
14
23
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
@@ -18,11 +27,18 @@ function textResult(data, isError = false) {
18
27
  export function registerMcpTools(server, deps) {
19
28
  const { manager, store, cwd } = deps;
20
29
  server.registerTool("list_pipelines", {
21
- description: "List runnable pipeline ids from the factory cwd",
30
+ description: "List manifest-declared pipeline paths from the project catalog",
31
+ inputSchema: z.object({}),
32
+ }, async () => {
33
+ const catalog = await browseCatalog(cwd);
34
+ return textResult({ pipelines: catalog.pipelines });
35
+ });
36
+ server.registerTool("list_tasks", {
37
+ description: "List manifest-declared task paths from the project catalog",
22
38
  inputSchema: z.object({}),
23
39
  }, async () => {
24
- const pipelines = await listPipelines(cwd);
25
- return textResult({ pipelines });
40
+ const catalog = await browseCatalog(cwd);
41
+ return textResult({ tasks: catalog.tasks });
26
42
  });
27
43
  server.registerTool("list_runs", {
28
44
  description: "List known pipeline runs",
@@ -36,16 +52,17 @@ export function registerMcpTools(server, deps) {
36
52
  inputSchema: z.object({}),
37
53
  }, async () => textResult(manager.getHealth()));
38
54
  server.registerTool("start_run", {
39
- description: "Start a pipeline with an inline TaskFile (not written under tasks/). Returns { runId }. On conflict returns isError with code busy_capacity (soft max full) or busy_checkout (same checkout leased), plus activeCount/maxConcurrent/activeRunIds and optional conflictingRunId/conflictingCheckout.",
40
- inputSchema: z.object({
41
- pipeline: z.string(),
42
- task: taskFileSchema,
43
- }),
44
- }, async ({ pipeline, task }) => {
55
+ description: "Start a pipeline run using a filesystem pipeline path and either task_path (catalog task file) or an inline task object. Returns { runId }. On conflict returns isError with code busy_capacity (soft max full) or busy_checkout (same checkout leased), plus activeCount/maxConcurrent/activeRunIds and optional conflictingRunId/conflictingCheckout.",
56
+ inputSchema: startRunSchema,
57
+ }, async ({ pipeline, task_path, task }) => {
45
58
  if (!pipeline.trim()) {
46
59
  return textResult({ error: "pipeline is required" }, true);
47
60
  }
48
- const result = await manager.startRun({ pipeline, task });
61
+ const taskInput = task_path ?? task;
62
+ if (taskInput === undefined) {
63
+ return textResult({ error: "Exactly one of task_path or task is required" }, true);
64
+ }
65
+ const result = await manager.startRun({ pipeline, task: taskInput });
49
66
  if (!result.ok) {
50
67
  const { ok: _ok, reason, ...rest } = result;
51
68
  return textResult({ error: reason, ...rest }, true);
@@ -0,0 +1,2 @@
1
+ export declare function findProjectRoot(startDir: string): string | null;
2
+ export declare function clearFindProjectRootCacheForTests(): void;
@@ -0,0 +1,51 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import path from "node:path";
4
+ const cache = new Map();
5
+ function normalizeRoot(dir) {
6
+ try {
7
+ return realpathSync(path.resolve(dir));
8
+ }
9
+ catch {
10
+ return path.resolve(dir);
11
+ }
12
+ }
13
+ export function findProjectRoot(startDir) {
14
+ const normalized = path.resolve(startDir);
15
+ if (cache.has(normalized)) {
16
+ return cache.get(normalized) ?? null;
17
+ }
18
+ let result = null;
19
+ try {
20
+ const stdout = execFileSync("git", ["rev-parse", "--show-toplevel"], {
21
+ cwd: normalized,
22
+ encoding: "utf8",
23
+ stdio: ["ignore", "pipe", "ignore"],
24
+ });
25
+ const trimmed = stdout.trim();
26
+ if (trimmed.length > 0) {
27
+ result = normalizeRoot(trimmed);
28
+ }
29
+ }
30
+ catch {
31
+ result = walkForGitRoot(normalized);
32
+ }
33
+ cache.set(normalized, result);
34
+ return result;
35
+ }
36
+ function walkForGitRoot(startDir) {
37
+ let dir = path.resolve(startDir);
38
+ for (;;) {
39
+ if (existsSync(path.join(dir, ".git"))) {
40
+ return normalizeRoot(dir);
41
+ }
42
+ const parent = path.dirname(dir);
43
+ if (parent === dir) {
44
+ return null;
45
+ }
46
+ dir = parent;
47
+ }
48
+ }
49
+ export function clearFindProjectRootCacheForTests() {
50
+ cache.clear();
51
+ }
@@ -0,0 +1,2 @@
1
+ export declare function globalStageflowHome(): string;
2
+ export declare function ensureGlobalHome(): string;
@@ -0,0 +1,19 @@
1
+ import { chmodSync, mkdirSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export function globalStageflowHome() {
5
+ return path.join(os.homedir(), ".stageflow");
6
+ }
7
+ export function ensureGlobalHome() {
8
+ const globalHome = globalStageflowHome();
9
+ mkdirSync(globalHome, { recursive: true });
10
+ const agentDir = path.join(globalHome, "agent");
11
+ mkdirSync(agentDir, { recursive: true });
12
+ try {
13
+ chmodSync(agentDir, 0o700);
14
+ }
15
+ catch {
16
+ // best-effort on non-POSIX
17
+ }
18
+ return globalHome;
19
+ }
@@ -0,0 +1,9 @@
1
+ export type ProjectContext = {
2
+ invocationCwd: string;
3
+ projectRoot: string;
4
+ globalHome: string;
5
+ isGitProject: boolean;
6
+ };
7
+ export declare function resolveProjectContext(invocationCwd: string): ProjectContext;
8
+ export declare function legacyProjectContext(cwd: string): ProjectContext;
9
+ export declare function asProjectContext(input: ProjectContext | string): ProjectContext;