@tagma/sdk 0.7.0 → 0.7.1
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/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/task-executor.d.ts.map +1 -1
- package/dist/core/task-executor.js +35 -51
- package/dist/core/task-executor.js.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/validate-raw.d.ts +4 -4
- package/dist/validate-raw.js +91 -132
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -2
- package/src/core/dataflow.test.ts +8 -9
- package/src/core/dataflow.ts +57 -14
- package/src/core/task-executor.ts +61 -95
- package/src/engine-ports-mixed.test.ts +68 -411
- package/src/engine-ports.test.ts +37 -341
- package/src/pipeline-runner.test.ts +5 -9
- package/src/ports.test.ts +80 -0
- package/src/ports.ts +36 -4
- package/src/schema-ports.test.ts +41 -214
- package/src/validate-raw-ports.test.ts +80 -393
- package/src/validate-raw.ts +93 -137
package/src/validate-raw.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.
|
|
@@ -27,7 +27,7 @@ interface QidEntry {
|
|
|
27
27
|
readonly task: RawTaskConfig;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** qid
|
|
30
|
+
/** qid 鈫?{track, task} lookup built once per validation pass. */
|
|
31
31
|
function buildQidIndex(config: RawPipelineConfig): Map<string, QidEntry> {
|
|
32
32
|
const idx = new Map<string, QidEntry>();
|
|
33
33
|
for (const track of config.tracks ?? []) {
|
|
@@ -48,7 +48,7 @@ function isValidDuration(input: string): boolean {
|
|
|
48
48
|
|
|
49
49
|
// D8: IDs may only contain letters, digits, underscores, and hyphens, and must
|
|
50
50
|
// start with a letter or underscore. Dots are explicitly forbidden because the
|
|
51
|
-
// engine uses "trackId.taskId" as the qualified separator
|
|
51
|
+
// engine uses "trackId.taskId" as the qualified separator 鈥?a dot in either
|
|
52
52
|
// part creates an ambiguous qualified ID and breaks resolveRef.
|
|
53
53
|
// Canonical regex and helper live in ./task-ref so every resolver (dag.ts,
|
|
54
54
|
// engine.ts, editor) stays in lockstep with what we accept here.
|
|
@@ -75,7 +75,7 @@ const BUILTIN_DRIVER_TYPES: ReadonlySet<string> = new Set(['opencode']);
|
|
|
75
75
|
* Optional second argument to `validateRaw`: the set of plugin types currently
|
|
76
76
|
* registered in the SDK runtime, keyed by category. Hosts (e.g. the editor
|
|
77
77
|
* server) pass this so `validateRaw` can emit a soft warning when a task
|
|
78
|
-
* references a type that isn't loaded
|
|
78
|
+
* references a type that isn't loaded 鈥?otherwise the Task panel would show
|
|
79
79
|
* no hint and the pipeline would only blow up at run time. Callers that
|
|
80
80
|
* legitimately validate a config offline (before plugins are loaded) can omit
|
|
81
81
|
* this argument and no plugin warnings will be produced.
|
|
@@ -95,11 +95,11 @@ export interface ValidationError {
|
|
|
95
95
|
message: string;
|
|
96
96
|
/**
|
|
97
97
|
* H8: not all "errors" are equally fatal. The DAG runtime is happy to
|
|
98
|
-
* insert implicit `continue_from
|
|
98
|
+
* insert implicit `continue_from 鈫?depends_on` ordering, so the matching
|
|
99
99
|
* validate-raw check is a *style* nit, not a hard failure. Severity lets
|
|
100
100
|
* the editor render it as a soft warning instead of blocking save / run.
|
|
101
101
|
* Existing call sites that don't read this field still treat every entry
|
|
102
|
-
* as fatal
|
|
102
|
+
* as fatal 鈥?defaulting `severity` to undefined preserves that behaviour.
|
|
103
103
|
*/
|
|
104
104
|
severity?: ValidationSeverity;
|
|
105
105
|
}
|
|
@@ -111,7 +111,7 @@ export interface ValidationError {
|
|
|
111
111
|
*
|
|
112
112
|
* Plugin type checks: when `knownTypes` is provided, task/track references to
|
|
113
113
|
* trigger/completion/middleware types that are neither built-in nor in the
|
|
114
|
-
* supplied set produce a soft warning (severity: 'warning')
|
|
114
|
+
* supplied set produce a soft warning (severity: 'warning') 鈥?these don't
|
|
115
115
|
* block save/run but light up the Task panel so users discover the broken
|
|
116
116
|
* reference in the editor instead of at run time. Omit `knownTypes` to skip
|
|
117
117
|
* plugin checks entirely (offline/pre-load validation).
|
|
@@ -135,7 +135,7 @@ export function validateRaw(
|
|
|
135
135
|
? new Set<string>([...BUILTIN_MIDDLEWARE_TYPES, ...(knownTypes.middlewares ?? [])])
|
|
136
136
|
: null;
|
|
137
137
|
|
|
138
|
-
//
|
|
138
|
+
// 鈹€鈹€ Top level 鈹€鈹€
|
|
139
139
|
if (!config.name?.trim()) {
|
|
140
140
|
errors.push({ path: 'name', message: 'Pipeline name is required' });
|
|
141
141
|
}
|
|
@@ -163,16 +163,16 @@ export function validateRaw(
|
|
|
163
163
|
return errors; // No point going further without tracks
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
//
|
|
166
|
+
// 鈹€鈹€ Build qualified ID sets for cross-reference checks 鈹€鈹€
|
|
167
167
|
// Qualified ID format: "trackId.taskId" (mirrors the engine's convention).
|
|
168
|
-
// Shared with dag.ts so "ambiguous" / "not found" stay consistent
|
|
168
|
+
// Shared with dag.ts so "ambiguous" / "not found" stay consistent 鈥?refs
|
|
169
169
|
// that buildDag later throws on will be reported here as errors first.
|
|
170
170
|
const index = buildTaskIndex(config);
|
|
171
|
-
// Full qid
|
|
171
|
+
// Full qid 鈫?{track, task} index used by port-inference validation
|
|
172
172
|
// to walk a Prompt task's neighbors without re-scanning the tracks.
|
|
173
173
|
const qidIndex = buildQidIndex(config);
|
|
174
174
|
|
|
175
|
-
//
|
|
175
|
+
// 鈹€鈹€ Per-track validation 鈹€鈹€
|
|
176
176
|
const seenTrackIds = new Set<string>();
|
|
177
177
|
for (let ti = 0; ti < config.tracks.length; ti++) {
|
|
178
178
|
const maybeTrack = config.tracks[ti] as unknown;
|
|
@@ -220,7 +220,7 @@ export function validateRaw(
|
|
|
220
220
|
validatePermissions(track.permissions, `${trackPath}.permissions`, errors);
|
|
221
221
|
|
|
222
222
|
// Track-level middlewares can reference a plugin that was uninstalled
|
|
223
|
-
// after the YAML was written
|
|
223
|
+
// after the YAML was written 鈥?surface a warning so the user notices
|
|
224
224
|
// before hitting Run.
|
|
225
225
|
if (knownMiddlewares && track.middlewares) {
|
|
226
226
|
for (let mi = 0; mi < track.middlewares.length; mi++) {
|
|
@@ -228,7 +228,7 @@ export function validateRaw(
|
|
|
228
228
|
if (mw?.type && !knownMiddlewares.has(mw.type)) {
|
|
229
229
|
errors.push({
|
|
230
230
|
path: `${trackPath}.middlewares[${mi}].type`,
|
|
231
|
-
message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference
|
|
231
|
+
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.`,
|
|
232
232
|
severity: 'warning',
|
|
233
233
|
});
|
|
234
234
|
}
|
|
@@ -250,7 +250,7 @@ export function validateRaw(
|
|
|
250
250
|
continue;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
//
|
|
253
|
+
// 鈹€鈹€ Per-task validation 鈹€鈹€
|
|
254
254
|
const seenTaskIds = new Set<string>();
|
|
255
255
|
for (let ki = 0; ki < track.tasks.length; ki++) {
|
|
256
256
|
const task = track.tasks[ki];
|
|
@@ -302,7 +302,7 @@ export function validateRaw(
|
|
|
302
302
|
});
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
//
|
|
305
|
+
// 鈹€鈹€ Field-level validations 鈹€鈹€
|
|
306
306
|
if (task.timeout && !isValidDuration(task.timeout)) {
|
|
307
307
|
errors.push({
|
|
308
308
|
path: `${taskPath}.timeout`,
|
|
@@ -324,7 +324,7 @@ export function validateRaw(
|
|
|
324
324
|
}
|
|
325
325
|
validatePermissions(task.permissions, `${taskPath}.permissions`, errors);
|
|
326
326
|
|
|
327
|
-
//
|
|
327
|
+
// 鈹€鈹€ Plugin type warnings (trigger / completion / middlewares) 鈹€鈹€
|
|
328
328
|
// Only fire when the host supplied a `knownTypes` snapshot, so offline
|
|
329
329
|
// validation stays quiet. The messages deliberately name the npm
|
|
330
330
|
// scope so users can copy-paste the install command.
|
|
@@ -352,46 +352,46 @@ export function validateRaw(
|
|
|
352
352
|
if (mw?.type && !knownMiddlewares.has(mw.type)) {
|
|
353
353
|
errors.push({
|
|
354
354
|
path: `${taskPath}.middlewares[${mi}].type`,
|
|
355
|
-
message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference
|
|
355
|
+
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.`,
|
|
356
356
|
severity: 'warning',
|
|
357
357
|
});
|
|
358
358
|
}
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
//
|
|
362
|
+
// 鈹€鈹€ Port declaration checks 鈹€鈹€
|
|
363
363
|
validateTaskPorts(task, track.id, taskPath, qidIndex, index, errors);
|
|
364
364
|
|
|
365
|
-
//
|
|
365
|
+
// 鈹€鈹€ depends_on reference checks 鈹€鈹€
|
|
366
366
|
if (task.depends_on && task.depends_on.length > 0) {
|
|
367
367
|
for (const dep of task.depends_on) {
|
|
368
368
|
const resolved = resolveTaskRef(dep, track.id, index);
|
|
369
369
|
if (resolved.kind === 'not_found') {
|
|
370
370
|
errors.push({
|
|
371
371
|
path: `${taskPath}.depends_on`,
|
|
372
|
-
message: `Task "${task.id}": depends_on "${dep}"
|
|
372
|
+
message: `Task "${task.id}": depends_on "${dep}" 鈥?no such task found`,
|
|
373
373
|
});
|
|
374
374
|
} else if (resolved.kind === 'ambiguous') {
|
|
375
375
|
errors.push({
|
|
376
376
|
path: `${taskPath}.depends_on`,
|
|
377
|
-
message: `Task "${task.id}": depends_on "${dep}" is ambiguous
|
|
377
|
+
message: `Task "${task.id}": depends_on "${dep}" is ambiguous 鈥?multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
|
|
378
378
|
});
|
|
379
379
|
}
|
|
380
380
|
}
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
-
//
|
|
383
|
+
// 鈹€鈹€ continue_from reference check 鈹€鈹€
|
|
384
384
|
if (task.continue_from) {
|
|
385
385
|
const resolved = resolveTaskRef(task.continue_from, track.id, index);
|
|
386
386
|
if (resolved.kind === 'not_found') {
|
|
387
387
|
errors.push({
|
|
388
388
|
path: `${taskPath}.continue_from`,
|
|
389
|
-
message: `Task "${task.id}": continue_from "${task.continue_from}"
|
|
389
|
+
message: `Task "${task.id}": continue_from "${task.continue_from}" 鈥?no such task found`,
|
|
390
390
|
});
|
|
391
391
|
} else if (resolved.kind === 'ambiguous') {
|
|
392
392
|
errors.push({
|
|
393
393
|
path: `${taskPath}.continue_from`,
|
|
394
|
-
message: `Task "${task.id}": continue_from "${task.continue_from}" is ambiguous
|
|
394
|
+
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}".`,
|
|
395
395
|
});
|
|
396
396
|
} else if (
|
|
397
397
|
!task.depends_on ||
|
|
@@ -415,7 +415,7 @@ export function validateRaw(
|
|
|
415
415
|
}
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
-
//
|
|
418
|
+
// 鈹€鈹€ Cycle detection 鈹€鈹€
|
|
419
419
|
errors.push(...detectCycles(config, index));
|
|
420
420
|
|
|
421
421
|
return errors;
|
|
@@ -455,7 +455,7 @@ const VALID_PORT_TYPES: ReadonlySet<PortType> = new Set([
|
|
|
455
455
|
'json',
|
|
456
456
|
]);
|
|
457
457
|
|
|
458
|
-
// Identifier pattern for port names. Deliberately narrower than task IDs
|
|
458
|
+
// Identifier pattern for port names. Deliberately narrower than task IDs 鈥?
|
|
459
459
|
// port names appear in `{{inputs.<name>}}` templates where hyphens would
|
|
460
460
|
// be parsed as subtraction, so we also forbid them here to keep the
|
|
461
461
|
// template grammar unambiguous.
|
|
@@ -520,7 +520,7 @@ function validatePortList(
|
|
|
520
520
|
}
|
|
521
521
|
}
|
|
522
522
|
if (kind === 'outputs' && (port.required === true || port.from !== undefined)) {
|
|
523
|
-
// `required` / `from` are input-only concepts
|
|
523
|
+
// `required` / `from` are input-only concepts 鈥?outputs are
|
|
524
524
|
// always "produced when the task succeeds". Warn softly so the
|
|
525
525
|
// YAML doesn't silently accept meaningless fields.
|
|
526
526
|
errors.push({
|
|
@@ -573,6 +573,25 @@ function validateBindingMap(
|
|
|
573
573
|
message: `task.inputs.${name}.required must be a boolean`,
|
|
574
574
|
});
|
|
575
575
|
}
|
|
576
|
+
if ('type' in binding && binding.type !== undefined && !VALID_PORT_TYPES.has(binding.type as PortType)) {
|
|
577
|
+
errors.push({
|
|
578
|
+
path: `${path}.type`,
|
|
579
|
+
message: `task.${kind}.${name}.type must be one of ${[...VALID_PORT_TYPES].join(', ')}`,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (binding.type === 'enum') {
|
|
583
|
+
if (!Array.isArray(binding.enum) || binding.enum.length === 0) {
|
|
584
|
+
errors.push({
|
|
585
|
+
path: `${path}.enum`,
|
|
586
|
+
message: `task.${kind}.${name}.enum must be a non-empty string array when type is enum`,
|
|
587
|
+
});
|
|
588
|
+
} else if (!binding.enum.every((v: unknown) => typeof v === 'string')) {
|
|
589
|
+
errors.push({
|
|
590
|
+
path: `${path}.enum`,
|
|
591
|
+
message: `task.${kind}.${name}.enum values must all be strings`,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
576
595
|
if (kind === 'outputs' && typeof binding.from === 'string') {
|
|
577
596
|
const source = binding.from;
|
|
578
597
|
const ok =
|
|
@@ -685,26 +704,17 @@ function validateTaskPorts(
|
|
|
685
704
|
validateBindingPortNameOverlap(task, taskPath, errors);
|
|
686
705
|
validateInputBindingSources(task, trackId, taskPath, index, errors);
|
|
687
706
|
|
|
688
|
-
|
|
689
|
-
//
|
|
690
|
-
// A Prompt Task's I/O contract is inferred from direct-neighbor
|
|
691
|
-
// Command Tasks at runtime (see `inferPromptPorts` in ports.ts).
|
|
692
|
-
// Declaring `ports` on a Prompt Task is always a configuration
|
|
693
|
-
// mistake: the declared shape would be silently ignored in favour of
|
|
694
|
-
// the inferred one, and the two drifting out of sync is the exact bug
|
|
695
|
-
// the inference design eliminates.
|
|
696
|
-
if (isPromptTask && ports !== undefined) {
|
|
707
|
+
if (ports !== undefined) {
|
|
697
708
|
errors.push({
|
|
698
709
|
path: `${taskPath}.ports`,
|
|
699
710
|
message:
|
|
700
|
-
`Task "${task.id}":
|
|
701
|
-
`
|
|
702
|
-
`and declare the corresponding inputs/outputs on the upstream/downstream ` +
|
|
703
|
-
`Command tasks instead.`,
|
|
711
|
+
`Task "${task.id}": ports has been replaced by typed inputs/outputs. ` +
|
|
712
|
+
`Move ports.inputs entries to task.inputs.<name> and ports.outputs entries to task.outputs.<name>.`,
|
|
704
713
|
});
|
|
714
|
+
return;
|
|
705
715
|
}
|
|
706
716
|
|
|
707
|
-
//
|
|
717
|
+
// Collect placeholder references 鈹€鈹€
|
|
708
718
|
// `{{inputs.X}}` is valid in both prompt and command text. The set of
|
|
709
719
|
// names a task may legally reference differs by task kind:
|
|
710
720
|
// - Command Task: its own declared `ports.inputs`
|
|
@@ -723,20 +733,16 @@ function validateTaskPorts(
|
|
|
723
733
|
for (const name of objectKeys(task.inputs)) availableInputs.add(name);
|
|
724
734
|
} else {
|
|
725
735
|
// Command Task (or the pathological both-keys case, which is caught
|
|
726
|
-
// earlier as a separate error
|
|
727
|
-
availableInputs = new Set<string>(
|
|
728
|
-
ports && Array.isArray(ports.inputs)
|
|
729
|
-
? ports.inputs.filter((p): p is PortDef => !!p && typeof p === 'object').map((p) => p.name)
|
|
730
|
-
: [],
|
|
731
|
-
);
|
|
736
|
+
// earlier as a separate error 鈥?tolerate it here).
|
|
737
|
+
availableInputs = new Set<string>();
|
|
732
738
|
for (const name of objectKeys(task.inputs)) availableInputs.add(name);
|
|
733
739
|
}
|
|
734
740
|
|
|
735
741
|
for (const name of referenced) {
|
|
736
742
|
if (!availableInputs.has(name)) {
|
|
737
743
|
const hint = isPromptTask
|
|
738
|
-
? `no upstream Command task exports an output
|
|
739
|
-
: `no such input
|
|
744
|
+
? `no upstream Command task exports an output named "${name}"`
|
|
745
|
+
: `no such input is declared`;
|
|
740
746
|
errors.push({
|
|
741
747
|
path: taskPath,
|
|
742
748
|
message: `Task "${task.id}": references "{{inputs.${name}}}" but ${hint}`,
|
|
@@ -744,58 +750,7 @@ function validateTaskPorts(
|
|
|
744
750
|
}
|
|
745
751
|
}
|
|
746
752
|
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// Prompt tasks already errored above if they tried to declare ports;
|
|
750
|
-
// running the per-port structural validator on the ignored object
|
|
751
|
-
// would just produce duplicate noise.
|
|
752
|
-
if (isCommandTask && ports) {
|
|
753
|
-
validatePortList(ports.inputs, `${taskPath}.ports.inputs`, 'inputs', errors);
|
|
754
|
-
validatePortList(ports.outputs, `${taskPath}.ports.outputs`, 'outputs', errors);
|
|
755
|
-
|
|
756
|
-
// Warn on declared-but-unused inputs. Not fatal — a user may want
|
|
757
|
-
// to surface an input as a data-flow hint for the editor even when
|
|
758
|
-
// the command doesn't template it explicitly.
|
|
759
|
-
if (typeof task.command === 'string' && Array.isArray(ports.inputs)) {
|
|
760
|
-
for (const port of ports.inputs) {
|
|
761
|
-
if (!port || typeof port !== 'object') continue;
|
|
762
|
-
if (!referenced.has(port.name)) {
|
|
763
|
-
errors.push({
|
|
764
|
-
path: `${taskPath}.ports.inputs`,
|
|
765
|
-
severity: 'warning',
|
|
766
|
-
message: `Task "${task.id}": command does not reference {{inputs.${port.name}}} — declared input is unused`,
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Validate that fully-qualified `from` references point to direct
|
|
773
|
-
// dependencies. The runtime's findUpstreamValue only scans dependsOn,
|
|
774
|
-
// so a from that skips the dependency list will always miss at run
|
|
775
|
-
// time and block the task with a cryptic "missing required input".
|
|
776
|
-
if (Array.isArray(ports.inputs)) {
|
|
777
|
-
for (const port of ports.inputs) {
|
|
778
|
-
if (!port || typeof port !== 'object' || typeof port.from !== 'string' || !port.from.includes('.')) {
|
|
779
|
-
continue;
|
|
780
|
-
}
|
|
781
|
-
const dot = port.from.lastIndexOf('.');
|
|
782
|
-
const upstreamId = port.from.slice(0, dot);
|
|
783
|
-
const deps = task.depends_on ?? [];
|
|
784
|
-
const isDirectDep = deps.some((dep) => {
|
|
785
|
-
const resolved = resolveTaskRef(dep, trackId, index);
|
|
786
|
-
return resolved.kind === 'resolved' && resolved.qid === upstreamId;
|
|
787
|
-
});
|
|
788
|
-
if (!isDirectDep) {
|
|
789
|
-
errors.push({
|
|
790
|
-
path: `${taskPath}.ports.inputs`,
|
|
791
|
-
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)`,
|
|
792
|
-
});
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// ─── Prompt-task inferred-port conflict checks ──
|
|
753
|
+
// Prompt-task inferred-port conflict checks 鈹€鈹€
|
|
799
754
|
//
|
|
800
755
|
// Static counterparts to the runtime checks `inferPromptPorts` runs.
|
|
801
756
|
// These surface problems at author-time in the editor so the user
|
|
@@ -807,8 +762,8 @@ function validateTaskPorts(
|
|
|
807
762
|
|
|
808
763
|
/**
|
|
809
764
|
* Walk the direct-upstream Commands of a Prompt Task and collect every
|
|
810
|
-
* output port name they export. Prompt upstreams contribute nothing
|
|
811
|
-
* they pass free text via continue_from, not structured ports
|
|
765
|
+
* output port name they export. Prompt upstreams contribute nothing 鈥?
|
|
766
|
+
* they pass free text via continue_from, not structured ports 鈥?so we
|
|
812
767
|
* skip them. This mirrors exactly what the engine does at runtime in
|
|
813
768
|
* `inferPromptPorts`, keeping the editor and runtime views aligned.
|
|
814
769
|
*/
|
|
@@ -824,14 +779,12 @@ function collectUpstreamCommandOutputNames(
|
|
|
824
779
|
if (r.kind !== 'resolved') continue;
|
|
825
780
|
const entry = qidIndex.get(r.qid);
|
|
826
781
|
if (!entry) continue;
|
|
827
|
-
// Only Command tasks contribute
|
|
782
|
+
// Only Command tasks contribute 鈥?Prompt upstreams pass free text.
|
|
828
783
|
if (typeof entry.task.command !== 'string') continue;
|
|
829
|
-
const outputs = entry.task.
|
|
830
|
-
if (!Array.isArray(outputs)) continue;
|
|
831
|
-
for (const
|
|
832
|
-
|
|
833
|
-
names.add(port.name);
|
|
834
|
-
}
|
|
784
|
+
const outputs = entry.task.outputs;
|
|
785
|
+
if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) continue;
|
|
786
|
+
for (const name of Object.keys(outputs)) {
|
|
787
|
+
names.add(name);
|
|
835
788
|
}
|
|
836
789
|
}
|
|
837
790
|
return names;
|
|
@@ -839,11 +792,11 @@ function collectUpstreamCommandOutputNames(
|
|
|
839
792
|
|
|
840
793
|
/**
|
|
841
794
|
* Detect the two kinds of collision that would block a Prompt Task at
|
|
842
|
-
* runtime
|
|
795
|
+
* runtime 鈥?report them at validate-time so the editor lights them up
|
|
843
796
|
* before a run is attempted.
|
|
844
797
|
*
|
|
845
798
|
* 1. Input collision: two direct-upstream Commands both export an
|
|
846
|
-
* output with the same name. Command
|
|
799
|
+
* output with the same name. Command鈫扖ommand would let the
|
|
847
800
|
* downstream disambiguate with `from:`; Prompt tasks have no port
|
|
848
801
|
* declarations and therefore no escape hatch.
|
|
849
802
|
* 2. Output collision: two direct-downstream Commands declare inputs
|
|
@@ -858,20 +811,19 @@ function validateInferredPromptPortConflicts(
|
|
|
858
811
|
index: TaskIndex,
|
|
859
812
|
errors: ValidationError[],
|
|
860
813
|
): void {
|
|
861
|
-
//
|
|
814
|
+
// 鈹€鈹€鈹€ Input collision 鈹€鈹€
|
|
862
815
|
const producersByName = new Map<string, string[]>();
|
|
863
816
|
for (const dep of task.depends_on ?? []) {
|
|
864
817
|
const r = resolveTaskRef(dep, trackId, index);
|
|
865
818
|
if (r.kind !== 'resolved') continue;
|
|
866
819
|
const entry = qidIndex.get(r.qid);
|
|
867
820
|
if (!entry || typeof entry.task.command !== 'string') continue;
|
|
868
|
-
const outputs = entry.task.
|
|
869
|
-
if (!Array.isArray(outputs)) continue;
|
|
870
|
-
for (const
|
|
871
|
-
|
|
872
|
-
const list = producersByName.get(port.name) ?? [];
|
|
821
|
+
const outputs = entry.task.outputs;
|
|
822
|
+
if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) continue;
|
|
823
|
+
for (const name of Object.keys(outputs)) {
|
|
824
|
+
const list = producersByName.get(name) ?? [];
|
|
873
825
|
list.push(r.qid);
|
|
874
|
-
producersByName.set(
|
|
826
|
+
producersByName.set(name, list);
|
|
875
827
|
}
|
|
876
828
|
}
|
|
877
829
|
for (const [name, producers] of producersByName) {
|
|
@@ -880,13 +832,13 @@ function validateInferredPromptPortConflicts(
|
|
|
880
832
|
path: taskPath,
|
|
881
833
|
message:
|
|
882
834
|
`Task "${task.id}": upstream Commands ${producers.join(', ')} all export ` +
|
|
883
|
-
`"${name}"
|
|
835
|
+
`"${name}" 鈥?prompt tasks cannot disambiguate (no "from:" binding available). ` +
|
|
884
836
|
`Rename the output on one of the upstream Commands.`,
|
|
885
837
|
});
|
|
886
838
|
}
|
|
887
839
|
}
|
|
888
840
|
|
|
889
|
-
//
|
|
841
|
+
// 鈹€鈹€鈹€ Output collision 鈹€鈹€
|
|
890
842
|
//
|
|
891
843
|
// Walk every task in the pipeline once and check whether it depends on
|
|
892
844
|
// us. We reuse the shared qidIndex + TaskIndex for the lookup; small
|
|
@@ -911,23 +863,23 @@ function validateInferredPromptPortConflicts(
|
|
|
911
863
|
}
|
|
912
864
|
}
|
|
913
865
|
if (!dependsOnUs) continue;
|
|
914
|
-
const inputs = entry.task.
|
|
915
|
-
if (!Array.isArray(inputs)) continue;
|
|
916
|
-
for (const
|
|
917
|
-
if (!
|
|
918
|
-
const shape =
|
|
919
|
-
const prior = consumerShapeByName.get(
|
|
866
|
+
const inputs = entry.task.inputs;
|
|
867
|
+
if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) continue;
|
|
868
|
+
for (const [inputName, binding] of Object.entries(inputs)) {
|
|
869
|
+
if (!binding || typeof binding !== 'object' || Array.isArray(binding)) continue;
|
|
870
|
+
const shape = bindingShapeKey(binding as { type?: PortType; enum?: readonly string[] });
|
|
871
|
+
const prior = consumerShapeByName.get(inputName);
|
|
920
872
|
if (!prior) {
|
|
921
|
-
consumerShapeByName.set(
|
|
873
|
+
consumerShapeByName.set(inputName, { shape, firstConsumer: downstreamQid });
|
|
922
874
|
continue;
|
|
923
875
|
}
|
|
924
|
-
if (prior.shape !== shape && !reported.has(
|
|
925
|
-
reported.add(
|
|
876
|
+
if (prior.shape !== shape && !reported.has(inputName)) {
|
|
877
|
+
reported.add(inputName);
|
|
926
878
|
errors.push({
|
|
927
879
|
path: taskPath,
|
|
928
880
|
message:
|
|
929
881
|
`Task "${task.id}": downstream Commands ${prior.firstConsumer} and ` +
|
|
930
|
-
`${downstreamQid} disagree on the shape of inferred output "${
|
|
882
|
+
`${downstreamQid} disagree on the shape of inferred output "${inputName}" 鈥?` +
|
|
931
883
|
`a single LLM emission cannot satisfy both. Rename on one side.`,
|
|
932
884
|
});
|
|
933
885
|
}
|
|
@@ -937,13 +889,17 @@ function validateInferredPromptPortConflicts(
|
|
|
937
889
|
|
|
938
890
|
/** Minimal shape fingerprint for conflict detection: type + enum set. */
|
|
939
891
|
function portShapeKey(port: PortDef): string {
|
|
940
|
-
|
|
892
|
+
return bindingShapeKey(port);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function bindingShapeKey(port: { type?: PortType; enum?: readonly string[] }): string {
|
|
896
|
+
if ((port.type ?? 'json') !== 'enum') return String(port.type ?? 'json');
|
|
941
897
|
const enums = Array.isArray(port.enum) ? [...port.enum].sort().join('|') : '';
|
|
942
898
|
return `enum:${enums}`;
|
|
943
899
|
}
|
|
944
900
|
|
|
945
901
|
function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationError[] {
|
|
946
|
-
// Build adjacency: qualifiedId
|
|
902
|
+
// Build adjacency: qualifiedId 鈫?[resolved dep qualifiedIds]
|
|
947
903
|
const adj = new Map<string, string[]>();
|
|
948
904
|
|
|
949
905
|
for (const track of config.tracks) {
|
|
@@ -969,7 +925,7 @@ function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationEr
|
|
|
969
925
|
const visited = new Set<string>();
|
|
970
926
|
const inStack = new Set<string>();
|
|
971
927
|
// Deduplicate cycles: the same cycle can be discovered from multiple entry points.
|
|
972
|
-
// Canonical key = sorted node list joined
|
|
928
|
+
// Canonical key = sorted node list joined 鈥?order-independent fingerprint.
|
|
973
929
|
const seenCycles = new Set<string>();
|
|
974
930
|
|
|
975
931
|
// Use a mutable path array instead of copying at each level (O(n) vs O(n^2)).
|
|
@@ -988,7 +944,7 @@ function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationEr
|
|
|
988
944
|
const display = [...uniqueNodes, id]; // include start for readable display
|
|
989
945
|
errors.push({
|
|
990
946
|
path: 'tracks',
|
|
991
|
-
message: `Circular dependency detected: ${display.join('
|
|
947
|
+
message: `Circular dependency detected: ${display.join(' 鈫?')}`,
|
|
992
948
|
});
|
|
993
949
|
}
|
|
994
950
|
return;
|