@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/src/validate-raw.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
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
|
|
|
9
9
|
import type {
|
|
10
|
-
PortDef,
|
|
11
10
|
PortType,
|
|
12
11
|
RawPipelineConfig,
|
|
13
12
|
RawTaskConfig,
|
|
@@ -27,7 +26,7 @@ interface QidEntry {
|
|
|
27
26
|
readonly task: RawTaskConfig;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
/** qid
|
|
29
|
+
/** qid 鈫?{track, task} lookup built once per validation pass. */
|
|
31
30
|
function buildQidIndex(config: RawPipelineConfig): Map<string, QidEntry> {
|
|
32
31
|
const idx = new Map<string, QidEntry>();
|
|
33
32
|
for (const track of config.tracks ?? []) {
|
|
@@ -48,7 +47,7 @@ function isValidDuration(input: string): boolean {
|
|
|
48
47
|
|
|
49
48
|
// D8: IDs may only contain letters, digits, underscores, and hyphens, and must
|
|
50
49
|
// start with a letter or underscore. Dots are explicitly forbidden because the
|
|
51
|
-
// engine uses "trackId.taskId" as the qualified separator
|
|
50
|
+
// engine uses "trackId.taskId" as the qualified separator 鈥?a dot in either
|
|
52
51
|
// part creates an ambiguous qualified ID and breaks resolveRef.
|
|
53
52
|
// Canonical regex and helper live in ./task-ref so every resolver (dag.ts,
|
|
54
53
|
// engine.ts, editor) stays in lockstep with what we accept here.
|
|
@@ -75,7 +74,7 @@ const BUILTIN_DRIVER_TYPES: ReadonlySet<string> = new Set(['opencode']);
|
|
|
75
74
|
* Optional second argument to `validateRaw`: the set of plugin types currently
|
|
76
75
|
* registered in the SDK runtime, keyed by category. Hosts (e.g. the editor
|
|
77
76
|
* server) pass this so `validateRaw` can emit a soft warning when a task
|
|
78
|
-
* references a type that isn't loaded
|
|
77
|
+
* references a type that isn't loaded 鈥?otherwise the Task panel would show
|
|
79
78
|
* no hint and the pipeline would only blow up at run time. Callers that
|
|
80
79
|
* legitimately validate a config offline (before plugins are loaded) can omit
|
|
81
80
|
* this argument and no plugin warnings will be produced.
|
|
@@ -95,11 +94,11 @@ export interface ValidationError {
|
|
|
95
94
|
message: string;
|
|
96
95
|
/**
|
|
97
96
|
* H8: not all "errors" are equally fatal. The DAG runtime is happy to
|
|
98
|
-
* insert implicit `continue_from
|
|
97
|
+
* insert implicit `continue_from 鈫?depends_on` ordering, so the matching
|
|
99
98
|
* validate-raw check is a *style* nit, not a hard failure. Severity lets
|
|
100
99
|
* the editor render it as a soft warning instead of blocking save / run.
|
|
101
100
|
* Existing call sites that don't read this field still treat every entry
|
|
102
|
-
* as fatal
|
|
101
|
+
* as fatal 鈥?defaulting `severity` to undefined preserves that behaviour.
|
|
103
102
|
*/
|
|
104
103
|
severity?: ValidationSeverity;
|
|
105
104
|
}
|
|
@@ -111,7 +110,7 @@ export interface ValidationError {
|
|
|
111
110
|
*
|
|
112
111
|
* Plugin type checks: when `knownTypes` is provided, task/track references to
|
|
113
112
|
* trigger/completion/middleware types that are neither built-in nor in the
|
|
114
|
-
* supplied set produce a soft warning (severity: 'warning')
|
|
113
|
+
* supplied set produce a soft warning (severity: 'warning') 鈥?these don't
|
|
115
114
|
* block save/run but light up the Task panel so users discover the broken
|
|
116
115
|
* reference in the editor instead of at run time. Omit `knownTypes` to skip
|
|
117
116
|
* plugin checks entirely (offline/pre-load validation).
|
|
@@ -135,7 +134,7 @@ export function validateRaw(
|
|
|
135
134
|
? new Set<string>([...BUILTIN_MIDDLEWARE_TYPES, ...(knownTypes.middlewares ?? [])])
|
|
136
135
|
: null;
|
|
137
136
|
|
|
138
|
-
//
|
|
137
|
+
// 鈹€鈹€ Top level 鈹€鈹€
|
|
139
138
|
if (!config.name?.trim()) {
|
|
140
139
|
errors.push({ path: 'name', message: 'Pipeline name is required' });
|
|
141
140
|
}
|
|
@@ -163,16 +162,16 @@ export function validateRaw(
|
|
|
163
162
|
return errors; // No point going further without tracks
|
|
164
163
|
}
|
|
165
164
|
|
|
166
|
-
//
|
|
165
|
+
// 鈹€鈹€ Build qualified ID sets for cross-reference checks 鈹€鈹€
|
|
167
166
|
// Qualified ID format: "trackId.taskId" (mirrors the engine's convention).
|
|
168
|
-
// Shared with dag.ts so "ambiguous" / "not found" stay consistent
|
|
167
|
+
// Shared with dag.ts so "ambiguous" / "not found" stay consistent 鈥?refs
|
|
169
168
|
// that buildDag later throws on will be reported here as errors first.
|
|
170
169
|
const index = buildTaskIndex(config);
|
|
171
|
-
// Full qid
|
|
170
|
+
// Full qid 鈫?{track, task} index used by port-inference validation
|
|
172
171
|
// to walk a Prompt task's neighbors without re-scanning the tracks.
|
|
173
172
|
const qidIndex = buildQidIndex(config);
|
|
174
173
|
|
|
175
|
-
//
|
|
174
|
+
// 鈹€鈹€ Per-track validation 鈹€鈹€
|
|
176
175
|
const seenTrackIds = new Set<string>();
|
|
177
176
|
for (let ti = 0; ti < config.tracks.length; ti++) {
|
|
178
177
|
const maybeTrack = config.tracks[ti] as unknown;
|
|
@@ -220,7 +219,7 @@ export function validateRaw(
|
|
|
220
219
|
validatePermissions(track.permissions, `${trackPath}.permissions`, errors);
|
|
221
220
|
|
|
222
221
|
// Track-level middlewares can reference a plugin that was uninstalled
|
|
223
|
-
// after the YAML was written
|
|
222
|
+
// after the YAML was written 鈥?surface a warning so the user notices
|
|
224
223
|
// before hitting Run.
|
|
225
224
|
if (knownMiddlewares && track.middlewares) {
|
|
226
225
|
for (let mi = 0; mi < track.middlewares.length; mi++) {
|
|
@@ -228,7 +227,7 @@ export function validateRaw(
|
|
|
228
227
|
if (mw?.type && !knownMiddlewares.has(mw.type)) {
|
|
229
228
|
errors.push({
|
|
230
229
|
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
|
|
230
|
+
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
231
|
severity: 'warning',
|
|
233
232
|
});
|
|
234
233
|
}
|
|
@@ -250,7 +249,7 @@ export function validateRaw(
|
|
|
250
249
|
continue;
|
|
251
250
|
}
|
|
252
251
|
|
|
253
|
-
//
|
|
252
|
+
// 鈹€鈹€ Per-task validation 鈹€鈹€
|
|
254
253
|
const seenTaskIds = new Set<string>();
|
|
255
254
|
for (let ki = 0; ki < track.tasks.length; ki++) {
|
|
256
255
|
const task = track.tasks[ki];
|
|
@@ -302,7 +301,7 @@ export function validateRaw(
|
|
|
302
301
|
});
|
|
303
302
|
}
|
|
304
303
|
|
|
305
|
-
//
|
|
304
|
+
// 鈹€鈹€ Field-level validations 鈹€鈹€
|
|
306
305
|
if (task.timeout && !isValidDuration(task.timeout)) {
|
|
307
306
|
errors.push({
|
|
308
307
|
path: `${taskPath}.timeout`,
|
|
@@ -324,7 +323,7 @@ export function validateRaw(
|
|
|
324
323
|
}
|
|
325
324
|
validatePermissions(task.permissions, `${taskPath}.permissions`, errors);
|
|
326
325
|
|
|
327
|
-
//
|
|
326
|
+
// 鈹€鈹€ Plugin type warnings (trigger / completion / middlewares) 鈹€鈹€
|
|
328
327
|
// Only fire when the host supplied a `knownTypes` snapshot, so offline
|
|
329
328
|
// validation stays quiet. The messages deliberately name the npm
|
|
330
329
|
// scope so users can copy-paste the install command.
|
|
@@ -352,46 +351,46 @@ export function validateRaw(
|
|
|
352
351
|
if (mw?.type && !knownMiddlewares.has(mw.type)) {
|
|
353
352
|
errors.push({
|
|
354
353
|
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
|
|
354
|
+
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
355
|
severity: 'warning',
|
|
357
356
|
});
|
|
358
357
|
}
|
|
359
358
|
}
|
|
360
359
|
}
|
|
361
360
|
|
|
362
|
-
//
|
|
361
|
+
// 鈹€鈹€ Port declaration checks 鈹€鈹€
|
|
363
362
|
validateTaskPorts(task, track.id, taskPath, qidIndex, index, errors);
|
|
364
363
|
|
|
365
|
-
//
|
|
364
|
+
// 鈹€鈹€ depends_on reference checks 鈹€鈹€
|
|
366
365
|
if (task.depends_on && task.depends_on.length > 0) {
|
|
367
366
|
for (const dep of task.depends_on) {
|
|
368
367
|
const resolved = resolveTaskRef(dep, track.id, index);
|
|
369
368
|
if (resolved.kind === 'not_found') {
|
|
370
369
|
errors.push({
|
|
371
370
|
path: `${taskPath}.depends_on`,
|
|
372
|
-
message: `Task "${task.id}": depends_on "${dep}"
|
|
371
|
+
message: `Task "${task.id}": depends_on "${dep}" 鈥?no such task found`,
|
|
373
372
|
});
|
|
374
373
|
} else if (resolved.kind === 'ambiguous') {
|
|
375
374
|
errors.push({
|
|
376
375
|
path: `${taskPath}.depends_on`,
|
|
377
|
-
message: `Task "${task.id}": depends_on "${dep}" is ambiguous
|
|
376
|
+
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
377
|
});
|
|
379
378
|
}
|
|
380
379
|
}
|
|
381
380
|
}
|
|
382
381
|
|
|
383
|
-
//
|
|
382
|
+
// 鈹€鈹€ continue_from reference check 鈹€鈹€
|
|
384
383
|
if (task.continue_from) {
|
|
385
384
|
const resolved = resolveTaskRef(task.continue_from, track.id, index);
|
|
386
385
|
if (resolved.kind === 'not_found') {
|
|
387
386
|
errors.push({
|
|
388
387
|
path: `${taskPath}.continue_from`,
|
|
389
|
-
message: `Task "${task.id}": continue_from "${task.continue_from}"
|
|
388
|
+
message: `Task "${task.id}": continue_from "${task.continue_from}" 鈥?no such task found`,
|
|
390
389
|
});
|
|
391
390
|
} else if (resolved.kind === 'ambiguous') {
|
|
392
391
|
errors.push({
|
|
393
392
|
path: `${taskPath}.continue_from`,
|
|
394
|
-
message: `Task "${task.id}": continue_from "${task.continue_from}" is ambiguous
|
|
393
|
+
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
394
|
});
|
|
396
395
|
} else if (
|
|
397
396
|
!task.depends_on ||
|
|
@@ -415,7 +414,7 @@ export function validateRaw(
|
|
|
415
414
|
}
|
|
416
415
|
}
|
|
417
416
|
|
|
418
|
-
//
|
|
417
|
+
// 鈹€鈹€ Cycle detection 鈹€鈹€
|
|
419
418
|
errors.push(...detectCycles(config, index));
|
|
420
419
|
|
|
421
420
|
return errors;
|
|
@@ -455,89 +454,12 @@ const VALID_PORT_TYPES: ReadonlySet<PortType> = new Set([
|
|
|
455
454
|
'json',
|
|
456
455
|
]);
|
|
457
456
|
|
|
458
|
-
// Identifier pattern for port names. Deliberately narrower than task IDs
|
|
457
|
+
// Identifier pattern for port names. Deliberately narrower than task IDs 鈥?
|
|
459
458
|
// port names appear in `{{inputs.<name>}}` templates where hyphens would
|
|
460
459
|
// be parsed as subtraction, so we also forbid them here to keep the
|
|
461
460
|
// template grammar unambiguous.
|
|
462
461
|
const PORT_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
463
462
|
|
|
464
|
-
function validatePortList(
|
|
465
|
-
list: readonly PortDef[] | undefined,
|
|
466
|
-
basePath: string,
|
|
467
|
-
kind: 'inputs' | 'outputs',
|
|
468
|
-
errors: ValidationError[],
|
|
469
|
-
): void {
|
|
470
|
-
if (!list) return;
|
|
471
|
-
if (!Array.isArray(list)) {
|
|
472
|
-
errors.push({
|
|
473
|
-
path: basePath,
|
|
474
|
-
message: `ports.${kind} must be an array`,
|
|
475
|
-
});
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
const seen = new Set<string>();
|
|
479
|
-
for (let i = 0; i < list.length; i++) {
|
|
480
|
-
const port = list[i];
|
|
481
|
-
const path = `${basePath}[${i}]`;
|
|
482
|
-
if (!port || typeof port !== 'object') {
|
|
483
|
-
errors.push({ path, message: `ports.${kind}[${i}] must be an object` });
|
|
484
|
-
continue;
|
|
485
|
-
}
|
|
486
|
-
if (typeof port.name !== 'string' || !port.name.trim()) {
|
|
487
|
-
errors.push({ path: `${path}.name`, message: 'port.name is required' });
|
|
488
|
-
continue;
|
|
489
|
-
}
|
|
490
|
-
if (!PORT_NAME_RE.test(port.name)) {
|
|
491
|
-
errors.push({
|
|
492
|
-
path: `${path}.name`,
|
|
493
|
-
message: `port name "${port.name}" is invalid. Must match /^[A-Za-z_][A-Za-z0-9_]*$/ (letters, digits, underscores; starts with letter/underscore).`,
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
if (seen.has(port.name)) {
|
|
497
|
-
errors.push({
|
|
498
|
-
path,
|
|
499
|
-
message: `Duplicate ports.${kind} name "${port.name}"`,
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
seen.add(port.name);
|
|
503
|
-
if (!VALID_PORT_TYPES.has(port.type)) {
|
|
504
|
-
errors.push({
|
|
505
|
-
path: `${path}.type`,
|
|
506
|
-
message: `port "${port.name}": type must be one of ${[...VALID_PORT_TYPES].join(', ')} (got ${JSON.stringify(port.type)})`,
|
|
507
|
-
});
|
|
508
|
-
}
|
|
509
|
-
if (port.type === 'enum') {
|
|
510
|
-
if (!Array.isArray(port.enum) || port.enum.length === 0) {
|
|
511
|
-
errors.push({
|
|
512
|
-
path: `${path}.enum`,
|
|
513
|
-
message: `port "${port.name}": enum type requires a non-empty "enum" array`,
|
|
514
|
-
});
|
|
515
|
-
} else if (port.enum.some((v: unknown) => typeof v !== 'string')) {
|
|
516
|
-
errors.push({
|
|
517
|
-
path: `${path}.enum`,
|
|
518
|
-
message: `port "${port.name}": enum values must all be strings`,
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
if (kind === 'outputs' && (port.required === true || port.from !== undefined)) {
|
|
523
|
-
// `required` / `from` are input-only concepts — outputs are
|
|
524
|
-
// always "produced when the task succeeds". Warn softly so the
|
|
525
|
-
// YAML doesn't silently accept meaningless fields.
|
|
526
|
-
errors.push({
|
|
527
|
-
path,
|
|
528
|
-
severity: 'warning',
|
|
529
|
-
message: `port "${port.name}": "required" and "from" are input-only; ignored on outputs`,
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
if (port.from !== undefined && typeof port.from !== 'string') {
|
|
533
|
-
errors.push({
|
|
534
|
-
path: `${path}.from`,
|
|
535
|
-
message: `port "${port.name}": "from" must be a string (got ${typeof port.from})`,
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
463
|
function validateBindingMap(
|
|
542
464
|
value: unknown,
|
|
543
465
|
basePath: string,
|
|
@@ -573,6 +495,25 @@ function validateBindingMap(
|
|
|
573
495
|
message: `task.inputs.${name}.required must be a boolean`,
|
|
574
496
|
});
|
|
575
497
|
}
|
|
498
|
+
if ('type' in binding && binding.type !== undefined && !VALID_PORT_TYPES.has(binding.type as PortType)) {
|
|
499
|
+
errors.push({
|
|
500
|
+
path: `${path}.type`,
|
|
501
|
+
message: `task.${kind}.${name}.type must be one of ${[...VALID_PORT_TYPES].join(', ')}`,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (binding.type === 'enum') {
|
|
505
|
+
if (!Array.isArray(binding.enum) || binding.enum.length === 0) {
|
|
506
|
+
errors.push({
|
|
507
|
+
path: `${path}.enum`,
|
|
508
|
+
message: `task.${kind}.${name}.enum must be a non-empty string array when type is enum`,
|
|
509
|
+
});
|
|
510
|
+
} else if (!binding.enum.every((v: unknown) => typeof v === 'string')) {
|
|
511
|
+
errors.push({
|
|
512
|
+
path: `${path}.enum`,
|
|
513
|
+
message: `task.${kind}.${name}.enum values must all be strings`,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
576
517
|
if (kind === 'outputs' && typeof binding.from === 'string') {
|
|
577
518
|
const source = binding.from;
|
|
578
519
|
const ok =
|
|
@@ -590,38 +531,6 @@ function validateBindingMap(
|
|
|
590
531
|
}
|
|
591
532
|
}
|
|
592
533
|
|
|
593
|
-
function validateBindingPortNameOverlap(
|
|
594
|
-
task: RawTaskConfig,
|
|
595
|
-
taskPath: string,
|
|
596
|
-
errors: ValidationError[],
|
|
597
|
-
): void {
|
|
598
|
-
const looseInputs = objectKeys(task.inputs);
|
|
599
|
-
const looseOutputs = objectKeys(task.outputs);
|
|
600
|
-
const strictInputs = new Set(
|
|
601
|
-
Array.isArray(task.ports?.inputs) ? task.ports.inputs.map((p) => p?.name) : [],
|
|
602
|
-
);
|
|
603
|
-
const strictOutputs = new Set(
|
|
604
|
-
Array.isArray(task.ports?.outputs) ? task.ports.outputs.map((p) => p?.name) : [],
|
|
605
|
-
);
|
|
606
|
-
|
|
607
|
-
for (const name of looseInputs) {
|
|
608
|
-
if (strictInputs.has(name)) {
|
|
609
|
-
errors.push({
|
|
610
|
-
path: `${taskPath}.inputs.${name}`,
|
|
611
|
-
message: `task input binding "${name}" duplicates strict ports.inputs; choose one layer for this name`,
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
for (const name of looseOutputs) {
|
|
616
|
-
if (strictOutputs.has(name)) {
|
|
617
|
-
errors.push({
|
|
618
|
-
path: `${taskPath}.outputs.${name}`,
|
|
619
|
-
message: `task output binding "${name}" duplicates strict ports.outputs; choose one layer for this name`,
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
534
|
function objectKeys(value: unknown): string[] {
|
|
626
535
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
|
|
627
536
|
return Object.keys(value as Record<string, unknown>);
|
|
@@ -682,32 +591,22 @@ function validateTaskPorts(
|
|
|
682
591
|
|
|
683
592
|
validateBindingMap(task.inputs, `${taskPath}.inputs`, 'inputs', errors);
|
|
684
593
|
validateBindingMap(task.outputs, `${taskPath}.outputs`, 'outputs', errors);
|
|
685
|
-
validateBindingPortNameOverlap(task, taskPath, errors);
|
|
686
594
|
validateInputBindingSources(task, trackId, taskPath, index, errors);
|
|
687
595
|
|
|
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) {
|
|
596
|
+
if (ports !== undefined) {
|
|
697
597
|
errors.push({
|
|
698
598
|
path: `${taskPath}.ports`,
|
|
699
599
|
message:
|
|
700
|
-
`Task "${task.id}":
|
|
701
|
-
`
|
|
702
|
-
`and declare the corresponding inputs/outputs on the upstream/downstream ` +
|
|
703
|
-
`Command tasks instead.`,
|
|
600
|
+
`Task "${task.id}": ports has been replaced by typed inputs/outputs. ` +
|
|
601
|
+
`Move ports.inputs entries to task.inputs.<name> and ports.outputs entries to task.outputs.<name>.`,
|
|
704
602
|
});
|
|
603
|
+
return;
|
|
705
604
|
}
|
|
706
605
|
|
|
707
|
-
//
|
|
606
|
+
// Collect placeholder references 鈹€鈹€
|
|
708
607
|
// `{{inputs.X}}` is valid in both prompt and command text. The set of
|
|
709
608
|
// names a task may legally reference differs by task kind:
|
|
710
|
-
// - Command Task: its own declared `
|
|
609
|
+
// - Command Task: its own declared `inputs`
|
|
711
610
|
// - Prompt Task: the union of direct-upstream Command outputs
|
|
712
611
|
const referenced = new Set<string>();
|
|
713
612
|
if (typeof task.prompt === 'string') {
|
|
@@ -723,20 +622,16 @@ function validateTaskPorts(
|
|
|
723
622
|
for (const name of objectKeys(task.inputs)) availableInputs.add(name);
|
|
724
623
|
} else {
|
|
725
624
|
// 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
|
-
);
|
|
625
|
+
// earlier as a separate error 鈥?tolerate it here).
|
|
626
|
+
availableInputs = new Set<string>();
|
|
732
627
|
for (const name of objectKeys(task.inputs)) availableInputs.add(name);
|
|
733
628
|
}
|
|
734
629
|
|
|
735
630
|
for (const name of referenced) {
|
|
736
631
|
if (!availableInputs.has(name)) {
|
|
737
632
|
const hint = isPromptTask
|
|
738
|
-
? `no upstream Command task exports an output
|
|
739
|
-
: `no such input
|
|
633
|
+
? `no upstream Command task exports an output named "${name}"`
|
|
634
|
+
: `no such input is declared`;
|
|
740
635
|
errors.push({
|
|
741
636
|
path: taskPath,
|
|
742
637
|
message: `Task "${task.id}": references "{{inputs.${name}}}" but ${hint}`,
|
|
@@ -744,58 +639,7 @@ function validateTaskPorts(
|
|
|
744
639
|
}
|
|
745
640
|
}
|
|
746
641
|
|
|
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 ──
|
|
642
|
+
// Prompt-task inferred-port conflict checks 鈹€鈹€
|
|
799
643
|
//
|
|
800
644
|
// Static counterparts to the runtime checks `inferPromptPorts` runs.
|
|
801
645
|
// These surface problems at author-time in the editor so the user
|
|
@@ -807,8 +651,8 @@ function validateTaskPorts(
|
|
|
807
651
|
|
|
808
652
|
/**
|
|
809
653
|
* 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
|
|
654
|
+
* output port name they export. Prompt upstreams contribute nothing 鈥?
|
|
655
|
+
* they pass free text via continue_from, not structured ports 鈥?so we
|
|
812
656
|
* skip them. This mirrors exactly what the engine does at runtime in
|
|
813
657
|
* `inferPromptPorts`, keeping the editor and runtime views aligned.
|
|
814
658
|
*/
|
|
@@ -824,14 +668,12 @@ function collectUpstreamCommandOutputNames(
|
|
|
824
668
|
if (r.kind !== 'resolved') continue;
|
|
825
669
|
const entry = qidIndex.get(r.qid);
|
|
826
670
|
if (!entry) continue;
|
|
827
|
-
// Only Command tasks contribute
|
|
671
|
+
// Only Command tasks contribute 鈥?Prompt upstreams pass free text.
|
|
828
672
|
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
|
-
}
|
|
673
|
+
const outputs = entry.task.outputs;
|
|
674
|
+
if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) continue;
|
|
675
|
+
for (const name of Object.keys(outputs)) {
|
|
676
|
+
names.add(name);
|
|
835
677
|
}
|
|
836
678
|
}
|
|
837
679
|
return names;
|
|
@@ -839,11 +681,11 @@ function collectUpstreamCommandOutputNames(
|
|
|
839
681
|
|
|
840
682
|
/**
|
|
841
683
|
* Detect the two kinds of collision that would block a Prompt Task at
|
|
842
|
-
* runtime
|
|
684
|
+
* runtime 鈥?report them at validate-time so the editor lights them up
|
|
843
685
|
* before a run is attempted.
|
|
844
686
|
*
|
|
845
687
|
* 1. Input collision: two direct-upstream Commands both export an
|
|
846
|
-
* output with the same name. Command
|
|
688
|
+
* output with the same name. Command鈫扖ommand would let the
|
|
847
689
|
* downstream disambiguate with `from:`; Prompt tasks have no port
|
|
848
690
|
* declarations and therefore no escape hatch.
|
|
849
691
|
* 2. Output collision: two direct-downstream Commands declare inputs
|
|
@@ -858,20 +700,19 @@ function validateInferredPromptPortConflicts(
|
|
|
858
700
|
index: TaskIndex,
|
|
859
701
|
errors: ValidationError[],
|
|
860
702
|
): void {
|
|
861
|
-
//
|
|
703
|
+
// 鈹€鈹€鈹€ Input collision 鈹€鈹€
|
|
862
704
|
const producersByName = new Map<string, string[]>();
|
|
863
705
|
for (const dep of task.depends_on ?? []) {
|
|
864
706
|
const r = resolveTaskRef(dep, trackId, index);
|
|
865
707
|
if (r.kind !== 'resolved') continue;
|
|
866
708
|
const entry = qidIndex.get(r.qid);
|
|
867
709
|
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) ?? [];
|
|
710
|
+
const outputs = entry.task.outputs;
|
|
711
|
+
if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) continue;
|
|
712
|
+
for (const name of Object.keys(outputs)) {
|
|
713
|
+
const list = producersByName.get(name) ?? [];
|
|
873
714
|
list.push(r.qid);
|
|
874
|
-
producersByName.set(
|
|
715
|
+
producersByName.set(name, list);
|
|
875
716
|
}
|
|
876
717
|
}
|
|
877
718
|
for (const [name, producers] of producersByName) {
|
|
@@ -880,13 +721,13 @@ function validateInferredPromptPortConflicts(
|
|
|
880
721
|
path: taskPath,
|
|
881
722
|
message:
|
|
882
723
|
`Task "${task.id}": upstream Commands ${producers.join(', ')} all export ` +
|
|
883
|
-
`"${name}"
|
|
724
|
+
`"${name}" 鈥?prompt tasks cannot disambiguate (no "from:" binding available). ` +
|
|
884
725
|
`Rename the output on one of the upstream Commands.`,
|
|
885
726
|
});
|
|
886
727
|
}
|
|
887
728
|
}
|
|
888
729
|
|
|
889
|
-
//
|
|
730
|
+
// 鈹€鈹€鈹€ Output collision 鈹€鈹€
|
|
890
731
|
//
|
|
891
732
|
// Walk every task in the pipeline once and check whether it depends on
|
|
892
733
|
// us. We reuse the shared qidIndex + TaskIndex for the lookup; small
|
|
@@ -911,23 +752,23 @@ function validateInferredPromptPortConflicts(
|
|
|
911
752
|
}
|
|
912
753
|
}
|
|
913
754
|
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(
|
|
755
|
+
const inputs = entry.task.inputs;
|
|
756
|
+
if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) continue;
|
|
757
|
+
for (const [inputName, binding] of Object.entries(inputs)) {
|
|
758
|
+
if (!binding || typeof binding !== 'object' || Array.isArray(binding)) continue;
|
|
759
|
+
const shape = bindingShapeKey(binding as { type?: PortType; enum?: readonly string[] });
|
|
760
|
+
const prior = consumerShapeByName.get(inputName);
|
|
920
761
|
if (!prior) {
|
|
921
|
-
consumerShapeByName.set(
|
|
762
|
+
consumerShapeByName.set(inputName, { shape, firstConsumer: downstreamQid });
|
|
922
763
|
continue;
|
|
923
764
|
}
|
|
924
|
-
if (prior.shape !== shape && !reported.has(
|
|
925
|
-
reported.add(
|
|
765
|
+
if (prior.shape !== shape && !reported.has(inputName)) {
|
|
766
|
+
reported.add(inputName);
|
|
926
767
|
errors.push({
|
|
927
768
|
path: taskPath,
|
|
928
769
|
message:
|
|
929
770
|
`Task "${task.id}": downstream Commands ${prior.firstConsumer} and ` +
|
|
930
|
-
`${downstreamQid} disagree on the shape of inferred output "${
|
|
771
|
+
`${downstreamQid} disagree on the shape of inferred output "${inputName}" 鈥?` +
|
|
931
772
|
`a single LLM emission cannot satisfy both. Rename on one side.`,
|
|
932
773
|
});
|
|
933
774
|
}
|
|
@@ -935,15 +776,14 @@ function validateInferredPromptPortConflicts(
|
|
|
935
776
|
}
|
|
936
777
|
}
|
|
937
778
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
if (port.type !== 'enum') return String(port.type);
|
|
779
|
+
function bindingShapeKey(port: { type?: PortType; enum?: readonly string[] }): string {
|
|
780
|
+
if ((port.type ?? 'json') !== 'enum') return String(port.type ?? 'json');
|
|
941
781
|
const enums = Array.isArray(port.enum) ? [...port.enum].sort().join('|') : '';
|
|
942
782
|
return `enum:${enums}`;
|
|
943
783
|
}
|
|
944
784
|
|
|
945
785
|
function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationError[] {
|
|
946
|
-
// Build adjacency: qualifiedId
|
|
786
|
+
// Build adjacency: qualifiedId 鈫?[resolved dep qualifiedIds]
|
|
947
787
|
const adj = new Map<string, string[]>();
|
|
948
788
|
|
|
949
789
|
for (const track of config.tracks) {
|
|
@@ -969,7 +809,7 @@ function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationEr
|
|
|
969
809
|
const visited = new Set<string>();
|
|
970
810
|
const inStack = new Set<string>();
|
|
971
811
|
// Deduplicate cycles: the same cycle can be discovered from multiple entry points.
|
|
972
|
-
// Canonical key = sorted node list joined
|
|
812
|
+
// Canonical key = sorted node list joined 鈥?order-independent fingerprint.
|
|
973
813
|
const seenCycles = new Set<string>();
|
|
974
814
|
|
|
975
815
|
// Use a mutable path array instead of copying at each level (O(n) vs O(n^2)).
|
|
@@ -988,7 +828,7 @@ function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationEr
|
|
|
988
828
|
const display = [...uniqueNodes, id]; // include start for readable display
|
|
989
829
|
errors.push({
|
|
990
830
|
path: 'tracks',
|
|
991
|
-
message: `Circular dependency detected: ${display.join('
|
|
831
|
+
message: `Circular dependency detected: ${display.join(' 鈫?')}`,
|
|
992
832
|
});
|
|
993
833
|
}
|
|
994
834
|
return;
|