@tagma/sdk 0.6.7 → 0.6.9

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/src/ports.test.ts CHANGED
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
  import {
3
3
  extractInputReferences,
4
4
  extractTaskOutputs,
5
+ inferPromptPorts,
5
6
  resolveTaskInputs,
6
7
  substituteInputs,
7
8
  } from './ports';
@@ -299,3 +300,172 @@ describe('extractTaskOutputs', () => {
299
300
  expect(r.diagnostic).toContain('could not find a final-line JSON object');
300
301
  });
301
302
  });
303
+
304
+ // ─── inferPromptPorts ───────────────────────────────────────────────
305
+
306
+ describe('inferPromptPorts', () => {
307
+ test('inputs are taken from direct-upstream Command outputs', () => {
308
+ const r = inferPromptPorts({
309
+ upstreams: [
310
+ {
311
+ taskId: 't.up',
312
+ outputs: [
313
+ { name: 'city', type: 'string' },
314
+ { name: 'id', type: 'number' },
315
+ ],
316
+ },
317
+ ],
318
+ downstreams: [],
319
+ });
320
+ expect(r.inputConflicts).toEqual([]);
321
+ expect(r.outputConflicts).toEqual([]);
322
+ expect(r.ports.inputs).toHaveLength(2);
323
+ expect(r.ports.inputs?.map((p) => p.name).sort()).toEqual(['city', 'id']);
324
+ // Inferred inputs default to required: the LLM wouldn't see a real
325
+ // value if the upstream failed to produce one.
326
+ expect(r.ports.inputs?.every((p) => p.required === true)).toBe(true);
327
+ expect(r.ports.outputs).toBeUndefined();
328
+ });
329
+
330
+ test('outputs are taken from direct-downstream Command inputs', () => {
331
+ const r = inferPromptPorts({
332
+ upstreams: [],
333
+ downstreams: [
334
+ {
335
+ taskId: 't.down',
336
+ inputs: [
337
+ { name: 'greeting', type: 'string', required: true },
338
+ { name: 'target', type: 'string', default: 'world' },
339
+ ],
340
+ },
341
+ ],
342
+ });
343
+ expect(r.outputConflicts).toEqual([]);
344
+ expect(r.ports.outputs?.map((p) => p.name).sort()).toEqual(['greeting', 'target']);
345
+ // Outputs drop input-only fields (required, default, from).
346
+ for (const p of r.ports.outputs ?? []) {
347
+ expect(p).not.toHaveProperty('required');
348
+ expect(p).not.toHaveProperty('default');
349
+ expect(p).not.toHaveProperty('from');
350
+ }
351
+ expect(r.ports.inputs).toBeUndefined();
352
+ });
353
+
354
+ test('Prompt neighbors (outputs undefined) contribute nothing', () => {
355
+ const r = inferPromptPorts({
356
+ upstreams: [
357
+ { taskId: 't.up', outputs: undefined }, // Prompt upstream
358
+ ],
359
+ downstreams: [
360
+ { taskId: 't.down', inputs: undefined }, // Prompt downstream
361
+ ],
362
+ });
363
+ expect(r.ports).toEqual({});
364
+ expect(r.inputConflicts).toEqual([]);
365
+ expect(r.outputConflicts).toEqual([]);
366
+ });
367
+
368
+ test('two upstreams with the same output name produce an input conflict', () => {
369
+ const r = inferPromptPorts({
370
+ upstreams: [
371
+ { taskId: 't.a', outputs: [{ name: 'city', type: 'string' }] },
372
+ { taskId: 't.b', outputs: [{ name: 'city', type: 'string' }] },
373
+ ],
374
+ downstreams: [],
375
+ });
376
+ expect(r.inputConflicts).toHaveLength(1);
377
+ expect(r.inputConflicts[0]!.portName).toBe('city');
378
+ expect(r.inputConflicts[0]!.producers.map((p) => p.taskId).sort()).toEqual(['t.a', 't.b']);
379
+ expect(r.inputConflicts[0]!.reason).toMatch(/cannot disambiguate/);
380
+ });
381
+
382
+ test('two downstreams with compatible input types merge silently', () => {
383
+ const r = inferPromptPorts({
384
+ upstreams: [],
385
+ downstreams: [
386
+ {
387
+ taskId: 't.d1',
388
+ inputs: [{ name: 'date', type: 'string', required: true }],
389
+ },
390
+ {
391
+ taskId: 't.d2',
392
+ inputs: [{ name: 'date', type: 'string', required: false }],
393
+ },
394
+ ],
395
+ });
396
+ expect(r.outputConflicts).toEqual([]);
397
+ expect(r.ports.outputs).toHaveLength(1);
398
+ expect(r.ports.outputs![0]!.name).toBe('date');
399
+ expect(r.ports.outputs![0]!.type).toBe('string');
400
+ });
401
+
402
+ test('two downstreams with incompatible input types produce an output conflict', () => {
403
+ const r = inferPromptPorts({
404
+ upstreams: [],
405
+ downstreams: [
406
+ { taskId: 't.d1', inputs: [{ name: 'date', type: 'string' }] },
407
+ { taskId: 't.d2', inputs: [{ name: 'date', type: 'number' }] },
408
+ ],
409
+ });
410
+ expect(r.outputConflicts).toHaveLength(1);
411
+ expect(r.outputConflicts[0]!.portName).toBe('date');
412
+ expect(r.outputConflicts[0]!.reason).toMatch(/conflicting type requirements/);
413
+ });
414
+
415
+ test('enum ports with differing value sets are incompatible', () => {
416
+ const r = inferPromptPorts({
417
+ upstreams: [],
418
+ downstreams: [
419
+ {
420
+ taskId: 't.d1',
421
+ inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'b'] }],
422
+ },
423
+ {
424
+ taskId: 't.d2',
425
+ inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'c'] }],
426
+ },
427
+ ],
428
+ });
429
+ expect(r.outputConflicts).toHaveLength(1);
430
+ });
431
+
432
+ test('enum ports with identical value sets merge', () => {
433
+ const r = inferPromptPorts({
434
+ upstreams: [],
435
+ downstreams: [
436
+ {
437
+ taskId: 't.d1',
438
+ inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'b'] }],
439
+ },
440
+ {
441
+ taskId: 't.d2',
442
+ inputs: [{ name: 'bucket', type: 'enum', enum: ['b', 'a'] }], // different order, same set
443
+ },
444
+ ],
445
+ });
446
+ expect(r.outputConflicts).toEqual([]);
447
+ expect(r.ports.outputs).toHaveLength(1);
448
+ });
449
+
450
+ test('description and enum propagate from the first occurrence', () => {
451
+ const r = inferPromptPorts({
452
+ upstreams: [
453
+ {
454
+ taskId: 't.up',
455
+ outputs: [
456
+ {
457
+ name: 'kind',
458
+ type: 'enum',
459
+ enum: ['hot', 'cold'],
460
+ description: 'Weather kind',
461
+ },
462
+ ],
463
+ },
464
+ ],
465
+ downstreams: [],
466
+ });
467
+ const port = r.ports.inputs![0]!;
468
+ expect(port.description).toBe('Weather kind');
469
+ expect(port.enum).toEqual(['hot', 'cold']);
470
+ });
471
+ });
package/src/ports.ts CHANGED
@@ -1,6 +1,6 @@
1
- // ═══ Task ports: substitute / resolve / extract ═══
1
+ // ═══ Task ports: substitute / resolve / extract / infer ═══
2
2
  //
