@tagma/sdk 0.1.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.
- package/README.md +1 -0
- package/package.json +32 -0
- package/src/adapters/stdin-approval.ts +117 -0
- package/src/adapters/websocket-approval.ts +144 -0
- package/src/approval.ts +125 -0
- package/src/bootstrap.ts +37 -0
- package/src/completions/exit-code.ts +19 -0
- package/src/completions/file-exists.ts +39 -0
- package/src/completions/output-check.ts +57 -0
- package/src/dag.ts +137 -0
- package/src/drivers/claude-code.ts +207 -0
- package/src/engine.ts +598 -0
- package/src/hooks.ts +138 -0
- package/src/logger.ts +100 -0
- package/src/middlewares/static-context.ts +29 -0
- package/src/registry.ts +56 -0
- package/src/runner.ts +194 -0
- package/src/schema.ts +260 -0
- package/src/sdk.ts +48 -0
- package/src/triggers/file.ts +94 -0
- package/src/triggers/manual.ts +61 -0
- package/src/types.ts +18 -0
- package/src/utils.ts +147 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import yaml from 'js-yaml';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import type {
|
|
4
|
+
PipelineConfig, RawPipelineConfig, RawTrackConfig, RawTaskConfig,
|
|
5
|
+
TrackConfig, TaskConfig, Permissions, MiddlewareConfig,
|
|
6
|
+
TemplateConfig, TemplateParamDef,
|
|
7
|
+
} from './types';
|
|
8
|
+
import { truncateForName, validatePathParam } from './utils';
|
|
9
|
+
import { DEFAULT_PERMISSIONS } from './types';
|
|
10
|
+
|
|
11
|
+
// ═══ YAML Parsing ═══
|
|
12
|
+
|
|
13
|
+
export function parseYaml(content: string): RawPipelineConfig {
|
|
14
|
+
const doc = yaml.load(content) as { pipeline?: RawPipelineConfig };
|
|
15
|
+
if (!doc?.pipeline) {
|
|
16
|
+
throw new Error('YAML must contain a top-level "pipeline" key');
|
|
17
|
+
}
|
|
18
|
+
const p = doc.pipeline;
|
|
19
|
+
if (!p.name) throw new Error('pipeline.name is required');
|
|
20
|
+
if (!p.tracks || p.tracks.length === 0) throw new Error('pipeline.tracks must be non-empty');
|
|
21
|
+
|
|
22
|
+
for (const track of p.tracks) {
|
|
23
|
+
validateRawTrack(track);
|
|
24
|
+
}
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateRawTrack(track: RawTrackConfig): void {
|
|
29
|
+
if (!track.id) throw new Error('track.id is required');
|
|
30
|
+
if (!track.name) throw new Error(`track "${track.id}": name is required`);
|
|
31
|
+
if (!track.tasks || track.tasks.length === 0) {
|
|
32
|
+
throw new Error(`track "${track.id}": tasks must be non-empty`);
|
|
33
|
+
}
|
|
34
|
+
for (const task of track.tasks) {
|
|
35
|
+
validateRawTask(task, track.id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateRawTask(task: RawTaskConfig, trackId: string): void {
|
|
40
|
+
if (!task.id) throw new Error(`track "${trackId}": task.id is required`);
|
|
41
|
+
if (task.use) return; // template usage, validated later
|
|
42
|
+
|
|
43
|
+
const hasPrompt = typeof task.prompt === 'string' && task.prompt.length > 0;
|
|
44
|
+
const hasCommand = typeof task.command === 'string' && task.command.length > 0;
|
|
45
|
+
if (!hasPrompt && !hasCommand) {
|
|
46
|
+
throw new Error(`task "${task.id}": must have either "prompt" or "command"`);
|
|
47
|
+
}
|
|
48
|
+
if (hasPrompt && hasCommand) {
|
|
49
|
+
throw new Error(`task "${task.id}": cannot have both "prompt" and "command"`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ═══ Template Expansion ═══
|
|
54
|
+
|
|
55
|
+
export async function expandTemplates(
|
|
56
|
+
tasks: readonly RawTaskConfig[],
|
|
57
|
+
instancePrefix: string,
|
|
58
|
+
): Promise<RawTaskConfig[]> {
|
|
59
|
+
const result: RawTaskConfig[] = [];
|
|
60
|
+
|
|
61
|
+
for (const task of tasks) {
|
|
62
|
+
if (!task.use) {
|
|
63
|
+
result.push(task);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const template = await loadTemplate(task.use);
|
|
68
|
+
const params = resolveTemplateParams(template, task.with ?? {}, task.id);
|
|
69
|
+
const expanded = expandTemplateTask(template, params, task.id, instancePrefix);
|
|
70
|
+
result.push(...expanded);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function loadTemplate(ref: string): Promise<TemplateConfig> {
|
|
77
|
+
// Strip version suffix for import
|
|
78
|
+
const moduleName = ref.replace(/@v\d+$/, '');
|
|
79
|
+
try {
|
|
80
|
+
const mod = await import(moduleName);
|
|
81
|
+
// Expect the module to export a template.yaml content or parsed object
|
|
82
|
+
if (mod.template) return mod.template as TemplateConfig;
|
|
83
|
+
|
|
84
|
+
// Try loading template.yaml from the package
|
|
85
|
+
const pkgPath = require.resolve(`${moduleName}/template.yaml`);
|
|
86
|
+
const content = await Bun.file(pkgPath).text();
|
|
87
|
+
const doc = yaml.load(content) as { template: TemplateConfig };
|
|
88
|
+
return doc.template;
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(`Failed to load template: "${ref}". Is the package installed?`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveTemplateParams(
|
|
95
|
+
template: TemplateConfig,
|
|
96
|
+
provided: Record<string, unknown>,
|
|
97
|
+
instanceId: string,
|
|
98
|
+
): Record<string, unknown> {
|
|
99
|
+
const params: Record<string, unknown> = {};
|
|
100
|
+
const defs = template.params ?? {};
|
|
101
|
+
|
|
102
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
103
|
+
const value = provided[key] ?? def.default;
|
|
104
|
+
if (value === undefined) {
|
|
105
|
+
throw new Error(`Template "${template.name}" instance "${instanceId}": missing required param "${key}"`);
|
|
106
|
+
}
|
|
107
|
+
validateParamType(key, value, def, template.name, instanceId);
|
|
108
|
+
params[key] = value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Warn about unknown params
|
|
112
|
+
for (const key of Object.keys(provided)) {
|
|
113
|
+
if (!(key in defs)) {
|
|
114
|
+
console.warn(`Template "${template.name}" instance "${instanceId}": unknown param "${key}"`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return params;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validateParamType(
|
|
122
|
+
key: string, value: unknown, def: TemplateParamDef,
|
|
123
|
+
templateName: string, instanceId: string,
|
|
124
|
+
): void {
|
|
125
|
+
const ctx = `Template "${templateName}" instance "${instanceId}" param "${key}"`;
|
|
126
|
+
const ptype = def.type ?? 'string';
|
|
127
|
+
|
|
128
|
+
switch (ptype) {
|
|
129
|
+
case 'string':
|
|
130
|
+
if (typeof value !== 'string') throw new Error(`${ctx}: expected string, got ${typeof value}`);
|
|
131
|
+
break;
|
|
132
|
+
case 'path':
|
|
133
|
+
if (typeof value !== 'string') throw new Error(`${ctx}: expected path string, got ${typeof value}`);
|
|
134
|
+
validatePathParam(value);
|
|
135
|
+
break;
|
|
136
|
+
case 'enum':
|
|
137
|
+
if (!def.enum?.includes(value as string)) {
|
|
138
|
+
throw new Error(`${ctx}: value "${value}" not in allowed values [${def.enum?.join(', ')}]`);
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
case 'number':
|
|
142
|
+
if (typeof value !== 'number') throw new Error(`${ctx}: expected number, got ${typeof value}`);
|
|
143
|
+
if (def.min !== undefined && value < def.min) throw new Error(`${ctx}: ${value} < min ${def.min}`);
|
|
144
|
+
if (def.max !== undefined && value > def.max) throw new Error(`${ctx}: ${value} > max ${def.max}`);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function expandTemplateTask(
|
|
150
|
+
template: TemplateConfig,
|
|
151
|
+
params: Record<string, unknown>,
|
|
152
|
+
instanceId: string,
|
|
153
|
+
instancePrefix: string,
|
|
154
|
+
): RawTaskConfig[] {
|
|
155
|
+
return template.tasks.map(task => {
|
|
156
|
+
const prefixedId = `${instanceId}.${task.id}`;
|
|
157
|
+
|
|
158
|
+
// Replace ${{ params.xxx }} in string fields
|
|
159
|
+
const interpolate = (s: string): string =>
|
|
160
|
+
s.replace(/\$\{\{\s*params\.(\w+)\s*\}\}/g, (_, key) => String(params[key] ?? ''));
|
|
161
|
+
|
|
162
|
+
const newTask: Record<string, unknown> = { ...task, id: prefixedId };
|
|
163
|
+
|
|
164
|
+
// Interpolate string fields
|
|
165
|
+
if (task.prompt) newTask.prompt = interpolate(task.prompt);
|
|
166
|
+
if (task.command) newTask.command = interpolate(task.command);
|
|
167
|
+
|
|
168
|
+
// Namespace depends_on
|
|
169
|
+
if (task.depends_on) {
|
|
170
|
+
newTask.depends_on = task.depends_on.map(dep => `${instanceId}.${dep}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Namespace continue_from
|
|
174
|
+
if (task.continue_from) {
|
|
175
|
+
newTask.continue_from = `${instanceId}.${task.continue_from}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Rewrite output path to instance namespace
|
|
179
|
+
if (task.output) {
|
|
180
|
+
const original = interpolate(task.output);
|
|
181
|
+
newTask.output = original.replace('./tmp/', `./tmp/${instanceId}/`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return newTask as unknown as RawTaskConfig;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ═══ Config Inheritance Resolution ═══
|
|
189
|
+
|
|
190
|
+
export function resolveConfig(raw: RawPipelineConfig, workDir: string): PipelineConfig {
|
|
191
|
+
const tracks: TrackConfig[] = raw.tracks.map(rawTrack => {
|
|
192
|
+
const trackDriver = rawTrack.driver ?? raw.driver;
|
|
193
|
+
const trackCwd = rawTrack.cwd ? resolve(workDir, rawTrack.cwd) : workDir;
|
|
194
|
+
|
|
195
|
+
const tasks: TaskConfig[] = rawTrack.tasks.map(rawTask => {
|
|
196
|
+
const name = rawTask.name
|
|
197
|
+
?? (rawTask.prompt ? truncateForName(rawTask.prompt) : rawTask.command ?? rawTask.id);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
id: rawTask.id,
|
|
201
|
+
name,
|
|
202
|
+
prompt: rawTask.prompt,
|
|
203
|
+
command: rawTask.command,
|
|
204
|
+
depends_on: rawTask.depends_on,
|
|
205
|
+
trigger: rawTask.trigger,
|
|
206
|
+
continue_from: rawTask.continue_from,
|
|
207
|
+
output: rawTask.output,
|
|
208
|
+
// Inheritance: Task > Track
|
|
209
|
+
model_tier: rawTask.model_tier ?? rawTrack.model_tier ?? 'medium',
|
|
210
|
+
permissions: rawTask.permissions ?? rawTrack.permissions ?? DEFAULT_PERMISSIONS,
|
|
211
|
+
driver: rawTask.driver ?? trackDriver ?? 'claude-code',
|
|
212
|
+
timeout: rawTask.timeout,
|
|
213
|
+
// Middleware: Task-level overrides Track (including [] to disable)
|
|
214
|
+
middlewares: rawTask.middlewares !== undefined ? rawTask.middlewares : rawTrack.middlewares,
|
|
215
|
+
completion: rawTask.completion,
|
|
216
|
+
agent_profile: rawTask.agent_profile ?? rawTrack.agent_profile,
|
|
217
|
+
cwd: rawTask.cwd ? resolve(workDir, rawTask.cwd) : trackCwd,
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
id: rawTrack.id,
|
|
223
|
+
name: rawTrack.name,
|
|
224
|
+
color: rawTrack.color,
|
|
225
|
+
agent_profile: rawTrack.agent_profile,
|
|
226
|
+
model_tier: rawTrack.model_tier ?? 'medium',
|
|
227
|
+
permissions: rawTrack.permissions ?? DEFAULT_PERMISSIONS,
|
|
228
|
+
driver: trackDriver ?? 'claude-code',
|
|
229
|
+
cwd: trackCwd,
|
|
230
|
+
middlewares: rawTrack.middlewares,
|
|
231
|
+
on_failure: rawTrack.on_failure ?? 'skip_downstream',
|
|
232
|
+
tasks,
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
name: raw.name,
|
|
238
|
+
driver: raw.driver,
|
|
239
|
+
timeout: raw.timeout,
|
|
240
|
+
plugins: raw.plugins,
|
|
241
|
+
hooks: raw.hooks,
|
|
242
|
+
tracks,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ═══ Full Parse Pipeline ═══
|
|
247
|
+
|
|
248
|
+
export async function loadPipeline(yamlContent: string, workDir: string): Promise<PipelineConfig> {
|
|
249
|
+
const raw = parseYaml(yamlContent);
|
|
250
|
+
|
|
251
|
+
// Expand templates in each track
|
|
252
|
+
const expandedTracks: RawTrackConfig[] = [];
|
|
253
|
+
for (const track of raw.tracks) {
|
|
254
|
+
const expandedTasks = await expandTemplates(track.tasks, track.id);
|
|
255
|
+
expandedTracks.push({ ...track, tasks: expandedTasks });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const expandedRaw: RawPipelineConfig = { ...raw, tracks: expandedTracks };
|
|
259
|
+
return resolveConfig(expandedRaw, workDir);
|
|
260
|
+
}
|
package/src/sdk.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ═══ tagma-sdk public API ═══
|
|
2
|
+
//
|
|
3
|
+
// This is the SDK entry point. Import from here, not from internal modules.
|
|
4
|
+
// The CLI (src/index.ts in the CLI project) also imports from here.
|
|
5
|
+
|
|
6
|
+
// ── Core engine ──
|
|
7
|
+
export { runPipeline } from './engine';
|
|
8
|
+
export type { EngineResult, RunPipelineOptions } from './engine';
|
|
9
|
+
|
|
10
|
+
// ── Schema: parse / resolve / load ──
|
|
11
|
+
export { parseYaml, resolveConfig, expandTemplates, loadPipeline } from './schema';
|
|
12
|
+
|
|
13
|
+
// ── DAG ──
|
|
14
|
+
export { buildDag } from './dag';
|
|
15
|
+
export type { DagNode, Dag } from './dag';
|
|
16
|
+
|
|
17
|
+
// ── Plugin registry ──
|
|
18
|
+
export { bootstrapBuiltins } from './bootstrap';
|
|
19
|
+
export { loadPlugins, registerPlugin, getHandler, hasHandler, listRegistered } from './registry';
|
|
20
|
+
|
|
21
|
+
// ── Approval gateway ──
|
|
22
|
+
export { InMemoryApprovalGateway } from './approval';
|
|
23
|
+
export type {
|
|
24
|
+
ApprovalGateway,
|
|
25
|
+
ApprovalRequest,
|
|
26
|
+
ApprovalDecision,
|
|
27
|
+
ApprovalOutcome,
|
|
28
|
+
ApprovalEvent,
|
|
29
|
+
ApprovalListener,
|
|
30
|
+
} from './approval';
|
|
31
|
+
|
|
32
|
+
// ── Approval adapters ──
|
|
33
|
+
export { attachStdinApprovalAdapter } from './adapters/stdin-approval';
|
|
34
|
+
export type { StdinApprovalAdapter } from './adapters/stdin-approval';
|
|
35
|
+
export { attachWebSocketApprovalAdapter } from './adapters/websocket-approval';
|
|
36
|
+
export type { WebSocketApprovalAdapter, WebSocketApprovalAdapterOptions } from './adapters/websocket-approval';
|
|
37
|
+
|
|
38
|
+
// ── Logger ──
|
|
39
|
+
export { Logger, tailLines, clip } from './logger';
|
|
40
|
+
|
|
41
|
+
// ── Hook context types (useful for frontend display) ──
|
|
42
|
+
export type { HookResult, PipelineInfo, TrackInfo, TaskInfo } from './hooks';
|
|
43
|
+
|
|
44
|
+
// ── Utils (public subset) ──
|
|
45
|
+
export { parseDuration, validatePath, generateRunId, nowISO, truncateForName } from './utils';
|
|
46
|
+
|
|
47
|
+
// ── All types from @tagma/types + runtime constants ──
|
|
48
|
+
export * from './types';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { watch } from 'chokidar';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
import type { TriggerPlugin, TriggerContext } from '../types';
|
|
4
|
+
import { parseDuration, validatePath } from '../utils';
|
|
5
|
+
|
|
6
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
7
|
+
|
|
8
|
+
function pathsEqual(a: string, b: string): boolean {
|
|
9
|
+
return IS_WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const FileTrigger: TriggerPlugin = {
|
|
13
|
+
name: 'file',
|
|
14
|
+
|
|
15
|
+
watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
|
|
16
|
+
const filePath = config.path as string;
|
|
17
|
+
if (!filePath) throw new Error(`file trigger: "path" is required`);
|
|
18
|
+
|
|
19
|
+
const safePath = validatePath(filePath, ctx.workDir);
|
|
20
|
+
const timeoutMs = config.timeout != null ? parseDuration(String(config.timeout)) : 0;
|
|
21
|
+
|
|
22
|
+
return new Promise((resolve_p, reject) => {
|
|
23
|
+
if (ctx.signal.aborted) {
|
|
24
|
+
reject(new Error('Pipeline aborted'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let settled = false;
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
30
|
+
|
|
31
|
+
const dir = dirname(safePath);
|
|
32
|
+
const watcher = watch(dir, {
|
|
33
|
+
ignoreInitial: true,
|
|
34
|
+
depth: 0,
|
|
35
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const cleanup = () => {
|
|
39
|
+
if (settled) return;
|
|
40
|
+
settled = true;
|
|
41
|
+
watcher.close().catch(() => { /* ignore */ });
|
|
42
|
+
if (timer) clearTimeout(timer);
|
|
43
|
+
ctx.signal.removeEventListener('abort', onAbort);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const onAbort = () => {
|
|
47
|
+
cleanup();
|
|
48
|
+
reject(new Error('Pipeline aborted'));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
watcher.on('add', (addedPath: string) => {
|
|
52
|
+
if (settled) return;
|
|
53
|
+
if (pathsEqual(resolve(addedPath), safePath)) {
|
|
54
|
+
cleanup();
|
|
55
|
+
resolve_p({ path: safePath });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
watcher.on('error', (err: unknown) => {
|
|
60
|
+
if (settled) return;
|
|
61
|
+
cleanup();
|
|
62
|
+
reject(new Error(`file trigger watch error: ${err instanceof Error ? err.message : String(err)}`));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// After the watcher finishes its initial scan, check if the file already exists.
|
|
66
|
+
// Doing this inside 'ready' eliminates the race window between existence check
|
|
67
|
+
// and watcher startup, so we neither miss events nor double-resolve.
|
|
68
|
+
watcher.on('ready', () => {
|
|
69
|
+
if (settled) return;
|
|
70
|
+
Bun.file(safePath).exists().then((exists) => {
|
|
71
|
+
if (settled) return;
|
|
72
|
+
if (exists) {
|
|
73
|
+
cleanup();
|
|
74
|
+
resolve_p({ path: safePath });
|
|
75
|
+
}
|
|
76
|
+
}).catch((err: unknown) => {
|
|
77
|
+
if (settled) return;
|
|
78
|
+
cleanup();
|
|
79
|
+
reject(new Error(`file trigger existence check failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (timeoutMs > 0) {
|
|
84
|
+
timer = setTimeout(() => {
|
|
85
|
+
if (settled) return;
|
|
86
|
+
cleanup();
|
|
87
|
+
reject(new Error(`file trigger timeout: ${filePath} did not appear within ${config.timeout}`));
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
ctx.signal.addEventListener('abort', onAbort);
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { TriggerPlugin, TriggerContext } from '../types';
|
|
2
|
+
import { parseDuration } from '../utils';
|
|
3
|
+
|
|
4
|
+
export const ManualTrigger: TriggerPlugin = {
|
|
5
|
+
name: 'manual',
|
|
6
|
+
|
|
7
|
+
async watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
|
|
8
|
+
const message =
|
|
9
|
+
(config.message as string | undefined) ?? `Manual confirmation required for task "${ctx.taskId}"`;
|
|
10
|
+
const timeoutMs = config.timeout ? parseDuration(config.timeout as string) : 0;
|
|
11
|
+
const options = Array.isArray(config.options)
|
|
12
|
+
? (config.options as unknown[]).map(String)
|
|
13
|
+
: undefined;
|
|
14
|
+
const metadata =
|
|
15
|
+
config.metadata && typeof config.metadata === 'object'
|
|
16
|
+
? (config.metadata as Record<string, unknown>)
|
|
17
|
+
: undefined;
|
|
18
|
+
|
|
19
|
+
const decisionPromise = ctx.approvalGateway.request({
|
|
20
|
+
taskId: ctx.taskId,
|
|
21
|
+
trackId: ctx.trackId,
|
|
22
|
+
message,
|
|
23
|
+
options,
|
|
24
|
+
timeoutMs,
|
|
25
|
+
metadata,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Wire AbortSignal → try to resolve this specific request as aborted.
|
|
29
|
+
// We can't directly cancel via the gateway (no id yet at .request() call site),
|
|
30
|
+
// so instead we race against an abort promise and let engine status logic
|
|
31
|
+
// fall back to pipelineAborted → skipped. abortAll() on gateway still runs
|
|
32
|
+
// from engine shutdown path to clean up any truly-pending entries.
|
|
33
|
+
const abortPromise = new Promise<never>((_, reject) => {
|
|
34
|
+
if (ctx.signal.aborted) {
|
|
35
|
+
reject(new Error('Pipeline aborted'));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
ctx.signal.addEventListener(
|
|
39
|
+
'abort',
|
|
40
|
+
() => reject(new Error('Pipeline aborted')),
|
|
41
|
+
{ once: true },
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const decision = await Promise.race([decisionPromise, abortPromise]);
|
|
46
|
+
|
|
47
|
+
switch (decision.outcome) {
|
|
48
|
+
case 'approved':
|
|
49
|
+
return { confirmed: true, approvalId: decision.approvalId, choice: decision.choice, actor: decision.actor };
|
|
50
|
+
case 'rejected':
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Manual trigger rejected by ${decision.actor ?? 'user'}` +
|
|
53
|
+
(decision.reason ? `: ${decision.reason}` : ''),
|
|
54
|
+
);
|
|
55
|
+
case 'timeout':
|
|
56
|
+
throw new Error(`Manual trigger timeout: ${decision.reason ?? 'no decision made'}`);
|
|
57
|
+
case 'aborted':
|
|
58
|
+
throw new Error(`Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +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
|
+
};
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { resolve, relative } from 'path';
|
|
2
|
+
|
|
3
|
+
const DURATION_RE = /^(\d+(?:\.\d+)?)\s*(s|m|h)$/;
|
|
4
|
+
|
|
5
|
+
export function parseDuration(input: string): number {
|
|
6
|
+
const match = DURATION_RE.exec(input.trim());
|
|
7
|
+
if (!match) {
|
|
8
|
+
throw new Error(`Invalid duration format: "${input}". Expected format: <number>(s|m|h)`);
|
|
9
|
+
}
|
|
10
|
+
const value = parseFloat(match[1]);
|
|
11
|
+
const unit = match[2];
|
|
12
|
+
switch (unit) {
|
|
13
|
+
case 's': return value * 1000;
|
|
14
|
+
case 'm': return value * 60_000;
|
|
15
|
+
case 'h': return value * 3_600_000;
|
|
16
|
+
default: throw new Error(`Unknown duration unit: "${unit}"`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validatePath(filePath: string, projectRoot: string): string {
|
|
21
|
+
const resolved = resolve(projectRoot, filePath);
|
|
22
|
+
const rel = relative(projectRoot, resolved);
|
|
23
|
+
|
|
24
|
+
if (rel.startsWith('..') || rel.startsWith('/') || /^[a-zA-Z]:/.test(rel)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Security: path "${filePath}" escapes project root. ` +
|
|
27
|
+
`All file references must be within "${projectRoot}".`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SHELL_META_CHARS = /[;&|$`\\!><()\[\]{}*?#~]/;
|
|
35
|
+
|
|
36
|
+
export function validatePathParam(filePath: string): void {
|
|
37
|
+
if (filePath.includes('..')) {
|
|
38
|
+
throw new Error(`Template param type=path: ".." traversal not allowed in "${filePath}"`);
|
|
39
|
+
}
|
|
40
|
+
if (resolve(filePath) === filePath) {
|
|
41
|
+
throw new Error(`Template param type=path: absolute path not allowed: "${filePath}"`);
|
|
42
|
+
}
|
|
43
|
+
if (SHELL_META_CHARS.test(filePath)) {
|
|
44
|
+
throw new Error(`Template param type=path: shell metacharacters not allowed in "${filePath}"`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let runCounter = 0;
|
|
49
|
+
|
|
50
|
+
export function generateRunId(): string {
|
|
51
|
+
const ts = Date.now().toString(36);
|
|
52
|
+
const seq = (runCounter++).toString(36);
|
|
53
|
+
return `run_${ts}_${seq}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function truncateForName(text: string, maxLen = 40): string {
|
|
57
|
+
const first = text.split('\n')[0].trim();
|
|
58
|
+
return first.length > maxLen ? first.slice(0, maxLen) + '...' : first;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function nowISO(): string {
|
|
62
|
+
return new Date().toISOString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ═══ Platform-aware shell ═══
|
|
66
|
+
//
|
|
67
|
+
// Resolution order:
|
|
68
|
+
// 1. Env override: PIPELINE_SHELL="bash" or PIPELINE_SHELL="cmd" etc.
|
|
69
|
+
// 2. Windows: prefer sh (Git Bash / MSYS2) if on PATH, fall back to cmd.exe
|
|
70
|
+
// 3. Unix: sh
|
|
71
|
+
//
|
|
72
|
+
// Resolution is cached once on first call to avoid repeated PATH lookups.
|
|
73
|
+
|
|
74
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
75
|
+
|
|
76
|
+
type ShellKind = 'sh' | 'bash' | 'cmd' | 'powershell';
|
|
77
|
+
let resolvedShell: { kind: ShellKind; path: string } | null = null;
|
|
78
|
+
|
|
79
|
+
function detectShell(): { kind: ShellKind; path: string } {
|
|
80
|
+
// Env override takes precedence
|
|
81
|
+
const override = process.env.PIPELINE_SHELL;
|
|
82
|
+
if (override) {
|
|
83
|
+
const kind = override as ShellKind;
|
|
84
|
+
return { kind, path: override };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!IS_WINDOWS) {
|
|
88
|
+
return { kind: 'sh', path: 'sh' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Windows: probe PATH for sh (bundled with Git for Windows / MSYS2)
|
|
92
|
+
const pathEnv = process.env.PATH ?? '';
|
|
93
|
+
const pathExt = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';');
|
|
94
|
+
const dirs = pathEnv.split(';').filter(Boolean);
|
|
95
|
+
|
|
96
|
+
for (const dir of dirs) {
|
|
97
|
+
for (const ext of ['', ...pathExt]) {
|
|
98
|
+
const candidate = `${dir}\\sh${ext}`;
|
|
99
|
+
try {
|
|
100
|
+
if (Bun.file(candidate).size > 0) {
|
|
101
|
+
return { kind: 'sh', path: candidate };
|
|
102
|
+
}
|
|
103
|
+
} catch { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fallback: cmd.exe (always present on Windows)
|
|
108
|
+
const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
|
|
109
|
+
return { kind: 'cmd', path: `${systemRoot}\\System32\\cmd.exe` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getShell(): { kind: ShellKind; path: string } {
|
|
113
|
+
if (!resolvedShell) resolvedShell = detectShell();
|
|
114
|
+
return resolvedShell;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function shellArgs(command: string): readonly string[] {
|
|
118
|
+
const sh = getShell();
|
|
119
|
+
if (sh.kind === 'cmd') {
|
|
120
|
+
return [sh.path, '/c', command];
|
|
121
|
+
}
|
|
122
|
+
if (sh.kind === 'powershell') {
|
|
123
|
+
return [sh.path, '-Command', command];
|
|
124
|
+
}
|
|
125
|
+
// sh or bash
|
|
126
|
+
return [sh.path, '-c', command];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Quote a single argument for inclusion in a shell command string. */
|
|
130
|
+
function quoteArg(arg: string): string {
|
|
131
|
+
if (!/[\s"'\\<>|&;`$!^%]/.test(arg)) return arg;
|
|
132
|
+
// Double-quote and escape embedded double quotes + backslashes
|
|
133
|
+
return '"' + arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Convert an args array to shell-wrapped args suitable for Bun.spawn.
|
|
138
|
+
* Each arg is quoted as needed, then joined and passed through shellArgs.
|
|
139
|
+
*/
|
|
140
|
+
export function shellArgsFromArray(args: readonly string[]): readonly string[] {
|
|
141
|
+
return shellArgs(args.map(quoteArg).join(' '));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// For tests: allow resetting the cached shell detection
|
|
145
|
+
export function _resetShellCache(): void {
|
|
146
|
+
resolvedShell = null;
|
|
147
|
+
}
|