@tagma/sdk 0.6.10 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -13
- package/dist/config-ops.d.ts +4 -2
- package/dist/config-ops.d.ts.map +1 -1
- package/dist/config-ops.js +30 -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/pipeline-runner.d.ts +1 -1
- package/dist/pipeline-runner.d.ts.map +1 -1
- package/dist/pipeline-runner.js +20 -0
- package/dist/pipeline-runner.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 +47 -11
- 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/task-ref.d.ts.map +1 -1
- package/dist/task-ref.js +2 -0
- package/dist/task-ref.js.map +1 -1
- package/dist/utils.js +3 -3
- package/dist/utils.js.map +1 -1
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +167 -3
- package/dist/validate-raw.js.map +1 -1
- package/dist/yaml-compiler.d.ts.map +1 -1
- package/dist/yaml-compiler.js +23 -5
- package/dist/yaml-compiler.js.map +1 -1
- package/package.json +2 -2
- package/src/completions/output-check.test.ts +50 -0
- package/src/config-ops.test.ts +70 -0
- package/src/config-ops.ts +23 -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/pipeline-runner.test.ts +95 -0
- package/src/pipeline-runner.ts +18 -1
- package/src/ports.test.ts +127 -0
- package/src/ports.ts +224 -1
- package/src/schema-ports.test.ts +86 -0
- package/src/schema.test.ts +113 -1
- package/src/schema.ts +52 -13
- package/src/sdk.ts +4 -0
- package/src/task-ref.ts +1 -0
- package/src/utils.test.ts +28 -0
- package/src/utils.ts +3 -3
- package/src/validate-raw-ports.test.ts +66 -0
- package/src/validate-raw.ts +189 -4
- package/src/yaml-compiler.test.ts +108 -0
- package/src/yaml-compiler.ts +32 -5
package/src/engine.ts
CHANGED
|
@@ -31,11 +31,14 @@ import {
|
|
|
31
31
|
renderOutputSchemaBlock,
|
|
32
32
|
} from './prompt-doc';
|
|
33
33
|
import {
|
|
34
|
+
extractTaskBindingOutputs,
|
|
34
35
|
extractTaskOutputs,
|
|
35
36
|
inferPromptPorts,
|
|
37
|
+
resolveTaskBindingInputs,
|
|
36
38
|
resolveTaskInputs,
|
|
37
39
|
substituteInputs,
|
|
38
40
|
} from './ports';
|
|
41
|
+
import type { UpstreamBindingData } from './ports';
|
|
39
42
|
import type { TaskPorts } from './types';
|
|
40
43
|
import {
|
|
41
44
|
executeHook,
|
|
@@ -70,6 +73,18 @@ export class TriggerTimeoutError extends Error {
|
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
function isPromptTaskConfig(
|
|
77
|
+
task: TaskConfig,
|
|
78
|
+
): task is TaskConfig & { readonly prompt: string; readonly command?: undefined } {
|
|
79
|
+
return task.prompt !== undefined && task.command === undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isCommandTaskConfig(
|
|
83
|
+
task: TaskConfig,
|
|
84
|
+
): task is TaskConfig & { readonly command: string; readonly prompt?: undefined } {
|
|
85
|
+
return task.command !== undefined && task.prompt === undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
// ═══ Preflight Validation ═══
|
|
74
89
|
|
|
75
90
|
function preflight(config: PipelineConfig, dag: Dag, registry: PluginRegistry): void {
|
|
@@ -81,7 +96,7 @@ function preflight(config: PipelineConfig, dag: Dag, registry: PluginRegistry):
|
|
|
81
96
|
const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
|
|
82
97
|
|
|
83
98
|
// Pure command tasks don't use a driver — skip driver registration check.
|
|
84
|
-
const isCommandOnly = task
|
|
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
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { bootstrapBuiltins } from './bootstrap';
|
|
6
|
+
import { PipelineRunner } from './pipeline-runner';
|
|
7
|
+
import { PluginRegistry } from './registry';
|
|
8
|
+
import type { PipelineConfig } from './types';
|
|
9
|
+
|
|
10
|
+
function makeDir(): string {
|
|
11
|
+
return mkdtempSync(join(tmpdir(), 'tagma-pipeline-runner-'));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function portsPipeline(dir: string): PipelineConfig {
|
|
15
|
+
const emit = join(dir, 'emit.js');
|
|
16
|
+
writeFileSync(
|
|
17
|
+
emit,
|
|
18
|
+
'process.stdout.write(JSON.stringify({ city: "Shanghai" }) + "\\n");\n',
|
|
19
|
+
);
|
|
20
|
+
const echo = join(dir, 'echo.js');
|
|
21
|
+
writeFileSync(echo, 'process.stdout.write(process.argv[2] + "\\n");\n');
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
name: 'runner-snapshot',
|
|
25
|
+
tracks: [
|
|
26
|
+
{
|
|
27
|
+
id: 't',
|
|
28
|
+
name: 'T',
|
|
29
|
+
tasks: [
|
|
30
|
+
{
|
|
31
|
+
id: 'up',
|
|
32
|
+
name: 'up',
|
|
33
|
+
command: `node "${emit}"`,
|
|
34
|
+
ports: {
|
|
35
|
+
outputs: [{ name: 'city', type: 'string' }],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'down',
|
|
40
|
+
name: 'down',
|
|
41
|
+
depends_on: ['up'],
|
|
42
|
+
command: `node "${echo}" "{{inputs.city}}"`,
|
|
43
|
+
ports: {
|
|
44
|
+
inputs: [{ name: 'city', type: 'string', required: true }],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function run(config: PipelineConfig, dir: string): Promise<PipelineRunner> {
|
|
54
|
+
const registry = new PluginRegistry();
|
|
55
|
+
bootstrapBuiltins(registry);
|
|
56
|
+
const runner = new PipelineRunner(config, dir, {
|
|
57
|
+
registry,
|
|
58
|
+
skipPluginLoading: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await runner.start();
|
|
62
|
+
expect(result.success).toBe(true);
|
|
63
|
+
return runner;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('PipelineRunner task snapshot', () => {
|
|
67
|
+
test('getTasks reflects task_update inputs and outputs', async () => {
|
|
68
|
+
const dir = makeDir();
|
|
69
|
+
try {
|
|
70
|
+
const runner = await run(portsPipeline(dir), dir);
|
|
71
|
+
|
|
72
|
+
const tasks = runner.getTasks();
|
|
73
|
+
const up = tasks.get('t.up');
|
|
74
|
+
const down = tasks.get('t.down');
|
|
75
|
+
expect(up?.outputs).toEqual({ city: 'Shanghai' });
|
|
76
|
+
expect(down?.inputs).toEqual({ city: 'Shanghai' });
|
|
77
|
+
} finally {
|
|
78
|
+
rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('getTasks folds streamed task logs into the task snapshot', async () => {
|
|
83
|
+
const dir = makeDir();
|
|
84
|
+
try {
|
|
85
|
+
const runner = await run(portsPipeline(dir), dir);
|
|
86
|
+
|
|
87
|
+
const tasks = runner.getTasks();
|
|
88
|
+
const up = tasks.get('t.up');
|
|
89
|
+
expect(up?.logs.length).toBeGreaterThan(0);
|
|
90
|
+
expect(up?.totalLogCount).toBeGreaterThan(0);
|
|
91
|
+
} finally {
|
|
92
|
+
rmSync(dir, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
package/src/pipeline-runner.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { runPipeline } from './engine';
|
|
25
25
|
import type { EngineResult, RunPipelineOptions } from './engine';
|
|
26
|
-
import type
|
|
26
|
+
import { TASK_LOG_CAP, type PipelineConfig, type RunEventPayload, type RunTaskState } from './types';
|
|
27
27
|
import { generateRunId } from './utils';
|
|
28
28
|
|
|
29
29
|
export type { EngineResult };
|
|
@@ -132,12 +132,29 @@ export class PipelineRunner {
|
|
|
132
132
|
stderrBytes: pick(event.stderrBytes, prev.stderrBytes),
|
|
133
133
|
sessionId: pick(event.sessionId, prev.sessionId),
|
|
134
134
|
normalizedOutput: pick(event.normalizedOutput, prev.normalizedOutput),
|
|
135
|
+
outputs: pick(event.outputs, prev.outputs),
|
|
136
|
+
inputs: pick(event.inputs, prev.inputs),
|
|
135
137
|
resolvedDriver: pick(event.resolvedDriver, prev.resolvedDriver),
|
|
136
138
|
resolvedModel: pick(event.resolvedModel, prev.resolvedModel),
|
|
137
139
|
resolvedPermissions: pick(event.resolvedPermissions, prev.resolvedPermissions),
|
|
138
140
|
});
|
|
139
141
|
return;
|
|
140
142
|
}
|
|
143
|
+
case 'task_log': {
|
|
144
|
+
if (event.taskId === null) return;
|
|
145
|
+
const prev = this._tasks.get(event.taskId);
|
|
146
|
+
if (!prev) return;
|
|
147
|
+
const logs = [
|
|
148
|
+
...prev.logs,
|
|
149
|
+
{ level: event.level, timestamp: event.timestamp, text: event.text },
|
|
150
|
+
];
|
|
151
|
+
this._tasks.set(event.taskId, {
|
|
152
|
+
...prev,
|
|
153
|
+
logs: logs.slice(-TASK_LOG_CAP),
|
|
154
|
+
totalLogCount: prev.totalLogCount + 1,
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
141
158
|
case 'run_end':
|
|
142
159
|
this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
|
|
143
160
|
return;
|
package/src/ports.test.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import {
|
|
3
3
|
extractInputReferences,
|
|
4
|
+
extractTaskBindingOutputs,
|
|
4
5
|
extractTaskOutputs,
|
|
5
6
|
inferPromptPorts,
|
|
7
|
+
resolveTaskBindingInputs,
|
|
6
8
|
resolveTaskInputs,
|
|
7
9
|
substituteInputs,
|
|
8
10
|
} from './ports';
|
|
@@ -244,6 +246,87 @@ describe('resolveTaskInputs', () => {
|
|
|
244
246
|
});
|
|
245
247
|
});
|
|
246
248
|
|
|
249
|
+
// ─── resolveTaskBindingInputs ────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
describe('resolveTaskBindingInputs', () => {
|
|
252
|
+
test('resolves literal values and defaults without requiring ports', () => {
|
|
253
|
+
const t = task({
|
|
254
|
+
id: 'downstream',
|
|
255
|
+
command: 'echo',
|
|
256
|
+
inputs: {
|
|
257
|
+
city: { value: 'Shanghai' },
|
|
258
|
+
mode: { from: 't.up.outputs.missing', default: 'quick' },
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const res = resolveTaskBindingInputs(t, new Map(), ['t.up']);
|
|
262
|
+
expect(res).toEqual({
|
|
263
|
+
kind: 'ready',
|
|
264
|
+
inputs: { city: 'Shanghai', mode: 'quick' },
|
|
265
|
+
missingOptional: [],
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('resolves values from a direct upstream output and stdout', () => {
|
|
270
|
+
const t = task({
|
|
271
|
+
id: 'downstream',
|
|
272
|
+
command: 'echo',
|
|
273
|
+
inputs: {
|
|
274
|
+
city: { from: 't.up.outputs.city' },
|
|
275
|
+
raw: { from: 't.up.stdout' },
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const upstream = new Map([
|
|
279
|
+
[
|
|
280
|
+
't.up',
|
|
281
|
+
{
|
|
282
|
+
outputs: { city: 'Shanghai' },
|
|
283
|
+
stdout: 'raw text\n',
|
|
284
|
+
stderr: '',
|
|
285
|
+
normalizedOutput: null,
|
|
286
|
+
exitCode: 0,
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
]);
|
|
290
|
+
const res = resolveTaskBindingInputs(t, upstream, ['t.up']);
|
|
291
|
+
expect(res.kind).toBe('ready');
|
|
292
|
+
if (res.kind !== 'ready') return;
|
|
293
|
+
expect(res.inputs).toEqual({ city: 'Shanghai', raw: 'raw text\n' });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('blocks required missing bindings with a readable reason', () => {
|
|
297
|
+
const t = task({
|
|
298
|
+
id: 'downstream',
|
|
299
|
+
command: 'echo',
|
|
300
|
+
inputs: {
|
|
301
|
+
city: { from: 't.up.outputs.city', required: true },
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
const res = resolveTaskBindingInputs(t, new Map(), ['t.up']);
|
|
305
|
+
expect(res.kind).toBe('blocked');
|
|
306
|
+
if (res.kind !== 'blocked') return;
|
|
307
|
+
expect(res.missingRequired).toEqual(['city']);
|
|
308
|
+
expect(res.reason).toContain('missing required binding input(s): city');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('detects ambiguous loose output name matches', () => {
|
|
312
|
+
const t = task({
|
|
313
|
+
id: 'downstream',
|
|
314
|
+
command: 'echo',
|
|
315
|
+
inputs: {
|
|
316
|
+
val: { from: 'outputs.val', required: true },
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
const upstream = new Map([
|
|
320
|
+
['t.a', { outputs: { val: 'a' }, stdout: '', stderr: '', normalizedOutput: null, exitCode: 0 }],
|
|
321
|
+
['t.b', { outputs: { val: 'b' }, stdout: '', stderr: '', normalizedOutput: null, exitCode: 0 }],
|
|
322
|
+
]);
|
|
323
|
+
const res = resolveTaskBindingInputs(t, upstream, ['t.a', 't.b']);
|
|
324
|
+
expect(res.kind).toBe('blocked');
|
|
325
|
+
if (res.kind !== 'blocked') return;
|
|
326
|
+
expect(res.ambiguous[0]).toEqual({ input: 'val', producers: ['t.a', 't.b'] });
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
247
330
|
// ─── extractTaskOutputs ──────────────────────────────────────────────
|
|
248
331
|
|
|
249
332
|
describe('extractTaskOutputs', () => {
|
|
@@ -301,6 +384,50 @@ describe('extractTaskOutputs', () => {
|
|
|
301
384
|
});
|
|
302
385
|
});
|
|
303
386
|
|
|
387
|
+
// ─── extractTaskBindingOutputs ───────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
describe('extractTaskBindingOutputs', () => {
|
|
390
|
+
test('extracts loose outputs from final-line JSON by default', () => {
|
|
391
|
+
const r = extractTaskBindingOutputs(
|
|
392
|
+
{
|
|
393
|
+
city: {},
|
|
394
|
+
temp: { from: 'json.temperature' },
|
|
395
|
+
},
|
|
396
|
+
'log\n{"city":"Shanghai","temperature":23}\n',
|
|
397
|
+
'',
|
|
398
|
+
null,
|
|
399
|
+
);
|
|
400
|
+
expect(r.outputs).toEqual({ city: 'Shanghai', temp: 23 });
|
|
401
|
+
expect(r.diagnostic).toBeNull();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('can publish whole stdout and normalizedOutput as named outputs', () => {
|
|
405
|
+
const r = extractTaskBindingOutputs(
|
|
406
|
+
{
|
|
407
|
+
raw: { from: 'stdout' },
|
|
408
|
+
normalized: { from: 'normalizedOutput' },
|
|
409
|
+
},
|
|
410
|
+
'raw text\n',
|
|
411
|
+
'',
|
|
412
|
+
'normalized text',
|
|
413
|
+
);
|
|
414
|
+
expect(r.outputs).toEqual({ raw: 'raw text\n', normalized: 'normalized text' });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('uses defaults for missing loose outputs without failing extraction', () => {
|
|
418
|
+
const r = extractTaskBindingOutputs(
|
|
419
|
+
{
|
|
420
|
+
city: { default: 'Unknown' },
|
|
421
|
+
},
|
|
422
|
+
'not json\n',
|
|
423
|
+
'',
|
|
424
|
+
null,
|
|
425
|
+
);
|
|
426
|
+
expect(r.outputs).toEqual({ city: 'Unknown' });
|
|
427
|
+
expect(r.diagnostic).toBeNull();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
304
431
|
// ─── inferPromptPorts ───────────────────────────────────────────────
|
|
305
432
|
|
|
306
433
|
describe('inferPromptPorts', () => {
|