@tagma/sdk 0.6.10 → 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.
Files changed (56) hide show
  1. package/README.md +93 -13
  2. package/dist/config-ops.d.ts +4 -2
  3. package/dist/config-ops.d.ts.map +1 -1
  4. package/dist/config-ops.js +30 -2
  5. package/dist/config-ops.js.map +1 -1
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +75 -27
  8. package/dist/engine.js.map +1 -1
  9. package/dist/pipeline-runner.d.ts +1 -1
  10. package/dist/pipeline-runner.d.ts.map +1 -1
  11. package/dist/pipeline-runner.js +20 -0
  12. package/dist/pipeline-runner.js.map +1 -1
  13. package/dist/ports.d.ts +23 -1
  14. package/dist/ports.d.ts.map +1 -1
  15. package/dist/ports.js +160 -0
  16. package/dist/ports.js.map +1 -1
  17. package/dist/schema.d.ts.map +1 -1
  18. package/dist/schema.js +47 -11
  19. package/dist/schema.js.map +1 -1
  20. package/dist/sdk.d.ts +2 -2
  21. package/dist/sdk.d.ts.map +1 -1
  22. package/dist/sdk.js +1 -1
  23. package/dist/sdk.js.map +1 -1
  24. package/dist/task-ref.d.ts.map +1 -1
  25. package/dist/task-ref.js +2 -0
  26. package/dist/task-ref.js.map +1 -1
  27. package/dist/utils.js +3 -3
  28. package/dist/utils.js.map +1 -1
  29. package/dist/validate-raw.d.ts.map +1 -1
  30. package/dist/validate-raw.js +167 -3
  31. package/dist/validate-raw.js.map +1 -1
  32. package/dist/yaml-compiler.d.ts.map +1 -1
  33. package/dist/yaml-compiler.js +23 -5
  34. package/dist/yaml-compiler.js.map +1 -1
  35. package/package.json +2 -2
  36. package/src/completions/output-check.test.ts +50 -0
  37. package/src/config-ops.test.ts +70 -0
  38. package/src/config-ops.ts +23 -2
  39. package/src/engine-ports.test.ts +66 -0
  40. package/src/engine-task-type.test.ts +56 -0
  41. package/src/engine.ts +100 -26
  42. package/src/pipeline-runner.test.ts +95 -0
  43. package/src/pipeline-runner.ts +18 -1
  44. package/src/ports.test.ts +127 -0
  45. package/src/ports.ts +224 -1
  46. package/src/schema-ports.test.ts +86 -0
  47. package/src/schema.test.ts +113 -1
  48. package/src/schema.ts +52 -13
  49. package/src/sdk.ts +4 -0
  50. package/src/task-ref.ts +1 -0
  51. package/src/utils.test.ts +28 -0
  52. package/src/utils.ts +3 -3
  53. package/src/validate-raw-ports.test.ts +66 -0
  54. package/src/validate-raw.ts +189 -4
  55. package/src/yaml-compiler.test.ts +108 -0
  56. package/src/yaml-compiler.ts +32 -5
package/src/engine.ts CHANGED
@@ -31,11 +31,14 @@ import {
31
31
  renderOutputSchemaBlock,
32
32
  } from './prompt-doc';
33
33
  import {
34
+ extractTaskBindingOutputs,
34
35
  extractTaskOutputs,
35
36
  inferPromptPorts,
37
+ resolveTaskBindingInputs,
36
38
  resolveTaskInputs,
37
39
  substituteInputs,
38
40
  } from './ports';
41
+ import type { UpstreamBindingData } from './ports';
39
42
  import type { TaskPorts } from './types';
40
43
  import {
41
44
  executeHook,
@@ -70,6 +73,18 @@ export class TriggerTimeoutError extends Error {
70
73
  }
71
74
  }
72
75
 
