@tagma/sdk 0.7.1 → 0.7.3
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 +84 -44
- package/dist/bootstrap.d.ts +20 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +21 -11
- package/dist/bootstrap.js.map +1 -1
- package/dist/core/run-context.d.ts +3 -0
- package/dist/core/run-context.d.ts.map +1 -1
- package/dist/core/run-context.js +2 -0
- package/dist/core/run-context.js.map +1 -1
- package/dist/core/task-executor.d.ts.map +1 -1
- package/dist/core/task-executor.js +11 -33
- package/dist/core/task-executor.js.map +1 -1
- package/dist/engine.d.ts +6 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +3 -0
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugins.d.ts +2 -2
- package/dist/plugins.d.ts.map +1 -1
- package/dist/registry.d.ts +10 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +64 -25
- package/dist/registry.js.map +1 -1
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +8 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -7
- package/dist/schema.js.map +1 -1
- package/dist/tagma.d.ts +11 -1
- package/dist/tagma.d.ts.map +1 -1
- package/dist/tagma.js +6 -0
- package/dist/tagma.js.map +1 -1
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +1 -101
- package/dist/validate-raw.js.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +23 -14
- package/src/core/run-context.test.ts +12 -0
- package/src/core/run-context.ts +4 -0
- package/src/core/task-executor.ts +15 -41
- package/src/engine.ts +8 -0
- package/src/index.ts +5 -0
- package/src/plugin-registry.test.ts +138 -1
- package/src/plugins.ts +5 -2
- package/src/registry.ts +81 -26
- package/src/runtime.ts +20 -0
- package/src/schema-ports.test.ts +23 -0
- package/src/schema.ts +1 -7
- package/src/tagma.test.ts +72 -1
- package/src/tagma.ts +16 -1
- package/src/validate-raw.ts +1 -117
package/src/engine.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
skipNonTerminalTasks,
|
|
33
33
|
} from './core/scheduler';
|
|
34
34
|
import { executeTask } from './core/task-executor';
|
|
35
|
+
import { bunRuntime, type TagmaRuntime } from './runtime';
|
|
35
36
|
export { TriggerBlockedError, TriggerTimeoutError } from './core/trigger-errors';
|
|
36
37
|
|
|
37
38
|
function isPromptTaskConfig(
|
|
@@ -104,6 +105,11 @@ export interface RunPipelineOptions {
|
|
|
104
105
|
* do not share handler state.
|
|
105
106
|
*/
|
|
106
107
|
readonly registry: PluginRegistry;
|
|
108
|
+
/**
|
|
109
|
+
* Runtime implementation for command and driver process execution.
|
|
110
|
+
* Defaults to the SDK's Bun runtime.
|
|
111
|
+
*/
|
|
112
|
+
readonly runtime?: TagmaRuntime;
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
// Poll interval when no tasks are in-flight but non-terminal tasks remain
|
|
@@ -123,6 +129,7 @@ export async function runPipeline(
|
|
|
123
129
|
const approvalGateway = options.approvalGateway ?? new InMemoryApprovalGateway();
|
|
124
130
|
const maxLogRuns = options.maxLogRuns ?? 20;
|
|
125
131
|
const registry = options.registry;
|
|
132
|
+
const runtime = options.runtime ?? bunRuntime();
|
|
126
133
|
if (!registry) {
|
|
127
134
|
throw new Error(
|
|
128
135
|
'runPipeline requires options.registry. Use createTagma().run(...) for the public SDK API.',
|
|
@@ -192,6 +199,7 @@ export async function runPipeline(
|
|
|
192
199
|
workDir,
|
|
193
200
|
pipelineInfo,
|
|
194
201
|
onEvent: options.onEvent,
|
|
202
|
+
runtime,
|
|
195
203
|
});
|
|
196
204
|
|
|
197
205
|
// Pipeline start hook (gate). Runs BEFORE the engine emits run_start so
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createTagma } from './tagma';
|
|
2
2
|
export type { CreateTagmaOptions, Tagma, TagmaRunOptions } from './tagma';
|
|
3
|
+
export { bunRuntime } from './runtime';
|
|
4
|
+
export type { TagmaRuntime, RunOptions as RuntimeRunOptions } from './runtime';
|
|
3
5
|
export { definePipeline } from './pipeline-definition';
|
|
4
6
|
export { PluginRegistry } from './registry';
|
|
5
7
|
export { TriggerBlockedError, TriggerTimeoutError } from './engine';
|
|
@@ -20,6 +22,9 @@ export type {
|
|
|
20
22
|
TaskStatus,
|
|
21
23
|
ApprovalRequest,
|
|
22
24
|
PluginCategory,
|
|
25
|
+
PluginCapabilities,
|
|
26
|
+
PluginSetupContext,
|
|
27
|
+
TagmaPlugin,
|
|
23
28
|
DriverPlugin,
|
|
24
29
|
TriggerPlugin,
|
|
25
30
|
CompletionPlugin,
|
|
@@ -3,9 +3,10 @@ import { PluginRegistry } from './registry';
|
|
|
3
3
|
import { bootstrapBuiltins } from './bootstrap';
|
|
4
4
|
import { runPipeline } from './engine';
|
|
5
5
|
import type { DriverPlugin, TriggerPlugin, PipelineConfig } from './types';
|
|
6
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
6
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
+
import type { TagmaPlugin } from './types';
|
|
9
10
|
|
|
10
11
|
function makeDriver(name: string, marker: string[]): DriverPlugin {
|
|
11
12
|
return {
|
|
@@ -100,6 +101,130 @@ describe('PluginRegistry — instance isolation', () => {
|
|
|
100
101
|
});
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
describe('PluginRegistry — capability plugins', () => {
|
|
105
|
+
test('registerTagmaPlugin registers multiple capabilities from one package', () => {
|
|
106
|
+
const reg = new PluginRegistry();
|
|
107
|
+
const driver = makeDriver('cap-driver', []);
|
|
108
|
+
const trigger = makeTrigger('cap-trigger', []);
|
|
109
|
+
const plugin: TagmaPlugin = {
|
|
110
|
+
name: 'tagma-plugin-multi',
|
|
111
|
+
capabilities: {
|
|
112
|
+
drivers: { cap_driver: driver },
|
|
113
|
+
triggers: { cap_trigger: trigger },
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
expect(reg.registerTagmaPlugin(plugin)).toEqual([
|
|
118
|
+
{ category: 'drivers', type: 'cap_driver', result: 'registered' },
|
|
119
|
+
{ category: 'triggers', type: 'cap_trigger', result: 'registered' },
|
|
120
|
+
]);
|
|
121
|
+
expect(reg.getHandler<DriverPlugin>('drivers', 'cap_driver')).toBe(driver);
|
|
122
|
+
expect(reg.getHandler<TriggerPlugin>('triggers', 'cap_trigger')).toBe(trigger);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('registerTagmaPlugin keeps replacement warnings from the registry path', () => {
|
|
126
|
+
const reg = new PluginRegistry();
|
|
127
|
+
const originalWarn = console.warn;
|
|
128
|
+
const warnings: string[] = [];
|
|
129
|
+
console.warn = (message?: unknown) => {
|
|
130
|
+
warnings.push(String(message));
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
reg.registerPlugin('drivers', 'mock', makeDriver('first', []));
|
|
134
|
+
const result = reg.registerTagmaPlugin({
|
|
135
|
+
name: 'tagma-plugin-replacement',
|
|
136
|
+
capabilities: {
|
|
137
|
+
drivers: { mock: makeDriver('second', []) },
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(result).toEqual([{ category: 'drivers', type: 'mock', result: 'replaced' }]);
|
|
142
|
+
expect(warnings).toContain(
|
|
143
|
+
'[tagma-sdk] registerPlugin: replaced existing drivers/mock - check for duplicate plugin packages claiming the same type.',
|
|
144
|
+
);
|
|
145
|
+
} finally {
|
|
146
|
+
console.warn = originalWarn;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('loadPlugins accepts capability plugin default exports', async () => {
|
|
151
|
+
const dir = mkdtempSync(join(tmpdir(), 'tagma-capability-plugin-'));
|
|
152
|
+
const pluginDir = join(dir, 'node_modules', 'tagma-plugin-capability');
|
|
153
|
+
mkdirSync(pluginDir, { recursive: true });
|
|
154
|
+
writeFileSync(
|
|
155
|
+
join(pluginDir, 'package.json'),
|
|
156
|
+
JSON.stringify({ name: 'tagma-plugin-capability', version: '1.0.0', type: 'module', main: './index.js' }),
|
|
157
|
+
'utf-8',
|
|
158
|
+
);
|
|
159
|
+
writeFileSync(
|
|
160
|
+
join(pluginDir, 'index.js'),
|
|
161
|
+
[
|
|
162
|
+
'const driver = {',
|
|
163
|
+
" name: 'cap-driver',",
|
|
164
|
+
' capabilities: { sessionResume: false, systemPrompt: false, outputFormat: false },',
|
|
165
|
+
" async buildCommand() { return { args: ['echo', 'cap'] }; },",
|
|
166
|
+
'};',
|
|
167
|
+
'const trigger = {',
|
|
168
|
+
" name: 'cap-trigger',",
|
|
169
|
+
' async watch() {}',
|
|
170
|
+
'};',
|
|
171
|
+
'export default {',
|
|
172
|
+
" name: 'tagma-plugin-capability',",
|
|
173
|
+
' capabilities: {',
|
|
174
|
+
' drivers: { cap_driver: driver },',
|
|
175
|
+
' triggers: { cap_trigger: trigger },',
|
|
176
|
+
' },',
|
|
177
|
+
'};',
|
|
178
|
+
'',
|
|
179
|
+
].join('\n'),
|
|
180
|
+
'utf-8',
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const reg = new PluginRegistry();
|
|
185
|
+
await reg.loadPlugins(['tagma-plugin-capability'], dir);
|
|
186
|
+
expect(reg.hasHandler('drivers', 'cap_driver')).toBe(true);
|
|
187
|
+
expect(reg.hasHandler('triggers', 'cap_trigger')).toBe(true);
|
|
188
|
+
} finally {
|
|
189
|
+
rmSync(dir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('loadPlugins rejects legacy plugin module exports', async () => {
|
|
194
|
+
const dir = mkdtempSync(join(tmpdir(), 'tagma-legacy-plugin-'));
|
|
195
|
+
const pluginDir = join(dir, 'node_modules', 'tagma-plugin-legacy');
|
|
196
|
+
mkdirSync(pluginDir, { recursive: true });
|
|
197
|
+
writeFileSync(
|
|
198
|
+
join(pluginDir, 'package.json'),
|
|
199
|
+
JSON.stringify({ name: 'tagma-plugin-legacy', version: '1.0.0', type: 'module', main: './index.js' }),
|
|
200
|
+
'utf-8',
|
|
201
|
+
);
|
|
202
|
+
writeFileSync(
|
|
203
|
+
join(pluginDir, 'index.js'),
|
|
204
|
+
[
|
|
205
|
+
"export const pluginCategory = 'drivers';",
|
|
206
|
+
"export const pluginType = 'legacy';",
|
|
207
|
+
'export default {',
|
|
208
|
+
" name: 'legacy',",
|
|
209
|
+
' capabilities: { sessionResume: false, systemPrompt: false, outputFormat: false },',
|
|
210
|
+
" async buildCommand() { return { args: ['echo', 'legacy'] }; },",
|
|
211
|
+
'};',
|
|
212
|
+
'',
|
|
213
|
+
].join('\n'),
|
|
214
|
+
'utf-8',
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const reg = new PluginRegistry();
|
|
219
|
+
await expect(reg.loadPlugins(['tagma-plugin-legacy'], dir)).rejects.toThrow(
|
|
220
|
+
/must default-export a TagmaPlugin/,
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
rmSync(dir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
103
228
|
describe('PluginRegistry — validation', () => {
|
|
104
229
|
test('rejects unknown category', () => {
|
|
105
230
|
const reg = new PluginRegistry();
|
|
@@ -153,6 +278,18 @@ describe('PluginRegistry — validation', () => {
|
|
|
153
278
|
/bun add @tagma\/middleware-audit/,
|
|
154
279
|
);
|
|
155
280
|
});
|
|
281
|
+
|
|
282
|
+
test('rejects middleware without enhanceDoc', () => {
|
|
283
|
+
const reg = new PluginRegistry();
|
|
284
|
+
expect(() =>
|
|
285
|
+
reg.registerPlugin('middlewares', 'old', {
|
|
286
|
+
name: 'old',
|
|
287
|
+
async enhance(prompt: string) {
|
|
288
|
+
return prompt;
|
|
289
|
+
},
|
|
290
|
+
} as never),
|
|
291
|
+
).toThrow(/must export enhanceDoc/);
|
|
292
|
+
});
|
|
156
293
|
});
|
|
157
294
|
|
|
158
295
|
describe('runPipeline — options.registry isolation', () => {
|
package/src/plugins.ts
CHANGED
|
@@ -5,14 +5,17 @@ export {
|
|
|
5
5
|
PLUGIN_NAME_RE,
|
|
6
6
|
readPluginManifest,
|
|
7
7
|
} from './registry';
|
|
8
|
-
export type { RegisterResult } from './registry';
|
|
8
|
+
export type { RegisteredCapability, RegisterResult } from './registry';
|
|
9
9
|
export type {
|
|
10
|
+
CapabilityHandler,
|
|
10
11
|
PluginCategory,
|
|
12
|
+
PluginCapabilities,
|
|
11
13
|
PluginModule,
|
|
12
14
|
PluginManifest,
|
|
15
|
+
PluginSetupContext,
|
|
16
|
+
TagmaPlugin,
|
|
13
17
|
DriverPlugin,
|
|
14
18
|
TriggerPlugin,
|
|
15
19
|
CompletionPlugin,
|
|
16
20
|
MiddlewarePlugin,
|
|
17
21
|
} from './types';
|
|
18
|
-
|
package/src/registry.ts
CHANGED
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import type {
|
|
4
|
+
CapabilityHandler,
|
|
4
5
|
PluginCategory,
|
|
5
6
|
DriverPlugin,
|
|
6
7
|
TriggerPlugin,
|
|
7
8
|
CompletionPlugin,
|
|
8
9
|
MiddlewarePlugin,
|
|
9
10
|
PluginManifest,
|
|
11
|
+
TagmaPlugin,
|
|
10
12
|
} from './types';
|
|
11
13
|
|
|
12
|
-
type PluginType =
|
|
14
|
+
type PluginType = CapabilityHandler;
|
|
13
15
|
|
|
14
|
-
const
|
|
16
|
+
const CAPABILITY_CATEGORIES = [
|
|
15
17
|
'drivers',
|
|
16
18
|
'triggers',
|
|
17
19
|
'completions',
|
|
18
20
|
'middlewares',
|
|
19
|
-
]
|
|
21
|
+
] as const satisfies readonly PluginCategory[];
|
|
22
|
+
|
|
23
|
+
const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set(CAPABILITY_CATEGORIES);
|
|
20
24
|
const PLUGIN_TYPE_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
21
25
|
|
|
26
|
+
export interface RegisteredCapability {
|
|
27
|
+
readonly category: PluginCategory;
|
|
28
|
+
readonly type: string;
|
|
29
|
+
readonly result: RegisterResult;
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
function singularCategory(category: PluginCategory): string {
|
|
23
33
|
switch (category) {
|
|
24
34
|
case 'drivers':
|
|
@@ -37,7 +47,7 @@ function singularCategory(category: PluginCategory): string {
|
|
|
37
47
|
* registration time rather than crashing the engine mid-run.
|
|
38
48
|
*
|
|
39
49
|
* For drivers we materialize `capabilities` and assert each field is a
|
|
40
|
-
* boolean
|
|
50
|
+
* boolean —otherwise a plugin author can write
|
|
41
51
|
* get capabilities() { throw new Error('boom') }
|
|
42
52
|
* and pass the basic typeof check, then crash preflight when the engine
|
|
43
53
|
* touches `driver.capabilities.sessionResume`. (R8)
|
|
@@ -55,7 +65,7 @@ function validateContract(category: PluginCategory, handler: unknown): void {
|
|
|
55
65
|
if (typeof h.buildCommand !== 'function') {
|
|
56
66
|
throw new Error(`drivers plugin "${h.name}" must export buildCommand()`);
|
|
57
67
|
}
|
|
58
|
-
// Materialize capabilities
|
|
68
|
+
// Materialize capabilities —this triggers any throwing getter NOW
|
|
59
69
|
// instead of during preflight.
|
|
60
70
|
let caps: unknown;
|
|
61
71
|
try {
|
|
@@ -96,22 +106,11 @@ function validateContract(category: PluginCategory, handler: unknown): void {
|
|
|
96
106
|
}
|
|
97
107
|
break;
|
|
98
108
|
case 'middlewares':
|
|
99
|
-
|
|
100
|
-
// the structured PromptDocument API (preferred); `enhance` is the
|
|
101
|
-
// legacy string-in/string-out API the engine still supports for
|
|
102
|
-
// v0.x plugins. Requiring only `enhance` here rejects every built-in
|
|
103
|
-
// and every plugin written against the current types.
|
|
104
|
-
if (typeof h.enhanceDoc !== 'function' && typeof h.enhance !== 'function') {
|
|
109
|
+
if (typeof h.enhanceDoc !== 'function') {
|
|
105
110
|
throw new Error(
|
|
106
|
-
`middlewares plugin "${h.name}" must export enhanceDoc()
|
|
111
|
+
`middlewares plugin "${h.name}" must export enhanceDoc()`,
|
|
107
112
|
);
|
|
108
113
|
}
|
|
109
|
-
if (h.enhanceDoc !== undefined && typeof h.enhanceDoc !== 'function') {
|
|
110
|
-
throw new Error(`middlewares plugin "${h.name}".enhanceDoc must be a function or undefined`);
|
|
111
|
-
}
|
|
112
|
-
if (h.enhance !== undefined && typeof h.enhance !== 'function') {
|
|
113
|
-
throw new Error(`middlewares plugin "${h.name}".enhance must be a function or undefined`);
|
|
114
|
-
}
|
|
115
114
|
break;
|
|
116
115
|
}
|
|
117
116
|
}
|
|
@@ -132,7 +131,7 @@ export function isValidPluginName(name: unknown): name is string {
|
|
|
132
131
|
*
|
|
133
132
|
* Returns the strongly-typed manifest if the field is present and
|
|
134
133
|
* well-formed (`category` is one of the four known categories and `type`
|
|
135
|
-
* is a non-empty string). Returns `null` if the field is absent
|
|
134
|
+
* is a non-empty string). Returns `null` if the field is absent —that
|
|
136
135
|
* is the host's signal that the package is a library, not a plugin.
|
|
137
136
|
*
|
|
138
137
|
* Throws if the field is present but malformed: that's a packaging bug
|
|
@@ -167,6 +166,33 @@ export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
|
|
|
167
166
|
return { category: category as PluginCategory, type };
|
|
168
167
|
}
|
|
169
168
|
|
|
169
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
170
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isTagmaPlugin(value: unknown): value is TagmaPlugin {
|
|
174
|
+
if (!isRecord(value)) return false;
|
|
175
|
+
if (typeof value.name !== 'string' || value.name.length === 0) return false;
|
|
176
|
+
if (value.capabilities !== undefined && !isRecord(value.capabilities)) return false;
|
|
177
|
+
if (value.setup !== undefined && typeof value.setup !== 'function') return false;
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function hasSupportedCapabilityMap(plugin: TagmaPlugin): boolean {
|
|
182
|
+
if (!plugin.capabilities) return false;
|
|
183
|
+
const capabilities = plugin.capabilities as Record<string, unknown>;
|
|
184
|
+
return CAPABILITY_CATEGORIES.some((category) => capabilities[category] !== undefined);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function moduleDefaultPlugin(name: string, mod: unknown): TagmaPlugin {
|
|
188
|
+
if (!isRecord(mod) || !isTagmaPlugin(mod.default) || !hasSupportedCapabilityMap(mod.default)) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Plugin "${name}" must default-export a TagmaPlugin with capabilities maps`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return mod.default;
|
|
194
|
+
}
|
|
195
|
+
|
|
170
196
|
/**
|
|
171
197
|
* Instance-scoped plugin registry. Each workspace in a multi-tenant sidecar
|
|
172
198
|
* owns its own PluginRegistry, so installing/uninstalling a driver in one
|
|
@@ -214,22 +240,54 @@ export class PluginRegistry {
|
|
|
214
240
|
if (wasReplaced) {
|
|
215
241
|
// D18: surface silent shadowing. Hot-reload flows legitimately replace
|
|
216
242
|
// handlers; installing two different plugin packages that both claim
|
|
217
|
-
// the same (category, type) does not
|
|
243
|
+
// the same (category, type) does not —the second wins and breaks the
|
|
218
244
|
// first's consumers with no audit trail. A console.warn is cheap,
|
|
219
245
|
// respects existing callers that rely on 'replaced', and gives ops a
|
|
220
246
|
// grep-able signal when registrations collide unexpectedly.
|
|
221
247
|
console.warn(
|
|
222
|
-
`[tagma-sdk] registerPlugin: replaced existing ${category}/${type}
|
|
248
|
+
`[tagma-sdk] registerPlugin: replaced existing ${category}/${type} - ` +
|
|
223
249
|
`check for duplicate plugin packages claiming the same type.`,
|
|
224
250
|
);
|
|
225
251
|
}
|
|
226
252
|
return wasReplaced ? 'replaced' : 'registered';
|
|
227
253
|
}
|
|
228
254
|
|
|
255
|
+
registerTagmaPlugin(plugin: TagmaPlugin): RegisteredCapability[] {
|
|
256
|
+
if (!isTagmaPlugin(plugin)) {
|
|
257
|
+
throw new Error('TagmaPlugin must be an object with a non-empty "name"');
|
|
258
|
+
}
|
|
259
|
+
if (!plugin.capabilities) {
|
|
260
|
+
throw new Error(`TagmaPlugin "${plugin.name}" must declare capabilities`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const registered: RegisteredCapability[] = [];
|
|
264
|
+
const capabilities = plugin.capabilities as Record<string, unknown>;
|
|
265
|
+
for (const category of CAPABILITY_CATEGORIES) {
|
|
266
|
+
const handlers = capabilities[category];
|
|
267
|
+
if (handlers === undefined) continue;
|
|
268
|
+
if (!isRecord(handlers)) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`TagmaPlugin "${plugin.name}" capabilities.${category} must be an object map`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
for (const [type, handler] of Object.entries(handlers)) {
|
|
274
|
+
const result = this.registerPlugin(category, type, handler as PluginType);
|
|
275
|
+
registered.push({ category, type, result });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (registered.length === 0) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`TagmaPlugin "${plugin.name}" must declare at least one supported capability`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return registered;
|
|
285
|
+
}
|
|
286
|
+
|
|
229
287
|
/**
|
|
230
288
|
* Remove a plugin from the in-process registry. Returns true if a plugin
|
|
231
289
|
* was actually removed. Note: ESM module caching is not affected, so
|
|
232
|
-
* re-importing the same file after unregister will yield the cached module
|
|
290
|
+
* re-importing the same file after unregister will yield the cached module — * callers wanting a fresh load must restart the host process.
|
|
233
291
|
*/
|
|
234
292
|
unregisterPlugin(category: PluginCategory, type: string): boolean {
|
|
235
293
|
if (!VALID_CATEGORIES.has(category)) return false;
|
|
@@ -289,10 +347,7 @@ export class PluginRegistry {
|
|
|
289
347
|
moduleUrl = pathToFileURL(resolved).href;
|
|
290
348
|
}
|
|
291
349
|
const mod = await import(moduleUrl);
|
|
292
|
-
|
|
293
|
-
throw new Error(`Plugin "${name}" must export pluginCategory, pluginType, and default`);
|
|
294
|
-
}
|
|
295
|
-
this.registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
|
|
350
|
+
this.registerTagmaPlugin(moduleDefaultPlugin(name, mod));
|
|
296
351
|
}
|
|
297
352
|
}
|
|
298
353
|
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DriverPlugin, SpawnSpec, TaskResult } from './types';
|
|
2
|
+
import { runCommand, runSpawn, type RunOptions } from './runner';
|
|
3
|
+
|
|
4
|
+
export type { RunOptions };
|
|
5
|
+
|
|
6
|
+
export interface TagmaRuntime {
|
|
7
|
+
runSpawn(
|
|
8
|
+
spec: SpawnSpec,
|
|
9
|
+
driver: DriverPlugin | null,
|
|
10
|
+
options?: RunOptions,
|
|
11
|
+
): Promise<TaskResult>;
|
|
12
|
+
runCommand(command: string, cwd: string, options?: RunOptions): Promise<TaskResult>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function bunRuntime(): TagmaRuntime {
|
|
16
|
+
return {
|
|
17
|
+
runSpawn,
|
|
18
|
+
runCommand,
|
|
19
|
+
};
|
|
20
|
+
}
|
package/src/schema-ports.test.ts
CHANGED
|
@@ -83,6 +83,29 @@ describe('schema — unified bindings passthrough', () => {
|
|
|
83
83
|
expect(back.tracks[0]!.tasks[0]!.outputs).toBeUndefined();
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
test('legacy ports are not carried through resolve or deresolve', () => {
|
|
87
|
+
const raw: RawPipelineConfig = {
|
|
88
|
+
name: 'p',
|
|
89
|
+
tracks: [
|
|
90
|
+
{
|
|
91
|
+
id: 't',
|
|
92
|
+
name: 'T',
|
|
93
|
+
tasks: [
|
|
94
|
+
{
|
|
95
|
+
id: 'a',
|
|
96
|
+
command: 'echo ok',
|
|
97
|
+
ports: { outputs: [{ name: 'old', type: 'string' }] },
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
const resolved = resolveConfig(raw, WORK_DIR);
|
|
104
|
+
expect(resolved.tracks[0]!.tasks[0]!.ports).toBeUndefined();
|
|
105
|
+
const back = deresolvePipeline(resolved, WORK_DIR);
|
|
106
|
+
expect(back.tracks[0]!.tasks[0]!.ports).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
86
109
|
test('YAML round-trip preserves typed unified binding shape', () => {
|
|
87
110
|
const raw: RawPipelineConfig = {
|
|
88
111
|
name: 'p',
|
package/src/schema.ts
CHANGED
|
@@ -161,11 +161,10 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
161
161
|
completion: rawTask.completion,
|
|
162
162
|
agent_profile: rawTask.agent_profile ?? rawTrack.agent_profile,
|
|
163
163
|
cwd: rawTask.cwd ? validatePath(rawTask.cwd, workDir) : trackCwd,
|
|
164
|
-
//
|
|
164
|
+
// Unified bindings have no inheritance; they describe
|
|
165
165
|
// per-task data flow, not cross-task defaults.
|
|
166
166
|
inputs: rawTask.inputs,
|
|
167
167
|
outputs: rawTask.outputs,
|
|
168
|
-
ports: rawTask.ports,
|
|
169
168
|
};
|
|
170
169
|
});
|
|
171
170
|
|
|
@@ -313,11 +312,6 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
313
312
|
: {}),
|
|
314
313
|
...(task.inputs && Object.keys(task.inputs).length > 0 ? { inputs: task.inputs } : {}),
|
|
315
314
|
...(task.outputs && Object.keys(task.outputs).length > 0 ? { outputs: task.outputs } : {}),
|
|
316
|
-
...(task.ports &&
|
|
317
|
-
((task.ports.inputs && task.ports.inputs.length > 0) ||
|
|
318
|
-
(task.ports.outputs && task.ports.outputs.length > 0))
|
|
319
|
-
? { ports: task.ports }
|
|
320
|
-
: {}),
|
|
321
315
|
};
|
|
322
316
|
});
|
|
323
317
|
|
package/src/tagma.test.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { createTagma } from './tagma';
|
|
6
|
-
import type { DriverPlugin,
|
|
6
|
+
import type { DriverPlugin, TagmaPlugin, TaskResult } from './types';
|
|
7
|
+
import type { TagmaRuntime } from './runtime';
|
|
7
8
|
|
|
8
9
|
function makeDir(prefix: string): string {
|
|
9
10
|
return mkdtempSync(join(tmpdir(), prefix));
|
|
@@ -21,6 +22,76 @@ function makeDriver(name: string, marker: string[]): DriverPlugin {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
describe('createTagma', () => {
|
|
25
|
+
test('runs command tasks through the configured runtime', async () => {
|
|
26
|
+
const calls: string[] = [];
|
|
27
|
+
const taskResult: TaskResult = {
|
|
28
|
+
exitCode: 0,
|
|
29
|
+
stdout: 'runtime-ok',
|
|
30
|
+
stderr: '',
|
|
31
|
+
stdoutPath: null,
|
|
32
|
+
stderrPath: null,
|
|
33
|
+
stdoutBytes: 10,
|
|
34
|
+
stderrBytes: 0,
|
|
35
|
+
durationMs: 1,
|
|
36
|
+
sessionId: null,
|
|
37
|
+
normalizedOutput: null,
|
|
38
|
+
failureKind: null,
|
|
39
|
+
};
|
|
40
|
+
const runtime: TagmaRuntime = {
|
|
41
|
+
async runCommand(command, cwd) {
|
|
42
|
+
calls.push(`${cwd}:${command}`);
|
|
43
|
+
return taskResult;
|
|
44
|
+
},
|
|
45
|
+
async runSpawn() {
|
|
46
|
+
throw new Error('runSpawn should not be called for command tasks');
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const tagma = createTagma({ builtins: false, runtime });
|
|
50
|
+
const dir = makeDir('tagma-runtime-run-');
|
|
51
|
+
try {
|
|
52
|
+
const result = await tagma.run(
|
|
53
|
+
{
|
|
54
|
+
name: 'runtime-run',
|
|
55
|
+
tracks: [
|
|
56
|
+
{
|
|
57
|
+
id: 't',
|
|
58
|
+
name: 'T',
|
|
59
|
+
tasks: [{ id: 'cmd', name: 'cmd', command: 'fake-only-command' }],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
cwd: dir,
|
|
65
|
+
skipPluginLoading: true,
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(result.success).toBe(true);
|
|
70
|
+
expect(calls).toEqual([`${dir}:fake-only-command`]);
|
|
71
|
+
expect(result.states.get('t.cmd')?.result?.stdout).toBe('runtime-ok');
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('registers capability plugins passed to options', () => {
|
|
78
|
+
const seen: string[] = [];
|
|
79
|
+
const driver = makeDriver('driver-plugin', seen);
|
|
80
|
+
const plugin: TagmaPlugin = {
|
|
81
|
+
name: 'tagma-plugin-local',
|
|
82
|
+
capabilities: {
|
|
83
|
+
drivers: {
|
|
84
|
+
mock: driver,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const tagma = createTagma({ builtins: false, plugins: [plugin] });
|
|
90
|
+
|
|
91
|
+
expect(tagma.registry.getHandler<DriverPlugin>('drivers', 'mock')).toBe(driver);
|
|
92
|
+
expect(seen).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
24
95
|
test('instances own isolated plugin registries', () => {
|
|
25
96
|
const seenA: string[] = [];
|
|
26
97
|
const seenB: string[] = [];
|
package/src/tagma.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { runPipeline, type EngineResult, type RunPipelineOptions } from './engin
|
|
|
2
2
|
import { bootstrapBuiltins } from './bootstrap';
|
|
3
3
|
import { PluginRegistry } from './registry';
|
|
4
4
|
import { validateConfig } from './schema';
|
|
5
|
-
import type
|
|
5
|
+
import { bunRuntime, type TagmaRuntime } from './runtime';
|
|
6
|
+
import type { PipelineConfig, TagmaPlugin } from './types';
|
|
6
7
|
|
|
7
8
|
export interface CreateTagmaOptions {
|
|
8
9
|
/**
|
|
@@ -14,6 +15,15 @@ export interface CreateTagmaOptions {
|
|
|
14
15
|
* instance registry. Defaults to true.
|
|
15
16
|
*/
|
|
16
17
|
readonly builtins?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Package-level capability plugins to register into this SDK instance.
|
|
20
|
+
*/
|
|
21
|
+
readonly plugins?: readonly TagmaPlugin[];
|
|
22
|
+
/**
|
|
23
|
+
* Runtime implementation used for command and driver process execution.
|
|
24
|
+
* Defaults to the SDK's Bun runtime.
|
|
25
|
+
*/
|
|
26
|
+
readonly runtime?: TagmaRuntime;
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
export interface TagmaRunOptions extends Omit<RunPipelineOptions, 'registry'> {
|
|
@@ -28,9 +38,13 @@ export interface Tagma {
|
|
|
28
38
|
|
|
29
39
|
export function createTagma(options: CreateTagmaOptions = {}): Tagma {
|
|
30
40
|
const registry = options.registry ?? new PluginRegistry();
|
|
41
|
+
const runtime = options.runtime ?? bunRuntime();
|
|
31
42
|
if (options.builtins !== false) {
|
|
32
43
|
bootstrapBuiltins(registry);
|
|
33
44
|
}
|
|
45
|
+
for (const plugin of options.plugins ?? []) {
|
|
46
|
+
registry.registerTagmaPlugin(plugin);
|
|
47
|
+
}
|
|
34
48
|
|
|
35
49
|
return {
|
|
36
50
|
registry,
|
|
@@ -38,6 +52,7 @@ export function createTagma(options: CreateTagmaOptions = {}): Tagma {
|
|
|
38
52
|
return runPipeline(config, cwd, {
|
|
39
53
|
...runOptions,
|
|
40
54
|
registry,
|
|
55
|
+
runtime,
|
|
41
56
|
});
|
|
42
57
|
},
|
|
43
58
|
validate(config) {
|