@tagma/sdk 0.4.7 → 0.4.9
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/LICENSE +21 -21
- package/README.md +3 -34
- package/dist/dag.d.ts +0 -1
- package/dist/dag.d.ts.map +1 -1
- package/dist/dag.js +0 -5
- package/dist/dag.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +12 -38
- package/dist/engine.js.map +1 -1
- package/dist/hooks.d.ts +0 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +0 -5
- package/dist/runner.js.map +1 -1
- package/dist/schema.d.ts +1 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -143
- package/dist/schema.js.map +1 -1
- package/dist/schema.test.js.map +1 -1
- package/dist/sdk.d.ts +1 -3
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +1 -3
- package/dist/sdk.js.map +1 -1
- package/dist/utils.d.ts +0 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +0 -12
- package/dist/utils.js.map +1 -1
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +1 -4
- package/dist/validate-raw.js.map +1 -1
- package/package.json +1 -1
- package/src/dag.ts +0 -3
- package/src/engine.ts +12 -39
- package/src/hooks.ts +0 -1
- package/src/registry.ts +214 -214
- package/src/runner.ts +0 -5
- package/src/schema.test.ts +97 -97
- package/src/schema.ts +53 -228
- package/src/sdk.ts +1 -5
- package/src/utils.ts +0 -14
- package/src/validate-raw.ts +1 -4
- package/src/templates.ts +0 -97
package/src/engine.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { resolve
|
|
2
|
-
import {
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import { readdir, rm } from 'fs/promises';
|
|
3
3
|
import type {
|
|
4
4
|
PipelineConfig, TaskConfig, TrackConfig, TaskState, TaskStatus,
|
|
5
5
|
TaskResult, DriverPlugin, TriggerPlugin, CompletionPlugin,
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
import { buildDag, type Dag, type DagNode } from './dag';
|
|
10
10
|
import { getHandler, hasHandler, loadPlugins } from './registry';
|
|
11
11
|
import { runSpawn, runCommand } from './runner';
|
|
12
|
-
import { parseDuration, nowISO, generateRunId
|
|
12
|
+
import { parseDuration, nowISO, generateRunId } from './utils';
|
|
13
13
|
import {
|
|
14
14
|
executeHook,
|
|
15
15
|
buildPipelineStartContext, buildTaskContext,
|
|
@@ -79,7 +79,7 @@ function preflight(config: PipelineConfig, dag: Dag): void {
|
|
|
79
79
|
const upstream = dag.nodes.get(upstreamId);
|
|
80
80
|
if (upstream) {
|
|
81
81
|
// A handoff is possible via session resume (already ruled out above),
|
|
82
|
-
//
|
|
82
|
+
// OR in-memory text injection through normalizedMap
|
|
83
83
|
// (when the upstream driver implements parseResult and returns normalizedOutput).
|
|
84
84
|
const upstreamDriverName = upstream.task.driver ?? upstream.track.driver
|
|
85
85
|
?? config.driver ?? 'claude-code';
|
|
@@ -88,12 +88,12 @@ function preflight(config: PipelineConfig, dag: Dag): void {
|
|
|
88
88
|
: null;
|
|
89
89
|
const canNormalize = typeof upstreamDriver?.parseResult === 'function';
|
|
90
90
|
|
|
91
|
-
if (!
|
|
91
|
+
if (!canNormalize) {
|
|
92
92
|
errors.push(
|
|
93
93
|
`Task "${node.taskId}" uses continue_from: "${task.continue_from}", ` +
|
|
94
|
-
`but upstream task "${upstreamId}"
|
|
94
|
+
`but upstream task "${upstreamId}" its driver ` +
|
|
95
95
|
`does not implement parseResult for text-injection handoff. ` +
|
|
96
|
-
`
|
|
96
|
+
`Use a driver with parseResult, or remove continue_from.`
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
}
|
|
@@ -297,7 +297,6 @@ export async function runPipeline(
|
|
|
297
297
|
options.onEvent?.({ type: 'pipeline_start', runId, states: statesSnapshot });
|
|
298
298
|
|
|
299
299
|
const sessionMap = new Map<string, string>();
|
|
300
|
-
const outputMap = new Map<string, string>();
|
|
301
300
|
const normalizedMap = new Map<string, string>();
|
|
302
301
|
|
|
303
302
|
// Pipeline timeout
|
|
@@ -407,7 +406,6 @@ export async function runPipeline(
|
|
|
407
406
|
status: state.status,
|
|
408
407
|
exit_code: state.result?.exitCode ?? null,
|
|
409
408
|
duration_ms: state.result?.durationMs ?? null,
|
|
410
|
-
output_path: state.result?.outputPath ?? null,
|
|
411
409
|
stderr_path: state.result?.stderrPath ?? null,
|
|
412
410
|
session_id: state.result?.sessionId ?? null,
|
|
413
411
|
started_at: state.startedAt,
|
|
@@ -585,7 +583,7 @@ export async function runPipeline(
|
|
|
585
583
|
log.debug(`[task:${taskId}]`,
|
|
586
584
|
`middleware chain: ${mws.map(m => m.type).join(' → ')}`);
|
|
587
585
|
const mwCtx: MiddlewareContext = {
|
|
588
|
-
task, track,
|
|
586
|
+
task, track, workDir: task.cwd ?? workDir,
|
|
589
587
|
};
|
|
590
588
|
for (const mwConfig of mws) {
|
|
591
589
|
const before = prompt.length;
|
|
@@ -611,7 +609,7 @@ export async function runPipeline(
|
|
|
611
609
|
|
|
612
610
|
// H1: hand the driver a continue_from that has already been
|
|
613
611
|
// qualified by dag.ts. Without this, drivers like codex/opencode/
|
|
614
|
-
// claude-code
|
|
612
|
+
// claude-code look up maps directly with
|
|
615
613
|
// the user's raw (possibly bare) string, which races whenever two
|
|
616
614
|
// tracks share a task name. dag.ts has the only authoritative
|
|
617
615
|
// resolver, so we use its precomputed answer here.
|
|
@@ -621,7 +619,7 @@ export async function runPipeline(
|
|
|
621
619
|
continue_from: node.resolvedContinueFrom ?? task.continue_from,
|
|
622
620
|
};
|
|
623
621
|
const driverCtx: DriverContext = {
|
|
624
|
-
sessionMap,
|
|
622
|
+
sessionMap, normalizedMap, workDir: task.cwd ?? workDir,
|
|
625
623
|
};
|
|
626
624
|
const spec = await driver.buildCommand(enrichedTask, track, driverCtx);
|
|
627
625
|
log.debug(`[task:${taskId}]`, `driver=${driverName}`);
|
|
@@ -635,27 +633,6 @@ export async function runPipeline(
|
|
|
635
633
|
result = await runSpawn(spec, driver, runOpts);
|
|
636
634
|
}
|
|
637
635
|
|
|
638
|
-
// 5. Write output file with RAW stdout (preserves driver output format).
|
|
639
|
-
// Done BEFORE the completion check so a `file_exists` completion pointing
|
|
640
|
-
// at `task.output` observes the AI-generated artefact. Writes happen
|
|
641
|
-
// regardless of exit code so failed/timed-out tasks still leave a
|
|
642
|
-
// debuggable artefact on disk.
|
|
643
|
-
if (task.output) {
|
|
644
|
-
// validatePath enforces no .. traversal and no absolute paths escaping workDir.
|
|
645
|
-
const outPath = validatePath(task.output, workDir);
|
|
646
|
-
await mkdir(dirname(outPath), { recursive: true });
|
|
647
|
-
await Bun.write(outPath, result.stdout);
|
|
648
|
-
result = { ...result, outputPath: outPath };
|
|
649
|
-
// H1: only write the fully-qualified taskId. The previous "also store
|
|
650
|
-
// bare id when not yet present" trick produced non-deterministic
|
|
651
|
-
// continue_from lookups when two tracks shared a task name —
|
|
652
|
-
// whichever finished first won the bare key. dag.ts now resolves
|
|
653
|
-
// continue_from to a qualified id (DagNode.resolvedContinueFrom),
|
|
654
|
-
// and the enrichedTask handed to drivers carries that qualified
|
|
655
|
-
// version, so bare keys are no longer needed.
|
|
656
|
-
outputMap.set(taskId, outPath);
|
|
657
|
-
}
|
|
658
|
-
|
|
659
636
|
// 6. Determine terminal status (without emitting yet — result must be complete first)
|
|
660
637
|
// H2: branch on failureKind so spawn errors no longer masquerade as
|
|
661
638
|
// timeouts. Old runners that don't set failureKind still work — we
|
|
@@ -697,7 +674,6 @@ export async function runPipeline(
|
|
|
697
674
|
? result.normalizedOutput.slice(0, MAX_NORMALIZED_BYTES) +
|
|
698
675
|
`\n[…clipped at ${MAX_NORMALIZED_BYTES} bytes]`
|
|
699
676
|
: result.normalizedOutput;
|
|
700
|
-
// H1: qualified-only key (see comment near outputMap above).
|
|
701
677
|
normalizedMap.set(taskId, clipped);
|
|
702
678
|
}
|
|
703
679
|
|
|
@@ -708,7 +684,7 @@ export async function runPipeline(
|
|
|
708
684
|
}
|
|
709
685
|
|
|
710
686
|
if (result.sessionId) {
|
|
711
|
-
// H1: qualified-only key
|
|
687
|
+
// H1: qualified-only key.
|
|
712
688
|
sessionMap.set(taskId, result.sessionId);
|
|
713
689
|
}
|
|
714
690
|
|
|
@@ -736,9 +712,6 @@ export async function runPipeline(
|
|
|
736
712
|
if (result.sessionId) {
|
|
737
713
|
log.debug(`[task:${taskId}]`, `sessionId: ${result.sessionId}`);
|
|
738
714
|
}
|
|
739
|
-
if (result.outputPath) {
|
|
740
|
-
log.debug(`[task:${taskId}]`, `wrote output: ${result.outputPath}`);
|
|
741
|
-
}
|
|
742
715
|
if (result.stderrPath) {
|
|
743
716
|
log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
|
|
744
717
|
}
|
|
@@ -760,7 +733,7 @@ export async function runPipeline(
|
|
|
760
733
|
exitCode: -1,
|
|
761
734
|
stdout: '',
|
|
762
735
|
stderr: errMsg,
|
|
763
|
-
|
|
736
|
+
stderrPath: null, durationMs: 0,
|
|
764
737
|
sessionId: null, normalizedOutput: null,
|
|
765
738
|
// H2: Engine-level pre-execution errors (driver throw, middleware
|
|
766
739
|
// throw, getHandler 404) classify as spawn_error — the process never
|
package/src/hooks.ts
CHANGED
|
@@ -141,7 +141,6 @@ export interface TaskInfo {
|
|
|
141
141
|
readonly status: string;
|
|
142
142
|
readonly exit_code: number | null;
|
|
143
143
|
readonly duration_ms: number | null;
|
|
144
|
-
readonly output_path: string | null;
|
|
145
144
|
readonly stderr_path: string | null;
|
|
146
145
|
readonly session_id: string | null;
|
|
147
146
|
readonly started_at: string | null;
|
package/src/registry.ts
CHANGED
|
@@ -1,214 +1,214 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
PluginCategory, DriverPlugin, TriggerPlugin,
|
|
3
|
-
CompletionPlugin, MiddlewarePlugin, PluginManifest,
|
|
4
|
-
} from './types';
|
|
5
|
-
|
|
6
|
-
type PluginType = DriverPlugin | TriggerPlugin | CompletionPlugin | MiddlewarePlugin;
|
|
7
|
-
|
|
8
|
-
const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
|
|
9
|
-
'drivers', 'triggers', 'completions', 'middlewares',
|
|
10
|
-
]);
|
|
11
|
-
|
|
12
|
-
const registries = {
|
|
13
|
-
drivers: new Map<string, DriverPlugin>(),
|
|
14
|
-
triggers: new Map<string, TriggerPlugin>(),
|
|
15
|
-
completions: new Map<string, CompletionPlugin>(),
|
|
16
|
-
middlewares: new Map<string, MiddlewarePlugin>(),
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Minimal contract enforcement so a malformed plugin fails fast at
|
|
21
|
-
* registration time rather than crashing the engine mid-run.
|
|
22
|
-
*
|
|
23
|
-
* For drivers we materialize `capabilities` and assert each field is a
|
|
24
|
-
* boolean — otherwise a plugin author can write
|
|
25
|
-
* get capabilities() { throw new Error('boom') }
|
|
26
|
-
* and pass the basic typeof check, then crash preflight when the engine
|
|
27
|
-
* touches `driver.capabilities.sessionResume`. (R8)
|
|
28
|
-
*/
|
|
29
|
-
function validateContract(category: PluginCategory, handler: unknown): void {
|
|
30
|
-
if (!handler || typeof handler !== 'object') {
|
|
31
|
-
throw new Error(`Plugin handler for category "${category}" must be an object`);
|
|
32
|
-
}
|
|
33
|
-
const h = handler as Record<string, unknown>;
|
|
34
|
-
if (typeof h.name !== 'string' || h.name.length === 0) {
|
|
35
|
-
throw new Error(`Plugin handler for category "${category}" must declare a non-empty "name"`);
|
|
36
|
-
}
|
|
37
|
-
switch (category) {
|
|
38
|
-
case 'drivers': {
|
|
39
|
-
if (typeof h.buildCommand !== 'function') {
|
|
40
|
-
throw new Error(`drivers plugin "${h.name}" must export buildCommand()`);
|
|
41
|
-
}
|
|
42
|
-
// Materialize capabilities — this triggers any throwing getter NOW
|
|
43
|
-
// instead of during preflight.
|
|
44
|
-
let caps: unknown;
|
|
45
|
-
try {
|
|
46
|
-
caps = h.capabilities;
|
|
47
|
-
} catch (err) {
|
|
48
|
-
throw new Error(
|
|
49
|
-
`drivers plugin "${h.name}" capabilities accessor threw: ` +
|
|
50
|
-
(err instanceof Error ? err.message : String(err))
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
if (!caps || typeof caps !== 'object') {
|
|
54
|
-
throw new Error(`drivers plugin "${h.name}" must declare capabilities object`);
|
|
55
|
-
}
|
|
56
|
-
const c = caps as Record<string, unknown>;
|
|
57
|
-
for (const field of ['sessionResume', 'systemPrompt', 'outputFormat'] as const) {
|
|
58
|
-
if (typeof c[field] !== 'boolean') {
|
|
59
|
-
throw new Error(
|
|
60
|
-
`drivers plugin "${h.name}".capabilities.${field} must be a boolean (got ${typeof c[field]})`
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
// Optional methods, but if present must be functions.
|
|
65
|
-
for (const opt of ['parseResult', 'resolveModel', 'resolveTools'] as const) {
|
|
66
|
-
if (h[opt] !== undefined && typeof h[opt] !== 'function') {
|
|
67
|
-
throw new Error(
|
|
68
|
-
`drivers plugin "${h.name}".${opt} must be a function or undefined`
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
break;
|
|
73
|
-
}
|
|
74
|
-
case 'triggers':
|
|
75
|
-
if (typeof h.watch !== 'function') {
|
|
76
|
-
throw new Error(`triggers plugin "${h.name}" must export watch()`);
|
|
77
|
-
}
|
|
78
|
-
break;
|
|
79
|
-
case 'completions':
|
|
80
|
-
if (typeof h.check !== 'function') {
|
|
81
|
-
throw new Error(`completions plugin "${h.name}" must export check()`);
|
|
82
|
-
}
|
|
83
|
-
break;
|
|
84
|
-
case 'middlewares':
|
|
85
|
-
if (typeof h.enhance !== 'function') {
|
|
86
|
-
throw new Error(`middlewares plugin "${h.name}" must export enhance()`);
|
|
87
|
-
}
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export type RegisterResult = 'registered' | 'replaced' | 'unchanged';
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Register a plugin under (category, type). Returns:
|
|
96
|
-
* - 'registered' on first registration
|
|
97
|
-
* - 'replaced' when an existing entry was overwritten with a different handler
|
|
98
|
-
* - 'unchanged' when the same handler instance was already present
|
|
99
|
-
*
|
|
100
|
-
* Throws if `category` is unknown, `type` is empty, or `handler` violates the
|
|
101
|
-
* minimum interface contract for the category.
|
|
102
|
-
*/
|
|
103
|
-
export function registerPlugin<T extends PluginType>(
|
|
104
|
-
category: PluginCategory, type: string, handler: T,
|
|
105
|
-
): RegisterResult {
|
|
106
|
-
if (!VALID_CATEGORIES.has(category)) {
|
|
107
|
-
throw new Error(`Unknown plugin category "${category}"`);
|
|
108
|
-
}
|
|
109
|
-
if (typeof type !== 'string' || type.length === 0) {
|
|
110
|
-
throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
|
|
111
|
-
}
|
|
112
|
-
validateContract(category, handler);
|
|
113
|
-
const registry = registries[category] as Map<string, T>;
|
|
114
|
-
const existing = registry.get(type);
|
|
115
|
-
if (existing === handler) return 'unchanged';
|
|
116
|
-
const wasReplaced = existing !== undefined;
|
|
117
|
-
registry.set(type, handler);
|
|
118
|
-
return wasReplaced ? 'replaced' : 'registered';
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Remove a plugin from the in-process registry. Returns true if a plugin
|
|
123
|
-
* was actually removed. Note: ESM module caching is not affected, so
|
|
124
|
-
* re-importing the same file after unregister will yield the cached module —
|
|
125
|
-
* callers wanting a fresh load must restart the host process.
|
|
126
|
-
*/
|
|
127
|
-
export function unregisterPlugin(category: PluginCategory, type: string): boolean {
|
|
128
|
-
if (!VALID_CATEGORIES.has(category)) return false;
|
|
129
|
-
return registries[category].delete(type);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function getHandler<T extends PluginType>(
|
|
133
|
-
category: PluginCategory, type: string,
|
|
134
|
-
): T {
|
|
135
|
-
const handler = registries[category].get(type);
|
|
136
|
-
if (!handler) {
|
|
137
|
-
throw new Error(
|
|
138
|
-
`${category} type "${type}" not registered.\n` +
|
|
139
|
-
`Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
return handler as T;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function hasHandler(category: PluginCategory, type: string): boolean {
|
|
146
|
-
return registries[category].has(type);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Plugin name must be a scoped npm package or a tagma-prefixed package.
|
|
150
|
-
// Reject absolute/relative paths and suspicious patterns to prevent
|
|
151
|
-
// arbitrary code execution via crafted YAML configs.
|
|
152
|
-
export const PLUGIN_NAME_RE = /^(@[a-z0-9-]+\/[a-z0-9._-]+|tagma-plugin-[a-z0-9._-]+)$/;
|
|
153
|
-
|
|
154
|
-
export function isValidPluginName(name: unknown): name is string {
|
|
155
|
-
return typeof name === 'string' && PLUGIN_NAME_RE.test(name);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Parse and validate the `tagmaPlugin` field of a `package.json` blob.
|
|
160
|
-
*
|
|
161
|
-
* Returns the strongly-typed manifest if the field is present and
|
|
162
|
-
* well-formed (`category` is one of the four known categories and `type`
|
|
163
|
-
* is a non-empty string). Returns `null` if the field is absent — that
|
|
164
|
-
* is the host's signal that the package is a library, not a plugin.
|
|
165
|
-
*
|
|
166
|
-
* Throws if the field is present but malformed: that's a packaging bug
|
|
167
|
-
* the plugin author should hear about loudly, not a silent skip.
|
|
168
|
-
*
|
|
169
|
-
* Hosts use this during auto-discovery to decide whether to load a
|
|
170
|
-
* package as a plugin without having to dynamically `import()` it.
|
|
171
|
-
*/
|
|
172
|
-
export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
|
|
173
|
-
if (!pkgJson || typeof pkgJson !== 'object') return null;
|
|
174
|
-
const raw = (pkgJson as Record<string, unknown>).tagmaPlugin;
|
|
175
|
-
if (raw === undefined) return null;
|
|
176
|
-
if (!raw || typeof raw !== 'object') {
|
|
177
|
-
throw new Error('tagmaPlugin field must be an object with { category, type }');
|
|
178
|
-
}
|
|
179
|
-
const m = raw as Record<string, unknown>;
|
|
180
|
-
const category = m.category;
|
|
181
|
-
const type = m.type;
|
|
182
|
-
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category as PluginCategory)) {
|
|
183
|
-
throw new Error(
|
|
184
|
-
`tagmaPlugin.category must be one of ${[...VALID_CATEGORIES].join(', ')}, got ${JSON.stringify(category)}`
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
if (typeof type !== 'string' || type.length === 0) {
|
|
188
|
-
throw new Error(`tagmaPlugin.type must be a non-empty string, got ${JSON.stringify(type)}`);
|
|
189
|
-
}
|
|
190
|
-
return { category: category as PluginCategory, type };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export async function loadPlugins(pluginNames: readonly string[]): Promise<void> {
|
|
194
|
-
for (const name of pluginNames) {
|
|
195
|
-
if (!isValidPluginName(name)) {
|
|
196
|
-
throw new Error(
|
|
197
|
-
`Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
|
|
198
|
-
`(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
|
|
199
|
-
`Relative/absolute paths are not allowed.`
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
const mod = await import(name);
|
|
203
|
-
if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
|
|
204
|
-
throw new Error(
|
|
205
|
-
`Plugin "${name}" must export pluginCategory, pluginType, and default`
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function listRegistered(category: PluginCategory): string[] {
|
|
213
|
-
return [...registries[category].keys()];
|
|
214
|
-
}
|
|
1
|
+
import type {
|
|
2
|
+
PluginCategory, DriverPlugin, TriggerPlugin,
|
|
3
|
+
CompletionPlugin, MiddlewarePlugin, PluginManifest,
|
|
4
|
+
} from './types';
|
|
5
|
+
|
|
6
|
+
type PluginType = DriverPlugin | TriggerPlugin | CompletionPlugin | MiddlewarePlugin;
|
|
7
|
+
|
|
8
|
+
const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
|
|
9
|
+
'drivers', 'triggers', 'completions', 'middlewares',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const registries = {
|
|
13
|
+
drivers: new Map<string, DriverPlugin>(),
|
|
14
|
+
triggers: new Map<string, TriggerPlugin>(),
|
|
15
|
+
completions: new Map<string, CompletionPlugin>(),
|
|
16
|
+
middlewares: new Map<string, MiddlewarePlugin>(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimal contract enforcement so a malformed plugin fails fast at
|
|
21
|
+
* registration time rather than crashing the engine mid-run.
|
|
22
|
+
*
|
|
23
|
+
* For drivers we materialize `capabilities` and assert each field is a
|
|
24
|
+
* boolean — otherwise a plugin author can write
|
|
25
|
+
* get capabilities() { throw new Error('boom') }
|
|
26
|
+
* and pass the basic typeof check, then crash preflight when the engine
|
|
27
|
+
* touches `driver.capabilities.sessionResume`. (R8)
|
|
28
|
+
*/
|
|
29
|
+
function validateContract(category: PluginCategory, handler: unknown): void {
|
|
30
|
+
if (!handler || typeof handler !== 'object') {
|
|
31
|
+
throw new Error(`Plugin handler for category "${category}" must be an object`);
|
|
32
|
+
}
|
|
33
|
+
const h = handler as Record<string, unknown>;
|
|
34
|
+
if (typeof h.name !== 'string' || h.name.length === 0) {
|
|
35
|
+
throw new Error(`Plugin handler for category "${category}" must declare a non-empty "name"`);
|
|
36
|
+
}
|
|
37
|
+
switch (category) {
|
|
38
|
+
case 'drivers': {
|
|
39
|
+
if (typeof h.buildCommand !== 'function') {
|
|
40
|
+
throw new Error(`drivers plugin "${h.name}" must export buildCommand()`);
|
|
41
|
+
}
|
|
42
|
+
// Materialize capabilities — this triggers any throwing getter NOW
|
|
43
|
+
// instead of during preflight.
|
|
44
|
+
let caps: unknown;
|
|
45
|
+
try {
|
|
46
|
+
caps = h.capabilities;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`drivers plugin "${h.name}" capabilities accessor threw: ` +
|
|
50
|
+
(err instanceof Error ? err.message : String(err))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (!caps || typeof caps !== 'object') {
|
|
54
|
+
throw new Error(`drivers plugin "${h.name}" must declare capabilities object`);
|
|
55
|
+
}
|
|
56
|
+
const c = caps as Record<string, unknown>;
|
|
57
|
+
for (const field of ['sessionResume', 'systemPrompt', 'outputFormat'] as const) {
|
|
58
|
+
if (typeof c[field] !== 'boolean') {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`drivers plugin "${h.name}".capabilities.${field} must be a boolean (got ${typeof c[field]})`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Optional methods, but if present must be functions.
|
|
65
|
+
for (const opt of ['parseResult', 'resolveModel', 'resolveTools'] as const) {
|
|
66
|
+
if (h[opt] !== undefined && typeof h[opt] !== 'function') {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`drivers plugin "${h.name}".${opt} must be a function or undefined`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'triggers':
|
|
75
|
+
if (typeof h.watch !== 'function') {
|
|
76
|
+
throw new Error(`triggers plugin "${h.name}" must export watch()`);
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
case 'completions':
|
|
80
|
+
if (typeof h.check !== 'function') {
|
|
81
|
+
throw new Error(`completions plugin "${h.name}" must export check()`);
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
case 'middlewares':
|
|
85
|
+
if (typeof h.enhance !== 'function') {
|
|
86
|
+
throw new Error(`middlewares plugin "${h.name}" must export enhance()`);
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type RegisterResult = 'registered' | 'replaced' | 'unchanged';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Register a plugin under (category, type). Returns:
|
|
96
|
+
* - 'registered' on first registration
|
|
97
|
+
* - 'replaced' when an existing entry was overwritten with a different handler
|
|
98
|
+
* - 'unchanged' when the same handler instance was already present
|
|
99
|
+
*
|
|
100
|
+
* Throws if `category` is unknown, `type` is empty, or `handler` violates the
|
|
101
|
+
* minimum interface contract for the category.
|
|
102
|
+
*/
|
|
103
|
+
export function registerPlugin<T extends PluginType>(
|
|
104
|
+
category: PluginCategory, type: string, handler: T,
|
|
105
|
+
): RegisterResult {
|
|
106
|
+
if (!VALID_CATEGORIES.has(category)) {
|
|
107
|
+
throw new Error(`Unknown plugin category "${category}"`);
|
|
108
|
+
}
|
|
109
|
+
if (typeof type !== 'string' || type.length === 0) {
|
|
110
|
+
throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
|
|
111
|
+
}
|
|
112
|
+
validateContract(category, handler);
|
|
113
|
+
const registry = registries[category] as Map<string, T>;
|
|
114
|
+
const existing = registry.get(type);
|
|
115
|
+
if (existing === handler) return 'unchanged';
|
|
116
|
+
const wasReplaced = existing !== undefined;
|
|
117
|
+
registry.set(type, handler);
|
|
118
|
+
return wasReplaced ? 'replaced' : 'registered';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove a plugin from the in-process registry. Returns true if a plugin
|
|
123
|
+
* was actually removed. Note: ESM module caching is not affected, so
|
|
124
|
+
* re-importing the same file after unregister will yield the cached module —
|
|
125
|
+
* callers wanting a fresh load must restart the host process.
|
|
126
|
+
*/
|
|
127
|
+
export function unregisterPlugin(category: PluginCategory, type: string): boolean {
|
|
128
|
+
if (!VALID_CATEGORIES.has(category)) return false;
|
|
129
|
+
return registries[category].delete(type);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getHandler<T extends PluginType>(
|
|
133
|
+
category: PluginCategory, type: string,
|
|
134
|
+
): T {
|
|
135
|
+
const handler = registries[category].get(type);
|
|
136
|
+
if (!handler) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${category} type "${type}" not registered.\n` +
|
|
139
|
+
`Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return handler as T;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function hasHandler(category: PluginCategory, type: string): boolean {
|
|
146
|
+
return registries[category].has(type);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Plugin name must be a scoped npm package or a tagma-prefixed package.
|
|
150
|
+
// Reject absolute/relative paths and suspicious patterns to prevent
|
|
151
|
+
// arbitrary code execution via crafted YAML configs.
|
|
152
|
+
export const PLUGIN_NAME_RE = /^(@[a-z0-9-]+\/[a-z0-9._-]+|tagma-plugin-[a-z0-9._-]+)$/;
|
|
153
|
+
|
|
154
|
+
export function isValidPluginName(name: unknown): name is string {
|
|
155
|
+
return typeof name === 'string' && PLUGIN_NAME_RE.test(name);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Parse and validate the `tagmaPlugin` field of a `package.json` blob.
|
|
160
|
+
*
|
|
161
|
+
* Returns the strongly-typed manifest if the field is present and
|
|
162
|
+
* well-formed (`category` is one of the four known categories and `type`
|
|
163
|
+
* is a non-empty string). Returns `null` if the field is absent — that
|
|
164
|
+
* is the host's signal that the package is a library, not a plugin.
|
|
165
|
+
*
|
|
166
|
+
* Throws if the field is present but malformed: that's a packaging bug
|
|
167
|
+
* the plugin author should hear about loudly, not a silent skip.
|
|
168
|
+
*
|
|
169
|
+
* Hosts use this during auto-discovery to decide whether to load a
|
|
170
|
+
* package as a plugin without having to dynamically `import()` it.
|
|
171
|
+
*/
|
|
172
|
+
export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
|
|
173
|
+
if (!pkgJson || typeof pkgJson !== 'object') return null;
|
|
174
|
+
const raw = (pkgJson as Record<string, unknown>).tagmaPlugin;
|
|
175
|
+
if (raw === undefined) return null;
|
|
176
|
+
if (!raw || typeof raw !== 'object') {
|
|
177
|
+
throw new Error('tagmaPlugin field must be an object with { category, type }');
|
|
178
|
+
}
|
|
179
|
+
const m = raw as Record<string, unknown>;
|
|
180
|
+
const category = m.category;
|
|
181
|
+
const type = m.type;
|
|
182
|
+
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category as PluginCategory)) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`tagmaPlugin.category must be one of ${[...VALID_CATEGORIES].join(', ')}, got ${JSON.stringify(category)}`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (typeof type !== 'string' || type.length === 0) {
|
|
188
|
+
throw new Error(`tagmaPlugin.type must be a non-empty string, got ${JSON.stringify(type)}`);
|
|
189
|
+
}
|
|
190
|
+
return { category: category as PluginCategory, type };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function loadPlugins(pluginNames: readonly string[]): Promise<void> {
|
|
194
|
+
for (const name of pluginNames) {
|
|
195
|
+
if (!isValidPluginName(name)) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
|
|
198
|
+
`(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
|
|
199
|
+
`Relative/absolute paths are not allowed.`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const mod = await import(name);
|
|
203
|
+
if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Plugin "${name}" must export pluginCategory, pluginType, and default`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function listRegistered(category: PluginCategory): string[] {
|
|
213
|
+
return [...registries[category].keys()];
|
|
214
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -111,7 +111,6 @@ function failResult(stderr: string, durationMs: number): TaskResult {
|
|
|
111
111
|
exitCode: -1,
|
|
112
112
|
stdout: '',
|
|
113
113
|
stderr,
|
|
114
|
-
outputPath: null,
|
|
115
114
|
stderrPath: null,
|
|
116
115
|
durationMs,
|
|
117
116
|
sessionId: null,
|
|
@@ -289,7 +288,6 @@ export async function runSpawn(
|
|
|
289
288
|
exitCode: -1,
|
|
290
289
|
stdout,
|
|
291
290
|
stderr,
|
|
292
|
-
outputPath: null,
|
|
293
291
|
stderrPath: null,
|
|
294
292
|
durationMs,
|
|
295
293
|
sessionId: null,
|
|
@@ -339,7 +337,6 @@ export async function runSpawn(
|
|
|
339
337
|
exitCode,
|
|
340
338
|
stdout,
|
|
341
339
|
stderr: stderr + note,
|
|
342
|
-
outputPath: null,
|
|
343
340
|
stderrPath: null,
|
|
344
341
|
durationMs,
|
|
345
342
|
sessionId: null,
|
|
@@ -362,7 +359,6 @@ export async function runSpawn(
|
|
|
362
359
|
exitCode: exitCode === 0 ? 1 : exitCode,
|
|
363
360
|
stdout,
|
|
364
361
|
stderr: stderr + (stderr.endsWith('\n') ? '' : '\n') + `[driver] ${forcedFailureMessage}`,
|
|
365
|
-
outputPath: null,
|
|
366
362
|
stderrPath: null,
|
|
367
363
|
durationMs,
|
|
368
364
|
sessionId,
|
|
@@ -374,7 +370,6 @@ export async function runSpawn(
|
|
|
374
370
|
exitCode,
|
|
375
371
|
stdout,
|
|
376
372
|
stderr,
|
|
377
|
-
outputPath: null,
|
|
378
373
|
stderrPath: null,
|
|
379
374
|
durationMs,
|
|
380
375
|
sessionId,
|