@tagma/sdk 0.6.11 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -3
- package/dist/config-ops.d.ts +4 -2
- package/dist/config-ops.d.ts.map +1 -1
- package/dist/config-ops.js +16 -2
- package/dist/config-ops.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +75 -27
- package/dist/engine.js.map +1 -1
- package/dist/ports.d.ts +23 -1
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +160 -0
- package/dist/ports.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +7 -3
- package/dist/schema.js.map +1 -1
- package/dist/sdk.d.ts +2 -2
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +1 -1
- package/dist/sdk.js.map +1 -1
- package/dist/validate-raw.js +118 -0
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -2
- package/src/config-ops.ts +12 -2
- package/src/engine-ports.test.ts +66 -0
- package/src/engine-task-type.test.ts +56 -0
- package/src/engine.ts +100 -26
- package/src/ports.test.ts +127 -0
- package/src/ports.ts +224 -1
- package/src/schema-ports.test.ts +86 -0
- package/src/schema.ts +7 -3
- package/src/sdk.ts +4 -0
- package/src/validate-raw-ports.test.ts +66 -0
- package/src/validate-raw.ts +137 -0
package/src/engine.ts
CHANGED
|
@@ -31,11 +31,14 @@ import {
|
|
|
31
31
|
renderOutputSchemaBlock,
|
|
32
32
|
} from './prompt-doc';
|
|
33
33
|
import {
|
|
34
|
+
extractTaskBindingOutputs,
|
|
34
35
|
extractTaskOutputs,
|
|
35
36
|
inferPromptPorts,
|
|
37
|
+
resolveTaskBindingInputs,
|
|
36
38
|
resolveTaskInputs,
|
|
37
39
|
substituteInputs,
|
|
38
40
|
} from './ports';
|
|
41
|
+
import type { UpstreamBindingData } from './ports';
|
|
39
42
|
import type { TaskPorts } from './types';
|
|
40
43
|
import {
|
|
41
44
|
executeHook,
|
|
@@ -70,6 +73,18 @@ export class TriggerTimeoutError extends Error {
|
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
function isPromptTaskConfig(
|
|
77
|
+
task: TaskConfig,
|
|
78
|
+
): task is TaskConfig & { readonly prompt: string; readonly command?: undefined } {
|
|
79
|
+
return task.prompt !== undefined && task.command === undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isCommandTaskConfig(
|
|
83
|
+
task: TaskConfig,
|
|
84
|
+
): task is TaskConfig & { readonly command: string; readonly prompt?: undefined } {
|
|
85
|
+
return task.command !== undefined && task.prompt === undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
// ═══ Preflight Validation ═══
|
|
74
89
|
|
|
75
90
|
function preflight(config: PipelineConfig, dag: Dag, registry: PluginRegistry): void {
|
|
@@ -81,7 +96,7 @@ function preflight(config: PipelineConfig, dag: Dag, registry: PluginRegistry):
|
|
|
81
96
|
const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
|
|
82
97
|
|
|
83
98
|
// Pure command tasks don't use a driver — skip driver registration check.
|
|
84
|
-
const isCommandOnly = task
|
|
99
|
+
const isCommandOnly = isCommandTaskConfig(task);
|
|
85
100
|
|
|
86
101
|
if (!isCommandOnly && !registry.hasHandler('drivers', driverName)) {
|
|
87
102
|
errors.push(`Task "${node.taskId}": driver "${driverName}" not registered`);
|
|
@@ -319,7 +334,7 @@ export async function runPipeline(
|
|
|
319
334
|
log.section('DAG topology');
|
|
320
335
|
for (const [id, node] of dag.nodes) {
|
|
321
336
|
const deps = node.dependsOn.length ? node.dependsOn.join(', ') : '(root)';
|
|
322
|
-
const kind = node.task
|
|
337
|
+
const kind = isPromptTaskConfig(node.task) ? 'ai' : 'cmd';
|
|
323
338
|
log.quiet(` • ${id} [${kind}] track=${node.track.id} deps=[${deps}]`);
|
|
324
339
|
}
|
|
325
340
|
log.quiet('');
|
|
@@ -388,13 +403,12 @@ export async function runPipeline(
|
|
|
388
403
|
|
|
389
404
|
const sessionMap = new Map<string, string>();
|
|
390
405
|
const normalizedMap = new Map<string, string>();
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
// downstream tasks via `resolveTaskInputs` to assemble their inputs.
|
|
394
|
-
// Kept separate from normalizedMap so the continue_from text handoff
|
|
395
|
-
// and the typed-port data handoff don't pollute each other — they
|
|
396
|
-
// solve different problems and have different lifetimes.
|
|
406
|
+
// Published structured outputs keyed by fully-qualified task id.
|
|
407
|
+
// Includes lightweight task.outputs and strict ports.outputs.
|
|
397
408
|
const outputValuesMap = new Map<string, Readonly<Record<string, unknown>>>();
|
|
409
|
+
// Full upstream result data for lightweight input bindings such as
|
|
410
|
+
// `taskId.stdout` and `taskId.outputs.name`.
|
|
411
|
+
const bindingDataMap = new Map<string, UpstreamBindingData>();
|
|
398
412
|
// Resolved port inputs keyed by fully-qualified task id. Written once,
|
|
399
413
|
// just before a task runs, so every subsequent task_update event can
|
|
400
414
|
// echo them to the UI without re-resolving.
|
|
@@ -577,7 +591,7 @@ export async function runPipeline(
|
|
|
577
591
|
return {
|
|
578
592
|
id: taskId,
|
|
579
593
|
name: state.config.name,
|
|
580
|
-
type: state.config
|
|
594
|
+
type: isPromptTaskConfig(state.config) ? 'ai' : 'command',
|
|
581
595
|
status: state.status,
|
|
582
596
|
exit_code: state.result?.exitCode ?? null,
|
|
583
597
|
duration_ms: state.result?.durationMs ?? null,
|
|
@@ -614,7 +628,7 @@ export async function runPipeline(
|
|
|
614
628
|
log.section(`Task ${taskId}`, taskId);
|
|
615
629
|
log.debug(
|
|
616
630
|
`[task:${taskId}]`,
|
|
617
|
-
`type=${task
|
|
631
|
+
`type=${isPromptTaskConfig(task) ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`,
|
|
618
632
|
);
|
|
619
633
|
|
|
620
634
|
// 1. Check dependencies
|
|
@@ -781,7 +795,7 @@ export async function runPipeline(
|
|
|
781
795
|
// pipeline the Command path uses. Collisions that a Prompt can't
|
|
782
796
|
// disambiguate (same input name on two upstreams, incompatible
|
|
783
797
|
// downstream output types) block the task with a clear message.
|
|
784
|
-
const isPromptTask = task
|
|
798
|
+
const isPromptTask = isPromptTaskConfig(task);
|
|
785
799
|
let effectivePorts: TaskPorts | undefined = task.ports;
|
|
786
800
|
let promptInferenceBlockReason: string | null = null;
|
|
787
801
|
|
|
@@ -789,7 +803,7 @@ export async function runPipeline(
|
|
|
789
803
|
const inference = inferPromptPorts({
|
|
790
804
|
upstreams: node.dependsOn.map((upstreamId) => {
|
|
791
805
|
const upstream = dag.nodes.get(upstreamId);
|
|
792
|
-
const isUpstreamCommand =
|
|
806
|
+
const isUpstreamCommand = upstream ? isCommandTaskConfig(upstream.task) : false;
|
|
793
807
|
return {
|
|
794
808
|
taskId: upstreamId,
|
|
795
809
|
outputs: isUpstreamCommand ? upstream?.task.ports?.outputs : undefined,
|
|
@@ -797,7 +811,7 @@ export async function runPipeline(
|
|
|
797
811
|
}),
|
|
798
812
|
downstreams: (directDownstreams.get(taskId) ?? []).map((downstreamId) => {
|
|
799
813
|
const downstream = dag.nodes.get(downstreamId);
|
|
800
|
-
const isDownstreamCommand =
|
|
814
|
+
const isDownstreamCommand = downstream ? isCommandTaskConfig(downstream.task) : false;
|
|
801
815
|
return {
|
|
802
816
|
taskId: downstreamId,
|
|
803
817
|
inputs: isDownstreamCommand ? downstream?.task.ports?.inputs : undefined,
|
|
@@ -844,6 +858,44 @@ export async function runPipeline(
|
|
|
844
858
|
return;
|
|
845
859
|
}
|
|
846
860
|
|
|
861
|
+
const bindingResolution = resolveTaskBindingInputs(task, bindingDataMap, node.dependsOn);
|
|
862
|
+
if (bindingResolution.kind === 'blocked') {
|
|
863
|
+
log.error(
|
|
864
|
+
`[task:${taskId}]`,
|
|
865
|
+
`blocked — cannot resolve task input bindings:\n${bindingResolution.reason}`,
|
|
866
|
+
);
|
|
867
|
+
state.result = {
|
|
868
|
+
exitCode: -1,
|
|
869
|
+
stdout: '',
|
|
870
|
+
stderr: `[engine] task input binding resolution failed:\n${bindingResolution.reason}`,
|
|
871
|
+
stdoutPath: null,
|
|
872
|
+
stderrPath: null,
|
|
873
|
+
durationMs: 0,
|
|
874
|
+
sessionId: null,
|
|
875
|
+
normalizedOutput: null,
|
|
876
|
+
failureKind: 'spawn_error',
|
|
877
|
+
outputs: null,
|
|
878
|
+
};
|
|
879
|
+
state.finishedAt = nowISO();
|
|
880
|
+
setTaskStatus(taskId, 'blocked');
|
|
881
|
+
try {
|
|
882
|
+
await fireHook(taskId, 'task_failure');
|
|
883
|
+
} catch (hookErr) {
|
|
884
|
+
log.error(
|
|
885
|
+
`[task:${taskId}]`,
|
|
886
|
+
`hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
if (getOnFailure(taskId) === 'stop_all') applyStopAll(node.track.id);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (bindingResolution.missingOptional.length > 0) {
|
|
893
|
+
log.debug(
|
|
894
|
+
`[task:${taskId}]`,
|
|
895
|
+
`optional input bindings unresolved (empty in placeholders): ${bindingResolution.missingOptional.join(', ')}`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
847
899
|
// Feed effective ports into `resolveTaskInputs` by shallow-cloning
|
|
848
900
|
// the task. Prompt tasks get the inferred ports; Command tasks are
|
|
849
901
|
// unchanged (effectivePorts === task.ports).
|
|
@@ -880,7 +932,7 @@ export async function runPipeline(
|
|
|
880
932
|
if (getOnFailure(taskId) === 'stop_all') applyStopAll(node.track.id);
|
|
881
933
|
return;
|
|
882
934
|
}
|
|
883
|
-
const resolvedInputs = inputResolution.inputs;
|
|
935
|
+
const resolvedInputs = { ...bindingResolution.inputs, ...inputResolution.inputs };
|
|
884
936
|
resolvedInputsMap.set(taskId, resolvedInputs);
|
|
885
937
|
if (inputResolution.missingOptional.length > 0) {
|
|
886
938
|
log.debug(
|
|
@@ -902,7 +954,7 @@ export async function runPipeline(
|
|
|
902
954
|
setTaskStatus(taskId, 'running');
|
|
903
955
|
log.info(
|
|
904
956
|
`[task:${taskId}]`,
|
|
905
|
-
task
|
|
957
|
+
isCommandTaskConfig(task) ? `running: ${task.command}` : `running (driver task)`,
|
|
906
958
|
);
|
|
907
959
|
|
|
908
960
|
// File-only: resolved config for this task
|
|
@@ -940,7 +992,7 @@ export async function runPipeline(
|
|
|
940
992
|
stderrPath,
|
|
941
993
|
};
|
|
942
994
|
|
|
943
|
-
if (task
|
|
995
|
+
if (isCommandTaskConfig(task)) {
|
|
944
996
|
// Substitute `{{inputs.X}}` placeholders into the command
|
|
945
997
|
// string. Tasks with no declared inputs always produce the same
|
|
946
998
|
// string back (no placeholders to match). Unresolved references
|
|
@@ -1168,16 +1220,29 @@ export async function runPipeline(
|
|
|
1168
1220
|
terminalStatus = 'success';
|
|
1169
1221
|
}
|
|
1170
1222
|
|
|
1171
|
-
// Extract declared
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
// shouldn't receive partial data.
|
|
1175
|
-
// when the task has no declared outputs, so this is free for
|
|
1176
|
-
// pre-ports tasks. Diagnostics are appended to stderr so users
|
|
1177
|
-
// see *why* a downstream input is missing without having to dig
|
|
1178
|
-
// through driver-specific logs.
|
|
1223
|
+
// Extract declared outputs from the task's output stream. Only
|
|
1224
|
+
// meaningful on success — a failed task's output is whatever the
|
|
1225
|
+
// child happened to emit before exiting, and downstream tasks
|
|
1226
|
+
// shouldn't receive partial data.
|
|
1179
1227
|
let extractedOutputs: Readonly<Record<string, unknown>> | null = null;
|
|
1180
1228
|
if (terminalStatus === 'success') {
|
|
1229
|
+
const looseExtraction = extractTaskBindingOutputs(
|
|
1230
|
+
task.outputs,
|
|
1231
|
+
result.stdout,
|
|
1232
|
+
result.stderr,
|
|
1233
|
+
result.normalizedOutput,
|
|
1234
|
+
);
|
|
1235
|
+
if (task.outputs && Object.keys(task.outputs).length > 0) {
|
|
1236
|
+
extractedOutputs = looseExtraction.outputs;
|
|
1237
|
+
log.debug(
|
|
1238
|
+
`[task:${taskId}]`,
|
|
1239
|
+
`extracted binding outputs: ${JSON.stringify(looseExtraction.outputs)}`,
|
|
1240
|
+
);
|
|
1241
|
+
if (looseExtraction.diagnostic) {
|
|
1242
|
+
log.debug(`[task:${taskId}]`, looseExtraction.diagnostic);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1181
1246
|
// Prompt tasks use inferred ports (from direct-downstream Command
|
|
1182
1247
|
// inputs); Command tasks use their declared ports. Either way,
|
|
1183
1248
|
// `extractTaskOutputs` is a no-op when there are no declared
|
|
@@ -1188,8 +1253,7 @@ export async function runPipeline(
|
|
|
1188
1253
|
result.normalizedOutput,
|
|
1189
1254
|
);
|
|
1190
1255
|
if (effectivePorts?.outputs && effectivePorts.outputs.length > 0) {
|
|
1191
|
-
extractedOutputs = extraction.outputs;
|
|
1192
|
-
outputValuesMap.set(taskId, extraction.outputs);
|
|
1256
|
+
extractedOutputs = { ...(extractedOutputs ?? {}), ...extraction.outputs };
|
|
1193
1257
|
log.debug(
|
|
1194
1258
|
`[task:${taskId}]`,
|
|
1195
1259
|
`extracted outputs: ${JSON.stringify(extraction.outputs)}` +
|
|
@@ -1207,6 +1271,16 @@ export async function runPipeline(
|
|
|
1207
1271
|
// — hooks, wire events, test assertions — all go through this
|
|
1208
1272
|
// one field rather than re-running extraction.
|
|
1209
1273
|
result = { ...result, outputs: extractedOutputs };
|
|
1274
|
+
if (extractedOutputs !== null) {
|
|
1275
|
+
outputValuesMap.set(taskId, extractedOutputs);
|
|
1276
|
+
}
|
|
1277
|
+
bindingDataMap.set(taskId, {
|
|
1278
|
+
outputs: extractedOutputs,
|
|
1279
|
+
stdout: result.stdout,
|
|
1280
|
+
stderr: result.stderr,
|
|
1281
|
+
normalizedOutput: result.normalizedOutput,
|
|
1282
|
+
exitCode: result.exitCode,
|
|
1283
|
+
});
|
|
1210
1284
|
|
|
1211
1285
|
// Store normalized text separately (in-memory) for continue_from handoff.
|
|
1212
1286
|
// R15: clip oversized values so a runaway parseResult can't accumulate
|
package/src/ports.test.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import {
|
|
3
3
|
extractInputReferences,
|
|
4
|
+
extractTaskBindingOutputs,
|
|
4
5
|
extractTaskOutputs,
|
|
5
6
|
inferPromptPorts,
|
|
7
|
+
resolveTaskBindingInputs,
|
|
6
8
|
resolveTaskInputs,
|
|
7
9
|
substituteInputs,
|
|
8
10
|
} from './ports';
|
|
@@ -244,6 +246,87 @@ describe('resolveTaskInputs', () => {
|
|
|
244
246
|
});
|
|
245
247
|
});
|
|
246
248
|
|
|
249
|
+
// ─── resolveTaskBindingInputs ────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
describe('resolveTaskBindingInputs', () => {
|
|
252
|
+
test('resolves literal values and defaults without requiring ports', () => {
|
|
253
|
+
const t = task({
|
|
254
|
+
id: 'downstream',
|
|
255
|
+
command: 'echo',
|
|
256
|
+
inputs: {
|
|
257
|
+
city: { value: 'Shanghai' },
|
|
258
|
+
mode: { from: 't.up.outputs.missing', default: 'quick' },
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const res = resolveTaskBindingInputs(t, new Map(), ['t.up']);
|
|
262
|
+
expect(res).toEqual({
|
|
263
|
+
kind: 'ready',
|
|
264
|
+
inputs: { city: 'Shanghai', mode: 'quick' },
|
|
265
|
+
missingOptional: [],
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('resolves values from a direct upstream output and stdout', () => {
|
|
270
|
+
const t = task({
|
|
271
|
+
id: 'downstream',
|
|
272
|
+
command: 'echo',
|
|
273
|
+
inputs: {
|
|
274
|
+
city: { from: 't.up.outputs.city' },
|
|
275
|
+
raw: { from: 't.up.stdout' },
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const upstream = new Map([
|
|
279
|
+
[
|
|
280
|
+
't.up',
|
|
281
|
+
{
|
|
282
|
+
outputs: { city: 'Shanghai' },
|
|
283
|
+
stdout: 'raw text\n',
|
|
284
|
+
stderr: '',
|
|
285
|
+
normalizedOutput: null,
|
|
286
|
+
exitCode: 0,
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
]);
|
|
290
|
+
const res = resolveTaskBindingInputs(t, upstream, ['t.up']);
|
|
291
|
+
expect(res.kind).toBe('ready');
|
|
292
|
+
if (res.kind !== 'ready') return;
|
|
293
|
+
expect(res.inputs).toEqual({ city: 'Shanghai', raw: 'raw text\n' });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('blocks required missing bindings with a readable reason', () => {
|
|
297
|
+
const t = task({
|
|
298
|
+
id: 'downstream',
|
|
299
|
+
command: 'echo',
|
|
300
|
+
inputs: {
|
|
301
|
+
city: { from: 't.up.outputs.city', required: true },
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
const res = resolveTaskBindingInputs(t, new Map(), ['t.up']);
|
|
305
|
+
expect(res.kind).toBe('blocked');
|
|
306
|
+
if (res.kind !== 'blocked') return;
|
|
307
|
+
expect(res.missingRequired).toEqual(['city']);
|
|
308
|
+
expect(res.reason).toContain('missing required binding input(s): city');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('detects ambiguous loose output name matches', () => {
|
|
312
|
+
const t = task({
|
|
313
|
+
id: 'downstream',
|
|
314
|
+
command: 'echo',
|
|
315
|
+
inputs: {
|
|
316
|
+
val: { from: 'outputs.val', required: true },
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
const upstream = new Map([
|
|
320
|
+
['t.a', { outputs: { val: 'a' }, stdout: '', stderr: '', normalizedOutput: null, exitCode: 0 }],
|
|
321
|
+
['t.b', { outputs: { val: 'b' }, stdout: '', stderr: '', normalizedOutput: null, exitCode: 0 }],
|
|
322
|
+
]);
|
|
323
|
+
const res = resolveTaskBindingInputs(t, upstream, ['t.a', 't.b']);
|
|
324
|
+
expect(res.kind).toBe('blocked');
|
|
325
|
+
if (res.kind !== 'blocked') return;
|
|
326
|
+
expect(res.ambiguous[0]).toEqual({ input: 'val', producers: ['t.a', 't.b'] });
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
247
330
|
// ─── extractTaskOutputs ──────────────────────────────────────────────
|
|
248
331
|
|
|
249
332
|
describe('extractTaskOutputs', () => {
|
|
@@ -301,6 +384,50 @@ describe('extractTaskOutputs', () => {
|
|
|
301
384
|
});
|
|
302
385
|
});
|
|
303
386
|
|
|
387
|
+
// ─── extractTaskBindingOutputs ───────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
describe('extractTaskBindingOutputs', () => {
|
|
390
|
+
test('extracts loose outputs from final-line JSON by default', () => {
|
|
391
|
+
const r = extractTaskBindingOutputs(
|
|
392
|
+
{
|
|
393
|
+
city: {},
|
|
394
|
+
temp: { from: 'json.temperature' },
|
|
395
|
+
},
|
|
396
|
+
'log\n{"city":"Shanghai","temperature":23}\n',
|
|
397
|
+
'',
|
|
398
|
+
null,
|
|
399
|
+
);
|
|
400
|
+
expect(r.outputs).toEqual({ city: 'Shanghai', temp: 23 });
|
|
401
|
+
expect(r.diagnostic).toBeNull();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('can publish whole stdout and normalizedOutput as named outputs', () => {
|
|
405
|
+
const r = extractTaskBindingOutputs(
|
|
406
|
+
{
|
|
407
|
+
raw: { from: 'stdout' },
|
|
408
|
+
normalized: { from: 'normalizedOutput' },
|
|
409
|
+
},
|
|
410
|
+
'raw text\n',
|
|
411
|
+
'',
|
|
412
|
+
'normalized text',
|
|
413
|
+
);
|
|
414
|
+
expect(r.outputs).toEqual({ raw: 'raw text\n', normalized: 'normalized text' });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('uses defaults for missing loose outputs without failing extraction', () => {
|
|
418
|
+
const r = extractTaskBindingOutputs(
|
|
419
|
+
{
|
|
420
|
+
city: { default: 'Unknown' },
|
|
421
|
+
},
|
|
422
|
+
'not json\n',
|
|
423
|
+
'',
|
|
424
|
+
null,
|
|
425
|
+
);
|
|
426
|
+
expect(r.outputs).toEqual({ city: 'Unknown' });
|
|
427
|
+
expect(r.diagnostic).toBeNull();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
304
431
|
// ─── inferPromptPorts ───────────────────────────────────────────────
|
|
305
432
|
|
|
306
433
|
describe('inferPromptPorts', () => {
|
package/src/ports.ts
CHANGED
|
@@ -34,7 +34,13 @@
|
|
|
34
34
|
// Everything here is pure / deterministic so it can be reused by the CLI,
|
|
35
35
|
// the editor (for preview/simulation), and the engine without side effects.
|
|
36
36
|
|
|
37
|
-
import type {
|
|
37
|
+
import type {
|
|
38
|
+
PortDef,
|
|
39
|
+
PortType,
|
|
40
|
+
TaskConfig,
|
|
41
|
+
TaskOutputBindings,
|
|
42
|
+
TaskPorts,
|
|
43
|
+
} from './types';
|
|
38
44
|
|
|
39
45
|
// ─── Template substitution ────────────────────────────────────────────
|
|
40
46
|
|
|
@@ -244,6 +250,157 @@ export function resolveTaskInputs(
|
|
|
244
250
|
return { kind: 'ready', inputs, missingOptional };
|
|
245
251
|
}
|
|
246
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 reason: string;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export function resolveTaskBindingInputs(
|
|
277
|
+
task: Pick<TaskConfig, 'inputs'>,
|
|
278
|
+
upstreamData: ReadonlyMap<string, UpstreamBindingData>,
|
|
279
|
+
dependsOn: readonly string[],
|
|
280
|
+
): BindingInputResolution {
|
|
281
|
+
const bindings = task.inputs;
|
|
282
|
+
if (!bindings || Object.keys(bindings).length === 0) {
|
|
283
|
+
return { kind: 'ready', inputs: {}, missingOptional: [] };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const inputs: Record<string, unknown> = {};
|
|
287
|
+
const missingRequired: string[] = [];
|
|
288
|
+
const missingOptional: string[] = [];
|
|
289
|
+
const ambiguous: { input: string; producers: string[] }[] = [];
|
|
290
|
+
|
|
291
|
+
for (const [name, binding] of Object.entries(bindings)) {
|
|
292
|
+
let value: unknown;
|
|
293
|
+
let present = false;
|
|
294
|
+
|
|
295
|
+
if ('value' in binding) {
|
|
296
|
+
value = binding.value;
|
|
297
|
+
present = true;
|
|
298
|
+
} else if (binding.from) {
|
|
299
|
+
const found = resolveBindingSource(binding.from, upstreamData, dependsOn);
|
|
300
|
+
if (found.kind === 'ambiguous') {
|
|
301
|
+
ambiguous.push({ input: name, producers: found.producers });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (found.kind === 'hit') {
|
|
305
|
+
value = found.value;
|
|
306
|
+
present = true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!present && 'default' in binding) {
|
|
311
|
+
value = binding.default;
|
|
312
|
+
present = true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!present || value === undefined || value === null) {
|
|
316
|
+
if (binding.required === true) {
|
|
317
|
+
missingRequired.push(name);
|
|
318
|
+
} else {
|
|
319
|
+
missingOptional.push(name);
|
|
320
|
+
}
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
inputs[name] = value;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (missingRequired.length > 0 || ambiguous.length > 0) {
|
|
328
|
+
const lines: string[] = [];
|
|
329
|
+
if (missingRequired.length > 0) {
|
|
330
|
+
lines.push(`missing required binding input(s): ${missingRequired.join(', ')}`);
|
|
331
|
+
}
|
|
332
|
+
for (const amb of ambiguous) {
|
|
333
|
+
lines.push(
|
|
334
|
+
`binding input "${amb.input}" is produced by multiple upstreams ` +
|
|
335
|
+
`(${amb.producers.join(', ')}) — use "taskId.outputs.${amb.input}"`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return { kind: 'blocked', missingRequired, ambiguous, reason: lines.join('\n') };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { kind: 'ready', inputs, missingOptional };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
type BindingLookup =
|
|
345
|
+
| { kind: 'hit'; producer: string; value: unknown }
|
|
346
|
+
| { kind: 'miss' }
|
|
347
|
+
| { kind: 'ambiguous'; producers: string[] };
|
|
348
|
+
|
|
349
|
+
function resolveBindingSource(
|
|
350
|
+
source: string,
|
|
351
|
+
upstreamData: ReadonlyMap<string, UpstreamBindingData>,
|
|
352
|
+
dependsOn: readonly string[],
|
|
353
|
+
): BindingLookup {
|
|
354
|
+
if (source.startsWith('outputs.')) {
|
|
355
|
+
return findOutputByName(source.slice('outputs.'.length), upstreamData, dependsOn);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const outputMarker = '.outputs.';
|
|
359
|
+
const outputIdx = source.lastIndexOf(outputMarker);
|
|
360
|
+
if (outputIdx > 0) {
|
|
361
|
+
const upstreamId = source.slice(0, outputIdx);
|
|
362
|
+
const outputName = source.slice(outputIdx + outputMarker.length);
|
|
363
|
+
if (!dependsOn.includes(upstreamId)) return { kind: 'miss' };
|
|
364
|
+
const upstream = upstreamData.get(upstreamId);
|
|
365
|
+
if (upstream?.outputs && outputName in upstream.outputs) {
|
|
366
|
+
return { kind: 'hit', producer: upstreamId, value: upstream.outputs[outputName] };
|
|
367
|
+
}
|
|
368
|
+
return { kind: 'miss' };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
for (const field of ['stdout', 'stderr', 'normalizedOutput', 'exitCode'] as const) {
|
|
372
|
+
const suffix = `.${field}`;
|
|
373
|
+
if (!source.endsWith(suffix)) continue;
|
|
374
|
+
const upstreamId = source.slice(0, -suffix.length);
|
|
375
|
+
if (!dependsOn.includes(upstreamId)) return { kind: 'miss' };
|
|
376
|
+
const upstream = upstreamData.get(upstreamId);
|
|
377
|
+
if (!upstream) return { kind: 'miss' };
|
|
378
|
+
const value = upstream[field];
|
|
379
|
+
return value === undefined || value === null
|
|
380
|
+
? { kind: 'miss' }
|
|
381
|
+
: { kind: 'hit', producer: upstreamId, value };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { kind: 'miss' };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function findOutputByName(
|
|
388
|
+
name: string,
|
|
389
|
+
upstreamData: ReadonlyMap<string, UpstreamBindingData>,
|
|
390
|
+
dependsOn: readonly string[],
|
|
391
|
+
): BindingLookup {
|
|
392
|
+
const hits: { producer: string; value: unknown }[] = [];
|
|
393
|
+
for (const upstreamId of dependsOn) {
|
|
394
|
+
const upstream = upstreamData.get(upstreamId);
|
|
395
|
+
if (upstream?.outputs && name in upstream.outputs) {
|
|
396
|
+
hits.push({ producer: upstreamId, value: upstream.outputs[name] });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (hits.length === 0) return { kind: 'miss' };
|
|
400
|
+
if (hits.length === 1) return { kind: 'hit', producer: hits[0]!.producer, value: hits[0]!.value };
|
|
401
|
+
return { kind: 'ambiguous', producers: hits.map((h) => h.producer) };
|
|
402
|
+
}
|
|
403
|
+
|
|
247
404
|
type UpstreamLookup =
|
|
248
405
|
| { kind: 'hit'; producer: string; value: unknown }
|
|
249
406
|
| { kind: 'miss' }
|
|
@@ -416,6 +573,72 @@ export function extractTaskOutputs(
|
|
|
416
573
|
return { outputs, diagnostic };
|
|
417
574
|
}
|
|
418
575
|
|
|
576
|
+
export function extractTaskBindingOutputs(
|
|
577
|
+
bindings: TaskOutputBindings | undefined,
|
|
578
|
+
stdout: string,
|
|
579
|
+
stderr: string,
|
|
580
|
+
normalizedOutput: string | null,
|
|
581
|
+
): ExtractResult {
|
|
582
|
+
if (!bindings || Object.keys(bindings).length === 0) {
|
|
583
|
+
return { outputs: {}, diagnostic: null };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const outputs: Record<string, unknown> = {};
|
|
587
|
+
const missing: string[] = [];
|
|
588
|
+
let record: Record<string, unknown> | null | undefined;
|
|
589
|
+
|
|
590
|
+
for (const [name, binding] of Object.entries(bindings)) {
|
|
591
|
+
let value: unknown;
|
|
592
|
+
let present = false;
|
|
593
|
+
|
|
594
|
+
if ('value' in binding) {
|
|
595
|
+
value = binding.value;
|
|
596
|
+
present = true;
|
|
597
|
+
} else {
|
|
598
|
+
const source = binding.from ?? `json.${name}`;
|
|
599
|
+
if (source === 'stdout') {
|
|
600
|
+
value = stdout;
|
|
601
|
+
present = true;
|
|
602
|
+
} else if (source === 'stderr') {
|
|
603
|
+
value = stderr;
|
|
604
|
+
present = true;
|
|
605
|
+
} else if (source === 'normalizedOutput') {
|
|
606
|
+
if (normalizedOutput !== null) {
|
|
607
|
+
value = normalizedOutput;
|
|
608
|
+
present = true;
|
|
609
|
+
}
|
|
610
|
+
} else if (source.startsWith('json.')) {
|
|
611
|
+
if (record === undefined) {
|
|
612
|
+
const jsonSource = (normalizedOutput ?? '').length > 0 ? normalizedOutput! : stdout;
|
|
613
|
+
record = parseJsonTail(jsonSource);
|
|
614
|
+
}
|
|
615
|
+
const key = source.slice('json.'.length);
|
|
616
|
+
if (record && key in record) {
|
|
617
|
+
value = record[key];
|
|
618
|
+
present = true;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (!present && 'default' in binding) {
|
|
624
|
+
value = binding.default;
|
|
625
|
+
present = true;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (!present || value === undefined || value === null) {
|
|
629
|
+
missing.push(name);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
outputs[name] = value;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
outputs,
|
|
638
|
+
diagnostic: missing.length > 0 ? `outputs: unresolved binding output(s): ${missing.join(', ')}` : null,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
419
642
|
/**
|
|
420
643
|
* Find the last non-empty line that parses as a JSON object. Returns
|
|
421
644
|
* null when no such line exists. Also tries the whole source as a
|