@tagma/sdk 0.2.3 → 0.2.5

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 CHANGED
@@ -60,7 +60,8 @@ console.log(result.success ? 'Done' : 'Failed');
60
60
  - **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
61
61
  - **Middleware** -- enrich prompts before execution (e.g. inject static context)
62
62
  - **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
63
- - **Template expansion** -- reusable task templates with parameterized `use` / `with`
63
+ - **Template expansion** -- reusable task templates with parameterized `use` / `with`; `discoverTemplates()` enumerates installed `@tagma/template-*` packages for editor integrations
64
+ - **Plugin schemas** -- triggers/completions/middlewares can declare a `PluginSchema` so visual editors render typed forms for their config
64
65
 
65
66
  ## Pipeline YAML Reference
66
67
 
@@ -333,6 +334,27 @@ Dynamically loads and registers external plugin packages.
333
334
 
334
335
  Registers a plugin handler manually. Idempotent — duplicate registrations are silently ignored.
335
336
 
337
+ Plugin handlers (`TriggerPlugin`, `CompletionPlugin`, `MiddlewarePlugin`) may optionally expose a declarative `schema: PluginSchema` field so visual editors can render a typed form for the plugin's config instead of a raw key/value editor:
338
+
339
+ ```ts
340
+ import type { TriggerPlugin } from '@tagma/types';
341
+
342
+ export const HttpTrigger: TriggerPlugin = {
343
+ name: 'http',
344
+ schema: {
345
+ description: 'Wait for an HTTP endpoint to return 2xx before the task runs.',
346
+ fields: {
347
+ url: { type: 'string', required: true, placeholder: 'https://...' },
348
+ method: { type: 'enum', enum: ['GET', 'POST'], default: 'GET' },
349
+ timeout:{ type: 'duration', description: 'Give up after this long.' },
350
+ },
351
+ },
352
+ async watch(config, ctx) { /* ... */ },
353
+ };
354
+ ```
355
+
356
+ The schema is purely descriptive — plugins still perform their own runtime validation. Supported field types: `string`, `number`, `boolean`, `enum`, `path`, `duration`, `number-or-list`. Each field can declare `required`, `default`, `description`, `enum`, `min`/`max`, `placeholder`. Built-in plugins (`file`/`manual` triggers; `exit_code`/`file_exists`/`output_check` completions; `static_context` middleware) all ship with schemas so editors can generate forms out of the box.
357
+
336
358
  ### `getHandler(category, type): PluginType`
337
359
 
338
360
  Retrieves a registered plugin handler. Throws if the plugin is not registered.
@@ -355,6 +377,25 @@ Use `loadPipeline` for the common parse-and-resolve flow. Use `resolveConfig` di
355
377
 
356
378
  Expands `use:` template references in a task list. Loads template packages (`@tagma/template-*`), resolves parameters, and namespaces task IDs and dependencies. Called internally by `loadPipeline`.
357
379
 
380
+ ### `discoverTemplates(workDir: string): TemplateManifest[]`
381
+
382
+ Scans `<workDir>/node_modules/@tagma/` for installed `template-*` packages and returns their manifests (name, description, params, ref). Intended for editors/UIs that want to render a "pick a template" browser without actually expanding any templates. Silently skips packages whose `template.yaml` is missing or invalid.
383
+
384
+ ```ts
385
+ import { discoverTemplates } from '@tagma/sdk';
386
+
387
+ const templates = discoverTemplates(process.cwd());
388
+ // [{ ref: '@tagma/template-review', name: 'Code Review', description: '...', params: {...}, tasks: [...] }, ...]
389
+ ```
390
+
391
+ Each `TemplateManifest` is a `TemplateConfig` with an extra `ref` field — the value users drop into `task.use`.
392
+
393
+ ### `loadTemplateManifest(ref: string, workDir: string): TemplateManifest | null`
394
+
395
+ Loads a single template's manifest by ref (e.g. `@tagma/template-review`). Returns `null` when the package isn't installed or its manifest fails to parse. Complements `discoverTemplates` when the caller only needs one template.
396
+
397
+ Both functions use Node's `fs` APIs and are safe to call from Node runtimes (unlike the legacy Bun-only `loadTemplate` used internally by `expandTemplates`).
398
+
358
399
  ### `attachStdinApprovalAdapter(gateway): StdinApprovalAdapter`
359
400
 
360
401
  Attaches an interactive stdin-based approval handler.
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@tagma/sdk",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/GoTagma/tagma-sdk.git"
8
+ },
4
9
  "type": "module",
