@tagma/sdk 0.4.14 → 0.4.16

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.
Files changed (67) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +569 -569
  3. package/dist/dag.d.ts.map +1 -1
  4. package/dist/dag.js +58 -69
  5. package/dist/dag.js.map +1 -1
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +63 -37
  8. package/dist/engine.js.map +1 -1
  9. package/dist/middlewares/static-context.d.ts.map +1 -1
  10. package/dist/middlewares/static-context.js +7 -3
  11. package/dist/middlewares/static-context.js.map +1 -1
  12. package/dist/prompt-doc.d.ts +36 -0
  13. package/dist/prompt-doc.d.ts.map +1 -0
  14. package/dist/prompt-doc.js +44 -0
  15. package/dist/prompt-doc.js.map +1 -0
  16. package/dist/registry.d.ts.map +1 -1
  17. package/dist/registry.js +11 -0
  18. package/dist/registry.js.map +1 -1
  19. package/dist/sdk.d.ts +3 -0
  20. package/dist/sdk.d.ts.map +1 -1
  21. package/dist/sdk.js +4 -0
  22. package/dist/sdk.js.map +1 -1
  23. package/dist/task-ref.d.ts +55 -0
  24. package/dist/task-ref.d.ts.map +1 -0
  25. package/dist/task-ref.js +101 -0
  26. package/dist/task-ref.js.map +1 -0
  27. package/dist/task-ref.test.d.ts +2 -0
  28. package/dist/task-ref.test.d.ts.map +1 -0
  29. package/dist/task-ref.test.js +364 -0
  30. package/dist/task-ref.test.js.map +1 -0
  31. package/dist/templates.d.ts +20 -0
  32. package/dist/templates.d.ts.map +1 -0
  33. package/dist/templates.js +93 -0
  34. package/dist/templates.js.map +1 -0
  35. package/dist/validate-raw.d.ts.map +1 -1
  36. package/dist/validate-raw.js +27 -53
  37. package/dist/validate-raw.js.map +1 -1
  38. package/package.json +2 -2
  39. package/scripts/preinstall.js +31 -31
  40. package/src/adapters/stdin-approval.ts +106 -106
  41. package/src/adapters/websocket-approval.ts +224 -224
  42. package/src/approval.ts +131 -131
  43. package/src/bootstrap.ts +37 -37
  44. package/src/completions/exit-code.ts +34 -34
  45. package/src/completions/file-exists.ts +66 -66
  46. package/src/completions/output-check.ts +86 -86
  47. package/src/config-ops.ts +307 -307
  48. package/src/dag.ts +61 -67
  49. package/src/drivers/claude-code.ts +250 -250
  50. package/src/engine.ts +1137 -1098
  51. package/src/hooks.ts +187 -187
  52. package/src/logger.ts +182 -182
  53. package/src/middlewares/static-context.ts +49 -45
  54. package/src/pipeline-runner.ts +156 -156
  55. package/src/prompt-doc.ts +49 -0
  56. package/src/registry.ts +255 -242
  57. package/src/runner.ts +395 -395
  58. package/src/schema.test.ts +101 -101
  59. package/src/schema.ts +338 -338
  60. package/src/sdk.ts +111 -92
  61. package/src/task-ref.test.ts +401 -0
  62. package/src/task-ref.ts +120 -0
  63. package/src/triggers/file.ts +164 -164
  64. package/src/triggers/manual.ts +86 -86
  65. package/src/types.ts +18 -18
  66. package/src/utils.ts +203 -203
  67. package/src/validate-raw.ts +412 -442
