@tagma/sdk 0.7.4 → 0.7.6

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 (191) hide show
  1. package/README.md +60 -53
  2. package/dist/completions/file-exists.js +1 -1
  3. package/dist/completions/file-exists.js.map +1 -1
  4. package/dist/completions/output-check.d.ts.map +1 -1
  5. package/dist/completions/output-check.js +17 -4
  6. package/dist/completions/output-check.js.map +1 -1
  7. package/dist/config.d.ts +4 -4
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +2 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/dataflow.d.ts +3 -0
  12. package/dist/dataflow.d.ts.map +1 -0
  13. package/dist/dataflow.js +2 -0
  14. package/dist/dataflow.js.map +1 -0
  15. package/dist/drivers/opencode.d.ts.map +1 -1
  16. package/dist/drivers/opencode.js +23 -71
  17. package/dist/drivers/opencode.js.map +1 -1
  18. package/dist/middlewares/static-context.d.ts.map +1 -1
  19. package/dist/middlewares/static-context.js +1 -2
  20. package/dist/middlewares/static-context.js.map +1 -1
  21. package/dist/pipeline-runner.d.ts.map +1 -1
  22. package/dist/pipeline-runner.js +2 -2
  23. package/dist/pipeline-runner.js.map +1 -1
  24. package/dist/schema.d.ts.map +1 -1
  25. package/dist/schema.js +3 -4
  26. package/dist/schema.js.map +1 -1
  27. package/dist/triggers/file.d.ts.map +1 -1
  28. package/dist/triggers/file.js +1 -2
  29. package/dist/triggers/file.js.map +1 -1
  30. package/dist/triggers/manual.d.ts.map +1 -1
  31. package/dist/triggers/manual.js +1 -2
  32. package/dist/triggers/manual.js.map +1 -1
  33. package/dist/types.d.ts +1 -2
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js +1 -12
  36. package/dist/types.js.map +1 -1
  37. package/dist/utils-api.d.ts +1 -1
  38. package/dist/utils-api.d.ts.map +1 -1
  39. package/dist/utils-api.js +1 -1
  40. package/dist/utils-api.js.map +1 -1
  41. package/dist/validate-raw.d.ts +4 -4
  42. package/dist/validate-raw.d.ts.map +1 -1
  43. package/dist/validate-raw.js +45 -52
  44. package/dist/validate-raw.js.map +1 -1
  45. package/package.json +11 -24
  46. package/dist/adapters/stdin-approval.d.ts +0 -2
  47. package/dist/adapters/stdin-approval.d.ts.map +0 -1
  48. package/dist/adapters/stdin-approval.js +0 -2
  49. package/dist/adapters/stdin-approval.js.map +0 -1
  50. package/dist/adapters/websocket-approval.d.ts +0 -2
  51. package/dist/adapters/websocket-approval.d.ts.map +0 -1
  52. package/dist/adapters/websocket-approval.js +0 -2
  53. package/dist/adapters/websocket-approval.js.map +0 -1
  54. package/dist/core/dataflow.d.ts +0 -23
  55. package/dist/core/dataflow.d.ts.map +0 -1
  56. package/dist/core/dataflow.js +0 -99
  57. package/dist/core/dataflow.js.map +0 -1
  58. package/dist/core/log-prune.d.ts +0 -16
  59. package/dist/core/log-prune.d.ts.map +0 -1
  60. package/dist/core/log-prune.js +0 -34
  61. package/dist/core/log-prune.js.map +0 -1
  62. package/dist/core/preflight.d.ts +0 -13
  63. package/dist/core/preflight.d.ts.map +0 -1
  64. package/dist/core/preflight.js +0 -61
  65. package/dist/core/preflight.js.map +0 -1
  66. package/dist/core/run-context.d.ts +0 -55
  67. package/dist/core/run-context.d.ts.map +0 -1
  68. package/dist/core/run-context.js +0 -158
  69. package/dist/core/run-context.js.map +0 -1
  70. package/dist/core/run-state.d.ts +0 -25
  71. package/dist/core/run-state.d.ts.map +0 -1
  72. package/dist/core/run-state.js +0 -93
  73. package/dist/core/run-state.js.map +0 -1
  74. package/dist/core/scheduler.d.ts +0 -13
  75. package/dist/core/scheduler.d.ts.map +0 -1
  76. package/dist/core/scheduler.js +0 -35
  77. package/dist/core/scheduler.js.map +0 -1
  78. package/dist/core/task-executor.d.ts +0 -13
  79. package/dist/core/task-executor.d.ts.map +0 -1
  80. package/dist/core/task-executor.js +0 -610
  81. package/dist/core/task-executor.js.map +0 -1
  82. package/dist/core/trigger-errors.d.ts +0 -9
  83. package/dist/core/trigger-errors.d.ts.map +0 -1
  84. package/dist/core/trigger-errors.js +0 -15
  85. package/dist/core/trigger-errors.js.map +0 -1
  86. package/dist/dag.d.ts +0 -45
  87. package/dist/dag.d.ts.map +0 -1
  88. package/dist/dag.js +0 -177
  89. package/dist/dag.js.map +0 -1
  90. package/dist/hooks.d.ts +0 -73
  91. package/dist/hooks.d.ts.map +0 -1
  92. package/dist/hooks.js +0 -106
  93. package/dist/hooks.js.map +0 -1
  94. package/dist/pipeline-definition.d.ts +0 -3
  95. package/dist/pipeline-definition.d.ts.map +0 -1
  96. package/dist/pipeline-definition.js +0 -4
  97. package/dist/pipeline-definition.js.map +0 -1
  98. package/dist/ports.d.ts +0 -196
  99. package/dist/ports.d.ts.map +0 -1
  100. package/dist/ports.js +0 -688
  101. package/dist/ports.js.map +0 -1
  102. package/dist/prompt-doc.d.ts +0 -70
  103. package/dist/prompt-doc.d.ts.map +0 -1
  104. package/dist/prompt-doc.js +0 -154
  105. package/dist/prompt-doc.js.map +0 -1
  106. package/dist/registry.d.ts +0 -3
  107. package/dist/registry.d.ts.map +0 -1
  108. package/dist/registry.js +0 -2
  109. package/dist/registry.js.map +0 -1
  110. package/dist/task-ref.d.ts +0 -55
  111. package/dist/task-ref.d.ts.map +0 -1
  112. package/dist/task-ref.js +0 -103
  113. package/dist/task-ref.js.map +0 -1
  114. package/dist/utils.d.ts +0 -13
  115. package/dist/utils.d.ts.map +0 -1
  116. package/dist/utils.js +0 -177
  117. package/dist/utils.js.map +0 -1
  118. package/src/adapters/stdin-approval.ts +0 -1
  119. package/src/adapters/websocket-approval.ts +0 -1
  120. package/src/approval.ts +0 -9
  121. package/src/bootstrap.ts +0 -55
  122. package/src/completions/exit-code.ts +0 -34
  123. package/src/completions/file-exists.ts +0 -66
  124. package/src/completions/output-check.test.ts +0 -50
  125. package/src/completions/output-check.ts +0 -92
  126. package/src/config-ops.test.ts +0 -70
  127. package/src/config-ops.ts +0 -328
  128. package/src/config.ts +0 -26
  129. package/src/core/dataflow.test.ts +0 -166
  130. package/src/core/dataflow.ts +0 -161
  131. package/src/core/log-prune.test.ts +0 -58
  132. package/src/core/log-prune.ts +0 -43
  133. package/src/core/preflight.test.ts +0 -49
  134. package/src/core/preflight.ts +0 -89
  135. package/src/core/run-context.test.ts +0 -291
  136. package/src/core/run-context.ts +0 -211
  137. package/src/core/run-state.test.ts +0 -98
  138. package/src/core/run-state.ts +0 -122
  139. package/src/core/scheduler.test.ts +0 -83
  140. package/src/core/scheduler.ts +0 -42
  141. package/src/core/task-executor.ts +0 -752
  142. package/src/core/trigger-errors.ts +0 -15
  143. package/src/dag.test.ts +0 -56
  144. package/src/dag.ts +0 -245
  145. package/src/drivers/opencode.ts +0 -410
  146. package/src/engine-ports-mixed.test.ts +0 -182
  147. package/src/engine-ports.test.ts +0 -210
  148. package/src/engine-task-type.test.ts +0 -56
  149. package/src/engine.ts +0 -32
  150. package/src/hooks.ts +0 -193
  151. package/src/index.ts +0 -31
  152. package/src/logger.ts +0 -2
  153. package/src/middlewares/static-context.ts +0 -49
  154. package/src/package-split.test.ts +0 -15
  155. package/src/pipeline-definition.ts +0 -5
  156. package/src/pipeline-runner.test.ts +0 -144
  157. package/src/pipeline-runner.ts +0 -194
  158. package/src/plugin-registry.test.ts +0 -448
  159. package/src/plugins.ts +0 -21
  160. package/src/ports.test.ts +0 -678
  161. package/src/ports.ts +0 -925
  162. package/src/prompt-doc.test.ts +0 -174
  163. package/src/prompt-doc.ts +0 -169
  164. package/src/registry.ts +0 -7
  165. package/src/runner.test.ts +0 -142
  166. package/src/runner.ts +0 -1
  167. package/src/runtime/adapters/stdin-approval.ts +0 -1
  168. package/src/runtime/adapters/websocket-approval.ts +0 -1
  169. package/src/runtime/bun-process-runner.ts +0 -1
  170. package/src/runtime-adapters.test.ts +0 -10
  171. package/src/runtime.ts +0 -12
  172. package/src/schema-ports.test.ts +0 -172
  173. package/src/schema.test.ts +0 -213
  174. package/src/schema.ts +0 -379
  175. package/src/tagma.test.ts +0 -317
  176. package/src/tagma.ts +0 -67
  177. package/src/task-ref.test.ts +0 -401
  178. package/src/task-ref.ts +0 -121
  179. package/src/triggers/file.test.ts +0 -79
  180. package/src/triggers/file.ts +0 -131
  181. package/src/triggers/manual.ts +0 -86
  182. package/src/types.ts +0 -18
  183. package/src/utils-api.ts +0 -8
  184. package/src/utils.test.ts +0 -28
  185. package/src/utils.ts +0 -203
  186. package/src/validate-raw-plugin-types.test.ts +0 -60
  187. package/src/validate-raw-ports.test.ts +0 -136
  188. package/src/validate-raw.ts +0 -852
  189. package/src/yaml-compiler.test.ts +0 -108
  190. package/src/yaml-compiler.ts +0 -110
  191. package/src/yaml.ts +0 -11
