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,10 +1,11 @@
1
- import { access, mkdir, readdir, writeFile } from "node:fs/promises";
1
+ import { access, mkdir, readdir, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { STAGE_ID_PATTERN } from "./createStage.js";
4
- import { loadPipeline } from "./loadPipeline.js";
5
- import { loadStage } from "./loadStage.js";
4
+ import { inferIdFromUsesPath, normalizePipelineStageEntries, toWiringRefs, } from "./normalizePipelineStageEntry.js";
6
5
  import { readYamlObject } from "./readYamlObject.js";
7
- import { resolvePipelineDag } from "./resolvePipelineDag.js";
6
+ import { resolvePipelineDagFromRefs } from "./resolvePipelineDag.js";
7
+ import { validatePipeline } from "./validateCatalog.js";
8
+ import { loadPipelineOutcome } from "./loadPipeline.js";
8
9
  function isPlainObject(value) {
9
10
  return value !== null && typeof value === "object" && !Array.isArray(value);
10
11
  }
@@ -17,24 +18,136 @@ function validatePipelineId(id) {
17
18
  }
18
19
  return null;
19
20
  }
21
+ function defaultUsesPath(stageId) {
22
+ return `./${stageId}.yaml`;
23
+ }
20
24
  export function normalizeCreatePipelineStages(stages) {
21
- if (stages.length > 0 && typeof stages[0] === "string") {
22
- return stages.map((id) => ({ id }));
25
+ return stages.map((stage) => ({
26
+ ...stage,
27
+ uses: stage.uses ?? (stage.inline ? undefined : defaultUsesPath(stage.id)),
28
+ }));
29
+ }
30
+ function stageRefToRaw(ref) {
31
+ const raw = { id: ref.id };
32
+ if (ref.needs !== undefined)
33
+ raw.needs = ref.needs;
34
+ if (ref.uses !== undefined)
35
+ raw.uses = ref.uses;
36
+ if (ref.inline) {
37
+ raw.system_prompt = ref.inline.system_prompt;
38
+ raw.model = ref.inline.model;
39
+ if (ref.inline.gate_kinds !== undefined) {
40
+ raw.gate_kinds = ref.inline.gate_kinds;
41
+ }
42
+ }
43
+ return raw;
44
+ }
45
+ function toAuthoringRawEntries(stages, declaringPath) {
46
+ return stages.map((stage) => ({
47
+ raw: stageRefToRaw(stage),
48
+ declaringPath,
49
+ }));
50
+ }
51
+ function normalizedToCreateRefs(entries) {
52
+ return entries.map((entry) => {
53
+ const ref = { id: entry.id };
54
+ if (entry.needs !== undefined)
55
+ ref.needs = entry.needs;
56
+ if (entry.body.kind === "uses") {
57
+ ref.uses = entry.body.path;
58
+ }
59
+ else {
60
+ const raw = entry.body.raw;
61
+ ref.inline = {
62
+ system_prompt: String(raw.system_prompt ?? ""),
63
+ model: String(raw.model ?? ""),
64
+ ...(Array.isArray(raw.gate_kinds)
65
+ ? { gate_kinds: raw.gate_kinds }
66
+ : {}),
67
+ };
68
+ }
69
+ return ref;
70
+ });
71
+ }
72
+ function normalizeAuthoringStages(stages, ctx) {
73
+ const prepared = normalizeCreatePipelineStages(stages);
74
+ const normalizeOutcome = normalizePipelineStageEntries(toAuthoringRawEntries(prepared, ctx.path), ctx);
75
+ if (!normalizeOutcome.ok) {
76
+ return {
77
+ ok: false,
78
+ status: 422,
79
+ error: normalizeOutcome.issues[0]?.message ?? "Invalid pipeline stages",
80
+ };
81
+ }
82
+ try {
83
+ resolvePipelineDagFromRefs(toWiringRefs(normalizeOutcome.value), ctx);
84
+ }
85
+ catch (err) {
86
+ const message = err instanceof Error ? err.message : String(err);
87
+ return { ok: false, status: 422, error: message };
88
+ }
89
+ return { ok: true, stages: normalizedToCreateRefs(normalizeOutcome.value) };
90
+ }
91
+ function parseInlineBody(entry, index) {
92
+ if (typeof entry.system_prompt !== "string" || entry.system_prompt.length === 0) {
93
+ return {
94
+ ok: false,
95
+ status: 400,
96
+ error: `stages[${index}].system_prompt is required for inline stage`,
97
+ };
98
+ }
99
+ if (typeof entry.model !== "string" || entry.model.length === 0) {
100
+ return {
101
+ ok: false,
102
+ status: 400,
103
+ error: `stages[${index}].model is required for inline stage`,
104
+ };
23
105
  }
24
- return stages;
106
+ const inline = {
107
+ system_prompt: entry.system_prompt,
108
+ model: entry.model,
109
+ };
110
+ if (entry.gate_kinds !== undefined) {
111
+ if (!Array.isArray(entry.gate_kinds)) {
112
+ return {
113
+ ok: false,
114
+ status: 400,
115
+ error: `stages[${index}].gate_kinds must be an array`,
116
+ };
117
+ }
118
+ inline.gate_kinds = entry.gate_kinds;
119
+ }
120
+ return inline;
25
121
  }
26
122
  function parseStageEntry(entry, index) {
123
+ if (typeof entry === "string") {
124
+ const hint = entry
125
+ ? `bare string stage refs are not supported; use { id: "${entry}", uses: "./${entry}.yaml" } or inline body`
126
+ : `bare string stage refs are not supported; use { id: "…", uses: "./….yaml" } or inline body`;
127
+ return {
128
+ ok: false,
129
+ status: 400,
130
+ error: `stages[${index}]: ${hint}`,
131
+ };
132
+ }
27
133
  if (!isPlainObject(entry)) {
28
134
  return {
29
135
  ok: false,
30
136
  status: 400,
31
- error: `stages[${index}] must be a string or object with id`,
137
+ error: `stages[${index}] must be an object with id`,
32
138
  };
33
139
  }
34
- if (typeof entry.id !== "string") {
140
+ let id = typeof entry.id === "string" ? entry.id : undefined;
141
+ const uses = typeof entry.uses === "string" && entry.uses.trim() ? entry.uses : undefined;
142
+ if (!id && uses) {
143
+ const inferred = inferIdFromUsesPath(uses);
144
+ if (inferred)
145
+ id = inferred;
146
+ }
147
+ if (!id) {
35
148
  return { ok: false, status: 400, error: `stages[${index}].id is required` };
36
149
  }
37
- const ref = { id: entry.id };
150
+ const ref = { id };
38
151
  if (entry.needs !== undefined) {
39
152
  if (typeof entry.needs !== "string") {
40
153
  return {
@@ -45,12 +158,45 @@ function parseStageEntry(entry, index) {
45
158
  }
46
159
  ref.needs = entry.needs;
47
160
  }
161
+ if (entry.uses !== undefined) {
162
+ if (typeof entry.uses !== "string" || !entry.uses.trim()) {
163
+ return {
164
+ ok: false,
165
+ status: 400,
166
+ error: `stages[${index}].uses must be a non-empty string`,
167
+ };
168
+ }
169
+ ref.uses = entry.uses;
170
+ }
171
+ else if (uses) {
172
+ ref.uses = uses;
173
+ }
174
+ const hasInlineFields = entry.system_prompt !== undefined ||
175
+ entry.model !== undefined ||
176
+ entry.gate_kinds !== undefined;
177
+ if (hasInlineFields) {
178
+ const inline = parseInlineBody(entry, index);
179
+ if ("ok" in inline)
180
+ return inline;
181
+ ref.inline = inline;
182
+ }
48
183
  return ref;
49
184
  }
50
185
  export function parseCreatePipelineBody(body) {
51
186
  if (!isPlainObject(body)) {
52
187
  return { ok: false, status: 400, error: "Request body must be an object" };
53
188
  }
189
+ if (typeof body.directory !== "string" || !body.directory.trim()) {
190
+ return { ok: false, status: 400, error: "directory is required" };
191
+ }
192
+ const directory = body.directory.trim().replace(/\\/g, "/");
193
+ if (directory.endsWith(".yaml") || directory.endsWith(".yml")) {
194
+ return {
195
+ ok: false,
196
+ status: 400,
197
+ error: "directory must be a catalog folder, not a pipeline file path",
198
+ };
199
+ }
54
200
  if (typeof body.id !== "string") {
55
201
  return { ok: false, status: 400, error: "id is required" };
56
202
  }
@@ -64,32 +210,6 @@ export function parseCreatePipelineBody(body) {
64
210
  if (body.stages.length === 0) {
65
211
  return { ok: false, status: 400, error: "stages must be a non-empty array" };
66
212
  }
67
- let hasString = false;
68
- let hasObject = false;
69
- for (const entry of body.stages) {
70
- if (typeof entry === "string") {
71
- hasString = true;
72
- }
73
- else if (isPlainObject(entry)) {
74
- hasObject = true;
75
- }
76
- else {
77
- return { ok: false, status: 400, error: "stages must be an array of strings" };
78
- }
79
- }
80
- if (hasString && hasObject) {
81
- return {
82
- ok: false,
83
- status: 400,
84
- error: "stages must be either all strings or all objects",
85
- };
86
- }
87
- if (hasString) {
88
- return {
89
- id: body.id,
90
- stages: body.stages,
91
- };
92
- }
93
213
  const refs = [];
94
214
  for (let index = 0; index < body.stages.length; index++) {
95
215
  const parsed = parseStageEntry(body.stages[index], index);
@@ -99,23 +219,54 @@ export function parseCreatePipelineBody(body) {
99
219
  refs.push(parsed);
100
220
  }
101
221
  return {
222
+ directory,
102
223
  id: body.id,
103
224
  stages: refs,
104
225
  };
105
226
  }
227
+ function formatInlineYamlScalar(value) {
228
+ if (value.includes("\n") ||
229
+ /[:#"'&*!|>@`[\]{}]/.test(value) ||
230
+ value.startsWith(" ") ||
231
+ value.endsWith(" ") ||
232
+ value.startsWith("-") ||
233
+ value.startsWith("?")) {
234
+ return JSON.stringify(value);
235
+ }
236
+ return value;
237
+ }
106
238
  export function pipelineConfigToYaml(pipeline, options) {
107
239
  const lines = [`id: ${pipeline.id}`, "stages:"];
108
- if (options.format === "linear") {
109
- for (const stage of pipeline.stages) {
110
- lines.push(` - ${stage.id}`);
111
- }
112
- }
113
- else {
114
- for (const stage of pipeline.stages) {
240
+ for (const stage of pipeline.stages) {
241
+ if (options.format === "linear" && !stage.needs && !stage.inline && stage.uses) {
115
242
  lines.push(` - id: ${stage.id}`);
116
- if (stage.needs) {
117
- lines.push(` needs: ${stage.needs}`);
243
+ lines.push(` uses: ${stage.uses}`);
244
+ continue;
245
+ }
246
+ lines.push(` - id: ${stage.id}`);
247
+ if (stage.needs) {
248
+ lines.push(` needs: ${stage.needs}`);
249
+ }
250
+ if (stage.inline) {
251
+ if (stage.inline.gate_kinds && stage.inline.gate_kinds.length > 0) {
252
+ lines.push(" gate_kinds:");
253
+ for (const kind of stage.inline.gate_kinds) {
254
+ lines.push(` - ${kind}`);
255
+ }
256
+ }
257
+ if (stage.inline.system_prompt.includes("\n")) {
258
+ lines.push(" system_prompt: |");
259
+ for (const line of stage.inline.system_prompt.split("\n")) {
260
+ lines.push(` ${line}`);
261
+ }
118
262
  }
263
+ else {
264
+ lines.push(` system_prompt: ${formatInlineYamlScalar(stage.inline.system_prompt)}`);
265
+ }
266
+ lines.push(` model: ${formatInlineYamlScalar(stage.inline.model)}`);
267
+ }
268
+ else if (stage.uses) {
269
+ lines.push(` uses: ${stage.uses}`);
119
270
  }
120
271
  }
121
272
  lines.push("");
@@ -130,14 +281,21 @@ async function fileExists(filePath) {
130
281
  return false;
131
282
  }
132
283
  }
133
- async function findPipelineIdCollision(cwd, id, targetPath) {
284
+ function resolveDirectory(projectRoot, directory) {
285
+ const absDirectory = path.resolve(projectRoot, directory);
286
+ const rel = path.relative(projectRoot, absDirectory);
287
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
288
+ return null;
289
+ }
290
+ return absDirectory;
291
+ }
292
+ async function findPipelineIdCollision(pipelineDirectory, projectRoot, id, targetPath) {
134
293
  if (await fileExists(targetPath)) {
135
- return path.relative(cwd, targetPath);
294
+ return path.relative(projectRoot, targetPath);
136
295
  }
137
- const dir = path.join(cwd, "pipelines");
138
296
  let entries;
139
297
  try {
140
- entries = await readdir(dir, { withFileTypes: true });
298
+ entries = await readdir(pipelineDirectory, { withFileTypes: true });
141
299
  }
142
300
  catch {
143
301
  return null;
@@ -145,13 +303,13 @@ async function findPipelineIdCollision(cwd, id, targetPath) {
145
303
  for (const entry of entries) {
146
304
  if (!entry.isFile() || !entry.name.endsWith(".yaml"))
147
305
  continue;
148
- const filePath = path.join(dir, entry.name);
306
+ const filePath = path.join(pipelineDirectory, entry.name);
149
307
  if (filePath === targetPath)
150
308
  continue;
151
309
  try {
152
310
  const raw = await readYamlObject(filePath);
153
311
  if (raw?.id === id) {
154
- return path.relative(cwd, filePath);
312
+ return path.relative(projectRoot, filePath);
155
313
  }
156
314
  }
157
315
  catch {
@@ -160,7 +318,7 @@ async function findPipelineIdCollision(cwd, id, targetPath) {
160
318
  }
161
319
  return null;
162
320
  }
163
- async function validateStageReferences(cwd, stages) {
321
+ async function validateStageReferences(pipelineDirectory, stages) {
164
322
  const seen = new Set();
165
323
  for (const stage of stages) {
166
324
  if (seen.has(stage.id)) {
@@ -172,44 +330,77 @@ async function validateStageReferences(cwd, stages) {
172
330
  }
173
331
  seen.add(stage.id);
174
332
  }
175
- const stagesDir = path.join(cwd, "stages");
333
+ const inlineCount = stages.filter((stage) => stage.inline).length;
334
+ if (inlineCount > 0 && stages.length > 1) {
335
+ return {
336
+ ok: false,
337
+ status: 422,
338
+ error: "Inline stage bodies are supported for single-stage pipelines only",
339
+ };
340
+ }
176
341
  for (const stage of stages) {
177
- const stagePath = path.join(stagesDir, `${stage.id}.yaml`);
178
- try {
179
- await loadStage(stagePath);
180
- }
181
- catch {
342
+ if (stage.inline)
343
+ continue;
344
+ const uses = stage.uses ?? defaultUsesPath(stage.id);
345
+ const stagePath = path.resolve(pipelineDirectory, uses);
346
+ if (!(await fileExists(stagePath))) {
182
347
  return {
183
348
  ok: false,
184
349
  status: 422,
185
- error: "one or more selected Stages no longer exist",
350
+ error: `missing stage file for "${stage.id}" at ${uses}`,
186
351
  };
187
352
  }
188
353
  }
189
354
  return { ok: true };
190
355
  }
191
- function buildResolverInput(input, normalized) {
192
- if (input.stages.length > 0 && typeof input.stages[0] === "string") {
193
- return input.stages;
194
- }
195
- return normalized.map((ref) => ref.needs ? { id: ref.id, needs: ref.needs } : { id: ref.id });
196
- }
197
- function usesDagYaml(input, normalized) {
198
- if (input.stages.length > 0 && typeof input.stages[0] !== "string") {
199
- return true;
200
- }
356
+ function usesDagYaml(normalized) {
201
357
  return normalized.some((ref) => ref.needs !== undefined);
202
358
  }
203
- export async function createPipeline(cwd, input) {
204
- const normalized = normalizeCreatePipelineStages(input.stages);
205
- const stageValidation = await validateStageReferences(cwd, normalized);
359
+ function buildPipelineListing(projectRoot, relPath, loaded) {
360
+ return {
361
+ path: relPath,
362
+ id: loaded.pipeline.id,
363
+ stages: loaded.stages.map((stage) => {
364
+ const source = loaded.stageSources?.[stage.id];
365
+ const listing = {
366
+ id: stage.id,
367
+ ...(stage.gate_kinds ? { gate_kinds: stage.gate_kinds } : {}),
368
+ };
369
+ if (source?.kind === "inline") {
370
+ return { ...listing, inline: true };
371
+ }
372
+ if (source?.kind === "file") {
373
+ return {
374
+ ...listing,
375
+ uses_path: path.relative(projectRoot, source.path).replace(/\\/g, "/"),
376
+ };
377
+ }
378
+ return listing;
379
+ }),
380
+ };
381
+ }
382
+ export async function createPipeline(projectRoot, input) {
383
+ const pipelineDirectory = resolveDirectory(projectRoot, input.directory);
384
+ if (!pipelineDirectory) {
385
+ return {
386
+ ok: false,
387
+ status: 400,
388
+ error: "directory must be inside the project root",
389
+ };
390
+ }
391
+ const filePath = path.join(pipelineDirectory, `${input.id}.pipeline.yaml`);
392
+ const relPath = path.relative(projectRoot, filePath).replace(/\\/g, "/");
393
+ const validationCtx = { pipelineId: input.id, path: relPath };
394
+ const normalizedOutcome = normalizeAuthoringStages(input.stages, validationCtx);
395
+ if (!normalizedOutcome.ok) {
396
+ return normalizedOutcome;
397
+ }
398
+ const normalized = normalizedOutcome.stages;
399
+ const stageValidation = await validateStageReferences(pipelineDirectory, normalized);
206
400
  if (!stageValidation.ok) {
207
401
  return stageValidation;
208
402
  }
209
- const pipelinesDir = path.join(cwd, "pipelines");
210
- const filePath = path.join(pipelinesDir, `${input.id}.yaml`);
211
- const relPath = path.relative(cwd, filePath);
212
- const collision = await findPipelineIdCollision(cwd, input.id, filePath);
403
+ const collision = await findPipelineIdCollision(pipelineDirectory, projectRoot, input.id, filePath);
213
404
  if (collision) {
214
405
  return {
215
406
  ok: false,
@@ -217,39 +408,40 @@ export async function createPipeline(cwd, input) {
217
408
  error: `Pipeline id already exists (${collision})`,
218
409
  };
219
410
  }
220
- const resolverInput = buildResolverInput(input, normalized);
221
- try {
222
- resolvePipelineDag(resolverInput, { pipelineId: input.id, path: relPath });
223
- }
224
- catch (err) {
225
- const message = err instanceof Error ? err.message : String(err);
226
- return { ok: false, status: 422, error: message };
227
- }
228
- const yamlFormat = usesDagYaml(input, normalized) ? "dag" : "linear";
411
+ const yamlFormat = usesDagYaml(normalized) ? "dag" : "linear";
229
412
  try {
230
- await mkdir(pipelinesDir, { recursive: true });
413
+ await mkdir(pipelineDirectory, { recursive: true });
231
414
  await writeFile(filePath, pipelineConfigToYaml({ id: input.id, stages: normalized }, { format: yamlFormat }), "utf8");
232
415
  }
233
416
  catch (err) {
234
417
  const message = err instanceof Error ? err.message : String(err);
235
418
  return { ok: false, status: 500, error: message };
236
419
  }
237
- try {
238
- const loaded = await loadPipeline(filePath, { cwd });
420
+ const validation = await validatePipeline(relPath, {
421
+ cwd: projectRoot,
422
+ validateStages: true,
423
+ strict: true,
424
+ });
425
+ if (!validation.ok) {
426
+ await unlink(filePath).catch(() => { });
427
+ const errorFinding = validation.findings.find((finding) => finding.severity === "error");
239
428
  return {
240
- ok: true,
241
- pipeline: {
242
- path: relPath,
243
- id: loaded.pipeline.id,
244
- stages: loaded.stages.map((stage) => ({
245
- id: stage.id,
246
- ...(stage.gate_kinds ? { gate_kinds: stage.gate_kinds } : {}),
247
- })),
248
- },
429
+ ok: false,
430
+ status: 422,
431
+ error: errorFinding?.message ?? "Pipeline validation failed",
249
432
  };
250
433
  }
251
- catch (err) {
252
- const message = err instanceof Error ? err.message : String(err);
253
- return { ok: false, status: 500, error: message };
434
+ const loadOutcome = await loadPipelineOutcome(relPath, { cwd: projectRoot });
435
+ if (!loadOutcome.ok) {
436
+ await unlink(filePath).catch(() => { });
437
+ return {
438
+ ok: false,
439
+ status: 500,
440
+ error: loadOutcome.issues[0]?.message ?? "Failed to load created pipeline",
441
+ };
254
442
  }
443
+ return {
444
+ ok: true,
445
+ pipeline: buildPipelineListing(projectRoot, relPath, loadOutcome.value),
446
+ };
255
447
  }
@@ -1,12 +1,18 @@
1
1
  import { type StageGateKind } from "../types/stage.js";
2
- import type { ValidStageListing } from "./listConfig.js";
3
2
  export declare const STAGE_ID_PATTERN: RegExp;
4
3
  export type CreateStageInput = {
4
+ pipeline_directory: string;
5
+ filename: string;
5
6
  id: string;
6
7
  system_prompt: string;
7
8
  model: string;
8
9
  gate_kinds?: StageGateKind[];
9
10
  };
11
+ export type CreatedStageListing = {
12
+ path: string;
13
+ id: string;
14
+ gate_kinds?: StageGateKind[];
15
+ };
10
16
  export type CreateStageParseError = {
11
17
  ok: false;
12
18
  status: 400;
@@ -14,12 +20,12 @@ export type CreateStageParseError = {
14
20
  };
15
21
  export type CreateStageResult = {
16
22
  ok: true;
17
- stage: ValidStageListing;
23
+ stage: CreatedStageListing;
18
24
  } | {
19
25
  ok: false;
20
26
  status: 400 | 409 | 500;
21
27
  error: string;
22
28
  };
23
29
  export declare function parseCreateStageBody(body: unknown): CreateStageInput | CreateStageParseError;
24
- export declare function stageConfigToYaml(stage: CreateStageInput): string;
25
- export declare function createStage(cwd: string, input: CreateStageInput): Promise<CreateStageResult>;
30
+ export declare function stageConfigToYaml(stage: Omit<CreateStageInput, "pipeline_directory" | "filename">): string;
31
+ export declare function createStage(projectRoot: string, input: CreateStageInput): Promise<CreateStageResult>;
@@ -43,6 +43,16 @@ export function parseCreateStageBody(body) {
43
43
  if ("skill" in body) {
44
44
  return { ok: false, status: 400, error: "skill is not supported" };
45
45
  }
46
+ if (typeof body.pipeline_directory !== "string" || !body.pipeline_directory.trim()) {
47
+ return { ok: false, status: 400, error: "pipeline_directory is required" };
48
+ }
49
+ if (typeof body.filename !== "string" || !body.filename.trim()) {
50
+ return { ok: false, status: 400, error: "filename is required" };
51
+ }
52
+ const filename = body.filename.trim().replace(/\\/g, "/");
53
+ if (!filename.endsWith(".yaml") && !filename.endsWith(".yml")) {
54
+ return { ok: false, status: 400, error: "filename must end with .yaml" };
55
+ }
46
56
  if (typeof body.id !== "string") {
47
57
  return { ok: false, status: 400, error: "id is required" };
48
58
  }
@@ -65,6 +75,8 @@ export function parseCreateStageBody(body) {
65
75
  };
66
76
  }
67
77
  return {
78
+ pipeline_directory: body.pipeline_directory.trim().replace(/\\/g, "/"),
79
+ filename,
68
80
  id: body.id,
69
81
  system_prompt: body.system_prompt,
70
82
  model: body.model,
@@ -112,14 +124,21 @@ async function fileExists(filePath) {
112
124
  return false;
113
125
  }
114
126
  }
115
- async function findStageIdCollision(cwd, id, targetPath) {
127
+ function resolvePipelineDirectory(projectRoot, pipelineDirectory) {
128
+ const absDirectory = path.resolve(projectRoot, pipelineDirectory);
129
+ const rel = path.relative(projectRoot, absDirectory);
130
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
131
+ return null;
132
+ }
133
+ return absDirectory;
134
+ }
135
+ async function findStageIdCollision(pipelineDirectory, projectRoot, id, targetPath) {
116
136
  if (await fileExists(targetPath)) {
117
- return path.relative(cwd, targetPath);
137
+ return path.relative(projectRoot, targetPath);
118
138
  }
119
- const dir = path.join(cwd, "stages");
120
139
  let entries;
121
140
  try {
122
- entries = await readdir(dir, { withFileTypes: true });
141
+ entries = await readdir(pipelineDirectory, { withFileTypes: true });
123
142
  }
124
143
  catch {
125
144
  return null;
@@ -127,13 +146,13 @@ async function findStageIdCollision(cwd, id, targetPath) {
127
146
  for (const entry of entries) {
128
147
  if (!entry.isFile() || !entry.name.endsWith(".yaml"))
129
148
  continue;
130
- const filePath = path.join(dir, entry.name);
149
+ const filePath = path.join(pipelineDirectory, entry.name);
131
150
  if (filePath === targetPath)
132
151
  continue;
133
152
  try {
134
153
  const raw = await readYamlObject(filePath);
135
154
  if (raw?.id === id) {
136
- return path.relative(cwd, filePath);
155
+ return path.relative(projectRoot, filePath);
137
156
  }
138
157
  }
139
158
  catch {
@@ -142,11 +161,18 @@ async function findStageIdCollision(cwd, id, targetPath) {
142
161
  }
143
162
  return null;
144
163
  }
145
- export async function createStage(cwd, input) {
146
- const stagesDir = path.join(cwd, "stages");
147
- const filePath = path.join(stagesDir, `${input.id}.yaml`);
148
- const relPath = path.relative(cwd, filePath);
149
- const collision = await findStageIdCollision(cwd, input.id, filePath);
164
+ export async function createStage(projectRoot, input) {
165
+ const pipelineDirectory = resolvePipelineDirectory(projectRoot, input.pipeline_directory);
166
+ if (!pipelineDirectory) {
167
+ return {
168
+ ok: false,
169
+ status: 400,
170
+ error: "pipeline_directory must be inside the project root",
171
+ };
172
+ }
173
+ const filePath = path.join(pipelineDirectory, path.basename(input.filename));
174
+ const relPath = path.relative(projectRoot, filePath).replace(/\\/g, "/");
175
+ const collision = await findStageIdCollision(pipelineDirectory, projectRoot, input.id, filePath);
150
176
  if (collision) {
151
177
  return {
152
178
  ok: false,
@@ -155,8 +181,13 @@ export async function createStage(cwd, input) {
155
181
  };
156
182
  }
157
183
  try {
158
- await mkdir(stagesDir, { recursive: true });
159
- await writeFile(filePath, stageConfigToYaml(input), "utf8");
184
+ await mkdir(pipelineDirectory, { recursive: true });
185
+ await writeFile(filePath, stageConfigToYaml({
186
+ id: input.id,
187
+ system_prompt: input.system_prompt,
188
+ model: input.model,
189
+ ...(input.gate_kinds ? { gate_kinds: input.gate_kinds } : {}),
190
+ }), "utf8");
160
191
  }
161
192
  catch (err) {
162
193
  const message = err instanceof Error ? err.message : String(err);
@@ -169,7 +200,6 @@ export async function createStage(cwd, input) {
169
200
  stage: {
170
201
  path: relPath,
171
202
  id: stage.id,
172
- used_by_pipeline_ids: [],
173
203
  ...(stage.gate_kinds ? { gate_kinds: stage.gate_kinds } : {}),
174
204
  },
175
205
  };