@tagma/sdk 0.6.0 → 0.6.2

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 (48) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +573 -573
  3. package/dist/bootstrap.d.ts +11 -1
  4. package/dist/bootstrap.d.ts.map +1 -1
  5. package/dist/bootstrap.js +18 -9
  6. package/dist/bootstrap.js.map +1 -1
  7. package/dist/drivers/opencode.d.ts.map +1 -1
  8. package/dist/drivers/opencode.js +47 -17
  9. package/dist/drivers/opencode.js.map +1 -1
  10. package/dist/engine.d.ts +8 -0
  11. package/dist/engine.d.ts.map +1 -1
  12. package/dist/engine.js +17 -16
  13. package/dist/engine.js.map +1 -1
  14. package/dist/plugin-registry.test.d.ts +2 -0
  15. package/dist/plugin-registry.test.d.ts.map +1 -0
  16. package/dist/plugin-registry.test.js +188 -0
  17. package/dist/plugin-registry.test.js.map +1 -0
  18. package/dist/registry.d.ts +52 -28
  19. package/dist/registry.d.ts.map +1 -1
  20. package/dist/registry.js +126 -91
  21. package/dist/registry.js.map +1 -1
  22. package/dist/sdk.d.ts +1 -1
  23. package/dist/sdk.d.ts.map +1 -1
  24. package/dist/sdk.js +1 -1
  25. package/dist/sdk.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/bootstrap.ts +46 -37
  28. package/src/completions/output-check.ts +92 -92
  29. package/src/dag.ts +245 -245
  30. package/src/drivers/opencode.ts +410 -371
  31. package/src/engine.ts +1228 -1220
  32. package/src/hooks.ts +193 -193
  33. package/src/middlewares/static-context.ts +49 -49
  34. package/src/pipeline-runner.ts +173 -173
  35. package/src/plugin-registry.test.ts +230 -0
  36. package/src/prompt-doc.ts +49 -49
  37. package/src/registry.ts +316 -267
  38. package/src/runner.ts +460 -460
  39. package/src/schema.test.ts +101 -101
  40. package/src/schema.ts +338 -338
  41. package/src/sdk.ts +120 -118
  42. package/src/task-ref.test.ts +401 -401
  43. package/src/task-ref.ts +120 -120
  44. package/src/validate-raw.ts +412 -412
  45. package/dist/drivers/claude-code.d.ts +0 -3
  46. package/dist/drivers/claude-code.d.ts.map +0 -1
  47. package/dist/drivers/claude-code.js +0 -225
  48. package/dist/drivers/claude-code.js.map +0 -1