76
+ function isPromptTaskConfig(
77
+ task: TaskConfig,
78
+ ): task is TaskConfig & { readonly prompt: string; readonly command?: undefined } {
79
+ return task.prompt !== undefined && task.command === undefined;
80
+ }
81
+
82
+ function isCommandTaskConfig(
83
+ task: TaskConfig,
84
+ ): task is TaskConfig & { readonly command: string; readonly prompt?: undefined } {
85
+ return task.command !== undefined && task.prompt === undefined;
86
+ }
87
+
73
88
  // ═══ Preflight Validation ═══
74
89
 
75
90
  function preflight(config: PipelineConfig, dag: Dag, registry: PluginRegistry): void {
@@ -81,7 +96,7 @@ function preflight(config: PipelineConfig, dag: Dag, registry: PluginRegistry):
81
96
  const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
82
97
 
83
98
  // Pure command tasks don't use a driver — skip driver registration check.
84
- const isCommandOnly = task.command && !task.prompt;
99
+ const isCommandOnly = isCommandTaskConfig(task);
85
100
 
86
101
  if (!isCommandOnly && !registry.hasHandler('drivers', driverName)) {
87
102
  errors.push(`Task "${node.taskId}": driver "${driverName}" not registered`);
@@ -319,7 +334,7 @@ export async function runPipeline(
319
334
  log.section('DAG topology');
320
335
  for (const [id, node] of dag.nodes) {
321
336
  const deps = node.dependsOn.length ? node.dependsOn.join(', ') : '(root)';
322
- const kind = node.task.prompt ? 'ai' : 'cmd';
337
+ const kind = isPromptTaskConfig(node.task) ? 'ai' : 'cmd';
323
338
  log.quiet(` • ${id} [${kind}] track=${node.track.id} deps=[${deps}]`);
324
339
  }
325
340
  log.quiet('');
@@ -388,13 +403,12 @@ export async function runPipeline(
388
403
 
389
404
  const sessionMap = new Map<string, string>();
390
405
  const normalizedMap = new Map<string, string>();
391
- // Extracted port outputs keyed by fully-qualified task id. Populated
392
- // after a task succeeds when its `ports.outputs` is declared; read by
393
- // downstream tasks via `resolveTaskInputs` to assemble their inputs.
394
- // Kept separate from normalizedMap so the continue_from text handoff
395
- // and the typed-port data handoff don't pollute each other — they
396
- // solve different problems and have different lifetimes.
406
+ // Published structured outputs keyed by fully-qualified task id.
407
+ // Includes lightweight task.outputs and strict ports.outputs.
397
408
  const outputValuesMap = new Map<string, Readonly<Record<string, unknown>>>();
409
+ // Full upstream result data for lightweight input bindings such as
410
+ // `taskId.stdout` and `taskId.outputs.name`.
411
+ const bindingDataMap = new Map<string, UpstreamBindingData>();
398
412
  // Resolved port inputs keyed by fully-qualified task id. Written once,
399
413
  // just before a task runs, so every subsequent task_update event can
400
414
  // echo them to the UI without re-resolving.
@@ -577,7 +591,7 @@ export async function runPipeline(
577
591
  return {
578
592
  id: taskId,
579
593
  name: state.config.name,
580
- type: state.config.prompt ? 'ai' : 'command',
594
+ type: isPromptTaskConfig(state.config) ? 'ai' : 'command',
581
595
  status: state.status,
582
596
  exit_code: state.result?.exitCode ?? null,
583
597
  duration_ms: state.result?.durationMs ?? null,
@@ -614,7 +628,7 @@ export async function runPipeline(
614
628
  log.section(`Task ${taskId}`, taskId);
615
629
  log.debug(
616
630
  `[task:${taskId}]`,
617
- `type=${task.prompt ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`,
631
+ `type=${isPromptTaskConfig(task) ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`,
618
632
  );
619
633
 
620
634
  // 1. Check dependencies
@@ -781,7 +795,7 @@ export async function runPipeline(
781
795
  // pipeline the Command path uses. Collisions that a Prompt can't
782
796
  // disambiguate (same input name on two upstreams, incompatible
783
797
  // downstream output types) block the task with a clear message.
784
- const isPromptTask = task.prompt !== undefined && task.command === undefined;
798
+ const isPromptTask = isPromptTaskConfig(task);
785
799
  let effectivePorts: TaskPorts | undefined = task.ports;
786
800
  let promptInferenceBlockReason: string | null = null;
787
801
 
@@ -789,7 +803,7 @@ export async function runPipeline(
789
803
  const inference = inferPromptPorts({
790
804
  upstreams: node.dependsOn.map((upstreamId) => {
791
805
  const upstream = dag.nodes.get(upstreamId);
792
- const isUpstreamCommand = !!upstream?.task.command;
806
+ const isUpstreamCommand = upstream ? isCommandTaskConfig(upstream.task) : false;
793
807
  return {
794
808
  taskId: upstreamId,
795
809
  outputs: isUpstreamCommand ? upstream?.task.ports?.outputs : undefined,
@@ -797,7 +811,7 @@ export async function runPipeline(
797
811
  }),
798
812
  downstreams: (directDownstreams.get(taskId) ?? []).map((downstreamId) => {
799
813
  const downstream = dag.nodes.get(downstreamId);
800
- const isDownstreamCommand = !!downstream?.task.command;
814
+ const isDownstreamCommand = downstream ? isCommandTaskConfig(downstream.task) : false;
801
815
  return {
802
816
  taskId: downstreamId,
803
817
  inputs: isDownstreamCommand ? downstream?.task.ports?.inputs : undefined,
@@ -844,6 +858,44 @@ export async function runPipeline(
844
858
  return;
845
859
  }
846
860
 
861
+ const bindingResolution = resolveTaskBindingInputs(task, bindingDataMap, node.dependsOn);
862
+ if (bindingResolution.kind === 'blocked') {
863
+ log.error(
864
+ `[task:${taskId}]`,
865
+ `blocked — cannot resolve task input bindings:\n${bindingResolution.reason}`,
866
+ );
867
+ state.result = {
868
+ exitCode: -1,
869
+ stdout: '',
870
+ stderr: `[engine] task input binding resolution failed:\n${bindingResolution.reason}`,
871
+ stdoutPath: null,
872
+ stderrPath: null,
873
+ durationMs: 0,
874
+ sessionId: null,
875
+ normalizedOutput: null,
876
+ failureKind: 'spawn_error',
877
+ outputs: null,
878
+ };
879
+ state.finishedAt = nowISO();
880
+ setTaskStatus(taskId, 'blocked');
881
+ try {
882
+ await fireHook(taskId, 'task_failure');
883
+ } catch (hookErr) {
884
+ log.error(
885
+ `[task:${taskId}]`,
886
+ `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
887
+ );
888
+ }
889
+ if (getOnFailure(taskId) === 'stop_all') applyStopAll(node.track.id);
890
+ return;
891
+ }
892
+ if (bindingResolution.missingOptional.length > 0) {
893
+ log.debug(
894
+ `[task:${taskId}]`,
895
+ `optional input bindings unresolved (empty in placeholders): ${bindingResolution.missingOptional.join(', ')}`,
896
+ );
897
+ }
898
+
847
899
  // Feed effective ports into `resolveTaskInputs` by shallow-cloning
848
900
  // the task. Prompt tasks get the inferred ports; Command tasks are
849
901
  // unchanged (effectivePorts === task.ports).
@@ -880,7 +932,7 @@ export async function runPipeline(
880
932
  if (getOnFailure(taskId) === 'stop_all') applyStopAll(node.track.id);
881
933
  return;
882
934
  }
883
- const resolvedInputs = inputResolution.inputs;
935
+ const resolvedInputs = { ...bindingResolution.inputs, ...inputResolution.inputs };
884
936
  resolvedInputsMap.set(taskId, resolvedInputs);
885
937
  if (inputResolution.missingOptional.length > 0) {
886
938
  log.debug(
@@ -902,7 +954,7 @@ export async function runPipeline(
902
954
  setTaskStatus(taskId, 'running');
903
955
  log.info(
904
956
  `[task:${taskId}]`,
905
- task.command ? `running: ${task.command}` : `running (driver task)`,
957
+ isCommandTaskConfig(task) ? `running: ${task.command}` : `running (driver task)`,
906
958
  );
907
959
 
908
960
  // File-only: resolved config for this task
@@ -940,7 +992,7 @@ export async function runPipeline(
940
992
  stderrPath,
941
993
  };
942
994
 
943
- if (task.command) {
995
+ if (isCommandTaskConfig(task)) {
944
996
  // Substitute `{{inputs.X}}` placeholders into the command
945
997
  // string. Tasks with no declared inputs always produce the same
946
998
  // string back (no placeholders to match). Unresolved references
@@ -1168,16 +1220,29 @@ export async function runPipeline(
1168
1220
  terminalStatus = 'success';
1169
1221
  }
1170
1222
 
1171
- // Extract declared port outputs from the task's output stream.
1172
- // Only meaningful on success — a failed task's output is whatever
1173
- // the child happened to emit before exiting, and downstream tasks
1174
- // shouldn't receive partial data. `extractTaskOutputs` is a no-op
1175
- // when the task has no declared outputs, so this is free for
1176
- // pre-ports tasks. Diagnostics are appended to stderr so users
1177
- // see *why* a downstream input is missing without having to dig
1178
- // through driver-specific logs.
1223
+ // Extract declared outputs from the task's output stream. Only
1224
+ // meaningful on success — a failed task's output is whatever the
1225
+ // child happened to emit before exiting, and downstream tasks
1226
+ // shouldn't receive partial data.
1179
1227
  let extractedOutputs: Readonly<Record<string, unknown>> | null = null;
1180
1228
  if (terminalStatus === 'success') {
1229
+ const looseExtraction = extractTaskBindingOutputs(
1230
+ task.outputs,
1231
+ result.stdout,
1232
+ result.stderr,
1233
+ result.normalizedOutput,
1234
+ );
1235
+ if (task.outputs && Object.keys(task.outputs).length > 0) {
1236
+ extractedOutputs = looseExtraction.outputs;
1237
+ log.debug(
1238
+ `[task:${taskId}]`,
1239
+ `extracted binding outputs: ${JSON.stringify(looseExtraction.outputs)}`,
1240
+ );
1241
+ if (looseExtraction.diagnostic) {
1242
+ log.debug(`[task:${taskId}]`, looseExtraction.diagnostic);
1243
+ }
1244
+ }
1245
+
1181
1246
  // Prompt tasks use inferred ports (from direct-downstream Command
1182
1247
  // inputs); Command tasks use their declared ports. Either way,
1183
1248
  // `extractTaskOutputs` is a no-op when there are no declared
@@ -1188,8 +1253,7 @@ export async function runPipeline(
1188
1253
  result.normalizedOutput,
1189
1254
  );
1190
1255
  if (effectivePorts?.outputs && effectivePorts.outputs.length > 0) {
1191
- extractedOutputs = extraction.outputs;
1192
- outputValuesMap.set(taskId, extraction.outputs);
1256
+ extractedOutputs = { ...(extractedOutputs ?? {}), ...extraction.outputs };
1193
1257
  log.debug(
1194
1258
  `[task:${taskId}]`,
1195
1259
  `extracted outputs: ${JSON.stringify(extraction.outputs)}` +
@@ -1207,6 +1271,16 @@ export async function runPipeline(
1207
1271
  // — hooks, wire events, test assertions — all go through this
1208
1272
  // one field rather than re-running extraction.
1209
1273
  result = { ...result, outputs: extractedOutputs };
1274
+ if (extractedOutputs !== null) {
1275
+ outputValuesMap.set(taskId, extractedOutputs);
1276
+ }
1277
+ bindingDataMap.set(taskId, {
1278
+ outputs: extractedOutputs,
1279
+ stdout: result.stdout,
1280
+ stderr: result.stderr,
1281
+ normalizedOutput: result.normalizedOutput,
1282
+ exitCode: result.exitCode,
1283
+ });
1210
1284
 
1211
1285
  // Store normalized text separately (in-memory) for continue_from handoff.
1212
1286
  // R15: clip oversized values so a runaway parseResult can't accumulate
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { bootstrapBuiltins } from './bootstrap';
6
+ import { PipelineRunner } from './pipeline-runner';
7
+ import { PluginRegistry } from './registry';
8
+ import type { PipelineConfig } from './types';
9
+
10
+ function makeDir(): string {
11
+ return mkdtempSync(join(tmpdir(), 'tagma-pipeline-runner-'));
12
+ }
13
+
14
+ function portsPipeline(dir: string): PipelineConfig {
15
+ const emit = join(dir, 'emit.js');
16
+ writeFileSync(
17
+ emit,
18
+ 'process.stdout.write(JSON.stringify({ city: "Shanghai" }) + "\\n");\n',
19
+ );
20
+ const echo = join(dir, 'echo.js');
21
+ writeFileSync(echo, 'process.stdout.write(process.argv[2] + "\\n");\n');
22
+
23
+ return {
24
+ name: 'runner-snapshot',
25
+ tracks: [
26
+ {
27
+ id: 't',
28
+ name: 'T',
29
+ tasks: [
30
+ {
31
+ id: 'up',
32
+ name: 'up',
33
+ command: `node "${emit}"`,
34
+ ports: {
35
+ outputs: [{ name: 'city', type: 'string' }],
36
+ },
37
+ },
38
+ {
39
+ id: 'down',
40
+ name: 'down',
41
+ depends_on: ['up'],
42
+ command: `node "${echo}" "{{inputs.city}}"`,
43
+ ports: {
44
+ inputs: [{ name: 'city', type: 'string', required: true }],
45
+ },
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ };
51
+ }
52
+
53
+ async function run(config: PipelineConfig, dir: string): Promise<PipelineRunner> {
54
+ const registry = new PluginRegistry();
55
+ bootstrapBuiltins(registry);
56
+ const runner = new PipelineRunner(config, dir, {
57
+ registry,
58
+ skipPluginLoading: true,
59
+ });
60
+
61
+ const result = await runner.start();
62
+ expect(result.success).toBe(true);
63
+ return runner;
64
+ }
65
+
66
+ describe('PipelineRunner task snapshot', () => {
67
+ test('getTasks reflects task_update inputs and outputs', async () => {
68
+ const dir = makeDir();
69
+ try {
70
+ const runner = await run(portsPipeline(dir), dir);
71
+
72
+ const tasks = runner.getTasks();
73
+ const up = tasks.get('t.up');
74
+ const down = tasks.get('t.down');
75
+ expect(up?.outputs).toEqual({ city: 'Shanghai' });
76
+ expect(down?.inputs).toEqual({ city: 'Shanghai' });
77
+ } finally {
78
+ rmSync(dir, { recursive: true, force: true });
79
+ }
80
+ });
81
+
82
+ test('getTasks folds streamed task logs into the task snapshot', async () => {
83
+ const dir = makeDir();
84
+ try {
85
+ const runner = await run(portsPipeline(dir), dir);
86
+
87
+ const tasks = runner.getTasks();
88
+ const up = tasks.get('t.up');
89
+ expect(up?.logs.length).toBeGreaterThan(0);
90
+ expect(up?.totalLogCount).toBeGreaterThan(0);
91
+ } finally {
92
+ rmSync(dir, { recursive: true, force: true });
93
+ }
94
+ });
95
+ });
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { runPipeline } from './engine';
25
25
  import type { EngineResult, RunPipelineOptions } from './engine';
26
- import type { PipelineConfig, RunEventPayload, RunTaskState } from './types';
26
+ import { TASK_LOG_CAP, type PipelineConfig, type RunEventPayload, type RunTaskState } from './types';
27
27
  import { generateRunId } from './utils';
28
28
 
29
29
  export type { EngineResult };
@@ -132,12 +132,29 @@ export class PipelineRunner {
132
132
  stderrBytes: pick(event.stderrBytes, prev.stderrBytes),
133
133
  sessionId: pick(event.sessionId, prev.sessionId),
134
134
  normalizedOutput: pick(event.normalizedOutput, prev.normalizedOutput),
135
+ outputs: pick(event.outputs, prev.outputs),
136
+ inputs: pick(event.inputs, prev.inputs),
135
137
  resolvedDriver: pick(event.resolvedDriver, prev.resolvedDriver),
136
138
  resolvedModel: pick(event.resolvedModel, prev.resolvedModel),
137
139
  resolvedPermissions: pick(event.resolvedPermissions, prev.resolvedPermissions),
138
140
  });
139
141
  return;
140
142
  }
143
+ case 'task_log': {
144
+ if (event.taskId === null) return;
145
+ const prev = this._tasks.get(event.taskId);
146
+ if (!prev) return;
147
+ const logs = [
148
+ ...prev.logs,
149
+ { level: event.level, timestamp: event.timestamp, text: event.text },
150
+ ];
151
+ this._tasks.set(event.taskId, {
152
+ ...prev,
153
+ logs: logs.slice(-TASK_LOG_CAP),
154
+ totalLogCount: prev.totalLogCount + 1,
155
+ });
156
+ return;
157
+ }
141
158
  case 'run_end':
142
159
  this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
143
160
  return;
package/src/ports.test.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import {
3
3
  extractInputReferences,
4
+ extractTaskBindingOutputs,
4
5
  extractTaskOutputs,
5
6
  inferPromptPorts,
7
+ resolveTaskBindingInputs,
6
8
  resolveTaskInputs,
7
9
  substituteInputs,
8
10
  } from './ports';
@@ -244,6 +246,87 @@ describe('resolveTaskInputs', () => {
244
246
  });
245
247
  });
246
248
 
249
+ // ─── resolveTaskBindingInputs ────────────────────────────────────────
250
+
251
+ describe('resolveTaskBindingInputs', () => {
252
+ test('resolves literal values and defaults without requiring ports', () => {
253
+ const t = task({
254
+ id: 'downstream',
255
+ command: 'echo',
256
+ inputs: {
257
+ city: { value: 'Shanghai' },
258
+ mode: { from: 't.up.outputs.missing', default: 'quick' },
259
+ },
260
+ });
261
+ const res = resolveTaskBindingInputs(t, new Map(), ['t.up']);
262
+ expect(res).toEqual({
263
+ kind: 'ready',
264
+ inputs: { city: 'Shanghai', mode: 'quick' },
265
+ missingOptional: [],
266
+ });
267
+ });
268
+
269
+ test('resolves values from a direct upstream output and stdout', () => {
270
+ const t = task({
271
+ id: 'downstream',
272
+ command: 'echo',
273
+ inputs: {
274
+ city: { from: 't.up.outputs.city' },
275
+ raw: { from: 't.up.stdout' },
276
+ },
277
+ });
278
+ const upstream = new Map([
279
+ [
280
+ 't.up',
281
+ {
282
+ outputs: { city: 'Shanghai' },
283
+ stdout: 'raw text\n',
284
+ stderr: '',
285
+ normalizedOutput: null,
286
+ exitCode: 0,
287
+ },
288
+ ],
289
+ ]);
290
+ const res = resolveTaskBindingInputs(t, upstream, ['t.up']);
291
+ expect(res.kind).toBe('ready');
292
+ if (res.kind !== 'ready') return;
293
+ expect(res.inputs).toEqual({ city: 'Shanghai', raw: 'raw text\n' });
294
+ });
295
+
296
+ test('blocks required missing bindings with a readable reason', () => {
297
+ const t = task({
298
+ id: 'downstream',
299
+ command: 'echo',
300
+ inputs: {
301
+ city: { from: 't.up.outputs.city', required: true },
302
+ },
303
+ });
304
+ const res = resolveTaskBindingInputs(t, new Map(), ['t.up']);
305
+ expect(res.kind).toBe('blocked');
306
+ if (res.kind !== 'blocked') return;
307
+ expect(res.missingRequired).toEqual(['city']);
308
+ expect(res.reason).toContain('missing required binding input(s): city');
309
+ });
310
+
311
+ test('detects ambiguous loose output name matches', () => {
312
+ const t = task({
313
+ id: 'downstream',
314
+ command: 'echo',
315
+ inputs: {
316
+ val: { from: 'outputs.val', required: true },
317
+ },
318
+ });
319
+ const upstream = new Map([
320
+ ['t.a', { outputs: { val: 'a' }, stdout: '', stderr: '', normalizedOutput: null, exitCode: 0 }],
321
+ ['t.b', { outputs: { val: 'b' }, stdout: '', stderr: '', normalizedOutput: null, exitCode: 0 }],
322
+ ]);
323
+ const res = resolveTaskBindingInputs(t, upstream, ['t.a', 't.b']);
324
+ expect(res.kind).toBe('blocked');
325
+ if (res.kind !== 'blocked') return;
326
+ expect(res.ambiguous[0]).toEqual({ input: 'val', producers: ['t.a', 't.b'] });
327
+ });
328
+ });
329
+
247
330
  // ─── extractTaskOutputs ──────────────────────────────────────────────
248
331
 
249
332
  describe('extractTaskOutputs', () => {
@@ -301,6 +384,50 @@ describe('extractTaskOutputs', () => {
301
384
  });
302
385
  });
303
386
 
387
+ // ─── extractTaskBindingOutputs ───────────────────────────────────────
388
+
389
+ describe('extractTaskBindingOutputs', () => {
390
+ test('extracts loose outputs from final-line JSON by default', () => {
391
+ const r = extractTaskBindingOutputs(
392
+ {
393
+ city: {},
394
+ temp: { from: 'json.temperature' },
395
+ },
396
+ 'log\n{"city":"Shanghai","temperature":23}\n',
397
+ '',
398
+ null,
399
+ );
400
+ expect(r.outputs).toEqual({ city: 'Shanghai', temp: 23 });
401
+ expect(r.diagnostic).toBeNull();
402
+ });
403
+
404
+ test('can publish whole stdout and normalizedOutput as named outputs', () => {
405
+ const r = extractTaskBindingOutputs(
406
+ {
407
+ raw: { from: 'stdout' },
408
+ normalized: { from: 'normalizedOutput' },
409
+ },
410
+ 'raw text\n',
411
+ '',
412
+ 'normalized text',
413
+ );
414
+ expect(r.outputs).toEqual({ raw: 'raw text\n', normalized: 'normalized text' });
415
+ });
416
+
417
+ test('uses defaults for missing loose outputs without failing extraction', () => {
418
+ const r = extractTaskBindingOutputs(
419
+ {
420
+ city: { default: 'Unknown' },
421
+ },
422
+ 'not json\n',
423
+ '',
424
+ null,
425
+ );
426
+ expect(r.outputs).toEqual({ city: 'Unknown' });
427
+ expect(r.diagnostic).toBeNull();
428
+ });
429
+ });
430
+
304
431
  // ─── inferPromptPorts ───────────────────────────────────────────────
305
432
 
306
433
  describe('inferPromptPorts', () => {