@tagma/sdk 0.7.3 → 0.7.5

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 (230) hide show
  1. package/README.md +85 -57
  2. package/dist/approval.d.ts +2 -12
  3. package/dist/approval.d.ts.map +1 -1
  4. package/dist/approval.js +1 -90
  5. package/dist/approval.js.map +1 -1
  6. package/dist/bootstrap.d.ts +1 -1
  7. package/dist/bootstrap.d.ts.map +1 -1
  8. package/dist/completions/file-exists.js +1 -1
  9. package/dist/completions/file-exists.js.map +1 -1
  10. package/dist/completions/output-check.d.ts.map +1 -1
  11. package/dist/completions/output-check.js +17 -4
  12. package/dist/completions/output-check.js.map +1 -1
  13. package/dist/config.d.ts +4 -4
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +2 -2
  16. package/dist/config.js.map +1 -1
  17. package/dist/dataflow.d.ts +3 -0
  18. package/dist/dataflow.d.ts.map +1 -0
  19. package/dist/dataflow.js +2 -0
  20. package/dist/dataflow.js.map +1 -0
  21. package/dist/drivers/opencode.d.ts.map +1 -1
  22. package/dist/drivers/opencode.js +23 -71
  23. package/dist/drivers/opencode.js.map +1 -1
  24. package/dist/engine.d.ts +5 -56
  25. package/dist/engine.d.ts.map +1 -1
  26. package/dist/engine.js +7 -297
  27. package/dist/engine.js.map +1 -1
  28. package/dist/index.d.ts +4 -6
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -4
  31. package/dist/index.js.map +1 -1
  32. package/dist/logger.d.ts +2 -60
  33. package/dist/logger.d.ts.map +1 -1
  34. package/dist/logger.js +1 -153
  35. package/dist/logger.js.map +1 -1
  36. package/dist/middlewares/static-context.d.ts.map +1 -1
  37. package/dist/middlewares/static-context.js +1 -2
  38. package/dist/middlewares/static-context.js.map +1 -1
  39. package/dist/pipeline-runner.d.ts.map +1 -1
  40. package/dist/pipeline-runner.js +2 -2
  41. package/dist/pipeline-runner.js.map +1 -1
  42. package/dist/plugins.d.ts +2 -2
  43. package/dist/plugins.d.ts.map +1 -1
  44. package/dist/plugins.js +1 -1
  45. package/dist/plugins.js.map +1 -1
  46. package/dist/runner.d.ts +1 -35
  47. package/dist/runner.d.ts.map +1 -1
  48. package/dist/runner.js +1 -610
  49. package/dist/runner.js.map +1 -1
  50. package/dist/runtime/adapters/stdin-approval.d.ts +2 -0
  51. package/dist/runtime/adapters/stdin-approval.d.ts.map +1 -0
  52. package/dist/runtime/adapters/stdin-approval.js +2 -0
  53. package/dist/runtime/adapters/stdin-approval.js.map +1 -0
  54. package/dist/runtime/adapters/websocket-approval.d.ts +2 -0
  55. package/dist/runtime/adapters/websocket-approval.d.ts.map +1 -0
  56. package/dist/runtime/adapters/websocket-approval.js +2 -0
  57. package/dist/runtime/adapters/websocket-approval.js.map +1 -0
  58. package/dist/runtime/bun-process-runner.d.ts +2 -0
  59. package/dist/runtime/bun-process-runner.d.ts.map +1 -0
  60. package/dist/runtime/bun-process-runner.js +2 -0
  61. package/dist/runtime/bun-process-runner.js.map +1 -0
  62. package/dist/runtime.d.ts +2 -8
  63. package/dist/runtime.d.ts.map +1 -1
  64. package/dist/runtime.js +1 -7
  65. package/dist/runtime.js.map +1 -1
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +3 -4
  68. package/dist/schema.js.map +1 -1
  69. package/dist/tagma.d.ts +3 -4
  70. package/dist/tagma.d.ts.map +1 -1
  71. package/dist/tagma.js +2 -3
  72. package/dist/tagma.js.map +1 -1
  73. package/dist/triggers/file.d.ts.map +1 -1
  74. package/dist/triggers/file.js +74 -108
  75. package/dist/triggers/file.js.map +1 -1
  76. package/dist/triggers/manual.d.ts.map +1 -1
  77. package/dist/triggers/manual.js +1 -2
  78. package/dist/triggers/manual.js.map +1 -1
  79. package/dist/types.d.ts +1 -2
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/types.js +1 -12
  82. package/dist/types.js.map +1 -1
  83. package/dist/utils-api.d.ts +1 -1
  84. package/dist/utils-api.d.ts.map +1 -1
  85. package/dist/utils-api.js +1 -1
  86. package/dist/utils-api.js.map +1 -1
  87. package/dist/validate-raw.d.ts.map +1 -1
  88. package/dist/validate-raw.js +5 -12
  89. package/dist/validate-raw.js.map +1 -1
  90. package/package.json +20 -22
  91. package/dist/adapters/stdin-approval.d.ts +0 -6
  92. package/dist/adapters/stdin-approval.d.ts.map +0 -1
  93. package/dist/adapters/stdin-approval.js +0 -90
  94. package/dist/adapters/stdin-approval.js.map +0 -1
  95. package/dist/adapters/websocket-approval.d.ts +0 -28
  96. package/dist/adapters/websocket-approval.d.ts.map +0 -1
  97. package/dist/adapters/websocket-approval.js +0 -147
  98. package/dist/adapters/websocket-approval.js.map +0 -1
  99. package/dist/core/dataflow.d.ts +0 -23
  100. package/dist/core/dataflow.d.ts.map +0 -1
  101. package/dist/core/dataflow.js +0 -99
  102. package/dist/core/dataflow.js.map +0 -1
  103. package/dist/core/log-prune.d.ts +0 -16
  104. package/dist/core/log-prune.d.ts.map +0 -1
  105. package/dist/core/log-prune.js +0 -34
  106. package/dist/core/log-prune.js.map +0 -1
  107. package/dist/core/preflight.d.ts +0 -13
  108. package/dist/core/preflight.d.ts.map +0 -1
  109. package/dist/core/preflight.js +0 -61
  110. package/dist/core/preflight.js.map +0 -1
  111. package/dist/core/run-context.d.ts +0 -55
  112. package/dist/core/run-context.d.ts.map +0 -1
  113. package/dist/core/run-context.js +0 -158
  114. package/dist/core/run-context.js.map +0 -1
  115. package/dist/core/run-state.d.ts +0 -25
  116. package/dist/core/run-state.d.ts.map +0 -1
  117. package/dist/core/run-state.js +0 -93
  118. package/dist/core/run-state.js.map +0 -1
  119. package/dist/core/scheduler.d.ts +0 -13
  120. package/dist/core/scheduler.d.ts.map +0 -1
  121. package/dist/core/scheduler.js +0 -35
  122. package/dist/core/scheduler.js.map +0 -1
  123. package/dist/core/task-executor.d.ts +0 -13
  124. package/dist/core/task-executor.d.ts.map +0 -1
  125. package/dist/core/task-executor.js +0 -601
  126. package/dist/core/task-executor.js.map +0 -1
  127. package/dist/core/trigger-errors.d.ts +0 -9
  128. package/dist/core/trigger-errors.d.ts.map +0 -1
  129. package/dist/core/trigger-errors.js +0 -15
  130. package/dist/core/trigger-errors.js.map +0 -1
  131. package/dist/dag.d.ts +0 -45
  132. package/dist/dag.d.ts.map +0 -1
  133. package/dist/dag.js +0 -177
  134. package/dist/dag.js.map +0 -1
  135. package/dist/hooks.d.ts +0 -73
  136. package/dist/hooks.d.ts.map +0 -1
  137. package/dist/hooks.js +0 -106
  138. package/dist/hooks.js.map +0 -1
  139. package/dist/pipeline-definition.d.ts +0 -3
  140. package/dist/pipeline-definition.d.ts.map +0 -1
  141. package/dist/pipeline-definition.js +0 -4
  142. package/dist/pipeline-definition.js.map +0 -1
  143. package/dist/ports.d.ts +0 -196
  144. package/dist/ports.d.ts.map +0 -1
  145. package/dist/ports.js +0 -688
  146. package/dist/ports.js.map +0 -1
  147. package/dist/prompt-doc.d.ts +0 -70
  148. package/dist/prompt-doc.d.ts.map +0 -1
  149. package/dist/prompt-doc.js +0 -154
  150. package/dist/prompt-doc.js.map +0 -1
  151. package/dist/registry.d.ts +0 -67
  152. package/dist/registry.d.ts.map +0 -1
  153. package/dist/registry.js +0 -293
  154. package/dist/registry.js.map +0 -1
  155. package/dist/task-ref.d.ts +0 -55
  156. package/dist/task-ref.d.ts.map +0 -1
  157. package/dist/task-ref.js +0 -103
  158. package/dist/task-ref.js.map +0 -1
  159. package/dist/utils.d.ts +0 -13
  160. package/dist/utils.d.ts.map +0 -1
  161. package/dist/utils.js +0 -177
  162. package/dist/utils.js.map +0 -1
  163. package/src/adapters/stdin-approval.ts +0 -106
  164. package/src/adapters/websocket-approval.ts +0 -224
  165. package/src/approval.ts +0 -131
  166. package/src/bootstrap.ts +0 -55
  167. package/src/completions/exit-code.ts +0 -34
  168. package/src/completions/file-exists.ts +0 -66
  169. package/src/completions/output-check.test.ts +0 -50
  170. package/src/completions/output-check.ts +0 -92
  171. package/src/config-ops.test.ts +0 -70
  172. package/src/config-ops.ts +0 -328
  173. package/src/config.ts +0 -26
  174. package/src/core/dataflow.test.ts +0 -166
  175. package/src/core/dataflow.ts +0 -161
  176. package/src/core/log-prune.test.ts +0 -58
  177. package/src/core/log-prune.ts +0 -43
  178. package/src/core/preflight.test.ts +0 -49
  179. package/src/core/preflight.ts +0 -89
  180. package/src/core/run-context.test.ts +0 -256
  181. package/src/core/run-context.ts +0 -211
  182. package/src/core/run-state.test.ts +0 -98
  183. package/src/core/run-state.ts +0 -122
  184. package/src/core/scheduler.test.ts +0 -83
  185. package/src/core/scheduler.ts +0 -42
  186. package/src/core/task-executor.ts +0 -743
  187. package/src/core/trigger-errors.ts +0 -15
  188. package/src/dag.test.ts +0 -56
  189. package/src/dag.ts +0 -245
  190. package/src/drivers/opencode.ts +0 -410
  191. package/src/engine-ports-mixed.test.ts +0 -156
  192. package/src/engine-ports.test.ts +0 -166
  193. package/src/engine-task-type.test.ts +0 -56
  194. package/src/engine.ts +0 -458
  195. package/src/hooks.ts +0 -193
  196. package/src/index.ts +0 -33
  197. package/src/logger.ts +0 -182
  198. package/src/middlewares/static-context.ts +0 -49
  199. package/src/pipeline-definition.ts +0 -5
  200. package/src/pipeline-runner.test.ts +0 -91
  201. package/src/pipeline-runner.ts +0 -194
  202. package/src/plugin-registry.test.ts +0 -382
  203. package/src/plugins.ts +0 -21
  204. package/src/ports.test.ts +0 -678
  205. package/src/ports.ts +0 -925
  206. package/src/prompt-doc.test.ts +0 -174
  207. package/src/prompt-doc.ts +0 -169
  208. package/src/registry.ts +0 -353
  209. package/src/runner.test.ts +0 -142
  210. package/src/runner.ts +0 -666
  211. package/src/runtime.ts +0 -20
  212. package/src/schema-ports.test.ts +0 -172
  213. package/src/schema.test.ts +0 -213
  214. package/src/schema.ts +0 -379
  215. package/src/tagma.test.ts +0 -155
  216. package/src/tagma.ts +0 -62
  217. package/src/task-ref.test.ts +0 -401
  218. package/src/task-ref.ts +0 -121
  219. package/src/triggers/file.ts +0 -164
  220. package/src/triggers/manual.ts +0 -86
  221. package/src/types.ts +0 -18
  222. package/src/utils-api.ts +0 -8
  223. package/src/utils.test.ts +0 -28
  224. package/src/utils.ts +0 -203
  225. package/src/validate-raw-plugin-types.test.ts +0 -60
  226. package/src/validate-raw-ports.test.ts +0 -136
  227. package/src/validate-raw.ts +0 -852
  228. package/src/yaml-compiler.test.ts +0 -108
  229. package/src/yaml-compiler.ts +0 -110
  230. package/src/yaml.ts +0 -11
