@tagma/sdk 0.2.7 → 0.2.8
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/package.json +1 -1
- package/src/completions/output-check.ts +12 -0
- package/src/dag.ts +11 -4
- package/src/engine.ts +33 -17
- package/src/hooks.ts +61 -31
- package/src/pipeline-runner.ts +9 -0
- package/src/registry.ts +12 -0
- package/src/runner.ts +12 -0
- package/src/schema.ts +7 -2
- package/src/triggers/file.ts +8 -1
- package/src/triggers/manual.ts +13 -6
- package/src/utils.ts +10 -4
- package/src/validate-raw.ts +10 -5
package/package.json
CHANGED
|
@@ -34,6 +34,17 @@ export const OutputCheckCompletion: CompletionPlugin = {
|
|
|
34
34
|
const controller = new AbortController();
|
|
35
35
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
36
36
|
|
|
37
|
+
// Wire pipeline abort signal into the check process so external abort
|
|
38
|
+
// terminates the child instead of leaving it running undetected.
|
|
39
|
+
const onAbort = () => controller.abort();
|
|
40
|
+
if (ctx.signal) {
|
|
41
|
+
if (ctx.signal.aborted) {
|
|
42
|
+
controller.abort();
|
|
43
|
+
} else {
|
|
44
|
+
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
const proc = Bun.spawn(shellArgs(checkCmd) as string[], {
|
|
38
49
|
cwd: ctx.workDir,
|
|
39
50
|
stdin: 'pipe',
|
|
@@ -69,6 +80,7 @@ export const OutputCheckCompletion: CompletionPlugin = {
|
|
|
69
80
|
return exitCode === 0;
|
|
70
81
|
} finally {
|
|
71
82
|
clearTimeout(timer);
|
|
83
|
+
if (ctx.signal) ctx.signal.removeEventListener('abort', onAbort);
|
|
72
84
|
}
|
|
73
85
|
},
|
|
74
86
|
};
|
package/src/dag.ts
CHANGED
|
@@ -118,8 +118,10 @@ export function buildDag(config: PipelineConfig): Dag {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
const sorted: string[] = [];
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// Use an index pointer instead of shift() to avoid O(n) per dequeue.
|
|
122
|
+
let qi = 0;
|
|
123
|
+
while (qi < queue.length) {
|
|
124
|
+
const current = queue[qi++]!;
|
|
123
125
|
sorted.push(current);
|
|
124
126
|
for (const child of adjacency.get(current)!) {
|
|
125
127
|
const newDegree = inDegree.get(child)! - 1;
|
|
@@ -129,8 +131,13 @@ export function buildDag(config: PipelineConfig): Dag {
|
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
if (sorted.length !== nodes.size) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
// Only report nodes that are actually part of cycles (in-degree > 0
|
|
135
|
+
// after Kahn's algorithm), not their downstream dependents.
|
|
136
|
+
const sortedSet = new Set(sorted);
|
|
137
|
+
const cycleMembers = [...nodes.keys()].filter(id =>
|
|
138
|
+
!sortedSet.has(id) && (inDegree.get(id) ?? 0) > 0
|
|
139
|
+
);
|
|
140
|
+
throw new Error(`Circular dependency detected involving tasks: ${cycleMembers.join(', ')}`);
|
|
134
141
|
}
|
|
135
142
|
|
|
136
143
|
return { nodes, sorted };
|
package/src/engine.ts
CHANGED
|
@@ -88,13 +88,23 @@ function preflight(config: PipelineConfig, dag: Dag): void {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
function resolveRefInDag(dag: Dag, ref: string, fromTrackId: string): string | null {
|
|
91
|
+
// Already fully qualified
|
|
91
92
|
if (dag.nodes.has(ref)) return ref;
|
|
93
|
+
// Same-track match (preferred)
|
|
92
94
|
const sameTrack = `${fromTrackId}.${ref}`;
|
|
93
95
|
if (dag.nodes.has(sameTrack)) return sameTrack;
|
|
96
|
+
// Cross-track bare name lookup — must be unambiguous (aligned with buildDag's resolveRef)
|
|
97
|
+
let match: string | null = null;
|
|
94
98
|
for (const [id] of dag.nodes) {
|
|
95
|
-
if (id.endsWith(`.${ref}`))
|
|
99
|
+
if (id.endsWith(`.${ref}`)) {
|
|
100
|
+
if (match !== null) {
|
|
101
|
+
// Ambiguous: multiple tasks share the bare name across tracks
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
match = id;
|
|
105
|
+
}
|
|
96
106
|
}
|
|
97
|
-
return
|
|
107
|
+
return match;
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
// ═══ Engine ═══
|
|
@@ -187,6 +197,9 @@ export async function runPipeline(
|
|
|
187
197
|
text: record.text,
|
|
188
198
|
});
|
|
189
199
|
});
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
|
|
190
203
|
log.info('[pipeline]', `start "${config.name}" run_id=${runId}`);
|
|
191
204
|
|
|
192
205
|
// File-only: dump the resolved pipeline shape + DAG topology for post-mortem.
|
|
@@ -220,8 +233,6 @@ export async function runPipeline(
|
|
|
220
233
|
});
|
|
221
234
|
}
|
|
222
235
|
|
|
223
|
-
try {
|
|
224
|
-
|
|
225
236
|
// Pipeline start hook (gate)
|
|
226
237
|
const startHook = await executeHook(
|
|
227
238
|
config.hooks, 'pipeline_start', buildPipelineStartContext(pipelineInfo), workDir,
|
|
@@ -274,15 +285,15 @@ export async function runPipeline(
|
|
|
274
285
|
});
|
|
275
286
|
|
|
276
287
|
// Wire external cancel signal into the internal abort controller.
|
|
288
|
+
const externalAbortHandler = () => {
|
|
289
|
+
pipelineAborted = true;
|
|
290
|
+
abortController.abort();
|
|
291
|
+
};
|
|
277
292
|
if (options.signal) {
|
|
278
293
|
if (options.signal.aborted) {
|
|
279
|
-
|
|
280
|
-
abortController.abort();
|
|
294
|
+
externalAbortHandler();
|
|
281
295
|
} else {
|
|
282
|
-
options.signal.addEventListener('abort',
|
|
283
|
-
pipelineAborted = true;
|
|
284
|
-
abortController.abort();
|
|
285
|
-
}, { once: true });
|
|
296
|
+
options.signal.addEventListener('abort', externalAbortHandler, { once: true });
|
|
286
297
|
}
|
|
287
298
|
}
|
|
288
299
|
|
|
@@ -367,7 +378,7 @@ export async function runPipeline(
|
|
|
367
378
|
|
|
368
379
|
async function fireHook(taskId: string, event: 'task_success' | 'task_failure'): Promise<void> {
|
|
369
380
|
await executeHook(config.hooks, event,
|
|
370
|
-
buildTaskContext(event, pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir);
|
|
381
|
+
buildTaskContext(event, pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir, abortController.signal);
|
|
371
382
|
}
|
|
372
383
|
|
|
373
384
|
// ── Process a single task ──
|
|
@@ -429,7 +440,7 @@ export async function runPipeline(
|
|
|
429
440
|
|
|
430
441
|
// 3. task_start hook (gate)
|
|
431
442
|
const hookResult = await executeHook(config.hooks, 'task_start',
|
|
432
|
-
buildTaskContext('task_start', pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir);
|
|
443
|
+
buildTaskContext('task_start', pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir, abortController.signal);
|
|
433
444
|
if (hookResult.exitCode !== 0 || config.hooks?.task_start) {
|
|
434
445
|
log.debug(`[task:${taskId}]`,
|
|
435
446
|
`task_start hook exit=${hookResult.exitCode} allowed=${hookResult.allowed}`);
|
|
@@ -521,7 +532,7 @@ export async function runPipeline(
|
|
|
521
532
|
terminalStatus = 'failed';
|
|
522
533
|
} else if (task.completion) {
|
|
523
534
|
const plugin = getHandler<CompletionPlugin>('completions', task.completion.type);
|
|
524
|
-
const completionCtx = { workDir: task.cwd ?? workDir };
|
|
535
|
+
const completionCtx = { workDir: task.cwd ?? workDir, signal: abortController.signal };
|
|
525
536
|
const passed = await plugin.check(task.completion as Record<string, unknown>, result, completionCtx);
|
|
526
537
|
terminalStatus = passed ? 'success' : 'failed';
|
|
527
538
|
} else {
|
|
@@ -670,6 +681,11 @@ export async function runPipeline(
|
|
|
670
681
|
}
|
|
671
682
|
} finally {
|
|
672
683
|
if (pipelineTimer) clearTimeout(pipelineTimer);
|
|
684
|
+
// Clean up the external abort signal listener to prevent dead references
|
|
685
|
+
// accumulating on long-lived shared AbortControllers.
|
|
686
|
+
if (options.signal) {
|
|
687
|
+
options.signal.removeEventListener('abort', externalAbortHandler);
|
|
688
|
+
}
|
|
673
689
|
// Safety net: drain any approvals still pending at shutdown (e.g. crash path).
|
|
674
690
|
if (approvalGateway.pending().length > 0) {
|
|
675
691
|
approvalGateway.abortAll('pipeline finished');
|
|
@@ -720,10 +736,10 @@ export async function runPipeline(
|
|
|
720
736
|
log.quiet(` ${state.status.padEnd(8)} ${id} (exit=${exit}, ${dur})`);
|
|
721
737
|
}
|
|
722
738
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
739
|
+
log.info('[pipeline]', `completed "${config.name}"`);
|
|
740
|
+
log.info('[pipeline]', `Total: ${summary.total} | Success: ${summary.success} | Failed: ${summary.failed} | Skipped: ${summary.skipped} | Timeout: ${summary.timeout} | Blocked: ${summary.blocked}`);
|
|
741
|
+
log.info('[pipeline]', `Duration: ${(durationMs / 1000).toFixed(1)}s`);
|
|
742
|
+
log.info('[pipeline]', `Log: ${log.path}`);
|
|
727
743
|
|
|
728
744
|
emit({ type: 'pipeline_end', runId, success: allSuccess });
|
|
729
745
|
return { success: allSuccess, runId, logPath: log.path, summary, states: freezeStates(states) };
|
package/src/hooks.ts
CHANGED
|
@@ -18,42 +18,71 @@ function normalizeCommands(cmd: HookCommand | undefined): readonly string[] {
|
|
|
18
18
|
return cmd;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
|
|
22
|
+
|
|
23
|
+
async function runSingleHook(
|
|
24
|
+
command: string,
|
|
25
|
+
context: unknown,
|
|
26
|
+
cwd?: string,
|
|
27
|
+
signal?: AbortSignal,
|
|
28
|
+
timeoutMs: number = DEFAULT_HOOK_TIMEOUT_MS,
|
|
29
|
+
): Promise<number> {
|
|
22
30
|
const jsonInput = JSON.stringify(context, null, 2);
|
|
23
31
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// Process may exit before reading stdin (e.g. `exit 1`), ignore EPIPE
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = timeoutMs > 0
|
|
34
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
35
|
+
: null;
|
|
36
|
+
|
|
37
|
+
// Wire pipeline abort signal into hook process
|
|
38
|
+
const onAbort = () => controller.abort();
|
|
39
|
+
if (signal) {
|
|
40
|
+
if (signal.aborted) {
|
|
41
|
+
controller.abort();
|
|
42
|
+
} else {
|
|
43
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
37
44
|
}
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
try {
|
|
48
|
+
const proc = Bun.spawn(shellArgs(command) as string[], {
|
|
49
|
+
stdin: 'pipe',
|
|
50
|
+
stdout: 'pipe',
|
|
51
|
+
stderr: 'pipe',
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
...(cwd ? { cwd } : {}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (proc.stdin) {
|
|
57
|
+
try {
|
|
58
|
+
proc.stdin.write(jsonInput);
|
|
59
|
+
proc.stdin.end();
|
|
60
|
+
} catch {
|
|
61
|
+
// Process may exit before reading stdin (e.g. `exit 1`), ignore EPIPE
|
|
62
|
+
}
|
|
63
|
+
}
|
|
55
64
|
|
|
56
|
-
|
|
65
|
+
// Consume stdout and stderr concurrently with waiting for exit.
|
|
66
|
+
// Sequential reads after proc.exited risk a pipe-buffer deadlock when
|
|
67
|
+
// hook output exceeds the ~64 KB kernel buffer.
|
|
68
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
69
|
+
proc.exited,
|
|
70
|
+
new Response(proc.stdout).text(),
|
|
71
|
+
new Response(proc.stderr).text(),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
if (stdout.trim()) {
|
|
75
|
+
console.log(`[hook: ${command}] stdout: ${stdout.trim()}`);
|
|
76
|
+
}
|
|
77
|
+
if (stderr.trim()) {
|
|
78
|
+
console.error(`[hook: ${command}] stderr: ${stderr.trim()}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return exitCode;
|
|
82
|
+
} finally {
|
|
83
|
+
if (timer) clearTimeout(timer);
|
|
84
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
85
|
+
}
|
|
57
86
|
}
|
|
58
87
|
|
|
59
88
|
export async function executeHook(
|
|
@@ -61,6 +90,7 @@ export async function executeHook(
|
|
|
61
90
|
event: HookEvent,
|
|
62
91
|
context: unknown,
|
|
63
92
|
workDir?: string,
|
|
93
|
+
signal?: AbortSignal,
|
|
64
94
|
): Promise<HookResult> {
|
|
65
95
|
if (!hooks) return { allowed: true, exitCode: 0 };
|
|
66
96
|
|
|
@@ -70,7 +100,7 @@ export async function executeHook(
|
|
|
70
100
|
const isGate = GATE_HOOKS.has(event);
|
|
71
101
|
|
|
72
102
|
for (const cmd of commands) {
|
|
73
|
-
const exitCode = await runSingleHook(cmd, context, workDir);
|
|
103
|
+
const exitCode = await runSingleHook(cmd, context, workDir, signal);
|
|
74
104
|
|
|
75
105
|
if (isGate && exitCode === 1) {
|
|
76
106
|
// Only exit code 1 has gate semantics (block execution)
|
package/src/pipeline-runner.ts
CHANGED
|
@@ -61,6 +61,15 @@ export class PipelineRunner {
|
|
|
61
61
|
start(): Promise<EngineResult> {
|
|
62
62
|
if (this._result) return this._result;
|
|
63
63
|
|
|
64
|
+
// Guard: if abort() was called before start(), the signal is already
|
|
65
|
+
// aborted. Create a fresh controller so the pipeline doesn't terminate
|
|
66
|
+
// immediately. If users truly want pre-abort semantics, they call
|
|
67
|
+
// abort() after start().
|
|
68
|
+
if (this._abortController.signal.aborted) {
|
|
69
|
+
this._abortController = new AbortController();
|
|
70
|
+
this._status = 'idle';
|
|
71
|
+
}
|
|
72
|
+
|
|
64
73
|
this._status = 'running';
|
|
65
74
|
this._result = runPipeline(this.config, this.workDir, {
|
|
66
75
|
...this.opts,
|
package/src/registry.ts
CHANGED
|
@@ -37,8 +37,20 @@ export function hasHandler(category: PluginCategory, type: string): boolean {
|
|
|
37
37
|
return registries[category].has(type);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Plugin name must be a scoped npm package or a tagma-prefixed package.
|
|
41
|
+
// Reject absolute/relative paths and suspicious patterns to prevent
|
|
42
|
+
// arbitrary code execution via crafted YAML configs.
|
|
43
|
+
const PLUGIN_NAME_RE = /^(@[a-z0-9-]+\/[a-z0-9._-]+|tagma-plugin-[a-z0-9._-]+)$/;
|
|
44
|
+
|
|
40
45
|
export async function loadPlugins(pluginNames: readonly string[]): Promise<void> {
|
|
41
46
|
for (const name of pluginNames) {
|
|
47
|
+
if (!PLUGIN_NAME_RE.test(name)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
|
|
50
|
+
`(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
|
|
51
|
+
`Relative/absolute paths are not allowed.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
42
54
|
const mod = await import(name);
|
|
43
55
|
if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
|
|
44
56
|
throw new Error(
|
package/src/runner.ts
CHANGED
|
@@ -49,8 +49,18 @@ export interface RunOptions {
|
|
|
49
49
|
* Returns the original name if resolution fails; Bun will raise the same
|
|
50
50
|
* ENOENT it would have otherwise.
|
|
51
51
|
*/
|
|
52
|
+
const RESOLVED_EXE_CACHE_MAX = 128;
|
|
52
53
|
const resolvedExeCache = new Map<string, string | null>();
|
|
53
54
|
|
|
55
|
+
/** Evict the oldest entry when the cache is at capacity. */
|
|
56
|
+
function evictIfFull(): void {
|
|
57
|
+
if (resolvedExeCache.size >= RESOLVED_EXE_CACHE_MAX) {
|
|
58
|
+
// Map iteration order is insertion order — delete the first (oldest) key.
|
|
59
|
+
const oldest = resolvedExeCache.keys().next().value;
|
|
60
|
+
if (oldest !== undefined) resolvedExeCache.delete(oldest);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
function resolveWindowsExe(
|
|
55
65
|
args: readonly string[],
|
|
56
66
|
envPath: string,
|
|
@@ -80,11 +90,13 @@ function resolveWindowsExe(
|
|
|
80
90
|
for (const ext of exts) {
|
|
81
91
|
const candidate = join(dir, cmd + ext);
|
|
82
92
|
if (existsSync(candidate)) {
|
|
93
|
+
evictIfFull();
|
|
83
94
|
resolvedExeCache.set(cacheKey, candidate);
|
|
84
95
|
return [candidate, ...args.slice(1)];
|
|
85
96
|
}
|
|
86
97
|
}
|
|
87
98
|
}
|
|
99
|
+
evictIfFull();
|
|
88
100
|
resolvedExeCache.set(cacheKey, null);
|
|
89
101
|
return args;
|
|
90
102
|
}
|
package/src/schema.ts
CHANGED
|
@@ -198,10 +198,15 @@ function expandTemplateTask(
|
|
|
198
198
|
newTask.continue_from = `${instanceId}.${task.continue_from}`;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
// Rewrite output path to instance namespace
|
|
201
|
+
// Rewrite output path to instance namespace so parallel template
|
|
202
|
+
// instances don't collide on the same file. Handles any relative path
|
|
203
|
+
// (e.g. ./tmp/foo, ./output/bar, ./build/result.json) by injecting
|
|
204
|
+
// the instanceId as the first directory component after `./`.
|
|
202
205
|
if (task.output) {
|
|
203
206
|
const original = interpolate(task.output);
|
|
204
|
-
newTask.output = original.
|
|
207
|
+
newTask.output = original.startsWith('./')
|
|
208
|
+
? `./${instanceId}/${original.slice(2)}`
|
|
209
|
+
: `${instanceId}/${original}`;
|
|
205
210
|
}
|
|
206
211
|
|
|
207
212
|
return newTask as unknown as RawTaskConfig;
|
package/src/triggers/file.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { watch } from 'chokidar';
|
|
2
2
|
import { resolve, dirname } from 'path';
|
|
3
|
+
import { mkdir } from 'fs/promises';
|
|
3
4
|
import type { TriggerPlugin, TriggerContext } from '../types';
|
|
4
5
|
import { parseDuration, validatePath } from '../utils';
|
|
5
6
|
|
|
@@ -35,7 +36,7 @@ export const FileTrigger: TriggerPlugin = {
|
|
|
35
36
|
const safePath = validatePath(filePath, ctx.workDir);
|
|
36
37
|
const timeoutMs = config.timeout != null ? parseDuration(String(config.timeout)) : 0;
|
|
37
38
|
|
|
38
|
-
return new Promise((resolve_p, reject) => {
|
|
39
|
+
return new Promise(async (resolve_p, reject) => {
|
|
39
40
|
if (ctx.signal.aborted) {
|
|
40
41
|
reject(new Error('Pipeline aborted'));
|
|
41
42
|
return;
|
|
@@ -44,7 +45,13 @@ export const FileTrigger: TriggerPlugin = {
|
|
|
44
45
|
let settled = false;
|
|
45
46
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
46
47
|
|
|
48
|
+
// Ensure the parent directory exists so the watcher doesn't fail
|
|
49
|
+
// with ENOENT for nested paths like `build/output/result.json`.
|
|
47
50
|
const dir = dirname(safePath);
|
|
51
|
+
try {
|
|
52
|
+
await mkdir(dir, { recursive: true });
|
|
53
|
+
} catch { /* best effort — dir may already exist */ }
|
|
54
|
+
|
|
48
55
|
const watcher = watch(dir, {
|
|
49
56
|
ignoreInitial: true,
|
|
50
57
|
depth: 0,
|
package/src/triggers/manual.ts
CHANGED
|
@@ -41,19 +41,26 @@ export const ManualTrigger: TriggerPlugin = {
|
|
|
41
41
|
// so instead we race against an abort promise and let engine status logic
|
|
42
42
|
// fall back to pipelineAborted → skipped. abortAll() on gateway still runs
|
|
43
43
|
// from engine shutdown path to clean up any truly-pending entries.
|
|
44
|
+
const onAbort = () => {};
|
|
44
45
|
const abortPromise = new Promise<never>((_, reject) => {
|
|
45
46
|
if (ctx.signal.aborted) {
|
|
46
47
|
reject(new Error('Pipeline aborted'));
|
|
47
48
|
return;
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
);
|
|
50
|
+
const handler = () => reject(new Error('Pipeline aborted'));
|
|
51
|
+
// Store reference so we can remove it after the race settles.
|
|
52
|
+
(onAbort as { handler?: () => void }).handler = handler;
|
|
53
|
+
ctx.signal.addEventListener('abort', handler, { once: true });
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
let decision: Awaited<typeof decisionPromise>;
|
|
57
|
+
try {
|
|
58
|
+
decision = await Promise.race([decisionPromise, abortPromise]);
|
|
59
|
+
} finally {
|
|
60
|
+
// Clean up the abort listener to prevent leaking on normal completion.
|
|
61
|
+
const handler = (onAbort as { handler?: () => void }).handler;
|
|
62
|
+
if (handler) ctx.signal.removeEventListener('abort', handler);
|
|
63
|
+
}
|
|
57
64
|
|
|
58
65
|
switch (decision.outcome) {
|
|
59
66
|
case 'approved':
|
package/src/utils.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { resolve, relative } from 'path';
|
|
2
2
|
import { randomBytes } from 'crypto';
|
|
3
3
|
|
|
4
|
-
const DURATION_RE = /^(\d
|
|
4
|
+
const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
|
|
5
5
|
|
|
6
6
|
export function parseDuration(input: string): number {
|
|
7
7
|
const match = DURATION_RE.exec(input.trim());
|
|
8
8
|
if (!match) {
|
|
9
|
-
throw new Error(`Invalid duration format: "${input}". Expected format: <number>(s|m|h)`);
|
|
9
|
+
throw new Error(`Invalid duration format: "${input}". Expected format: <number>(s|m|h|d)`);
|
|
10
10
|
}
|
|
11
11
|
const value = parseFloat(match[1]);
|
|
12
12
|
const unit = match[2];
|
|
@@ -14,6 +14,7 @@ export function parseDuration(input: string): number {
|
|
|
14
14
|
case 's': return value * 1000;
|
|
15
15
|
case 'm': return value * 60_000;
|
|
16
16
|
case 'h': return value * 3_600_000;
|
|
17
|
+
case 'd': return value * 86_400_000;
|
|
17
18
|
default: throw new Error(`Unknown duration unit: "${unit}"`);
|
|
18
19
|
}
|
|
19
20
|
}
|
|
@@ -136,8 +137,13 @@ export function shellArgs(command: string): readonly string[] {
|
|
|
136
137
|
/** Quote a single argument for inclusion in a shell command string. */
|
|
137
138
|
function quoteArg(arg: string): string {
|
|
138
139
|
if (!/[\s"'\\<>|&;`$!^%]/.test(arg)) return arg;
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
if (IS_WINDOWS) {
|
|
141
|
+
// On Windows (cmd.exe), double-quote and escape embedded quotes + backslashes
|
|
142
|
+
return '"' + arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
143
|
+
}
|
|
144
|
+
// On Unix, use single quotes to prevent $variable expansion.
|
|
145
|
+
// Escape embedded single quotes via the '\'' idiom.
|
|
146
|
+
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
/**
|
package/src/validate-raw.ts
CHANGED
|
@@ -201,10 +201,13 @@ function detectCycles(
|
|
|
201
201
|
// Canonical key = sorted node list joined — order-independent fingerprint.
|
|
202
202
|
const seenCycles = new Set<string>();
|
|
203
203
|
|
|
204
|
-
|
|
204
|
+
// Use a mutable path array instead of copying at each level (O(n) vs O(n^2)).
|
|
205
|
+
const pathStack: string[] = [];
|
|
206
|
+
|
|
207
|
+
function dfs(id: string): void {
|
|
205
208
|
if (inStack.has(id)) {
|
|
206
|
-
const cycleStart =
|
|
207
|
-
const cycleNodes = [...
|
|
209
|
+
const cycleStart = pathStack.indexOf(id);
|
|
210
|
+
const cycleNodes = [...pathStack.slice(cycleStart), id];
|
|
208
211
|
const key = [...cycleNodes].sort().join(',');
|
|
209
212
|
if (!seenCycles.has(key)) {
|
|
210
213
|
seenCycles.add(key);
|
|
@@ -215,14 +218,16 @@ function detectCycles(
|
|
|
215
218
|
if (visited.has(id)) return;
|
|
216
219
|
visited.add(id);
|
|
217
220
|
inStack.add(id);
|
|
221
|
+
pathStack.push(id);
|
|
218
222
|
for (const dep of adj.get(id) ?? []) {
|
|
219
|
-
dfs(dep
|
|
223
|
+
dfs(dep);
|
|
220
224
|
}
|
|
225
|
+
pathStack.pop();
|
|
221
226
|
inStack.delete(id);
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
for (const id of adj.keys()) {
|
|
225
|
-
if (!visited.has(id)) dfs(id
|
|
230
|
+
if (!visited.has(id)) dfs(id);
|
|
226
231
|
}
|
|
227
232
|
|
|
228
233
|
return errors;
|