@smartmemory/compose 0.2.51-beta → 0.2.52-beta

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 (64) hide show
  1. package/dist/assets/App-Ddhfx18X.js +889 -0
  2. package/dist/assets/{_baseUniq-CsOHc_iS.js → _baseUniq-CoEVVYyC.js} +1 -1
  3. package/dist/assets/{arc-BXht3LyE.js → arc-DFnGxuCB.js} +1 -1
  4. package/dist/assets/{architectureDiagram-Q4EWVU46-BV1r86-w.js → architectureDiagram-Q4EWVU46-66rx01Tn.js} +1 -1
  5. package/dist/assets/{blockDiagram-DXYQGD6D-BgcMJjAR.js → blockDiagram-DXYQGD6D-AE5PKSak.js} +1 -1
  6. package/dist/assets/{c4Diagram-AHTNJAMY-nBIRLJTv.js → c4Diagram-AHTNJAMY-DtDSNlOX.js} +1 -1
  7. package/dist/assets/channel-CScyWprq.js +1 -0
  8. package/dist/assets/{chunk-4BX2VUAB-CNmGyhrp.js → chunk-4BX2VUAB-oXU2npoL.js} +1 -1
  9. package/dist/assets/{chunk-4TB4RGXK-MLT7I7w4.js → chunk-4TB4RGXK-5w2lZAjW.js} +1 -1
  10. package/dist/assets/{chunk-55IACEB6-D8KuKVLa.js → chunk-55IACEB6-BEEk2FdH.js} +1 -1
  11. package/dist/assets/{chunk-EDXVE4YY-TPlt1bS2.js → chunk-EDXVE4YY-Bse2kdRh.js} +1 -1
  12. package/dist/assets/{chunk-FMBD7UC4-tYY8xcjr.js → chunk-FMBD7UC4-BAa7Edp6.js} +1 -1
  13. package/dist/assets/{chunk-OYMX7WX6-yBPwT1AT.js → chunk-OYMX7WX6-CGr_YiAc.js} +1 -1
  14. package/dist/assets/{chunk-QZHKN3VN-B4Wfybww.js → chunk-QZHKN3VN-BMWFLZ8t.js} +1 -1
  15. package/dist/assets/{chunk-YZCP3GAM-DE6_5aqh.js → chunk-YZCP3GAM-CqjNXooj.js} +1 -1
  16. package/dist/assets/classDiagram-6PBFFD2Q-DibVYYdr.js +1 -0
  17. package/dist/assets/classDiagram-v2-HSJHXN6E-DibVYYdr.js +1 -0
  18. package/dist/assets/clone-ZMALIFmw.js +1 -0
  19. package/dist/assets/{cose-bilkent-S5V4N54A-DzzRyJtB.js → cose-bilkent-S5V4N54A-NYnH3mVW.js} +1 -1
  20. package/dist/assets/{dagre-KV5264BT-CTcTVGdU.js → dagre-KV5264BT--mrfaTHR.js} +1 -1
  21. package/dist/assets/{diagram-5BDNPKRD-qR6mc8kJ.js → diagram-5BDNPKRD-DjXvyFzh.js} +1 -1
  22. package/dist/assets/{diagram-G4DWMVQ6-CUcGXyNb.js → diagram-G4DWMVQ6-BrrexEvm.js} +1 -1
  23. package/dist/assets/{diagram-MMDJMWI5-B68cPonH.js → diagram-MMDJMWI5-D3Q0OT2M.js} +1 -1
  24. package/dist/assets/{diagram-TYMM5635-q8Id8eFG.js → diagram-TYMM5635-DKTntmxE.js} +1 -1
  25. package/dist/assets/{erDiagram-SMLLAGMA-CtxJi57K.js → erDiagram-SMLLAGMA-BWbR0dPj.js} +1 -1
  26. package/dist/assets/{flowDiagram-DWJPFMVM-BzzO4JYU.js → flowDiagram-DWJPFMVM-Dj5M5XaY.js} +1 -1
  27. package/dist/assets/{ganttDiagram-T4ZO3ILL-Dn0wIyhR.js → ganttDiagram-T4ZO3ILL-DKppFh_5.js} +1 -1
  28. package/dist/assets/{gitGraphDiagram-UUTBAWPF-tnNFXfMM.js → gitGraphDiagram-UUTBAWPF-9xQ9O25V.js} +1 -1
  29. package/dist/assets/{graph-Tq_bs_r0.js → graph-Cgmhvu1T.js} +1 -1
  30. package/dist/assets/index-BxRamj_i.css +1 -0
  31. package/dist/assets/{index-8UhRLbGq.js → index-D8uDfn8y.js} +2 -2
  32. package/dist/assets/{infoDiagram-42DDH7IO-BXLvLeS0.js → infoDiagram-42DDH7IO-DQYWVzVN.js} +1 -1
  33. package/dist/assets/{ishikawaDiagram-UXIWVN3A-DWx-7BBy.js → ishikawaDiagram-UXIWVN3A-CK2IFFAP.js} +1 -1
  34. package/dist/assets/{journeyDiagram-VCZTEJTY-C42hXNha.js → journeyDiagram-VCZTEJTY-DAH5Stkf.js} +1 -1
  35. package/dist/assets/{kanban-definition-6JOO6SKY-CEq930ew.js → kanban-definition-6JOO6SKY-Bi0aCqkv.js} +1 -1
  36. package/dist/assets/{layout-qtUgN9BC.js → layout-DN13RkA1.js} +1 -1
  37. package/dist/assets/{linear-D158F7OT.js → linear-ClGEGlS2.js} +1 -1
  38. package/dist/assets/{min-hESxN-c0.js → min-CXr-fUFS.js} +1 -1
  39. package/dist/assets/{mindmap-definition-QFDTVHPH-d9SSh2nG.js → mindmap-definition-QFDTVHPH-Wjr-PgC6.js} +1 -1
  40. package/dist/assets/{pieDiagram-DEJITSTG-gglmeMav.js → pieDiagram-DEJITSTG-CVipbIlr.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-34T5L4WZ-CqFC_htU.js → quadrantDiagram-34T5L4WZ-CHJFyhm-.js} +1 -1
  42. package/dist/assets/{requirementDiagram-MS252O5E-B1ZFIotK.js → requirementDiagram-MS252O5E-CU9Rlyq5.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-XADWPNL6-BvItvZt5.js → sankeyDiagram-XADWPNL6-BSULeqST.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-FGHM5R23-CAIn5Z3U.js → sequenceDiagram-FGHM5R23-CCOKXvjj.js} +1 -1
  45. package/dist/assets/{stateDiagram-FHFEXIEX-CyJrGzib.js → stateDiagram-FHFEXIEX-B91gKA7Q.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dc8ur9ta.js +1 -0
  47. package/dist/assets/{timeline-definition-GMOUNBTQ-k_0vJQAK.js → timeline-definition-GMOUNBTQ-C8SZmaLp.js} +1 -1
  48. package/dist/assets/{vennDiagram-DHZGUBPP-TZAW60b0.js → vennDiagram-DHZGUBPP-YvEPDlsM.js} +1 -1
  49. package/dist/assets/{wardley-RL74JXVD-DzQEoG6X.js → wardley-RL74JXVD-TMBUq_w1.js} +1 -1
  50. package/dist/assets/{wardleyDiagram-NUSXRM2D-BAoVVIjZ.js → wardleyDiagram-NUSXRM2D-DzEkPrri.js} +1 -1
  51. package/dist/assets/{xychartDiagram-5P7HB3ND-CynkZcUb.js → xychartDiagram-5P7HB3ND-BIiCeiYk.js} +1 -1
  52. package/dist/index.html +2 -2
  53. package/package.json +1 -1
  54. package/scripts/release-manual.sh +98 -0
  55. package/server/file-watcher.js +33 -0
  56. package/server/index.js +7 -0
  57. package/server/pipeline-routes.js +773 -3
  58. package/dist/assets/App-CT0vXPgd.js +0 -724
  59. package/dist/assets/channel-Dd4XaiYv.js +0 -1
  60. package/dist/assets/classDiagram-6PBFFD2Q-DshSQDF9.js +0 -1
  61. package/dist/assets/classDiagram-v2-HSJHXN6E-DshSQDF9.js +0 -1
  62. package/dist/assets/clone-CBsbNGAa.js +0 -1
  63. package/dist/assets/index-CRqB9els.css +0 -1
  64. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DprhF_Ho.js +0 -1
@@ -8,13 +8,60 @@
8
8
  * GET /api/pipeline/draft — get current draft
9
9
  * POST /api/pipeline/draft/approve — approve and persist current draft
10
10
  * POST /api/pipeline/draft/reject — reject and discard current draft
11
+ * GET /api/pipeline/specs — list raw spec files (filename-keyed) [COMP-PIPE-EDIT-1]
12
+ * POST /api/pipeline/save — save an edited model back to its source file [COMP-PIPE-EDIT-1]
11
13
  */
12
14
 
13
- import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
14
- import { join } from 'node:path';
15
- import { randomUUID } from 'node:crypto';
15
+ import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, lstatSync, realpathSync } from 'node:fs';
16
+ import { join, basename, dirname } from 'node:path';
17
+ import { randomUUID, createHash } from 'node:crypto';
16
18
  import YAML from 'yaml';