package/src/ports.test.ts DELETED
@@ -1,678 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import {
3
- extractInputReferences,
4
- extractTaskBindingOutputs,
5
- extractTaskOutputs,
6
- inferPromptPorts,
7
- resolveTaskBindingInputs,
8
- resolveTaskInputs,
9
- substituteInputs,
10
- } from './ports';
11
- import type { Permissions, PortDef, TaskConfig } from './types';
12
-
13
- const PERMS: Permissions = { read: true, write: false, execute: false };
14
-
15
- function task(overrides: Partial<TaskConfig> & { id: string }): TaskConfig {
16
- return {
17
- name: overrides.id,
18
- permissions: PERMS,
19
- ...overrides,
20
- };
21
- }
22
-
23
- // ─── substituteInputs ────────────────────────────────────────────────
24
-
25
- describe('substituteInputs', () => {
26
- test('replaces single placeholder with string value', () => {
27
- const { text, unresolved } = substituteInputs('hello {{inputs.name}}', { name: 'world' });
28
- expect(text).toBe('hello world');
29
- expect(unresolved).toEqual([]);
30
- });
31
-
32
- test('allows optional whitespace inside braces', () => {
33
- const { text } = substituteInputs('{{ inputs.name }} / {{inputs.name}}', { name: 'x' });
34
- expect(text).toBe('x / x');
35
- });
36
-
37
- test('stringifies number / boolean values verbatim', () => {
38
- const { text } = substituteInputs(
39
- 'n={{inputs.n}} b={{inputs.b}}',
40
- { n: 42, b: true },
41
- );
42
- expect(text).toBe('n=42 b=true');
43
- });
44
-
45
- test('JSON-stringifies object values', () => {
46
- const { text } = substituteInputs('payload={{inputs.p}}', {
47
- p: { a: 1, b: 'x' },
48
- });
49
- expect(text).toBe('payload={"a":1,"b":"x"}');
50
- });
51
-
52
- test('renders unknown placeholder empty and reports it', () => {
53
- const { text, unresolved } = substituteInputs('hello {{inputs.missing}}', {});
54
- expect(text).toBe('hello ');
55
- expect(unresolved).toEqual(['missing']);
56
- });
57
-
58
- test('renders null / undefined as empty and reports', () => {
59
- const { text, unresolved } = substituteInputs('a={{inputs.a}} b={{inputs.b}}', {
60
- a: null,
61
- b: undefined,
62
- });
63
- expect(text).toBe('a= b=');
64
- expect([...unresolved].sort()).toEqual(['a', 'b']);
65
- });
66
-
67
- test('leaves malformed placeholders alone', () => {
68
- const { text } = substituteInputs('{{inputs.a.b}} {{inputs.}}', { a: 'x' });
69
- expect(text).toBe('{{inputs.a.b}} {{inputs.}}');
70
- });
71
-
72
- test('handles circular objects without throwing', () => {
73
- const obj: Record<string, unknown> = { self: null };
74
- obj.self = obj;
75
- const { text, unresolved } = substituteInputs('{{inputs.x}}', { x: obj });
76
- expect(text).toBe('');
77
- expect(unresolved).toEqual(['x']);
78
- });
79
- });
80
-
81
- describe('extractInputReferences', () => {
82
- test('returns unique referenced names', () => {
83
- const refs = extractInputReferences(
84
- 'get {{inputs.city}} for id={{inputs.id}} and {{inputs.city}} again',
85
- );
86
- expect(refs.sort()).toEqual(['city', 'id']);
87
- });
88
-
89
- test('returns empty for text without placeholders', () => {
90
- expect(extractInputReferences('no placeholders here')).toEqual([]);
91
- });
92
- });
93
-
94
- // ─── resolveTaskInputs ────────────────────────────────────────────────
95
-
96
- const cityPort: PortDef = { name: 'city', type: 'string', required: true };
97
- const idPort: PortDef = { name: 'id', type: 'number', required: true };
98
-
99
- describe('resolveTaskInputs', () => {
100
- test('no declared inputs → ready with empty map', () => {
101
- const t = task({ id: 'downstream', command: 'echo' });
102
- const res = resolveTaskInputs(t, new Map(), []);
103
- expect(res).toEqual({ kind: 'ready', inputs: {}, missingOptional: [] });
104
- });
105
-
106
- test('matches inputs by name across upstream outputs', () => {
107
- const t = task({
108
- id: 'downstream',
109
- command: 'echo',
110
- ports: { inputs: [cityPort, idPort] },
111
- });
112
- const upstream = new Map<string, Record<string, unknown>>([
113
- ['t.prompt', { city: 'Shanghai' }],
114
- ['t.other', { id: 42 }],
115
- ]);
116
- const res = resolveTaskInputs(t, upstream, ['t.prompt', 't.other']);
117
- expect(res.kind).toBe('ready');
118
- if (res.kind !== 'ready') return;
119
- expect(res.inputs).toEqual({ city: 'Shanghai', id: 42 });
120
- });
121
-
122
- test('required missing blocks with a readable reason', () => {
123
- const t = task({
124
- id: 'downstream',
125
- command: 'echo',
126
- ports: { inputs: [cityPort, idPort] },
127
- });
128
- const res = resolveTaskInputs(t, new Map(), ['t.x']);
129
- expect(res.kind).toBe('blocked');
130
- if (res.kind !== 'blocked') return;
131
- expect([...res.missingRequired].sort()).toEqual(['city', 'id']);
132
- expect(res.reason).toMatch(/city.*id|id.*city/);
133
- });
134
-
135
- test('optional missing yields ready but reports missingOptional', () => {
136
- const optional: PortDef = { name: 'note', type: 'string' };
137
- const t = task({
138
- id: 'downstream',
139
- command: 'echo',
140
- ports: { inputs: [optional] },
141
- });
142
- const res = resolveTaskInputs(t, new Map(), []);
143
- expect(res.kind).toBe('ready');
144
- if (res.kind !== 'ready') return;
145
- expect(res.inputs).toEqual({});
146
- expect(res.missingOptional).toEqual(['note']);
147
- });
148
-
149
- test('applies default for missing optional', () => {
150
- const optional: PortDef = { name: 'note', type: 'string', default: 'n/a' };
151
- const t = task({
152
- id: 'd',
153
- command: 'echo',
154
- ports: { inputs: [optional] },
155
- });
156
- const res = resolveTaskInputs(t, new Map(), []);
157
- expect(res.kind).toBe('ready');
158
- if (res.kind !== 'ready') return;
159
- expect(res.inputs).toEqual({ note: 'n/a' });
160
- });
161
-
162
- test('ambiguous multi-upstream match blocks unless disambiguated', () => {
163
- const t = task({
164
- id: 'd',
165
- command: 'echo',
166
- ports: { inputs: [cityPort] },
167
- });
168
- const upstream = new Map<string, Record<string, unknown>>([
169
- ['t.a', { city: 'Shanghai' }],
170
- ['t.b', { city: 'Beijing' }],
171
- ]);
172
- const res = resolveTaskInputs(t, upstream, ['t.a', 't.b']);
173
- expect(res.kind).toBe('blocked');
174
- if (res.kind !== 'blocked') return;
175
- expect(res.ambiguous.length).toBe(1);
176
- expect(res.ambiguous[0]!.port).toBe('city');
177
- expect([...res.ambiguous[0]!.producers].sort()).toEqual(['t.a', 't.b']);
178
- });
179
-
180
- test('explicit fully-qualified "from" wins over name-match ambiguity', () => {
181
- const explicit: PortDef = {
182
- name: 'city',
183
- type: 'string',
184
- required: true,
185
- from: 't.b.city',
186
- };
187
- const t = task({
188
- id: 'd',
189
- command: 'echo',
190
- ports: { inputs: [explicit] },
191
- });
192
- const upstream = new Map<string, Record<string, unknown>>([
193
- ['t.a', { city: 'Shanghai' }],
194
- ['t.b', { city: 'Beijing' }],
195
- ]);
196
- const res = resolveTaskInputs(t, upstream, ['t.a', 't.b']);
197
- expect(res.kind).toBe('ready');
198
- if (res.kind !== 'ready') return;
199
- expect(res.inputs).toEqual({ city: 'Beijing' });
200
- });
201
-
202
- test('coerces numeric strings to number type', () => {
203
- const t = task({
204
- id: 'd',
205
- command: 'echo',
206
- ports: { inputs: [idPort] },
207
- });
208
- const upstream = new Map<string, Record<string, unknown>>([['t.a', { id: '42' }]]);
209
- const res = resolveTaskInputs(t, upstream, ['t.a']);
210
- expect(res.kind).toBe('ready');
211
- if (res.kind !== 'ready') return;
212
- expect(res.inputs.id).toBe(42);
213
- });
214
-
215
- test('flags type-coercion failures as blocked', () => {
216
- const t = task({
217
- id: 'd',
218
- command: 'echo',
219
- ports: { inputs: [idPort] },
220
- });
221
- const upstream = new Map<string, Record<string, unknown>>([['t.a', { id: 'nope' }]]);
222
- const res = resolveTaskInputs(t, upstream, ['t.a']);
223
- expect(res.kind).toBe('blocked');
224
- if (res.kind !== 'blocked') return;
225
- expect(res.typeErrors.length).toBe(1);
226
- expect(res.typeErrors[0]!.port).toBe('id');
227
- });
228
-
229
- test('enforces enum membership', () => {
230
- const colorPort: PortDef = {
231
- name: 'color',
232
- type: 'enum',
233
- enum: ['red', 'green'],
234
- required: true,
235
- };
236
- const t = task({
237
- id: 'd',
238
- command: 'echo',
239
- ports: { inputs: [colorPort] },
240
- });
241
- const upstream = new Map<string, Record<string, unknown>>([['t.a', { color: 'blue' }]]);
242
- const res = resolveTaskInputs(t, upstream, ['t.a']);
243
- expect(res.kind).toBe('blocked');
244
- if (res.kind !== 'blocked') return;
245
- expect(res.typeErrors[0]!.port).toBe('color');
246
- });
247
- });
248
-
249
- // ─── resolveTaskBindingInputs ────────────────────────────────────────
250
-
251
- describe('resolveTaskBindingInputs', () => {
252
- test('coerces typed unified inputs from upstream outputs', () => {
253
- const t = task({
254
- id: 'downstream',
255
- command: 'echo',
256
- inputs: {
257
- id: { from: 't.up.outputs.id', type: 'number', required: true },
258
- enabled: { value: 'true', type: 'boolean' },
259
- },
260
- });
261
- const upstream = new Map([
262
- [
263
- 't.up',
264
- {
265
- outputs: { id: '42' },
266
- stdout: '',
267
- stderr: '',
268
- normalizedOutput: null,
269
- exitCode: 0,
270
- },
271
- ],
272
- ]);
273
- const res = resolveTaskBindingInputs(t, upstream, ['t.up']);
274
- expect(res.kind).toBe('ready');
275
- if (res.kind !== 'ready') return;
276
- expect(res.inputs).toEqual({ id: 42, enabled: true });
277
- });
278
-
279
- test('blocks typed unified input coercion failures', () => {
280
- const t = task({
281
- id: 'downstream',
282
- command: 'echo',
283
- inputs: {
284
- id: { from: 't.up.outputs.id', type: 'number', required: true },
285
- },
286
- });
287
- const upstream = new Map([
288
- [
289
- 't.up',
290
- {
291
- outputs: { id: 'not-a-number' },
292
- stdout: '',
293
- stderr: '',
294
- normalizedOutput: null,
295
- exitCode: 0,
296
- },
297
- ],
298
- ]);
299
- const res = resolveTaskBindingInputs(t, upstream, ['t.up']);
300
- expect(res.kind).toBe('blocked');
301
- if (res.kind !== 'blocked') return;
302
- expect(res.typeErrors).toEqual([{ input: 'id', reason: 'expected number, got string' }]);
303
- });
304
-
305
- test('resolves literal values and defaults without requiring ports', () => {
306
- const t = task({
307
- id: 'downstream',
308
- command: 'echo',
309
- inputs: {
310
- city: { value: 'Shanghai' },
311
- mode: { from: 't.up.outputs.missing', default: 'quick' },
312
- },
313
- });
314
- const res = resolveTaskBindingInputs(t, new Map(), ['t.up']);
315
- expect(res).toEqual({
316
- kind: 'ready',
317
- inputs: { city: 'Shanghai', mode: 'quick' },
318
- missingOptional: [],
319
- });
320
- });
321
-
322
- test('resolves values from a direct upstream output and stdout', () => {
323
- const t = task({
324
- id: 'downstream',
325
- command: 'echo',
326
- inputs: {
327
- city: { from: 't.up.outputs.city' },
328
- raw: { from: 't.up.stdout' },
329
- },
330
- });
331
- const upstream = new Map([
332
- [
333
- 't.up',
334
- {
335
- outputs: { city: 'Shanghai' },
336
- stdout: 'raw text\n',
337
- stderr: '',
338
- normalizedOutput: null,
339
- exitCode: 0,
340
- },
341
- ],
342
- ]);
343
- const res = resolveTaskBindingInputs(t, upstream, ['t.up']);
344
- expect(res.kind).toBe('ready');
345
- if (res.kind !== 'ready') return;
346
- expect(res.inputs).toEqual({ city: 'Shanghai', raw: 'raw text\n' });
347
- });
348
-
349
- test('blocks required missing bindings with a readable reason', () => {
350
- const t = task({
351
- id: 'downstream',
352
- command: 'echo',
353
- inputs: {
354
- city: { from: 't.up.outputs.city', required: true },
355
- },
356
- });
357
- const res = resolveTaskBindingInputs(t, new Map(), ['t.up']);
358
- expect(res.kind).toBe('blocked');
359
- if (res.kind !== 'blocked') return;
360
- expect(res.missingRequired).toEqual(['city']);
361
- expect(res.reason).toContain('missing required binding input(s): city');
362
- });
363
-
364
- test('detects ambiguous loose output name matches', () => {
365
- const t = task({
366
- id: 'downstream',
367
- command: 'echo',
368
- inputs: {
369
- val: { from: 'outputs.val', required: true },
370
- },
371
- });
372
- const upstream = new Map([
373
- ['t.a', { outputs: { val: 'a' }, stdout: '', stderr: '', normalizedOutput: null, exitCode: 0 }],
374
- ['t.b', { outputs: { val: 'b' }, stdout: '', stderr: '', normalizedOutput: null, exitCode: 0 }],
375
- ]);
376
- const res = resolveTaskBindingInputs(t, upstream, ['t.a', 't.b']);
377
- expect(res.kind).toBe('blocked');
378
- if (res.kind !== 'blocked') return;
379
- expect(res.ambiguous[0]).toEqual({ input: 'val', producers: ['t.a', 't.b'] });
380
- });
381
- });
382
-
383
- // ─── extractTaskOutputs ──────────────────────────────────────────────
384
-
385
- describe('extractTaskOutputs', () => {
386
- const outputs = [
387
- { name: 'city', type: 'string' as const },
388
- { name: 'temp', type: 'number' as const },
389
- ];
390
-
391
- test('no declared outputs → empty map, null diagnostic', () => {
392
- const r = extractTaskOutputs(undefined, 'anything', null);
393
- expect(r.outputs).toEqual({});
394
- expect(r.diagnostic).toBeNull();
395
- });
396
-
397
- test('parses last-line JSON object as source record', () => {
398
- const stdout = 'some log\nmore log\n{"city":"Shanghai","temp":23}\n';
399
- const r = extractTaskOutputs({ outputs }, stdout, null);
400
- expect(r.outputs).toEqual({ city: 'Shanghai', temp: 23 });
401
- expect(r.diagnostic).toBeNull();
402
- });
403
-
404
- test('falls back to whole-source JSON when last line is a closing brace', () => {
405
- const stdout = '{\n "city": "Shanghai",\n "temp": 23\n}\n';
406
- const r = extractTaskOutputs({ outputs }, stdout, null);
407
- expect(r.outputs).toEqual({ city: 'Shanghai', temp: 23 });
408
- });
409
-
410
- test('prefers normalizedOutput over stdout when provided', () => {
411
- const stdout = '{"city":"Wrong","temp":0}';
412
- const normalized = '{"city":"Shanghai","temp":23}';
413
- const r = extractTaskOutputs({ outputs }, stdout, normalized);
414
- expect(r.outputs).toEqual({ city: 'Shanghai', temp: 23 });
415
- });
416
-
417
- test('reports missing keys as diagnostic, keeps resolved keys', () => {
418
- const r = extractTaskOutputs({ outputs }, '{"city":"Shanghai"}', null);
419
- expect(r.outputs).toEqual({ city: 'Shanghai' });
420
- expect(r.diagnostic).toContain('missing key "temp"');
421
- });
422
-
423
- test('reports coercion failure and skips bad port', () => {
424
- const r = extractTaskOutputs(
425
- { outputs },
426
- '{"city":"Shanghai","temp":"not-a-number"}',
427
- null,
428
- );
429
- expect(r.outputs).toEqual({ city: 'Shanghai' });
430
- expect(r.diagnostic).toContain('"temp"');
431
- });
432
-
433
- test('reports diagnostic when no JSON can be parsed', () => {
434
- const r = extractTaskOutputs({ outputs }, 'plain text output\nnothing json\n', null);
435
- expect(r.outputs).toEqual({});
436
- expect(r.diagnostic).toContain('could not find a final-line JSON object');
437
- });
438
- });
439
-
440
- // ─── extractTaskBindingOutputs ───────────────────────────────────────
441
-
442
- describe('extractTaskBindingOutputs', () => {
443
- test('coerces typed unified outputs from final-line JSON', () => {
444
- const r = extractTaskBindingOutputs(
445
- {
446
- id: { type: 'number' },
447
- ok: { from: 'json.success', type: 'boolean' },
448
- },
449
- 'log\n{"id":"42","success":"true"}\n',
450
- '',
451
- null,
452
- );
453
- expect(r.outputs).toEqual({ id: 42, ok: true });
454
- expect(r.diagnostic).toBeNull();
455
- });
456
-
457
- test('diagnoses typed unified output coercion failures', () => {
458
- const r = extractTaskBindingOutputs(
459
- {
460
- id: { type: 'number' },
461
- },
462
- '{"id":"nope"}',
463
- '',
464
- null,
465
- );
466
- expect(r.outputs).toEqual({});
467
- expect(r.diagnostic).toContain('"id": expected number, got string');
468
- });
469
-
470
- test('extracts loose outputs from final-line JSON by default', () => {
471
- const r = extractTaskBindingOutputs(
472
- {
473
- city: {},
474
- temp: { from: 'json.temperature' },
475
- },
476
- 'log\n{"city":"Shanghai","temperature":23}\n',
477
- '',
478
- null,
479
- );
480
- expect(r.outputs).toEqual({ city: 'Shanghai', temp: 23 });
481
- expect(r.diagnostic).toBeNull();
482
- });
483
-
484
- test('can publish whole stdout and normalizedOutput as named outputs', () => {
485
- const r = extractTaskBindingOutputs(
486
- {
487
- raw: { from: 'stdout' },
488
- normalized: { from: 'normalizedOutput' },
489
- },
490
- 'raw text\n',
491
- '',
492
- 'normalized text',
493
- );
494
- expect(r.outputs).toEqual({ raw: 'raw text\n', normalized: 'normalized text' });
495
- });
496
-
497
- test('uses defaults for missing loose outputs without failing extraction', () => {
498
- const r = extractTaskBindingOutputs(
499
- {
500
- city: { default: 'Unknown' },
501
- },
502
- 'not json\n',
503
- '',
504
- null,
505
- );
506
- expect(r.outputs).toEqual({ city: 'Unknown' });
507
- expect(r.diagnostic).toBeNull();
508
- });
509
- });
510
-
511
- // ─── inferPromptPorts ───────────────────────────────────────────────
512
-
513
- describe('inferPromptPorts', () => {
514
- test('inputs are taken from direct-upstream Command outputs', () => {
515
- const r = inferPromptPorts({
516
- upstreams: [
517
- {
518
- taskId: 't.up',
519
- outputs: [
520
- { name: 'city', type: 'string' },
521
- { name: 'id', type: 'number' },
522
- ],
523
- },
524
- ],
525
- downstreams: [],
526
- });
527
- expect(r.inputConflicts).toEqual([]);
528
- expect(r.outputConflicts).toEqual([]);
529
- expect(r.ports.inputs).toHaveLength(2);
530
- expect(r.ports.inputs?.map((p) => p.name).sort()).toEqual(['city', 'id']);
531
- // Inferred inputs default to required: the LLM wouldn't see a real
532
- // value if the upstream failed to produce one.
533
- expect(r.ports.inputs?.every((p) => p.required === true)).toBe(true);
534
- expect(r.ports.outputs).toBeUndefined();
535
- });
536
-
537
- test('outputs are taken from direct-downstream Command inputs', () => {
538
- const r = inferPromptPorts({
539
- upstreams: [],
540
- downstreams: [
541
- {
542
- taskId: 't.down',
543
- inputs: [
544
- { name: 'greeting', type: 'string', required: true },
545
- { name: 'target', type: 'string', default: 'world' },
546
- ],
547
- },
548
- ],
549
- });
550
- expect(r.outputConflicts).toEqual([]);
551
- expect(r.ports.outputs?.map((p) => p.name).sort()).toEqual(['greeting', 'target']);
552
- // Outputs drop input-only fields (required, default, from).
553
- for (const p of r.ports.outputs ?? []) {
554
- expect(p).not.toHaveProperty('required');
555
- expect(p).not.toHaveProperty('default');
556
- expect(p).not.toHaveProperty('from');
557
- }
558
- expect(r.ports.inputs).toBeUndefined();
559
- });
560
-
561
- test('Prompt neighbors (outputs undefined) contribute nothing', () => {
562
- const r = inferPromptPorts({
563
- upstreams: [
564
- { taskId: 't.up', outputs: undefined }, // Prompt upstream
565
- ],
566
- downstreams: [
567
- { taskId: 't.down', inputs: undefined }, // Prompt downstream
568
- ],
569
- });
570
- expect(r.ports).toEqual({});
571
- expect(r.inputConflicts).toEqual([]);
572
- expect(r.outputConflicts).toEqual([]);
573
- });
574
-
575
- test('two upstreams with the same output name produce an input conflict', () => {
576
- const r = inferPromptPorts({
577
- upstreams: [
578
- { taskId: 't.a', outputs: [{ name: 'city', type: 'string' }] },
579
- { taskId: 't.b', outputs: [{ name: 'city', type: 'string' }] },
580
- ],
581
- downstreams: [],
582
- });
583
- expect(r.inputConflicts).toHaveLength(1);
584
- expect(r.inputConflicts[0]!.portName).toBe('city');
585
- expect(r.inputConflicts[0]!.producers.map((p) => p.taskId).sort()).toEqual(['t.a', 't.b']);
586
- expect(r.inputConflicts[0]!.reason).toMatch(/cannot disambiguate/);
587
- });
588
-
589
- test('two downstreams with compatible input types merge silently', () => {
590
- const r = inferPromptPorts({
591
- upstreams: [],
592
- downstreams: [
593
- {
594
- taskId: 't.d1',
595
- inputs: [{ name: 'date', type: 'string', required: true }],
596
- },
597
- {
598
- taskId: 't.d2',
599
- inputs: [{ name: 'date', type: 'string', required: false }],
600
- },
601
- ],
602
- });
603
- expect(r.outputConflicts).toEqual([]);
604
- expect(r.ports.outputs).toHaveLength(1);
605
- expect(r.ports.outputs![0]!.name).toBe('date');
606
- expect(r.ports.outputs![0]!.type).toBe('string');
607
- });
608
-
609
- test('two downstreams with incompatible input types produce an output conflict', () => {
610
- const r = inferPromptPorts({
611
- upstreams: [],
612
- downstreams: [
613
- { taskId: 't.d1', inputs: [{ name: 'date', type: 'string' }] },
614
- { taskId: 't.d2', inputs: [{ name: 'date', type: 'number' }] },
615
- ],
616
- });
617
- expect(r.outputConflicts).toHaveLength(1);
618
- expect(r.outputConflicts[0]!.portName).toBe('date');
619
- expect(r.outputConflicts[0]!.reason).toMatch(/conflicting type requirements/);
620
- });
621
-
622
- test('enum ports with differing value sets are incompatible', () => {
623
- const r = inferPromptPorts({
624
- upstreams: [],
625
- downstreams: [
626
- {
627
- taskId: 't.d1',
628
- inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'b'] }],
629
- },
630
- {
631
- taskId: 't.d2',
632
- inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'c'] }],
633
- },
634
- ],
635
- });
636
- expect(r.outputConflicts).toHaveLength(1);
637
- });
638
-
639
- test('enum ports with identical value sets merge', () => {
640
- const r = inferPromptPorts({
641
- upstreams: [],
642
- downstreams: [
643
- {
644
- taskId: 't.d1',
645
- inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'b'] }],
646
- },
647
- {
648
- taskId: 't.d2',
649
- inputs: [{ name: 'bucket', type: 'enum', enum: ['b', 'a'] }], // different order, same set
650
- },
651
- ],
652
- });
653
- expect(r.outputConflicts).toEqual([]);
654
- expect(r.ports.outputs).toHaveLength(1);
655
- });
656
-
657
- test('description and enum propagate from the first occurrence', () => {
658
- const r = inferPromptPorts({
659
- upstreams: [
660
- {
661
- taskId: 't.up',
662
- outputs: [
663
- {
664
- name: 'kind',
665
- type: 'enum',
666
- enum: ['hot', 'cold'],
667
- description: 'Weather kind',
668
- },
669
- ],
670
- },
671
- ],
672
- downstreams: [],
673
- });
674
- const port = r.ports.inputs![0]!;
675
- expect(port.description).toBe('Weather kind');
676
- expect(port.enum).toEqual(['hot', 'cold']);
677
- });
678
- });