@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/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +108 -9
- package/dist/engine.js.map +1 -1
- package/dist/ports.d.ts +53 -1
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +142 -2
- package/dist/ports.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +19 -6
- package/dist/runner.js.map +1 -1
- package/dist/sdk.d.ts +5 -3
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +3 -1
- package/dist/sdk.js.map +1 -1
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +240 -31
- package/dist/validate-raw.js.map +1 -1
- package/dist/yaml-compiler.d.ts +18 -0
- package/dist/yaml-compiler.d.ts.map +1 -0
- package/dist/yaml-compiler.js +59 -0
- package/dist/yaml-compiler.js.map +1 -0
- package/package.json +6 -1
- package/src/engine-ports-mixed.test.ts +499 -0
- package/src/engine.ts +118 -9
- package/src/ports.test.ts +170 -0
- package/src/ports.ts +231 -3
- package/src/runner.test.ts +3 -3
- package/src/runner.ts +21 -5
- package/src/sdk.ts +15 -2
- package/src/validate-raw-ports.test.ts +234 -49
- package/src/validate-raw.ts +269 -34
- package/src/yaml-compiler.ts +83 -0
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,
|
|
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
|
+
}
|
package/src/runner.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
117
|
+
let streamError: Error | null = null;
|
|
118
118
|
|
|
119
119
|
try {
|
|
120
|
-
for (
|
|
121
|
-
|
|
122
|
-
|
|
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';
|