17
19
 
20
+ /** sha-256 hex of a string (COMP-PIPE-EDIT-6 conflict-detection baseline). */
21
+ function sha256(text) {
22
+ return createHash('sha256').update(text, 'utf-8').digest('hex');
23
+ }
24
+
25
+ /**
26
+ * Symlink/realpath containment guard (COMP-PIPE-EDIT — security). A bare-basename
27
+ * check stops `..`/slashes but NOT a symlink that escapes the pipelines dir. This
28
+ * resolves real paths and refuses anything that is a symlink or whose resolved
29
+ * parent is not the (resolved) pipelines dir.
30
+ *
31
+ * Two modes:
32
+ * - mustExist:true (read/overwrite an EXISTING file — /spec, /save): realpath
33
+ * the file itself, reject if it is a symlink or its real parent ≠ real dir.
34
+ * - mustExist:false (CREATE a new file — /save-as-template): the target may not
35
+ * exist yet, so realpath the DIR and require real(dirname(target)) === realDir;
36
+ * also reject if a node already sits at the target path AS a symlink.
37
+ *
38
+ * @returns {{ ok: true } | { ok: false, error: string }}
39
+ */
40
+ function assertWithinPipelines(pipelinesDir, filePath, { mustExist }) {
41
+ try {
42
+ const realDir = realpathSync(pipelinesDir);
43
+ if (mustExist) {
44
+ const realFile = realpathSync(filePath);
45
+ if (lstatSync(filePath).isSymbolicLink() || dirname(realFile) !== realDir) {
46
+ return { ok: false, error: 'refusing to read or write through a symlink or outside the pipelines dir' };
47
+ }
48
+ } else {
49
+ // Create path: refuse a pre-existing symlink at the target, and require the
50
+ // resolved parent directory to be the resolved pipelines dir.
51
+ if (existsSync(filePath) && lstatSync(filePath).isSymbolicLink()) {
52
+ return { ok: false, error: 'refusing to write through a symlink or outside the pipelines dir' };
53
+ }
54
+ const realParent = realpathSync(dirname(filePath));
55
+ if (realParent !== realDir) {
56
+ return { ok: false, error: 'refusing to write through a symlink or outside the pipelines dir' };
57
+ }
58
+ }
59
+ } catch (e) {
60
+ return { ok: false, error: `Cannot resolve spec path: ${e.message}` };
61
+ }
62
+ return { ok: true };
63
+ }
64
+
18
65
  // ---------------------------------------------------------------------------
19
66
  // Helpers
20
67
  // ---------------------------------------------------------------------------
@@ -83,6 +130,417 @@ function extractSteps(parsed) {
83
130
  return [];
84
131
  }
85
132
 