@@ -1,173 +1,173 @@
1
- // ═══ PipelineRunner ═══
2
- //
3
- // Wraps runPipeline in a lifecycle object suited for multi-pipeline
4
- // management in sidecar / Tauri IPC scenarios. Each instance controls
5
- // one pipeline run.
6
- //
7
- // The runner forwards wire-shape `RunEventPayload` values to its
8
- // subscribers — identical to what the editor server broadcasts over SSE —
9
- // so sidecar hosts don't need to know anything about the engine's
10
- // internal TaskState.
11
- //
12
- // Typical sidecar usage:
13
- //
14
- // const runners = new Map<string, PipelineRunner>();
15
- //
16
- // const runner = new PipelineRunner(config, workDir);
17
- // runner.subscribe(event => ipcEmit('run_event', event));
18
- // runner.start();
19
- // runners.set(runner.instanceId, runner);
20
- //
21
- // // Later, from IPC:
22
- // runners.get(id)?.abort();
23
-
24
- import { runPipeline } from './engine';
25
- import type { EngineResult, RunPipelineOptions } from './engine';
26
- import type { PipelineConfig, RunEventPayload, RunTaskState } from './types';
27
- import { generateRunId } from './utils';
28
-
29
- export type { EngineResult };
30
-
31
- export type PipelineRunnerStatus = 'idle' | 'running' | 'done' | 'aborted';
32
-
33
- export class PipelineRunner {
34
- /**
35
- * Stable ID assigned before start() — safe to use as a Map key before
36
- * the engine-assigned runId becomes available.
37
- */
38
- readonly instanceId: string;
39
-
40
- /**
41
- * The runId generated by the engine. Set when the first `run_start`
42
- * event arrives on the forwarded event stream. null until then.
43
- */
44
- private _runId: string | null = null;
45
- private _status: PipelineRunnerStatus = 'idle';
46
- private _result: Promise<EngineResult> | null = null;
47
- private _abortController = new AbortController();
48
- private _handlers = new Set<(event: RunEventPayload) => void>();
49
- /**
50
- * Wire-shape task mirror, kept in sync with `run_start` / `task_update`
51
- * events. Exposed through `getTasks()`. Hosts see the same wire
52
- * projection the editor client sees, so there is exactly one task-state
53
- * vocabulary across IPC boundaries.
54
- */
55
- private _tasks = new Map<string, RunTaskState>();
56
-
57
- constructor(
58
- private readonly config: PipelineConfig,
59
- private readonly workDir: string,
60
- private readonly opts: Omit<RunPipelineOptions, 'signal' | 'onEvent'> = {},
61
- ) {
62
- this.instanceId = generateRunId();
63
- }
64
-
65
- get runId(): string | null {
66
- return this._runId;
67
- }
68
- get status(): PipelineRunnerStatus {
69
- return this._status;
70
- }
71
-
72
- /**
73
- * Start the pipeline. Calling start() more than once returns the same Promise.
74
- */
75
- start(): Promise<EngineResult> {
76
- if (this._result) return this._result;
77
-
78
- // Guard: if abort() was called before start(), the signal is already
79
- // aborted. Create a fresh controller so the pipeline doesn't terminate
80
- // immediately. If users truly want pre-abort semantics, they call
81
- // abort() after start().
82
- if (this._abortController.signal.aborted) {
83
- this._abortController = new AbortController();
84
- this._status = 'idle';
85
- }
86
-
87
- this._status = 'running';
88
- this._result = runPipeline(this.config, this.workDir, {
89
- ...this.opts,
90
- signal: this._abortController.signal,
91
- onEvent: (event) => {
92
- this._applyEvent(event);
93
- for (const h of this._handlers) h(event);
94
- },
95
- })
96
- .then((result) => {
97
- if (this._status === 'running') this._status = 'done';
98
- return result;
99
- })
100
- .catch((err) => {
101
- this._status = 'aborted';
102
- throw err;
103
- });
104
-
105
- return this._result;
106
- }
107
-
108
- private _applyEvent(event: RunEventPayload): void {
109
- switch (event.type) {
110
- case 'run_start':
111
- this._runId = event.runId;
112
- this._tasks.clear();
113
- for (const t of event.tasks) this._tasks.set(t.taskId, { ...t });
114
- return;
115
- case 'task_update': {
116
- const prev = this._tasks.get(event.taskId);
117
- if (!prev) return;
118
- const pick = <T>(incoming: T | undefined, previous: T): T =>
119
- incoming !== undefined ? incoming : previous;
120
- this._tasks.set(event.taskId, {
121
- ...prev,
122
- status: event.status,
123
- startedAt: pick(event.startedAt, prev.startedAt),
124
- finishedAt: pick(event.finishedAt, prev.finishedAt),
125
- durationMs: pick(event.durationMs, prev.durationMs),
126
- exitCode: pick(event.exitCode, prev.exitCode),
127
- stdout: pick(event.stdout, prev.stdout),
128
- stderr: pick(event.stderr, prev.stderr),
129
- stderrPath: pick(event.stderrPath, prev.stderrPath),
130
- sessionId: pick(event.sessionId, prev.sessionId),
131
- normalizedOutput: pick(event.normalizedOutput, prev.normalizedOutput),
132
- resolvedDriver: pick(event.resolvedDriver, prev.resolvedDriver),
133
- resolvedModel: pick(event.resolvedModel, prev.resolvedModel),
134
- resolvedPermissions: pick(event.resolvedPermissions, prev.resolvedPermissions),
135
- });
136
- return;
137
- }
138
- case 'run_end':
139
- this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
140
- return;
141
- default:
142
- return;
143
- }
144
- }
145
-
146
- /**
147
- * Cancel the running pipeline. Safe to call multiple times or before start().
148
- */
149
- abort(reason?: string): void {
150
- this._status = 'aborted';
151
- this._abortController.abort(reason);
152
- }
153
-
154
- /**
155
- * Live snapshot of wire-shape task states. Populated from the first
156
- * `run_start` event onward. Returns an empty map before the run starts.
157
- */
158
- getTasks(): ReadonlyMap<string, RunTaskState> {
159
- const copy = new Map<string, RunTaskState>();
160
- for (const [id, t] of this._tasks) copy.set(id, { ...t });
161
- return copy;
162
- }
163
-
164
- /**
165
- * Subscribe to run events. Returns an unsubscribe function. Events are
166
- * emitted synchronously in the engine's event loop, so keep handlers
167
- * non-blocking (e.g. queue to IPC, do not await inside).
168
- */
169
- subscribe(handler: (event: RunEventPayload) => void): () => void {
170
- this._handlers.add(handler);
171
- return () => this._handlers.delete(handler);
172
- }
173
- }
1
+ // ═══ PipelineRunner ═══
2
+ //
3
+ // Wraps runPipeline in a lifecycle object suited for multi-pipeline
4
+ // management in sidecar / Tauri IPC scenarios. Each instance controls
5
+ // one pipeline run.
6
+ //
7
+ // The runner forwards wire-shape `RunEventPayload` values to its
8
+ // subscribers — identical to what the editor server broadcasts over SSE —
9
+ // so sidecar hosts don't need to know anything about the engine's
10
+ // internal TaskState.
11
+ //
12
+ // Typical sidecar usage:
13
+ //
14
+ // const runners = new Map<string, PipelineRunner>();
15
+ //
16
+ // const runner = new PipelineRunner(config, workDir);
17
+ // runner.subscribe(event => ipcEmit('run_event', event));
18
+ // runner.start();
19
+ // runners.set(runner.instanceId, runner);
20
+ //
21
+ // // Later, from IPC:
22
+ // runners.get(id)?.abort();
23
+
24
+ import { runPipeline } from './engine';
25
+ import type { EngineResult, RunPipelineOptions } from './engine';
26
+ import type { PipelineConfig, RunEventPayload, RunTaskState } from './types';
27
+ import { generateRunId } from './utils';
28
+
29
+ export type { EngineResult };
30
+
31
+ export type PipelineRunnerStatus = 'idle' | 'running' | 'done' | 'aborted';
32
+
33
+ export class PipelineRunner {
34
+ /**
35
+ * Stable ID assigned before start() — safe to use as a Map key before
36
+ * the engine-assigned runId becomes available.
37
+ */
38
+ readonly instanceId: string;
39
+
40
+ /**
41
+ * The runId generated by the engine. Set when the first `run_start`
42
+ * event arrives on the forwarded event stream. null until then.
43
+ */
44
+ private _runId: string | null = null;
45
+ private _status: PipelineRunnerStatus = 'idle';
46
+ private _result: Promise<EngineResult> | null = null;
47
+ private _abortController = new AbortController();
48
+ private _handlers = new Set<(event: RunEventPayload) => void>();
49
+ /**
50
+ * Wire-shape task mirror, kept in sync with `run_start` / `task_update`
51
+ * events. Exposed through `getTasks()`. Hosts see the same wire
52
+ * projection the editor client sees, so there is exactly one task-state
53
+ * vocabulary across IPC boundaries.
54
+ */
55
+ private _tasks = new Map<string, RunTaskState>();
56
+
57
+ constructor(
58
+ private readonly config: PipelineConfig,
59
+ private readonly workDir: string,
60
+ private readonly opts: Omit<RunPipelineOptions, 'signal' | 'onEvent'> = {},
61
+ ) {
62
+ this.instanceId = generateRunId();
63
+ }
64
+
65
+ get runId(): string | null {
66
+ return this._runId;
67
+ }
68
+ get status(): PipelineRunnerStatus {
69
+ return this._status;
70
+ }
71
+
72
+ /**
73
+ * Start the pipeline. Calling start() more than once returns the same Promise.
74
+ */
75
+ start(): Promise<EngineResult> {
76
+ if (this._result) return this._result;
77
+
78
+ // Guard: if abort() was called before start(), the signal is already
79
+ // aborted. Create a fresh controller so the pipeline doesn't terminate
80
+ // immediately. If users truly want pre-abort semantics, they call
81
+ // abort() after start().
82
+ if (this._abortController.signal.aborted) {
83
+ this._abortController = new AbortController();
84
+ this._status = 'idle';
85
+ }
86
+
87
+ this._status = 'running';
88
+ this._result = runPipeline(this.config, this.workDir, {
89
+ ...this.opts,
90
+ signal: this._abortController.signal,
91
+ onEvent: (event) => {
92
+ this._applyEvent(event);
93
+ for (const h of this._handlers) h(event);
94
+ },
95
+ })
96
+ .then((result) => {
97
+ if (this._status === 'running') this._status = 'done';
98
+ return result;
99
+ })
100
+ .catch((err) => {
101
+ this._status = 'aborted';
102
+ throw err;
103
+ });
104
+
105
+ return this._result;
106
+ }
107
+
108
+ private _applyEvent(event: RunEventPayload): void {
109
+ switch (event.type) {
110
+ case 'run_start':
111
+ this._runId = event.runId;
112
+ this._tasks.clear();
113
+ for (const t of event.tasks) this._tasks.set(t.taskId, { ...t });
114
+ return;
115
+ case 'task_update': {
116
+ const prev = this._tasks.get(event.taskId);
117
+ if (!prev) return;
118
+ const pick = <T>(incoming: T | undefined, previous: T): T =>
119
+ incoming !== undefined ? incoming : previous;
120
+ this._tasks.set(event.taskId, {
121
+ ...prev,
122
+ status: event.status,
123
+ startedAt: pick(event.startedAt, prev.startedAt),
124
+ finishedAt: pick(event.finishedAt, prev.finishedAt),
125
+ durationMs: pick(event.durationMs, prev.durationMs),
126
+ exitCode: pick(event.exitCode, prev.exitCode),
127
+ stdout: pick(event.stdout, prev.stdout),
128
+ stderr: pick(event.stderr, prev.stderr),
129
+ stderrPath: pick(event.stderrPath, prev.stderrPath),
130
+ sessionId: pick(event.sessionId, prev.sessionId),
131
+ normalizedOutput: pick(event.normalizedOutput, prev.normalizedOutput),
132
+ resolvedDriver: pick(event.resolvedDriver, prev.resolvedDriver),
133
+ resolvedModel: pick(event.resolvedModel, prev.resolvedModel),
134
+ resolvedPermissions: pick(event.resolvedPermissions, prev.resolvedPermissions),
135
+ });
136
+ return;
137
+ }
138
+ case 'run_end':
139
+ this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
140
+ return;
141
+ default:
142
+ return;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Cancel the running pipeline. Safe to call multiple times or before start().
148
+ */
149
+ abort(reason?: string): void {
150
+ this._status = 'aborted';
151
+ this._abortController.abort(reason);
152
+ }
153
+
154
+ /**
155
+ * Live snapshot of wire-shape task states. Populated from the first
156
+ * `run_start` event onward. Returns an empty map before the run starts.
157
+ */
158
+ getTasks(): ReadonlyMap<string, RunTaskState> {
159
+ const copy = new Map<string, RunTaskState>();
160
+ for (const [id, t] of this._tasks) copy.set(id, { ...t });
161
+ return copy;
162
+ }
163
+
164
+ /**
165
+ * Subscribe to run events. Returns an unsubscribe function. Events are
166
+ * emitted synchronously in the engine's event loop, so keep handlers
167
+ * non-blocking (e.g. queue to IPC, do not await inside).
168
+ */
169
+ subscribe(handler: (event: RunEventPayload) => void): () => void {
170
+ this._handlers.add(handler);
171
+ return () => this._handlers.delete(handler);
172
+ }
173
+ }
@@ -0,0 +1,230 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { PluginRegistry, defaultRegistry } from './registry';
3
+ import { bootstrapBuiltins } from './bootstrap';
4
+ import { runPipeline } from './engine';
5
+ import type { DriverPlugin, TriggerPlugin, PipelineConfig } from './types';
6
+ import { mkdtempSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ function makeDriver(name: string, marker: string[]): DriverPlugin {
11
+ return {
12
+ name,
13
+ capabilities: { sessionResume: false, systemPrompt: false, outputFormat: false },
14
+ async buildCommand() {
15
+ marker.push(`buildCommand:${name}`);
16
+ return { args: ['echo', 'noop'] };
17
+ },
18
+ };
19
+ }
20
+
21
+ function makeTrigger(name: string, marker: string[]): TriggerPlugin {
22
+ return {
23
+ name,
24
+ async watch() {
25
+ marker.push(`watch:${name}`);
26
+ },
27
+ };
28
+ }
29
+
30
+ describe('PluginRegistry — instance isolation', () => {
31
+ test('two registries do not share drivers registered under the same type', () => {
32
+ const regA = new PluginRegistry();
33
+ const regB = new PluginRegistry();
34
+ const markerA: string[] = [];
35
+ const markerB: string[] = [];
36
+
37
+ regA.registerPlugin('drivers', 'mock', makeDriver('mockA', markerA));
38
+ regB.registerPlugin('drivers', 'mock', makeDriver('mockB', markerB));
39
+
40
+ expect(regA.getHandler<DriverPlugin>('drivers', 'mock').name).toBe('mockA');
41
+ expect(regB.getHandler<DriverPlugin>('drivers', 'mock').name).toBe('mockB');
42
+
43
+ expect(regA.hasHandler('drivers', 'mock')).toBe(true);
44
+ expect(regB.hasHandler('drivers', 'mock')).toBe(true);
45
+ expect(regA.hasHandler('triggers', 'mock')).toBe(false);
46
+ });
47
+
48
+ test('unregistering in one registry does not affect the other', () => {
49
+ const regA = new PluginRegistry();
50
+ const regB = new PluginRegistry();
51
+ regA.registerPlugin('drivers', 'mock', makeDriver('mockA', []));
52
+ regB.registerPlugin('drivers', 'mock', makeDriver('mockB', []));
53
+
54
+ expect(regA.unregisterPlugin('drivers', 'mock')).toBe(true);
55
+ expect(regA.hasHandler('drivers', 'mock')).toBe(false);
56
+ expect(regB.hasHandler('drivers', 'mock')).toBe(true);
57
+ });
58
+
59
+ test('listRegistered is scoped per instance', () => {
60
+ const regA = new PluginRegistry();
61
+ const regB = new PluginRegistry();
62
+ regA.registerPlugin('triggers', 'a-only', makeTrigger('a-only', []));
63
+ regB.registerPlugin('triggers', 'b-only', makeTrigger('b-only', []));
64
+
65
+ expect(regA.listRegistered('triggers')).toEqual(['a-only']);
66
+ expect(regB.listRegistered('triggers')).toEqual(['b-only']);
67
+ });
68
+
69
+ test('registering the same instance twice returns unchanged', () => {
70
+ const reg = new PluginRegistry();
71
+ const driver = makeDriver('same', []);
72
+ expect(reg.registerPlugin('drivers', 'mock', driver)).toBe('registered');
73
+ expect(reg.registerPlugin('drivers', 'mock', driver)).toBe('unchanged');
74
+ });
75
+
76
+ test('replacing with a different handler returns replaced', () => {
77
+ const reg = new PluginRegistry();
78
+ expect(reg.registerPlugin('drivers', 'mock', makeDriver('one', []))).toBe('registered');
79
+ expect(reg.registerPlugin('drivers', 'mock', makeDriver('two', []))).toBe('replaced');
80
+ expect(reg.getHandler<DriverPlugin>('drivers', 'mock').name).toBe('two');
81
+ });
82
+
83
+ test('bootstrapBuiltins(target) populates a specific instance without touching the default', () => {
84
+ const fresh = new PluginRegistry();
85
+ expect(fresh.hasHandler('drivers', 'opencode')).toBe(false);
86
+
87
+ bootstrapBuiltins(fresh);
88
+
89
+ expect(fresh.hasHandler('drivers', 'opencode')).toBe(true);
90
+ expect(fresh.hasHandler('triggers', 'file')).toBe(true);
91
+ expect(fresh.hasHandler('triggers', 'manual')).toBe(true);
92
+ expect(fresh.hasHandler('completions', 'exit_code')).toBe(true);
93
+ expect(fresh.hasHandler('middlewares', 'static_context')).toBe(true);
94
+
95
+ // Default registry's state is independent of `fresh` — if the default
96
+ // happens to have opencode (because another test bootstrapped it), that
97
+ // is fine; the guarantee is that `fresh.unregister` does not leak.
98
+ fresh.unregisterPlugin('drivers', 'opencode');
99
+ expect(fresh.hasHandler('drivers', 'opencode')).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe('PluginRegistry — validation', () => {
104
+ test('rejects unknown category', () => {
105
+ const reg = new PluginRegistry();
106
+ expect(() =>
107
+ reg.registerPlugin(
108
+ 'nope' as 'drivers',
109
+ 'x',
110
+ makeDriver('x', []),
111
+ ),
112
+ ).toThrow(/Unknown plugin category/);
113
+ });
114
+
115
+ test('rejects driver missing buildCommand', () => {
116
+ const reg = new PluginRegistry();
117
+ expect(() =>
118
+ reg.registerPlugin(
119
+ 'drivers',
120
+ 'broken',
121
+ // deliberately bad: no buildCommand
122
+ { name: 'broken', capabilities: { sessionResume: false, systemPrompt: false, outputFormat: false } } as unknown as DriverPlugin,
123
+ ),
124
+ ).toThrow(/must export buildCommand/);
125
+ });
126
+
127
+ test('rejects handler with missing name', () => {
128
+ const reg = new PluginRegistry();
129
+ expect(() =>
130
+ reg.registerPlugin(
131
+ 'drivers',
132
+ 'x',
133
+ // deliberately bad: no name
134
+ { capabilities: { sessionResume: false, systemPrompt: false, outputFormat: false }, buildCommand: async () => ({ args: [] }) } as unknown as DriverPlugin,
135
+ ),
136
+ ).toThrow(/non-empty "name"/);
137
+ });
138
+ });
139
+
140
+ describe('runPipeline — options.registry isolation', () => {
141
+ test('concurrent runs with different registries see their own drivers', async () => {
142
+ const regA = new PluginRegistry();
143
+ const regB = new PluginRegistry();
144
+ const seenA: string[] = [];
145
+ const seenB: string[] = [];
146
+
147
+ bootstrapBuiltins(regA);
148
+ bootstrapBuiltins(regB);
149
+
150
+ regA.registerPlugin('drivers', 'mock', makeDriver('mockA', seenA));
151
+ regB.registerPlugin('drivers', 'mock', makeDriver('mockB', seenB));
152
+
153
+ // Command-only pipeline exercises the preflight path (which uses the
154
+ // registry) plus the run-loop path without requiring a real driver
155
+ // invocation. We verify isolation by asserting that preflight with a
156
+ // registry missing `mock` rejects, while the matching registry accepts.
157
+ const config: PipelineConfig = {
158
+ name: 'isolation-test',
159
+ tracks: [
160
+ {
161
+ id: 't',
162
+ name: 'T',
163
+ tasks: [{ id: 'only', name: 'only', command: 'echo hi' }],
164
+ },
165
+ ],
166
+ };
167
+
168
+ const tmpA = mkdtempSync(join(tmpdir(), 'tagma-regA-'));
169
+ const tmpB = mkdtempSync(join(tmpdir(), 'tagma-regB-'));
170
+ try {
171
+ const [resA, resB] = await Promise.all([
172
+ runPipeline(config, tmpA, { registry: regA, skipPluginLoading: true }),
173
+ runPipeline(config, tmpB, { registry: regB, skipPluginLoading: true }),
174
+ ]);
175
+ expect(resA.success).toBe(true);
176
+ expect(resB.success).toBe(true);
177
+ expect(resA.runId).not.toBe(resB.runId);
178
+ } finally {
179
+ rmSync(tmpA, { recursive: true, force: true });
180
+ rmSync(tmpB, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test('preflight fails when referenced driver is missing from the passed registry', async () => {
185
+ const regNoOpencode = new PluginRegistry();
186
+ // Deliberately do NOT bootstrap builtins — opencode is not registered.
187
+ const config: PipelineConfig = {
188
+ name: 'preflight-miss',
189
+ tracks: [
190
+ {
191
+ id: 't',
192
+ name: 'T',
193
+ tasks: [{ id: 'x', name: 'x', prompt: 'hello' }],
194
+ },
195
+ ],
196
+ };
197
+ const tmp = mkdtempSync(join(tmpdir(), 'tagma-miss-'));
198
+ try {
199
+ await expect(
200
+ runPipeline(config, tmp, { registry: regNoOpencode, skipPluginLoading: true }),
201
+ ).rejects.toThrow(/driver "opencode" not registered/);
202
+ } finally {
203
+ rmSync(tmp, { recursive: true, force: true });
204
+ }
205
+ });
206
+
207
+ test('omitting options.registry falls back to defaultRegistry', async () => {
208
+ // bootstrapBuiltins into default happens in most host callers; do it
209
+ // explicitly here so the test is independent of module-load order.
210
+ bootstrapBuiltins(defaultRegistry);
211
+
212
+ const config: PipelineConfig = {
213
+ name: 'default-fallback',
214
+ tracks: [
215
+ {
216
+ id: 't',
217
+ name: 'T',
218
+ tasks: [{ id: 'only', name: 'only', command: 'echo hi' }],
219
+ },
220
+ ],
221
+ };
222
+ const tmp = mkdtempSync(join(tmpdir(), 'tagma-default-'));
223
+ try {
224
+ const res = await runPipeline(config, tmp, { skipPluginLoading: true });
225
+ expect(res.success).toBe(true);
226
+ } finally {
227
+ rmSync(tmp, { recursive: true, force: true });
228
+ }
229
+ });
230
+ });
package/src/prompt-doc.ts CHANGED
@@ -1,49 +1,49 @@
1
- import type { PromptDocument, PromptContextBlock } from './types';
2
-
3
- /**
4
- * Build a fresh `PromptDocument` from a raw task string.
5
- * Middlewares receive this from the engine and push context blocks onto
6
- * `contexts`. `task` is the user's original prompt and should not be
7
- * rewritten by middlewares (translation middlewares are the rare exception).
8
- */
9
- export function promptDocumentFromString(task: string): PromptDocument {
10
- return { contexts: [], task };
11
- }
12
-
13
- /**
14
- * Serialize a `PromptDocument` to the default string form consumed by
15
- * drivers that read `task.prompt` instead of `ctx.promptDoc`.
16
- *
17
- * Format:
18
- *
19
- * [<label1>]
20
- * <content1>
21
- *
22
- * [<label2>]
23
- * <content2>
24
- *
25
- * <task>
26
- *
27
- * Each context block is separated from the next (and from `task`) by a
28
- * single blank line. No implicit `[Task]` header is emitted — that framing
29
- * is the driver's responsibility (e.g. opencode's `agent_profile` wrapping).
30
- * Emitting one here would compose incorrectly with any driver that also
31
- * adds a `[Task]` header, producing a double header that some models
32
- * (observed with `opencode/big-pickle`) misread as a cut-off message.
33
- */
34
- export function serializePromptDocument(doc: PromptDocument): string {
35
- if (doc.contexts.length === 0) return doc.task;
36
- const blocks = doc.contexts.map((c) => `[${c.label}]\n${c.content}`);
37
- return `${blocks.join('\n\n')}\n\n${doc.task}`;
38
- }
39
-
40
- /**
41
- * Helper for middlewares: return a new document with the given block
42
- * appended to `contexts`, preserving immutability of `doc`.
43
- */
44
- export function appendContext(
45
- doc: PromptDocument,
46
- block: PromptContextBlock,
47
- ): PromptDocument {
48
- return { contexts: [...doc.contexts, block], task: doc.task };
49
- }
1
+ import type { PromptDocument, PromptContextBlock } from './types';
2
+
3
+ /**
4
+ * Build a fresh `PromptDocument` from a raw task string.
5
+ * Middlewares receive this from the engine and push context blocks onto
6
+ * `contexts`. `task` is the user's original prompt and should not be
7
+ * rewritten by middlewares (translation middlewares are the rare exception).
8
+ */
9
+ export function promptDocumentFromString(task: string): PromptDocument {
10
+ return { contexts: [], task };
11
+ }
12
+
13
+ /**
14
+ * Serialize a `PromptDocument` to the default string form consumed by
15
+ * drivers that read `task.prompt` instead of `ctx.promptDoc`.
16
+ *
17
+ * Format:
18
+ *
19
+ * [<label1>]
20
+ * <content1>
21
+ *
22
+ * [<label2>]
23
+ * <content2>
24
+ *
25
+ * <task>
26
+ *
27
+ * Each context block is separated from the next (and from `task`) by a
28
+ * single blank line. No implicit `[Task]` header is emitted — that framing
29
+ * is the driver's responsibility (e.g. opencode's `agent_profile` wrapping).
30
+ * Emitting one here would compose incorrectly with any driver that also
31
+ * adds a `[Task]` header, producing a double header that some models
32
+ * (observed with `opencode/big-pickle`) misread as a cut-off message.
33
+ */
34
+ export function serializePromptDocument(doc: PromptDocument): string {
35
+ if (doc.contexts.length === 0) return doc.task;
36
+ const blocks = doc.contexts.map((c) => `[${c.label}]\n${c.content}`);
37
+ return `${blocks.join('\n\n')}\n\n${doc.task}`;
38
+ }
39
+
40
+ /**
41
+ * Helper for middlewares: return a new document with the given block
42
+ * appended to `contexts`, preserving immutability of `doc`.
43
+ */
44
+ export function appendContext(
45
+ doc: PromptDocument,
46
+ block: PromptContextBlock,
47
+ ): PromptDocument {
48
+ return { contexts: [...doc.contexts, block], task: doc.task };
49
+ }