@tagma/sdk 0.6.7 → 0.6.9

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.
@@ -6,7 +6,13 @@
6
6
  //
7
7
  // Returns a flat list of ValidationError objects. An empty array means valid.
8
8
 
9
- import type { PortDef, PortType, RawPipelineConfig, RawTaskConfig } from './types';
9
+ import type {
10
+ PortDef,
11
+ PortType,
12
+ RawPipelineConfig,
13
+ RawTaskConfig,
14
+ RawTrackConfig,
15
+ } from './types';
10
16
  import {
11
17
  isValidTaskId,
12
18
  qualifyTaskId,
@@ -16,6 +22,24 @@ import {
16
22
  } from './task-ref';
17
23
  import { extractInputReferences } from './ports';
18
24
 
25
+ interface QidEntry {
26
+ readonly track: RawTrackConfig;
27
+ readonly task: RawTaskConfig;
28
+ }
29
+
30
+ /** qid → {track, task} lookup built once per validation pass. */
31
+ function buildQidIndex(config: RawPipelineConfig): Map<string, QidEntry> {
32
+ const idx = new Map<string, QidEntry>();
33
+ for (const track of config.tracks ?? []) {
34
+ if (!track.id) continue;
35
+ for (const task of track.tasks ?? []) {
36
+ if (!task.id) continue;
37
+ idx.set(qualifyTaskId(track.id, task.id), { track, task });
38
+ }
39
+ }
40
+ return idx;
41
+ }
42
+
19
43
  const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
20
44
  function isValidDuration(input: string): boolean {
21
45
  return DURATION_RE.test(input.trim());
@@ -125,6 +149,9 @@ export function validateRaw(
125
149
  // Shared with dag.ts so "ambiguous" / "not found" stay consistent — refs
126
150
  // that buildDag later throws on will be reported here as errors first.
127
151
  const index = buildTaskIndex(config);
152
+ // Full qid → {track, task} index used by port-inference validation
153
+ // to walk a Prompt task's neighbors without re-scanning the tracks.
154
+ const qidIndex = buildQidIndex(config);
128
155
 
129
156
  // ── Per-track validation ──
130
157
  const seenTrackIds = new Set<string>();
@@ -286,7 +313,7 @@ export function validateRaw(
286
313
  }
287
314
 
288
315
  // ── Port declaration checks ──
289
- validateTaskPorts(task, taskPath, errors);
316
+ validateTaskPorts(task, track.id, taskPath, qidIndex, index, errors);
290
317
 
291
318
  // ── depends_on reference checks ──
292
319
  if (task.depends_on && task.depends_on.length > 0) {
@@ -440,20 +467,40 @@ function validatePortList(
440
467
 
441
468
  function validateTaskPorts(
442
469
  task: RawTaskConfig,
470
+ trackId: string,
443
471
  taskPath: string,
472
+ qidIndex: Map<string, QidEntry>,
473
+ index: TaskIndex,
444
474
  errors: ValidationError[],
445
475
  ): void {
446
476
  const ports = task.ports;
447
- // Placeholder cross-checks are independent of ports being declared
448
- // a user can type `{{inputs.X}}` without declaring any ports yet, and
449
- // that's always an error (the engine has no `X` to substitute, and
450
- // `validate-raw` is the one place that surfaces this before a run).
451
- // Running the check unconditionally catches the typo on its own.
452
- const declaredInputs = new Set<string>(
453
- ports && Array.isArray(ports.inputs)
454
- ? ports.inputs.filter((p): p is PortDef => !!p && typeof p === 'object').map((p) => p.name)
455
- : [],
456
- );
477
+ const isPromptTask = typeof task.prompt === 'string' && typeof task.command !== 'string';
478
+ const isCommandTask = typeof task.command === 'string' && typeof task.prompt !== 'string';
479
+
480
+ // ─── Prompt tasks do not declare ports ──
481
+ //
482
+ // A Prompt Task's I/O contract is inferred from direct-neighbor
483
+ // Command Tasks at runtime (see `inferPromptPorts` in ports.ts).
484
+ // Declaring `ports` on a Prompt Task is always a configuration
485
+ // mistake: the declared shape would be silently ignored in favour of
486
+ // the inferred one, and the two drifting out of sync is the exact bug
487
+ // the inference design eliminates.
488
+ if (isPromptTask && ports !== undefined) {
489
+ errors.push({
490
+ path: `${taskPath}.ports`,
491
+ message:
492
+ `Task "${task.id}": prompt tasks do not declare ports — their I/O is ` +
493
+ `inferred from direct-neighbor Command tasks. Remove the "ports" field ` +
494
+ `and declare the corresponding inputs/outputs on the upstream/downstream ` +
495
+ `Command tasks instead.`,
496
+ });
497
+ }
498
+
499
+ // ─── Collect placeholder references ──
500
+ // `{{inputs.X}}` is valid in both prompt and command text. The set of
501
+ // names a task may legally reference differs by task kind:
502
+ // - Command Task: its own declared `ports.inputs`
503
+ // - Prompt Task: the union of direct-upstream Command outputs
457
504
  const referenced = new Set<string>();
458
505
  if (typeof task.prompt === 'string') {
459
506
  for (const n of extractInputReferences(task.prompt)) referenced.add(n);
@@ -461,42 +508,230 @@ function validateTaskPorts(
461
508
  if (typeof task.command === 'string') {
462
509
  for (const n of extractInputReferences(task.command)) referenced.add(n);
463
510
  }
511
+
512
+ let availableInputs: Set<string>;
513
+ if (isPromptTask) {
514
+ availableInputs = collectUpstreamCommandOutputNames(task, trackId, qidIndex, index);
515
+ } else {
516
+ // Command Task (or the pathological both-keys case, which is caught
517
+ // earlier as a separate error — tolerate it here).
518
+ availableInputs = new Set<string>(
519
+ ports && Array.isArray(ports.inputs)
520
+ ? ports.inputs.filter((p): p is PortDef => !!p && typeof p === 'object').map((p) => p.name)
521
+ : [],
522
+ );
523
+ }
524
+
464
525
  for (const name of referenced) {
465
- if (!declaredInputs.has(name)) {
526
+ if (!availableInputs.has(name)) {
527
+ const hint = isPromptTask
528
+ ? `no upstream Command task exports an output port named "${name}"`
529
+ : `no such input port is declared`;
466
530
  errors.push({
467
531
  path: taskPath,
468
- message: `Task "${task.id}": references "{{inputs.${name}}}" but no such input port is declared`,
532
+ message: `Task "${task.id}": references "{{inputs.${name}}}" but ${hint}`,
469
533
  });
470
534
  }
471
535
  }
472
536
 
473
- if (!ports) return;
474
-
475
- // Per-port structural validation runs only after we've established
476
- // that `ports.inputs` / `ports.outputs` are arrays validatePortList
477
- // also re-checks Array.isArray internally, which keeps it callable
478
- // from contexts that hand it stray values.
479
- validatePortList(ports.inputs, `${taskPath}.ports.inputs`, 'inputs', errors);
480
- validatePortList(ports.outputs, `${taskPath}.ports.outputs`, 'outputs', errors);
481
-
482
- // Warn on declared-but-unused inputs. Not fatal — a user may want to
483
- // surface an input as a data-flow hint for the editor even when the
484
- // prompt/command doesn't template it explicitly (e.g. AI tasks that
485
- // consume inputs through the `[Inputs]` context block).
486
- if (typeof task.command === 'string' && Array.isArray(ports.inputs)) {
487
- for (const port of ports.inputs) {
488
- if (!port || typeof port !== 'object') continue;
489
- if (!referenced.has(port.name)) {
537
+ // ─── Structural port validation — Command Tasks only ──
538
+ //
539
+ // Prompt tasks already errored above if they tried to declare ports;
540
+ // running the per-port structural validator on the ignored object
541
+ // would just produce duplicate noise.
542
+ if (isCommandTask && ports) {
543
+ validatePortList(ports.inputs, `${taskPath}.ports.inputs`, 'inputs', errors);
544
+ validatePortList(ports.outputs, `${taskPath}.ports.outputs`, 'outputs', errors);
545
+
546
+ // Warn on declared-but-unused inputs. Not fatal — a user may want
547
+ // to surface an input as a data-flow hint for the editor even when
548
+ // the command doesn't template it explicitly.
549
+ if (typeof task.command === 'string' && Array.isArray(ports.inputs)) {
550
+ for (const port of ports.inputs) {
551
+ if (!port || typeof port !== 'object') continue;
552
+ if (!referenced.has(port.name)) {
553
+ errors.push({
554
+ path: `${taskPath}.ports.inputs`,
555
+ severity: 'warning',
556
+ message: `Task "${task.id}": command does not reference {{inputs.${port.name}}} — declared input is unused`,
557
+ });
558
+ }
559
+ }
560
+ }
561
+
562
+ // Validate that fully-qualified `from` references point to direct
563
+ // dependencies. The runtime's findUpstreamValue only scans dependsOn,
564
+ // so a from that skips the dependency list will always miss at run
565
+ // time and block the task with a cryptic "missing required input".
566
+ if (Array.isArray(ports.inputs)) {
567
+ for (const port of ports.inputs) {
568
+ if (!port || typeof port !== 'object' || typeof port.from !== 'string' || !port.from.includes('.')) {
569
+ continue;
570
+ }
571
+ const dot = port.from.lastIndexOf('.');
572
+ const upstreamId = port.from.slice(0, dot);
573
+ const deps = task.depends_on ?? [];
574
+ const isDirectDep = deps.some((dep) => {
575
+ const resolved = resolveTaskRef(dep, trackId, index);
576
+ return resolved.kind === 'resolved' && resolved.qid === upstreamId;
577
+ });
578
+ if (!isDirectDep) {
579
+ errors.push({
580
+ path: `${taskPath}.ports.inputs`,
581
+ 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)`,
582
+ });
583
+ }
584
+ }
585
+ }
586
+ }
587
+
588
+ // ─── Prompt-task inferred-port conflict checks ──
589
+ //
590
+ // Static counterparts to the runtime checks `inferPromptPorts` runs.
591
+ // These surface problems at author-time in the editor so the user
592
+ // fixes them before a run, rather than hitting a "blocked" task.
593
+ if (isPromptTask) {
594
+ validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex, index, errors);
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Walk the direct-upstream Commands of a Prompt Task and collect every
600
+ * output port name they export. Prompt upstreams contribute nothing —
601
+ * they pass free text via continue_from, not structured ports — so we
602
+ * skip them. This mirrors exactly what the engine does at runtime in
603
+ * `inferPromptPorts`, keeping the editor and runtime views aligned.
604
+ */
605
+ function collectUpstreamCommandOutputNames(
606
+ task: RawTaskConfig,
607
+ trackId: string,
608
+ qidIndex: Map<string, QidEntry>,
609
+ index: TaskIndex,
610
+ ): Set<string> {
611
+ const names = new Set<string>();
612
+ for (const dep of task.depends_on ?? []) {
613
+ const r = resolveTaskRef(dep, trackId, index);
614
+ if (r.kind !== 'resolved') continue;
615
+ const entry = qidIndex.get(r.qid);
616
+ if (!entry) continue;
617
+ // Only Command tasks contribute — Prompt upstreams pass free text.
618
+ if (typeof entry.task.command !== 'string') continue;
619
+ const outputs = entry.task.ports?.outputs;
620
+ if (!Array.isArray(outputs)) continue;
621
+ for (const port of outputs) {
622
+ if (port && typeof port === 'object' && typeof port.name === 'string') {
623
+ names.add(port.name);
624
+ }
625
+ }
626
+ }
627
+ return names;
628
+ }
629
+
630
+ /**
631
+ * Detect the two kinds of collision that would block a Prompt Task at
632
+ * runtime — report them at validate-time so the editor lights them up
633
+ * before a run is attempted.
634
+ *
635
+ * 1. Input collision: two direct-upstream Commands both export an
636
+ * output with the same name. Command→Command would let the
637
+ * downstream disambiguate with `from:`; Prompt tasks have no port
638
+ * declarations and therefore no escape hatch.
639
+ * 2. Output collision: two direct-downstream Commands declare inputs
640
+ * with the same name but incompatible shapes (different type, or
641
+ * different enum sets). A single LLM emission cannot satisfy both.
642
+ */
643
+ function validateInferredPromptPortConflicts(
644
+ task: RawTaskConfig,
645
+ trackId: string,
646
+ taskPath: string,
647
+ qidIndex: Map<string, QidEntry>,
648
+ index: TaskIndex,
649
+ errors: ValidationError[],
650
+ ): void {
651
+ // ─── Input collision ──
652
+ const producersByName = new Map<string, string[]>();
653
+ for (const dep of task.depends_on ?? []) {
654
+ const r = resolveTaskRef(dep, trackId, index);
655
+ if (r.kind !== 'resolved') continue;
656
+ const entry = qidIndex.get(r.qid);
657
+ if (!entry || typeof entry.task.command !== 'string') continue;
658
+ const outputs = entry.task.ports?.outputs;
659
+ if (!Array.isArray(outputs)) continue;
660
+ for (const port of outputs) {
661
+ if (!port || typeof port !== 'object' || typeof port.name !== 'string') continue;
662
+ const list = producersByName.get(port.name) ?? [];
663
+ list.push(r.qid);
664
+ producersByName.set(port.name, list);
665
+ }
666
+ }
667
+ for (const [name, producers] of producersByName) {
668
+ if (producers.length > 1) {
669
+ errors.push({
670
+ path: taskPath,
671
+ message:
672
+ `Task "${task.id}": upstream Commands ${producers.join(', ')} all export ` +
673
+ `"${name}" — prompt tasks cannot disambiguate (no "from:" binding available). ` +
674
+ `Rename the output on one of the upstream Commands.`,
675
+ });
676
+ }
677
+ }
678
+
679
+ // ─── Output collision ──
680
+ //
681
+ // Walk every task in the pipeline once and check whether it depends on
682
+ // us. We reuse the shared qidIndex + TaskIndex for the lookup; small
683
+ // pipelines stay O(tasks), which is fine for validate-raw (it already
684
+ // O(tasks) elsewhere).
685
+ const taskQid = qualifyTaskId(trackId, task.id);
686
+ const consumerShapeByName = new Map<
687
+ string,
688
+ { readonly shape: string; readonly firstConsumer: string }
689
+ >();
690
+ const reported = new Set<string>();
691
+ for (const [downstreamQid, entry] of qidIndex) {
692
+ if (downstreamQid === taskQid) continue;
693
+ if (typeof entry.task.command !== 'string') continue; // only downstream Commands contribute
694
+ const deps = entry.task.depends_on ?? [];
695
+ let dependsOnUs = false;
696
+ for (const d of deps) {
697
+ const r = resolveTaskRef(d, entry.track.id, index);
698
+ if (r.kind === 'resolved' && r.qid === taskQid) {
699
+ dependsOnUs = true;
700
+ break;
701
+ }
702
+ }
703
+ if (!dependsOnUs) continue;
704
+ const inputs = entry.task.ports?.inputs;
705
+ if (!Array.isArray(inputs)) continue;
706
+ for (const port of inputs) {
707
+ if (!port || typeof port !== 'object' || typeof port.name !== 'string') continue;
708
+ const shape = portShapeKey(port);
709
+ const prior = consumerShapeByName.get(port.name);
710
+ if (!prior) {
711
+ consumerShapeByName.set(port.name, { shape, firstConsumer: downstreamQid });
712
+ continue;
713
+ }
714
+ if (prior.shape !== shape && !reported.has(port.name)) {
715
+ reported.add(port.name);
490
716
  errors.push({
491
- path: `${taskPath}.ports.inputs`,
492
- severity: 'warning',
493
- message: `Task "${task.id}": command does not reference {{inputs.${port.name}}} declared input is unused`,
717
+ path: taskPath,
718
+ message:
719
+ `Task "${task.id}": downstream Commands ${prior.firstConsumer} and ` +
720
+ `${downstreamQid} disagree on the shape of inferred output "${port.name}" — ` +
721
+ `a single LLM emission cannot satisfy both. Rename on one side.`,
494
722
  });
495
723
  }
496
724
  }
497
725
  }
498
726
  }
499
727
 
728
+ /** Minimal shape fingerprint for conflict detection: type + enum set. */
729
+ function portShapeKey(port: PortDef): string {
730
+ if (port.type !== 'enum') return String(port.type);
731
+ const enums = Array.isArray(port.enum) ? [...port.enum].sort().join('|') : '';
732
+ return `enum:${enums}`;
733
+ }
734
+
500
735
  function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationError[] {
501
736
  // Build adjacency: qualifiedId → [resolved dep qualifiedIds]
502
737
  const adj = new Map<string, string[]>();
@@ -0,0 +1,83 @@
1
+ import { parseYaml } from './schema';
2
+ import { validateRaw } from './validate-raw';
3
+ import type { ValidationError, KnownPluginTypes } from './validate-raw';
4
+ import type { RawPipelineConfig } from './types';
5
+
6
+ export interface YamlCompileResult {
7
+ readonly timestamp: string;
8
+ readonly sourceName: string;
9
+ readonly success: boolean;
10
+ readonly parseOk: boolean;
11
+ readonly validation: {
12
+ readonly errors: ReadonlyArray<Pick<ValidationError, 'path' | 'message'>>;
13
+ readonly warnings: ReadonlyArray<Pick<ValidationError, 'path' | 'message'>>;
14
+ };
15
+ readonly summary: string;
16
+ }
17
+
18
+ export interface CompileYamlOptions {
19
+ readonly sourceName?: string;
20
+ readonly knownTypes?: KnownPluginTypes;
21
+ }
22
+
23
+ export function compileYamlContent(
24
+ content: string,
25
+ opts: CompileYamlOptions = {},
26
+ ): YamlCompileResult {
27
+ const timestamp = new Date().toISOString();
28
+ const sourceName = opts.sourceName ?? 'untitled';
29
+
30
+ let config: RawPipelineConfig;
31
+ try {
32
+ config = parseYaml(content);
33
+ } catch (err) {
34
+ return {
35
+ timestamp,
36
+ sourceName,
37
+ success: false,
38
+ parseOk: false,
39
+ validation: { errors: [], warnings: [] },
40
+ summary: `YAML parse error: ${errorMessage(err)}`,
41
+ };
42
+ }
43
+
44
+ let errors: ValidationError[];
45
+ try {
46
+ errors = validateRaw(config, opts.knownTypes);
47
+ } catch (err) {
48
+ return {
49
+ timestamp,
50
+ sourceName,
51
+ success: false,
52
+ parseOk: true,
53
+ validation: { errors: [], warnings: [] },
54
+ summary: `Validation crashed: ${errorMessage(err)}`,
55
+ };
56
+ }
57
+
58
+ const validationErrors = errors.filter((e) => e.severity === 'error' || e.severity == null);
59
+ const validationWarnings = errors.filter((e) => e.severity === 'warning');
60
+
61
+ return {
62
+ timestamp,
63
+ sourceName,
64
+ success: validationErrors.length === 0,
65
+ parseOk: true,
66
+ validation: {
67
+ errors: validationErrors.map((e) => ({ path: e.path, message: e.message })),
68
+ warnings: validationWarnings.map((e) => ({ path: e.path, message: e.message })),
69
+ },
70
+ summary:
71
+ validationErrors.length === 0
72
+ ? validationWarnings.length === 0
73
+ ? 'Valid pipeline configuration'
74
+ : `Valid with ${validationWarnings.length} warning(s)`
75
+ : `Invalid: ${validationErrors.length} error(s), ${validationWarnings.length} warning(s)`,
76
+ };
77
+ }
78
+
79
+ function errorMessage(err: unknown): string {
80
+ if (err instanceof Error && err.message) return err.message;
81
+ if (typeof err === 'string') return err;
82
+ return 'Unknown error';
83
+ }