stageflow 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -4
- package/dist/agent/claudeTools.d.ts +1 -4
- package/dist/agent/claudeTools.js +1 -6
- package/dist/agent/fakeAgent.js +4 -5
- package/dist/agent/piAdapter.js +1 -14
- package/dist/config/cloneChain.d.ts +12 -0
- package/dist/config/cloneChain.js +322 -0
- package/dist/config/createPipeline.js +101 -21
- package/dist/config/createStage.js +7 -0
- package/dist/config/loadOutcome.d.ts +1 -0
- package/dist/config/loadPipeline.d.ts +2 -0
- package/dist/config/loadPipeline.js +34 -22
- package/dist/config/loadStage.d.ts +2 -0
- package/dist/config/loadStage.js +135 -125
- package/dist/config/migrateYaml.js +14 -3
- package/dist/config/normalizePipelineStageEntry.d.ts +5 -9
- package/dist/config/normalizePipelineStageEntry.js +133 -60
- package/dist/config/parsePreEmitChecks.js +2 -2
- package/dist/config/pipelineNeeds.d.ts +15 -2
- package/dist/config/pipelineNeeds.js +86 -4
- package/dist/config/pipelineRoute.d.ts +46 -0
- package/dist/config/pipelineRoute.js +215 -0
- package/dist/config/pipelineStageKeys.d.ts +2 -0
- package/dist/config/pipelineStageKeys.js +15 -4
- package/dist/config/printTargetYaml.js +18 -12
- package/dist/config/resolveForkEmitContext.d.ts +1 -1
- package/dist/config/resolveForkEmitContext.js +3 -26
- package/dist/config/resolvePipelineDag.d.ts +1 -3
- package/dist/config/resolvePipelineDag.js +143 -186
- package/dist/config/routeIf.d.ts +24 -0
- package/dist/config/routeIf.js +247 -0
- package/dist/config/validateCatalog.d.ts +1 -1
- package/dist/config/validateCatalog.js +5 -3
- package/dist/config/yamlDialect.d.ts +5 -8
- package/dist/config/yamlDialect.js +37 -29
- package/dist/envelope/check.d.ts +0 -2
- package/dist/envelope/check.js +1 -57
- package/dist/envelope/feedbackLoop.js +0 -3
- package/dist/envelope/forkChoice.js +1 -1
- package/dist/envelope/payloadSchema.d.ts +14 -0
- package/dist/envelope/payloadSchema.js +87 -2
- package/dist/mcp/catalogTools.js +1 -2
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/projection/projectRun.d.ts +0 -2
- package/dist/projection/projectRun.js +0 -3
- package/dist/prompt/priorEnvelope.js +1 -1
- package/dist/runstore/stageInstanceId.d.ts +1 -0
- package/dist/runstore/stageInstanceId.js +9 -0
- package/dist/runtime/cloneSchedule.d.ts +16 -33
- package/dist/runtime/cloneSchedule.js +155 -236
- package/dist/runtime/envelopeRouting.js +43 -94
- package/dist/runtime/forkGeneration.d.ts +2 -2
- package/dist/runtime/forkGeneration.js +29 -15
- package/dist/runtime/joinReadiness.d.ts +16 -0
- package/dist/runtime/joinReadiness.js +156 -0
- package/dist/runtime/pipelineScheduler.d.ts +0 -3
- package/dist/runtime/pipelineScheduler.js +81 -65
- package/dist/runtime/routeIfEval.d.ts +3 -0
- package/dist/runtime/routeIfEval.js +94 -0
- package/dist/runtime/runRetryCoordinator.js +0 -4
- package/dist/runtime/stageAttemptBootstrap.js +60 -11
- package/dist/runtime/taskInput.js +1 -13
- package/dist/tools/emitStageEnvelope.d.ts +2 -3
- package/dist/tools/emitStageEnvelope.js +1 -65
- package/dist/types/envelope.d.ts +0 -2
- package/dist/types/forkChoice.d.ts +0 -16
- package/dist/types/pipeline.d.ts +86 -27
- package/dist/types/stage.d.ts +11 -7
- package/dist/ui/assets/{index-D2Wt8hwH.css → index-DsgVjLUS.css} +1 -1
- package/dist/ui/assets/{index-XWJovnp7.js → index-DwKGaQvQ.js} +13 -12
- package/dist/ui/index.html +2 -2
- package/package.json +2 -1
- package/skills/stageflow-author/SKILL.md +4 -4
- package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -2
- package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -4
- package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +6 -4
- package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +9 -1
- package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +3 -0
- package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +8 -0
- package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +5 -2
- package/skills/stageflow-author/assets/examples/linear-review/review.yaml +8 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +3 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +5 -2
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +8 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +8 -0
- package/skills/stageflow-author/references/catalog-mapping.md +159 -50
- package/skills/stageflow-author/references/catalog-write-conventions.md +3 -3
- package/skills/stageflow-author/references/stage-prompt-template.md +57 -17
- package/skills/stageflow-session-capture/SKILL.md +2 -2
- package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +3 -1
- package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +15 -0
- package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +3 -0
- package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -2
- package/dist/envelope/cloneForks.d.ts +0 -2
- package/dist/envelope/cloneForks.js +0 -77
package/README.md
CHANGED
|
@@ -31,13 +31,12 @@ The same pipeline runs three ways without rewriting anything:
|
|
|
31
31
|
- **Path-based CLI** — `--pipeline` and `--task` take filesystem paths (no bare-id lookup)
|
|
32
32
|
- **Pi-native** — runs on `@earendil-works/pi-coding-agent`; reuse an existing Pi login (`pi_home`) or store credentials in `~/.stageflow/agent/auth.json` (`sf_owned`)
|
|
33
33
|
- **Envelope handoffs** — typed stage payloads and artifacts via `write_stage_artifact` / `emit_stage_envelope`
|
|
34
|
-
- **Verified stage execution** —
|
|
34
|
+
- **Verified stage execution** — after-phase `verify` checks with durable evidence, plus `on_verify_fail` repair or manual operator recovery ([guide](docs/verified-stage-execution.md))
|
|
35
35
|
- **HITL gates** — operator questions in the console; CI exits `2` when a run is waiting
|
|
36
36
|
- **Operator console** — triage runs, connect providers, answer gates, inspect transcripts at `http://127.0.0.1:3847`
|
|
37
37
|
- **MCP endpoint** — Streamable HTTP at `/mcp` when `sf ui` or `sf mcp` is running
|
|
38
38
|
- **CI / headless** — `sf validate --strict --json`, `sf run --json` with exit codes `0` / `1` / `2`
|
|
39
|
-
- **Parallel stages** — pipeline DAG with fan-out and
|
|
40
|
-
- **Clonable fan-out** — clone one successor N times at completion, then join (see [YAML catalog](docs/yaml-catalog.md#clonable-successors))
|
|
39
|
+
- **Parallel stages** — pipeline DAG with fan-out, join, and Clone Chains (one Clone Instance per Clone Array element; see [YAML catalog](docs/yaml-catalog.md#clone-chain))
|
|
41
40
|
- **SQLite run store** — `<git-root>/.stageflow/` state plus per-run workspaces under `.stageflow/runs/`
|
|
42
41
|
|
|
43
42
|
## Architecture at a glance
|
|
@@ -219,7 +218,6 @@ If you want deterministic YAML routing across many agents, look at [Conductor](h
|
|
|
219
218
|
| [hello-world](examples/hello-world/) | Single stage, domain-neutral |
|
|
220
219
|
| [plan-review](examples/plan-review/) | Multi-stage with operator gate — SDLC-style **example** |
|
|
221
220
|
| [conditional-fork](examples/conditional-fork/) | Exclusive fork routing with operator branch choice |
|
|
222
|
-
| [clonable-fanout](examples/clonable-fanout/) | Clone one successor N times, then join |
|
|
223
221
|
| [stage-mcp](examples/stage-mcp/) | Stage MCP via project `.mcp.json` and a local echo fixture |
|
|
224
222
|
| [playwright-mcp](examples/playwright-mcp/) | One stage: open a page and save a PNG screenshot (Playwright MCP) |
|
|
225
223
|
| [context7-mcp](examples/context7-mcp/) | Three stages: resolve a library, fetch docs, write a brief (Context7 MCP) |
|
|
@@ -45,10 +45,8 @@ export type AskOperatorCapture = {
|
|
|
45
45
|
* nothing else catches one drifting from the other.
|
|
46
46
|
*
|
|
47
47
|
* Field presence mirrors the typebox schema's own conditionality:
|
|
48
|
-
* `clone_forks` only exists when a stage can clone-fork at all, and
|
|
49
48
|
* `fork_choice` is required (not just present) when the stage has a fork
|
|
50
|
-
* to route.
|
|
51
|
-
* `clone_forks` field on stages that have nowhere to route one.
|
|
49
|
+
* to route.
|
|
52
50
|
*/
|
|
53
51
|
export declare function buildEmitStageEnvelopeShape(options: {
|
|
54
52
|
forkEmitContext?: ForkEmitContext;
|
|
@@ -62,7 +60,6 @@ export declare function buildEmitStageEnvelopeShape(options: {
|
|
|
62
60
|
artifacts: z.ZodArray<z.ZodString>;
|
|
63
61
|
payload: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
64
62
|
fork_choice: z.ZodArray<z.ZodString> | z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
65
|
-
clone_forks?: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>> | undefined;
|
|
66
63
|
checklist_attestations: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
67
64
|
check_id: z.ZodString;
|
|
68
65
|
items: z.ZodArray<z.ZodString>;
|
|
@@ -40,10 +40,8 @@ export const ASK_OPERATOR_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__ask_op
|
|
|
40
40
|
* nothing else catches one drifting from the other.
|
|
41
41
|
*
|
|
42
42
|
* Field presence mirrors the typebox schema's own conditionality:
|
|
43
|
-
* `clone_forks` only exists when a stage can clone-fork at all, and
|
|
44
43
|
* `fork_choice` is required (not just present) when the stage has a fork
|
|
45
|
-
* to route.
|
|
46
|
-
* `clone_forks` field on stages that have nowhere to route one.
|
|
44
|
+
* to route.
|
|
47
45
|
*/
|
|
48
46
|
export function buildEmitStageEnvelopeShape(options) {
|
|
49
47
|
return {
|
|
@@ -54,9 +52,6 @@ export function buildEmitStageEnvelopeShape(options) {
|
|
|
54
52
|
fork_choice: options.forkEmitContext !== undefined
|
|
55
53
|
? z.array(z.string())
|
|
56
54
|
: z.array(z.string()).optional(),
|
|
57
|
-
...(options.cloneEmitContext !== undefined
|
|
58
|
-
? { clone_forks: z.array(z.record(z.string(), z.unknown())) }
|
|
59
|
-
: {}),
|
|
60
55
|
checklist_attestations: z
|
|
61
56
|
.array(z.object({
|
|
62
57
|
check_id: z.string().min(1),
|
package/dist/agent/fakeAgent.js
CHANGED
|
@@ -3,7 +3,6 @@ import path from "node:path";
|
|
|
3
3
|
import { assertRequiredEnvelope, isAdvancingEnvelope, } from "../envelope/check.js";
|
|
4
4
|
import { assertFeedbackLoopAction } from "../envelope/feedbackLoop.js";
|
|
5
5
|
import { assertEnvelopePayload } from "../envelope/payloadSchema.js";
|
|
6
|
-
import { assertCloneForks } from "../envelope/cloneForks.js";
|
|
7
6
|
import { assertForkEnvelope } from "../envelope/forkChoice.js";
|
|
8
7
|
import { assertAnswerMatchesPrompt, parseAskOperatorAnswer, } from "../tools/askOperator.js";
|
|
9
8
|
import { tryParsePendingPrompt } from "../runtime/stageHitl.js";
|
|
@@ -154,15 +153,15 @@ export class FakeAgent {
|
|
|
154
153
|
? behavior.envelope
|
|
155
154
|
: undefined;
|
|
156
155
|
try {
|
|
157
|
-
|
|
156
|
+
let envelope = assertRequiredEnvelope(envelopeValue);
|
|
157
|
+
if (envelope.status === "success" && envelope.payload === undefined) {
|
|
158
|
+
envelope = { ...envelope, payload: {} };
|
|
159
|
+
}
|
|
158
160
|
assertFeedbackLoopAction(envelope, input.feedbackLoopEmitContext);
|
|
159
161
|
const isFeedbackSendBack = envelope.feedback_loop?.action === "send_back";
|
|
160
162
|
if (!isFeedbackSendBack && input.forkEmitContext !== undefined) {
|
|
161
163
|
assertForkEnvelope(envelope, input.forkEmitContext);
|
|
162
164
|
}
|
|
163
|
-
if (!isFeedbackSendBack && input.cloneEmitContext !== undefined) {
|
|
164
|
-
assertCloneForks(envelope, input.cloneEmitContext);
|
|
165
|
-
}
|
|
166
165
|
assertEnvelopePayload(envelope, input.stage.payload_schema);
|
|
167
166
|
if (!isAdvancingEnvelope(envelope)) {
|
|
168
167
|
input.onActivity?.({ event: "agent_end" });
|
package/dist/agent/piAdapter.js
CHANGED
|
@@ -404,23 +404,10 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
|
|
|
404
404
|
emitHint += `\nFailed-check evidence from attempt ${input.repairContext.prior_attempt}:\n${JSON.stringify(input.repairContext.failed_checks, null, 2)}`;
|
|
405
405
|
}
|
|
406
406
|
}
|
|
407
|
-
if (input.cloneEmitContext !== undefined) {
|
|
408
|
-
const allowedActions = input.cloneEmitContext.allowedActions ?? ["skip", "once", "fanout"];
|
|
409
|
-
const successorLines = input.cloneEmitContext.clonableSuccessors.map((successor) => {
|
|
410
|
-
const header = `- ${successor.successorId} (clone_cap: ${successor.cloneCap})`;
|
|
411
|
-
if (successor.cloneInputSchema === undefined) {
|
|
412
|
-
return header;
|
|
413
|
-
}
|
|
414
|
-
return `${header}\n Assignment schema (io.input.schema):\n${JSON.stringify(successor.cloneInputSchema, null, 2)}`;
|
|
415
|
-
});
|
|
416
|
-
emitHint += `\nClonable successors — emit clone_forks covering each of these ids exactly once:\n${successorLines.join("\n")}`;
|
|
417
|
-
emitHint += `\nEach once or fanout envelope is a full StageEnvelope — it requires status ("success" or "failure"), summary (non-empty string), and artifacts (array of strings). io.input.schema fields belong in envelope.payload, not at the top level of the clone_forks item.`;
|
|
418
|
-
emitHint += `\nAllowed clone actions: ${allowedActions.join(", ")}`;
|
|
419
|
-
}
|
|
420
407
|
if (input.feedbackLoopEmitContext !== undefined) {
|
|
421
408
|
emitHint += `\nFeedback-loop decision: this stage is a feedback-loop source. On status=success, feedback_loop is required. Emit either {"action":"continue"} to proceed downstream or {"action":"send_back","target":"…"} to request another pass.`;
|
|
422
409
|
emitHint += `\nAllowed send_back target: ${input.feedbackLoopEmitContext.target}.`;
|
|
423
|
-
emitHint += `\nA send_back decision cannot be combined with fork_choice
|
|
410
|
+
emitHint += `\nA send_back decision cannot be combined with fork_choice.`;
|
|
424
411
|
}
|
|
425
412
|
const attempt = input.roots.attempt ?? 1;
|
|
426
413
|
const attemptArtifactsPath = `stages/${runtimeStageId(input)}/attempts/${attempt}/artifacts/`;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PipelineStageRef, ResolvedPipelineDag } from "../types/pipeline.js";
|
|
2
|
+
import type { StageConfig } from "../types/stage.js";
|
|
3
|
+
import { type LoadOutcome } from "./loadOutcome.js";
|
|
4
|
+
export type DetectedCloneChain = {
|
|
5
|
+
emitterId: string;
|
|
6
|
+
cloneChildId: string;
|
|
7
|
+
joinId: string;
|
|
8
|
+
arrayField: string;
|
|
9
|
+
ref: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function detectCloneChains(stages: StageConfig[], refs: PipelineStageRef[], pipelineId: string): LoadOutcome<DetectedCloneChain[]>;
|
|
12
|
+
export declare function applyCloneChains(stages: StageConfig[], refs: PipelineStageRef[], dag: ResolvedPipelineDag, pipelineId: string): LoadOutcome<void>;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { loadFailure, loadSuccess } from "./loadOutcome.js";
|
|
2
|
+
import { toRouteEdges, toRouteLoopEntries } from "./pipelineRoute.js";
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function namedSchemaRef(schema) {
|
|
7
|
+
if (!isPlainObject(schema))
|
|
8
|
+
return undefined;
|
|
9
|
+
const keys = Object.keys(schema);
|
|
10
|
+
if (keys.length !== 1 || keys[0] !== "$ref")
|
|
11
|
+
return undefined;
|
|
12
|
+
const ref = schema.$ref;
|
|
13
|
+
if (typeof ref !== "string" || !ref.startsWith("#/schemas/"))
|
|
14
|
+
return undefined;
|
|
15
|
+
const name = ref.slice("#/schemas/".length);
|
|
16
|
+
if (!name || name.includes("/"))
|
|
17
|
+
return undefined;
|
|
18
|
+
return ref;
|
|
19
|
+
}
|
|
20
|
+
function inspectOutput(schema) {
|
|
21
|
+
const empty = {
|
|
22
|
+
namedRefArrays: [],
|
|
23
|
+
nonRefArrayFields: [],
|
|
24
|
+
isRootArray: false,
|
|
25
|
+
rootArrayRef: undefined,
|
|
26
|
+
};
|
|
27
|
+
if (!isPlainObject(schema))
|
|
28
|
+
return empty;
|
|
29
|
+
if (schema.type === "array") {
|
|
30
|
+
const ref = namedSchemaRef(schema.items);
|
|
31
|
+
return {
|
|
32
|
+
namedRefArrays: [],
|
|
33
|
+
nonRefArrayFields: ref === undefined ? ["<root>"] : [],
|
|
34
|
+
isRootArray: true,
|
|
35
|
+
rootArrayRef: ref,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (schema.type !== "object" || !isPlainObject(schema.properties))
|
|
39
|
+
return empty;
|
|
40
|
+
const namedRefArrays = [];
|
|
41
|
+
const nonRefArrayFields = [];
|
|
42
|
+
for (const [field, prop] of Object.entries(schema.properties)) {
|
|
43
|
+
if (!isPlainObject(prop) || prop.type !== "array")
|
|
44
|
+
continue;
|
|
45
|
+
const ref = namedSchemaRef(prop.items);
|
|
46
|
+
if (ref !== undefined)
|
|
47
|
+
namedRefArrays.push({ field, ref });
|
|
48
|
+
else
|
|
49
|
+
nonRefArrayFields.push(field);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
namedRefArrays,
|
|
53
|
+
nonRefArrayFields,
|
|
54
|
+
isRootArray: false,
|
|
55
|
+
rootArrayRef: undefined,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function inboundSources(refs, targetId) {
|
|
59
|
+
return refs.filter((ref) => toRouteEdges(ref.route).some((edge) => edge.to === targetId));
|
|
60
|
+
}
|
|
61
|
+
function isSealedThreeStagePath(refs, stageById, refById, emitter, childId) {
|
|
62
|
+
const edges = toRouteEdges(emitter.route);
|
|
63
|
+
if (edges.length !== 1)
|
|
64
|
+
return false;
|
|
65
|
+
const childEdge = edges[0];
|
|
66
|
+
if (childEdge.to !== childId || childEdge.if !== undefined)
|
|
67
|
+
return false;
|
|
68
|
+
const childStage = stageById.get(childId);
|
|
69
|
+
const childRef = refById.get(childId);
|
|
70
|
+
if (!childStage || !childRef)
|
|
71
|
+
return false;
|
|
72
|
+
if (inboundSources(refs, childId).length !== 1)
|
|
73
|
+
return false;
|
|
74
|
+
const childEdges = toRouteEdges(childRef.route);
|
|
75
|
+
if (childEdges.length !== 1 || childEdges[0].if !== undefined)
|
|
76
|
+
return false;
|
|
77
|
+
const joinId = childEdges[0].to;
|
|
78
|
+
if (!stageById.has(joinId))
|
|
79
|
+
return false;
|
|
80
|
+
if (inboundSources(refs, joinId).length !== 1)
|
|
81
|
+
return false;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
function dagError(pipelineId, message, stageId) {
|
|
85
|
+
return {
|
|
86
|
+
code: "pipeline.dag_error",
|
|
87
|
+
message: `Pipeline ${pipelineId}: ${message}`,
|
|
88
|
+
category: "pipeline",
|
|
89
|
+
pipelineId,
|
|
90
|
+
...(stageId !== undefined ? { stageId } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function fail(pipelineId, message, stageId) {
|
|
94
|
+
return loadFailure([dagError(pipelineId, message, stageId)]);
|
|
95
|
+
}
|
|
96
|
+
function childOutputIsCloneArray(schema) {
|
|
97
|
+
const inspected = inspectOutput(schema);
|
|
98
|
+
return inspected.namedRefArrays.length > 0 || inspected.rootArrayRef !== undefined;
|
|
99
|
+
}
|
|
100
|
+
export function detectCloneChains(stages, refs, pipelineId) {
|
|
101
|
+
const stageById = new Map(stages.map((stage) => [stage.id, stage]));
|
|
102
|
+
const refById = new Map(refs.map((ref) => [ref.id, ref]));
|
|
103
|
+
const chains = [];
|
|
104
|
+
for (const emitter of refs) {
|
|
105
|
+
const emitterStage = stageById.get(emitter.id);
|
|
106
|
+
if (!emitterStage)
|
|
107
|
+
continue;
|
|
108
|
+
const edges = toRouteEdges(emitter.route);
|
|
109
|
+
const output = inspectOutput(emitterStage.payload_schema);
|
|
110
|
+
if (output.namedRefArrays.length >= 2 && edges.length > 0) {
|
|
111
|
+
return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter output must have exactly one named $ref Clone Array`, emitter.id);
|
|
112
|
+
}
|
|
113
|
+
if (output.isRootArray && edges.length > 0) {
|
|
114
|
+
const looksLikeChain = edges.some((edge) => {
|
|
115
|
+
const childStage = stageById.get(edge.to);
|
|
116
|
+
if (!childStage)
|
|
117
|
+
return false;
|
|
118
|
+
if (namedSchemaRef(childStage.clone_input_schema) === undefined)
|
|
119
|
+
return false;
|
|
120
|
+
return isSealedThreeStagePath(refs, stageById, refById, emitter, edge.to);
|
|
121
|
+
});
|
|
122
|
+
if (looksLikeChain) {
|
|
123
|
+
return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter output is a root-level array, which is not a Clone Array`, emitter.id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (output.namedRefArrays.length === 0 &&
|
|
127
|
+
output.nonRefArrayFields.length > 0 &&
|
|
128
|
+
edges.length > 0) {
|
|
129
|
+
for (const edge of edges) {
|
|
130
|
+
const childStage = stageById.get(edge.to);
|
|
131
|
+
const childRef = refById.get(edge.to);
|
|
132
|
+
if (!childStage || !childRef)
|
|
133
|
+
continue;
|
|
134
|
+
if (namedSchemaRef(childStage.clone_input_schema) === undefined)
|
|
135
|
+
continue;
|
|
136
|
+
if (toRouteEdges(childRef.route).length === 0)
|
|
137
|
+
continue;
|
|
138
|
+
return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter Clone Array items must be a named $ref`, emitter.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (output.namedRefArrays.length !== 1 || edges.length === 0)
|
|
142
|
+
continue;
|
|
143
|
+
const array = output.namedRefArrays[0];
|
|
144
|
+
const childTakesNamedRef = edges.some((edge) => {
|
|
145
|
+
const childStage = stageById.get(edge.to);
|
|
146
|
+
return (childStage !== undefined &&
|
|
147
|
+
namedSchemaRef(childStage.clone_input_schema) !== undefined);
|
|
148
|
+
});
|
|
149
|
+
const hasClonePolicy = emitter.clone_cap !== undefined || emitter.clone_mode !== undefined;
|
|
150
|
+
const sealedLookalike = edges.some((edge) => isSealedThreeStagePath(refs, stageById, refById, emitter, edge.to));
|
|
151
|
+
if (!childTakesNamedRef && !hasClonePolicy && !sealedLookalike) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const matchingChildIds = edges
|
|
155
|
+
.filter((edge) => {
|
|
156
|
+
const childStage = stageById.get(edge.to);
|
|
157
|
+
return (childStage !== undefined &&
|
|
158
|
+
namedSchemaRef(childStage.clone_input_schema) === array.ref);
|
|
159
|
+
})
|
|
160
|
+
.map((edge) => edge.to);
|
|
161
|
+
const uniqueMatching = [...new Set(matchingChildIds)];
|
|
162
|
+
if (edges.length !== 1) {
|
|
163
|
+
if (uniqueMatching.length >= 2) {
|
|
164
|
+
return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter must have exactly one clone child`, emitter.id);
|
|
165
|
+
}
|
|
166
|
+
return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter must have exactly one forward Route (the clone child)`, emitter.id);
|
|
167
|
+
}
|
|
168
|
+
const childEdge = edges[0];
|
|
169
|
+
const cloneChildId = childEdge.to;
|
|
170
|
+
if (childEdge.if !== undefined) {
|
|
171
|
+
return fail(pipelineId, `stage "${emitter.id}": Clone Chain inbound edge to the clone child must not have if`, emitter.id);
|
|
172
|
+
}
|
|
173
|
+
const childStage = stageById.get(cloneChildId);
|
|
174
|
+
const childRef = refById.get(cloneChildId);
|
|
175
|
+
if (!childStage || !childRef) {
|
|
176
|
+
return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter must have exactly one forward Route (the clone child)`, emitter.id);
|
|
177
|
+
}
|
|
178
|
+
if (namedSchemaRef(childStage.clone_input_schema) !== array.ref) {
|
|
179
|
+
return fail(pipelineId, `stage "${cloneChildId}": Clone Chain clone child input must be exactly the named $ref of the emitter Clone Array`, cloneChildId);
|
|
180
|
+
}
|
|
181
|
+
if (inboundSources(refs, cloneChildId).length !== 1) {
|
|
182
|
+
return fail(pipelineId, `stage "${cloneChildId}": Clone Chain clone child must have exactly one parent (the emitter)`, cloneChildId);
|
|
183
|
+
}
|
|
184
|
+
const childEdges = toRouteEdges(childRef.route);
|
|
185
|
+
if (childEdges.length !== 1) {
|
|
186
|
+
return fail(pipelineId, `stage "${cloneChildId}": Clone Chain clone child must have exactly one forward Route (the Join)`, cloneChildId);
|
|
187
|
+
}
|
|
188
|
+
if (childEdges[0].if !== undefined) {
|
|
189
|
+
return fail(pipelineId, `stage "${cloneChildId}": Clone Chain inbound edge to the Join must not have if`, cloneChildId);
|
|
190
|
+
}
|
|
191
|
+
const joinId = childEdges[0].to;
|
|
192
|
+
if (!stageById.has(joinId)) {
|
|
193
|
+
return fail(pipelineId, `stage "${cloneChildId}": Clone Chain clone child must have exactly one forward Route (the Join)`, cloneChildId);
|
|
194
|
+
}
|
|
195
|
+
if (inboundSources(refs, joinId).length !== 1) {
|
|
196
|
+
return fail(pipelineId, `stage "${joinId}": Clone Chain Join must have exactly one parent (the clone child)`, joinId);
|
|
197
|
+
}
|
|
198
|
+
if (childOutputIsCloneArray(childStage.payload_schema)) {
|
|
199
|
+
return fail(pipelineId, `stage "${cloneChildId}": Clone Chain clone child output must not be a Clone Array`, cloneChildId);
|
|
200
|
+
}
|
|
201
|
+
chains.push({
|
|
202
|
+
emitterId: emitter.id,
|
|
203
|
+
cloneChildId,
|
|
204
|
+
joinId,
|
|
205
|
+
arrayField: array.field,
|
|
206
|
+
ref: array.ref,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const emitterIds = new Set();
|
|
210
|
+
const cloneChildIds = new Set();
|
|
211
|
+
const joinIds = new Set();
|
|
212
|
+
for (const chain of chains) {
|
|
213
|
+
if (emitterIds.has(chain.emitterId)) {
|
|
214
|
+
return fail(pipelineId, `stage "${chain.emitterId}": Clone Chain emitter is shared with another Clone Chain`, chain.emitterId);
|
|
215
|
+
}
|
|
216
|
+
if (cloneChildIds.has(chain.cloneChildId)) {
|
|
217
|
+
return fail(pipelineId, `stage "${chain.cloneChildId}": Clone Chain clone child is shared with another Clone Chain`, chain.cloneChildId);
|
|
218
|
+
}
|
|
219
|
+
if (joinIds.has(chain.joinId)) {
|
|
220
|
+
return fail(pipelineId, `stage "${chain.joinId}": Clone Chain Join is shared with another Clone Chain`, chain.joinId);
|
|
221
|
+
}
|
|
222
|
+
emitterIds.add(chain.emitterId);
|
|
223
|
+
cloneChildIds.add(chain.cloneChildId);
|
|
224
|
+
joinIds.add(chain.joinId);
|
|
225
|
+
}
|
|
226
|
+
return loadSuccess(chains);
|
|
227
|
+
}
|
|
228
|
+
function validateCloneChainLoops(refs, chains, pipelineId) {
|
|
229
|
+
if (chains.length === 0)
|
|
230
|
+
return loadSuccess(undefined);
|
|
231
|
+
const emitterIds = new Set(chains.map((chain) => chain.emitterId));
|
|
232
|
+
const cloneChildIds = new Set(chains.map((chain) => chain.cloneChildId));
|
|
233
|
+
const forbiddenTargets = new Map();
|
|
234
|
+
for (const chain of chains) {
|
|
235
|
+
forbiddenTargets.set(chain.emitterId, "emitter");
|
|
236
|
+
forbiddenTargets.set(chain.cloneChildId, "clone child");
|
|
237
|
+
forbiddenTargets.set(chain.joinId, "Join");
|
|
238
|
+
}
|
|
239
|
+
for (const ref of refs) {
|
|
240
|
+
const loops = toRouteLoopEntries(ref.route);
|
|
241
|
+
if (loops.length === 0)
|
|
242
|
+
continue;
|
|
243
|
+
if (emitterIds.has(ref.id)) {
|
|
244
|
+
return loadFailure([
|
|
245
|
+
dagError(pipelineId, `stage "${ref.id}": Clone Chain emitter cannot originate a Loop`, ref.id),
|
|
246
|
+
]);
|
|
247
|
+
}
|
|
248
|
+
if (cloneChildIds.has(ref.id)) {
|
|
249
|
+
return loadFailure([
|
|
250
|
+
dagError(pipelineId, `stage "${ref.id}": Clone Chain clone child cannot originate a Loop`, ref.id),
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
for (const loop of loops) {
|
|
254
|
+
const role = forbiddenTargets.get(loop.to);
|
|
255
|
+
if (role === undefined)
|
|
256
|
+
continue;
|
|
257
|
+
return loadFailure([
|
|
258
|
+
dagError(pipelineId, `stage "${ref.id}": Loop target "${loop.to}" is a Clone Chain ${role}`, ref.id),
|
|
259
|
+
]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return loadSuccess(undefined);
|
|
263
|
+
}
|
|
264
|
+
function applyCloneCapToSchema(schema, field, cap) {
|
|
265
|
+
if (!isPlainObject(schema) || !isPlainObject(schema.properties))
|
|
266
|
+
return;
|
|
267
|
+
const prop = schema.properties[field];
|
|
268
|
+
if (!isPlainObject(prop))
|
|
269
|
+
return;
|
|
270
|
+
prop.minItems = 1;
|
|
271
|
+
prop.maxItems = cap;
|
|
272
|
+
}
|
|
273
|
+
export function applyCloneChains(stages, refs, dag, pipelineId) {
|
|
274
|
+
const detected = detectCloneChains(stages, refs, pipelineId);
|
|
275
|
+
if (!detected.ok)
|
|
276
|
+
return detected;
|
|
277
|
+
const chains = detected.value;
|
|
278
|
+
const emitterIds = new Set(chains.map((chain) => chain.emitterId));
|
|
279
|
+
const refById = new Map(refs.map((ref) => [ref.id, ref]));
|
|
280
|
+
const nodeById = new Map(dag.nodes.map((node) => [node.id, node]));
|
|
281
|
+
for (const ref of refs) {
|
|
282
|
+
const isEmitter = emitterIds.has(ref.id);
|
|
283
|
+
if (isEmitter) {
|
|
284
|
+
if (ref.clone_cap === undefined || ref.clone_mode === undefined) {
|
|
285
|
+
return loadFailure([
|
|
286
|
+
dagError(pipelineId, `stage "${ref.id}": Clone Chain emitter requires clone_cap and clone_mode`, ref.id),
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (ref.clone_cap !== undefined) {
|
|
292
|
+
return loadFailure([
|
|
293
|
+
dagError(pipelineId, `stage "${ref.id}": "clone_cap" is no longer supported — use a Clone Chain instead`, ref.id),
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
if (ref.clone_mode !== undefined) {
|
|
297
|
+
return loadFailure([
|
|
298
|
+
dagError(pipelineId, `stage "${ref.id}": "clone_mode" is only valid on a Clone Chain emitter`, ref.id),
|
|
299
|
+
]);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const loopsOutcome = validateCloneChainLoops(refs, chains, pipelineId);
|
|
303
|
+
if (!loopsOutcome.ok)
|
|
304
|
+
return loopsOutcome;
|
|
305
|
+
const stageById = new Map(stages.map((stage) => [stage.id, stage]));
|
|
306
|
+
for (const chain of chains) {
|
|
307
|
+
const ref = refById.get(chain.emitterId);
|
|
308
|
+
const cap = ref.clone_cap;
|
|
309
|
+
const mode = ref.clone_mode;
|
|
310
|
+
const node = nodeById.get(chain.emitterId);
|
|
311
|
+
if (node) {
|
|
312
|
+
node.clone_cap = cap;
|
|
313
|
+
node.clone_mode = mode;
|
|
314
|
+
node.clone_array_field = chain.arrayField;
|
|
315
|
+
}
|
|
316
|
+
const emitterStage = stageById.get(chain.emitterId);
|
|
317
|
+
if (emitterStage?.payload_schema !== undefined) {
|
|
318
|
+
applyCloneCapToSchema(emitterStage.payload_schema, chain.arrayField, cap);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return loadSuccess(undefined);
|
|
322
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { access, mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { parsePipelineNeeds } from "./pipelineNeeds.js";
|
|
3
|
+
import { invertPredecessorEdgesToRoute, invertRouteToPredecessorEdges, parsePipelineNeeds, toPipelineNeeds, } from "./pipelineNeeds.js";
|
|
4
4
|
import { STAGE_ID_PATTERN } from "./createStage.js";
|
|
5
5
|
import { inferIdFromUsesPath, normalizePipelineStageEntries, toWiringRefs, } from "./normalizePipelineStageEntry.js";
|
|
6
6
|
import { parseModelField } from "./modelField.js";
|
|
@@ -29,10 +29,12 @@ export function normalizeCreatePipelineStages(stages) {
|
|
|
29
29
|
uses: stage.uses ?? (stage.inline ? undefined : defaultUsesPath(stage.id)),
|
|
30
30
|
}));
|
|
31
31
|
}
|
|
32
|
-
function stageRefToRaw(ref) {
|
|
32
|
+
function stageRefToRaw(ref, routeInfo) {
|
|
33
33
|
const raw = { id: ref.id };
|
|
34
|
-
if (
|
|
35
|
-
raw.
|
|
34
|
+
if (routeInfo?.entry)
|
|
35
|
+
raw.entry = true;
|
|
36
|
+
if (routeInfo?.route && routeInfo.route.length > 0)
|
|
37
|
+
raw.route = routeInfo.route;
|
|
36
38
|
if (ref.uses !== undefined)
|
|
37
39
|
raw.uses = ref.uses;
|
|
38
40
|
if (ref.inline) {
|
|
@@ -47,16 +49,20 @@ function stageRefToRaw(ref) {
|
|
|
47
49
|
return raw;
|
|
48
50
|
}
|
|
49
51
|
function toAuthoringRawEntries(stages, declaringPath) {
|
|
52
|
+
const routeById = invertPredecessorEdgesToRoute(stages);
|
|
50
53
|
return stages.map((stage) => ({
|
|
51
|
-
raw: stageRefToRaw(stage),
|
|
54
|
+
raw: stageRefToRaw(stage, routeById.get(stage.id)),
|
|
52
55
|
declaringPath,
|
|
53
56
|
}));
|
|
54
57
|
}
|
|
55
58
|
function normalizedToCreateRefs(entries) {
|
|
59
|
+
const inbound = invertRouteToPredecessorEdges(entries);
|
|
56
60
|
return entries.map((entry) => {
|
|
57
61
|
const ref = { id: entry.id };
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
const edges = inbound.get(entry.id);
|
|
63
|
+
const needs = edges !== undefined ? toPipelineNeeds(edges) : undefined;
|
|
64
|
+
if (needs !== undefined)
|
|
65
|
+
ref.needs = needs;
|
|
60
66
|
if (entry.body.kind === "uses") {
|
|
61
67
|
ref.uses = entry.body.path;
|
|
62
68
|
}
|
|
@@ -236,21 +242,86 @@ export function parseCreatePipelineBody(body) {
|
|
|
236
242
|
stages: refs,
|
|
237
243
|
};
|
|
238
244
|
}
|
|
239
|
-
function
|
|
240
|
-
if (typeof
|
|
241
|
-
|
|
245
|
+
function formatRouteIfValue(value) {
|
|
246
|
+
if (typeof value === "string")
|
|
247
|
+
return JSON.stringify(value);
|
|
248
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
249
|
+
return String(value);
|
|
250
|
+
if (value === null)
|
|
251
|
+
return "null";
|
|
252
|
+
if (Array.isArray(value)) {
|
|
253
|
+
return `[${value.map((item) => formatRouteIfValue(item)).join(", ")}]`;
|
|
254
|
+
}
|
|
255
|
+
return JSON.stringify(value);
|
|
256
|
+
}
|
|
257
|
+
function appendRouteIfYamlListItem(lines, indent, predicate) {
|
|
258
|
+
if ("all" in predicate) {
|
|
259
|
+
lines.push(`${indent}- all:`);
|
|
260
|
+
for (const child of predicate.all) {
|
|
261
|
+
appendRouteIfYamlListItem(lines, `${indent} `, child);
|
|
262
|
+
}
|
|
242
263
|
return;
|
|
243
264
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
lines
|
|
248
|
-
|
|
265
|
+
if ("any" in predicate) {
|
|
266
|
+
lines.push(`${indent}- any:`);
|
|
267
|
+
for (const child of predicate.any) {
|
|
268
|
+
appendRouteIfYamlListItem(lines, `${indent} `, child);
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if ("not" in predicate) {
|
|
273
|
+
lines.push(`${indent}- not:`);
|
|
274
|
+
appendRouteIfYaml(lines, `${indent} `, predicate.not);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
lines.push(`${indent}- field: ${formatInlineYamlScalar(predicate.field)}`);
|
|
278
|
+
lines.push(`${indent} op: ${predicate.op}`);
|
|
279
|
+
lines.push(`${indent} value: ${formatRouteIfValue(predicate.value)}`);
|
|
280
|
+
}
|
|
281
|
+
function appendRouteIfYaml(lines, indent, predicate) {
|
|
282
|
+
if ("all" in predicate) {
|
|
283
|
+
lines.push(`${indent}all:`);
|
|
284
|
+
for (const child of predicate.all) {
|
|
285
|
+
appendRouteIfYamlListItem(lines, `${indent} `, child);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if ("any" in predicate) {
|
|
290
|
+
lines.push(`${indent}any:`);
|
|
291
|
+
for (const child of predicate.any) {
|
|
292
|
+
appendRouteIfYamlListItem(lines, `${indent} `, child);
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if ("not" in predicate) {
|
|
297
|
+
lines.push(`${indent}not:`);
|
|
298
|
+
appendRouteIfYaml(lines, `${indent} `, predicate.not);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
lines.push(`${indent}field: ${formatInlineYamlScalar(predicate.field)}`);
|
|
302
|
+
lines.push(`${indent}op: ${predicate.op}`);
|
|
303
|
+
lines.push(`${indent}value: ${formatRouteIfValue(predicate.value)}`);
|
|
304
|
+
}
|
|
305
|
+
function appendRouteYaml(lines, routeInfo) {
|
|
306
|
+
if (routeInfo.entry) {
|
|
307
|
+
lines.push(" entry: true");
|
|
308
|
+
}
|
|
309
|
+
if (!routeInfo.route || routeInfo.route.length === 0) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
lines.push(" route:");
|
|
313
|
+
for (const item of routeInfo.route) {
|
|
314
|
+
const on = item.on === undefined ? ["succeeded"] : Array.isArray(item.on) ? item.on : [item.on];
|
|
315
|
+
lines.push(` - to: ${item.to}`);
|
|
316
|
+
if (!(on.length === 1 && on[0] === "succeeded")) {
|
|
317
|
+
lines.push(" on:");
|
|
318
|
+
for (const state of on) {
|
|
319
|
+
lines.push(` - ${state}`);
|
|
320
|
+
}
|
|
249
321
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
lines.push(` - ${state}`);
|
|
322
|
+
if (item.if !== undefined) {
|
|
323
|
+
lines.push(" if:");
|
|
324
|
+
appendRouteIfYaml(lines, " ", item.if);
|
|
254
325
|
}
|
|
255
326
|
}
|
|
256
327
|
}
|
|
@@ -266,6 +337,7 @@ function formatInlineYamlScalar(value) {
|
|
|
266
337
|
return value;
|
|
267
338
|
}
|
|
268
339
|
export function pipelineConfigToYaml(pipeline, options) {
|
|
340
|
+
const routeById = invertPredecessorEdgesToRoute(pipeline.stages);
|
|
269
341
|
const lines = [`id: ${pipeline.id}`, "stages:"];
|
|
270
342
|
for (const stage of pipeline.stages) {
|
|
271
343
|
if (options.format === "linear" && !stage.needs && !stage.inline && stage.uses) {
|
|
@@ -274,8 +346,9 @@ export function pipelineConfigToYaml(pipeline, options) {
|
|
|
274
346
|
continue;
|
|
275
347
|
}
|
|
276
348
|
lines.push(` - id: ${stage.id}`);
|
|
277
|
-
|
|
278
|
-
|
|
349
|
+
const routeInfo = routeById.get(stage.id);
|
|
350
|
+
if (routeInfo) {
|
|
351
|
+
appendRouteYaml(lines, routeInfo);
|
|
279
352
|
}
|
|
280
353
|
if (stage.inline) {
|
|
281
354
|
if (stage.inline.gate_kinds !== undefined) {
|
|
@@ -301,6 +374,13 @@ export function pipelineConfigToYaml(pipeline, options) {
|
|
|
301
374
|
if (stage.inline.model !== undefined && stage.inline.model.length > 0) {
|
|
302
375
|
lines.push(` model: ${formatInlineYamlScalar(stage.inline.model)}`);
|
|
303
376
|
}
|
|
377
|
+
lines.push(" io:");
|
|
378
|
+
lines.push(" input:");
|
|
379
|
+
lines.push(" schema:");
|
|
380
|
+
lines.push(" type: object");
|
|
381
|
+
lines.push(" output:");
|
|
382
|
+
lines.push(" schema:");
|
|
383
|
+
lines.push(" type: object");
|
|
304
384
|
}
|
|
305
385
|
else if (stage.uses) {
|
|
306
386
|
lines.push(` uses: ${stage.uses}`);
|
|
@@ -122,6 +122,13 @@ export function stageConfigToYaml(stage) {
|
|
|
122
122
|
if (stage.model !== undefined && stage.model.length > 0) {
|
|
123
123
|
lines.push(`model: ${formatInlineYamlScalar(stage.model)}`);
|
|
124
124
|
}
|
|
125
|
+
lines.push("io:");
|
|
126
|
+
lines.push(" input:");
|
|
127
|
+
lines.push(" schema:");
|
|
128
|
+
lines.push(" type: object");
|
|
129
|
+
lines.push(" output:");
|
|
130
|
+
lines.push(" schema:");
|
|
131
|
+
lines.push(" type: object");
|
|
125
132
|
lines.push("");
|
|
126
133
|
return lines.join("\n");
|
|
127
134
|
}
|