package/src/ports.ts DELETED
@@ -1,925 +0,0 @@
1
- // ═══ Task ports: substitute / resolve / extract / infer ═══
2
- //
3
- // One module, four concerns, all keyed on `task.ports`:
4
- //
5
- // 1. `substituteInputs(text, inputs)` — expand `{{inputs.<name>}}` in
6
- // user-authored strings (command lines, prompts). Strict syntax, no
7
- // arbitrary expressions — the placeholder is a thin pasteboard, not
8
- // a templating engine. Unknown / undefined references render as empty
9
- // string with a diagnostic that the caller can surface.
10
- //
11
- // 2. `resolveTaskInputs(task, upstreamOutputs, dependsOn)` — gather the
12
- // values a task will consume from its direct upstreams. Matches by
13
- // port name (or by explicit `from:`), applies defaults, coerces to
14
- // the declared type, and classifies the result as ready / missing
15
- // required / ambiguous. The engine calls this before a task starts
16
- // and uses the classification to decide whether to block.
17
- //
18
- // 3. `extractTaskOutputs(ports, stdout, normalizedOutput)` — after a
19
- // task succeeds, pull the declared output values from the task's
20
- // output stream. Default strategy: find the last non-empty line that
21
- // parses as a JSON object, and read each declared output name from
22
- // it. Prefer `normalizedOutput` for AI tasks, fall back to raw
23
- // stdout — command tasks only ever have stdout.
24
- //
25
- // 4. `inferPromptPorts({upstreams, downstreams})` — Prompt Tasks do NOT
26
- // declare ports; their I/O contract is inferred from direct-neighbor
27
- // Command Tasks. This helper synthesizes a `TaskPorts` object the
28
- // engine can feed into the three concerns above, and surfaces any
29
- // collisions that block the task (same port name on two upstreams,
30
- // incompatible types across downstreams, …). Prompt neighbors
31
- // contribute zero structured I/O — they pass free text via
32
- // `continue_from` / normalizedOutput instead.
33
- //
34
- // Everything here is pure / deterministic so it can be reused by the CLI,
35
- // the editor (for preview/simulation), and the engine without side effects.
36
-
37
- import type {
38
- PortDef,
39
- PortType,
40
- TaskConfig,
41
- TaskOutputBindings,
42
- TaskPorts,
43
- } from './types';
44
-
45
- // ─── Template substitution ────────────────────────────────────────────
46
-
47
- /**
48
- * Matches `{{inputs.<identifier>}}` with optional whitespace inside the
49
- * braces. The identifier is restricted to the same character set we use
50
- * for task IDs (letter/underscore, then letters/digits/underscores) so
51
- * accidental use of `{{inputs.foo.bar}}` fails loudly rather than
52
- * silently producing garbage.
53
- */
54
- const PLACEHOLDER_RE = /\{\{\s*inputs\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
55
-
56
- /**
57
- * Scan `text` for every `{{inputs.<name>}}` placeholder and return the
58
- * set of referenced input names. Useful at validation time: the editor
59
- * can cross-check that each placeholder has a corresponding declared
60
- * port and flag typos before a run ever starts.
61
- */
62
- export function extractInputReferences(text: string): string[] {
63
- const names = new Set<string>();
64
- for (const match of text.matchAll(PLACEHOLDER_RE)) {
65
- names.add(match[1]!);
66
- }
67
- return [...names];
68
- }
69
-
70
- export interface SubstituteResult {
71
- readonly text: string;
72
- /** Port names that appeared in placeholders but weren't in `inputs`. */
73
- readonly unresolved: readonly string[];
74
- }
75
-
76
- /**
77
- * Replace `{{inputs.<name>}}` placeholders in `text` with values from
78
- * `inputs`. Coercion:
79
- * - string → as-is
80
- * - number / boolean → `String(value)`
81
- * - null / undefined → empty string (name is also reported as unresolved)
82
- * - anything else (object, array, json port) → `JSON.stringify(value)`
83
- *
84
- * Values are substituted *verbatim* — quoting is the user's
85
- * responsibility in the authored text. For command lines that interpolate
86
- * user-provided strings, authors should wrap the placeholder in quotes:
87
- *
88
- * weather.sh --city "{{inputs.city}}"
89
- *
90
- * That's a documented contract rather than a silent shell-escape, because
91
- * silent escaping would hide the difference between `--city Shanghai` and
92
- * `--flag $(echo pwned)` — both valid command fragments, one a bug, one a
93
- * feature. Users know which they want; the engine doesn't.
94
- */
95
- export function substituteInputs(
96
- text: string,
97
- inputs: Readonly<Record<string, unknown>>,
98
- ): SubstituteResult {
99
- const unresolved = new Set<string>();
100
- const out = text.replace(PLACEHOLDER_RE, (_full, name: string) => {
101
- if (!(name in inputs)) {
102
- unresolved.add(name);
103
- return '';
104
- }
105
- const value = inputs[name];
106
- if (value === null || value === undefined) {
107
- unresolved.add(name);
108
- return '';
109
- }
110
- if (typeof value === 'string') return value;
111
- if (typeof value === 'number' || typeof value === 'boolean') return String(value);
112
- try {
113
- return JSON.stringify(value);
114
- } catch {
115
- // Circular / unserializable — render a placeholder rather than
116
- // throwing, and mark it unresolved so the caller can warn.
117
- unresolved.add(name);
118
- return '';
119
- }
120
- });
121
- return { text: out, unresolved: [...unresolved] };
122
- }
123
-
124
- // ─── Input resolution ─────────────────────────────────────────────────
125
-
126
- export type InputResolution =
127
- | {
128
- readonly kind: 'ready';
129
- readonly inputs: Readonly<Record<string, unknown>>;
130
- /**
131
- * Optional inputs that had no upstream producer and no default;
132
- * they are absent from `inputs` (so placeholders render empty).
133
- * Separate from `missingRequired` so the engine can log softly
134
- * without blocking the task.
135
- */
136
- readonly missingOptional: readonly string[];
137
- }
138
- | {
139
- readonly kind: 'blocked';
140
- /** Required port names that could not be satisfied. */
141
- readonly missingRequired: readonly string[];
142
- /** Port names with multiple ambiguous producers. */
143
- readonly ambiguous: readonly { port: string; producers: readonly string[] }[];
144
- /** Port names whose resolved value failed type coercion. */
145
- readonly typeErrors: readonly { port: string; reason: string }[];
146
- /** Human-readable multi-line description for the engine to log. */
147
- readonly reason: string;
148
- };
149
-
150
- /**
151
- * Resolve the input values for `task` from the outputs its direct
152
- * upstreams produced.
153
- *
154
- * `upstreamOutputs` is keyed by fully-qualified task id and maps to the
155
- * outputs that task published (its `TaskResult.outputs`). `dependsOn` is
156
- * the already-qualified dependency list (from `DagNode.dependsOn`). When
157
- * an upstream has no outputs entry (e.g. it didn't declare any or it
158
- * failed), its entry may be missing — we just skip it during matching.
159
- *
160
- * Matching rules:
161
- * - If the input port has `from: "taskId.portName"` → look up that
162
- * specific upstream / port. Missing = unsatisfied.
163
- * - If it has `from: "portName"` (bare) → treat as explicit port name
164
- * but allow any upstream to provide it (useful when the user wants
165
- * to match by name but still be explicit about the intent).
166
- * - If no `from` → scan every upstream's outputs for a key matching
167
- * the input name. Zero hits = unsatisfied; 2+ hits across different
168
- * upstreams = ambiguous.
169
- *
170
- * The function never throws on config errors — every failure mode maps
171
- * to a field of the `blocked` result so the engine can log a unified
172
- * message and mark the task blocked.
173
- */
174
- export function resolveTaskInputs(
175
- task: TaskConfig,
176
- upstreamOutputs: ReadonlyMap<string, Readonly<Record<string, unknown>>>,
177
- dependsOn: readonly string[],
178
- ): InputResolution {
179
- const inputsDecl = task.ports?.inputs;
180
- if (!inputsDecl || inputsDecl.length === 0) {
181
- return { kind: 'ready', inputs: {}, missingOptional: [] };
182
- }
183
-
184
- const inputs: Record<string, unknown> = {};
185
- const missingRequired: string[] = [];
186
- const missingOptional: string[] = [];
187
- const ambiguous: { port: string; producers: string[] }[] = [];
188
- const typeErrors: { port: string; reason: string }[] = [];
189
-
190
- for (const port of inputsDecl) {
191
- const found = findUpstreamValue(port, upstreamOutputs, dependsOn);
192
- if (found.kind === 'ambiguous') {
193
- ambiguous.push({ port: port.name, producers: found.producers });
194
- continue;
195
- }
196
- let value: unknown;
197
- let present = false;
198
- if (found.kind === 'hit') {
199
- value = found.value;
200
- present = true;
201
- } else if (port.default !== undefined) {
202
- value = port.default;
203
- present = true;
204
- }
205
-
206
- if (!present) {
207
- if (port.required === true) {
208
- missingRequired.push(port.name);
209
- } else {
210
- missingOptional.push(port.name);
211
- }
212
- continue;
213
- }
214
-
215
- const coerced = coerceValue(port, value);
216
- if (coerced.kind === 'error') {
217
- typeErrors.push({ port: port.name, reason: coerced.reason });
218
- continue;
219
- }
220
- inputs[port.name] = coerced.value;
221
- }
222
-
223
- if (missingRequired.length > 0 || ambiguous.length > 0 || typeErrors.length > 0) {
224
- const lines: string[] = [];
225
- if (missingRequired.length > 0) {
226
- lines.push(`missing required input(s): ${missingRequired.join(', ')}`);
227
- }
228
- if (ambiguous.length > 0) {
229
- for (const amb of ambiguous) {
230
- lines.push(
231
- `input "${amb.port}" is produced by multiple upstreams ` +
232
- `(${amb.producers.join(', ')}) — disambiguate with "from: taskId.${amb.port}"`,
233
- );
234
- }
235
- }
236
- if (typeErrors.length > 0) {
237
- for (const te of typeErrors) {
238
- lines.push(`input "${te.port}": ${te.reason}`);
239
- }
240
- }
241
- return {
242
- kind: 'blocked',
243
- missingRequired,
244
- ambiguous,
245
- typeErrors,
246
- reason: lines.join('\n'),
247
- };
248
- }
249
-
250
- return { kind: 'ready', inputs, missingOptional };
251
- }
252
-
253
- // ─── Lightweight binding resolution ──────────────────────────────────
254
-
255
- export interface UpstreamBindingData {
256
- readonly outputs?: Readonly<Record<string, unknown>> | null;
257
- readonly stdout?: string;
258
- readonly stderr?: string;
259
- readonly normalizedOutput?: string | null;
260
- readonly exitCode?: number | null;
261
- }
262
-
263
- export type BindingInputResolution =
264
- | {
265
- readonly kind: 'ready';
266
- readonly inputs: Readonly<Record<string, unknown>>;
267
- readonly missingOptional: readonly string[];
268
- }
269
- | {
270
- readonly kind: 'blocked';
271
- readonly missingRequired: readonly string[];
272
- readonly ambiguous: readonly { input: string; producers: readonly string[] }[];
273
- readonly typeErrors: readonly { input: string; reason: string }[];
274
- readonly reason: string;
275
- };
276
-
277
- export function resolveTaskBindingInputs(
278
- task: Pick<TaskConfig, 'inputs'>,
279
- upstreamData: ReadonlyMap<string, UpstreamBindingData>,
280
- dependsOn: readonly string[],
281
- ): BindingInputResolution {
282
- const bindings = task.inputs;
283
- if (!bindings || Object.keys(bindings).length === 0) {
284
- return { kind: 'ready', inputs: {}, missingOptional: [] };
285
- }
286
-
287
- const inputs: Record<string, unknown> = {};
288
- const missingRequired: string[] = [];
289
- const missingOptional: string[] = [];
290
- const ambiguous: { input: string; producers: string[] }[] = [];
291
- const typeErrors: { input: string; reason: string }[] = [];
292
-
293
- for (const [name, binding] of Object.entries(bindings)) {
294
- let value: unknown;
295
- let present = false;
296
-
297
- if ('value' in binding) {
298
- value = binding.value;
299
- present = true;
300
- } else if (binding.from) {
301
- const found = resolveBindingSource(binding.from, upstreamData, dependsOn);
302
- if (found.kind === 'ambiguous') {
303
- ambiguous.push({ input: name, producers: found.producers });
304
- continue;
305
- }
306
- if (found.kind === 'hit') {
307
- value = found.value;
308
- present = true;
309
- }
310
- }
311
-
312
- if (!present && 'default' in binding) {
313
- value = binding.default;
314
- present = true;
315
- }
316
-
317
- if (!present || value === undefined || value === null) {
318
- if (binding.required === true) {
319
- missingRequired.push(name);
320
- } else {
321
- missingOptional.push(name);
322
- }
323
- continue;
324
- }
325
-
326
- const coerced = coerceBindingValue(binding, value);
327
- if (coerced.kind === 'error') {
328
- typeErrors.push({ input: name, reason: coerced.reason });
329
- continue;
330
- }
331
-
332
- inputs[name] = coerced.value;
333
- }
334
-
335
- if (missingRequired.length > 0 || ambiguous.length > 0 || typeErrors.length > 0) {
336
- const lines: string[] = [];
337
- if (missingRequired.length > 0) {
338
- lines.push(`missing required binding input(s): ${missingRequired.join(', ')}`);
339
- }
340
- for (const amb of ambiguous) {
341
- lines.push(
342
- `binding input "${amb.input}" is produced by multiple upstreams ` +
343
- `(${amb.producers.join(', ')}) — use "taskId.outputs.${amb.input}"`,
344
- );
345
- }
346
- for (const te of typeErrors) {
347
- lines.push(`binding input "${te.input}": ${te.reason}`);
348
- }
349
- return { kind: 'blocked', missingRequired, ambiguous, typeErrors, reason: lines.join('\n') };
350
- }
351
-
352
- return { kind: 'ready', inputs, missingOptional };
353
- }
354
-
355
- type BindingLookup =
356
- | { kind: 'hit'; producer: string; value: unknown }
357
- | { kind: 'miss' }
358
- | { kind: 'ambiguous'; producers: string[] };
359
-
360
- function resolveBindingSource(
361
- source: string,
362
- upstreamData: ReadonlyMap<string, UpstreamBindingData>,
363
- dependsOn: readonly string[],
364
- ): BindingLookup {
365
- if (source.startsWith('outputs.')) {
366
- return findOutputByName(source.slice('outputs.'.length), upstreamData, dependsOn);
367
- }
368
-
369
- const outputMarker = '.outputs.';
370
- const outputIdx = source.lastIndexOf(outputMarker);
371
- if (outputIdx > 0) {
372
- const upstreamId = source.slice(0, outputIdx);
373
- const outputName = source.slice(outputIdx + outputMarker.length);
374
- if (!dependsOn.includes(upstreamId)) return { kind: 'miss' };
375
- const upstream = upstreamData.get(upstreamId);
376
- if (upstream?.outputs && outputName in upstream.outputs) {
377
- return { kind: 'hit', producer: upstreamId, value: upstream.outputs[outputName] };
378
- }
379
- return { kind: 'miss' };
380
- }
381
-
382
- for (const field of ['stdout', 'stderr', 'normalizedOutput', 'exitCode'] as const) {
383
- const suffix = `.${field}`;
384
- if (!source.endsWith(suffix)) continue;
385
- const upstreamId = source.slice(0, -suffix.length);
386
- if (!dependsOn.includes(upstreamId)) return { kind: 'miss' };
387
- const upstream = upstreamData.get(upstreamId);
388
- if (!upstream) return { kind: 'miss' };
389
- const value = upstream[field];
390
- return value === undefined || value === null
391
- ? { kind: 'miss' }
392
- : { kind: 'hit', producer: upstreamId, value };
393
- }
394
-
395
- return { kind: 'miss' };
396
- }
397
-
398
- function findOutputByName(
399
- name: string,
400
- upstreamData: ReadonlyMap<string, UpstreamBindingData>,
401
- dependsOn: readonly string[],
402
- ): BindingLookup {
403
- const hits: { producer: string; value: unknown }[] = [];
404
- for (const upstreamId of dependsOn) {
405
- const upstream = upstreamData.get(upstreamId);
406
- if (upstream?.outputs && name in upstream.outputs) {
407
- hits.push({ producer: upstreamId, value: upstream.outputs[name] });
408
- }
409
- }
410
- if (hits.length === 0) return { kind: 'miss' };
411
- if (hits.length === 1) return { kind: 'hit', producer: hits[0]!.producer, value: hits[0]!.value };
412
- return { kind: 'ambiguous', producers: hits.map((h) => h.producer) };
413
- }
414
-
415
- type UpstreamLookup =
416
- | { kind: 'hit'; producer: string; value: unknown }
417
- | { kind: 'miss' }
418
- | { kind: 'ambiguous'; producers: string[] };
419
-
420
- function findUpstreamValue(
421
- port: PortDef,
422
- upstreamOutputs: ReadonlyMap<string, Readonly<Record<string, unknown>>>,
423
- dependsOn: readonly string[],
424
- ): UpstreamLookup {
425
- // Explicit fully-qualified binding: "taskId.portName"
426
- if (port.from && port.from.includes('.')) {
427
- const dot = port.from.lastIndexOf('.');
428
- const upstreamId = port.from.slice(0, dot);
429
- const portName = port.from.slice(dot + 1);
430
- const upstream = upstreamOutputs.get(upstreamId);
431
- if (upstream && portName in upstream) {
432
- return { kind: 'hit', producer: upstreamId, value: upstream[portName] };
433
- }
434
- return { kind: 'miss' };
435
- }
436
-
437
- // Name match (either explicit `from: "portName"` or defaulted to port.name)
438
- const key = port.from ?? port.name;
439
- const hits: { producer: string; value: unknown }[] = [];
440
- for (const upstreamId of dependsOn) {
441
- const upstream = upstreamOutputs.get(upstreamId);
442
- if (upstream && key in upstream) {
443
- hits.push({ producer: upstreamId, value: upstream[key] });
444
- }
445
- }
446
- if (hits.length === 0) return { kind: 'miss' };
447
- if (hits.length === 1) return { kind: 'hit', producer: hits[0]!.producer, value: hits[0]!.value };
448
- return { kind: 'ambiguous', producers: hits.map((h) => h.producer) };
449
- }
450
-
451
- // ─── Type coercion ────────────────────────────────────────────────────
452
-
453
- type Coercion = { kind: 'ok'; value: unknown } | { kind: 'error'; reason: string };
454
-
455
- function coerceValue(port: PortDef, raw: unknown): Coercion {
456
- switch (port.type) {
457
- case 'string': {
458
- if (typeof raw === 'string') return { kind: 'ok', value: raw };
459
- if (typeof raw === 'number' || typeof raw === 'boolean') {
460
- return { kind: 'ok', value: String(raw) };
461
- }
462
- return { kind: 'error', reason: `expected string, got ${describe(raw)}` };
463
- }
464
- case 'number': {
465
- if (typeof raw === 'number' && Number.isFinite(raw)) return { kind: 'ok', value: raw };
466
- if (typeof raw === 'string' && raw.trim() !== '') {
467
- const n = Number(raw);
468
- if (Number.isFinite(n)) return { kind: 'ok', value: n };
469
- }
470
- return { kind: 'error', reason: `expected number, got ${describe(raw)}` };
471
- }
472
- case 'boolean': {
473
- if (typeof raw === 'boolean') return { kind: 'ok', value: raw };
474
- if (raw === 'true' || raw === 'false') return { kind: 'ok', value: raw === 'true' };
475
- return { kind: 'error', reason: `expected boolean, got ${describe(raw)}` };
476
- }
477
- case 'enum': {
478
- const allowed = port.enum ?? [];
479
- if (allowed.length === 0) {
480
- return { kind: 'error', reason: 'enum port declared without "enum" values' };
481
- }
482
- const asStr = typeof raw === 'string' ? raw : String(raw);
483
- if (!allowed.includes(asStr)) {
484
- return {
485
- kind: 'error',
486
- reason: `value ${JSON.stringify(raw)} not in enum [${allowed.map((v) => JSON.stringify(v)).join(', ')}]`,
487
- };
488
- }
489
- return { kind: 'ok', value: asStr };
490
- }
491
- case 'json':
492
- // 'json' accepts anything that survives JSON round-trip. We don't
493
- // validate deeply — users opt into `json` precisely because they
494
- // want a free-form payload.
495
- return { kind: 'ok', value: raw };
496
- default: {
497
- // Exhaustiveness — TypeScript won't let us reach here unless a
498
- // new PortType is added without updating this switch. The return
499
- // satisfies the type checker; in practice the default branch is
500
- // dead code.
501
- const _exhaustive: never = port.type;
502
- void _exhaustive;
503
- return { kind: 'error', reason: `unknown port type "${String(port.type)}"` };
504
- }
505
- }
506
- }
507
-
508
- function coerceBindingValue(
509
- binding: { readonly type?: PortType; readonly enum?: readonly string[] },
510
- raw: unknown,
511
- ): Coercion {
512
- if (!binding.type) return { kind: 'ok', value: raw };
513
- return coerceValue(
514
- {
515
- name: 'binding',
516
- type: binding.type,
517
- ...(binding.enum ? { enum: binding.enum } : {}),
518
- },
519
- raw,
520
- );
521
- }
522
-
523
- function describe(v: unknown): string {
524
- if (v === null) return 'null';
525
- if (Array.isArray(v)) return 'array';
526
- return typeof v;
527
- }
528
-
529
- // ─── Output extraction ────────────────────────────────────────────────
530
-
531
- export interface ExtractResult {
532
- /** Coerced values keyed by port name. Ports that failed to resolve are absent. */
533
- readonly outputs: Readonly<Record<string, unknown>>;
534
- /**
535
- * Human-readable diagnostic describing what went wrong, if anything.
536
- * `null` when every declared output was resolved cleanly. The engine
537
- * appends this to stderr so the pipeline log explains why downstream
538
- * inputs are missing.
539
- */
540
- readonly diagnostic: string | null;
541
- }
542
-
543
- /**
544
- * Extract declared outputs from a terminated task's output streams.
545
- *
546
- * Strategy (v1 — intentionally dumb but predictable):
547
- * 1. Prefer `normalizedOutput` when provided (AI drivers populate this
548
- * with the canonical assistant message; it's much cleaner than raw
549
- * stdout, which often has JSONL event dumps). Fall back to stdout
550
- * otherwise.
551
- * 2. Scan from the end for the first non-empty line. If it parses as a
552
- * JSON object, use that as the source record.
553
- * 3. If (2) fails, try parsing the entire source as JSON (supports
554
- * commands that pretty-print with line breaks).
555
- * 4. For each declared output port, read the matching key and coerce
556
- * to the declared type. Coercion failures produce a diagnostic and
557
- * the port is absent from `outputs` (treated as missing downstream).
558
- *
559
- * When no declared outputs are present this returns an empty `outputs`
560
- * map and null diagnostic — the engine interprets that as "task has no
561
- * port contract".
562
- */
563
- export function extractTaskOutputs(
564
- ports: TaskPorts | undefined,
565
- stdout: string,
566
- normalizedOutput: string | null,
567
- ): ExtractResult {
568
- const decl = ports?.outputs;
569
- if (!decl || decl.length === 0) {
570
- return { outputs: {}, diagnostic: null };
571
- }
572
-
573
- const source = (normalizedOutput ?? '').length > 0 ? normalizedOutput! : stdout;
574
- const record = parseJsonTail(source);
575
- if (record === null) {
576
- return {
577
- outputs: {},
578
- diagnostic:
579
- 'outputs: could not find a final-line JSON object in task output — declared outputs are unresolved',
580
- };
581
- }
582
-
583
- const outputs: Record<string, unknown> = {};
584
- const warnings: string[] = [];
585
- for (const port of decl) {
586
- if (!(port.name in record)) {
587
- warnings.push(`missing key "${port.name}"`);
588
- continue;
589
- }
590
- const coerced = coerceValue(port, record[port.name]);
591
- if (coerced.kind === 'error') {
592
- warnings.push(`"${port.name}": ${coerced.reason}`);
593
- continue;
594
- }
595
- outputs[port.name] = coerced.value;
596
- }
597
-
598
- const diagnostic = warnings.length > 0 ? `outputs: ${warnings.join('; ')}` : null;
599
- return { outputs, diagnostic };
600
- }
601
-
602
- export function extractTaskBindingOutputs(
603
- bindings: TaskOutputBindings | undefined,
604
- stdout: string,
605
- stderr: string,
606
- normalizedOutput: string | null,
607
- ): ExtractResult {
608
- if (!bindings || Object.keys(bindings).length === 0) {
609
- return { outputs: {}, diagnostic: null };
610
- }
611
-
612
- const outputs: Record<string, unknown> = {};
613
- const missing: string[] = [];
614
- let record: Record<string, unknown> | null | undefined;
615
-
616
- for (const [name, binding] of Object.entries(bindings)) {
617
- let value: unknown;
618
- let present = false;
619
-
620
- if ('value' in binding) {
621
- value = binding.value;
622
- present = true;
623
- } else {
624
- const source = binding.from ?? `json.${name}`;
625
- if (source === 'stdout') {
626
- value = stdout;
627
- present = true;
628
- } else if (source === 'stderr') {
629
- value = stderr;
630
- present = true;
631
- } else if (source === 'normalizedOutput') {
632
- if (normalizedOutput !== null) {
633
- value = normalizedOutput;
634
- present = true;
635
- }
636
- } else if (source.startsWith('json.')) {
637
- if (record === undefined) {
638
- const jsonSource = (normalizedOutput ?? '').length > 0 ? normalizedOutput! : stdout;
639
- record = parseJsonTail(jsonSource);
640
- }
641
- const key = source.slice('json.'.length);
642
- if (record && key in record) {
643
- value = record[key];
644
- present = true;
645
- }
646
- }
647
- }
648
-
649
- if (!present && 'default' in binding) {
650
- value = binding.default;
651
- present = true;
652
- }
653
-
654
- if (!present || value === undefined || value === null) {
655
- missing.push(name);
656
- continue;
657
- }
658
-
659
- const coerced = coerceBindingValue(binding, value);
660
- if (coerced.kind === 'error') {
661
- missing.push(`"${name}": ${coerced.reason}`);
662
- continue;
663
- }
664
-
665
- outputs[name] = coerced.value;
666
- }
667
-
668
- return {
669
- outputs,
670
- diagnostic: missing.length > 0 ? `outputs: unresolved binding output(s): ${missing.join(', ')}` : null,
671
- };
672
- }
673
-
674
- /**
675
- * Find the last non-empty line that parses as a JSON object. Returns
676
- * null when no such line exists. Also tries the whole source as a
677
- * fallback — covers pretty-printed JSON that spans multiple lines.
678
- */
679
- function parseJsonTail(source: string): Record<string, unknown> | null {
680
- const lines = source.split(/\r?\n/);
681
- for (let i = lines.length - 1; i >= 0; i--) {
682
- const line = lines[i]!.trim();
683
- if (!line) continue;
684
- const parsed = safeParseJson(line);
685
- if (parsed !== null) return parsed;
686
- // First non-empty line from the tail — if it didn't parse, fall through
687
- // to the whole-source attempt below rather than scanning further up
688
- // (otherwise a prior human-readable line would be silently picked up
689
- // if it happened to contain `{...}` fragments).
690
- break;
691
- }
692
- return safeParseJson(source.trim());
693
- }
694
-
695
- function safeParseJson(candidate: string): Record<string, unknown> | null {
696
- if (!candidate.startsWith('{')) return null;
697
- try {
698
- const parsed = JSON.parse(candidate);
699
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
700
- return parsed as Record<string, unknown>;
701
- }
702
- } catch {
703
- /* not JSON */
704
- }
705
- return null;
706
- }
707
-
708
- // ─── Prompt-task port inference ───────────────────────────────────────
709
- //
710
- // Prompt Tasks have no declared ports. The engine calls `inferPromptPorts`
711
- // to synthesize one from the Task's direct DAG neighbors:
712
- //
713
- // - **inputs** are taken from the declared `outputs` of every direct
714
- // upstream Command Task. The union of names becomes the Prompt's
715
- // inferred inputs. Upstream Prompt neighbors contribute nothing —
716
- // information flows between Prompts as free text through
717
- // `continue_from` / normalizedOutput, not through port values.
718
- //
719
- // - **outputs** are taken from the declared `inputs` of every direct
720
- // downstream Command Task. The union of names becomes the Prompt's
721
- // inferred outputs, which drives the `[Output Format]` block that
722
- // tells the LLM what JSON to emit. Downstream Prompt neighbors
723
- // contribute nothing (they just consume free text).
724
- //
725
- // Collisions:
726
- //
727
- // - **Input collision**: two upstream Commands both export an output
728
- // named `city`. Command→Command would let a downstream add
729
- // `from: taskId.city` to pick one; Prompt Tasks have no port
730
- // declarations and therefore no escape hatch. The only fix is to
731
- // rename on the Command side. We surface this as an `inputConflicts`
732
- // entry; the engine blocks the task with that reason.
733
- //
734
- // - **Output collision with compatible types** (e.g. both downstreams
735
- // ask for `date: string`) → merged into a single inferred output.
736
- // Compatibility is determined by `type` and `enum` only; `description`
737
- // differences are ignored. The Prompt produces one `date`; both
738
- // downstreams consume it.
739
- //
740
- // - **Output collision with incompatible types** (e.g. one downstream
741
- // wants `date: string`, another `date: number`) → no single LLM
742
- // emission can satisfy both. Surfaced as `outputConflicts`; engine
743
- // blocks the task. User must rename on one side.
744
-
745
- export interface PromptUpstreamNeighbor {
746
- readonly taskId: string;
747
- /**
748
- * Declared outputs of the upstream task. `undefined` signals that the
749
- * neighbor is a Prompt Task (no structured contribution) or otherwise
750
- * has no outputs to offer. The inference logic treats `undefined` and
751
- * an empty array the same way — neither contributes ports.
752
- */
753
- readonly outputs: readonly PortDef[] | undefined;
754
- }
755
-
756
- export interface PromptDownstreamNeighbor {
757
- readonly taskId: string;
758
- /**
759
- * Declared inputs of the downstream task. `undefined` signals a
760
- * Prompt-Task neighbor or a Command Task without declared inputs.
761
- * Either way it contributes no ports to the inferred output contract.
762
- */
763
- readonly inputs: readonly PortDef[] | undefined;
764
- }
765
-
766
- export interface PromptPortConflict {
767
- readonly portName: string;
768
- readonly producers: readonly { readonly taskId: string; readonly type: PortType }[];
769
- /** Pre-formatted human-readable reason for logs / stderr. */
770
- readonly reason: string;
771
- }
772
-
773
- export interface PromptPortInference {
774
- /**
775
- * Synthetic `TaskPorts` the engine feeds into the resolve / substitute /
776
- * render / extract helpers, exactly as if the Prompt had declared these
777
- * ports itself. Empty arrays are preserved as absent so downstream code
778
- * paths treat "no ports" uniformly (see engine.ts's existing
779
- * `task.ports?.outputs && task.ports.outputs.length > 0` guard).
780
- */
781
- readonly ports: TaskPorts;
782
- readonly inputConflicts: readonly PromptPortConflict[];
783
- readonly outputConflicts: readonly PromptPortConflict[];
784
- }
785
-
786
- /**
787
- * Derive the effective `TaskPorts` for a Prompt Task from its direct
788
- * neighbors. See the module-level "Prompt-task port inference" comment
789
- * for the full contract.
790
- *
791
- * Pure function — no side effects, safe to call from the CLI, editor
792
- * preview, and engine hot path alike.
793
- */
794
- export function inferPromptPorts(input: {
795
- readonly upstreams: readonly PromptUpstreamNeighbor[];
796
- readonly downstreams: readonly PromptDownstreamNeighbor[];
797
- }): PromptPortInference {
798
- const { upstreams, downstreams } = input;
799
-
800
- // ─── Inputs: union of upstream-Command outputs ─────────────────────
801
- //
802
- // Walk every upstream in DAG order. First occurrence of a name wins
803
- // (for the synthesized port shape used to resolve values). Subsequent
804
- // occurrences under the same name become an `inputConflicts` entry —
805
- // the engine blocks the task because a Prompt can't disambiguate.
806
- const inputsByName = new Map<string, { port: PortDef; firstProducer: string }>();
807
- const inputCollisionSources = new Map<string, { taskId: string; type: PortType }[]>();
808
-
809
- for (const upstream of upstreams) {
810
- if (!upstream.outputs || upstream.outputs.length === 0) continue;
811
- for (const out of upstream.outputs) {
812
- const prior = inputsByName.get(out.name);
813
- if (!prior) {
814
- // Copy the shape verbatim but drop output-only fields and force
815
- // `required: true`. Prompt-task inferred inputs are required by
816
- // default: the LLM wouldn't be getting a real-world value
817
- // otherwise, and substituting an empty string silently is the
818
- // same kind of bug we already reject elsewhere.
819
- inputsByName.set(out.name, {
820
- port: {
821
- name: out.name,
822
- type: out.type,
823
- ...(out.description ? { description: out.description } : {}),
824
- ...(out.enum ? { enum: [...out.enum] } : {}),
825
- required: true,
826
- },
827
- firstProducer: upstream.taskId,
828
- });
829
- continue;
830
- }
831
- // Collision — seed the source list with the first producer too so
832
- // the emitted conflict lists *all* contributing producers.
833
- const list = inputCollisionSources.get(out.name) ?? [
834
- { taskId: prior.firstProducer, type: prior.port.type },
835
- ];
836
- list.push({ taskId: upstream.taskId, type: out.type });
837
- inputCollisionSources.set(out.name, list);
838
- }
839
- }
840
-
841
- const inputConflicts: PromptPortConflict[] = [];
842
- for (const [portName, producers] of inputCollisionSources) {
843
- const producerList = producers.map((p) => p.taskId).join(', ');
844
- inputConflicts.push({
845
- portName,
846
- producers,
847
- reason:
848
- `input "${portName}" is produced by multiple upstream Commands (${producerList}) — ` +
849
- `Prompt tasks cannot disambiguate (no explicit "from:" binding). ` +
850
- `Rename the output on one of the upstream Commands.`,
851
- });
852
- }
853
-
854
- // ─── Outputs: union of downstream-Command inputs ───────────────────
855
- //
856
- // Compatible repeats merge (preserve first-encountered shape; prefer
857
- // required when any downstream requires it). Incompatible repeats
858
- // (different type, different enum set) go to `outputConflicts`.
859
- const outputsByName = new Map<string, { port: PortDef; firstConsumer: string }>();
860
- const outputCollisionSources = new Map<string, { taskId: string; type: PortType }[]>();
861
-
862
- for (const downstream of downstreams) {
863
- if (!downstream.inputs || downstream.inputs.length === 0) continue;
864
- for (const inp of downstream.inputs) {
865
- const prior = outputsByName.get(inp.name);
866
- if (!prior) {
867
- // Outputs drop input-only fields (required, default, from).
868
- outputsByName.set(inp.name, {
869
- port: {
870
- name: inp.name,
871
- type: inp.type,
872
- ...(inp.description ? { description: inp.description } : {}),
873
- ...(inp.enum ? { enum: [...inp.enum] } : {}),
874
- },
875
- firstConsumer: downstream.taskId,
876
- });
877
- continue;
878
- }
879
- if (portsAreCompatible(prior.port, inp)) continue; // merge silently
880
- const list = outputCollisionSources.get(inp.name) ?? [
881
- { taskId: prior.firstConsumer, type: prior.port.type },
882
- ];
883
- list.push({ taskId: downstream.taskId, type: inp.type });
884
- outputCollisionSources.set(inp.name, list);
885
- }
886
- }
887
-
888
- const outputConflicts: PromptPortConflict[] = [];
889
- for (const [portName, producers] of outputCollisionSources) {
890
- const consumerList = producers.map((p) => `${p.taskId} (${p.type})`).join(', ');
891
- outputConflicts.push({
892
- portName,
893
- producers,
894
- reason:
895
- `output "${portName}" has conflicting type requirements across downstream Commands ` +
896
- `(${consumerList}) — a single LLM emission cannot satisfy both. ` +
897
- `Rename the input on one of the downstream Commands.`,
898
- });
899
- }
900
-
901
- const inferredInputs = [...inputsByName.values()].map((e) => e.port);
902
- const inferredOutputs = [...outputsByName.values()].map((e) => e.port);
903
-
904
- const ports: TaskPorts = {
905
- ...(inferredInputs.length > 0 ? { inputs: inferredInputs } : {}),
906
- ...(inferredOutputs.length > 0 ? { outputs: inferredOutputs } : {}),
907
- };
908
- return { ports, inputConflicts, outputConflicts };
909
- }
910
-
911
- /**
912
- * Two ports with the same name are compatible if they agree on `type`
913
- * and, for enum ports, on the enum value set. Descriptions and
914
- * required/default flags are deliberately ignored — they don't affect
915
- * whether a single value can satisfy both consumers.
916
- */
917
- function portsAreCompatible(a: PortDef, b: PortDef): boolean {
918
- if (a.type !== b.type) return false;
919
- if (a.type === 'enum') {
920
- const aEnum = [...(a.enum ?? [])].sort().join('');
921
- const bEnum = [...(b.enum ?? [])].sort().join('');
922
- if (aEnum !== bEnum) return false;
923
- }
924
- return true;
925
- }