@@ -0,0 +1,120 @@
1
+ // ═══ Task reference resolution — single source of truth ═══
2
+ //
3
+ // Before this module existed, four sites each carried their own copy of the
4
+ // "what is a valid id" + "how do I resolve a bare / same-track-shorthand /
5
+ // fully qualified ref" logic:
6
+ //
7
+ // - dag.ts/buildDag (threw on unresolved, threw on ambiguous)
8
+ // - dag.ts/buildRawDag (silently skipped unresolved and ambiguous)
9
+ // - validate-raw.ts (reported both as errors, with different wording)
10
+ // - engine.ts/resolveRefInDag (returned null on ambiguous)
11
+ //
12
+ // In addition, the editor shipped its own regex for id validation in
13
+ // `shared/config-id.ts` and a test-local copy in `config-id-generation.test.ts`,
14
+ // creating multiple places where the character set could drift from the
15
+ // validator. Bugs observed downstream (silent context loss when two tracks
16
+ // happened to share a bare task name; editor-generated ids occasionally
17
+ // failing SDK validate-raw) all traced back to this duplication.
18
+ //
19
+ // Callers now build a TaskIndex once and use `resolveTaskRef` to classify
20
+ // each reference, then decide themselves whether to throw / warn / skip —
21
+ // instead of re-implementing the index build and the lookup logic.
22
+
23
+ import type { PipelineConfig, RawPipelineConfig } from './types';
24
+
25
+ /**
26
+ * D8: task and track ids must match this pattern. No dots: the `.` is the
27
+ * qualified-id separator ("trackId.taskId"), so allowing it inside either
28
+ * part would make qid parsing ambiguous and break every resolver below.
29
+ */
30
+ export const TASK_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
31
+
32
+ export function isValidTaskId(id: unknown): id is string {
33
+ return typeof id === 'string' && TASK_ID_RE.test(id);
34
+ }
35
+
36
+ /** Canonical qualified form used throughout the engine. */
37
+ export function qualifyTaskId(trackId: string, taskId: string): string {
38
+ return `${trackId}.${taskId}`;
39
+ }
40
+
41
+ /** Does the reference already include a track prefix? */
42
+ export function isQualifiedRef(ref: string): boolean {
43
+ return ref.includes('.');
44
+ }
45
+
46
+ /**
47
+ * Sentinel stored in `TaskIndex.bareToQualified` when a bare task id is
48
+ * shared by more than one track, making it unresolvable without a prefix.
49
+ * Exposed so callers that want to inspect the index directly know what to
50
+ * look for — but prefer `resolveTaskRef` which returns a typed `kind`.
51
+ */
52
+ export const AMBIGUOUS = '__ambiguous__';
53
+
54
+ export interface TaskIndex {
55
+ /** All fully-qualified ids ("trackId.taskId") present in the config. */
56
+ readonly allQualified: ReadonlySet<string>;
57
+ /** bare taskId → qid, or the {@link AMBIGUOUS} sentinel. */
58
+ readonly bareToQualified: ReadonlyMap<string, string>;
59
+ }
60
+
61
+ /**
62
+ * Build the index used by {@link resolveTaskRef}. Tolerant of partially
63
+ * malformed configs: tracks or tasks missing an `id` are skipped so the
64
+ * editor can call this during real-time validation on in-progress edits.
65
+ */
66
+ export function buildTaskIndex(config: RawPipelineConfig | PipelineConfig): TaskIndex {
67
+ const allQualified = new Set<string>();
68
+ const bareToQualified = new Map<string, string>();
69
+ for (const track of config.tracks ?? []) {
70
+ if (!track?.id) continue;
71
+ for (const task of track.tasks ?? []) {
72
+ if (!task?.id) continue;
73
+ const qid = qualifyTaskId(track.id, task.id);
74
+ allQualified.add(qid);
75
+ if (bareToQualified.has(task.id)) {
76
+ bareToQualified.set(task.id, AMBIGUOUS);
77
+ } else {
78
+ bareToQualified.set(task.id, qid);
79
+ }
80
+ }
81
+ }
82
+ return { allQualified, bareToQualified };
83
+ }
84
+
85
+ export type RefResolution =
86
+ | { readonly kind: 'resolved'; readonly qid: string }
87
+ | { readonly kind: 'ambiguous'; readonly ref: string }
88
+ | { readonly kind: 'not_found'; readonly ref: string };
89
+
90
+ /**
91
+ * Resolve a dependency / continue_from reference to a canonical qid.
92
+ *
93
+ * 1. If the ref already contains a `.`, treat it as fully qualified —
94
+ * return `resolved` when the qid exists, `not_found` otherwise.
95
+ * 2. Otherwise, prefer the same-track shorthand (`fromTrackId.ref`).
96
+ * 3. Fall back to a global bare lookup. Returns `ambiguous` when more
97
+ * than one track has a task with that bare name.
98
+ *
99
+ * Callers decide the policy: `buildDag` throws on non-resolved, `buildRawDag`
100
+ * skips silently, `validateRaw` emits a structured ValidationError.
101
+ */
102
+ export function resolveTaskRef(
103
+ ref: string,
104
+ fromTrackId: string,
105
+ index: TaskIndex,
106
+ ): RefResolution {
107
+ if (isQualifiedRef(ref)) {
108
+ return index.allQualified.has(ref)
109
+ ? { kind: 'resolved', qid: ref }
110
+ : { kind: 'not_found', ref };
111
+ }
112
+ const sameTrack = qualifyTaskId(fromTrackId, ref);
113
+ if (index.allQualified.has(sameTrack)) {
114
+ return { kind: 'resolved', qid: sameTrack };
115
+ }
116
+ const global = index.bareToQualified.get(ref);
117
+ if (global === AMBIGUOUS) return { kind: 'ambiguous', ref };
118
+ if (global !== undefined) return { kind: 'resolved', qid: global };
119
+ return { kind: 'not_found', ref };
120
+ }
@@ -1,164 +1,164 @@
1
- import { watch } from 'chokidar';
2
- import { resolve, dirname } from 'path';
3
- import { mkdir } from 'fs/promises';
4
- import type { TriggerPlugin, TriggerContext } from '../types';
5
- import { parseDuration, validatePath } from '../utils';
6
- import { TriggerTimeoutError } from '../engine';
7
-
8
- const IS_WINDOWS = process.platform === 'win32';
9
-
10
- function pathsEqual(a: string, b: string): boolean {
11
- return IS_WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b;
12
- }
13
-
14
- export const FileTrigger: TriggerPlugin = {
15
- name: 'file',
16
- schema: {
17
- description: 'Wait for a file to appear or be modified before the task runs.',
18
- fields: {
19
- path: {
20
- type: 'path',
21
- required: true,
22
- description: 'Path to the file to watch (relative to workDir or absolute).',
23
- placeholder: 'e.g. build/output.json',
24
- },
25
- timeout: {
26
- type: 'duration',
27
- description: 'Maximum wait time (e.g. 30s, 5m). Omit or 0 to wait indefinitely.',
28
- placeholder: '30s',
29
- },
30
- },
31
- },
32
-
33
- watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
34
- const filePath = config.path as string;
35
- if (!filePath) throw new Error(`file trigger: "path" is required`);
36
-
37
- const safePath = validatePath(filePath, ctx.workDir);
38
- const timeoutMs = config.timeout != null ? parseDuration(String(config.timeout)) : 0;
39
-
40
- // Hoist the async work into a named async function so the Promise
41
- // constructor itself is synchronous — avoids the no-async-promise-executor
42
- // lint error and ensures exceptions are always propagated via reject().
43
- async function start(
44
- resolve_p: (value: unknown) => void,
45
- reject: (reason?: unknown) => void,
46
- ): Promise<void> {
47
- if (ctx.signal.aborted) {
48
- reject(new Error('Pipeline aborted'));
49
- return;
50
- }
51
-
52
- let settled = false;
53
- let timer: ReturnType<typeof setTimeout> | null = null;
54
-
55
- // Ensure the parent directory exists so the watcher doesn't fail
56
- // with ENOENT for nested paths like `build/output/result.json`.
57
- const dir = dirname(safePath);
58
- try {
59
- await mkdir(dir, { recursive: true });
60
- } catch {
61
- /* best effort — dir may already exist */
62
- }
63
-
64
- // Pass `cwd: dir` so chokidar resolves paths relative to the watched
65
- // directory. The 'add'/'change' events will then carry paths relative
66
- // to `dir`, which we resolve with `resolve(dir, addedPath)` for an
67
- // accurate absolute comparison — fixing the ambiguous process.cwd()
68
- // resolution of the previous implementation.
69
- const watcher = watch(dir, {
70
- ignoreInitial: true,
71
- depth: 0,
72
- cwd: dir,
73
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
74
- });
75
-
76
- const cleanup = () => {
77
- if (settled) return;
78
- settled = true;
79
- watcher.close().catch(() => {
80
- /* ignore */
81
- });
82
- if (timer) clearTimeout(timer);
83
- ctx.signal.removeEventListener('abort', onAbort);
84
- };
85
-
86
- const onAbort = () => {
87
- cleanup();
88
- reject(new Error('Pipeline aborted'));
89
- };
90
-
91
- watcher.on('add', (addedPath: string) => {
92
- if (settled) return;
93
- if (pathsEqual(resolve(dir, addedPath), safePath)) {
94
- cleanup();
95
- resolve_p({ path: safePath });
96
- }
97
- });
98
-
99
- // Also fire on 'change' so that overwriting an existing file is detected.
100
- // Without this, upstream tasks that truncate-and-rewrite a file emit only
101
- // a 'change' event and the downstream trigger would never resolve.
102
- watcher.on('change', (changedPath: string) => {
103
- if (settled) return;
104
- if (pathsEqual(resolve(dir, changedPath), safePath)) {
105
- cleanup();
106
- resolve_p({ path: safePath });
107
- }
108
- });
109
-
110
- watcher.on('error', (err: unknown) => {
111
- if (settled) return;
112
- cleanup();
113
- reject(
114
- new Error(
115
- `file trigger watch error: ${err instanceof Error ? err.message : String(err)}`,
116
- ),
117
- );
118
- });
119
-
120
- // After the watcher finishes its initial scan, check if the file already exists.
121
- // Doing this inside 'ready' eliminates the race window between existence check
122
- // and watcher startup, so we neither miss events nor double-resolve.
123
- watcher.on('ready', () => {
124
- if (settled) return;
125
- Bun.file(safePath)
126
- .exists()
127
- .then((exists) => {
128
- if (settled) return;
129
- if (exists) {
130
- cleanup();
131
- resolve_p({ path: safePath });
132
- }
133
- })
134
- .catch((err: unknown) => {
135
- if (settled) return;
136
- cleanup();
137
- reject(
138
- new Error(
139
- `file trigger existence check failed: ${err instanceof Error ? err.message : String(err)}`,
140
- ),
141
- );
142
- });
143
- });
144
-
145
- if (timeoutMs > 0) {
146
- timer = setTimeout(() => {
147
- if (settled) return;
148
- cleanup();
149
- reject(
150
- new TriggerTimeoutError(
151
- `file trigger timeout: ${filePath} did not appear within ${config.timeout}`,
152
- ),
153
- );
154
- }, timeoutMs);
155
- }
156
-
157
- ctx.signal.addEventListener('abort', onAbort);
158
- }
159
-
160
- return new Promise((resolve_p, reject) => {
161
- start(resolve_p, reject).catch(reject);
162
- });
163
- },
164
- };
1
+ import { watch } from 'chokidar';
2
+ import { resolve, dirname } from 'path';
3
+ import { mkdir } from 'fs/promises';
4
+ import type { TriggerPlugin, TriggerContext } from '../types';
5
+ import { parseDuration, validatePath } from '../utils';
6
+ import { TriggerTimeoutError } from '../engine';
7
+
8
+ const IS_WINDOWS = process.platform === 'win32';
9
+
10
+ function pathsEqual(a: string, b: string): boolean {
11
+ return IS_WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b;
12
+ }
13
+
14
+ export const FileTrigger: TriggerPlugin = {
15
+ name: 'file',
16
+ schema: {
17
+ description: 'Wait for a file to appear or be modified before the task runs.',
18
+ fields: {
19
+ path: {
20
+ type: 'path',
21
+ required: true,
22
+ description: 'Path to the file to watch (relative to workDir or absolute).',
23
+ placeholder: 'e.g. build/output.json',
24
+ },
25
+ timeout: {
26
+ type: 'duration',
27
+ description: 'Maximum wait time (e.g. 30s, 5m). Omit or 0 to wait indefinitely.',
28
+ placeholder: '30s',
29
+ },
30
+ },
31
+ },
32
+
33
+ watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
34
+ const filePath = config.path as string;
35
+ if (!filePath) throw new Error(`file trigger: "path" is required`);
36
+
37
+ const safePath = validatePath(filePath, ctx.workDir);
38
+ const timeoutMs = config.timeout != null ? parseDuration(String(config.timeout)) : 0;
39
+
40
+ // Hoist the async work into a named async function so the Promise
41
+ // constructor itself is synchronous — avoids the no-async-promise-executor
42
+ // lint error and ensures exceptions are always propagated via reject().
43
+ async function start(
44
+ resolve_p: (value: unknown) => void,
45
+ reject: (reason?: unknown) => void,
46
+ ): Promise<void> {
47
+ if (ctx.signal.aborted) {
48
+ reject(new Error('Pipeline aborted'));
49
+ return;
50
+ }
51
+
52
+ let settled = false;
53
+ let timer: ReturnType<typeof setTimeout> | null = null;
54
+
55
+ // Ensure the parent directory exists so the watcher doesn't fail
56
+ // with ENOENT for nested paths like `build/output/result.json`.
57
+ const dir = dirname(safePath);
58
+ try {
59
+ await mkdir(dir, { recursive: true });
60
+ } catch {
61
+ /* best effort — dir may already exist */
62
+ }
63
+
64
+ // Pass `cwd: dir` so chokidar resolves paths relative to the watched
65
+ // directory. The 'add'/'change' events will then carry paths relative
66
+ // to `dir`, which we resolve with `resolve(dir, addedPath)` for an
67
+ // accurate absolute comparison — fixing the ambiguous process.cwd()
68
+ // resolution of the previous implementation.
69
+ const watcher = watch(dir, {
70
+ ignoreInitial: true,
71
+ depth: 0,
72
+ cwd: dir,
73
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
74
+ });
75
+
76
+ const cleanup = () => {
77
+ if (settled) return;
78
+ settled = true;
79
+ watcher.close().catch(() => {
80
+ /* ignore */
81
+ });
82
+ if (timer) clearTimeout(timer);
83
+ ctx.signal.removeEventListener('abort', onAbort);
84
+ };
85
+
86
+ const onAbort = () => {
87
+ cleanup();
88
+ reject(new Error('Pipeline aborted'));
89
+ };
90
+
91
+ watcher.on('add', (addedPath: string) => {
92
+ if (settled) return;
93
+ if (pathsEqual(resolve(dir, addedPath), safePath)) {
94
+ cleanup();
95
+ resolve_p({ path: safePath });
96
+ }
97
+ });
98
+
99
+ // Also fire on 'change' so that overwriting an existing file is detected.
100
+ // Without this, upstream tasks that truncate-and-rewrite a file emit only
101
+ // a 'change' event and the downstream trigger would never resolve.
102
+ watcher.on('change', (changedPath: string) => {
103
+ if (settled) return;
104
+ if (pathsEqual(resolve(dir, changedPath), safePath)) {
105
+ cleanup();
106
+ resolve_p({ path: safePath });
107
+ }
108
+ });
109
+
110
+ watcher.on('error', (err: unknown) => {
111
+ if (settled) return;
112
+ cleanup();
113
+ reject(
114
+ new Error(
115
+ `file trigger watch error: ${err instanceof Error ? err.message : String(err)}`,
116
+ ),
117
+ );
118
+ });
119
+
120
+ // After the watcher finishes its initial scan, check if the file already exists.
121
+ // Doing this inside 'ready' eliminates the race window between existence check
122
+ // and watcher startup, so we neither miss events nor double-resolve.
123
+ watcher.on('ready', () => {
124
+ if (settled) return;
125
+ Bun.file(safePath)
126
+ .exists()
127
+ .then((exists) => {
128
+ if (settled) return;
129
+ if (exists) {
130
+ cleanup();
131
+ resolve_p({ path: safePath });
132
+ }
133
+ })
134
+ .catch((err: unknown) => {
135
+ if (settled) return;
136
+ cleanup();
137
+ reject(
138
+ new Error(
139
+ `file trigger existence check failed: ${err instanceof Error ? err.message : String(err)}`,
140
+ ),
141
+ );
142
+ });
143
+ });
144
+
145
+ if (timeoutMs > 0) {
146
+ timer = setTimeout(() => {
147
+ if (settled) return;
148
+ cleanup();
149
+ reject(
150
+ new TriggerTimeoutError(
151
+ `file trigger timeout: ${filePath} did not appear within ${config.timeout}`,
152
+ ),
153
+ );
154
+ }, timeoutMs);
155
+ }
156
+
157
+ ctx.signal.addEventListener('abort', onAbort);
158
+ }
159
+
160
+ return new Promise((resolve_p, reject) => {
161
+ start(resolve_p, reject).catch(reject);
162
+ });
163
+ },
164
+ };
@@ -1,86 +1,86 @@
1
- import type { TriggerPlugin, TriggerContext } from '../types';
2
- import { parseDuration } from '../utils';
3
- import { TriggerBlockedError, TriggerTimeoutError } from '../engine';
4
-
5
- export const ManualTrigger: TriggerPlugin = {
6
- name: 'manual',
7
- schema: {
8
- description: 'Pause the task until a user approves via the approval gateway.',
9
- fields: {
10
- message: {
11
- type: 'string',
12
- description: 'Prompt shown to the approver. Defaults to a generic message if empty.',
13
- placeholder: 'Confirm deployment to production?',
14
- },
15
- timeout: {
16
- type: 'duration',
17
- description: 'Maximum wait time (e.g. 10m). Omit or 0 to wait indefinitely.',
18
- placeholder: '10m',
19
- },
20
- },
21
- },
22
-
23
- async watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
24
- const message =
25
- (config.message as string | undefined) ??
26
- `Manual confirmation required for task "${ctx.taskId}"`;
27
- const timeoutMs = config.timeout ? parseDuration(config.timeout as string) : 0;
28
- const metadata =
29
- config.metadata && typeof config.metadata === 'object'
30
- ? (config.metadata as Record<string, unknown>)
31
- : undefined;
32
-
33
- const decisionPromise = ctx.approvalGateway.request({
34
- taskId: ctx.taskId,
35
- trackId: ctx.trackId,
36
- message,
37
- timeoutMs,
38
- metadata,
39
- });
40
-
41
- // Wire AbortSignal → try to resolve this specific request as aborted.
42
- // We can't directly cancel via the gateway (no id yet at .request() call site),
43
- // so instead we race against an abort promise and let engine status logic
44
- // fall back to pipelineAborted → skipped. abortAll() on gateway still runs
45
- // from engine shutdown path to clean up any truly-pending entries.
46
- const onAbort = () => {};
47
- const abortPromise = new Promise<never>((_, reject) => {
48
- if (ctx.signal.aborted) {
49
- reject(new Error('Pipeline aborted'));
50
- return;
51
- }
52
- const handler = () => reject(new Error('Pipeline aborted'));
53
- // Store reference so we can remove it after the race settles.
54
- (onAbort as { handler?: () => void }).handler = handler;
55
- ctx.signal.addEventListener('abort', handler, { once: true });
56
- });
57
-
58
- let decision: Awaited<typeof decisionPromise>;
59
- try {
60
- decision = await Promise.race([decisionPromise, abortPromise]);
61
- } finally {
62
- // Clean up the abort listener to prevent leaking on normal completion.
63
- const handler = (onAbort as { handler?: () => void }).handler;
64
- if (handler) ctx.signal.removeEventListener('abort', handler);
65
- }
66
-
67
- switch (decision.outcome) {
68
- case 'approved':
69
- return { confirmed: true, approvalId: decision.approvalId, actor: decision.actor };
70
- case 'rejected':
71
- // A7: Use typed error for proper classification in the engine.
72
- throw new TriggerBlockedError(
73
- `Manual trigger rejected by ${decision.actor ?? 'user'}` +
74
- (decision.reason ? `: ${decision.reason}` : ''),
75
- );
76
- case 'timeout':
77
- throw new TriggerTimeoutError(
78
- `Manual trigger timeout: ${decision.reason ?? 'no decision made'}`,
79
- );
80
- case 'aborted':
81
- throw new TriggerBlockedError(
82
- `Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`,
83
- );
84
- }
85
- },
86
- };
1
+ import type { TriggerPlugin, TriggerContext } from '../types';
2
+ import { parseDuration } from '../utils';
3
+ import { TriggerBlockedError, TriggerTimeoutError } from '../engine';
4
+
5
+ export const ManualTrigger: TriggerPlugin = {
6
+ name: 'manual',
7
+ schema: {
8
+ description: 'Pause the task until a user approves via the approval gateway.',
9
+ fields: {
10
+ message: {
11
+ type: 'string',
12
+ description: 'Prompt shown to the approver. Defaults to a generic message if empty.',
13
+ placeholder: 'Confirm deployment to production?',
14
+ },
15
+ timeout: {
16
+ type: 'duration',
17
+ description: 'Maximum wait time (e.g. 10m). Omit or 0 to wait indefinitely.',
18
+ placeholder: '10m',
19
+ },
20
+ },
21
+ },
22
+
23
+ async watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
24
+ const message =
25
+ (config.message as string | undefined) ??
26
+ `Manual confirmation required for task "${ctx.taskId}"`;
27
+ const timeoutMs = config.timeout ? parseDuration(config.timeout as string) : 0;
28
+ const metadata =
29
+ config.metadata && typeof config.metadata === 'object'
30
+ ? (config.metadata as Record<string, unknown>)
31
+ : undefined;
32
+
33
+ const decisionPromise = ctx.approvalGateway.request({
34
+ taskId: ctx.taskId,
35
+ trackId: ctx.trackId,
36
+ message,
37
+ timeoutMs,
38
+ metadata,
39
+ });
40
+
41
+ // Wire AbortSignal → try to resolve this specific request as aborted.
42
+ // We can't directly cancel via the gateway (no id yet at .request() call site),
43
+ // so instead we race against an abort promise and let engine status logic
44
+ // fall back to pipelineAborted → skipped. abortAll() on gateway still runs
45
+ // from engine shutdown path to clean up any truly-pending entries.
46
+ const onAbort = () => {};
47
+ const abortPromise = new Promise<never>((_, reject) => {
48
+ if (ctx.signal.aborted) {
49
+ reject(new Error('Pipeline aborted'));
50
+ return;
51
+ }
52
+ const handler = () => reject(new Error('Pipeline aborted'));
53
+ // Store reference so we can remove it after the race settles.
54
+ (onAbort as { handler?: () => void }).handler = handler;
55
+ ctx.signal.addEventListener('abort', handler, { once: true });
56
+ });
57
+
58
+ let decision: Awaited<typeof decisionPromise>;
59
+ try {
60
+ decision = await Promise.race([decisionPromise, abortPromise]);
61
+ } finally {
62
+ // Clean up the abort listener to prevent leaking on normal completion.
63
+ const handler = (onAbort as { handler?: () => void }).handler;
64
+ if (handler) ctx.signal.removeEventListener('abort', handler);
65
+ }
66
+
67
+ switch (decision.outcome) {
68
+ case 'approved':
69
+ return { confirmed: true, approvalId: decision.approvalId, actor: decision.actor };
70
+ case 'rejected':
71
+ // A7: Use typed error for proper classification in the engine.
72
+ throw new TriggerBlockedError(
73
+ `Manual trigger rejected by ${decision.actor ?? 'user'}` +
74
+ (decision.reason ? `: ${decision.reason}` : ''),
75
+ );
76
+ case 'timeout':
77
+ throw new TriggerTimeoutError(
78
+ `Manual trigger timeout: ${decision.reason ?? 'no decision made'}`,
79
+ );
80
+ case 'aborted':
81
+ throw new TriggerBlockedError(
82
+ `Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`,
83
+ );
84
+ }
85
+ },
86
+ };
package/src/types.ts CHANGED
@@ -1,18 +1,18 @@
1
- // ═══ Engine-facing type surface ═══
2
- //
3
- // All type definitions live in the shared `@tagma/types` workspace package
4
- // so that plugins under plugins/* can depend on the same types without
5
- // reaching into the engine's internals. This file re-exports everything
6
- // and adds runtime-only values (constants) that plugins don't need.
7
-
8
- export * from '@tagma/types';
9
-
10
- import type { Permissions } from '@tagma/types';
11
-
12
- // ═══ Runtime Constants ═══
13
-
14
- export const DEFAULT_PERMISSIONS: Permissions = {
15
- read: true,
16
- write: false,
17
- execute: false,
18
- };
1
+ // ═══ Engine-facing type surface ═══
2
+ //
3
+ // All type definitions live in the shared `@tagma/types` workspace package
4
+ // so that plugins under plugins/* can depend on the same types without
5
+ // reaching into the engine's internals. This file re-exports everything
6
+ // and adds runtime-only values (constants) that plugins don't need.
7
+
8
+ export * from '@tagma/types';
9
+
10
+ import type { Permissions } from '@tagma/types';
11
+
12
+ // ═══ Runtime Constants ═══
13
+
14
+ export const DEFAULT_PERMISSIONS: Permissions = {
15
+ read: true,
16
+ write: false,
17
+ execute: false,
18
+ };