@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.
Files changed (72) hide show
  1. package/README.md +84 -44
  2. package/dist/bootstrap.d.ts +20 -0
  3. package/dist/bootstrap.d.ts.map +1 -1
  4. package/dist/bootstrap.js +21 -11
  5. package/dist/bootstrap.js.map +1 -1
  6. package/dist/core/dataflow.d.ts.map +1 -1
  7. package/dist/core/dataflow.js +45 -9
  8. package/dist/core/dataflow.js.map +1 -1
  9. package/dist/core/run-context.d.ts +3 -0
  10. package/dist/core/run-context.d.ts.map +1 -1
  11. package/dist/core/run-context.js +2 -0
  12. package/dist/core/run-context.js.map +1 -1
  13. package/dist/core/task-executor.d.ts.map +1 -1
  14. package/dist/core/task-executor.js +46 -84
  15. package/dist/core/task-executor.js.map +1 -1
  16. package/dist/engine.d.ts +6 -0
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +3 -0
  19. package/dist/engine.js.map +1 -1
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/plugins.d.ts +2 -2
  25. package/dist/plugins.d.ts.map +1 -1
  26. package/dist/ports.d.ts +4 -0
  27. package/dist/ports.d.ts.map +1 -1
  28. package/dist/ports.js +27 -4
  29. package/dist/ports.js.map +1 -1
  30. package/dist/registry.d.ts +10 -4
  31. package/dist/registry.d.ts.map +1 -1
  32. package/dist/registry.js +64 -25
  33. package/dist/registry.js.map +1 -1
  34. package/dist/runtime.d.ts +9 -0
  35. package/dist/runtime.d.ts.map +1 -0
  36. package/dist/runtime.js +8 -0
  37. package/dist/runtime.js.map +1 -0
  38. package/dist/schema.d.ts.map +1 -1
  39. package/dist/schema.js +1 -7
  40. package/dist/schema.js.map +1 -1
  41. package/dist/tagma.d.ts +11 -1
  42. package/dist/tagma.d.ts.map +1 -1
  43. package/dist/tagma.js +6 -0
  44. package/dist/tagma.js.map +1 -1
  45. package/dist/validate-raw.d.ts +4 -4
  46. package/dist/validate-raw.d.ts.map +1 -1
  47. package/dist/validate-raw.js +89 -230
  48. package/dist/validate-raw.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/bootstrap.ts +23 -14
  51. package/src/core/dataflow.test.ts +8 -9
  52. package/src/core/dataflow.ts +57 -14
  53. package/src/core/run-context.test.ts +12 -0
  54. package/src/core/run-context.ts +4 -0
  55. package/src/core/task-executor.ts +75 -135
  56. package/src/engine-ports-mixed.test.ts +68 -411
  57. package/src/engine-ports.test.ts +37 -341
  58. package/src/engine.ts +8 -0
  59. package/src/index.ts +5 -0
  60. package/src/pipeline-runner.test.ts +5 -9
  61. package/src/plugin-registry.test.ts +138 -1
  62. package/src/plugins.ts +5 -2
  63. package/src/ports.test.ts +80 -0
  64. package/src/ports.ts +36 -4
  65. package/src/registry.ts +81 -26
  66. package/src/runtime.ts +20 -0
  67. package/src/schema-ports.test.ts +47 -197
  68. package/src/schema.ts +1 -7
  69. package/src/tagma.test.ts +72 -1
  70. package/src/tagma.ts +16 -1
  71. package/src/validate-raw-ports.test.ts +80 -393
  72. package/src/validate-raw.ts +90 -250
@@ -1,13 +1,12 @@
1
- // ═══ Raw Pipeline Config Validation ═══
1
+ // 鈺愨晲鈺?Raw Pipeline Config Validation 鈺愨晲鈺?
2
2
  //
3
3
  // Validates a RawPipelineConfig without resolving inheritance or executing
4
- // anything intended for real-time feedback in a visual editor (e.g. drag
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 {track, task} lookup built once per validation pass. */
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 a dot in either
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 otherwise the Task panel would show
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 depends_on` ordering, so the matching
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 defaulting `severity` to undefined preserves that behaviour.
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') these don't
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
- // ── Top level ──
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
- // ── Build qualified ID sets for cross-reference checks ──
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 refs
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 {track, task} index used by port-inference validation
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
- // ── Per-track validation ──
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 surface a warning so the user notices
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 the pipeline will fail at run time.`,
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
- // ── Per-task validation ──
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
- // ── Field-level validations ──
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
- // ── Plugin type warnings (trigger / completion / middlewares) ──
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 the pipeline will fail at run time.`,
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
- // ── Port declaration checks ──
361
+ // 鈹€鈹€ Port declaration checks 鈹€鈹€
363
362
  validateTaskPorts(task, track.id, taskPath, qidIndex, index, errors);
364
363
 
365
- // ── depends_on reference checks ──
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}" no such task found`,
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 multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
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
- // ── continue_from reference check ──
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}" no such task found`,
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 multiple tracks have a task with this id. Use the fully-qualified form "trackId.${task.continue_from}".`,
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
- // ── Cycle detection ──
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
- // ─── Prompt tasks do not declare ports ──
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}": prompt tasks do not declare ports — their I/O is ` +
701
- `inferred from direct-neighbor Command tasks. Remove the "ports" field ` +
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
- // ─── Collect placeholder references ──
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 `ports.inputs`
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 tolerate it here).
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 port named "${name}"`
739
- : `no such input port is declared`;
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
- // ─── Structural port validation Command Tasks only ──
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 so we
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 Prompt upstreams pass free text.
671
+ // Only Command tasks contribute 鈥?Prompt upstreams pass free text.
828
672
  if (typeof entry.task.command !== 'string') continue;
829
- const outputs = entry.task.ports?.outputs;
830
- if (!Array.isArray(outputs)) continue;
831
- for (const port of outputs) {
832
- if (port && typeof port === 'object' && typeof port.name === 'string') {
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 report them at validate-time so the editor lights them up
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→Command would let the
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
- // ─── Input collision ──
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.ports?.outputs;
869
- if (!Array.isArray(outputs)) continue;
870
- for (const port of outputs) {
871
- if (!port || typeof port !== 'object' || typeof port.name !== 'string') continue;
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(port.name, list);
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}" prompt tasks cannot disambiguate (no "from:" binding available). ` +
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
- // ─── Output collision ──
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.ports?.inputs;
915
- if (!Array.isArray(inputs)) continue;
916
- for (const port of inputs) {
917
- if (!port || typeof port !== 'object' || typeof port.name !== 'string') continue;
918
- const shape = portShapeKey(port);
919
- const prior = consumerShapeByName.get(port.name);
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(port.name, { shape, firstConsumer: downstreamQid });
762
+ consumerShapeByName.set(inputName, { shape, firstConsumer: downstreamQid });
922
763
  continue;
923
764
  }
924
- if (prior.shape !== shape && !reported.has(port.name)) {
925
- reported.add(port.name);
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 "${port.name}" ` +
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
- /** Minimal shape fingerprint for conflict detection: type + enum set. */
939
- function portShapeKey(port: PortDef): string {
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 [resolved dep qualifiedIds]
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 order-independent fingerprint.
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;