3
- // One module, three concerns, all keyed on `task.ports`:
3
+ // One module, four concerns, all keyed on `task.ports`:
4
4
  //
5
5
  // 1. `substituteInputs(text, inputs)` — expand `{{inputs.<name>}}` in
6
6
  // user-authored strings (command lines, prompts). Strict syntax, no
@@ -22,10 +22,19 @@
22
22
  // it. Prefer `normalizedOutput` for AI tasks, fall back to raw
23
23
  // stdout — command tasks only ever have stdout.
24
24
  //
25
+ // 4. `inferPromptPorts({upstreams, downstreams})` — Prompt Tasks do NOT
26
+ // declare ports; their I/O contract is inferred from direct-neighbor
27
+ // Command Tasks. This helper synthesizes a `TaskPorts` object the
28
+ // engine can feed into the three concerns above, and surfaces any
29
+ // collisions that block the task (same port name on two upstreams,
30
+ // incompatible types across downstreams, …). Prompt neighbors
31
+ // contribute zero structured I/O — they pass free text via
32
+ // `continue_from` / normalizedOutput instead.
33
+ //
25
34
  // Everything here is pure / deterministic so it can be reused by the CLI,
26
35
  // the editor (for preview/simulation), and the engine without side effects.
27
36
 
28
- import type { PortDef, TaskConfig, TaskPorts } from './types';
37
+ import type { PortDef, PortType, TaskConfig, TaskPorts } from './types';
29
38
 