5
10
  "workspaces": [
6
11
  "plugins/*"
@@ -1,117 +1,117 @@
1
- import * as readline from 'readline';
2
- import type { ApprovalGateway, ApprovalRequest } from '../approval';
3
-
4
- // ═══ CLI Stdin Adapter ═══
5
- //
6
- // Subscribes to the gateway's 'requested' events, prompts the user on stdout,
7
- // reads a line from stdin, and calls gateway.resolve(). Handles at most one
8
- // prompt at a time — additional requests queue up.
9
-
10
- export interface StdinApprovalAdapter {
11
- readonly detach: () => void;
12
- }
13
-
14
- export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinApprovalAdapter {
15
- const queue: ApprovalRequest[] = [];
16
- let processing = false;
17
- let rl: readline.Interface | null = null;
18
-
19
- function ensureReadline(): readline.Interface {
20
- if (!rl) {
21
- rl = readline.createInterface({ input: process.stdin, terminal: false });
22
- }
23
- return rl;
24
- }
25
-
26
- function readOneLine(): Promise<string> {
27
- return new Promise((resolvePromise) => {
28
- const reader = ensureReadline();
29
- const handler = (line: string): void => {
30
- reader.off('line', handler);
31
- resolvePromise(line);
32
- };
33
- reader.on('line', handler);
34
- });
35
- }
36
-
37
- async function processNext(): Promise<void> {
38
- if (processing) return;
39
- processing = true;
40
- try {
41
- while (queue.length > 0) {
42
- const req = queue.shift()!;
43
- // If the request was already resolved by another path while queued, skip it.
44
- if (!gateway.pending().some((p) => p.id === req.id)) continue;
45
-
46
- const optionsStr = req.options.join(' / ');
47
- process.stdout.write(
48
- `\n[APPROVAL REQUIRED] ${req.message}\n` +
49
- ` id: ${req.id}\n` +
50
- ` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
51
- ` options: ${optionsStr}\n` +
52
- ` > `,
53
- );
54
-
55
- const input = (await readOneLine()).trim().toLowerCase();
56
-
57
- const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
58
- const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
59
- const matchedOption = req.options.find((o) => o.toLowerCase() === input);
60
-
61
- if (matchedOption) {
62
- const isReject = rejectAliases.has(matchedOption.toLowerCase());
63
- gateway.resolve(req.id, {
64
- outcome: isReject ? 'rejected' : 'approved',
65
- choice: matchedOption,
66
- actor: 'cli',
67
- });
68
- } else if (approveAliases.has(input)) {
69
- gateway.resolve(req.id, { outcome: 'approved', choice: input, actor: 'cli' });
70
- } else if (rejectAliases.has(input)) {
71
- gateway.resolve(req.id, {
72
- outcome: 'rejected',
73
- choice: input,
74
- actor: 'cli',
75
- reason: 'user rejected via CLI',
76
- });
77
- } else {
78
- process.stdout.write(` unrecognized input "${input}" — treating as rejection\n`);
79
- gateway.resolve(req.id, {
80
- outcome: 'rejected',
81
- actor: 'cli',
82
- reason: `unrecognized CLI input: ${input}`,
83
- });
84
- }
85
- }
86
- } finally {
87
- processing = false;
88
- }
89
- }
90
-
91
- const unsubscribe = gateway.subscribe((event) => {
92
- switch (event.type) {
93
- case 'requested':
94
- queue.push(event.request);
95
- void processNext();
96
- return;
97
- case 'resolved':
98
- case 'expired':
99
- case 'aborted': {
100
- // Drop from queue if it's still waiting its turn.
101
- const idx = queue.findIndex((r) => r.id === event.request.id);
102
- if (idx >= 0) queue.splice(idx, 1);
103
- return;
104
- }
105
- }
106
- });
107
-
108
- return {
109
- detach: () => {
110
- unsubscribe();
111
- if (rl) {
112
- rl.close();
113
- rl = null;
114
- }
115
- },
116
- };
117
- }
1
+ import * as readline from 'readline';
2
+ import type { ApprovalGateway, ApprovalRequest } from '../approval';
3
+
4
+ // ═══ CLI Stdin Adapter ═══
5
+ //
6
+ // Subscribes to the gateway's 'requested' events, prompts the user on stdout,
7
+ // reads a line from stdin, and calls gateway.resolve(). Handles at most one
8
+ // prompt at a time — additional requests queue up.
9
+
10
+ export interface StdinApprovalAdapter {
11
+ readonly detach: () => void;
12
+ }
13
+
14
+ export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinApprovalAdapter {
15
+ const queue: ApprovalRequest[] = [];
16
+ let processing = false;
17
+ let rl: readline.Interface | null = null;
18
+
19
+ function ensureReadline(): readline.Interface {
20
+ if (!rl) {
21
+ rl = readline.createInterface({ input: process.stdin, terminal: false });
22
+ }
23
+ return rl;
24
+ }
25
+
26
+ function readOneLine(): Promise<string> {
27
+ return new Promise((resolvePromise) => {
28
+ const reader = ensureReadline();
29
+ const handler = (line: string): void => {
30
+ reader.off('line', handler);
31
+ resolvePromise(line);
32
+ };
33
+ reader.on('line', handler);
34
+ });
35
+ }
36
+
37
+ async function processNext(): Promise<void> {
38
+ if (processing) return;
39
+ processing = true;
40
+ try {
41
+ while (queue.length > 0) {
42
+ const req = queue.shift()!;
43
+ // If the request was already resolved by another path while queued, skip it.
44
+ if (!gateway.pending().some((p) => p.id === req.id)) continue;
45
+
46
+ const optionsStr = req.options.join(' / ');
47
+ process.stdout.write(
48
+ `\n[APPROVAL REQUIRED] ${req.message}\n` +
49
+ ` id: ${req.id}\n` +
50
+ ` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
51
+ ` options: ${optionsStr}\n` +
52
+ ` > `,
53
+ );
54
+
55
+ const input = (await readOneLine()).trim().toLowerCase();
56
+
57
+ const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
58
+ const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
59
+ const matchedOption = req.options.find((o) => o.toLowerCase() === input);
60
+
61
+ if (matchedOption) {
62
+ const isReject = rejectAliases.has(matchedOption.toLowerCase());
63
+ gateway.resolve(req.id, {
64
+ outcome: isReject ? 'rejected' : 'approved',
65
+ choice: matchedOption,
66
+ actor: 'cli',
67
+ });
68
+ } else if (approveAliases.has(input)) {
69
+ gateway.resolve(req.id, { outcome: 'approved', choice: input, actor: 'cli' });
70
+ } else if (rejectAliases.has(input)) {
71
+ gateway.resolve(req.id, {
72
+ outcome: 'rejected',
73
+ choice: input,
74
+ actor: 'cli',
75
+ reason: 'user rejected via CLI',
76
+ });
77
+ } else {
78
+ process.stdout.write(` unrecognized input "${input}" — treating as rejection\n`);
79
+ gateway.resolve(req.id, {
80
+ outcome: 'rejected',
81
+ actor: 'cli',
82
+ reason: `unrecognized CLI input: ${input}`,
83
+ });
84
+ }
85
+ }
86
+ } finally {
87
+ processing = false;
88
+ }
89
+ }
90
+
91
+ const unsubscribe = gateway.subscribe((event) => {
92
+ switch (event.type) {
93
+ case 'requested':
94
+ queue.push(event.request);
95
+ void processNext();
96
+ return;
97
+ case 'resolved':
98
+ case 'expired':
99
+ case 'aborted': {
100
+ // Drop from queue if it's still waiting its turn.
101
+ const idx = queue.findIndex((r) => r.id === event.request.id);
102
+ if (idx >= 0) queue.splice(idx, 1);
103
+ return;
104
+ }
105
+ }
106
+ });
107
+
108
+ return {
109
+ detach: () => {
110
+ unsubscribe();
111
+ if (rl) {
112
+ rl.close();
113
+ rl = null;
114
+ }
115
+ },
116
+ };
117
+ }
@@ -1,19 +1,30 @@
1
- import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
2
-
3
- export const ExitCodeCompletion: CompletionPlugin = {
4
- name: 'exit_code',
5
-
6
- async check(config: Record<string, unknown>, result: TaskResult, _ctx: CompletionContext): Promise<boolean> {
7
- const expected = config.expect ?? 0;
8
-
9
- if (typeof expected === 'number') {
10
- return result.exitCode === expected;
11
- }
12
- if (Array.isArray(expected) && expected.every((v) => typeof v === 'number')) {
13
- return expected.includes(result.exitCode);
14
- }
15
- throw new Error(
16
- `exit_code completion: "expect" must be a number or number[], got ${typeof expected}`
17
- );
18
- },
19
- };
1
+ import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
2
+
3
+ export const ExitCodeCompletion: CompletionPlugin = {
4
+ name: 'exit_code',
5
+ schema: {
6
+ description: 'Mark the task successful when the exit code matches.',
7
+ fields: {
8
+ expect: {
9
+ type: 'number-or-list',
10
+ default: 0,
11
+ description: 'Expected exit code, or list of acceptable codes (e.g. 0 or [0, 2]).',
12
+ placeholder: '0',
13
+ },
14
+ },
15
+ },
16
+
17
+ async check(config: Record<string, unknown>, result: TaskResult, _ctx: CompletionContext): Promise<boolean> {
18
+ const expected = config.expect ?? 0;
19
+
20
+ if (typeof expected === 'number') {
21
+ return result.exitCode === expected;
22
+ }
23
+ if (Array.isArray(expected) && expected.every((v) => typeof v === 'number')) {
24
+ return expected.includes(result.exitCode);
25
+ }
26
+ throw new Error(
27
+ `exit_code completion: "expect" must be a number or number[], got ${typeof expected}`
28
+ );
29
+ },
30
+ };
@@ -1,39 +1,60 @@
1
- import { stat } from 'node:fs/promises';
2
- import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
3
- import { validatePath } from '../utils';
4
-
5
- type Kind = 'file' | 'dir' | 'any';
6
-
7
- export const FileExistsCompletion: CompletionPlugin = {
8
- name: 'file_exists',
9
-
10
- async check(config: Record<string, unknown>, _result: TaskResult, ctx: CompletionContext): Promise<boolean> {
11
- const filePath = config.path as string;
12
- if (!filePath) throw new Error('file_exists completion: "path" is required');
13
-
14
- const safePath = validatePath(filePath, ctx.workDir);
15
-
16
- const kind = (config.kind as Kind | undefined) ?? 'any';
17
- if (kind !== 'file' && kind !== 'dir' && kind !== 'any') {
18
- throw new Error(`file_exists completion: "kind" must be "file" | "dir" | "any", got "${kind}"`);
19
- }
20
-
21
- const minSize = config.min_size;
22
- if (minSize != null && (typeof minSize !== 'number' || minSize < 0)) {
23
- throw new Error(`file_exists completion: "min_size" must be a non-negative number`);
24
- }
25
-
26
- try {
27
- const st = await stat(safePath);
28
- if (kind === 'file' && !st.isFile()) return false;
29
- if (kind === 'dir' && !st.isDirectory()) return false;
30
- if (typeof minSize === 'number' && st.isFile() && st.size < minSize) return false;
31
- return true;
32
- } catch (err: unknown) {
33
- const code = (err as NodeJS.ErrnoException).code;
34
- if (code === 'ENOENT' || code === 'ENOTDIR') return false;
35
- // Permission / IO errors should surface, not silently mean "missing"
36
- throw err;
37
- }
38
- },
39
- };
1
+ import { stat } from 'node:fs/promises';
2
+ import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
3
+ import { validatePath } from '../utils';
4
+
5
+ type Kind = 'file' | 'dir' | 'any';
6
+
7
+ export const FileExistsCompletion: CompletionPlugin = {
8
+ name: 'file_exists',
9
+ schema: {
10
+ description: 'Mark the task successful when a target file or directory exists.',
11
+ fields: {
12
+ path: {
13
+ type: 'path',
14
+ required: true,
15
+ description: 'Path to check (relative to workDir or absolute).',
16
+ },
17
+ kind: {
18
+ type: 'enum',
19
+ enum: ['file', 'dir', 'any'],
20
+ default: 'any',
21
+ description: 'Restrict to a file, directory, or accept either.',
22
+ },
23
+ min_size: {
24
+ type: 'number',
25
+ min: 0,
26
+ description: 'Optional minimum size in bytes (files only).',
27
+ },
28
+ },
29
+ },
30
+
31
+ async check(config: Record<string, unknown>, _result: TaskResult, ctx: CompletionContext): Promise<boolean> {
32
+ const filePath = config.path as string;
33
+ if (!filePath) throw new Error('file_exists completion: "path" is required');
34
+
35
+ const safePath = validatePath(filePath, ctx.workDir);
36
+
37
+ const kind = (config.kind as Kind | undefined) ?? 'any';
38
+ if (kind !== 'file' && kind !== 'dir' && kind !== 'any') {
39
+ throw new Error(`file_exists completion: "kind" must be "file" | "dir" | "any", got "${kind}"`);
40
+ }
41
+
42
+ const minSize = config.min_size;
43
+ if (minSize != null && (typeof minSize !== 'number' || minSize < 0)) {
44
+ throw new Error(`file_exists completion: "min_size" must be a non-negative number`);
45
+ }
46
+
47
+ try {
48
+ const st = await stat(safePath);
49
+ if (kind === 'file' && !st.isFile()) return false;
50
+ if (kind === 'dir' && !st.isDirectory()) return false;
51
+ if (typeof minSize === 'number' && st.isFile() && st.size < minSize) return false;
52
+ return true;
53
+ } catch (err: unknown) {
54
+ const code = (err as NodeJS.ErrnoException).code;
55
+ if (code === 'ENOENT' || code === 'ENOTDIR') return false;
56
+ // Permission / IO errors should surface, not silently mean "missing"
57
+ throw err;
58
+ }
59
+ },
60
+ };
@@ -5,6 +5,23 @@ const DEFAULT_TIMEOUT_MS = 30_000;
5
5
 
6
6
  export const OutputCheckCompletion: CompletionPlugin = {
7
7
  name: 'output_check',
8
+ schema: {
9
+ description: 'Pipe task stdout into a shell command; mark success when that command exits 0.',
10
+ fields: {
11
+ check: {
12
+ type: 'string',
13
+ required: true,
14
+ description: 'Shell command to run. Task stdout is piped to its stdin.',
15
+ placeholder: "grep -q 'PASS'",
16
+ },
17
+ timeout: {
18
+ type: 'duration',
19
+ default: '30s',
20
+ description: 'Maximum time to wait for the check command.',
21
+ placeholder: '30s',
22
+ },
23
+ },
24
+ },
8
25
 
9
26
  async check(config: Record<string, unknown>, result: TaskResult, ctx: CompletionContext): Promise<boolean> {
10
27
  const checkCmd = config.check as string;
package/src/dag.ts CHANGED
@@ -1,222 +1,222 @@
1
- import type { PipelineConfig, RawPipelineConfig, RawTaskConfig, TaskConfig, TrackConfig } from './types';
2
-
3
- export interface DagNode {
4
- readonly taskId: string; // fully qualified: track_id.task_id or just task_id
5
- readonly task: TaskConfig;
6
- readonly track: TrackConfig;
7
- readonly dependsOn: readonly string[];
8
- }
9
-
10
- export interface Dag {
11
- readonly nodes: ReadonlyMap<string, DagNode>;
12
- readonly sorted: readonly string[]; // topological order
13
- }
14
-
15
- // Build a global task ID: for cross-track refs we use "track_id.task_id"
16
- // Within a track, bare "task_id" is also valid
17
- function qualifyId(trackId: string, taskId: string): string {
18
- return `${trackId}.${taskId}`;
19
- }
20
-
21
- export function buildDag(config: PipelineConfig): Dag {
22
- const nodes = new Map<string, DagNode>();
23
- // Map bare task IDs to qualified IDs (for resolving unqualified refs)
24
- const bareToQualified = new Map<string, string>();
25
-
26
- // 1. Register all nodes
27
- for (const track of config.tracks) {
28
- for (const task of track.tasks) {
29
- const qid = qualifyId(track.id, task.id);
30
-
31
- if (nodes.has(qid)) {
32
- throw new Error(`Duplicate task ID: "${qid}"`);
33
- }
34
-
35
- // Track bare ID → qualified. If same bare ID in multiple tracks, mark ambiguous
36
- if (bareToQualified.has(task.id)) {
37
- bareToQualified.set(task.id, '__ambiguous__');
38
- } else {
39
- bareToQualified.set(task.id, qid);
40
- }
41
-
42
- nodes.set(qid, {
43
- taskId: qid,
44
- task,
45
- track,
46
- dependsOn: [], // filled below
47
- });
48
- }
49
- }
50
-
51
- // Helper to resolve a dependency ref to a qualified ID
52
- function resolveRef(ref: string, fromTrackId: string): string {
53
- // Already qualified (contains dot)
54
- if (ref.includes('.')) {
55
- if (!nodes.has(ref)) {
56
- throw new Error(`Task reference "${ref}" not found`);
57
- }
58
- return ref;
59
- }
60
- // Try within same track first
61
- const sameTrack = qualifyId(fromTrackId, ref);
62
- if (nodes.has(sameTrack)) return sameTrack;
63
- // Try global bare lookup
64
- const global = bareToQualified.get(ref);
65
- if (global && global !== '__ambiguous__') return global;
66
- if (global === '__ambiguous__') {
67
- throw new Error(
68
- `Ambiguous task reference "${ref}" exists in multiple tracks. ` +
69
- `Use "track_id.task_id" format.`
70
- );
71
- }
72
- throw new Error(`Task reference "${ref}" not found`);
73
- }
74
-
75
- // 2. Resolve depends_on and continue_from to qualified IDs
76
- for (const track of config.tracks) {
77
- for (const task of track.tasks) {
78
- const qid = qualifyId(track.id, task.id);
79
- const deps: string[] = [];
80
-
81
- if (task.depends_on) {
82
- for (const dep of task.depends_on) {
83
- deps.push(resolveRef(dep, track.id));
84
- }
85
- }
86
- if (task.continue_from) {
87
- const resolved = resolveRef(task.continue_from, track.id);
88
- if (!deps.includes(resolved)) {
89
- deps.push(resolved); // continue_from implies dependency
90
- }
91
- }
92
-
93
- // Replace node with resolved deps
94
- const node = nodes.get(qid)!;
95
- nodes.set(qid, { ...node, dependsOn: deps });
96
- }
97
- }
98
-
99
- // 3. Topological sort + cycle detection (Kahn's algorithm)
100
- const inDegree = new Map<string, number>();
101
- const adjacency = new Map<string, string[]>(); // parent → children
102
-
103
- for (const [id] of nodes) {
104
- inDegree.set(id, 0);
105
- adjacency.set(id, []);
106
- }
107
-
108
- for (const [id, node] of nodes) {
109
- for (const dep of node.dependsOn) {
110
- adjacency.get(dep)!.push(id);
111
- inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
112
- }
113
- }
114
-
115
- const queue: string[] = [];
116
- for (const [id, degree] of inDegree) {
117
- if (degree === 0) queue.push(id);
118
- }
119
-
120
- const sorted: string[] = [];
121
- while (queue.length > 0) {
122
- const current = queue.shift()!;
123
- sorted.push(current);
124
- for (const child of adjacency.get(current)!) {
125
- const newDegree = inDegree.get(child)! - 1;
126
- inDegree.set(child, newDegree);
127
- if (newDegree === 0) queue.push(child);
128
- }
129
- }
130
-
131
- if (sorted.length !== nodes.size) {
132
- const remaining = [...nodes.keys()].filter(id => !sorted.includes(id));
133
- throw new Error(`Circular dependency detected involving tasks: ${remaining.join(', ')}`);
134
- }
135
-
136
- return { nodes, sorted };
137
- }
138
-
139
- // ═══ Raw DAG (for visual editor — no workDir required) ═══
140
-
141
- export interface RawDagNode {
142
- readonly taskId: string; // fully qualified: track_id.task_id
143
- readonly trackId: string;
144
- readonly rawTask: RawTaskConfig;
145
- readonly dependsOn: readonly string[]; // fully qualified IDs, best-effort resolved
146
- }
147
-
148
- export interface RawDag {
149
- readonly nodes: ReadonlyMap<string, RawDagNode>;
150
- /** Directed edges: from → to means "from must complete before to starts" */
151
- readonly edges: readonly { readonly from: string; readonly to: string }[];
152
- }
153
-
154
- /**
155
- * Build a lightweight DAG from a raw (unresolved) pipeline config.
156
- * Unlike buildDag, this function:
157
- * - Does not require a workDir or resolved PipelineConfig
158
- * - Is lenient: missing or ambiguous refs are silently skipped
159
- * - Skips template-expansion tasks (those with a `use` field)
160
- *
161
- * Intended for the visual editor to render the flow graph before a pipeline is run.
162
- */
163
- export function buildRawDag(config: RawPipelineConfig): RawDag {
164
- const nodes = new Map<string, RawDagNode>();
165
- const bareToQualified = new Map<string, string>();
166
-
167
- // 1. Register all concrete tasks
168
- for (const track of config.tracks) {
169
- for (const task of track.tasks) {
170
- if (task.use) continue; // template-expansion tasks are not yet materialized
171
- const qid = `${track.id}.${task.id}`;
172
- if (nodes.has(qid)) continue; // skip duplicates silently
173
-
174
- if (bareToQualified.has(task.id)) {
175
- bareToQualified.set(task.id, '__ambiguous__');
176
- } else {
177
- bareToQualified.set(task.id, qid);
178
- }
179
- nodes.set(qid, { taskId: qid, trackId: track.id, rawTask: task, dependsOn: [] });
180
- }
181
- }
182
-
183
- // 2. Resolve dependency refs leniently (missing / ambiguous refs are skipped)
184
- function tryResolve(ref: string, fromTrackId: string): string | null {
185
- if (ref.includes('.')) return nodes.has(ref) ? ref : null;
186
- const sameTrack = `${fromTrackId}.${ref}`;
187
- if (nodes.has(sameTrack)) return sameTrack;
188
- const global = bareToQualified.get(ref);
189
- if (global && global !== '__ambiguous__') return global;
190
- return null;
191
- }
192
-
193
- const edges: { from: string; to: string }[] = [];
194
-
195
- for (const track of config.tracks) {
196
- for (const task of track.tasks) {
197
- if (task.use) continue;
198
- const qid = `${track.id}.${task.id}`;
199
- const deps: string[] = [];
200
-
201
- for (const ref of task.depends_on ?? []) {
202
- const resolved = tryResolve(ref, track.id);
203
- if (resolved && !deps.includes(resolved)) {
204
- deps.push(resolved);
205
- edges.push({ from: resolved, to: qid });
206
- }
207
- }
208
- if (task.continue_from) {
209
- const resolved = tryResolve(task.continue_from, track.id);
210
- if (resolved && !deps.includes(resolved)) {
211
- deps.push(resolved);
212
- edges.push({ from: resolved, to: qid });
213
- }
214
- }
215
-
216
- const node = nodes.get(qid)!;
217
- nodes.set(qid, { ...node, dependsOn: deps });
218
- }
219
- }
220
-
221
- return { nodes, edges };
222
- }
1
+ import type { PipelineConfig, RawPipelineConfig, RawTaskConfig, TaskConfig, TrackConfig } from './types';
2
+
3
+ export interface DagNode {
4
+ readonly taskId: string; // fully qualified: track_id.task_id or just task_id
5
+ readonly task: TaskConfig;
6
+ readonly track: TrackConfig;
7
+ readonly dependsOn: readonly string[];
8
+ }
9
+
10
+ export interface Dag {
11
+ readonly nodes: ReadonlyMap<string, DagNode>;
12
+ readonly sorted: readonly string[]; // topological order
13
+ }
14
+
15
+ // Build a global task ID: for cross-track refs we use "track_id.task_id"
16
+ // Within a track, bare "task_id" is also valid
17
+ function qualifyId(trackId: string, taskId: string): string {
18
+ return `${trackId}.${taskId}`;
19
+ }
20
+
21
+ export function buildDag(config: PipelineConfig): Dag {
22
+ const nodes = new Map<string, DagNode>();
23
+ // Map bare task IDs to qualified IDs (for resolving unqualified refs)
24
+ const bareToQualified = new Map<string, string>();
25
+
26
+ // 1. Register all nodes
27
+ for (const track of config.tracks) {
28
+ for (const task of track.tasks) {
29
+ const qid = qualifyId(track.id, task.id);
30
+
31
+ if (nodes.has(qid)) {
32
+ throw new Error(`Duplicate task ID: "${qid}"`);
33
+ }
34
+
35
+ // Track bare ID → qualified. If same bare ID in multiple tracks, mark ambiguous
36
+ if (bareToQualified.has(task.id)) {
37
+ bareToQualified.set(task.id, '__ambiguous__');
38
+ } else {
39
+ bareToQualified.set(task.id, qid);
40
+ }
41
+
42
+ nodes.set(qid, {
43
+ taskId: qid,
44
+ task,
45
+ track,
46
+ dependsOn: [], // filled below
47
+ });
48
+ }
49
+ }
50
+
51
+ // Helper to resolve a dependency ref to a qualified ID
52
+ function resolveRef(ref: string, fromTrackId: string): string {
53
+ // Already qualified (contains dot)
54
+ if (ref.includes('.')) {
55
+ if (!nodes.has(ref)) {
56
+ throw new Error(`Task reference "${ref}" not found`);
57
+ }
58
+ return ref;
59
+ }
60
+ // Try within same track first
61
+ const sameTrack = qualifyId(fromTrackId, ref);
62
+ if (nodes.has(sameTrack)) return sameTrack;
63
+ // Try global bare lookup
64
+ const global = bareToQualified.get(ref);
65
+ if (global && global !== '__ambiguous__') return global;
66
+ if (global === '__ambiguous__') {
67
+ throw new Error(
68
+ `Ambiguous task reference "${ref}" exists in multiple tracks. ` +
69
+ `Use "track_id.task_id" format.`
70
+ );
71
+ }
72
+ throw new Error(`Task reference "${ref}" not found`);
73
+ }
74
+
75
+ // 2. Resolve depends_on and continue_from to qualified IDs
76
+ for (const track of config.tracks) {
77
+ for (const task of track.tasks) {
78
+ const qid = qualifyId(track.id, task.id);
79
+ const deps: string[] = [];
80
+
81
+ if (task.depends_on) {
82
+ for (const dep of task.depends_on) {
83
+ deps.push(resolveRef(dep, track.id));
84
+ }
85
+ }
86
+ if (task.continue_from) {
87
+ const resolved = resolveRef(task.continue_from, track.id);
88
+ if (!deps.includes(resolved)) {
89
+ deps.push(resolved); // continue_from implies dependency
90
+ }
91
+ }
92
+
93
+ // Replace node with resolved deps
94
+ const node = nodes.get(qid)!;
95
+ nodes.set(qid, { ...node, dependsOn: deps });
96
+ }
97
+ }
98
+
99
+ // 3. Topological sort + cycle detection (Kahn's algorithm)
100
+ const inDegree = new Map<string, number>();
101
+ const adjacency = new Map<string, string[]>(); // parent → children
102
+
103
+ for (const [id] of nodes) {
104
+ inDegree.set(id, 0);
105
+ adjacency.set(id, []);
106
+ }
107
+
108
+ for (const [id, node] of nodes) {
109
+ for (const dep of node.dependsOn) {
110
+ adjacency.get(dep)!.push(id);
111
+ inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
112
+ }
113
+ }
114
+
115
+ const queue: string[] = [];
116
+ for (const [id, degree] of inDegree) {
117
+ if (degree === 0) queue.push(id);
118
+ }
119
+
120
+ const sorted: string[] = [];
121
+ while (queue.length > 0) {
122
+ const current = queue.shift()!;
123
+ sorted.push(current);
124
+ for (const child of adjacency.get(current)!) {
125
+ const newDegree = inDegree.get(child)! - 1;
126
+ inDegree.set(child, newDegree);
127
+ if (newDegree === 0) queue.push(child);
128
+ }
129
+ }
130
+
131
+ if (sorted.length !== nodes.size) {
132
+ const remaining = [...nodes.keys()].filter(id => !sorted.includes(id));
133
+ throw new Error(`Circular dependency detected involving tasks: ${remaining.join(', ')}`);
134
+ }
135
+
136
+ return { nodes, sorted };
137
+ }
138
+
139
+ // ═══ Raw DAG (for visual editor — no workDir required) ═══
140
+
141
+ export interface RawDagNode {
142
+ readonly taskId: string; // fully qualified: track_id.task_id
143
+ readonly trackId: string;
144
+ readonly rawTask: RawTaskConfig;
145
+ readonly dependsOn: readonly string[]; // fully qualified IDs, best-effort resolved
146
+ }
147
+
148
+ export interface RawDag {
149
+ readonly nodes: ReadonlyMap<string, RawDagNode>;
150
+ /** Directed edges: from → to means "from must complete before to starts" */
151
+ readonly edges: readonly { readonly from: string; readonly to: string }[];
152
+ }
153
+
154
+ /**
155
+ * Build a lightweight DAG from a raw (unresolved) pipeline config.
156
+ * Unlike buildDag, this function:
157
+ * - Does not require a workDir or resolved PipelineConfig
158
+ * - Is lenient: missing or ambiguous refs are silently skipped
159
+ * - Skips template-expansion tasks (those with a `use` field)
160
+ *
161
+ * Intended for the visual editor to render the flow graph before a pipeline is run.
162
+ */
163
+ export function buildRawDag(config: RawPipelineConfig): RawDag {
164
+ const nodes = new Map<string, RawDagNode>();
165
+ const bareToQualified = new Map<string, string>();
166
+
167
+ // 1. Register all concrete tasks
168
+ for (const track of config.tracks) {
169
+ for (const task of track.tasks) {
170
+ if (task.use) continue; // template-expansion tasks are not yet materialized
171
+ const qid = `${track.id}.${task.id}`;
172
+ if (nodes.has(qid)) continue; // skip duplicates silently
173
+
174
+ if (bareToQualified.has(task.id)) {
175
+ bareToQualified.set(task.id, '__ambiguous__');
176
+ } else {
177
+ bareToQualified.set(task.id, qid);
178
+ }
179
+ nodes.set(qid, { taskId: qid, trackId: track.id, rawTask: task, dependsOn: [] });
180
+ }
181
+ }
182
+
183
+ // 2. Resolve dependency refs leniently (missing / ambiguous refs are skipped)
184
+ function tryResolve(ref: string, fromTrackId: string): string | null {
185
+ if (ref.includes('.')) return nodes.has(ref) ? ref : null;
186
+ const sameTrack = `${fromTrackId}.${ref}`;
187
+ if (nodes.has(sameTrack)) return sameTrack;
188
+ const global = bareToQualified.get(ref);
189
+ if (global && global !== '__ambiguous__') return global;
190
+ return null;
191
+ }
192
+
193
+ const edges: { from: string; to: string }[] = [];
194
+
195
+ for (const track of config.tracks) {
196
+ for (const task of track.tasks) {
197
+ if (task.use) continue;
198
+ const qid = `${track.id}.${task.id}`;
199
+ const deps: string[] = [];
200
+
201
+ for (const ref of task.depends_on ?? []) {
202
+ const resolved = tryResolve(ref, track.id);
203
+ if (resolved && !deps.includes(resolved)) {
204
+ deps.push(resolved);
205
+ edges.push({ from: resolved, to: qid });
206
+ }
207
+ }
208
+ if (task.continue_from) {
209
+ const resolved = tryResolve(task.continue_from, track.id);
210
+ if (resolved && !deps.includes(resolved)) {
211
+ deps.push(resolved);
212
+ edges.push({ from: resolved, to: qid });
213
+ }
214
+ }
215
+
216
+ const node = nodes.get(qid)!;
217
+ nodes.set(qid, { ...node, dependsOn: deps });
218
+ }
219
+ }
220
+
221
+ return { nodes, edges };
222
+ }
@@ -1,29 +1,45 @@
1
- import { basename } from 'path';
2
- import type { MiddlewarePlugin, MiddlewareContext } from '../types';
3
- import { validatePath } from '../utils';
4
-
5
- export const StaticContextMiddleware: MiddlewarePlugin = {
6
- name: 'static_context',
7
-
8
- async enhance(
9
- prompt: string,
10
- config: Record<string, unknown>,
11
- ctx: MiddlewareContext,
12
- ): Promise<string> {
13
- const filePath = config.file as string;
14
- if (!filePath) throw new Error('static_context middleware: "file" is required');
15
-
16
- const safePath = validatePath(filePath, ctx.workDir);
17
- const file = Bun.file(safePath);
18
-
19
- if (!(await file.exists())) {
20
- console.warn(`static_context: file ${filePath} not found, skipping`);
21
- return prompt;
22
- }
23
-
24
- const content = await file.text();
25
- const label = (config.label as string) ?? `Reference: ${basename(filePath)}`;
26
-
27
- return `[${label}]\n${content}\n\n[Task]\n${prompt}`;
28
- },
29
- };
1
+ import { basename } from 'path';
2
+ import type { MiddlewarePlugin, MiddlewareContext } from '../types';
3
+ import { validatePath } from '../utils';
4
+
5
+ export const StaticContextMiddleware: MiddlewarePlugin = {
6
+ name: 'static_context',
7
+ schema: {
8
+ description: 'Prepend a reference file to the prompt as static context.',
9
+ fields: {
10
+ file: {
11
+ type: 'path',
12
+ required: true,
13
+ description: 'Path to the reference file (relative to workDir or absolute).',
14
+ placeholder: 'docs/spec.md',
15
+ },
16
+ label: {
17
+ type: 'string',
18
+ description: 'Header shown before the content. Defaults to "Reference: <basename>".',
19
+ placeholder: 'Reference: spec.md',
20
+ },
21
+ },
22
+ },
23
+
24
+ async enhance(
25
+ prompt: string,
26
+ config: Record<string, unknown>,
27
+ ctx: MiddlewareContext,
28
+ ): Promise<string> {
29
+ const filePath = config.file as string;
30
+ if (!filePath) throw new Error('static_context middleware: "file" is required');
31
+
32
+ const safePath = validatePath(filePath, ctx.workDir);
33
+ const file = Bun.file(safePath);
34
+
35
+ if (!(await file.exists())) {
36
+ console.warn(`static_context: file ${filePath} not found, skipping`);
37
+ return prompt;
38
+ }
39
+
40
+ const content = await file.text();
41
+ const label = (config.label as string) ?? `Reference: ${basename(filePath)}`;
42
+
43
+ return `[${label}]\n${content}\n\n[Task]\n${prompt}`;
44
+ },
45
+ };
package/src/sdk.ts CHANGED
@@ -32,6 +32,10 @@ export type { ValidationError } from './validate-raw';
32
32
  // ── Schema: parse / resolve / load / serialize / validate ──
33
33
  export { parseYaml, resolveConfig, expandTemplates, loadPipeline, serializePipeline, deresolvePipeline, validateConfig } from './schema';
34
34
 
35
+ // ── Templates: discovery + manifest loading (F1) ──
36
+ export { discoverTemplates, loadTemplateManifest } from './templates';
37
+ export type { TemplateManifest } from './templates';
38
+
35
39
  // ── DAG ──
36
40
  export { buildDag, buildRawDag } from './dag';
37
41
  export type { DagNode, Dag, RawDagNode, RawDag } from './dag';
@@ -0,0 +1,97 @@
1
+ // ═══ Template Discovery (F1) ═══
2
+ //
3
+ // Public helpers so editors / UIs can enumerate installed `@tagma/template-*`
4
+ // packages in a workspace and read each template's declarative metadata
5
+ // (name, description, params) without actually expanding the template.
6
+ //
7
+ // The legacy private `loadTemplate` in schema.ts uses Bun-specific APIs
8
+ // (Bun.file, require.resolve). These helpers are Node-compatible because
9
+ // the editor server runs on Node, not Bun.
10
+
11
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
12
+ import { join } from 'path';
13
+ import yaml from 'js-yaml';
14
+ import type { TemplateConfig } from './types';
15
+
16
+ export interface TemplateManifest extends TemplateConfig {
17
+ /** The package ref as it would appear in `task.use`, e.g. `@tagma/template-review`. */
18
+ readonly ref: string;
19
+ }
20
+
21
+ /**
22
+ * Scan the workspace's `node_modules/@tagma/*` for packages whose name starts
23
+ * with `template-` and load each one's manifest. Packages without a valid
24
+ * `template.yaml` (or that fail to parse) are silently skipped.
25
+ *
26
+ * Returns an empty array when `workDir` doesn't exist or has no such packages.
27
+ */
28
+ export function discoverTemplates(workDir: string): TemplateManifest[] {
29
+ const out: TemplateManifest[] = [];
30
+ const scopeDir = join(workDir, 'node_modules', '@tagma');
31
+ if (!existsSync(scopeDir)) return out;
32
+
33
+ let entries: string[] = [];
34
+ try {
35
+ entries = readdirSync(scopeDir);
36
+ } catch {
37
+ return out;
38
+ }
39
+
40
+ for (const entry of entries) {
41
+ if (!entry.startsWith('template-')) continue;
42
+ const pkgDir = join(scopeDir, entry);
43
+ try {
44
+ const st = statSync(pkgDir);
45
+ if (!st.isDirectory()) continue;
46
+ } catch {
47
+ continue;
48
+ }
49
+
50
+ const ref = `@tagma/${entry}`;
51
+ const manifest = loadTemplateManifestFromDir(pkgDir, ref);
52
+ if (manifest) out.push(manifest);
53
+ }
54
+
55
+ // Sort alphabetically for deterministic UI rendering.
56
+ out.sort((a, b) => a.ref.localeCompare(b.ref));
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Load a single template's manifest by its ref (e.g. `@tagma/template-review`)
62
+ * from the given workspace's `node_modules`. Returns `null` if the package
63
+ * isn't installed or its manifest can't be parsed.
64
+ */
65
+ export function loadTemplateManifest(ref: string, workDir: string): TemplateManifest | null {
66
+ // Only @tagma/template-* refs are supported (matches SDK validateTemplateRef).
67
+ const stripped = ref.replace(/@v\d+$/, '');
68
+ if (!stripped.startsWith('@tagma/template-')) return null;
69
+ const pkgDir = join(workDir, 'node_modules', stripped);
70
+ if (!existsSync(pkgDir)) return null;
71
+ return loadTemplateManifestFromDir(pkgDir, stripped);
72
+ }
73
+
74
+ /**
75
+ * Resolve a template manifest from an absolute package directory. Tries
76
+ * `template.yaml` first (the documented convention), then a `template` export
77
+ * from `package.json`'s `main`. Returns `null` on any failure so discovery
78
+ * stays robust against malformed packages.
79
+ */
80
+ function loadTemplateManifestFromDir(pkgDir: string, ref: string): TemplateManifest | null {
81
+ const yamlPath = join(pkgDir, 'template.yaml');
82
+ if (existsSync(yamlPath)) {
83
+ try {
84
+ const content = readFileSync(yamlPath, 'utf-8');
85
+ const doc = yaml.load(content) as { template?: TemplateConfig } | TemplateConfig;
86
+ const tpl = (doc && typeof doc === 'object' && 'template' in doc
87
+ ? (doc as { template?: TemplateConfig }).template
88
+ : (doc as TemplateConfig)) as TemplateConfig | undefined;
89
+ if (tpl && typeof tpl === 'object' && tpl.name && Array.isArray(tpl.tasks)) {
90
+ return { ...tpl, ref };
91
+ }
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+ return null;
97
+ }
@@ -11,6 +11,22 @@ function pathsEqual(a: string, b: string): boolean {
11
11
 
12
12
  export const FileTrigger: TriggerPlugin = {
13
13
  name: 'file',
14
+ schema: {
15
+ description: 'Wait for a file to appear or be modified before the task runs.',
16
+ fields: {
17
+ path: {
18
+ type: 'path',
19
+ required: true,
20
+ description: 'Path to the file to watch (relative to workDir or absolute).',
21
+ placeholder: 'e.g. build/output.json',
22
+ },
23
+ timeout: {
24
+ type: 'duration',
25
+ description: 'Maximum wait time (e.g. 30s, 5m). Omit or 0 to wait indefinitely.',
26
+ placeholder: '30s',
27
+ },
28
+ },
29
+ },
14
30
 
15
31
  watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
16
32
  const filePath = config.path as string;
@@ -1,61 +1,81 @@
1
- import type { TriggerPlugin, TriggerContext } from '../types';
2
- import { parseDuration } from '../utils';
3
-
4
- export const ManualTrigger: TriggerPlugin = {
5
- name: 'manual',
6
-
7
- async watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
8
- const message =
9
- (config.message as string | undefined) ?? `Manual confirmation required for task "${ctx.taskId}"`;
10
- const timeoutMs = config.timeout ? parseDuration(config.timeout as string) : 0;
11
- const options = Array.isArray(config.options)
12
- ? (config.options as unknown[]).map(String)
13
- : undefined;
14
- const metadata =
15
- config.metadata && typeof config.metadata === 'object'
16
- ? (config.metadata as Record<string, unknown>)
17
- : undefined;
18
-
19
- const decisionPromise = ctx.approvalGateway.request({
20
- taskId: ctx.taskId,
21
- trackId: ctx.trackId,
22
- message,
23
- options,
24
- timeoutMs,
25
- metadata,
26
- });
27
-
28
- // Wire AbortSignal → try to resolve this specific request as aborted.
29
- // We can't directly cancel via the gateway (no id yet at .request() call site),
30
- // so instead we race against an abort promise and let engine status logic
31
- // fall back to pipelineAborted → skipped. abortAll() on gateway still runs
32
- // from engine shutdown path to clean up any truly-pending entries.
33
- const abortPromise = new Promise<never>((_, reject) => {
34
- if (ctx.signal.aborted) {
35
- reject(new Error('Pipeline aborted'));
36
- return;
37
- }
38
- ctx.signal.addEventListener(
39
- 'abort',
40
- () => reject(new Error('Pipeline aborted')),
41
- { once: true },
42
- );
43
- });
44
-
45
- const decision = await Promise.race([decisionPromise, abortPromise]);
46
-
47
- switch (decision.outcome) {
48
- case 'approved':
49
- return { confirmed: true, approvalId: decision.approvalId, choice: decision.choice, actor: decision.actor };
50
- case 'rejected':
51
- throw new Error(
52
- `Manual trigger rejected by ${decision.actor ?? 'user'}` +
53
- (decision.reason ? `: ${decision.reason}` : ''),
54
- );
55
- case 'timeout':
56
- throw new Error(`Manual trigger timeout: ${decision.reason ?? 'no decision made'}`);
57
- case 'aborted':
58
- throw new Error(`Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`);
59
- }
60
- },
61
- };
1
+ import type { TriggerPlugin, TriggerContext } from '../types';
2
+ import { parseDuration } from '../utils';
3
+
4
+ export const ManualTrigger: TriggerPlugin = {
5
+ name: 'manual',
6
+ schema: {
7
+ description: 'Pause the task until a user approves via the approval gateway.',
8
+ fields: {
9
+ message: {
10
+ type: 'string',
11
+ description: 'Prompt shown to the approver. Defaults to a generic message if empty.',
12
+ placeholder: 'Confirm deployment to production?',
13
+ },
14
+ timeout: {
15
+ type: 'duration',
16
+ description: 'Maximum wait time (e.g. 10m). Omit or 0 to wait indefinitely.',
17
+ placeholder: '10m',
18
+ },
19
+ options: {
20
+ type: 'string',
21
+ description: 'Comma-separated list of choices offered to the approver (e.g. "yes,no,defer").',
22
+ placeholder: 'yes,no',
23
+ },
24
+ },
25
+ },
26
+
27
+ async watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
28
+ const message =
29
+ (config.message as string | undefined) ?? `Manual confirmation required for task "${ctx.taskId}"`;
30
+ const timeoutMs = config.timeout ? parseDuration(config.timeout as string) : 0;
31
+ const options = Array.isArray(config.options)
32
+ ? (config.options as unknown[]).map(String)
33
+ : undefined;
34
+ const metadata =
35
+ config.metadata && typeof config.metadata === 'object'
36
+ ? (config.metadata as Record<string, unknown>)
37
+ : undefined;
38
+
39
+ const decisionPromise = ctx.approvalGateway.request({
40
+ taskId: ctx.taskId,
41
+ trackId: ctx.trackId,
42
+ message,
43
+ options,
44
+ timeoutMs,
45
+ metadata,
46
+ });
47
+
48
+ // Wire AbortSignal → try to resolve this specific request as aborted.
49
+ // We can't directly cancel via the gateway (no id yet at .request() call site),
50
+ // so instead we race against an abort promise and let engine status logic
51
+ // fall back to pipelineAborted → skipped. abortAll() on gateway still runs
52
+ // from engine shutdown path to clean up any truly-pending entries.
53
+ const abortPromise = new Promise<never>((_, reject) => {
54
+ if (ctx.signal.aborted) {
55
+ reject(new Error('Pipeline aborted'));
56
+ return;
57
+ }
58
+ ctx.signal.addEventListener(
59
+ 'abort',
60
+ () => reject(new Error('Pipeline aborted')),
61
+ { once: true },
62
+ );
63
+ });
64
+
65
+ const decision = await Promise.race([decisionPromise, abortPromise]);
66
+
67
+ switch (decision.outcome) {
68
+ case 'approved':
69
+ return { confirmed: true, approvalId: decision.approvalId, choice: decision.choice, actor: decision.actor };
70
+ case 'rejected':
71
+ throw new Error(
72
+ `Manual trigger rejected by ${decision.actor ?? 'user'}` +
73
+ (decision.reason ? `: ${decision.reason}` : ''),
74
+ );
75
+ case 'timeout':
76
+ throw new Error(`Manual trigger timeout: ${decision.reason ?? 'no decision made'}`);
77
+ case 'aborted':
78
+ throw new Error(`Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`);
79
+ }
80
+ },
81
+ };