@smartmemory/compose 0.2.51 → 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.
- package/dist/assets/App-Ddhfx18X.js +889 -0
- package/dist/assets/{_baseUniq-CsOHc_iS.js → _baseUniq-CoEVVYyC.js} +1 -1
- package/dist/assets/{arc-BXht3LyE.js → arc-DFnGxuCB.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BV1r86-w.js → architectureDiagram-Q4EWVU46-66rx01Tn.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-BgcMJjAR.js → blockDiagram-DXYQGD6D-AE5PKSak.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-nBIRLJTv.js → c4Diagram-AHTNJAMY-DtDSNlOX.js} +1 -1
- package/dist/assets/channel-CScyWprq.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-CNmGyhrp.js → chunk-4BX2VUAB-oXU2npoL.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-MLT7I7w4.js → chunk-4TB4RGXK-5w2lZAjW.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-D8KuKVLa.js → chunk-55IACEB6-BEEk2FdH.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-TPlt1bS2.js → chunk-EDXVE4YY-Bse2kdRh.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-tYY8xcjr.js → chunk-FMBD7UC4-BAa7Edp6.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-yBPwT1AT.js → chunk-OYMX7WX6-CGr_YiAc.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-B4Wfybww.js → chunk-QZHKN3VN-BMWFLZ8t.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-DE6_5aqh.js → chunk-YZCP3GAM-CqjNXooj.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DibVYYdr.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DibVYYdr.js +1 -0
- package/dist/assets/clone-ZMALIFmw.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-DzzRyJtB.js → cose-bilkent-S5V4N54A-NYnH3mVW.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CTcTVGdU.js → dagre-KV5264BT--mrfaTHR.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-qR6mc8kJ.js → diagram-5BDNPKRD-DjXvyFzh.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-CUcGXyNb.js → diagram-G4DWMVQ6-BrrexEvm.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-B68cPonH.js → diagram-MMDJMWI5-D3Q0OT2M.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-q8Id8eFG.js → diagram-TYMM5635-DKTntmxE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CtxJi57K.js → erDiagram-SMLLAGMA-BWbR0dPj.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BzzO4JYU.js → flowDiagram-DWJPFMVM-Dj5M5XaY.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-Dn0wIyhR.js → ganttDiagram-T4ZO3ILL-DKppFh_5.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-tnNFXfMM.js → gitGraphDiagram-UUTBAWPF-9xQ9O25V.js} +1 -1
- package/dist/assets/{graph-Tq_bs_r0.js → graph-Cgmhvu1T.js} +1 -1
- package/dist/assets/index-BxRamj_i.css +1 -0
- package/dist/assets/{index-8UhRLbGq.js → index-D8uDfn8y.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BXLvLeS0.js → infoDiagram-42DDH7IO-DQYWVzVN.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-DWx-7BBy.js → ishikawaDiagram-UXIWVN3A-CK2IFFAP.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-C42hXNha.js → journeyDiagram-VCZTEJTY-DAH5Stkf.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-CEq930ew.js → kanban-definition-6JOO6SKY-Bi0aCqkv.js} +1 -1
- package/dist/assets/{layout-qtUgN9BC.js → layout-DN13RkA1.js} +1 -1
- package/dist/assets/{linear-D158F7OT.js → linear-ClGEGlS2.js} +1 -1
- package/dist/assets/{min-hESxN-c0.js → min-CXr-fUFS.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-d9SSh2nG.js → mindmap-definition-QFDTVHPH-Wjr-PgC6.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-gglmeMav.js → pieDiagram-DEJITSTG-CVipbIlr.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-CqFC_htU.js → quadrantDiagram-34T5L4WZ-CHJFyhm-.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-B1ZFIotK.js → requirementDiagram-MS252O5E-CU9Rlyq5.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BvItvZt5.js → sankeyDiagram-XADWPNL6-BSULeqST.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-CAIn5Z3U.js → sequenceDiagram-FGHM5R23-CCOKXvjj.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CyJrGzib.js → stateDiagram-FHFEXIEX-B91gKA7Q.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dc8ur9ta.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-k_0vJQAK.js → timeline-definition-GMOUNBTQ-C8SZmaLp.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-TZAW60b0.js → vennDiagram-DHZGUBPP-YvEPDlsM.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DzQEoG6X.js → wardley-RL74JXVD-TMBUq_w1.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-BAoVVIjZ.js → wardleyDiagram-NUSXRM2D-DzEkPrri.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CynkZcUb.js → xychartDiagram-5P7HB3ND-BIiCeiYk.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/release-manual.sh +98 -0
- package/server/file-watcher.js +33 -0
- package/server/index.js +7 -0
- package/server/pipeline-routes.js +773 -3
- package/dist/assets/App-CT0vXPgd.js +0 -724
- package/dist/assets/channel-Dd4XaiYv.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DshSQDF9.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DshSQDF9.js +0 -1
- package/dist/assets/clone-CBsbNGAa.js +0 -1
- package/dist/assets/index-CRqB9els.css +0 -1
- 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
|
}
|