30
39
  // ─── Template substitution ────────────────────────────────────────────
31
40
 
@@ -440,3 +449,222 @@ function safeParseJson(candidate: string): Record<string, unknown> | null {
440
449
  }
441
450
  return null;
442
451
  }
452
+
453
+ // ─── Prompt-task port inference ───────────────────────────────────────
454
+ //
455
+ // Prompt Tasks have no declared ports. The engine calls `inferPromptPorts`
456
+ // to synthesize one from the Task's direct DAG neighbors:
457
+ //
458
+ // - **inputs** are taken from the declared `outputs` of every direct
459
+ // upstream Command Task. The union of names becomes the Prompt's
460
+ // inferred inputs. Upstream Prompt neighbors contribute nothing —
461
+ // information flows between Prompts as free text through
462
+ // `continue_from` / normalizedOutput, not through port values.
463
+ //
464
+ // - **outputs** are taken from the declared `inputs` of every direct
465
+ // downstream Command Task. The union of names becomes the Prompt's
466
+ // inferred outputs, which drives the `[Output Format]` block that
467
+ // tells the LLM what JSON to emit. Downstream Prompt neighbors
468
+ // contribute nothing (they just consume free text).
469
+ //
470
+ // Collisions:
471
+ //
472
+ // - **Input collision**: two upstream Commands both export an output
473
+ // named `city`. Command→Command would let a downstream add
474
+ // `from: taskId.city` to pick one; Prompt Tasks have no port
475
+ // declarations and therefore no escape hatch. The only fix is to
476
+ // rename on the Command side. We surface this as an `inputConflicts`
477
+ // entry; the engine blocks the task with that reason.
478
+ //
479
+ // - **Output collision with compatible types** (e.g. both downstreams
480
+ // ask for `date: string`) → merged into a single inferred output.
481
+ // Compatibility is determined by `type` and `enum` only; `description`
482
+ // differences are ignored. The Prompt produces one `date`; both
483
+ // downstreams consume it.
484
+ //
485
+ // - **Output collision with incompatible types** (e.g. one downstream
486
+ // wants `date: string`, another `date: number`) → no single LLM
487
+ // emission can satisfy both. Surfaced as `outputConflicts`; engine
488
+ // blocks the task. User must rename on one side.
489
+
490
+ export interface PromptUpstreamNeighbor {
491
+ readonly taskId: string;
492
+ /**
493
+ * Declared outputs of the upstream task. `undefined` signals that the
494
+ * neighbor is a Prompt Task (no structured contribution) or otherwise
495
+ * has no outputs to offer. The inference logic treats `undefined` and
496
+ * an empty array the same way — neither contributes ports.
497
+ */
498
+ readonly outputs: readonly PortDef[] | undefined;
499
+ }
500
+
501
+ export interface PromptDownstreamNeighbor {
502
+ readonly taskId: string;
503
+ /**
504
+ * Declared inputs of the downstream task. `undefined` signals a
505
+ * Prompt-Task neighbor or a Command Task without declared inputs.
506
+ * Either way it contributes no ports to the inferred output contract.
507
+ */
508
+ readonly inputs: readonly PortDef[] | undefined;
509
+ }
510
+
511
+ export interface PromptPortConflict {
512
+ readonly portName: string;
513
+ readonly producers: readonly { readonly taskId: string; readonly type: PortType }[];
514
+ /** Pre-formatted human-readable reason for logs / stderr. */
515
+ readonly reason: string;
516
+ }
517
+
518
+ export interface PromptPortInference {
519
+ /**
520
+ * Synthetic `TaskPorts` the engine feeds into the resolve / substitute /
521
+ * render / extract helpers, exactly as if the Prompt had declared these
522
+ * ports itself. Empty arrays are preserved as absent so downstream code
523
+ * paths treat "no ports" uniformly (see engine.ts's existing
524
+ * `task.ports?.outputs && task.ports.outputs.length > 0` guard).
525
+ */
526
+ readonly ports: TaskPorts;
527
+ readonly inputConflicts: readonly PromptPortConflict[];
528
+ readonly outputConflicts: readonly PromptPortConflict[];
529
+ }
530
+
531
+ /**
532
+ * Derive the effective `TaskPorts` for a Prompt Task from its direct
533
+ * neighbors. See the module-level "Prompt-task port inference" comment
534
+ * for the full contract.
535
+ *
536
+ * Pure function — no side effects, safe to call from the CLI, editor
537
+ * preview, and engine hot path alike.
538
+ */
539
+ export function inferPromptPorts(input: {
540
+ readonly upstreams: readonly PromptUpstreamNeighbor[];
541
+ readonly downstreams: readonly PromptDownstreamNeighbor[];
542
+ }): PromptPortInference {
543
+ const { upstreams, downstreams } = input;
544
+
545
+ // ─── Inputs: union of upstream-Command outputs ─────────────────────
546
+ //
547
+ // Walk every upstream in DAG order. First occurrence of a name wins
548
+ // (for the synthesized port shape used to resolve values). Subsequent
549
+ // occurrences under the same name become an `inputConflicts` entry —
550
+ // the engine blocks the task because a Prompt can't disambiguate.
551
+ const inputsByName = new Map<string, { port: PortDef; firstProducer: string }>();
552
+ const inputCollisionSources = new Map<string, { taskId: string; type: PortType }[]>();
553
+
554
+ for (const upstream of upstreams) {
555
+ if (!upstream.outputs || upstream.outputs.length === 0) continue;
556
+ for (const out of upstream.outputs) {
557
+ const prior = inputsByName.get(out.name);
558
+ if (!prior) {
559
+ // Copy the shape verbatim but drop output-only fields and force
560
+ // `required: true`. Prompt-task inferred inputs are required by
561
+ // default: the LLM wouldn't be getting a real-world value
562
+ // otherwise, and substituting an empty string silently is the
563
+ // same kind of bug we already reject elsewhere.
564
+ inputsByName.set(out.name, {
565
+ port: {
566
+ name: out.name,
567
+ type: out.type,
568
+ ...(out.description ? { description: out.description } : {}),
569
+ ...(out.enum ? { enum: [...out.enum] } : {}),
570
+ required: true,
571
+ },
572
+ firstProducer: upstream.taskId,
573
+ });
574
+ continue;
575
+ }
576
+ // Collision — seed the source list with the first producer too so
577
+ // the emitted conflict lists *all* contributing producers.
578
+ const list = inputCollisionSources.get(out.name) ?? [
579
+ { taskId: prior.firstProducer, type: prior.port.type },
580
+ ];
581
+ list.push({ taskId: upstream.taskId, type: out.type });
582
+ inputCollisionSources.set(out.name, list);
583
+ }
584
+ }
585
+
586
+ const inputConflicts: PromptPortConflict[] = [];
587
+ for (const [portName, producers] of inputCollisionSources) {
588
+ const producerList = producers.map((p) => p.taskId).join(', ');
589
+ inputConflicts.push({
590
+ portName,
591
+ producers,
592
+ reason:
593
+ `input "${portName}" is produced by multiple upstream Commands (${producerList}) — ` +
594
+ `Prompt tasks cannot disambiguate (no explicit "from:" binding). ` +
595
+ `Rename the output on one of the upstream Commands.`,
596
+ });
597
+ }
598
+
599
+ // ─── Outputs: union of downstream-Command inputs ───────────────────
600
+ //
601
+ // Compatible repeats merge (preserve first-encountered shape; prefer
602
+ // required when any downstream requires it). Incompatible repeats
603
+ // (different type, different enum set) go to `outputConflicts`.
604
+ const outputsByName = new Map<string, { port: PortDef; firstConsumer: string }>();
605
+ const outputCollisionSources = new Map<string, { taskId: string; type: PortType }[]>();
606
+
607
+ for (const downstream of downstreams) {
608
+ if (!downstream.inputs || downstream.inputs.length === 0) continue;
609
+ for (const inp of downstream.inputs) {
610
+ const prior = outputsByName.get(inp.name);
611
+ if (!prior) {
612
+ // Outputs drop input-only fields (required, default, from).
613
+ outputsByName.set(inp.name, {
614
+ port: {
615
+ name: inp.name,
616
+ type: inp.type,
617
+ ...(inp.description ? { description: inp.description } : {}),
618
+ ...(inp.enum ? { enum: [...inp.enum] } : {}),
619
+ },
620
+ firstConsumer: downstream.taskId,
621
+ });
622
+ continue;
623
+ }
624
+ if (portsAreCompatible(prior.port, inp)) continue; // merge silently
625
+ const list = outputCollisionSources.get(inp.name) ?? [
626
+ { taskId: prior.firstConsumer, type: prior.port.type },
627
+ ];
628
+ list.push({ taskId: downstream.taskId, type: inp.type });
629
+ outputCollisionSources.set(inp.name, list);
630
+ }
631
+ }
632
+
633
+ const outputConflicts: PromptPortConflict[] = [];
634
+ for (const [portName, producers] of outputCollisionSources) {
635
+ const consumerList = producers.map((p) => `${p.taskId} (${p.type})`).join(', ');
636
+ outputConflicts.push({
637
+ portName,
638
+ producers,
639
+ reason:
640
+ `output "${portName}" has conflicting type requirements across downstream Commands ` +
641
+ `(${consumerList}) — a single LLM emission cannot satisfy both. ` +
642
+ `Rename the input on one of the downstream Commands.`,
643
+ });
644
+ }
645
+
646
+ const inferredInputs = [...inputsByName.values()].map((e) => e.port);
647
+ const inferredOutputs = [...outputsByName.values()].map((e) => e.port);
648
+
649
+ const ports: TaskPorts = {
650
+ ...(inferredInputs.length > 0 ? { inputs: inferredInputs } : {}),
651
+ ...(inferredOutputs.length > 0 ? { outputs: inferredOutputs } : {}),
652
+ };
653
+ return { ports, inputConflicts, outputConflicts };
654
+ }
655
+
656
+ /**
657
+ * Two ports with the same name are compatible if they agree on `type`
658
+ * and, for enum ports, on the enum value set. Descriptions and
659
+ * required/default flags are deliberately ignored — they don't affect
660
+ * whether a single value can satisfy both consumers.
661
+ */
662
+ function portsAreCompatible(a: PortDef, b: PortDef): boolean {
663
+ if (a.type !== b.type) return false;
664
+ if (a.type === 'enum') {
665
+ const aEnum = [...(a.enum ?? [])].sort().join('');
666
+ const bEnum = [...(b.enum ?? [])].sort().join('');
667
+ if (aEnum !== bEnum) return false;
668
+ }
669
+ return true;
670
+ }
@@ -55,10 +55,10 @@ test('runSpawn: oversized output — bounded tail in memory, full bytes on disk'
55
55
  expect(result.exitCode).toBe(0);
56
56
  // Total bytes reported match reality
57
57
  expect(result.stdoutBytes).toBe(totalBytes);
58
- // In-memory tail bounded (tail + truncation marker header is a couple
59
- // hundred bytes at most; give it slack)
58
+ // In-memory tail bounded above (tail + truncation marker header is a
59
+ // couple hundred bytes at most; give it slack). No lower bound — chunk
60
+ // boundaries are platform-dependent so the exact retained size varies.
60
61
  expect(result.stdout.length).toBeLessThan(cap + 1024);
61
- expect(result.stdout.length).toBeGreaterThan(cap - 1024);
62
62
  // Truncation breadcrumb present and points at the full output
63
63
  expect(result.stdout).toContain('truncated from head');
64
64
  expect(result.stdout).toContain(stdoutPath);
package/src/runner.ts CHANGED
@@ -114,12 +114,20 @@ async function collectStream(
114
114
  const chunks: Uint8Array[] = [];
115
115
  let tailBytes = 0;
116
116
  let totalBytes = 0;
117
- const reader = stream.getReader();
117
+ let streamError: Error | null = null;
118
118
 
119
119
  try {
120
- for (;;) {
121
- const { done, value } = await reader.read();
122
- if (done) break;
120
+ // Use for await...of to avoid Bun bug where getReader() returns an
121
+ // incomplete reader missing releaseLock() under concurrent spawn.
122
+ // https://github.com/oven-sh/bun/issues/28952
123
+ //
124
+ // Bun 1.3.x also has sporadic failures iterating a spawned process's
125
+ // stream under concurrent Bun.spawn — the iterator throws mid-drain even
126
+ // when the child exited 0. We record the error as a breadcrumb instead
127
+ // of propagating, so the caller still sees the real exitCode from
128
+ // proc.exited and a task that the OS considered successful doesn't get
129
+ // marked failed over a runtime stream glitch.
130
+ for await (const value of stream as AsyncIterable<Uint8Array>) {
123
131
  totalBytes += value.length;
124
132
 
125
133
  // Disk: persist every byte. Failure here degrades to tail-only mode
@@ -157,8 +165,12 @@ async function collectStream(
157
165
  tailBytes = chunks[0]!.length;
158
166
  }
159
167
  }
168
+ } catch (err) {
169
+ streamError = err instanceof Error ? err : new Error(String(err));
170
+ console.error(
171
+ `[runner] stream read failed: ${streamError.message} — returning partial output`,
172
+ );
160
173
  } finally {
161
- reader.releaseLock();
162
174
  if (fh) {
163
175
  try {
164
176
  await fh.close();
@@ -187,6 +199,10 @@ async function collectStream(
187
199
  text = `[…${dropped} bytes truncated from head — full output at: ${pathHint}]\n${text}`;
188
200
  }
189
201
 
202
+ if (streamError) {
203
+ text = text + `\n[runner] stream read aborted: ${streamError.message}`;
204
+ }
205
+
190
206
  return {
191
207
  text,
192
208
  totalBytes,
package/src/sdk.ts CHANGED
@@ -27,7 +27,11 @@ export {
27
27
 
28
28
  // ── Raw config validation (real-time feedback) ──
29
29
  export { validateRaw } from './validate-raw';
30
- export type { ValidationError } from './validate-raw';
30
+ export type { ValidationError, KnownPluginTypes } from './validate-raw';
31
+
32
+ // ── YAML compiler (validation + compile log support) ──
33
+ export { compileYamlContent } from './yaml-compiler';
34
+ export type { YamlCompileResult, CompileYamlOptions } from './yaml-compiler';
31
35
 
32
36
  // ── Schema: parse / resolve / load / serialize / validate ──
33
37
  export {
@@ -127,8 +131,17 @@ export {
127
131
  extractInputReferences,
128
132
  resolveTaskInputs,
129
133
  extractTaskOutputs,
134
+ inferPromptPorts,
135
+ } from './ports';
136
+ export type {
137
+ SubstituteResult,
138
+ InputResolution,
139
+ ExtractResult,
140
+ PromptPortInference,
141
+ PromptPortConflict,
142
+ PromptUpstreamNeighbor,
143
+ PromptDownstreamNeighbor,
130
144
  } from './ports';
131
- export type { SubstituteResult, InputResolution, ExtractResult } from './ports';
132
145
 
133
146
  // ── All types from @tagma/types + runtime constants ──
134
147
  export * from './types';