@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/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
|
package/src/schema-ports.test.ts
CHANGED
|
@@ -13,6 +13,34 @@ const WORK_DIR = process.platform === 'win32' ? 'D:\\fake-work' : '/fake-work';
|
|
|
13
13
|
// ─── resolveConfig preserves ports ───────────────────────────────────
|
|
14
14
|
|
|
15
15
|
describe('resolveConfig — ports passthrough', () => {
|
|
16
|
+
test('raw lightweight bindings survive onto the resolved task', () => {
|
|
17
|
+
const raw: RawPipelineConfig = {
|
|
18
|
+
name: 'p',
|
|
19
|
+
tracks: [
|
|
20
|
+
{
|
|
21
|
+
id: 't',
|
|
22
|
+
name: 'T',
|
|
23
|
+
tasks: [
|
|
24
|
+
{
|
|
25
|
+
id: 'a',
|
|
26
|
+
command: 'echo "{{inputs.city}}"',
|
|
27
|
+
inputs: {
|
|
28
|
+
city: { from: 't.plan.outputs.city', required: true },
|
|
29
|
+
},
|
|
30
|
+
outputs: {
|
|
31
|
+
report: { from: 'json.reportPath' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
const resolved = resolveConfig(raw, WORK_DIR);
|
|
39
|
+
const task = resolved.tracks[0]!.tasks[0]!;
|
|
40
|
+
expect(task.inputs).toEqual(raw.tracks[0]!.tasks[0]!.inputs!);
|
|
41
|
+
expect(task.outputs).toEqual(raw.tracks[0]!.tasks[0]!.outputs!);
|
|
42
|
+
});
|
|
43
|
+
|
|
16
44
|
test('raw ports survive onto the resolved task', () => {
|
|
17
45
|
const raw: RawPipelineConfig = {
|
|
18
46
|
name: 'p',
|
|
@@ -83,6 +111,35 @@ describe('resolveConfig — ports passthrough', () => {
|
|
|
83
111
|
// ─── deresolvePipeline preserves ports ───────────────────────────────
|
|
84
112
|
|
|
85
113
|
describe('deresolvePipeline — ports round-trip', () => {
|
|
114
|
+
test('lightweight bindings round-trip', () => {
|
|
115
|
+
const raw: RawPipelineConfig = {
|
|
116
|
+
name: 'p',
|
|
117
|
+
tracks: [
|
|
118
|
+
{
|
|
119
|
+
id: 't',
|
|
120
|
+
name: 'T',
|
|
121
|
+
tasks: [
|
|
122
|
+
{
|
|
123
|
+
id: 'a',
|
|
124
|
+
command: 'echo "{{inputs.city}}"',
|
|
125
|
+
inputs: {
|
|
126
|
+
city: { from: 't.plan.outputs.city', required: true },
|
|
127
|
+
mode: { default: 'quick' },
|
|
128
|
+
},
|
|
129
|
+
outputs: {
|
|
130
|
+
raw: { from: 'stdout' },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
const resolved = resolveConfig(raw, WORK_DIR);
|
|
138
|
+
const back = deresolvePipeline(resolved, WORK_DIR);
|
|
139
|
+
expect(back.tracks[0]!.tasks[0]!.inputs).toEqual(raw.tracks[0]!.tasks[0]!.inputs!);
|
|
140
|
+
expect(back.tracks[0]!.tasks[0]!.outputs).toEqual(raw.tracks[0]!.tasks[0]!.outputs!);
|
|
141
|
+
});
|
|
142
|
+
|
|
86
143
|
test('ports with both inputs and outputs round-trip', () => {
|
|
87
144
|
const raw: RawPipelineConfig = {
|
|
88
145
|
name: 'p',
|
|
@@ -200,6 +257,35 @@ describe('deresolvePipeline — ports round-trip', () => {
|
|
|
200
257
|
// ─── parseYaml accepts ports ─────────────────────────────────────────
|
|
201
258
|
|
|
202
259
|
describe('parseYaml — accepts ports declarations', () => {
|
|
260
|
+
test('real-world YAML with lightweight bindings parses cleanly', () => {
|
|
261
|
+
const text = `pipeline:
|
|
262
|
+
name: demo
|
|
263
|
+
tracks:
|
|
264
|
+
- id: t
|
|
265
|
+
name: Main
|
|
266
|
+
tasks:
|
|
267
|
+
- id: build
|
|
268
|
+
command: bun run build
|
|
269
|
+
outputs:
|
|
270
|
+
bundlePath: { from: json.bundlePath }
|
|
271
|
+
- id: test
|
|
272
|
+
depends_on: [build]
|
|
273
|
+
command: 'bun test "{{inputs.bundlePath}}"'
|
|
274
|
+
inputs:
|
|
275
|
+
bundlePath:
|
|
276
|
+
from: t.build.outputs.bundlePath
|
|
277
|
+
required: true
|
|
278
|
+
`;
|
|
279
|
+
const config = parseYaml(text);
|
|
280
|
+
const build = config.tracks[0]!.tasks[0]!;
|
|
281
|
+
const testTask = config.tracks[0]!.tasks[1]!;
|
|
282
|
+
expect(build.outputs!.bundlePath).toEqual({ from: 'json.bundlePath' });
|
|
283
|
+
expect(testTask.inputs!.bundlePath).toEqual({
|
|
284
|
+
from: 't.build.outputs.bundlePath',
|
|
285
|
+
required: true,
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
203
289
|
test('real-world YAML with ports parses cleanly', () => {
|
|
204
290
|
const text = `pipeline:
|
|
205
291
|
name: demo
|
package/src/schema.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import yaml from 'js-yaml';
|
|
3
3
|
import type { PipelineConfig, RawPipelineConfig } from './types';
|
|
4
|
-
import { deresolvePipeline, serializePipeline } from './schema';
|
|
4
|
+
import { deresolvePipeline, parseYaml, resolveConfig, serializePipeline } from './schema';
|
|
5
5
|
|
|
6
6
|
function parsePipelineYaml(content: string): RawPipelineConfig {
|
|
7
7
|
const doc = yaml.load(content) as { pipeline: RawPipelineConfig };
|
|
@@ -56,6 +56,37 @@ describe('completion default serialization', () => {
|
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test('serializePipeline drops continue_from from command tasks (prompt-only field)', () => {
|
|
60
|
+
const raw: RawPipelineConfig = {
|
|
61
|
+
name: 'Strip Continue From',
|
|
62
|
+
tracks: [
|
|
63
|
+
{
|
|
64
|
+
id: 'track_a',
|
|
65
|
+
name: 'Track A',
|
|
66
|
+
tasks: [
|
|
67
|
+
{ id: 'upstream', prompt: 'generate something' },
|
|
68
|
+
// Simulates a task the user authored as `prompt` with a
|
|
69
|
+
// continue_from, then toggled to `command` in the editor panel.
|
|
70
|
+
// The field should not survive serialization.
|
|
71
|
+
{
|
|
72
|
+
id: 'downstream',
|
|
73
|
+
command: 'bun run build',
|
|
74
|
+
continue_from: 'upstream',
|
|
75
|
+
depends_on: ['upstream'],
|
|
76
|
+
},
|
|
77
|
+
// A prompt task keeps its continue_from as-is.
|
|
78
|
+
{ id: 'threaded', prompt: 'refine', continue_from: 'upstream' },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const parsed = parsePipelineYaml(serializePipeline(raw));
|
|
85
|
+
expect(parsed.tracks[0].tasks[1].continue_from).toBeUndefined();
|
|
86
|
+
expect(parsed.tracks[0].tasks[1].depends_on).toEqual(['upstream']);
|
|
87
|
+
expect(parsed.tracks[0].tasks[2].continue_from).toBe('upstream');
|
|
88
|
+
});
|
|
89
|
+
|
|
59
90
|
test('deresolvePipeline also omits the default exit_code completion', () => {
|
|
60
91
|
const resolved: PipelineConfig = {
|
|
61
92
|
name: 'Deresolve Defaults',
|
|
@@ -99,3 +130,84 @@ describe('completion default serialization', () => {
|
|
|
99
130
|
});
|
|
100
131
|
});
|
|
101
132
|
});
|
|
133
|
+
|
|
134
|
+
describe('parseYaml structural validation', () => {
|
|
135
|
+
test('rejects non-array pipeline.tracks with a clear error', () => {
|
|
136
|
+
expect(() =>
|
|
137
|
+
parseYaml(`
|
|
138
|
+
pipeline:
|
|
139
|
+
name: Bad
|
|
140
|
+
tracks:
|
|
141
|
+
id: not-an-array
|
|
142
|
+
`),
|
|
143
|
+
).toThrow(/pipeline\.tracks must be an array/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('rejects non-array track.tasks with a clear error', () => {
|
|
147
|
+
expect(() =>
|
|
148
|
+
parseYaml(`
|
|
149
|
+
pipeline:
|
|
150
|
+
name: Bad
|
|
151
|
+
tracks:
|
|
152
|
+
- id: t
|
|
153
|
+
name: T
|
|
154
|
+
tasks:
|
|
155
|
+
id: not-an-array
|
|
156
|
+
`),
|
|
157
|
+
).toThrow(/track "t": tasks must be an array/);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('permissions inheritance', () => {
|
|
162
|
+
test('resolveConfig applies pipeline-level permissions to tracks and tasks', () => {
|
|
163
|
+
const raw: RawPipelineConfig = {
|
|
164
|
+
name: 'Pipeline Permissions',
|
|
165
|
+
permissions: { read: true, write: true, execute: false },
|
|
166
|
+
tracks: [
|
|
167
|
+
{
|
|
168
|
+
id: 'track_a',
|
|
169
|
+
name: 'Track A',
|
|
170
|
+
tasks: [{ id: 'task_1', prompt: 'hello' }],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const resolved = resolveConfig(raw, 'D:/workspace');
|
|
176
|
+
expect(resolved.tracks[0].permissions).toEqual({ read: true, write: true, execute: false });
|
|
177
|
+
expect(resolved.tracks[0].tasks[0].permissions).toEqual({
|
|
178
|
+
read: true,
|
|
179
|
+
write: true,
|
|
180
|
+
execute: false,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('deresolvePipeline preserves pipeline-level permissions without repeating inherited values', () => {
|
|
185
|
+
const resolved: PipelineConfig = {
|
|
186
|
+
name: 'Deresolve Permissions',
|
|
187
|
+
permissions: { read: true, write: true, execute: false },
|
|
188
|
+
tracks: [
|
|
189
|
+
{
|
|
190
|
+
id: 'track_a',
|
|
191
|
+
name: 'Track A',
|
|
192
|
+
permissions: { read: true, write: true, execute: false },
|
|
193
|
+
cwd: 'D:/workspace',
|
|
194
|
+
tasks: [
|
|
195
|
+
{
|
|
196
|
+
id: 'task_1',
|
|
197
|
+
name: 'Task 1',
|
|
198
|
+
prompt: 'hello',
|
|
199
|
+
permissions: { read: true, write: true, execute: false },
|
|
200
|
+
cwd: 'D:/workspace',
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const raw = deresolvePipeline(resolved, 'D:/workspace');
|
|
208
|
+
|
|
209
|
+
expect(raw.permissions).toEqual({ read: true, write: true, execute: false });
|
|
210
|
+
expect(raw.tracks[0].permissions).toBeUndefined();
|
|
211
|
+
expect(raw.tracks[0].tasks[0].permissions).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
});
|
package/src/schema.ts
CHANGED
|
@@ -17,13 +17,17 @@ import { buildDag } from './dag';
|
|
|
17
17
|
// ═══ YAML Parsing ═══
|
|
18
18
|
|
|
19
19
|
export function parseYaml(content: string): RawPipelineConfig {
|
|
20
|
-
const doc = yaml.load(content) as { pipeline?:
|
|
20
|
+
const doc = yaml.load(content) as { pipeline?: unknown };
|
|
21
21
|
if (!doc?.pipeline) {
|
|
22
22
|
throw new Error('YAML must contain a top-level "pipeline" key');
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
if (typeof doc.pipeline !== 'object' || Array.isArray(doc.pipeline)) {
|
|
25
|
+
throw new Error('pipeline must be an object');
|
|
26
|
+
}
|
|
27
|
+
const p = doc.pipeline as RawPipelineConfig;
|
|
25
28
|
if (!p.name) throw new Error('pipeline.name is required');
|
|
26
|
-
if (!
|
|
29
|
+
if (!Array.isArray(p.tracks)) throw new Error('pipeline.tracks must be an array');
|
|
30
|
+
if (p.tracks.length === 0) throw new Error('pipeline.tracks must be non-empty');
|
|
27
31
|
|
|
28
32
|
// D14: Detect duplicate track IDs before per-track validation so the error
|
|
29
33
|
// message is clear ("Duplicate track id") rather than a confusing DAG error
|
|
@@ -60,10 +64,16 @@ function assertValidId(id: string, label: string): void {
|
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
function validateRawTrack(track: RawTrackConfig): void {
|
|
67
|
+
if (!track || typeof track !== 'object' || Array.isArray(track)) {
|
|
68
|
+
throw new Error('track must be an object');
|
|
69
|
+
}
|
|
63
70
|
if (!track.id) throw new Error('track.id is required');
|
|
64
71
|
assertValidId(track.id, `track "${track.id}"`);
|
|
65
72
|
if (!track.name) throw new Error(`track "${track.id}": name is required`);
|
|
66
|
-
if (!
|
|
73
|
+
if (!Array.isArray(track.tasks)) {
|
|
74
|
+
throw new Error(`track "${track.id}": tasks must be an array`);
|
|
75
|
+
}
|
|
76
|
+
if (track.tasks.length === 0) {
|
|
67
77
|
throw new Error(`track "${track.id}": tasks must be non-empty`);
|
|
68
78
|
}
|
|
69
79
|
for (const task of track.tasks) {
|
|
@@ -72,6 +82,9 @@ function validateRawTrack(track: RawTrackConfig): void {
|
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
function validateRawTask(task: RawTaskConfig, trackId: string): void {
|
|
85
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)) {
|
|
86
|
+
throw new Error(`track "${trackId}": task must be an object`);
|
|
87
|
+
}
|
|
75
88
|
if (!task.id) throw new Error(`track "${trackId}": task.id is required`);
|
|
76
89
|
assertValidId(task.id, `task "${task.id}" in track "${trackId}"`);
|
|
77
90
|
|
|
@@ -84,7 +97,7 @@ function validateRawTask(task: RawTaskConfig, trackId: string): void {
|
|
|
84
97
|
throw new Error(`task "${task.id}": cannot have both "prompt" and "command"`);
|
|
85
98
|
}
|
|
86
99
|
// Empty-content tasks (e.g. `prompt: ''`) are allowed at parse time and
|
|
87
|
-
// flagged as
|
|
100
|
+
// flagged as hard validation errors by validate-raw.ts.
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
// ═══ Config Inheritance Resolution ═══
|
|
@@ -140,7 +153,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
140
153
|
model: rawTask.model ?? rawTrack.model ?? raw.model,
|
|
141
154
|
reasoning_effort:
|
|
142
155
|
rawTask.reasoning_effort ?? rawTrack.reasoning_effort ?? raw.reasoning_effort,
|
|
143
|
-
permissions: rawTask.permissions ?? rawTrack.permissions ?? DEFAULT_PERMISSIONS,
|
|
156
|
+
permissions: rawTask.permissions ?? rawTrack.permissions ?? raw.permissions ?? DEFAULT_PERMISSIONS,
|
|
144
157
|
driver: rawTask.driver ?? trackDriver ?? 'opencode',
|
|
145
158
|
timeout: rawTask.timeout,
|
|
146
159
|
// Middleware: Task-level overrides Track (including [] to disable)
|
|
@@ -148,8 +161,10 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
148
161
|
completion: rawTask.completion,
|
|
149
162
|
agent_profile: rawTask.agent_profile ?? rawTrack.agent_profile,
|
|
150
163
|
cwd: rawTask.cwd ? validatePath(rawTask.cwd, workDir) : trackCwd,
|
|
151
|
-
//
|
|
152
|
-
//
|
|
164
|
+
// Lightweight bindings and ports: no inheritance — they describe
|
|
165
|
+
// per-task data flow, not cross-task defaults.
|
|
166
|
+
inputs: rawTask.inputs,
|
|
167
|
+
outputs: rawTask.outputs,
|
|
153
168
|
ports: rawTask.ports,
|
|
154
169
|
};
|
|
155
170
|
});
|
|
@@ -161,7 +176,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
161
176
|
agent_profile: rawTrack.agent_profile,
|
|
162
177
|
model: rawTrack.model ?? raw.model,
|
|
163
178
|
reasoning_effort: rawTrack.reasoning_effort ?? raw.reasoning_effort,
|
|
164
|
-
permissions: rawTrack.permissions ?? DEFAULT_PERMISSIONS,
|
|
179
|
+
permissions: rawTrack.permissions ?? raw.permissions ?? DEFAULT_PERMISSIONS,
|
|
165
180
|
driver: trackDriver ?? 'opencode',
|
|
166
181
|
cwd: trackCwd,
|
|
167
182
|
middlewares: rawTrack.middlewares,
|
|
@@ -175,6 +190,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
175
190
|
driver: raw.driver,
|
|
176
191
|
model: raw.model,
|
|
177
192
|
reasoning_effort: raw.reasoning_effort,
|
|
193
|
+
permissions: raw.permissions,
|
|
178
194
|
timeout: raw.timeout,
|
|
179
195
|
plugins: raw.plugins,
|
|
180
196
|
hooks: raw.hooks,
|
|
@@ -208,14 +224,32 @@ function stripDefaultTaskCompletion<T extends { completion?: CompletionConfig }>
|
|
|
208
224
|
return rest as T;
|
|
209
225
|
}
|
|
210
226
|
|
|
211
|
-
|
|
227
|
+
// `continue_from` is a prompt-only field — it tells AI drivers with
|
|
228
|
+
// session-resume capability to thread off an upstream prompt task's context.
|
|
229
|
+
// A command task runs as a plain shell subprocess and has no session to
|
|
230
|
+
// resume, so any `continue_from` on a command task is dead weight. Drop it
|
|
231
|
+
// at serialization time so YAML on disk never carries the stale field after
|
|
232
|
+
// a user toggles task mode from prompt → command. The tagma-yaml agent's
|
|
233
|
+
// system prompt (apps/editor/server/opencode-seed.ts) documents this
|
|
234
|
+
// stripping — keep them in sync.
|
|
235
|
+
function stripPromptOnlyFieldsFromCommandTask<
|
|
236
|
+
T extends { command?: string; continue_from?: string },
|
|
237
|
+
>(task: T): T {
|
|
238
|
+
if (task.command === undefined || task.continue_from === undefined) return task;
|
|
239
|
+
const { continue_from: _cf, ...rest } = task;
|
|
240
|
+
return rest as T;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function stripForSerialization<T extends PipelineConfig | RawPipelineConfig>(
|
|
212
244
|
config: T,
|
|
213
245
|
): T {
|
|
214
246
|
return {
|
|
215
247
|
...config,
|
|
216
248
|
tracks: config.tracks.map((track) => ({
|
|
217
249
|
...track,
|
|
218
|
-
tasks: track.tasks.map((task) =>
|
|
250
|
+
tasks: track.tasks.map((task) =>
|
|
251
|
+
stripPromptOnlyFieldsFromCommandTask(stripDefaultTaskCompletion(task)),
|
|
252
|
+
),
|
|
219
253
|
})),
|
|
220
254
|
} as T;
|
|
221
255
|
}
|
|
@@ -228,7 +262,7 @@ function stripDefaultCompletionsForSerialization<T extends PipelineConfig | RawP
|
|
|
228
262
|
*/
|
|
229
263
|
export function serializePipeline(config: PipelineConfig | RawPipelineConfig): string {
|
|
230
264
|
return yaml.dump(
|
|
231
|
-
{ pipeline:
|
|
265
|
+
{ pipeline: stripForSerialization(config) },
|
|
232
266
|
{ lineWidth: 120, indent: 2 },
|
|
233
267
|
);
|
|
234
268
|
}
|
|
@@ -277,6 +311,8 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
277
311
|
...(task.permissions && !permissionsEqual(task.permissions, track.permissions)
|
|
278
312
|
? { permissions: task.permissions }
|
|
279
313
|
: {}),
|
|
314
|
+
...(task.inputs && Object.keys(task.inputs).length > 0 ? { inputs: task.inputs } : {}),
|
|
315
|
+
...(task.outputs && Object.keys(task.outputs).length > 0 ? { outputs: task.outputs } : {}),
|
|
280
316
|
...(task.ports &&
|
|
281
317
|
((task.ports.inputs && task.ports.inputs.length > 0) ||
|
|
282
318
|
(task.ports.outputs && task.ports.outputs.length > 0))
|
|
@@ -302,7 +338,7 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
302
338
|
...(track.on_failure && track.on_failure !== 'skip_downstream'
|
|
303
339
|
? { on_failure: track.on_failure }
|
|
304
340
|
: {}),
|
|
305
|
-
...(track.permissions && !permissionsEqual(track.permissions, DEFAULT_PERMISSIONS)
|
|
341
|
+
...(track.permissions && !permissionsEqual(track.permissions, config.permissions ?? DEFAULT_PERMISSIONS)
|
|
306
342
|
? { permissions: track.permissions }
|
|
307
343
|
: {}),
|
|
308
344
|
tasks,
|
|
@@ -314,6 +350,9 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
314
350
|
...(config.driver ? { driver: config.driver } : {}),
|
|
315
351
|
...(config.model ? { model: config.model } : {}),
|
|
316
352
|
...(config.reasoning_effort ? { reasoning_effort: config.reasoning_effort } : {}),
|
|
353
|
+
...(config.permissions && !permissionsEqual(config.permissions, DEFAULT_PERMISSIONS)
|
|
354
|
+
? { permissions: config.permissions }
|
|
355
|
+
: {}),
|
|
317
356
|
...(config.timeout ? { timeout: config.timeout } : {}),
|
|
318
357
|
...(config.plugins?.length ? { plugins: config.plugins } : {}),
|
|
319
358
|
...(config.hooks ? { hooks: config.hooks } : {}),
|
package/src/sdk.ts
CHANGED
|
@@ -129,12 +129,16 @@ export {
|
|
|
129
129
|
export {
|
|
130
130
|
substituteInputs,
|
|
131
131
|
extractInputReferences,
|
|
132
|
+
resolveTaskBindingInputs,
|
|
132
133
|
resolveTaskInputs,
|
|
134
|
+
extractTaskBindingOutputs,
|
|
133
135
|
extractTaskOutputs,
|
|
134
136
|
inferPromptPorts,
|
|
135
137
|
} from './ports';
|
|
136
138
|
export type {
|
|
137
139
|
SubstituteResult,
|
|
140
|
+
BindingInputResolution,
|
|
141
|
+
UpstreamBindingData,
|
|
138
142
|
InputResolution,
|
|
139
143
|
ExtractResult,
|
|
140
144
|
PromptPortInference,
|
package/src/task-ref.ts
CHANGED
|
@@ -68,6 +68,7 @@ export function buildTaskIndex(config: RawPipelineConfig | PipelineConfig): Task
|
|
|
68
68
|
const bareToQualified = new Map<string, string>();
|
|
69
69
|
for (const track of config.tracks ?? []) {
|
|
70
70
|
if (!track?.id) continue;
|
|
71
|
+
if (!Array.isArray(track.tasks)) continue;
|
|
71
72
|
for (const task of track.tasks ?? []) {
|
|
72
73
|
if (!task?.id) continue;
|
|
73
74
|
const qid = qualifyTaskId(track.id, task.id);
|