@tagma/sdk 0.6.11 → 0.6.12

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.
@@ -13,6 +13,34 @@ const WORK_DIR = process.platform === 'win32' ? 'D:\\fake-work' : '/fake-work';
13
13
  // ─── resolveConfig preserves ports ───────────────────────────────────
14
14
 
15
15
  describe('resolveConfig — ports passthrough', () => {
16
+ test('raw lightweight bindings survive onto the resolved task', () => {
17
+ const raw: RawPipelineConfig = {
18
+ name: 'p',
19
+ tracks: [
20
+ {
21
+ id: 't',
22
+ name: 'T',
23
+ tasks: [
24
+ {
25
+ id: 'a',
26
+ command: 'echo "{{inputs.city}}"',
27
+ inputs: {
28
+ city: { from: 't.plan.outputs.city', required: true },
29
+ },
30
+ outputs: {
31
+ report: { from: 'json.reportPath' },
32
+ },
33
+ },
34
+ ],
35
+ },
36
+ ],
37
+ };
38
+ const resolved = resolveConfig(raw, WORK_DIR);
39
+ const task = resolved.tracks[0]!.tasks[0]!;
40
+ expect(task.inputs).toEqual(raw.tracks[0]!.tasks[0]!.inputs!);
41
+ expect(task.outputs).toEqual(raw.tracks[0]!.tasks[0]!.outputs!);
42
+ });
43
+
16
44
  test('raw ports survive onto the resolved task', () => {
17
45
  const raw: RawPipelineConfig = {
18
46
  name: 'p',
@@ -83,6 +111,35 @@ describe('resolveConfig — ports passthrough', () => {
83
111
  // ─── deresolvePipeline preserves ports ───────────────────────────────
84
112
 
85
113
  describe('deresolvePipeline — ports round-trip', () => {
114
+ test('lightweight bindings round-trip', () => {
115
+ const raw: RawPipelineConfig = {
116
+ name: 'p',
117
+ tracks: [
118
+ {
119
+ id: 't',
120
+ name: 'T',
121
+ tasks: [
122
+ {
123
+ id: 'a',
124
+ command: 'echo "{{inputs.city}}"',
125
+ inputs: {
126
+ city: { from: 't.plan.outputs.city', required: true },
127
+ mode: { default: 'quick' },
128
+ },
129
+ outputs: {
130
+ raw: { from: 'stdout' },
131
+ },
132
+ },
133
+ ],
134
+ },
135
+ ],
136
+ };
137
+ const resolved = resolveConfig(raw, WORK_DIR);
138
+ const back = deresolvePipeline(resolved, WORK_DIR);
139
+ expect(back.tracks[0]!.tasks[0]!.inputs).toEqual(raw.tracks[0]!.tasks[0]!.inputs!);
140
+ expect(back.tracks[0]!.tasks[0]!.outputs).toEqual(raw.tracks[0]!.tasks[0]!.outputs!);
141
+ });
142
+
86
143
  test('ports with both inputs and outputs round-trip', () => {
87
144
  const raw: RawPipelineConfig = {
88
145
  name: 'p',
@@ -200,6 +257,35 @@ describe('deresolvePipeline — ports round-trip', () => {
200
257
  // ─── parseYaml accepts ports ─────────────────────────────────────────
201
258
 
202
259
  describe('parseYaml — accepts ports declarations', () => {
260
+ test('real-world YAML with lightweight bindings parses cleanly', () => {
261
+ const text = `pipeline:
262
+ name: demo
263
+ tracks:
264
+ - id: t
265
+ name: Main
266
+ tasks:
267
+ - id: build
268
+ command: bun run build
269
+ outputs:
270
+ bundlePath: { from: json.bundlePath }
271
+ - id: test
272
+ depends_on: [build]
273
+ command: 'bun test "{{inputs.bundlePath}}"'
274
+ inputs:
275
+ bundlePath:
276
+ from: t.build.outputs.bundlePath
277
+ required: true
278
+ `;
279
+ const config = parseYaml(text);
280
+ const build = config.tracks[0]!.tasks[0]!;
281
+ const testTask = config.tracks[0]!.tasks[1]!;
282
+ expect(build.outputs!.bundlePath).toEqual({ from: 'json.bundlePath' });
283
+ expect(testTask.inputs!.bundlePath).toEqual({
284
+ from: 't.build.outputs.bundlePath',
285
+ required: true,
286
+ });
287
+ });
288
+
203
289
  test('real-world YAML with ports parses cleanly', () => {
204
290
  const text = `pipeline:
205
291
  name: demo
package/src/schema.ts CHANGED
@@ -97,7 +97,7 @@ function validateRawTask(task: RawTaskConfig, trackId: string): void {
97
97
  throw new Error(`task "${task.id}": cannot have both "prompt" and "command"`);
98
98
  }
99
99
  // Empty-content tasks (e.g. `prompt: ''`) are allowed at parse time and
100
- // flagged as non-fatal validation errors by validate-raw.ts.
100
+ // flagged as hard validation errors by validate-raw.ts.
101
101
  }
102
102
 
103
103
  // ═══ Config Inheritance Resolution ═══
@@ -161,8 +161,10 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
161
161
  completion: rawTask.completion,
162
162
  agent_profile: rawTask.agent_profile ?? rawTrack.agent_profile,
163
163
  cwd: rawTask.cwd ? validatePath(rawTask.cwd, workDir) : trackCwd,
164
- // Ports: no inheritance — they describe per-task I/O contract, not
165
- // cross-task defaults. Passed through as-is (including `undefined`).
164
+ // Lightweight bindings and ports: no inheritance — they describe
165
+ // per-task data flow, not cross-task defaults.
166
+ inputs: rawTask.inputs,
167
+ outputs: rawTask.outputs,
166
168
  ports: rawTask.ports,
167
169
  };
168
170
  });
@@ -309,6 +311,8 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
309
311
  ...(task.permissions && !permissionsEqual(task.permissions, track.permissions)
310
312
  ? { permissions: task.permissions }
311
313
  : {}),
314
+ ...(task.inputs && Object.keys(task.inputs).length > 0 ? { inputs: task.inputs } : {}),
315
+ ...(task.outputs && Object.keys(task.outputs).length > 0 ? { outputs: task.outputs } : {}),
312
316
  ...(task.ports &&
313
317
  ((task.ports.inputs && task.ports.inputs.length > 0) ||
314
318
  (task.ports.outputs && task.ports.outputs.length > 0))
package/src/sdk.ts CHANGED
@@ -129,12 +129,16 @@ export {
129
129
  export {
130
130
  substituteInputs,
131
131
  extractInputReferences,
132
+ resolveTaskBindingInputs,
132
133
  resolveTaskInputs,
134
+ extractTaskBindingOutputs,
133
135
  extractTaskOutputs,
134
136
  inferPromptPorts,
135
137
  } from './ports';
136
138
  export type {
137
139
  SubstituteResult,
140
+ BindingInputResolution,
141
+ UpstreamBindingData,
138
142
  InputResolution,
139
143
  ExtractResult,
140
144
  PromptPortInference,
@@ -38,6 +38,12 @@ function portsErrors(errors: ReturnType<typeof validateRaw>): typeof errors {
38
38
  );
39
39
  }
40
40
 
41
+ function bindingErrors(errors: ReturnType<typeof validateRaw>): typeof errors {
42
+ return errors.filter(
43
+ (e) => e.path.includes('.inputs') || e.path.includes('.outputs'),
44
+ );
45
+ }
46
+
41
47
  // ─── Structural validation (Command Tasks) ───────────────────────────
42
48
 
43
49
  describe('validateRaw — port structure (command tasks)', () => {
@@ -123,6 +129,66 @@ describe('validateRaw — port structure (command tasks)', () => {
123
129
  });
124
130
  });
125
131
 
132
+ // ─── Lightweight binding validation ──────────────────────────────────
133
+
134
+ describe('validateRaw — lightweight task bindings', () => {
135
+ test('accepts top-level inputs for command placeholder references', () => {
136
+ const errors = errorsFor(
137
+ commandTask({
138
+ id: 'a',
139
+ command: 'echo {{inputs.city}}',
140
+ inputs: { city: { value: 'Shanghai' } },
141
+ }),
142
+ );
143
+ expect(errors.some((e) => e.message.includes('references "{{inputs.city}}"'))).toBe(false);
144
+ });
145
+
146
+ test('rejects non-object binding maps and entries', () => {
147
+ const errors = errorsFor(
148
+ commandTask({
149
+ id: 'a',
150
+ inputs: 'bad' as unknown as never,
151
+ outputs: { ok: 'bad' as unknown as never },
152
+ }),
153
+ );
154
+ const msgs = bindingErrors(errors).map((e) => e.message);
155
+ expect(msgs.some((m) => /task\.inputs must be an object/.test(m))).toBe(true);
156
+ expect(msgs.some((m) => /task\.outputs\.ok must be an object/.test(m))).toBe(true);
157
+ });
158
+
159
+ test('rejects invalid binding names and duplicate loose/strict names', () => {
160
+ const errors = errorsFor(
161
+ commandTask({
162
+ id: 'a',
163
+ inputs: { 'bad-name': { value: 'x' }, city: { value: 'Shanghai' } },
164
+ outputs: { report: { from: 'stdout' } },
165
+ ports: {
166
+ inputs: [{ name: 'city', type: 'string' }],
167
+ outputs: [{ name: 'report', type: 'string' }],
168
+ },
169
+ }),
170
+ );
171
+ const msgs = errors.map((e) => e.message);
172
+ expect(msgs.some((m) => /binding name "bad-name" is invalid/.test(m))).toBe(true);
173
+ expect(msgs.some((m) => /duplicates strict ports\.inputs/.test(m))).toBe(true);
174
+ expect(msgs.some((m) => /duplicates strict ports\.outputs/.test(m))).toBe(true);
175
+ });
176
+
177
+ test('fully-qualified binding sources must reference direct dependencies', () => {
178
+ const errors = validateRaw(
179
+ pipeline([
180
+ commandTask({ id: 'up', outputs: { city: {} } }),
181
+ commandTask({
182
+ id: 'down',
183
+ depends_on: [],
184
+ inputs: { city: { from: 't.up.outputs.city', required: true } },
185
+ }),
186
+ ]),
187
+ );
188
+ expect(errors.some((e) => /not a direct dependency/.test(e.message))).toBe(true);
189
+ });
190
+ });
191
+
126
192
  // ─── Input/output separation (Command Tasks) ─────────────────────────
127
193
 
128
194
  describe('validateRaw — input vs output constraints (command tasks)', () => {
@@ -538,6 +538,136 @@ function validatePortList(
538
538
  }
539
539
  }
540
540
 
541
+ function validateBindingMap(
542
+ value: unknown,
543
+ basePath: string,
544
+ kind: 'inputs' | 'outputs',
545
+ errors: ValidationError[],
546
+ ): void {
547
+ if (value === undefined) return;
548
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
549
+ errors.push({ path: basePath, message: `task.${kind} must be an object map` });
550
+ return;
551
+ }
552
+
553
+ const map = value as Record<string, unknown>;
554
+ for (const [name, rawBinding] of Object.entries(map)) {
555
+ const path = `${basePath}.${name}`;
556
+ if (!PORT_NAME_RE.test(name)) {
557
+ errors.push({
558
+ path,
559
+ message: `binding name "${name}" is invalid. Must match /^[A-Za-z_][A-Za-z0-9_]*$/.`,
560
+ });
561
+ }
562
+ if (!rawBinding || typeof rawBinding !== 'object' || Array.isArray(rawBinding)) {
563
+ errors.push({ path, message: `task.${kind}.${name} must be an object` });
564
+ continue;
565
+ }
566
+ const binding = rawBinding as Record<string, unknown>;
567
+ if ('from' in binding && typeof binding.from !== 'string') {
568
+ errors.push({ path: `${path}.from`, message: `task.${kind}.${name}.from must be a string` });
569
+ }
570
+ if (kind === 'inputs' && 'required' in binding && typeof binding.required !== 'boolean') {
571
+ errors.push({
572
+ path: `${path}.required`,
573
+ message: `task.inputs.${name}.required must be a boolean`,
574
+ });
575
+ }
576
+ if (kind === 'outputs' && typeof binding.from === 'string') {
577
+ const source = binding.from;
578
+ const ok =
579
+ source === 'stdout' ||
580
+ source === 'stderr' ||
581
+ source === 'normalizedOutput' ||
582
+ /^json\.[A-Za-z_][A-Za-z0-9_]*$/.test(source);
583
+ if (!ok) {
584
+ errors.push({
585
+ path: `${path}.from`,
586
+ message: `task.outputs.${name}.from must be stdout, stderr, normalizedOutput, or json.<key>`,
587
+ });
588
+ }
589
+ }
590
+ }
591
+ }
592
+
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
+ function objectKeys(value: unknown): string[] {
626
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
627
+ return Object.keys(value as Record<string, unknown>);
628
+ }
629
+
630
+ function validateInputBindingSources(
631
+ task: RawTaskConfig,
632
+ trackId: string,
633
+ taskPath: string,
634
+ index: TaskIndex,
635
+ errors: ValidationError[],
636
+ ): void {
637
+ if (!task.inputs || typeof task.inputs !== 'object' || Array.isArray(task.inputs)) return;
638
+ for (const [name, rawBinding] of Object.entries(task.inputs)) {
639
+ if (!rawBinding || typeof rawBinding !== 'object' || Array.isArray(rawBinding)) continue;
640
+ const source = (rawBinding as Record<string, unknown>).from;
641
+ if (typeof source !== 'string') continue;
642
+ const upstreamId = bindingSourceTaskId(source);
643
+ if (!upstreamId) continue;
644
+ const deps = task.depends_on ?? [];
645
+ const isDirectDep = deps.some((dep) => {
646
+ const resolved = resolveTaskRef(dep, trackId, index);
647
+ return resolved.kind === 'resolved' && resolved.qid === upstreamId;
648
+ });
649
+ if (!isDirectDep) {
650
+ errors.push({
651
+ path: `${taskPath}.inputs.${name}.from`,
652
+ message: `Task "${task.id}": input binding "${name}" from "${source}" references task "${upstreamId}" which is not a direct dependency (must be listed in depends_on)`,
653
+ });
654
+ }
655
+ }
656
+ }
657
+
658
+ function bindingSourceTaskId(source: string): string | null {
659
+ const outputMarker = '.outputs.';
660
+ const outputIdx = source.lastIndexOf(outputMarker);
661
+ if (outputIdx > 0) return source.slice(0, outputIdx);
662
+ for (const field of ['stdout', 'stderr', 'normalizedOutput', 'exitCode']) {
663
+ const suffix = `.${field}`;
664
+ if (source.endsWith(suffix) && source.length > suffix.length) {
665
+ return source.slice(0, -suffix.length);
666
+ }
667
+ }
668
+ return null;
669
+ }
670
+
541
671
  function validateTaskPorts(
542
672
  task: RawTaskConfig,
543
673
  trackId: string,
@@ -550,6 +680,11 @@ function validateTaskPorts(
550
680
  const isPromptTask = typeof task.prompt === 'string' && typeof task.command !== 'string';
551
681
  const isCommandTask = typeof task.command === 'string' && typeof task.prompt !== 'string';
552
682
 
683
+ validateBindingMap(task.inputs, `${taskPath}.inputs`, 'inputs', errors);
684
+ validateBindingMap(task.outputs, `${taskPath}.outputs`, 'outputs', errors);
685
+ validateBindingPortNameOverlap(task, taskPath, errors);
686
+ validateInputBindingSources(task, trackId, taskPath, index, errors);
687
+
553
688
  // ─── Prompt tasks do not declare ports ──
554
689
  //
555
690
  // A Prompt Task's I/O contract is inferred from direct-neighbor
@@ -585,6 +720,7 @@ function validateTaskPorts(
585
720
  let availableInputs: Set<string>;
586
721
  if (isPromptTask) {
587
722
  availableInputs = collectUpstreamCommandOutputNames(task, trackId, qidIndex, index);
723
+ for (const name of objectKeys(task.inputs)) availableInputs.add(name);
588
724
  } else {
589
725
  // Command Task (or the pathological both-keys case, which is caught
590
726
  // earlier as a separate error — tolerate it here).
@@ -593,6 +729,7 @@ function validateTaskPorts(
593
729
  ? ports.inputs.filter((p): p is PortDef => !!p && typeof p === 'object').map((p) => p.name)
594
730
  : [],
595
731
  );
732
+ for (const name of objectKeys(task.inputs)) availableInputs.add(name);
596
733
  }
597
734
 
598
735
  for (const name of referenced) {