@tagma/sdk 0.7.4 → 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.
- package/README.md +60 -53
- package/dist/completions/file-exists.js +1 -1
- package/dist/completions/file-exists.js.map +1 -1
- package/dist/completions/output-check.d.ts.map +1 -1
- package/dist/completions/output-check.js +17 -4
- package/dist/completions/output-check.js.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/dataflow.d.ts +3 -0
- package/dist/dataflow.d.ts.map +1 -0
- package/dist/dataflow.js +2 -0
- package/dist/dataflow.js.map +1 -0
- package/dist/drivers/opencode.d.ts.map +1 -1
- package/dist/drivers/opencode.js +23 -71
- package/dist/drivers/opencode.js.map +1 -1
- package/dist/middlewares/static-context.d.ts.map +1 -1
- package/dist/middlewares/static-context.js +1 -2
- package/dist/middlewares/static-context.js.map +1 -1
- package/dist/pipeline-runner.d.ts.map +1 -1
- package/dist/pipeline-runner.js +2 -2
- package/dist/pipeline-runner.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +3 -4
- package/dist/schema.js.map +1 -1
- package/dist/triggers/file.d.ts.map +1 -1
- package/dist/triggers/file.js +1 -2
- package/dist/triggers/file.js.map +1 -1
- package/dist/triggers/manual.d.ts.map +1 -1
- package/dist/triggers/manual.js +1 -2
- package/dist/triggers/manual.js.map +1 -1
- package/dist/types.d.ts +1 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -12
- package/dist/types.js.map +1 -1
- package/dist/utils-api.d.ts +1 -1
- package/dist/utils-api.d.ts.map +1 -1
- package/dist/utils-api.js +1 -1
- package/dist/utils-api.js.map +1 -1
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +5 -12
- package/dist/validate-raw.js.map +1 -1
- package/package.json +11 -24
- package/dist/adapters/stdin-approval.d.ts +0 -2
- package/dist/adapters/stdin-approval.d.ts.map +0 -1
- package/dist/adapters/stdin-approval.js +0 -2
- package/dist/adapters/stdin-approval.js.map +0 -1
- package/dist/adapters/websocket-approval.d.ts +0 -2
- package/dist/adapters/websocket-approval.d.ts.map +0 -1
- package/dist/adapters/websocket-approval.js +0 -2
- package/dist/adapters/websocket-approval.js.map +0 -1
- package/dist/core/dataflow.d.ts +0 -23
- package/dist/core/dataflow.d.ts.map +0 -1
- package/dist/core/dataflow.js +0 -99
- package/dist/core/dataflow.js.map +0 -1
- package/dist/core/log-prune.d.ts +0 -16
- package/dist/core/log-prune.d.ts.map +0 -1
- package/dist/core/log-prune.js +0 -34
- package/dist/core/log-prune.js.map +0 -1
- package/dist/core/preflight.d.ts +0 -13
- package/dist/core/preflight.d.ts.map +0 -1
- package/dist/core/preflight.js +0 -61
- package/dist/core/preflight.js.map +0 -1
- package/dist/core/run-context.d.ts +0 -55
- package/dist/core/run-context.d.ts.map +0 -1
- package/dist/core/run-context.js +0 -158
- package/dist/core/run-context.js.map +0 -1
- package/dist/core/run-state.d.ts +0 -25
- package/dist/core/run-state.d.ts.map +0 -1
- package/dist/core/run-state.js +0 -93
- package/dist/core/run-state.js.map +0 -1
- package/dist/core/scheduler.d.ts +0 -13
- package/dist/core/scheduler.d.ts.map +0 -1
- package/dist/core/scheduler.js +0 -35
- package/dist/core/scheduler.js.map +0 -1
- package/dist/core/task-executor.d.ts +0 -13
- package/dist/core/task-executor.d.ts.map +0 -1
- package/dist/core/task-executor.js +0 -610
- package/dist/core/task-executor.js.map +0 -1
- package/dist/core/trigger-errors.d.ts +0 -9
- package/dist/core/trigger-errors.d.ts.map +0 -1
- package/dist/core/trigger-errors.js +0 -15
- package/dist/core/trigger-errors.js.map +0 -1
- package/dist/dag.d.ts +0 -45
- package/dist/dag.d.ts.map +0 -1
- package/dist/dag.js +0 -177
- package/dist/dag.js.map +0 -1
- package/dist/hooks.d.ts +0 -73
- package/dist/hooks.d.ts.map +0 -1
- package/dist/hooks.js +0 -106
- package/dist/hooks.js.map +0 -1
- package/dist/pipeline-definition.d.ts +0 -3
- package/dist/pipeline-definition.d.ts.map +0 -1
- package/dist/pipeline-definition.js +0 -4
- package/dist/pipeline-definition.js.map +0 -1
- package/dist/ports.d.ts +0 -196
- package/dist/ports.d.ts.map +0 -1
- package/dist/ports.js +0 -688
- package/dist/ports.js.map +0 -1
- package/dist/prompt-doc.d.ts +0 -70
- package/dist/prompt-doc.d.ts.map +0 -1
- package/dist/prompt-doc.js +0 -154
- package/dist/prompt-doc.js.map +0 -1
- package/dist/registry.d.ts +0 -3
- package/dist/registry.d.ts.map +0 -1
- package/dist/registry.js +0 -2
- package/dist/registry.js.map +0 -1
- package/dist/task-ref.d.ts +0 -55
- package/dist/task-ref.d.ts.map +0 -1
- package/dist/task-ref.js +0 -103
- package/dist/task-ref.js.map +0 -1
- package/dist/utils.d.ts +0 -13
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js +0 -177
- package/dist/utils.js.map +0 -1
- package/src/adapters/stdin-approval.ts +0 -1
- package/src/adapters/websocket-approval.ts +0 -1
- package/src/approval.ts +0 -9
- package/src/bootstrap.ts +0 -55
- package/src/completions/exit-code.ts +0 -34
- package/src/completions/file-exists.ts +0 -66
- package/src/completions/output-check.test.ts +0 -50
- package/src/completions/output-check.ts +0 -92
- package/src/config-ops.test.ts +0 -70
- package/src/config-ops.ts +0 -328
- package/src/config.ts +0 -26
- package/src/core/dataflow.test.ts +0 -166
- package/src/core/dataflow.ts +0 -161
- package/src/core/log-prune.test.ts +0 -58
- package/src/core/log-prune.ts +0 -43
- package/src/core/preflight.test.ts +0 -49
- package/src/core/preflight.ts +0 -89
- package/src/core/run-context.test.ts +0 -291
- package/src/core/run-context.ts +0 -211
- package/src/core/run-state.test.ts +0 -98
- package/src/core/run-state.ts +0 -122
- package/src/core/scheduler.test.ts +0 -83
- package/src/core/scheduler.ts +0 -42
- package/src/core/task-executor.ts +0 -752
- package/src/core/trigger-errors.ts +0 -15
- package/src/dag.test.ts +0 -56
- package/src/dag.ts +0 -245
- package/src/drivers/opencode.ts +0 -410
- package/src/engine-ports-mixed.test.ts +0 -182
- package/src/engine-ports.test.ts +0 -210
- package/src/engine-task-type.test.ts +0 -56
- package/src/engine.ts +0 -32
- package/src/hooks.ts +0 -193
- package/src/index.ts +0 -31
- package/src/logger.ts +0 -2
- package/src/middlewares/static-context.ts +0 -49
- package/src/package-split.test.ts +0 -15
- package/src/pipeline-definition.ts +0 -5
- package/src/pipeline-runner.test.ts +0 -144
- package/src/pipeline-runner.ts +0 -194
- package/src/plugin-registry.test.ts +0 -448
- package/src/plugins.ts +0 -21
- package/src/ports.test.ts +0 -678
- package/src/ports.ts +0 -925
- package/src/prompt-doc.test.ts +0 -174
- package/src/prompt-doc.ts +0 -169
- package/src/registry.ts +0 -7
- package/src/runner.test.ts +0 -142
- package/src/runner.ts +0 -1
- package/src/runtime/adapters/stdin-approval.ts +0 -1
- package/src/runtime/adapters/websocket-approval.ts +0 -1
- package/src/runtime/bun-process-runner.ts +0 -1
- package/src/runtime-adapters.test.ts +0 -10
- package/src/runtime.ts +0 -12
- package/src/schema-ports.test.ts +0 -172
- package/src/schema.test.ts +0 -213
- package/src/schema.ts +0 -379
- package/src/tagma.test.ts +0 -317
- package/src/tagma.ts +0 -67
- package/src/task-ref.test.ts +0 -401
- package/src/task-ref.ts +0 -121
- package/src/triggers/file.test.ts +0 -79
- package/src/triggers/file.ts +0 -131
- package/src/triggers/manual.ts +0 -86
- package/src/types.ts +0 -18
- package/src/utils-api.ts +0 -8
- package/src/utils.test.ts +0 -28
- package/src/utils.ts +0 -203
- package/src/validate-raw-plugin-types.test.ts +0 -60
- package/src/validate-raw-ports.test.ts +0 -136
- package/src/validate-raw.ts +0 -852
- package/src/yaml-compiler.test.ts +0 -108
- package/src/yaml-compiler.ts +0 -110
- 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
|
-
});
|