133
+ // ---------------------------------------------------------------------------
134
+ // Spec discovery + save helpers (COMP-PIPE-EDIT-1)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * List raw spec files in the pipelines dir, keyed by filename (NOT metadata).
139
+ * The shipped specs store `metadata` as a leading `#` comment (or omit it), so
140
+ * the metadata-based `loadTemplates` never surfaces them. The editor discovers
141
+ * by filename instead.
142
+ * @returns {Array<{ file, version, flows: string[] }>}
143
+ */
144
+ function listSpecFiles(pipelinesDir) {
145
+ let files;
146
+ try {
147
+ files = readdirSync(pipelinesDir).filter(f => f.endsWith('.stratum.yaml'));
148
+ } catch {
149
+ return [];
150
+ }
151
+ const out = [];
152
+ for (const file of files) {
153
+ try {
154
+ const parsed = YAML.parse(readFileSync(join(pipelinesDir, file), 'utf-8'));
155
+ out.push({
156
+ file,
157
+ version: parsed?.version ?? null,
158
+ flows: flowNamesOf(parsed),
159
+ });
160
+ } catch {
161
+ out.push({ file, version: null, flows: [] });
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /** Flow names with a non-empty steps[] (workflow.steps or flows.<name>.steps). */
168
+ function flowNamesOf(parsed) {
169
+ const names = [];
170
+ if (Array.isArray(parsed?.workflow?.steps)) {
171
+ names.push(parsed.workflow.name || 'workflow');
172
+ }
173
+ if (parsed?.flows && typeof parsed.flows === 'object') {
174
+ for (const name of Object.keys(parsed.flows)) {
175
+ if (Array.isArray(parsed.flows[name]?.steps)) names.push(name);
176
+ }
177
+ }
178
+ return names;
179
+ }
180
+
181
+ /**
182
+ * Reconstruct a plain step object from a normalized model step, merging `_extra`
183
+ * back so nothing the editor never surfaced is lost. Mirrors
184
+ * src/lib/pipeline-model.js `denormalizeStep` (kept in sync; server cannot import
185
+ * the frontend module). `id` leads; surfaced fields override `_extra` on clash.
186
+ */
187
+ function denormalizeModelStep(step) {
188
+ const out = { id: step.id };
189
+ if (step._extra && typeof step._extra === 'object') {
190
+ for (const key of Object.keys(step._extra)) out[key] = step._extra[key];
191
+ }
192
+ if (step.agent !== undefined) out.agent = step.agent;
193
+ if (step.function !== undefined) out.function = step.function;
194
+ if (step.intent !== undefined) out.intent = step.intent;
195
+ if (step.inputs && Object.keys(step.inputs).length > 0) out.inputs = { ...step.inputs };
196
+ if (step.output_contract !== undefined) out.output_contract = step.output_contract;
197
+ if (Array.isArray(step.ensure) && step.ensure.length > 0) out.ensure = [...step.ensure];
198
+ if (step.retries !== undefined) out.retries = step.retries;
199
+ if (Array.isArray(step.depends_on) && step.depends_on.length > 0) out.depends_on = [...step.depends_on];
200
+ if (step.on_fail !== undefined) out.on_fail = step.on_fail;
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Find a model flow by name. The flow-scoped model stores flows as an array of
206
+ * { name, steps[] }.
207
+ */
208
+ function modelFlow(model, flowName) {
209
+ if (!model || !Array.isArray(model.flows)) return null;
210
+ return model.flows.find(f => f && f.name === flowName) || null;
211
+ }
212
+
213
+ // The reserved built-in contract — never written as a user contract (mirrors
214
+ // src/lib/pipeline-model.js serializeContracts; server can't import the FE module).
215
+ const RESERVED_CONTRACT = 'TaskGraph';
216
+
217
+ /** The contracts object a model wants persisted, excluding the reserved TaskGraph. */
218
+ function serializeModelContracts(model) {
219
+ const out = {};
220
+ const contracts = (model && model.contracts && typeof model.contracts === 'object') ? model.contracts : {};
221
+ for (const key of Object.keys(contracts)) {
222
+ if (key === RESERVED_CONTRACT) continue;
223
+ out[key] = contracts[key];
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /** Structural equality over plain JSON-ish data (field specs, contract maps). */
229
+ function deepEqual(a, b) {
230
+ if (a === b) return true;
231
+ if (typeof a !== 'object' || typeof b !== 'object' || a == null || b == null) return false;
232
+ const ak = Object.keys(a), bk = Object.keys(b);
233
+ if (ak.length !== bk.length) return false;
234
+ for (const k of ak) {
235
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
236
+ if (!deepEqual(a[k], b[k])) return false;
237
+ }
238
+ return true;
239
+ }
240
+
241
+ /**
242
+ * Merge a model's contracts into the freshly-parsed Document in place
243
+ * (COMP-PIPE-EDIT-4). Only contracts that differ from disk or are new get a
244
+ * `setIn` (preserving comments on unchanged contracts); contracts on disk but
245
+ * absent from the model get a `deleteIn` (except the reserved TaskGraph).
246
+ *
247
+ * @param {YAML.Document} doc
248
+ * @param {object} diskParsed — the same file's `YAML.parse` output (plain JS)
249
+ * @param {object} model
250
+ */
251
+ function mergeContracts(doc, diskParsed, model) {
252
+ const wanted = serializeModelContracts(model);
253
+ const onDisk = (diskParsed && diskParsed.contracts && typeof diskParsed.contracts === 'object')
254
+ ? diskParsed.contracts : {};
255
+
256
+ // Add / update changed contracts (skip unchanged to preserve their comments).
257
+ for (const name of Object.keys(wanted)) {
258
+ if (deepEqual(onDisk[name], wanted[name])) continue;
259
+ doc.setIn(['contracts', name], doc.createNode(wanted[name]));
260
+ }
261
+ // Delete contracts present on disk but absent from the model (never TaskGraph).
262
+ for (const name of Object.keys(onDisk)) {
263
+ if (name === RESERVED_CONTRACT) continue;
264
+ if (!(name in wanted)) doc.deleteIn(['contracts', name]);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Reconcile `flows.<name>.output` and `functions.<name>.output` scalar refs
270
+ * (COMP-PIPE-EDIT-4). A contract rename in the model edits these passthrough
271
+ * values in `model._doc`; the save re-parses disk and would otherwise miss
272
+ * them. When the model's `_doc` value differs from the fresh disk value, write
273
+ * the new value; otherwise leave the node (and its comment) untouched.
274
+ */
275
+ function reconcileOutputRefs(doc, diskParsed, model, specWide = false) {
276
+ const docOf = model && model._doc;
277
+ if (!docOf || typeof docOf !== 'object') return;
278
+
279
+ for (const section of ['flows', 'functions']) {
280
+ const modelSection = docOf[section];
281
+ const diskSection = diskParsed && diskParsed[section];
282
+ if (!modelSection || typeof modelSection !== 'object') continue;
283
+ for (const name of Object.keys(modelSection)) {
284
+ const modelOut = modelSection[name] && modelSection[name].output;
285
+ const diskOut = diskSection && diskSection[name] && diskSection[name].output;
286
+ const hasNode = doc.hasIn([section, name]);
287
+ if (modelOut === undefined) {
288
+ // On a spec-wide save, output ABSENT in model._doc but present on disk is a
289
+ // removal — delete it. (Flow-scoped leaves it; it may be unsurfaced.)
290
+ if (specWide && hasNode && doc.hasIn([section, name, 'output'])) {
291
+ doc.deleteIn([section, name, 'output']);
292
+ }
293
+ continue;
294
+ }
295
+ if (doc.hasIn([section, name, 'output'])) {
296
+ // Update an existing output node only when the value changed (rename or edit).
297
+ if (modelOut !== diskOut) doc.setIn([section, name, 'output'], doc.createNode(modelOut));
298
+ } else if (specWide && hasNode) {
299
+ // Spec-wide: CREATE an output that the model added but disk lacks.
300
+ doc.setIn([section, name, 'output'], doc.createNode(modelOut));
301
+ }
302
+ }
303
+ }
304
+
305
+ // workflow.output — a single object (not a name-map). A contract rename also
306
+ // rewrites _doc.workflow.output, so persist it like the flows/functions case
307
+ // (rename/edit always; create/delete only on a spec-wide save).
308
+ const mWf = docOf.workflow;
309
+ if (mWf && typeof mWf === 'object' && doc.hasIn(['workflow'])) {
310
+ const modelOut = mWf.output;
311
+ const diskOut = diskParsed && diskParsed.workflow && diskParsed.workflow.output;
312
+ if (modelOut === undefined) {
313
+ if (specWide && doc.hasIn(['workflow', 'output'])) doc.deleteIn(['workflow', 'output']);
314
+ } else if (doc.hasIn(['workflow', 'output'])) {
315
+ if (modelOut !== diskOut) doc.setIn(['workflow', 'output'], doc.createNode(modelOut));
316
+ } else if (specWide) {
317
+ doc.setIn(['workflow', 'output'], doc.createNode(modelOut));
318
+ }
319
+ }
320
+ }
321
+
322
+ /** Resolve where a flow's steps live in the doc (flows.<name>.steps or workflow.steps). */
323
+ function stepsPathFor(doc, name) {
324
+ if (doc.hasIn(['flows', name, 'steps'])) return ['flows', name, 'steps'];
325
+ if (doc.hasIn(['workflow', 'steps'])) {
326
+ const wfName = doc.getIn(['workflow', 'name']);
327
+ if (wfName === name || (wfName == null && name === 'workflow')) return ['workflow', 'steps'];
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * Propagate a contract rename to step.output_contract across EVERY flow, not just
334
+ * the one being saved (COMP-PIPE-EDIT-4). The per-flow step merge only rewrites
335
+ * the saved flow's steps, but renameContract changes output_contract on steps in
336
+ * other flows too; without this, a multi-flow spec saved while editing one flow
337
+ * would leave other flows' steps pointing at the old contract name. Only the
338
+ * output_contract scalar is touched (matched by step id), so non-saved flows'
339
+ * other fields/comments are untouched.
340
+ */
341
+ function reconcileStepContractRefs(doc, model) {
342
+ for (const flow of (model.flows || [])) {
343
+ const path = stepsPathFor(doc, flow.name);
344
+ if (!path) continue;
345
+ const seq = doc.getIn(path, true);
346
+ if (!seq || !Array.isArray(seq.items)) continue;
347
+ for (const step of (flow.steps || [])) {
348
+ if (step.output_contract === undefined) continue;
349
+ const node = seq.items.find(it => it && typeof it.get === 'function' && it.get('id') === step.id);
350
+ if (!node) continue;
351
+ if (node.get('output_contract') !== step.output_contract) {
352
+ node.set('output_contract', doc.createNode(step.output_contract));
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Reconcile each flow's `input` declaration block (COMP-PIPE-EDIT-5/-6) on a
360
+ * spec-wide save. A YAML-pane edit (or a collapse minting sub-flow input ports)
361
+ * lives in model._doc.flows.<name>.input. Per field: setIn only when the model
362
+ * value DIFFERS from fresh disk (preserving comments on unchanged fields),
363
+ * deleteIn for fields removed in the model. Only flows that exist in the doc are
364
+ * touched (a brand-new flow's input was already written by createNewFlowNode).
365
+ */
366
+ function reconcileFlowInputs(doc, diskParsed, model) {
367
+ const docFlows = model && model._doc && model._doc.flows;
368
+ if (!docFlows || typeof docFlows !== 'object') return;
369
+ for (const name of Object.keys(docFlows)) {
370
+ if (!doc.hasIn(['flows', name])) continue; // new flows handled elsewhere
371
+ const modelInput = docFlows[name] && docFlows[name].input;
372
+ // Whole-block removal: the model dropped this flow's input entirely — delete
373
+ // the on-disk `input` block (spec-wide save treats model._doc as authoritative).
374
+ if (!modelInput || typeof modelInput !== 'object') {
375
+ if (doc.hasIn(['flows', name, 'input'])) doc.deleteIn(['flows', name, 'input']);
376
+ continue;
377
+ }
378
+ const diskInput = (diskParsed && diskParsed.flows && diskParsed.flows[name] && diskParsed.flows[name].input) || {};
379
+ // Add / update changed fields (skip unchanged to keep their comments).
380
+ for (const field of Object.keys(modelInput)) {
381
+ if (deepEqual(diskInput[field], modelInput[field])) continue;
382
+ doc.setIn(['flows', name, 'input', field], doc.createNode(modelInput[field]));
383
+ }
384
+ // Delete fields present on disk but removed in the model.
385
+ for (const field of Object.keys(diskInput)) {
386
+ if (!(field in modelInput)) doc.deleteIn(['flows', name, 'input', field]);
387
+ }
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Merge the top-level `functions` block (COMP-PIPE-EDIT-6) on a spec-wide save,
393
+ * mirroring mergeContracts: setIn changed/new functions, deleteIn removed ones,
394
+ * never replace an unchanged function (keeps its comments). Reads model._doc.functions.
395
+ */
396
+ function mergeFunctions(doc, diskParsed, model) {
397
+ // Only called on a SPEC-WIDE save, where model._doc is the authoritative full
398
+ // document. So `model._doc.functions` ABSENT means the whole functions block was
399
+ // removed — delete it on disk (not "leave untouched").
400
+ const hasModelDoc = model && model._doc && typeof model._doc === 'object';
401
+ const wanted = (hasModelDoc && model._doc.functions && typeof model._doc.functions === 'object')
402
+ ? model._doc.functions : null;
403
+ if (!wanted) {
404
+ if (hasModelDoc && doc.hasIn(['functions'])) doc.deleteIn(['functions']);
405
+ return;
406
+ }
407
+ const onDisk = (diskParsed && diskParsed.functions && typeof diskParsed.functions === 'object')
408
+ ? diskParsed.functions : {};
409
+ for (const name of Object.keys(wanted)) {
410
+ if (deepEqual(onDisk[name], wanted[name])) continue;
411
+ doc.setIn(['functions', name], doc.createNode(wanted[name]));
412
+ }
413
+ for (const name of Object.keys(onDisk)) {
414
+ if (!(name in wanted)) doc.deleteIn(['functions', name]);
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Reconcile `workflow.name` (COMP-PIPE-EDIT-6) on a spec-wide save: write it only
420
+ * when the model._doc value differs from fresh disk and the node exists.
421
+ */
422
+ function reconcileWorkflowName(doc, diskParsed, model) {
423
+ const modelWf = model && model._doc && model._doc.workflow;
424
+ // Only act when the model actually carries a workflow block (else nothing to do).
425
+ if (!modelWf || typeof modelWf !== 'object') return;
426
+ const modelName = modelWf.name;
427
+ const diskName = diskParsed && diskParsed.workflow && diskParsed.workflow.name;
428
+ if (modelName === undefined) {
429
+ // workflow.name became undefined in the model — delete it on disk.
430
+ if (doc.hasIn(['workflow', 'name'])) doc.deleteIn(['workflow', 'name']);
431
+ return;
432
+ }
433
+ if (modelName !== diskName && doc.hasIn(['workflow'])) {
434
+ doc.setIn(['workflow', 'name'], doc.createNode(modelName));
435
+ }
436
+ }
437
+
438
+ // Surfaced (editable) step fields, with the same include/omit rules as
439
+ // denormalizeModelStep so an absent/empty value clears the key.
440
+ const SURFACED_STEP_KEYS = ['agent', 'function', 'intent', 'inputs', 'output_contract', 'ensure', 'retries', 'depends_on', 'on_fail'];
441
+ function surfacedValue(step, key) {
442
+ if (key === 'inputs') {
443
+ return step.inputs && Object.keys(step.inputs).length > 0
444
+ ? { present: true, value: { ...step.inputs } } : { present: false };
445
+ }
446
+ if (key === 'ensure') {
447
+ return Array.isArray(step.ensure) && step.ensure.length > 0
448
+ ? { present: true, value: [...step.ensure] } : { present: false };
449
+ }
450
+ if (key === 'depends_on') {
451
+ return Array.isArray(step.depends_on) && step.depends_on.length > 0
452
+ ? { present: true, value: [...step.depends_on] } : { present: false };
453
+ }
454
+ return step[key] !== undefined ? { present: true, value: step[key] } : { present: false };
455
+ }
456
+
457
+ /**
458
+ * Merge a normalized model step onto an EXISTING YAML map node in place.
459
+ * Surfaced fields are overwritten from the model (or deleted when cleared).
460
+ *
461
+ * `_extra` (unsurfaced) handling depends on the save scope (COMP-PIPE-EDIT-6):
462
+ * - flow-scoped (specWide=false): ADDITIVE only — disk `_extra` keys are LEFT
463
+ * untouched (preserving values + comments) and absent model `_extra` keys are
464
+ * added. An incomplete client `_extra` can never silently delete a disk field.
465
+ * - spec-wide (specWide=true): model._doc is the authoritative full document, so
466
+ * `_extra` is fully reconciled — changed keys are UPDATED (only when different,
467
+ * to keep comments on unchanged nodes) and disk `_extra` keys absent from the
468
+ * model step are DELETED. The set of unsurfaced disk keys = all map keys minus
469
+ * `id` and the surfaced keys.
470
+ */
471
+ function mergeStepNode(doc, mapNode, step, specWide = false) {
472
+ const extra = (step._extra && typeof step._extra === 'object') ? step._extra : {};
473
+ if (!specWide) {
474
+ // Additive: add only missing keys.
475
+ for (const k of Object.keys(extra)) {
476
+ if (!mapNode.has(k)) mapNode.set(k, doc.createNode(extra[k]));
477
+ }
478
+ } else {
479
+ // Full reconcile of unsurfaced fields.
480
+ const surfacedSet = new Set(['id', ...SURFACED_STEP_KEYS]);
481
+ // Add / update changed extra keys (skip unchanged to preserve their comments).
482
+ for (const k of Object.keys(extra)) {
483
+ if (!mapNode.has(k)) {
484
+ mapNode.set(k, doc.createNode(extra[k]));
485
+ } else {
486
+ // Compare on plain-JS projections so a scalar OR nested node compares to
487
+ // the model value; only write (losing the node's comment) when different.
488
+ const diskNode = mapNode.get(k, true);
489
+ const diskJs = diskNode && typeof diskNode.toJSON === 'function' ? diskNode.toJSON() : mapNode.get(k);
490
+ if (!deepEqual(diskJs, extra[k])) mapNode.set(k, doc.createNode(extra[k]));
491
+ }
492
+ }
493
+ // Delete unsurfaced disk keys absent from the model step's _extra.
494
+ for (const item of [...(mapNode.items || [])]) {
495
+ const k = item && item.key && item.key.value !== undefined ? item.key.value : (item && item.key);
496
+ if (k == null || surfacedSet.has(k)) continue;
497
+ if (!(k in extra)) mapNode.delete(k);
498
+ }
499
+ }
500
+ if (mapNode.get('id') !== step.id) mapNode.set('id', step.id);
501
+ for (const key of SURFACED_STEP_KEYS) {
502
+ const { present, value } = surfacedValue(step, key);
503
+ if (present) mapNode.set(key, doc.createNode(value));
504
+ else if (mapNode.has(key)) mapNode.delete(key);
505
+ }
506
+ return mapNode;
507
+ }
508
+
509
+ /**
510
+ * Create a brand-new `flows.<name>` document node for a flow that exists in the
511
+ * model but not yet on disk (COMP-PIPE-EDIT-5 — a just-collapsed sub-flow). The
512
+ * node gets `input` + `output` from `model._doc.flows[name]` (the collapse
513
+ * injects them there) and an empty `steps` sequence the caller then populates by
514
+ * the normal per-step merge. Returns true if the node was created, false if the
515
+ * flow cannot be created here (no model flow, or no flows: container to host it).
516
+ *
517
+ * @returns {boolean}
518
+ */
519
+ function createNewFlowNode(doc, model, name) {
520
+ const flow = modelFlow(model, name);
521
+ if (!flow) return false;
522
+
523
+ // The collapse injects { input, output, steps:[] } into model._doc.flows[name].
524
+ const injected = (model._doc && model._doc.flows && typeof model._doc.flows === 'object')
525
+ ? model._doc.flows[name] : null;
526
+
527
+ const node = {};
528
+ if (injected && injected.input && typeof injected.input === 'object') {
529
+ node.input = injected.input;
530
+ }
531
+ if (injected && injected.output !== undefined && injected.output !== null) {
532
+ node.output = injected.output;
533
+ }
534
+ node.steps = []; // populated by the per-step merge that runs next.
535
+
536
+ // Ensure a top-level `flows:` map exists, then set the new flow node.
537
+ if (!doc.hasIn(['flows'])) {
538
+ doc.setIn(['flows'], doc.createNode({}));
539
+ }
540
+ doc.setIn(['flows', name], doc.createNode(node));
541
+ return true;
542
+ }
543
+
86
544
  // ---------------------------------------------------------------------------
87
545
  // In-memory draft state (single draft at a time)
88
546
  // ---------------------------------------------------------------------------
@@ -285,4 +743,316 @@ export function attachPipelineRoutes(app, { broadcastMessage, scheduleBroadcast,
285
743
 
286
744
  res.json({ ok: true });
287
745
  });
746
+
747
+ // GET /api/pipeline/specs — filename-keyed spec discovery (COMP-PIPE-EDIT-1).
748
+ app.get('/api/pipeline/specs', (_req, res) => {
749
+ res.json({ specs: listSpecFiles(getPipelinesDir()) });
750
+ });
751
+
752
+ // GET /api/pipeline/spec?file=<name> — raw YAML text for one spec, keyed by
753
+ // filename (NOT metadata id — the shipped specs carry metadata as a comment
754
+ // the template loader can't see, so /templates/:id/spec misses them).
755
+ app.get('/api/pipeline/spec', (req, res) => {
756
+ const file = req.query?.file;
757
+ if (!file || typeof file !== 'string' || basename(file) !== file || !file.endsWith('.stratum.yaml')) {
758
+ return res.status(400).json({ error: 'file must be a bare *.stratum.yaml filename' });
759
+ }
760
+ const pipelinesDir = getPipelinesDir();
761
+ const filePath = join(pipelinesDir, file);
762
+ if (!existsSync(filePath)) {
763
+ return res.status(404).json({ error: `Spec "${file}" not found` });
764
+ }
765
+ // Symlink/containment guard: never read through a symlink or a resolved path
766
+ // outside the pipelines dir (basename alone does not stop symlink escape).
767
+ {
768
+ const guard = assertWithinPipelines(pipelinesDir, filePath, { mustExist: true });
769
+ if (!guard.ok) return res.status(400).json({ error: guard.error });
770
+ }
771
+ const text = readFileSync(filePath, 'utf-8');
772
+ // COMP-PIPE-EDIT-6: sha-256 of the on-disk text as a conflict-detection
773
+ // baseline. Computed on the file only (never on a serialized model), so it
774
+ // stays a true disk-divergence signal.
775
+ res.json({ file, text, hash: sha256(text) });
776
+ });
777
+
778
+ // POST /api/pipeline/save — save an edited model back to its source file.
779
+ // Body: { file, model, flowName }. Mutates the on-disk YAML Document in place
780
+ // so the `# metadata:` comment header, body comments, key ordering, and every
781
+ // untouched flow/field survive (COMP-PIPE-EDIT-1).
782
+ app.post('/api/pipeline/save', (req, res) => {
783
+ const { file, model, flowName, baseHash, force } = req.body || {};
784
+
785
+ if (!file || typeof file !== 'string') {
786
+ return res.status(400).json({ error: 'file is required' });
787
+ }
788
+ // Path-traversal-safe: reject anything whose basename differs (no slashes / ..).
789
+ if (basename(file) !== file || !file.endsWith('.stratum.yaml')) {
790
+ return res.status(400).json({ error: 'file must be a bare *.stratum.yaml filename' });
791
+ }
792
+ const pipelinesDir = getPipelinesDir();
793
+ const filePath = join(pipelinesDir, file);
794
+ if (!existsSync(filePath)) {
795
+ return res.status(400).json({ error: `Spec "${file}" not found in pipelines dir` });
796
+ }
797
+ // Symlink/containment guard: never write through a symlink or to a resolved
798
+ // path outside the pipelines dir (basename alone does not stop symlink escape).
799
+ {
800
+ const guard = assertWithinPipelines(pipelinesDir, filePath, { mustExist: true });
801
+ if (!guard.ok) return res.status(400).json({ error: guard.error });
802
+ }
803
+ if (!model || !Array.isArray(model.flows)) {
804
+ return res.status(400).json({ error: 'model with flows[] is required' });
805
+ }
806
+
807
+ // Determine which flows to write back. If flowName is given, write only that
808
+ // flow (the editor edits one flow at a time); otherwise write every flow the
809
+ // model carries that exists in the document.
810
+ const flowsToWrite = flowName
811
+ ? [flowName]
812
+ : model.flows.map(f => f && f.name).filter(Boolean);
813
+
814
+ // A SPEC-WIDE save (flowName omitted) treats model._doc as the authoritative
815
+ // full document: reconcilers update AND delete, not just add. The flow-scoped
816
+ // path (flowName given) stays additive and never touches other flows/blocks.
817
+ const specWide = !flowName;
818
+
819
+ let doc;
820
+ let diskParsed;
821
+ let diskText;
822
+ try {
823
+ diskText = readFileSync(filePath, 'utf-8');
824
+ } catch (e) {
825
+ return res.status(400).json({ error: `Cannot read current spec: ${e.message}` });
826
+ }
827
+
828
+ // COMP-PIPE-EDIT-6 conflict detection: if the client sent a baseHash and the
829
+ // freshly-read disk text no longer matches it, the file diverged since load
830
+ // (another writer / external edit). Refuse to write (409) unless force:true.
831
+ if (baseHash && !force) {
832
+ const currentHash = sha256(diskText);
833
+ if (currentHash !== baseHash) {
834
+ return res.status(409).json({
835
+ error: 'Spec changed on disk since it was loaded',
836
+ conflict: true,
837
+ currentHash,
838
+ });
839
+ }
840
+ }
841
+
842
+ try {
843
+ doc = YAML.parseDocument(diskText);
844
+ diskParsed = YAML.parse(diskText);
845
+ } catch (e) {
846
+ return res.status(400).json({ error: `Failed to parse current spec: ${e.message}` });
847
+ }
848
+
849
+ for (const name of flowsToWrite) {
850
+ const flow = modelFlow(model, name);
851
+ if (!flow) {
852
+ return res.status(400).json({ error: `Flow "${name}" not present in model` });
853
+ }
854
+
855
+ // Where do this flow's steps live? Prefer flows.<name>.steps; fall back to
856
+ // workflow.steps only on an exact name match (or the synthetic 'workflow'
857
+ // name when the doc omits workflow.name) — never on a blanket null match.
858
+ let stepsPath = null;
859
+ if (doc.hasIn(['flows', name, 'steps'])) {
860
+ stepsPath = ['flows', name, 'steps'];
861
+ } else if (doc.hasIn(['workflow', 'steps'])) {
862
+ const wfName = doc.getIn(['workflow', 'name']);
863
+ if (wfName === name || (wfName == null && name === 'workflow')) {
864
+ stepsPath = ['workflow', 'steps'];
865
+ }
866
+ }
867
+ if (!stepsPath) {
868
+ // COMP-PIPE-EDIT-5: the flow is not on disk yet (e.g. a just-collapsed
869
+ // sub-flow that exists only in the model). CREATE the flows.<name> node —
870
+ // steps + input + output from model._doc.flows — rather than erroring.
871
+ // ONLY on a SPEC-WIDE save (flowName omitted): an explicit flowName that
872
+ // doesn't map to a steps location is still a 400 (never auto-create a
873
+ // flow the caller named by mistake / clobber a workflow.steps spec).
874
+ const created = !flowName && createNewFlowNode(doc, model, name);
875
+ if (!created) {
876
+ return res.status(400).json({ error: `Flow "${name}" has no steps location in the spec` });
877
+ }
878
+ stepsPath = ['flows', name, 'steps'];
879
+ }
880
+
881
+ // Merge in place: existing steps are mutated by id (preserving disk-only
882
+ // fields/comments), new steps are created, dropped steps are removed, and
883
+ // the sequence is reordered to match the model.
884
+ const seq = doc.getIn(stepsPath, true);
885
+ const oldById = new Map();
886
+ for (const item of (seq?.items || [])) {
887
+ const id = item && typeof item.get === 'function' ? item.get('id') : undefined;
888
+ if (id != null) oldById.set(id, item);
889
+ }
890
+ seq.items = (flow.steps || []).map((step) => {
891
+ // Match a renamed step to its ORIGINAL disk node via _renamedFrom FIRST
892
+ // (so a rename chain like A->B, B->C maps each step to its true node),
893
+ // else by current id. Consume the matched node so it is never claimed
894
+ // twice. Unmatched steps are created fresh.
895
+ let existing;
896
+ if (step._renamedFrom != null && oldById.has(step._renamedFrom)) {
897
+ existing = oldById.get(step._renamedFrom);
898
+ oldById.delete(step._renamedFrom);
899
+ } else if (oldById.has(step.id)) {
900
+ existing = oldById.get(step.id);
901
+ oldById.delete(step.id);
902
+ }
903
+ return existing
904
+ ? mergeStepNode(doc, existing, step, specWide)
905
+ : doc.createNode(denormalizeModelStep(step));
906
+ });
907
+ }
908
+
909
+ // Persist the top-level contracts block (COMP-PIPE-EDIT-4) — only when the
910
+ // model carries a contracts field, so step-only saves never touch contracts.
911
+ // Order matters: reconcile the rename-driven output refs BEFORE the contract
912
+ // key merge so both the old and new contract values are still observable
913
+ // (the helpers read model state + fresh disk, not each other's writes).
914
+ if (model.contracts && typeof model.contracts === 'object') {
915
+ // Flow-scoped: reconcile rename-driven output refs here (update-only). On a
916
+ // spec-wide save the full output create/update/delete reconcile runs below,
917
+ // so skip it here to avoid a redundant pass.
918
+ if (!specWide) reconcileOutputRefs(doc, diskParsed, model, false);
919
+ reconcileStepContractRefs(doc, model);
920
+ mergeContracts(doc, diskParsed, model);
921
+ }
922
+
923
+ // COMP-PIPE-EDIT-5/-6: a SPEC-WIDE save (flowName omitted) can carry YAML-pane
924
+ // edits to structural blocks beyond steps/contracts. Reconcile them
925
+ // comment-preservingly (setIn only when the model._doc value differs from
926
+ // fresh disk; deleteIn for removed; never replace an unchanged node, to keep
927
+ // its comments). The flow-SCOPED path is left untouched (edits one flow's
928
+ // steps only). NOTE: only steps, contracts, the `functions` block,
929
+ // flows.<name>.input/output, and workflow.name are reconciled — arbitrary
930
+ // OTHER top-level keys are intentionally out of scope (not pane-editable).
931
+ if (specWide) {
932
+ reconcileOutputRefs(doc, diskParsed, model, true);
933
+ reconcileFlowInputs(doc, diskParsed, model);
934
+ mergeFunctions(doc, diskParsed, model);
935
+ reconcileWorkflowName(doc, diskParsed, model);
936
+ }
937
+
938
+ const out = String(doc);
939
+
940
+ // Validation gate: the serialized text must still parse before we write it.
941
+ try {
942
+ YAML.parse(out);
943
+ } catch (e) {
944
+ return res.status(400).json({ error: `Refusing to write invalid YAML: ${e.message}` });
945
+ }
946
+
947
+ writeFileSync(filePath, out);
948
+ // COMP-PIPE-EDIT-6: return the hash of the freshly-written text so the editor
949
+ // updates its conflict baseline (editorSpecHash) without an extra GET.
950
+ res.json({ ok: true, file, hash: sha256(out) });
951
+ });
952
+
953
+ // POST /api/pipeline/save-as-template — write the current model as a NEW
954
+ // discoverable template file (COMP-PIPE-EDIT-7). Body:
955
+ // { filename, model, metadata: { id, label?, description?, category? } }
956
+ // Create-only (refuses to overwrite); refuses an id colliding with any existing
957
+ // spec's metadata.id (the template system keys on metadata.id). Emits a REAL
958
+ // `metadata:` key so TemplateSelector / loadTemplates surface it.
959
+ app.post('/api/pipeline/save-as-template', (req, res) => {
960
+ const { filename, model, metadata } = req.body || {};
961
+
962
+ if (!filename || typeof filename !== 'string'
963
+ || basename(filename) !== filename || !filename.endsWith('.stratum.yaml')) {
964
+ return res.status(400).json({ error: 'filename must be a bare *.stratum.yaml filename' });
965
+ }
966
+ if (!model || !Array.isArray(model.flows)) {
967
+ return res.status(400).json({ error: 'model with flows[] is required' });
968
+ }
969
+ if (!metadata || typeof metadata !== 'object' || !metadata.id || typeof metadata.id !== 'string') {
970
+ return res.status(400).json({ error: 'metadata.id is required' });
971
+ }
972
+
973
+ const pipelinesDir = getPipelinesDir();
974
+ const filePath = join(pipelinesDir, filename);
975
+
976
+ // Symlink/containment guard FIRST: refuse a target that is a symlink or whose
977
+ // resolved parent escapes the pipelines dir, before the create-only check (so a
978
+ // symlink-escape reports as such, not as "already exists").
979
+ {
980
+ const guard = assertWithinPipelines(pipelinesDir, filePath, { mustExist: false });
981
+ if (!guard.ok) return res.status(400).json({ error: guard.error });
982
+ }
983
+
984
+ // Create-only: refuse to overwrite an existing file (editing is /save).
985
+ if (existsSync(filePath)) {
986
+ return res.status(400).json({ error: `File "${filename}" already exists (create-only; use /save to edit)` });
987
+ }
988
+
989
+ // Refuse a metadata.id that collides with any existing spec's metadata.id.
990
+ let existingFiles;
991
+ try {
992
+ existingFiles = readdirSync(pipelinesDir).filter(f => f.endsWith('.stratum.yaml'));
993
+ } catch {
994
+ existingFiles = [];
995
+ }
996
+ for (const f of existingFiles) {
997
+ try {
998
+ const parsed = YAML.parse(readFileSync(join(pipelinesDir, f), 'utf-8'));
999
+ if (parsed?.metadata?.id === metadata.id) {
1000
+ return res.status(409).json({ error: `metadata.id "${metadata.id}" already used by ${f}` });
1001
+ }
1002
+ } catch {
1003
+ // skip unparseable files
1004
+ }
1005
+ }
1006
+
1007
+ // Build the spec object: version/flows passthrough from the model, then a
1008
+ // REAL metadata key + the serialized contracts block.
1009
+ const specObj = buildSpecObjectFromModel(model);
1010
+ const ordered = { metadata, ...specObj };
1011
+ ordered.contracts = serializeModelContracts(model);
1012
+
1013
+ let text;
1014
+ try {
1015
+ text = YAML.stringify(ordered);
1016
+ YAML.parse(text); // parse gate — never write something that won't reload
1017
+ } catch (e) {
1018
+ return res.status(400).json({ error: `Refusing to write invalid YAML: ${e.message}` });
1019
+ }
1020
+
1021
+ writeFileSync(filePath, text);
1022
+ res.json({ ok: true, file: filename });
1023
+ });
1024
+ }
1025
+
1026
+ /**
1027
+ * Reconstruct a plain spec object from the model for template serialization
1028
+ * (COMP-PIPE-EDIT-7). Mirrors src/lib/pipeline-model.js `modelToSpecObject`:
1029
+ * version/flows/functions passthrough from `_doc`, with each flow's steps
1030
+ * rebuilt from the model. `metadata` and `contracts` are set by the caller.
1031
+ */
1032
+ function buildSpecObjectFromModel(model) {
1033
+ const doc = (model && model._doc && typeof model._doc === 'object') ? model._doc : {};
1034
+ const out = { ...doc };
1035
+ // Don't carry a parsed metadata/contracts through — the caller sets fresh ones.
1036
+ delete out.metadata;
1037
+ delete out.contracts;
1038
+
1039
+ const byName = new Map((model.flows || []).map(f => [f.name, f]));
1040
+
1041
+ if (Array.isArray(doc?.workflow?.steps)) {
1042
+ const wfName = doc.workflow.name || 'workflow';
1043
+ const flow = byName.get(wfName);
1044
+ if (flow) out.workflow = { ...doc.workflow, steps: (flow.steps || []).map(denormalizeModelStep) };
1045
+ }
1046
+
1047
+ if (doc?.flows && typeof doc.flows === 'object') {
1048
+ out.flows = {};
1049
+ for (const name of Object.keys(doc.flows)) {
1050
+ const flow = byName.get(name);
1051
+ out.flows[name] = flow
1052
+ ? { ...doc.flows[name], steps: (flow.steps || []).map(denormalizeModelStep) }
1053
+ : doc.flows[name];
1054
+ }
1055
+ }
1056
+
1057
+ return out;
288
1058
  }