@tagma/sdk 0.7.1 → 0.7.4
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 +109 -48
- package/dist/adapters/stdin-approval.d.ts +1 -5
- package/dist/adapters/stdin-approval.d.ts.map +1 -1
- package/dist/adapters/stdin-approval.js +1 -89
- package/dist/adapters/stdin-approval.js.map +1 -1
- package/dist/adapters/websocket-approval.d.ts +1 -27
- package/dist/adapters/websocket-approval.d.ts.map +1 -1
- package/dist/adapters/websocket-approval.js +1 -146
- package/dist/adapters/websocket-approval.js.map +1 -1
- package/dist/approval.d.ts +2 -12
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +1 -90
- package/dist/approval.js.map +1 -1
- package/dist/bootstrap.d.ts +21 -1
- 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 +24 -37
- package/dist/core/task-executor.js.map +1 -1
- package/dist/engine.d.ts +8 -53
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +7 -294
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +2 -60
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +1 -153
- package/dist/logger.js.map +1 -1
- package/dist/plugins.d.ts +3 -3
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +1 -1
- package/dist/plugins.js.map +1 -1
- package/dist/registry.d.ts +2 -60
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -253
- package/dist/registry.js.map +1 -1
- package/dist/runner.d.ts +1 -35
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +1 -610
- package/dist/runner.js.map +1 -1
- package/dist/runtime/adapters/stdin-approval.d.ts +2 -0
- package/dist/runtime/adapters/stdin-approval.d.ts.map +1 -0
- package/dist/runtime/adapters/stdin-approval.js +2 -0
- package/dist/runtime/adapters/stdin-approval.js.map +1 -0
- package/dist/runtime/adapters/websocket-approval.d.ts +2 -0
- package/dist/runtime/adapters/websocket-approval.d.ts.map +1 -0
- package/dist/runtime/adapters/websocket-approval.js +2 -0
- package/dist/runtime/adapters/websocket-approval.js.map +1 -0
- package/dist/runtime/bun-process-runner.d.ts +2 -0
- package/dist/runtime/bun-process-runner.d.ts.map +1 -0
- package/dist/runtime/bun-process-runner.js +2 -0
- package/dist/runtime/bun-process-runner.js.map +1 -0
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +2 -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 +13 -4
- package/dist/tagma.d.ts.map +1 -1
- package/dist/tagma.js +7 -2
- package/dist/tagma.js.map +1 -1
- package/dist/triggers/file.d.ts.map +1 -1
- package/dist/triggers/file.js +74 -107
- package/dist/triggers/file.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 +15 -4
- package/src/adapters/stdin-approval.ts +1 -106
- package/src/adapters/websocket-approval.ts +1 -224
- package/src/approval.ts +5 -127
- package/src/bootstrap.ts +24 -15
- package/src/core/run-context.test.ts +47 -0
- package/src/core/run-context.ts +4 -0
- package/src/core/task-executor.ts +28 -45
- package/src/engine-ports-mixed.test.ts +70 -44
- package/src/engine-ports.test.ts +77 -33
- package/src/engine.ts +21 -439
- package/src/index.ts +7 -4
- package/src/logger.ts +2 -182
- package/src/package-split.test.ts +15 -0
- package/src/pipeline-runner.test.ts +65 -12
- package/src/plugin-registry.test.ts +207 -4
- package/src/plugins.ts +6 -3
- package/src/registry.ts +7 -298
- package/src/runner.ts +1 -666
- package/src/runtime/adapters/stdin-approval.ts +1 -0
- package/src/runtime/adapters/websocket-approval.ts +1 -0
- package/src/runtime/bun-process-runner.ts +1 -0
- package/src/runtime-adapters.test.ts +10 -0
- package/src/runtime.ts +12 -0
- package/src/schema-ports.test.ts +23 -0
- package/src/schema.ts +1 -7
- package/src/tagma.test.ts +234 -1
- package/src/tagma.ts +24 -4
- package/src/triggers/file.test.ts +79 -0
- package/src/triggers/file.ts +85 -118
- package/src/validate-raw.ts +1 -117
package/src/runtime.ts
ADDED
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));
|
|
@@ -20,7 +21,239 @@ function makeDriver(name: string, marker: string[]): DriverPlugin {
|
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
function memoryLogStore() {
|
|
25
|
+
return {
|
|
26
|
+
openRunLog({ runId }: { runId: string }) {
|
|
27
|
+
return {
|
|
28
|
+
path: `mem://${runId}/pipeline.log`,
|
|
29
|
+
dir: `mem://${runId}`,
|
|
30
|
+
append() {
|
|
31
|
+
/* memory sink */
|
|
32
|
+
},
|
|
33
|
+
close() {
|
|
34
|
+
/* memory sink */
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
taskOutputPath({
|
|
39
|
+
runId,
|
|
40
|
+
taskId,
|
|
41
|
+
stream,
|
|
42
|
+
}: {
|
|
43
|
+
runId: string;
|
|
44
|
+
taskId: string;
|
|
45
|
+
stream: 'stdout' | 'stderr';
|
|
46
|
+
}) {
|
|
47
|
+
return `mem://${runId}/${taskId}.${stream}`;
|
|
48
|
+
},
|
|
49
|
+
logsDir(workDir: string) {
|
|
50
|
+
return `mem://${workDir}/logs`;
|
|
51
|
+
},
|
|
52
|
+
async prune() {
|
|
53
|
+
/* memory sink */
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
describe('createTagma', () => {
|
|
59
|
+
test('runs command tasks through the configured runtime', async () => {
|
|
60
|
+
const calls: string[] = [];
|
|
61
|
+
const taskResult: TaskResult = {
|
|
62
|
+
exitCode: 0,
|
|
63
|
+
stdout: 'runtime-ok',
|
|
64
|
+
stderr: '',
|
|
65
|
+
stdoutPath: null,
|
|
66
|
+
stderrPath: null,
|
|
67
|
+
stdoutBytes: 10,
|
|
68
|
+
stderrBytes: 0,
|
|
69
|
+
durationMs: 1,
|
|
70
|
+
sessionId: null,
|
|
71
|
+
normalizedOutput: null,
|
|
72
|
+
failureKind: null,
|
|
73
|
+
};
|
|
74
|
+
const runtime: TagmaRuntime = {
|
|
75
|
+
async runCommand(command, cwd) {
|
|
76
|
+
calls.push(`${cwd}:${command}`);
|
|
77
|
+
return taskResult;
|
|
78
|
+
},
|
|
79
|
+
async runSpawn() {
|
|
80
|
+
throw new Error('runSpawn should not be called for command tasks');
|
|
81
|
+
},
|
|
82
|
+
async ensureDir() {
|
|
83
|
+
/* no-op */
|
|
84
|
+
},
|
|
85
|
+
async fileExists() {
|
|
86
|
+
return false;
|
|
87
|
+
},
|
|
88
|
+
async *watch() {
|
|
89
|
+
/* no-op */
|
|
90
|
+
},
|
|
91
|
+
logStore: memoryLogStore(),
|
|
92
|
+
now() {
|
|
93
|
+
return new Date('2026-04-26T00:00:00.000Z');
|
|
94
|
+
},
|
|
95
|
+
sleep() {
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const tagma = createTagma({ builtins: false, runtime });
|
|
100
|
+
const dir = makeDir('tagma-runtime-run-');
|
|
101
|
+
try {
|
|
102
|
+
const result = await tagma.run(
|
|
103
|
+
{
|
|
104
|
+
name: 'runtime-run',
|
|
105
|
+
tracks: [
|
|
106
|
+
{
|
|
107
|
+
id: 't',
|
|
108
|
+
name: 'T',
|
|
109
|
+
tasks: [{ id: 'cmd', name: 'cmd', command: 'fake-only-command' }],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
cwd: dir,
|
|
115
|
+
skipPluginLoading: true,
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(result.success).toBe(true);
|
|
120
|
+
expect(calls).toEqual([`${dir}:fake-only-command`]);
|
|
121
|
+
expect(result.states.get('t.cmd')?.result?.stdout).toBe('runtime-ok');
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('routes run logs and task output artifacts through the runtime log store', async () => {
|
|
128
|
+
const calls: string[] = [];
|
|
129
|
+
let stdoutPath: string | undefined;
|
|
130
|
+
let stderrPath: string | undefined;
|
|
131
|
+
|
|
132
|
+
const runtime = {
|
|
133
|
+
async runCommand(_command: string, _cwd: string, options?: { stdoutPath?: string; stderrPath?: string }) {
|
|
134
|
+
stdoutPath = options?.stdoutPath;
|
|
135
|
+
stderrPath = options?.stderrPath;
|
|
136
|
+
return {
|
|
137
|
+
exitCode: 0,
|
|
138
|
+
stdout: 'runtime-log-ok',
|
|
139
|
+
stderr: '',
|
|
140
|
+
stdoutPath: options?.stdoutPath ?? null,
|
|
141
|
+
stderrPath: options?.stderrPath ?? null,
|
|
142
|
+
stdoutBytes: 14,
|
|
143
|
+
stderrBytes: 0,
|
|
144
|
+
durationMs: 1,
|
|
145
|
+
sessionId: null,
|
|
146
|
+
normalizedOutput: null,
|
|
147
|
+
failureKind: null,
|
|
148
|
+
} satisfies TaskResult;
|
|
149
|
+
},
|
|
150
|
+
async runSpawn() {
|
|
151
|
+
throw new Error('runSpawn should not be called for command tasks');
|
|
152
|
+
},
|
|
153
|
+
async ensureDir(path: string) {
|
|
154
|
+
calls.push(`ensure:${path}`);
|
|
155
|
+
},
|
|
156
|
+
async fileExists(path: string) {
|
|
157
|
+
calls.push(`exists:${path}`);
|
|
158
|
+
return false;
|
|
159
|
+
},
|
|
160
|
+
async *watch(path: string) {
|
|
161
|
+
calls.push(`watch:${path}`);
|
|
162
|
+
},
|
|
163
|
+
now() {
|
|
164
|
+
return new Date('2026-04-26T00:00:00.000Z');
|
|
165
|
+
},
|
|
166
|
+
sleep(ms: number) {
|
|
167
|
+
calls.push(`sleep:${ms}`);
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
},
|
|
170
|
+
logStore: {
|
|
171
|
+
openRunLog({ runId, header }: { runId: string; header: string }) {
|
|
172
|
+
calls.push(`open:${runId}:${header.includes(runId)}`);
|
|
173
|
+
return {
|
|
174
|
+
path: `mem://${runId}/pipeline.log`,
|
|
175
|
+
dir: `mem://${runId}`,
|
|
176
|
+
append(line: string) {
|
|
177
|
+
calls.push(`append:${line.length > 0}`);
|
|
178
|
+
},
|
|
179
|
+
close() {
|
|
180
|
+
calls.push(`close:${runId}`);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
taskOutputPath({
|
|
185
|
+
runId,
|
|
186
|
+
taskId,
|
|
187
|
+
stream,
|
|
188
|
+
}: {
|
|
189
|
+
runId: string;
|
|
190
|
+
taskId: string;
|
|
191
|
+
stream: 'stdout' | 'stderr';
|
|
192
|
+
}) {
|
|
193
|
+
calls.push(`task-output:${taskId}:${stream}`);
|
|
194
|
+
return `mem://${runId}/${taskId}.${stream}`;
|
|
195
|
+
},
|
|
196
|
+
logsDir(workDir: string) {
|
|
197
|
+
calls.push(`logs-dir:${workDir}`);
|
|
198
|
+
return `mem://${workDir}/logs`;
|
|
199
|
+
},
|
|
200
|
+
async prune({ keep, excludeRunId }: { keep: number; excludeRunId: string }) {
|
|
201
|
+
calls.push(`prune:${keep}:${excludeRunId}`);
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
} as unknown as TagmaRuntime;
|
|
205
|
+
|
|
206
|
+
const tagma = createTagma({ builtins: false, runtime });
|
|
207
|
+
const dir = makeDir('tagma-runtime-log-store-');
|
|
208
|
+
try {
|
|
209
|
+
const result = await tagma.run(
|
|
210
|
+
{
|
|
211
|
+
name: 'runtime-log-store',
|
|
212
|
+
tracks: [
|
|
213
|
+
{
|
|
214
|
+
id: 't',
|
|
215
|
+
name: 'T',
|
|
216
|
+
tasks: [{ id: 'cmd', name: 'cmd', command: 'fake-only-command' }],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
cwd: dir,
|
|
222
|
+
skipPluginLoading: true,
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(result.success).toBe(true);
|
|
227
|
+
expect(result.logPath).toMatch(/^mem:\/\/run_.+\/pipeline\.log$/);
|
|
228
|
+
expect(stdoutPath).toMatch(/^mem:\/\/run_.+\/t\.cmd\.stdout$/);
|
|
229
|
+
expect(stderrPath).toMatch(/^mem:\/\/run_.+\/t\.cmd\.stderr$/);
|
|
230
|
+
expect(calls.some((call) => call.startsWith('open:run_'))).toBe(true);
|
|
231
|
+
expect(calls).toContain('task-output:t.cmd:stdout');
|
|
232
|
+
expect(calls).toContain('task-output:t.cmd:stderr');
|
|
233
|
+
expect(calls.some((call) => call.startsWith('prune:20:run_'))).toBe(true);
|
|
234
|
+
} finally {
|
|
235
|
+
rmSync(dir, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('registers capability plugins passed to options', () => {
|
|
240
|
+
const seen: string[] = [];
|
|
241
|
+
const driver = makeDriver('driver-plugin', seen);
|
|
242
|
+
const plugin: TagmaPlugin = {
|
|
243
|
+
name: 'tagma-plugin-local',
|
|
244
|
+
capabilities: {
|
|
245
|
+
drivers: {
|
|
246
|
+
mock: driver,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const tagma = createTagma({ builtins: false, plugins: [plugin] });
|
|
252
|
+
|
|
253
|
+
expect(tagma.registry.getHandler<DriverPlugin>('drivers', 'mock')).toBe(driver);
|
|
254
|
+
expect(seen).toEqual([]);
|
|
255
|
+
});
|
|
256
|
+
|
|
24
257
|
test('instances own isolated plugin registries', () => {
|
|
25
258
|
const seenA: string[] = [];
|
|
26
259
|
const seenB: string[] = [];
|
package/src/tagma.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
PluginRegistry,
|
|
3
|
+
runPipeline,
|
|
4
|
+
type EngineResult,
|
|
5
|
+
type RunPipelineOptions,
|
|
6
|
+
} from '@tagma/core';
|
|
2
7
|
import { bootstrapBuiltins } from './bootstrap';
|
|
3
|
-
import { PluginRegistry } from './registry';
|
|
4
8
|
import { validateConfig } from './schema';
|
|
5
|
-
import
|
|
9
|
+
import { bunRuntime } from '@tagma/runtime-bun';
|
|
10
|
+
import type { TagmaRuntime } from '@tagma/core';
|
|
11
|
+
import type { PipelineConfig, TagmaPlugin } from './types';
|
|
6
12
|
|
|
7
13
|
export interface CreateTagmaOptions {
|
|
8
14
|
/**
|
|
@@ -14,9 +20,18 @@ export interface CreateTagmaOptions {
|
|
|
14
20
|
* instance registry. Defaults to true.
|
|
15
21
|
*/
|
|
16
22
|
readonly builtins?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Package-level capability plugins to register into this SDK instance.
|
|
25
|
+
*/
|
|
26
|
+
readonly plugins?: readonly TagmaPlugin[];
|
|
27
|
+
/**
|
|
28
|
+
* Runtime implementation used for command and driver process execution.
|
|
29
|
+
* Defaults to the SDK's Bun runtime.
|
|
30
|
+
*/
|
|
31
|
+
readonly runtime?: TagmaRuntime;
|
|
17
32
|
}
|
|
18
33
|
|
|
19
|
-
export interface TagmaRunOptions extends Omit<RunPipelineOptions, 'registry'> {
|
|
34
|
+
export interface TagmaRunOptions extends Omit<RunPipelineOptions, 'registry' | 'runtime'> {
|
|
20
35
|
readonly cwd: string;
|
|
21
36
|
}
|
|
22
37
|
|
|
@@ -28,9 +43,13 @@ export interface Tagma {
|
|
|
28
43
|
|
|
29
44
|
export function createTagma(options: CreateTagmaOptions = {}): Tagma {
|
|
30
45
|
const registry = options.registry ?? new PluginRegistry();
|
|
46
|
+
const runtime = options.runtime ?? bunRuntime();
|
|
31
47
|
if (options.builtins !== false) {
|
|
32
48
|
bootstrapBuiltins(registry);
|
|
33
49
|
}
|
|
50
|
+
for (const plugin of options.plugins ?? []) {
|
|
51
|
+
registry.registerTagmaPlugin(plugin);
|
|
52
|
+
}
|
|
34
53
|
|
|
35
54
|
return {
|
|
36
55
|
registry,
|
|
@@ -38,6 +57,7 @@ export function createTagma(options: CreateTagmaOptions = {}): Tagma {
|
|
|
38
57
|
return runPipeline(config, cwd, {
|
|
39
58
|
...runOptions,
|
|
40
59
|
registry,
|
|
60
|
+
runtime,
|
|
41
61
|
});
|
|
42
62
|
},
|
|
43
63
|
validate(config) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { InMemoryApprovalGateway } from '../approval';
|
|
6
|
+
import { FileTrigger } from './file';
|
|
7
|
+
import type { TagmaRuntime } from '../runtime';
|
|
8
|
+
|
|
9
|
+
function makeDir(prefix: string): string {
|
|
10
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('FileTrigger runtime boundary', () => {
|
|
14
|
+
test('uses ctx.runtime watch APIs instead of direct chokidar or Bun file APIs', async () => {
|
|
15
|
+
const dir = makeDir('tagma-file-trigger-runtime-');
|
|
16
|
+
const calls: string[] = [];
|
|
17
|
+
const runtime = {
|
|
18
|
+
async runCommand() {
|
|
19
|
+
throw new Error('runCommand should not be called by FileTrigger');
|
|
20
|
+
},
|
|
21
|
+
async runSpawn() {
|
|
22
|
+
throw new Error('runSpawn should not be called by FileTrigger');
|
|
23
|
+
},
|
|
24
|
+
async ensureDir(path: string) {
|
|
25
|
+
calls.push(`ensure:${path}`);
|
|
26
|
+
},
|
|
27
|
+
async fileExists(path: string) {
|
|
28
|
+
calls.push(`exists:${path}`);
|
|
29
|
+
return false;
|
|
30
|
+
},
|
|
31
|
+
async *watch(path: string, options?: { cwd?: string }) {
|
|
32
|
+
calls.push(`watch:${path}:${options?.cwd ?? ''}`);
|
|
33
|
+
yield { type: 'ready', path: '' };
|
|
34
|
+
yield { type: 'add', path: 'target.txt' };
|
|
35
|
+
},
|
|
36
|
+
now() {
|
|
37
|
+
return new Date('2026-04-26T00:00:00.000Z');
|
|
38
|
+
},
|
|
39
|
+
sleep() {
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
},
|
|
42
|
+
logStore: {
|
|
43
|
+
openRunLog() {
|
|
44
|
+
throw new Error('logStore should not be called by FileTrigger');
|
|
45
|
+
},
|
|
46
|
+
taskOutputPath() {
|
|
47
|
+
throw new Error('logStore should not be called by FileTrigger');
|
|
48
|
+
},
|
|
49
|
+
logsDir() {
|
|
50
|
+
throw new Error('logStore should not be called by FileTrigger');
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
} as unknown as TagmaRuntime;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await expect(
|
|
57
|
+
FileTrigger.watch(
|
|
58
|
+
{ type: 'file', path: 'target.txt', timeout: '0.05s' },
|
|
59
|
+
{
|
|
60
|
+
taskId: 't.wait',
|
|
61
|
+
trackId: 't',
|
|
62
|
+
workDir: dir,
|
|
63
|
+
signal: new AbortController().signal,
|
|
64
|
+
approvalGateway: new InMemoryApprovalGateway(),
|
|
65
|
+
runtime,
|
|
66
|
+
} as never,
|
|
67
|
+
),
|
|
68
|
+
).resolves.toEqual({ path: resolve(dir, 'target.txt') });
|
|
69
|
+
|
|
70
|
+
expect(calls).toEqual([
|
|
71
|
+
`ensure:${dir}`,
|
|
72
|
+
`watch:${dir}:${dir}`,
|
|
73
|
+
`exists:${resolve(dir, 'target.txt')}`,
|
|
74
|
+
]);
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/triggers/file.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { watch } from 'chokidar';
|
|
2
1
|
import { resolve, dirname } from 'path';
|
|
3
|
-
import { mkdir } from 'fs/promises';
|
|
4
2
|
import type { TriggerPlugin, TriggerContext } from '../types';
|
|
5
3
|
import { parseDuration, validatePath } from '../utils';
|
|
6
|
-
import { TriggerTimeoutError } from '../
|
|
4
|
+
import { TriggerTimeoutError } from '../core/trigger-errors';
|
|
7
5
|
|
|
8
6
|
const IS_WINDOWS = process.platform === 'win32';
|
|
9
7
|
|
|
@@ -37,128 +35,97 @@ export const FileTrigger: TriggerPlugin = {
|
|
|
37
35
|
const safePath = validatePath(filePath, ctx.workDir);
|
|
38
36
|
const timeoutMs = config.timeout != null ? parseDuration(String(config.timeout)) : 0;
|
|
39
37
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
async function start(
|
|
44
|
-
resolve_p: (value: unknown) => void,
|
|
45
|
-
reject: (reason?: unknown) => void,
|
|
46
|
-
): Promise<void> {
|
|
47
|
-
if (ctx.signal.aborted) {
|
|
48
|
-
reject(new Error('Pipeline aborted'));
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
let settled = false;
|
|
53
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
54
|
-
|
|
55
|
-
// Ensure the parent directory exists so the watcher doesn't fail
|
|
56
|
-
// with ENOENT for nested paths like `build/output/result.json`.
|
|
57
|
-
const dir = dirname(safePath);
|
|
58
|
-
try {
|
|
59
|
-
await mkdir(dir, { recursive: true });
|
|
60
|
-
} catch {
|
|
61
|
-
/* best effort — dir may already exist */
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Pass `cwd: dir` so chokidar resolves paths relative to the watched
|
|
65
|
-
// directory. The 'add'/'change' events will then carry paths relative
|
|
66
|
-
// to `dir`, which we resolve with `resolve(dir, addedPath)` for an
|
|
67
|
-
// accurate absolute comparison — fixing the ambiguous process.cwd()
|
|
68
|
-
// resolution of the previous implementation.
|
|
69
|
-
const watcher = watch(dir, {
|
|
70
|
-
ignoreInitial: true,
|
|
71
|
-
depth: 0,
|
|
72
|
-
cwd: dir,
|
|
73
|
-
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const cleanup = () => {
|
|
77
|
-
if (settled) return;
|
|
78
|
-
settled = true;
|
|
79
|
-
watcher.close().catch(() => {
|
|
80
|
-
/* ignore */
|
|
81
|
-
});
|
|
82
|
-
if (timer) clearTimeout(timer);
|
|
83
|
-
ctx.signal.removeEventListener('abort', onAbort);
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const onAbort = () => {
|
|
87
|
-
cleanup();
|
|
88
|
-
reject(new Error('Pipeline aborted'));
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
watcher.on('add', (addedPath: string) => {
|
|
92
|
-
if (settled) return;
|
|
93
|
-
if (pathsEqual(resolve(dir, addedPath), safePath)) {
|
|
94
|
-
cleanup();
|
|
95
|
-
resolve_p({ path: safePath });
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// Also fire on 'change' so that overwriting an existing file is detected.
|
|
100
|
-
// Without this, upstream tasks that truncate-and-rewrite a file emit only
|
|
101
|
-
// a 'change' event and the downstream trigger would never resolve.
|
|
102
|
-
watcher.on('change', (changedPath: string) => {
|
|
103
|
-
if (settled) return;
|
|
104
|
-
if (pathsEqual(resolve(dir, changedPath), safePath)) {
|
|
105
|
-
cleanup();
|
|
106
|
-
resolve_p({ path: safePath });
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
watcher.on('error', (err: unknown) => {
|
|
111
|
-
if (settled) return;
|
|
112
|
-
cleanup();
|
|
113
|
-
reject(
|
|
114
|
-
new Error(
|
|
115
|
-
`file trigger watch error: ${err instanceof Error ? err.message : String(err)}`,
|
|
116
|
-
),
|
|
117
|
-
);
|
|
118
|
-
});
|
|
38
|
+
return waitForFile({ filePath, safePath, timeoutMs, timeoutLabel: config.timeout, ctx });
|
|
39
|
+
},
|
|
40
|
+
};
|
|
119
41
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
42
|
+
async function waitForFile(options: {
|
|
43
|
+
readonly filePath: string;
|
|
44
|
+
readonly safePath: string;
|
|
45
|
+
readonly timeoutMs: number;
|
|
46
|
+
readonly timeoutLabel: unknown;
|
|
47
|
+
readonly ctx: TriggerContext;
|
|
48
|
+
}): Promise<unknown> {
|
|
49
|
+
const { filePath, safePath, timeoutMs, timeoutLabel, ctx } = options;
|
|
50
|
+
if (ctx.signal.aborted) throw new Error('Pipeline aborted');
|
|
51
|
+
|
|
52
|
+
const dir = dirname(safePath);
|
|
53
|
+
await ctx.runtime.ensureDir(dir).catch(() => {
|
|
54
|
+
/* best effort; runtime watch will surface real failures */
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const watchController = new AbortController();
|
|
58
|
+
let removeAbortListener = () => {
|
|
59
|
+
/* no-op until the abort listener is installed */
|
|
60
|
+
};
|
|
61
|
+
const abortPromise = new Promise<never>((_, reject) => {
|
|
62
|
+
const onAbort = () => {
|
|
63
|
+
watchController.abort();
|
|
64
|
+
reject(new Error('Pipeline aborted'));
|
|
65
|
+
};
|
|
66
|
+
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
67
|
+
removeAbortListener = () => ctx.signal.removeEventListener('abort', onAbort);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
71
|
+
const timeoutPromise =
|
|
72
|
+
timeoutMs > 0
|
|
73
|
+
? new Promise<never>((_, reject) => {
|
|
74
|
+
timer = setTimeout(() => {
|
|
75
|
+
watchController.abort();
|
|
137
76
|
reject(
|
|
138
|
-
new
|
|
139
|
-
`file trigger
|
|
77
|
+
new TriggerTimeoutError(
|
|
78
|
+
`file trigger timeout: ${filePath} did not appear within ${timeoutLabel}`,
|
|
140
79
|
),
|
|
141
80
|
);
|
|
142
|
-
});
|
|
143
|
-
|
|
81
|
+
}, timeoutMs);
|
|
82
|
+
})
|
|
83
|
+
: new Promise<never>(() => {
|
|
84
|
+
/* no timeout */
|
|
85
|
+
});
|
|
144
86
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
87
|
+
async function watchLoop(): Promise<unknown> {
|
|
88
|
+
// Pass `cwd: dir` so runtimes can emit paths relative to the watched
|
|
89
|
+
// directory. The 'add'/'change' events are resolved against `dir` before
|
|
90
|
+
// comparison, preserving the old chokidar behavior without coupling this
|
|
91
|
+
// trigger to chokidar or Bun file APIs.
|
|
92
|
+
for await (const event of ctx.runtime.watch(dir, {
|
|
93
|
+
ignoreInitial: true,
|
|
94
|
+
depth: 0,
|
|
95
|
+
cwd: dir,
|
|
96
|
+
awaitWriteFinishMs: 100,
|
|
97
|
+
signal: watchController.signal,
|
|
98
|
+
})) {
|
|
99
|
+
if (event.type === 'ready') {
|
|
100
|
+
let exists = false;
|
|
101
|
+
try {
|
|
102
|
+
exists = await ctx.runtime.fileExists(safePath);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`file trigger existence check failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
153
106
|
);
|
|
154
|
-
}
|
|
107
|
+
}
|
|
108
|
+
if (exists) return { path: safePath };
|
|
109
|
+
continue;
|
|
155
110
|
}
|
|
156
111
|
|
|
157
|
-
|
|
112
|
+
if (
|
|
113
|
+
(event.type === 'add' || event.type === 'change') &&
|
|
114
|
+
pathsEqual(resolve(dir, event.path), safePath)
|
|
115
|
+
) {
|
|
116
|
+
return { path: safePath };
|
|
117
|
+
}
|
|
158
118
|
}
|
|
159
119
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
120
|
+
if (ctx.signal.aborted) throw new Error('Pipeline aborted');
|
|
121
|
+
throw new Error(`file trigger watch ended before ${filePath} appeared`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
return await Promise.race([watchLoop(), timeoutPromise, abortPromise]);
|
|
126
|
+
} finally {
|
|
127
|
+
if (timer !== null) clearTimeout(timer);
|
|
128
|
+
removeAbortListener();
|
|
129
|
+
watchController.abort();
|
|
130
|
+
}
|
|
131
|
+
}
|