@tagma/sdk 0.7.0 → 0.7.3
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 +84 -44
- package/dist/bootstrap.d.ts +20 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +21 -11
- package/dist/bootstrap.js.map +1 -1
- package/dist/core/dataflow.d.ts.map +1 -1
- package/dist/core/dataflow.js +45 -9
- package/dist/core/dataflow.js.map +1 -1
- package/dist/core/run-context.d.ts +3 -0
- package/dist/core/run-context.d.ts.map +1 -1
- package/dist/core/run-context.js +2 -0
- package/dist/core/run-context.js.map +1 -1
- package/dist/core/task-executor.d.ts.map +1 -1
- package/dist/core/task-executor.js +46 -84
- package/dist/core/task-executor.js.map +1 -1
- package/dist/engine.d.ts +6 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +3 -0
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugins.d.ts +2 -2
- package/dist/plugins.d.ts.map +1 -1
- package/dist/ports.d.ts +4 -0
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +27 -4
- package/dist/ports.js.map +1 -1
- package/dist/registry.d.ts +10 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +64 -25
- package/dist/registry.js.map +1 -1
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +8 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -7
- package/dist/schema.js.map +1 -1
- package/dist/tagma.d.ts +11 -1
- package/dist/tagma.d.ts.map +1 -1
- package/dist/tagma.js +6 -0
- package/dist/tagma.js.map +1 -1
- package/dist/validate-raw.d.ts +4 -4
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +89 -230
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +23 -14
- package/src/core/dataflow.test.ts +8 -9
- package/src/core/dataflow.ts +57 -14
- package/src/core/run-context.test.ts +12 -0
- package/src/core/run-context.ts +4 -0
- package/src/core/task-executor.ts +75 -135
- package/src/engine-ports-mixed.test.ts +68 -411
- package/src/engine-ports.test.ts +37 -341
- package/src/engine.ts +8 -0
- package/src/index.ts +5 -0
- package/src/pipeline-runner.test.ts +5 -9
- package/src/plugin-registry.test.ts +138 -1
- package/src/plugins.ts +5 -2
- package/src/ports.test.ts +80 -0
- package/src/ports.ts +36 -4
- package/src/registry.ts +81 -26
- package/src/runtime.ts +20 -0
- package/src/schema-ports.test.ts +47 -197
- package/src/schema.ts +1 -7
- package/src/tagma.test.ts +72 -1
- package/src/tagma.ts +16 -1
- package/src/validate-raw-ports.test.ts +80 -393
- package/src/validate-raw.ts +90 -250
package/dist/validate-raw.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 鈺愨晲鈺?Raw Pipeline Config Validation 鈺愨晲鈺?
|
|
2
2
|
//
|
|
3
3
|
// Validates a RawPipelineConfig without resolving inheritance or executing
|
|
4
|
-
// anything
|
|
4
|
+
// anything 鈥?intended for real-time feedback in a visual editor (e.g. drag
|
|
5
5
|
// to add a task, live error highlighting).
|
|
6
6
|
//
|
|
7
7
|
// Returns a flat list of ValidationError objects. An empty array means valid.
|
|
8
8
|
import { isValidTaskId, qualifyTaskId, buildTaskIndex, resolveTaskRef, } from './task-ref';
|
|
9
9
|
import { extractInputReferences } from './ports';
|
|
10
|
-
/** qid
|
|
10
|
+
/** qid 鈫?{track, task} lookup built once per validation pass. */
|
|
11
11
|
function buildQidIndex(config) {
|
|
12
12
|
const idx = new Map();
|
|
13
13
|
for (const track of config.tracks ?? []) {
|
|
@@ -29,7 +29,7 @@ function isValidDuration(input) {
|
|
|
29
29
|
}
|
|
30
30
|
// D8: IDs may only contain letters, digits, underscores, and hyphens, and must
|
|
31
31
|
// start with a letter or underscore. Dots are explicitly forbidden because the
|
|
32
|
-
// engine uses "trackId.taskId" as the qualified separator
|
|
32
|
+
// engine uses "trackId.taskId" as the qualified separator 鈥?a dot in either
|
|
33
33
|
// part creates an ambiguous qualified ID and breaks resolveRef.
|
|
34
34
|
// Canonical regex and helper live in ./task-ref so every resolver (dag.ts,
|
|
35
35
|
// engine.ts, editor) stays in lockstep with what we accept here.
|
|
@@ -56,7 +56,7 @@ const BUILTIN_DRIVER_TYPES = new Set(['opencode']);
|
|
|
56
56
|
*
|
|
57
57
|
* Plugin type checks: when `knownTypes` is provided, task/track references to
|
|
58
58
|
* trigger/completion/middleware types that are neither built-in nor in the
|
|
59
|
-
* supplied set produce a soft warning (severity: 'warning')
|
|
59
|
+
* supplied set produce a soft warning (severity: 'warning') 鈥?these don't
|
|
60
60
|
* block save/run but light up the Task panel so users discover the broken
|
|
61
61
|
* reference in the editor instead of at run time. Omit `knownTypes` to skip
|
|
62
62
|
* plugin checks entirely (offline/pre-load validation).
|
|
@@ -75,7 +75,7 @@ export function validateRaw(config, knownTypes) {
|
|
|
75
75
|
const knownMiddlewares = knownTypes
|
|
76
76
|
? new Set([...BUILTIN_MIDDLEWARE_TYPES, ...(knownTypes.middlewares ?? [])])
|
|
77
77
|
: null;
|
|
78
|
-
//
|
|
78
|
+
// 鈹€鈹€ Top level 鈹€鈹€
|
|
79
79
|
if (!config.name?.trim()) {
|
|
80
80
|
errors.push({ path: 'name', message: 'Pipeline name is required' });
|
|
81
81
|
}
|
|
@@ -101,15 +101,15 @@ export function validateRaw(config, knownTypes) {
|
|
|
101
101
|
errors.push({ path: 'tracks', message: 'At least one track is required' });
|
|
102
102
|
return errors; // No point going further without tracks
|
|
103
103
|
}
|
|
104
|
-
//
|
|
104
|
+
// 鈹€鈹€ Build qualified ID sets for cross-reference checks 鈹€鈹€
|
|
105
105
|
// Qualified ID format: "trackId.taskId" (mirrors the engine's convention).
|
|
106
|
-
// Shared with dag.ts so "ambiguous" / "not found" stay consistent
|
|
106
|
+
// Shared with dag.ts so "ambiguous" / "not found" stay consistent 鈥?refs
|
|
107
107
|
// that buildDag later throws on will be reported here as errors first.
|
|
108
108
|
const index = buildTaskIndex(config);
|
|
109
|
-
// Full qid
|
|
109
|
+
// Full qid 鈫?{track, task} index used by port-inference validation
|
|
110
110
|
// to walk a Prompt task's neighbors without re-scanning the tracks.
|
|
111
111
|
const qidIndex = buildQidIndex(config);
|
|
112
|
-
//
|
|
112
|
+
// 鈹€鈹€ Per-track validation 鈹€鈹€
|
|
113
113
|
const seenTrackIds = new Set();
|
|
114
114
|
for (let ti = 0; ti < config.tracks.length; ti++) {
|
|
115
115
|
const maybeTrack = config.tracks[ti];
|
|
@@ -158,7 +158,7 @@ export function validateRaw(config, knownTypes) {
|
|
|
158
158
|
}
|
|
159
159
|
validatePermissions(track.permissions, `${trackPath}.permissions`, errors);
|
|
160
160
|
// Track-level middlewares can reference a plugin that was uninstalled
|
|
161
|
-
// after the YAML was written
|
|
161
|
+
// after the YAML was written 鈥?surface a warning so the user notices
|
|
162
162
|
// before hitting Run.
|
|
163
163
|
if (knownMiddlewares && track.middlewares) {
|
|
164
164
|
for (let mi = 0; mi < track.middlewares.length; mi++) {
|
|
@@ -166,7 +166,7 @@ export function validateRaw(config, knownTypes) {
|
|
|
166
166
|
if (mw?.type && !knownMiddlewares.has(mw.type)) {
|
|
167
167
|
errors.push({
|
|
168
168
|
path: `${trackPath}.middlewares[${mi}].type`,
|
|
169
|
-
message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference
|
|
169
|
+
message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference 鈥?the pipeline will fail at run time.`,
|
|
170
170
|
severity: 'warning',
|
|
171
171
|
});
|
|
172
172
|
}
|
|
@@ -186,7 +186,7 @@ export function validateRaw(config, knownTypes) {
|
|
|
186
186
|
});
|
|
187
187
|
continue;
|
|
188
188
|
}
|
|
189
|
-
//
|
|
189
|
+
// 鈹€鈹€ Per-task validation 鈹€鈹€
|
|
190
190
|
const seenTaskIds = new Set();
|
|
191
191
|
for (let ki = 0; ki < track.tasks.length; ki++) {
|
|
192
192
|
const task = track.tasks[ki];
|
|
@@ -236,7 +236,7 @@ export function validateRaw(config, knownTypes) {
|
|
|
236
236
|
message: `Task "${task.id}": command content cannot be empty`,
|
|
237
237
|
});
|
|
238
238
|
}
|
|
239
|
-
//
|
|
239
|
+
// 鈹€鈹€ Field-level validations 鈹€鈹€
|
|
240
240
|
if (task.timeout && !isValidDuration(task.timeout)) {
|
|
241
241
|
errors.push({
|
|
242
242
|
path: `${taskPath}.timeout`,
|
|
@@ -257,7 +257,7 @@ export function validateRaw(config, knownTypes) {
|
|
|
257
257
|
});
|
|
258
258
|
}
|
|
259
259
|
validatePermissions(task.permissions, `${taskPath}.permissions`, errors);
|
|
260
|
-
//
|
|
260
|
+
// 鈹€鈹€ Plugin type warnings (trigger / completion / middlewares) 鈹€鈹€
|
|
261
261
|
// Only fire when the host supplied a `knownTypes` snapshot, so offline
|
|
262
262
|
// validation stays quiet. The messages deliberately name the npm
|
|
263
263
|
// scope so users can copy-paste the install command.
|
|
@@ -283,45 +283,45 @@ export function validateRaw(config, knownTypes) {
|
|
|
283
283
|
if (mw?.type && !knownMiddlewares.has(mw.type)) {
|
|
284
284
|
errors.push({
|
|
285
285
|
path: `${taskPath}.middlewares[${mi}].type`,
|
|
286
|
-
message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference
|
|
286
|
+
message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference 鈥?the pipeline will fail at run time.`,
|
|
287
287
|
severity: 'warning',
|
|
288
288
|
});
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
|
-
//
|
|
292
|
+
// 鈹€鈹€ Port declaration checks 鈹€鈹€
|
|
293
293
|
validateTaskPorts(task, track.id, taskPath, qidIndex, index, errors);
|
|
294
|
-
//
|
|
294
|
+
// 鈹€鈹€ depends_on reference checks 鈹€鈹€
|
|
295
295
|
if (task.depends_on && task.depends_on.length > 0) {
|
|
296
296
|
for (const dep of task.depends_on) {
|
|
297
297
|
const resolved = resolveTaskRef(dep, track.id, index);
|
|
298
298
|
if (resolved.kind === 'not_found') {
|
|
299
299
|
errors.push({
|
|
300
300
|
path: `${taskPath}.depends_on`,
|
|
301
|
-
message: `Task "${task.id}": depends_on "${dep}"
|
|
301
|
+
message: `Task "${task.id}": depends_on "${dep}" 鈥?no such task found`,
|
|
302
302
|
});
|
|
303
303
|
}
|
|
304
304
|
else if (resolved.kind === 'ambiguous') {
|
|
305
305
|
errors.push({
|
|
306
306
|
path: `${taskPath}.depends_on`,
|
|
307
|
-
message: `Task "${task.id}": depends_on "${dep}" is ambiguous
|
|
307
|
+
message: `Task "${task.id}": depends_on "${dep}" is ambiguous 鈥?multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
|
|
308
308
|
});
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
|
-
//
|
|
312
|
+
// 鈹€鈹€ continue_from reference check 鈹€鈹€
|
|
313
313
|
if (task.continue_from) {
|
|
314
314
|
const resolved = resolveTaskRef(task.continue_from, track.id, index);
|
|
315
315
|
if (resolved.kind === 'not_found') {
|
|
316
316
|
errors.push({
|
|
317
317
|
path: `${taskPath}.continue_from`,
|
|
318
|
-
message: `Task "${task.id}": continue_from "${task.continue_from}"
|
|
318
|
+
message: `Task "${task.id}": continue_from "${task.continue_from}" 鈥?no such task found`,
|
|
319
319
|
});
|
|
320
320
|
}
|
|
321
321
|
else if (resolved.kind === 'ambiguous') {
|
|
322
322
|
errors.push({
|
|
323
323
|
path: `${taskPath}.continue_from`,
|
|
324
|
-
message: `Task "${task.id}": continue_from "${task.continue_from}" is ambiguous
|
|
324
|
+
message: `Task "${task.id}": continue_from "${task.continue_from}" is ambiguous 鈥?multiple tracks have a task with this id. Use the fully-qualified form "trackId.${task.continue_from}".`,
|
|
325
325
|
});
|
|
326
326
|
}
|
|
327
327
|
else if (!task.depends_on ||
|
|
@@ -343,7 +343,7 @@ export function validateRaw(config, knownTypes) {
|
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
|
-
//
|
|
346
|
+
// 鈹€鈹€ Cycle detection 鈹€鈹€
|
|
347
347
|
errors.push(...detectCycles(config, index));
|
|
348
348
|
return errors;
|
|
349
349
|
}
|
|
@@ -376,84 +376,11 @@ const VALID_PORT_TYPES = new Set([
|
|
|
376
376
|
'enum',
|
|
377
377
|
'json',
|
|
378
378
|
]);
|
|
379
|
-
// Identifier pattern for port names. Deliberately narrower than task IDs
|
|
379
|
+
// Identifier pattern for port names. Deliberately narrower than task IDs 鈥?
|
|
380
380
|
// port names appear in `{{inputs.<name>}}` templates where hyphens would
|
|
381
381
|
// be parsed as subtraction, so we also forbid them here to keep the
|
|
382
382
|
// template grammar unambiguous.
|
|
383
383
|
const PORT_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
384
|
-
function validatePortList(list, basePath, kind, errors) {
|
|
385
|
-
if (!list)
|
|
386
|
-
return;
|
|
387
|
-
if (!Array.isArray(list)) {
|
|
388
|
-
errors.push({
|
|
389
|
-
path: basePath,
|
|
390
|
-
message: `ports.${kind} must be an array`,
|
|
391
|
-
});
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
const seen = new Set();
|
|
395
|
-
for (let i = 0; i < list.length; i++) {
|
|
396
|
-
const port = list[i];
|
|
397
|
-
const path = `${basePath}[${i}]`;
|
|
398
|
-
if (!port || typeof port !== 'object') {
|
|
399
|
-
errors.push({ path, message: `ports.${kind}[${i}] must be an object` });
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
if (typeof port.name !== 'string' || !port.name.trim()) {
|
|
403
|
-
errors.push({ path: `${path}.name`, message: 'port.name is required' });
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
if (!PORT_NAME_RE.test(port.name)) {
|
|
407
|
-
errors.push({
|
|
408
|
-
path: `${path}.name`,
|
|
409
|
-
message: `port name "${port.name}" is invalid. Must match /^[A-Za-z_][A-Za-z0-9_]*$/ (letters, digits, underscores; starts with letter/underscore).`,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
if (seen.has(port.name)) {
|
|
413
|
-
errors.push({
|
|
414
|
-
path,
|
|
415
|
-
message: `Duplicate ports.${kind} name "${port.name}"`,
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
seen.add(port.name);
|
|
419
|
-
if (!VALID_PORT_TYPES.has(port.type)) {
|
|
420
|
-
errors.push({
|
|
421
|
-
path: `${path}.type`,
|
|
422
|
-
message: `port "${port.name}": type must be one of ${[...VALID_PORT_TYPES].join(', ')} (got ${JSON.stringify(port.type)})`,
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
if (port.type === 'enum') {
|
|
426
|
-
if (!Array.isArray(port.enum) || port.enum.length === 0) {
|
|
427
|
-
errors.push({
|
|
428
|
-
path: `${path}.enum`,
|
|
429
|
-
message: `port "${port.name}": enum type requires a non-empty "enum" array`,
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
else if (port.enum.some((v) => typeof v !== 'string')) {
|
|
433
|
-
errors.push({
|
|
434
|
-
path: `${path}.enum`,
|
|
435
|
-
message: `port "${port.name}": enum values must all be strings`,
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
if (kind === 'outputs' && (port.required === true || port.from !== undefined)) {
|
|
440
|
-
// `required` / `from` are input-only concepts — outputs are
|
|
441
|
-
// always "produced when the task succeeds". Warn softly so the
|
|
442
|
-
// YAML doesn't silently accept meaningless fields.
|
|
443
|
-
errors.push({
|
|
444
|
-
path,
|
|
445
|
-
severity: 'warning',
|
|
446
|
-
message: `port "${port.name}": "required" and "from" are input-only; ignored on outputs`,
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
if (port.from !== undefined && typeof port.from !== 'string') {
|
|
450
|
-
errors.push({
|
|
451
|
-
path: `${path}.from`,
|
|
452
|
-
message: `port "${port.name}": "from" must be a string (got ${typeof port.from})`,
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
384
|
function validateBindingMap(value, basePath, kind, errors) {
|
|
458
385
|
if (value === undefined)
|
|
459
386
|
return;
|
|
@@ -484,6 +411,26 @@ function validateBindingMap(value, basePath, kind, errors) {
|
|
|
484
411
|
message: `task.inputs.${name}.required must be a boolean`,
|
|
485
412
|
});
|
|
486
413
|
}
|
|
414
|
+
if ('type' in binding && binding.type !== undefined && !VALID_PORT_TYPES.has(binding.type)) {
|
|
415
|
+
errors.push({
|
|
416
|
+
path: `${path}.type`,
|
|
417
|
+
message: `task.${kind}.${name}.type must be one of ${[...VALID_PORT_TYPES].join(', ')}`,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
if (binding.type === 'enum') {
|
|
421
|
+
if (!Array.isArray(binding.enum) || binding.enum.length === 0) {
|
|
422
|
+
errors.push({
|
|
423
|
+
path: `${path}.enum`,
|
|
424
|
+
message: `task.${kind}.${name}.enum must be a non-empty string array when type is enum`,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
else if (!binding.enum.every((v) => typeof v === 'string')) {
|
|
428
|
+
errors.push({
|
|
429
|
+
path: `${path}.enum`,
|
|
430
|
+
message: `task.${kind}.${name}.enum values must all be strings`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
487
434
|
if (kind === 'outputs' && typeof binding.from === 'string') {
|
|
488
435
|
const source = binding.from;
|
|
489
436
|
const ok = source === 'stdout' ||
|
|
@@ -499,28 +446,6 @@ function validateBindingMap(value, basePath, kind, errors) {
|
|
|
499
446
|
}
|
|
500
447
|
}
|
|
501
448
|
}
|
|
502
|
-
function validateBindingPortNameOverlap(task, taskPath, errors) {
|
|
503
|
-
const looseInputs = objectKeys(task.inputs);
|
|
504
|
-
const looseOutputs = objectKeys(task.outputs);
|
|
505
|
-
const strictInputs = new Set(Array.isArray(task.ports?.inputs) ? task.ports.inputs.map((p) => p?.name) : []);
|
|
506
|
-
const strictOutputs = new Set(Array.isArray(task.ports?.outputs) ? task.ports.outputs.map((p) => p?.name) : []);
|
|
507
|
-
for (const name of looseInputs) {
|
|
508
|
-
if (strictInputs.has(name)) {
|
|
509
|
-
errors.push({
|
|
510
|
-
path: `${taskPath}.inputs.${name}`,
|
|
511
|
-
message: `task input binding "${name}" duplicates strict ports.inputs; choose one layer for this name`,
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
for (const name of looseOutputs) {
|
|
516
|
-
if (strictOutputs.has(name)) {
|
|
517
|
-
errors.push({
|
|
518
|
-
path: `${taskPath}.outputs.${name}`,
|
|
519
|
-
message: `task output binding "${name}" duplicates strict ports.outputs; choose one layer for this name`,
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
449
|
function objectKeys(value) {
|
|
525
450
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
526
451
|
return [];
|
|
@@ -570,29 +495,19 @@ function validateTaskPorts(task, trackId, taskPath, qidIndex, index, errors) {
|
|
|
570
495
|
const isCommandTask = typeof task.command === 'string' && typeof task.prompt !== 'string';
|
|
571
496
|
validateBindingMap(task.inputs, `${taskPath}.inputs`, 'inputs', errors);
|
|
572
497
|
validateBindingMap(task.outputs, `${taskPath}.outputs`, 'outputs', errors);
|
|
573
|
-
validateBindingPortNameOverlap(task, taskPath, errors);
|
|
574
498
|
validateInputBindingSources(task, trackId, taskPath, index, errors);
|
|
575
|
-
|
|
576
|
-
//
|
|
577
|
-
// A Prompt Task's I/O contract is inferred from direct-neighbor
|
|
578
|
-
// Command Tasks at runtime (see `inferPromptPorts` in ports.ts).
|
|
579
|
-
// Declaring `ports` on a Prompt Task is always a configuration
|
|
580
|
-
// mistake: the declared shape would be silently ignored in favour of
|
|
581
|
-
// the inferred one, and the two drifting out of sync is the exact bug
|
|
582
|
-
// the inference design eliminates.
|
|
583
|
-
if (isPromptTask && ports !== undefined) {
|
|
499
|
+
if (ports !== undefined) {
|
|
584
500
|
errors.push({
|
|
585
501
|
path: `${taskPath}.ports`,
|
|
586
|
-
message: `Task "${task.id}":
|
|
587
|
-
`
|
|
588
|
-
`and declare the corresponding inputs/outputs on the upstream/downstream ` +
|
|
589
|
-
`Command tasks instead.`,
|
|
502
|
+
message: `Task "${task.id}": ports has been replaced by typed inputs/outputs. ` +
|
|
503
|
+
`Move ports.inputs entries to task.inputs.<name> and ports.outputs entries to task.outputs.<name>.`,
|
|
590
504
|
});
|
|
505
|
+
return;
|
|
591
506
|
}
|
|
592
|
-
//
|
|
507
|
+
// Collect placeholder references 鈹€鈹€
|
|
593
508
|
// `{{inputs.X}}` is valid in both prompt and command text. The set of
|
|
594
509
|
// names a task may legally reference differs by task kind:
|
|
595
|
-
// - Command Task: its own declared `
|
|
510
|
+
// - Command Task: its own declared `inputs`
|
|
596
511
|
// - Prompt Task: the union of direct-upstream Command outputs
|
|
597
512
|
const referenced = new Set();
|
|
598
513
|
if (typeof task.prompt === 'string') {
|
|
@@ -611,74 +526,23 @@ function validateTaskPorts(task, trackId, taskPath, qidIndex, index, errors) {
|
|
|
611
526
|
}
|
|
612
527
|
else {
|
|
613
528
|
// Command Task (or the pathological both-keys case, which is caught
|
|
614
|
-
// earlier as a separate error
|
|
615
|
-
availableInputs = new Set(
|
|
616
|
-
? ports.inputs.filter((p) => !!p && typeof p === 'object').map((p) => p.name)
|
|
617
|
-
: []);
|
|
529
|
+
// earlier as a separate error 鈥?tolerate it here).
|
|
530
|
+
availableInputs = new Set();
|
|
618
531
|
for (const name of objectKeys(task.inputs))
|
|
619
532
|
availableInputs.add(name);
|
|
620
533
|
}
|
|
621
534
|
for (const name of referenced) {
|
|
622
535
|
if (!availableInputs.has(name)) {
|
|
623
536
|
const hint = isPromptTask
|
|
624
|
-
? `no upstream Command task exports an output
|
|
625
|
-
: `no such input
|
|
537
|
+
? `no upstream Command task exports an output named "${name}"`
|
|
538
|
+
: `no such input is declared`;
|
|
626
539
|
errors.push({
|
|
627
540
|
path: taskPath,
|
|
628
541
|
message: `Task "${task.id}": references "{{inputs.${name}}}" but ${hint}`,
|
|
629
542
|
});
|
|
630
543
|
}
|
|
631
544
|
}
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
// Prompt tasks already errored above if they tried to declare ports;
|
|
635
|
-
// running the per-port structural validator on the ignored object
|
|
636
|
-
// would just produce duplicate noise.
|
|
637
|
-
if (isCommandTask && ports) {
|
|
638
|
-
validatePortList(ports.inputs, `${taskPath}.ports.inputs`, 'inputs', errors);
|
|
639
|
-
validatePortList(ports.outputs, `${taskPath}.ports.outputs`, 'outputs', errors);
|
|
640
|
-
// Warn on declared-but-unused inputs. Not fatal — a user may want
|
|
641
|
-
// to surface an input as a data-flow hint for the editor even when
|
|
642
|
-
// the command doesn't template it explicitly.
|
|
643
|
-
if (typeof task.command === 'string' && Array.isArray(ports.inputs)) {
|
|
644
|
-
for (const port of ports.inputs) {
|
|
645
|
-
if (!port || typeof port !== 'object')
|
|
646
|
-
continue;
|
|
647
|
-
if (!referenced.has(port.name)) {
|
|
648
|
-
errors.push({
|
|
649
|
-
path: `${taskPath}.ports.inputs`,
|
|
650
|
-
severity: 'warning',
|
|
651
|
-
message: `Task "${task.id}": command does not reference {{inputs.${port.name}}} — declared input is unused`,
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
// Validate that fully-qualified `from` references point to direct
|
|
657
|
-
// dependencies. The runtime's findUpstreamValue only scans dependsOn,
|
|
658
|
-
// so a from that skips the dependency list will always miss at run
|
|
659
|
-
// time and block the task with a cryptic "missing required input".
|
|
660
|
-
if (Array.isArray(ports.inputs)) {
|
|
661
|
-
for (const port of ports.inputs) {
|
|
662
|
-
if (!port || typeof port !== 'object' || typeof port.from !== 'string' || !port.from.includes('.')) {
|
|
663
|
-
continue;
|
|
664
|
-
}
|
|
665
|
-
const dot = port.from.lastIndexOf('.');
|
|
666
|
-
const upstreamId = port.from.slice(0, dot);
|
|
667
|
-
const deps = task.depends_on ?? [];
|
|
668
|
-
const isDirectDep = deps.some((dep) => {
|
|
669
|
-
const resolved = resolveTaskRef(dep, trackId, index);
|
|
670
|
-
return resolved.kind === 'resolved' && resolved.qid === upstreamId;
|
|
671
|
-
});
|
|
672
|
-
if (!isDirectDep) {
|
|
673
|
-
errors.push({
|
|
674
|
-
path: `${taskPath}.ports.inputs`,
|
|
675
|
-
message: `Task "${task.id}": port "${port.name}" from "${port.from}" references task "${upstreamId}" which is not a direct dependency (must be listed in depends_on)`,
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
// ─── Prompt-task inferred-port conflict checks ──
|
|
545
|
+
// Prompt-task inferred-port conflict checks 鈹€鈹€
|
|
682
546
|
//
|
|
683
547
|
// Static counterparts to the runtime checks `inferPromptPorts` runs.
|
|
684
548
|
// These surface problems at author-time in the editor so the user
|
|
@@ -689,8 +553,8 @@ function validateTaskPorts(task, trackId, taskPath, qidIndex, index, errors) {
|
|
|
689
553
|
}
|
|
690
554
|
/**
|
|
691
555
|
* Walk the direct-upstream Commands of a Prompt Task and collect every
|
|
692
|
-
* output port name they export. Prompt upstreams contribute nothing
|
|
693
|
-
* they pass free text via continue_from, not structured ports
|
|
556
|
+
* output port name they export. Prompt upstreams contribute nothing 鈥?
|
|
557
|
+
* they pass free text via continue_from, not structured ports 鈥?so we
|
|
694
558
|
* skip them. This mirrors exactly what the engine does at runtime in
|
|
695
559
|
* `inferPromptPorts`, keeping the editor and runtime views aligned.
|
|
696
560
|
*/
|
|
@@ -703,27 +567,25 @@ function collectUpstreamCommandOutputNames(task, trackId, qidIndex, index) {
|
|
|
703
567
|
const entry = qidIndex.get(r.qid);
|
|
704
568
|
if (!entry)
|
|
705
569
|
continue;
|
|
706
|
-
// Only Command tasks contribute
|
|
570
|
+
// Only Command tasks contribute 鈥?Prompt upstreams pass free text.
|
|
707
571
|
if (typeof entry.task.command !== 'string')
|
|
708
572
|
continue;
|
|
709
|
-
const outputs = entry.task.
|
|
710
|
-
if (!Array.isArray(outputs))
|
|
573
|
+
const outputs = entry.task.outputs;
|
|
574
|
+
if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs))
|
|
711
575
|
continue;
|
|
712
|
-
for (const
|
|
713
|
-
|
|
714
|
-
names.add(port.name);
|
|
715
|
-
}
|
|
576
|
+
for (const name of Object.keys(outputs)) {
|
|
577
|
+
names.add(name);
|
|
716
578
|
}
|
|
717
579
|
}
|
|
718
580
|
return names;
|
|
719
581
|
}
|
|
720
582
|
/**
|
|
721
583
|
* Detect the two kinds of collision that would block a Prompt Task at
|
|
722
|
-
* runtime
|
|
584
|
+
* runtime 鈥?report them at validate-time so the editor lights them up
|
|
723
585
|
* before a run is attempted.
|
|
724
586
|
*
|
|
725
587
|
* 1. Input collision: two direct-upstream Commands both export an
|
|
726
|
-
* output with the same name. Command
|
|
588
|
+
* output with the same name. Command鈫扖ommand would let the
|
|
727
589
|
* downstream disambiguate with `from:`; Prompt tasks have no port
|
|
728
590
|
* declarations and therefore no escape hatch.
|
|
729
591
|
* 2. Output collision: two direct-downstream Commands declare inputs
|
|
@@ -731,7 +593,7 @@ function collectUpstreamCommandOutputNames(task, trackId, qidIndex, index) {
|
|
|
731
593
|
* different enum sets). A single LLM emission cannot satisfy both.
|
|
732
594
|
*/
|
|
733
595
|
function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex, index, errors) {
|
|
734
|
-
//
|
|
596
|
+
// 鈹€鈹€鈹€ Input collision 鈹€鈹€
|
|
735
597
|
const producersByName = new Map();
|
|
736
598
|
for (const dep of task.depends_on ?? []) {
|
|
737
599
|
const r = resolveTaskRef(dep, trackId, index);
|
|
@@ -740,15 +602,13 @@ function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex,
|
|
|
740
602
|
const entry = qidIndex.get(r.qid);
|
|
741
603
|
if (!entry || typeof entry.task.command !== 'string')
|
|
742
604
|
continue;
|
|
743
|
-
const outputs = entry.task.
|
|
744
|
-
if (!Array.isArray(outputs))
|
|
605
|
+
const outputs = entry.task.outputs;
|
|
606
|
+
if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs))
|
|
745
607
|
continue;
|
|
746
|
-
for (const
|
|
747
|
-
|
|
748
|
-
continue;
|
|
749
|
-
const list = producersByName.get(port.name) ?? [];
|
|
608
|
+
for (const name of Object.keys(outputs)) {
|
|
609
|
+
const list = producersByName.get(name) ?? [];
|
|
750
610
|
list.push(r.qid);
|
|
751
|
-
producersByName.set(
|
|
611
|
+
producersByName.set(name, list);
|
|
752
612
|
}
|
|
753
613
|
}
|
|
754
614
|
for (const [name, producers] of producersByName) {
|
|
@@ -756,12 +616,12 @@ function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex,
|
|
|
756
616
|
errors.push({
|
|
757
617
|
path: taskPath,
|
|
758
618
|
message: `Task "${task.id}": upstream Commands ${producers.join(', ')} all export ` +
|
|
759
|
-
`"${name}"
|
|
619
|
+
`"${name}" 鈥?prompt tasks cannot disambiguate (no "from:" binding available). ` +
|
|
760
620
|
`Rename the output on one of the upstream Commands.`,
|
|
761
621
|
});
|
|
762
622
|
}
|
|
763
623
|
}
|
|
764
|
-
//
|
|
624
|
+
// 鈹€鈹€鈹€ Output collision 鈹€鈹€
|
|
765
625
|
//
|
|
766
626
|
// Walk every task in the pipeline once and check whether it depends on
|
|
767
627
|
// us. We reuse the shared qidIndex + TaskIndex for the lookup; small
|
|
@@ -786,39 +646,38 @@ function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex,
|
|
|
786
646
|
}
|
|
787
647
|
if (!dependsOnUs)
|
|
788
648
|
continue;
|
|
789
|
-
const inputs = entry.task.
|
|
790
|
-
if (!Array.isArray(inputs))
|
|
649
|
+
const inputs = entry.task.inputs;
|
|
650
|
+
if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs))
|
|
791
651
|
continue;
|
|
792
|
-
for (const
|
|
793
|
-
if (!
|
|
652
|
+
for (const [inputName, binding] of Object.entries(inputs)) {
|
|
653
|
+
if (!binding || typeof binding !== 'object' || Array.isArray(binding))
|
|
794
654
|
continue;
|
|
795
|
-
const shape =
|
|
796
|
-
const prior = consumerShapeByName.get(
|
|
655
|
+
const shape = bindingShapeKey(binding);
|
|
656
|
+
const prior = consumerShapeByName.get(inputName);
|
|
797
657
|
if (!prior) {
|
|
798
|
-
consumerShapeByName.set(
|
|
658
|
+
consumerShapeByName.set(inputName, { shape, firstConsumer: downstreamQid });
|
|
799
659
|
continue;
|
|
800
660
|
}
|
|
801
|
-
if (prior.shape !== shape && !reported.has(
|
|
802
|
-
reported.add(
|
|
661
|
+
if (prior.shape !== shape && !reported.has(inputName)) {
|
|
662
|
+
reported.add(inputName);
|
|
803
663
|
errors.push({
|
|
804
664
|
path: taskPath,
|
|
805
665
|
message: `Task "${task.id}": downstream Commands ${prior.firstConsumer} and ` +
|
|
806
|
-
`${downstreamQid} disagree on the shape of inferred output "${
|
|
666
|
+
`${downstreamQid} disagree on the shape of inferred output "${inputName}" 鈥?` +
|
|
807
667
|
`a single LLM emission cannot satisfy both. Rename on one side.`,
|
|
808
668
|
});
|
|
809
669
|
}
|
|
810
670
|
}
|
|
811
671
|
}
|
|
812
672
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
return String(port.type);
|
|
673
|
+
function bindingShapeKey(port) {
|
|
674
|
+
if ((port.type ?? 'json') !== 'enum')
|
|
675
|
+
return String(port.type ?? 'json');
|
|
817
676
|
const enums = Array.isArray(port.enum) ? [...port.enum].sort().join('|') : '';
|
|
818
677
|
return `enum:${enums}`;
|
|
819
678
|
}
|
|
820
679
|
function detectCycles(config, index) {
|
|
821
|
-
// Build adjacency: qualifiedId
|
|
680
|
+
// Build adjacency: qualifiedId 鈫?[resolved dep qualifiedIds]
|
|
822
681
|
const adj = new Map();
|
|
823
682
|
for (const track of config.tracks) {
|
|
824
683
|
if (!track.id)
|
|
@@ -847,7 +706,7 @@ function detectCycles(config, index) {
|
|
|
847
706
|
const visited = new Set();
|
|
848
707
|
const inStack = new Set();
|
|
849
708
|
// Deduplicate cycles: the same cycle can be discovered from multiple entry points.
|
|
850
|
-
// Canonical key = sorted node list joined
|
|
709
|
+
// Canonical key = sorted node list joined 鈥?order-independent fingerprint.
|
|
851
710
|
const seenCycles = new Set();
|
|
852
711
|
// Use a mutable path array instead of copying at each level (O(n) vs O(n^2)).
|
|
853
712
|
const pathStack = [];
|
|
@@ -864,7 +723,7 @@ function detectCycles(config, index) {
|
|
|
864
723
|
const display = [...uniqueNodes, id]; // include start for readable display
|
|
865
724
|
errors.push({
|
|
866
725
|
path: 'tracks',
|
|
867
|
-
message: `Circular dependency detected: ${display.join('
|
|
726
|
+
message: `Circular dependency detected: ${display.join(' 鈫?')}`,
|
|
868
727
|
});
|
|
869
728
|
}
|
|
870
729
|
return;
|