@tagma/sdk 0.6.4 → 0.6.6
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 +74 -6
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +194 -21
- package/dist/engine.js.map +1 -1
- package/dist/pipeline-runner.d.ts.map +1 -1
- package/dist/pipeline-runner.js +3 -0
- package/dist/pipeline-runner.js.map +1 -1
- package/dist/ports.d.ts +118 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +365 -0
- package/dist/ports.js.map +1 -0
- package/dist/prompt-doc.d.ts +35 -1
- package/dist/prompt-doc.d.ts.map +1 -1
- package/dist/prompt-doc.js +110 -0
- package/dist/prompt-doc.js.map +1 -1
- package/dist/runner.d.ts +17 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +171 -8
- package/dist/runner.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +8 -0
- package/dist/schema.js.map +1 -1
- package/dist/sdk.d.ts +3 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +5 -1
- package/dist/sdk.js.map +1 -1
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +141 -0
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -7
- package/src/dag.test.ts +56 -0
- package/src/engine-ports.test.ts +404 -0
- package/src/engine.ts +231 -24
- package/src/pipeline-runner.ts +3 -0
- package/src/ports.test.ts +301 -0
- package/src/ports.ts +442 -0
- package/src/prompt-doc.test.ts +174 -0
- package/src/prompt-doc.ts +121 -1
- package/src/runner.test.ts +142 -0
- package/src/runner.ts +198 -8
- package/src/schema-ports.test.ts +236 -0
- package/src/schema.ts +8 -0
- package/src/sdk.ts +14 -0
- package/src/validate-raw-ports.test.ts +198 -0
- package/src/validate-raw.ts +155 -1
- package/dist/plugin-registry.test.d.ts +0 -2
- package/dist/plugin-registry.test.d.ts.map +0 -1
- package/dist/plugin-registry.test.js +0 -188
- package/dist/plugin-registry.test.js.map +0 -1
- package/dist/schema.test.d.ts +0 -2
- package/dist/schema.test.d.ts.map +0 -1
- package/dist/schema.test.js +0 -94
- package/dist/schema.test.js.map +0 -1
- package/dist/task-ref.test.d.ts +0 -2
- package/dist/task-ref.test.d.ts.map +0 -1
- package/dist/task-ref.test.js +0 -364
- package/dist/task-ref.test.js.map +0 -1
package/README.md
CHANGED
|
@@ -72,6 +72,7 @@ console.log(result.success ? 'Done' : 'Failed');
|
|
|
72
72
|
- **Middleware** -- enrich prompts before execution (e.g. inject static context)
|
|
73
73
|
- **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
|
|
74
74
|
- **Plugin schemas** -- triggers/completions/middlewares can declare a `PluginSchema` so visual editors render typed forms for their config
|
|
75
|
+
- **Typed task ports** -- declare named, typed `inputs` / `outputs` on a task. Inputs from upstream tasks are substituted into `command` / `prompt` via `{{inputs.<name>}}` and rendered as an `[Inputs]` context block for AI tasks; outputs are extracted from the final-line JSON object on stdout (or `normalizedOutput`) and surfaced to downstream tasks
|
|
75
76
|
|
|
76
77
|
## Pipeline YAML Reference
|
|
77
78
|
|
|
@@ -199,6 +200,7 @@ Each hook value can be a single command string or an array of commands.
|
|
|
199
200
|
| `middlewares` | `MiddlewareConfig[]` | No | Inherited from track | Middleware override. Set `[]` to disable inherited middlewares |
|
|
200
201
|
| `trigger` | `TriggerConfig` | No | — | Gate that must resolve before the task runs (see Triggers) |
|
|
201
202
|
| `completion` | `CompletionConfig` | No | — | Post-execution check to validate task output (see Completions) |
|
|
203
|
+
| `ports` | `TaskPorts` | No | — | Typed input/output ports — see Typed Ports below |
|
|
202
204
|
|
|
203
205
|
### Permissions
|
|
204
206
|
|
|
@@ -218,6 +220,51 @@ Track-level `middlewares` apply to all tasks in the track. Setting task-level `m
|
|
|
218
220
|
|
|
219
221
|
---
|
|
220
222
|
|
|
223
|
+
### Typed Ports
|
|
224
|
+
|
|
225
|
+
Tasks can declare named, typed `inputs` / `outputs`. Inputs flow in from upstream task outputs; outputs are extracted from a task's stdout (or the AI driver's `normalizedOutput`) on success.
|
|
226
|
+
|
|
227
|
+
```yaml
|
|
228
|
+
- id: lookup-weather
|
|
229
|
+
name: Lookup weather
|
|
230
|
+
command: weather.sh --city "{{inputs.city}}"
|
|
231
|
+
ports:
|
|
232
|
+
inputs:
|
|
233
|
+
- { name: city, type: string, required: true, description: Target city }
|
|
234
|
+
outputs:
|
|
235
|
+
- { name: temperature, type: number, description: Current temperature in Celsius }
|
|
236
|
+
- { name: conditions, type: enum, enum: [sunny, cloudy, rain, snow] }
|
|
237
|
+
|
|
238
|
+
- id: write-report
|
|
239
|
+
depends_on: [lookup-weather]
|
|
240
|
+
prompt: 'Write a brief weather report for {{inputs.city}}.'
|
|
241
|
+
ports:
|
|
242
|
+
inputs:
|
|
243
|
+
- { name: city, type: string, required: true }
|
|
244
|
+
- { name: temperature, type: number, required: true }
|
|
245
|
+
- { name: conditions, type: enum, enum: [sunny, cloudy, rain, snow] }
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
#### `PortDef` fields
|
|
249
|
+
|
|
250
|
+
| Field | Type | Required | Description |
|
|
251
|
+
| ------------- | ----------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
252
|
+
| `name` | `string` | Yes | Port name; also the substitution key (`{{inputs.<name>}}`) |
|
|
253
|
+
| `type` | `'string' \| 'number' \| 'boolean' \| 'enum' \| 'json'` | Yes | Coercion type. Mismatched values block the task with a typed-error diagnostic |
|
|
254
|
+
| `description` | `string` | No | Free-text description; rendered into the `[Inputs]` / `[Output Format]` blocks |
|
|
255
|
+
| `required` | `boolean` | No | Inputs only. When `true`, missing upstream value (and no `default`) blocks the task |
|
|
256
|
+
| `default` | `unknown` | No | Inputs only. Fallback value when no upstream produces the port |
|
|
257
|
+
| `enum` | `string[]` | When `type: enum` | Allowed values |
|
|
258
|
+
| `from` | `string` | No | Inputs only. Explicit upstream binding — bare `portName` (match by name) or `taskId.portName` (fully qualified). Unset = match by name across all direct upstreams; ambiguous matches block |
|
|
259
|
+
|
|
260
|
+
#### Substitution and AI prompt blocks
|
|
261
|
+
|
|
262
|
+
- `{{inputs.<name>}}` is expanded verbatim in `command` and `prompt` strings before execution. Quote your placeholders in command lines (`--city "{{inputs.city}}"`) — the engine does not shell-escape.
|
|
263
|
+
- AI tasks additionally get two prepended `PromptContextBlock`s: `[Output Format]` (instructs the model to emit a final-line JSON object matching the declared outputs) and `[Inputs]` (renders the resolved inputs as `name: value # description`). Tasks without ports get no extra blocks.
|
|
264
|
+
- Output extraction strategy: prefer `normalizedOutput` (AI tasks), fall back to stdout (command tasks). Find the last non-empty line that parses as a JSON object, then read each declared output key. Failures append a diagnostic to stderr; the port is absent from `outputs` and downstream tasks see it as missing.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
221
268
|
### Built-in Triggers
|
|
222
269
|
|
|
223
270
|
#### `manual` — Human approval gate
|
|
@@ -306,6 +353,8 @@ Options:
|
|
|
306
353
|
- `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
|
|
307
354
|
- `skipPluginLoading` -- skip the engine's built-in `loadPlugins(config.plugins)` call. Set this when the host has already pre-loaded plugins from a custom resolution path (e.g. the editor loading from the user's workspace `node_modules`) so the engine doesn't re-resolve them via Node's default cwd-based import.
|
|
308
355
|
|
|
356
|
+
> **stdout / stderr persistence.** The engine streams every task's stdout and stderr to disk under `<workDir>/.tagma/logs/<runId>/<taskId>.stdout` and `.stderr`. The `TaskResult.stdout` / `stderr` strings are bounded tails (8 MB / 4 MB by default) — long outputs are truncated from the head with a marker, and consumers that need the full bytes should read `TaskResult.stdoutPath` / `stderrPath`. Use `TaskResult.stdoutBytes` / `stderrBytes` to display "32 MB (truncated)" without re-stat'ing the file.
|
|
357
|
+
|
|
309
358
|
### `PipelineRunner`
|
|
310
359
|
|
|
311
360
|
Higher-level wrapper for managing multiple concurrent pipeline runs — designed for sidecar / Tauri IPC scenarios where the frontend controls pipeline lifecycle by ID.
|
|
@@ -474,11 +523,27 @@ Validates a resolved pipeline config without executing it. Checks DAG structure
|
|
|
474
523
|
|
|
475
524
|
Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `resolveConfig` for a final pre-run check.
|
|
476
525
|
|
|
526
|
+
### Typed Ports API
|
|
527
|
+
|
|
528
|
+
Pure helpers backing the `task.ports` feature (see Typed Ports above). Safe to use in editors, simulators, and custom drivers — no I/O, no side effects.
|
|
529
|
+
|
|
530
|
+
| Function | Description |
|
|
531
|
+
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
532
|
+
| `substituteInputs(text, inputs)` | Expand `{{inputs.<name>}}` placeholders in `text`. Returns `{ text, unresolved }`. Strings pass through, numbers/booleans coerce via `String(...)`, objects/arrays via `JSON.stringify`. Caller is responsible for shell quoting |
|
|
533
|
+
| `extractInputReferences(text)` | Return the set of input port names referenced by `{{inputs.<name>}}` placeholders in `text`. Use at edit time to flag undeclared references |
|
|
534
|
+
| `resolveTaskInputs(task, upstreamOutputs, dependsOn)` | Gather the input values a task will consume from its direct upstreams. Applies `from` bindings, defaults, and type coercion. Returns `{ kind: 'ready', inputs, missingOptional }` or `{ kind: 'blocked', missingRequired, ambiguous, typeErrors, reason }` |
|
|
535
|
+
| `extractTaskOutputs(ports, stdout, normalizedOutput)` | Pull declared output values from a terminated task's output. Strategy: prefer `normalizedOutput`; find the last non-empty line that parses as a JSON object; coerce each declared key. Returns `{ outputs, diagnostic }` |
|
|
536
|
+
| `prependContext(doc, block)` | Same shape as `appendContext` but prepends; the engine uses this to place `[Output Format]` and `[Inputs]` blocks before middleware-added context |
|
|
537
|
+
| `renderInputsBlock(inputsDecl, values)` | Build the `[Inputs]` `PromptContextBlock` rendered into AI prompts (`name: value # description` lines). Returns `null` when no inputs to render |
|
|
538
|
+
| `renderOutputSchemaBlock(outputsDecl)` | Build the `[Output Format]` `PromptContextBlock` instructing the model to emit a final-line JSON object matching the declared outputs. Returns `null` when no outputs declared |
|
|
539
|
+
|
|
540
|
+
Custom drivers that wrap the prompt in their own envelope can read `DriverContext.inputs` (resolved + coerced map keyed by port name) and call `substituteInputs` themselves — the engine has already substituted into `task.prompt` upstream, so most drivers can ignore this.
|
|
541
|
+
|
|
477
542
|
### `validateRaw(config: RawPipelineConfig): ValidationError[]`
|
|
478
543
|
|
|
479
544
|
Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
|
|
480
545
|
|
|
481
|
-
Checks: required fields, `prompt`/`command` exclusivity, duplicate task IDs within a track, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection.
|
|
546
|
+
Checks: required fields, `prompt`/`command` exclusivity, duplicate task IDs within a track, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection, port shape (name format, valid `type`, duplicate names, `enum` requires non-empty `enum` array, `required`/`from` ignored on outputs), and `{{inputs.<name>}}` references resolving to a declared input port.
|
|
482
547
|
|
|
483
548
|
Does **not** check plugin registration (plugins may not be loaded at edit time).
|
|
484
549
|
|
|
@@ -562,11 +627,14 @@ Truncates `text` to at most `maxBytes` UTF-8 bytes (default 16 KB), appending a
|
|
|
562
627
|
|
|
563
628
|
## Related Packages
|
|
564
629
|
|
|
565
|
-
| Package
|
|
566
|
-
|
|
|
567
|
-
| [@tagma/types](https://www.npmjs.com/package/@tagma/types)
|
|
568
|
-
| [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex)
|
|
569
|
-
| [@tagma/driver-claude-code](https://www.npmjs.com/package/@tagma/driver-claude-code)
|
|
630
|
+
| Package | Description |
|
|
631
|
+
| ---------------------------------------------------------------------------------------- | --------------------------------------------- |
|
|
632
|
+
| [@tagma/types](https://www.npmjs.com/package/@tagma/types) | Shared TypeScript types |
|
|
633
|
+
| [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex) | Codex CLI driver plugin |
|
|
634
|
+
| [@tagma/driver-claude-code](https://www.npmjs.com/package/@tagma/driver-claude-code) | Claude Code CLI driver plugin |
|
|
635
|
+
| [@tagma/middleware-lightrag](https://www.npmjs.com/package/@tagma/middleware-lightrag) | LightRAG knowledge-graph retrieval middleware |
|
|
636
|
+
| [@tagma/trigger-webhook](https://www.npmjs.com/package/@tagma/trigger-webhook) | HTTP webhook trigger plugin |
|
|
637
|
+
| [@tagma/completion-llm-judge](https://www.npmjs.com/package/@tagma/completion-llm-judge) | LLM-as-judge completion plugin |
|
|
570
638
|
|
|
571
639
|
## License
|
|
572
640
|
|
package/dist/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,cAAc,EAEd,SAAS,EAaT,eAAe,EAEhB,MAAM,SAAS,CAAC;AAEjB,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,cAAc,EAEd,SAAS,EAaT,eAAe,EAEhB,MAAM,SAAS,CAAC;AAEjB,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,YAAY,CAAC;AAsBlE,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAM3E,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,IAAI,EAAG,iBAAiB,CAAU;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AAED,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,IAAI,EAAG,iBAAiB,CAAU;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AA6ED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;IACF,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;CACjD;AAWD,YAAY,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AA8C/C,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,eAAe,CAAC,EAAE,eAAe,CAAC;IAC3C;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IACpD;;;;;OAKG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,cAAc,CAAC;CACpC;AAWD,wBAAsB,WAAW,CAC/B,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,YAAY,CAAC,CAqlCvB"}
|
package/dist/engine.js
CHANGED
|
@@ -4,7 +4,8 @@ import { buildDag } from './dag';
|
|
|
4
4
|
import { defaultRegistry } from './registry';
|
|
5
5
|
import { runSpawn, runCommand } from './runner';
|
|
6
6
|
import { parseDuration, nowISO, generateRunId } from './utils';
|
|
7
|
-
import { promptDocumentFromString, serializePromptDocument } from './prompt-doc';
|
|
7
|
+
import { promptDocumentFromString, serializePromptDocument, prependContext, renderInputsBlock, renderOutputSchemaBlock, } from './prompt-doc';
|
|
8
|
+
import { extractTaskOutputs, resolveTaskInputs, substituteInputs } from './ports';
|
|
8
9
|
import { executeHook, buildPipelineStartContext, buildTaskContext, buildPipelineCompleteContext, buildPipelineErrorContext, } from './hooks';
|
|
9
10
|
import { Logger, tailLines, clip } from './logger';
|
|
10
11
|
import { InMemoryApprovalGateway } from './approval';
|
|
@@ -102,12 +103,19 @@ function toRunTaskState(taskId, trackId, taskName, state) {
|
|
|
102
103
|
exitCode: result?.exitCode ?? null,
|
|
103
104
|
stdout: result?.stdout ?? '',
|
|
104
105
|
stderr: result?.stderr ?? '',
|
|
106
|
+
stdoutPath: result?.stdoutPath ?? null,
|
|
105
107
|
stderrPath: result?.stderrPath ?? null,
|
|
108
|
+
stdoutBytes: result?.stdoutBytes ?? null,
|
|
109
|
+
stderrBytes: result?.stderrBytes ?? null,
|
|
106
110
|
sessionId: result?.sessionId ?? null,
|
|
107
111
|
normalizedOutput: result?.normalizedOutput ?? null,
|
|
108
112
|
resolvedDriver: cfg.driver ?? null,
|
|
109
113
|
resolvedModel: cfg.model ?? null,
|
|
110
114
|
resolvedPermissions: cfg.permissions ?? null,
|
|
115
|
+
// Ports not yet wired through the engine's event surface. Null placeholder
|
|
116
|
+
// keeps the wire type honest until the ports extraction pass lands.
|
|
117
|
+
outputs: result?.outputs ?? null,
|
|
118
|
+
inputs: null,
|
|
111
119
|
logs: [],
|
|
112
120
|
totalLogCount: 0,
|
|
113
121
|
};
|
|
@@ -218,6 +226,17 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
218
226
|
emit({ type: 'run_start', runId, tasks: runStartTasks });
|
|
219
227
|
const sessionMap = new Map();
|
|
220
228
|
const normalizedMap = new Map();
|
|
229
|
+
// Extracted port outputs keyed by fully-qualified task id. Populated
|
|
230
|
+
// after a task succeeds when its `ports.outputs` is declared; read by
|
|
231
|
+
// downstream tasks via `resolveTaskInputs` to assemble their inputs.
|
|
232
|
+
// Kept separate from normalizedMap so the continue_from text handoff
|
|
233
|
+
// and the typed-port data handoff don't pollute each other — they
|
|
234
|
+
// solve different problems and have different lifetimes.
|
|
235
|
+
const outputValuesMap = new Map();
|
|
236
|
+
// Resolved port inputs keyed by fully-qualified task id. Written once,
|
|
237
|
+
// just before a task runs, so every subsequent task_update event can
|
|
238
|
+
// echo them to the UI without re-resolving.
|
|
239
|
+
const resolvedInputsMap = new Map();
|
|
221
240
|
// Pipeline timeout + abort reason tracking.
|
|
222
241
|
//
|
|
223
242
|
// `abortReason` replaces the previous `pipelineAborted: boolean`: it
|
|
@@ -314,9 +333,14 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
314
333
|
exitCode: result?.exitCode,
|
|
315
334
|
stdout: result?.stdout,
|
|
316
335
|
stderr: result?.stderr,
|
|
336
|
+
stdoutPath: result?.stdoutPath ?? null,
|
|
317
337
|
stderrPath: result?.stderrPath ?? null,
|
|
338
|
+
stdoutBytes: result?.stdoutBytes ?? null,
|
|
339
|
+
stderrBytes: result?.stderrBytes ?? null,
|
|
318
340
|
sessionId: result?.sessionId ?? null,
|
|
319
341
|
normalizedOutput: result?.normalizedOutput ?? null,
|
|
342
|
+
inputs: resolvedInputsMap.get(taskId) ?? null,
|
|
343
|
+
outputs: outputValuesMap.get(taskId) ?? null,
|
|
320
344
|
resolvedDriver: cfg.driver ?? null,
|
|
321
345
|
resolvedModel: cfg.model ?? null,
|
|
322
346
|
resolvedPermissions: cfg.permissions ?? null,
|
|
@@ -413,20 +437,28 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
413
437
|
log.debug(`[task:${taskId}]`, `trigger wait: type=${task.trigger.type} ${JSON.stringify(task.trigger)}`);
|
|
414
438
|
try {
|
|
415
439
|
const triggerPlugin = registry.getHandler('triggers', task.trigger.type);
|
|
416
|
-
// R6: race the plugin's watch() against the pipeline's abort signal
|
|
417
|
-
// Third-party triggers may forget to
|
|
418
|
-
//
|
|
419
|
-
// plugin's watch promise to
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
440
|
+
// R6: race the plugin's watch() against the pipeline's abort signal
|
|
441
|
+
// AND the task-level timeout. Third-party triggers may forget to
|
|
442
|
+
// wire up ctx.signal — without the abort race, an aborted pipeline
|
|
443
|
+
// would hang forever waiting for the plugin's watch promise to
|
|
444
|
+
// resolve. And without the timeout race, a buggy watch() that never
|
|
445
|
+
// settles would ignore the user's `task.timeout` (which the spawn
|
|
446
|
+
// path at step 4 already honours) — a task could wedge the whole
|
|
447
|
+
// pipeline until pipeline-level timeout fires (or forever, if none
|
|
448
|
+
// is set). Honouring task.timeout here makes the two stages
|
|
449
|
+
// symmetric. The cleanup paths in finally never run on the orphaned
|
|
450
|
+
// plugin promise (it's allowed to leak a watcher; the pipeline is
|
|
451
|
+
// being torn down anyway).
|
|
452
|
+
const triggerTimeoutMs = task.timeout ? parseDuration(task.timeout) : 0;
|
|
423
453
|
await new Promise((resolve, reject) => {
|
|
424
454
|
let settled = false;
|
|
455
|
+
let timer = null;
|
|
425
456
|
const onAbort = () => {
|
|
426
457
|
if (settled)
|
|
427
458
|
return;
|
|
428
459
|
settled = true;
|
|
429
|
-
|
|
460
|
+
if (timer !== null)
|
|
461
|
+
clearTimeout(timer);
|
|
430
462
|
reject(new Error('Pipeline aborted'));
|
|
431
463
|
};
|
|
432
464
|
if (abortController.signal.aborted) {
|
|
@@ -434,6 +466,15 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
434
466
|
return;
|
|
435
467
|
}
|
|
436
468
|
abortController.signal.addEventListener('abort', onAbort, { once: true });
|
|
469
|
+
if (triggerTimeoutMs > 0) {
|
|
470
|
+
timer = setTimeout(() => {
|
|
471
|
+
if (settled)
|
|
472
|
+
return;
|
|
473
|
+
settled = true;
|
|
474
|
+
abortController.signal.removeEventListener('abort', onAbort);
|
|
475
|
+
reject(new TriggerTimeoutError(`Trigger "${task.trigger.type}" did not settle within ${task.timeout} (task-level timeout)`));
|
|
476
|
+
}, triggerTimeoutMs);
|
|
477
|
+
}
|
|
437
478
|
triggerPlugin
|
|
438
479
|
.watch(task.trigger, {
|
|
439
480
|
taskId: node.taskId,
|
|
@@ -446,12 +487,16 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
446
487
|
if (settled)
|
|
447
488
|
return;
|
|
448
489
|
settled = true;
|
|
490
|
+
if (timer !== null)
|
|
491
|
+
clearTimeout(timer);
|
|
449
492
|
abortController.signal.removeEventListener('abort', onAbort);
|
|
450
493
|
resolve(v);
|
|
451
494
|
}, (e) => {
|
|
452
495
|
if (settled)
|
|
453
496
|
return;
|
|
454
497
|
settled = true;
|
|
498
|
+
if (timer !== null)
|
|
499
|
+
clearTimeout(timer);
|
|
455
500
|
abortController.signal.removeEventListener('abort', onAbort);
|
|
456
501
|
reject(e);
|
|
457
502
|
});
|
|
@@ -510,6 +555,49 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
510
555
|
}
|
|
511
556
|
return;
|
|
512
557
|
}
|
|
558
|
+
// 3.5. Resolve port inputs from upstream outputs. This is the last
|
|
559
|
+
// gate before execution: missing-required inputs block the task
|
|
560
|
+
// without ever spawning a process, so the caller sees a clear
|
|
561
|
+
// "blocked: missing input X" rather than a cryptic runtime error
|
|
562
|
+
// from a command that expanded a placeholder to the empty string.
|
|
563
|
+
// Resolution runs even for tasks that declare no ports — the call
|
|
564
|
+
// is cheap and returns `{kind: 'ready', inputs: {}}` in that case,
|
|
565
|
+
// which downstream code handles uniformly.
|
|
566
|
+
const inputResolution = resolveTaskInputs(task, outputValuesMap, node.dependsOn);
|
|
567
|
+
if (inputResolution.kind === 'blocked') {
|
|
568
|
+
log.error(`[task:${taskId}]`, `blocked — cannot resolve port inputs:\n${inputResolution.reason}`);
|
|
569
|
+
state.result = {
|
|
570
|
+
exitCode: -1,
|
|
571
|
+
stdout: '',
|
|
572
|
+
stderr: `[engine] port input resolution failed:\n${inputResolution.reason}`,
|
|
573
|
+
stdoutPath: null,
|
|
574
|
+
stderrPath: null,
|
|
575
|
+
durationMs: 0,
|
|
576
|
+
sessionId: null,
|
|
577
|
+
normalizedOutput: null,
|
|
578
|
+
failureKind: 'spawn_error',
|
|
579
|
+
outputs: null,
|
|
580
|
+
};
|
|
581
|
+
state.finishedAt = nowISO();
|
|
582
|
+
setTaskStatus(taskId, 'blocked');
|
|
583
|
+
try {
|
|
584
|
+
await fireHook(taskId, 'task_failure');
|
|
585
|
+
}
|
|
586
|
+
catch (hookErr) {
|
|
587
|
+
log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
|
|
588
|
+
}
|
|
589
|
+
if (getOnFailure(taskId) === 'stop_all')
|
|
590
|
+
applyStopAll(node.track.id);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const resolvedInputs = inputResolution.inputs;
|
|
594
|
+
resolvedInputsMap.set(taskId, resolvedInputs);
|
|
595
|
+
if (inputResolution.missingOptional.length > 0) {
|
|
596
|
+
log.debug(`[task:${taskId}]`, `optional inputs unresolved (empty in placeholders): ${inputResolution.missingOptional.join(', ')}`);
|
|
597
|
+
}
|
|
598
|
+
if (task.ports?.inputs && task.ports.inputs.length > 0) {
|
|
599
|
+
log.debug(`[task:${taskId}]`, `resolved inputs: ${JSON.stringify(resolvedInputs)}`);
|
|
600
|
+
}
|
|
513
601
|
// 4. Mark running — set startedAt before emitting so subscribers see a
|
|
514
602
|
// complete task_update (startedAt non-null) on the status transition.
|
|
515
603
|
state.startedAt = nowISO();
|
|
@@ -531,17 +619,60 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
531
619
|
try {
|
|
532
620
|
let result;
|
|
533
621
|
const timeoutMs = task.timeout ? parseDuration(task.timeout) : undefined;
|
|
534
|
-
|
|
622
|
+
// Stream child stdout/stderr directly to disk in the logger's run dir
|
|
623
|
+
// and keep only a bounded tail in the returned TaskResult. Filenames
|
|
624
|
+
// mirror the existing `.stderr` naming — dots in task ids are replaced
|
|
625
|
+
// so hierarchical ids (e.g. `track1.task2`) map cleanly to a flat dir.
|
|
626
|
+
const fsSafeTaskId = taskId.replace(/\./g, '_');
|
|
627
|
+
const stdoutPath = resolve(log.dir, `${fsSafeTaskId}.stdout`);
|
|
628
|
+
const stderrPath = resolve(log.dir, `${fsSafeTaskId}.stderr`);
|
|
629
|
+
const runOpts = {
|
|
630
|
+
timeoutMs,
|
|
631
|
+
signal: abortController.signal,
|
|
632
|
+
stdoutPath,
|
|
633
|
+
stderrPath,
|
|
634
|
+
};
|
|
535
635
|
if (task.command) {
|
|
536
|
-
|
|
537
|
-
|
|
636
|
+
// Substitute `{{inputs.X}}` placeholders into the command
|
|
637
|
+
// string. Tasks with no declared inputs always produce the same
|
|
638
|
+
// string back (no placeholders to match). Unresolved references
|
|
639
|
+
// render empty — validate-raw flags undeclared references as
|
|
640
|
+
// errors, so the only way to land here with an unresolved is an
|
|
641
|
+
// optional input that had no upstream producer and no default,
|
|
642
|
+
// which we surface in the log.
|
|
643
|
+
const { text: expandedCommand, unresolved } = substituteInputs(task.command, resolvedInputs);
|
|
644
|
+
if (unresolved.length > 0) {
|
|
645
|
+
log.debug(`[task:${taskId}]`, `command placeholders rendered empty: ${unresolved.join(', ')}`);
|
|
646
|
+
}
|
|
647
|
+
log.debug(`[task:${taskId}]`, `command: ${expandedCommand}`);
|
|
648
|
+
result = await runCommand(expandedCommand, task.cwd ?? workDir, runOpts);
|
|
538
649
|
}
|
|
539
650
|
else {
|
|
540
651
|
// AI task: apply middleware chain against a structured PromptDocument.
|
|
541
652
|
const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
|
|
542
653
|
const driver = registry.getHandler('drivers', driverName);
|
|
543
|
-
|
|
544
|
-
|
|
654
|
+
// Substitute placeholders in the user-authored prompt before
|
|
655
|
+
// wrapping into a PromptDocument so middlewares see the
|
|
656
|
+
// already-resolved task text.
|
|
657
|
+
const { text: expandedPrompt, unresolved } = substituteInputs(task.prompt, resolvedInputs);
|
|
658
|
+
if (unresolved.length > 0) {
|
|
659
|
+
log.debug(`[task:${taskId}]`, `prompt placeholders rendered empty: ${unresolved.join(', ')}`);
|
|
660
|
+
}
|
|
661
|
+
const originalLen = expandedPrompt.length;
|
|
662
|
+
let doc = promptDocumentFromString(expandedPrompt);
|
|
663
|
+
// Prepend port-related context blocks so the model sees them
|
|
664
|
+
// before any middleware-added retrieval / memory blocks. Order
|
|
665
|
+
// matters: [Output Format] first (sets the deliverable), then
|
|
666
|
+
// [Inputs] (the concrete data to operate on). Empty blocks are
|
|
667
|
+
// filtered out — tasks without ports get no extra blocks at all.
|
|
668
|
+
const outputFormatBlock = renderOutputSchemaBlock(task.ports?.outputs);
|
|
669
|
+
if (outputFormatBlock) {
|
|
670
|
+
doc = prependContext(doc, outputFormatBlock);
|
|
671
|
+
}
|
|
672
|
+
const inputsBlock = renderInputsBlock(task.ports?.inputs, resolvedInputs);
|
|
673
|
+
if (inputsBlock) {
|
|
674
|
+
doc = prependContext(doc, inputsBlock);
|
|
675
|
+
}
|
|
545
676
|
const mws = task.middlewares !== undefined ? task.middlewares : track.middlewares;
|
|
546
677
|
if (mws && mws.length > 0) {
|
|
547
678
|
log.debug(`[task:${taskId}]`, `middleware chain: ${mws.map((m) => m.type).join(' → ')}`);
|
|
@@ -625,6 +756,13 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
625
756
|
// contexts and task). Drivers that read task.prompt see the
|
|
626
757
|
// default serialization and need no changes.
|
|
627
758
|
promptDoc: doc,
|
|
759
|
+
// Ports feature: resolved input values keyed by port name,
|
|
760
|
+
// already coerced to the declared port type. Drivers that
|
|
761
|
+
// need to re-substitute placeholders inside a custom envelope
|
|
762
|
+
// can read this and call `substituteInputs`; most drivers can
|
|
763
|
+
// ignore it because the engine has already expanded
|
|
764
|
+
// `{{inputs.X}}` into `task.prompt` upstream.
|
|
765
|
+
inputs: resolvedInputs,
|
|
628
766
|
};
|
|
629
767
|
const spec = await driver.buildCommand(enrichedTask, track, driverCtx);
|
|
630
768
|
log.debug(`[task:${taskId}]`, `driver=${driverName}`);
|
|
@@ -672,6 +810,33 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
672
810
|
else {
|
|
673
811
|
terminalStatus = 'success';
|
|
674
812
|
}
|
|
813
|
+
// Extract declared port outputs from the task's output stream.
|
|
814
|
+
// Only meaningful on success — a failed task's output is whatever
|
|
815
|
+
// the child happened to emit before exiting, and downstream tasks
|
|
816
|
+
// shouldn't receive partial data. `extractTaskOutputs` is a no-op
|
|
817
|
+
// when the task has no declared outputs, so this is free for
|
|
818
|
+
// pre-ports tasks. Diagnostics are appended to stderr so users
|
|
819
|
+
// see *why* a downstream input is missing without having to dig
|
|
820
|
+
// through driver-specific logs.
|
|
821
|
+
let extractedOutputs = null;
|
|
822
|
+
if (terminalStatus === 'success') {
|
|
823
|
+
const extraction = extractTaskOutputs(task.ports, result.stdout, result.normalizedOutput);
|
|
824
|
+
if (task.ports?.outputs && task.ports.outputs.length > 0) {
|
|
825
|
+
extractedOutputs = extraction.outputs;
|
|
826
|
+
outputValuesMap.set(taskId, extraction.outputs);
|
|
827
|
+
log.debug(`[task:${taskId}]`, `extracted outputs: ${JSON.stringify(extraction.outputs)}`);
|
|
828
|
+
if (extraction.diagnostic) {
|
|
829
|
+
log.error(`[task:${taskId}]`, extraction.diagnostic);
|
|
830
|
+
const note = `\n[engine] ${extraction.diagnostic}`;
|
|
831
|
+
result = { ...result, stderr: result.stderr + note };
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
// Attach outputs to the result (null when task has no declared
|
|
836
|
+
// outputs or extraction failed entirely). Consumers of TaskResult
|
|
837
|
+
// — hooks, wire events, test assertions — all go through this
|
|
838
|
+
// one field rather than re-running extraction.
|
|
839
|
+
result = { ...result, outputs: extractedOutputs };
|
|
675
840
|
// Store normalized text separately (in-memory) for continue_from handoff.
|
|
676
841
|
// R15: clip oversized values so a runaway parseResult can't accumulate
|
|
677
842
|
// hundreds of MB across tasks.
|
|
@@ -682,11 +847,9 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
682
847
|
: result.normalizedOutput;
|
|
683
848
|
normalizedMap.set(taskId, clipped);
|
|
684
849
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
result = { ...result, stderrPath };
|
|
689
|
-
}
|
|
850
|
+
// Note: stderr is already persisted by runner.ts as it streams; the
|
|
851
|
+
// old "write full string after the fact" block is gone — that's what
|
|
852
|
+
// the streaming rewrite fixed (unbounded in-memory buffering).
|
|
690
853
|
if (result.sessionId) {
|
|
691
854
|
// H1: qualified-only key.
|
|
692
855
|
sessionMap.set(taskId, result.sessionId);
|
|
@@ -707,11 +870,18 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
707
870
|
log.error(`[task:${taskId}]`, `stderr tail:\n${tail}`);
|
|
708
871
|
}
|
|
709
872
|
}
|
|
710
|
-
// File-only: full
|
|
711
|
-
|
|
873
|
+
// File-only: byte counts (prefer full totals from the runner over the
|
|
874
|
+
// bounded tail length so oversized outputs show their real size) +
|
|
875
|
+
// paths to the on-disk full copies.
|
|
876
|
+
const stdoutSize = result.stdoutBytes ?? result.stdout.length;
|
|
877
|
+
const stderrSize = result.stderrBytes ?? result.stderr.length;
|
|
878
|
+
log.debug(`[task:${taskId}]`, `stdout: ${stdoutSize} bytes, stderr: ${stderrSize} bytes`);
|
|
712
879
|
if (result.sessionId) {
|
|
713
880
|
log.debug(`[task:${taskId}]`, `sessionId: ${result.sessionId}`);
|
|
714
881
|
}
|
|
882
|
+
if (result.stdoutPath) {
|
|
883
|
+
log.debug(`[task:${taskId}]`, `wrote stdout: ${result.stdoutPath}`);
|
|
884
|
+
}
|
|
715
885
|
if (result.stderrPath) {
|
|
716
886
|
log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
|
|
717
887
|
}
|
|
@@ -732,7 +902,10 @@ export async function runPipeline(config, workDir, options = {}) {
|
|
|
732
902
|
exitCode: -1,
|
|
733
903
|
stdout: '',
|
|
734
904
|
stderr: errMsg,
|
|
905
|
+
stdoutPath: null,
|
|
735
906
|
stderrPath: null,
|
|
907
|
+
stdoutBytes: 0,
|
|
908
|
+
stderrBytes: errMsg.length,
|
|
736
909
|
durationMs: 0,
|
|
737
910
|
sessionId: null,
|
|
738
911
|
normalizedOutput: null,
|