@tagma/sdk 0.6.10 → 0.6.11
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 +58 -10
- package/dist/config-ops.d.ts.map +1 -1
- package/dist/config-ops.js +14 -0
- package/dist/config-ops.js.map +1 -1
- package/dist/pipeline-runner.d.ts +1 -1
- package/dist/pipeline-runner.d.ts.map +1 -1
- package/dist/pipeline-runner.js +20 -0
- package/dist/pipeline-runner.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +40 -8
- package/dist/schema.js.map +1 -1
- package/dist/task-ref.d.ts.map +1 -1
- package/dist/task-ref.js +2 -0
- package/dist/task-ref.js.map +1 -1
- package/dist/utils.js +3 -3
- package/dist/utils.js.map +1 -1
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +49 -3
- package/dist/validate-raw.js.map +1 -1
- package/dist/yaml-compiler.d.ts.map +1 -1
- package/dist/yaml-compiler.js +23 -5
- package/dist/yaml-compiler.js.map +1 -1
- package/package.json +2 -2
- package/src/completions/output-check.test.ts +50 -0
- package/src/config-ops.test.ts +70 -0
- package/src/config-ops.ts +11 -0
- package/src/pipeline-runner.test.ts +95 -0
- package/src/pipeline-runner.ts +18 -1
- package/src/schema.test.ts +113 -1
- package/src/schema.ts +45 -10
- package/src/task-ref.ts +1 -0
- package/src/utils.test.ts +28 -0
- package/src/utils.ts +3 -3
- package/src/validate-raw.ts +52 -4
- package/src/yaml-compiler.test.ts +108 -0
- package/src/yaml-compiler.ts +32 -5
package/src/schema.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import yaml from 'js-yaml';
|
|
3
3
|
import type { PipelineConfig, RawPipelineConfig } from './types';
|
|
4
|
-
import { deresolvePipeline, serializePipeline } from './schema';
|
|
4
|
+
import { deresolvePipeline, parseYaml, resolveConfig, serializePipeline } from './schema';
|
|
5
5
|
|
|
6
6
|
function parsePipelineYaml(content: string): RawPipelineConfig {
|
|
7
7
|
const doc = yaml.load(content) as { pipeline: RawPipelineConfig };
|
|
@@ -56,6 +56,37 @@ describe('completion default serialization', () => {
|
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test('serializePipeline drops continue_from from command tasks (prompt-only field)', () => {
|
|
60
|
+
const raw: RawPipelineConfig = {
|
|
61
|
+
name: 'Strip Continue From',
|
|
62
|
+
tracks: [
|
|
63
|
+
{
|
|
64
|
+
id: 'track_a',
|
|
65
|
+
name: 'Track A',
|
|
66
|
+
tasks: [
|
|
67
|
+
{ id: 'upstream', prompt: 'generate something' },
|
|
68
|
+
// Simulates a task the user authored as `prompt` with a
|
|
69
|
+
// continue_from, then toggled to `command` in the editor panel.
|
|
70
|
+
// The field should not survive serialization.
|
|
71
|
+
{
|
|
72
|
+
id: 'downstream',
|
|
73
|
+
command: 'bun run build',
|
|
74
|
+
continue_from: 'upstream',
|
|
75
|
+
depends_on: ['upstream'],
|
|
76
|
+
},
|
|
77
|
+
// A prompt task keeps its continue_from as-is.
|
|
78
|
+
{ id: 'threaded', prompt: 'refine', continue_from: 'upstream' },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const parsed = parsePipelineYaml(serializePipeline(raw));
|
|
85
|
+
expect(parsed.tracks[0].tasks[1].continue_from).toBeUndefined();
|
|
86
|
+
expect(parsed.tracks[0].tasks[1].depends_on).toEqual(['upstream']);
|
|
87
|
+
expect(parsed.tracks[0].tasks[2].continue_from).toBe('upstream');
|
|
88
|
+
});
|
|
89
|
+
|
|
59
90
|
test('deresolvePipeline also omits the default exit_code completion', () => {
|
|
60
91
|
const resolved: PipelineConfig = {
|
|
61
92
|
name: 'Deresolve Defaults',
|
|
@@ -99,3 +130,84 @@ describe('completion default serialization', () => {
|
|
|
99
130
|
});
|
|
100
131
|
});
|
|
101
132
|
});
|
|
133
|
+
|
|
134
|
+
describe('parseYaml structural validation', () => {
|
|
135
|
+
test('rejects non-array pipeline.tracks with a clear error', () => {
|
|
136
|
+
expect(() =>
|
|
137
|
+
parseYaml(`
|
|
138
|
+
pipeline:
|
|
139
|
+
name: Bad
|
|
140
|
+
tracks:
|
|
141
|
+
id: not-an-array
|
|
142
|
+
`),
|
|
143
|
+
).toThrow(/pipeline\.tracks must be an array/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('rejects non-array track.tasks with a clear error', () => {
|
|
147
|
+
expect(() =>
|
|
148
|
+
parseYaml(`
|
|
149
|
+
pipeline:
|
|
150
|
+
name: Bad
|
|
151
|
+
tracks:
|
|
152
|
+
- id: t
|
|
153
|
+
name: T
|
|
154
|
+
tasks:
|
|
155
|
+
id: not-an-array
|
|
156
|
+
`),
|
|
157
|
+
).toThrow(/track "t": tasks must be an array/);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('permissions inheritance', () => {
|
|
162
|
+
test('resolveConfig applies pipeline-level permissions to tracks and tasks', () => {
|
|
163
|
+
const raw: RawPipelineConfig = {
|
|
164
|
+
name: 'Pipeline Permissions',
|
|
165
|
+
permissions: { read: true, write: true, execute: false },
|
|
166
|
+
tracks: [
|
|
167
|
+
{
|
|
168
|
+
id: 'track_a',
|
|
169
|
+
name: 'Track A',
|
|
170
|
+
tasks: [{ id: 'task_1', prompt: 'hello' }],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const resolved = resolveConfig(raw, 'D:/workspace');
|
|
176
|
+
expect(resolved.tracks[0].permissions).toEqual({ read: true, write: true, execute: false });
|
|
177
|
+
expect(resolved.tracks[0].tasks[0].permissions).toEqual({
|
|
178
|
+
read: true,
|
|
179
|
+
write: true,
|
|
180
|
+
execute: false,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('deresolvePipeline preserves pipeline-level permissions without repeating inherited values', () => {
|
|
185
|
+
const resolved: PipelineConfig = {
|
|
186
|
+
name: 'Deresolve Permissions',
|
|
187
|
+
permissions: { read: true, write: true, execute: false },
|
|
188
|
+
tracks: [
|
|
189
|
+
{
|
|
190
|
+
id: 'track_a',
|
|
191
|
+
name: 'Track A',
|
|
192
|
+
permissions: { read: true, write: true, execute: false },
|
|
193
|
+
cwd: 'D:/workspace',
|
|
194
|
+
tasks: [
|
|
195
|
+
{
|
|
196
|
+
id: 'task_1',
|
|
197
|
+
name: 'Task 1',
|
|
198
|
+
prompt: 'hello',
|
|
199
|
+
permissions: { read: true, write: true, execute: false },
|
|
200
|
+
cwd: 'D:/workspace',
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const raw = deresolvePipeline(resolved, 'D:/workspace');
|
|
208
|
+
|
|
209
|
+
expect(raw.permissions).toEqual({ read: true, write: true, execute: false });
|
|
210
|
+
expect(raw.tracks[0].permissions).toBeUndefined();
|
|
211
|
+
expect(raw.tracks[0].tasks[0].permissions).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
});
|
package/src/schema.ts
CHANGED
|
@@ -17,13 +17,17 @@ import { buildDag } from './dag';
|
|
|
17
17
|
// ═══ YAML Parsing ═══
|
|
18
18
|
|
|
19
19
|
export function parseYaml(content: string): RawPipelineConfig {
|
|
20
|
-
const doc = yaml.load(content) as { pipeline?:
|
|
20
|
+
const doc = yaml.load(content) as { pipeline?: unknown };
|
|
21
21
|
if (!doc?.pipeline) {
|
|
22
22
|
throw new Error('YAML must contain a top-level "pipeline" key');
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
if (typeof doc.pipeline !== 'object' || Array.isArray(doc.pipeline)) {
|
|
25
|
+
throw new Error('pipeline must be an object');
|
|
26
|
+
}
|
|
27
|
+
const p = doc.pipeline as RawPipelineConfig;
|
|
25
28
|
if (!p.name) throw new Error('pipeline.name is required');
|
|
26
|
-
if (!
|
|
29
|
+
if (!Array.isArray(p.tracks)) throw new Error('pipeline.tracks must be an array');
|
|
30
|
+
if (p.tracks.length === 0) throw new Error('pipeline.tracks must be non-empty');
|
|
27
31
|
|
|
28
32
|
// D14: Detect duplicate track IDs before per-track validation so the error
|
|
29
33
|
// message is clear ("Duplicate track id") rather than a confusing DAG error
|
|
@@ -60,10 +64,16 @@ function assertValidId(id: string, label: string): void {
|
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
function validateRawTrack(track: RawTrackConfig): void {
|
|
67
|
+
if (!track || typeof track !== 'object' || Array.isArray(track)) {
|
|
68
|
+
throw new Error('track must be an object');
|
|
69
|
+
}
|
|
63
70
|
if (!track.id) throw new Error('track.id is required');
|
|
64
71
|
assertValidId(track.id, `track "${track.id}"`);
|
|
65
72
|
if (!track.name) throw new Error(`track "${track.id}": name is required`);
|
|
66
|
-
if (!
|
|
73
|
+
if (!Array.isArray(track.tasks)) {
|
|
74
|
+
throw new Error(`track "${track.id}": tasks must be an array`);
|
|
75
|
+
}
|
|
76
|
+
if (track.tasks.length === 0) {
|
|
67
77
|
throw new Error(`track "${track.id}": tasks must be non-empty`);
|
|
68
78
|
}
|
|
69
79
|
for (const task of track.tasks) {
|
|
@@ -72,6 +82,9 @@ function validateRawTrack(track: RawTrackConfig): void {
|
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
function validateRawTask(task: RawTaskConfig, trackId: string): void {
|
|
85
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)) {
|
|
86
|
+
throw new Error(`track "${trackId}": task must be an object`);
|
|
87
|
+
}
|
|
75
88
|
if (!task.id) throw new Error(`track "${trackId}": task.id is required`);
|
|
76
89
|
assertValidId(task.id, `task "${task.id}" in track "${trackId}"`);
|
|
77
90
|
|
|
@@ -140,7 +153,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
140
153
|
model: rawTask.model ?? rawTrack.model ?? raw.model,
|
|
141
154
|
reasoning_effort:
|
|
142
155
|
rawTask.reasoning_effort ?? rawTrack.reasoning_effort ?? raw.reasoning_effort,
|
|
143
|
-
permissions: rawTask.permissions ?? rawTrack.permissions ?? DEFAULT_PERMISSIONS,
|
|
156
|
+
permissions: rawTask.permissions ?? rawTrack.permissions ?? raw.permissions ?? DEFAULT_PERMISSIONS,
|
|
144
157
|
driver: rawTask.driver ?? trackDriver ?? 'opencode',
|
|
145
158
|
timeout: rawTask.timeout,
|
|
146
159
|
// Middleware: Task-level overrides Track (including [] to disable)
|
|
@@ -161,7 +174,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
161
174
|
agent_profile: rawTrack.agent_profile,
|
|
162
175
|
model: rawTrack.model ?? raw.model,
|
|
163
176
|
reasoning_effort: rawTrack.reasoning_effort ?? raw.reasoning_effort,
|
|
164
|
-
permissions: rawTrack.permissions ?? DEFAULT_PERMISSIONS,
|
|
177
|
+
permissions: rawTrack.permissions ?? raw.permissions ?? DEFAULT_PERMISSIONS,
|
|
165
178
|
driver: trackDriver ?? 'opencode',
|
|
166
179
|
cwd: trackCwd,
|
|
167
180
|
middlewares: rawTrack.middlewares,
|
|
@@ -175,6 +188,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
|
|
|
175
188
|
driver: raw.driver,
|
|
176
189
|
model: raw.model,
|
|
177
190
|
reasoning_effort: raw.reasoning_effort,
|
|
191
|
+
permissions: raw.permissions,
|
|
178
192
|
timeout: raw.timeout,
|
|
179
193
|
plugins: raw.plugins,
|
|
180
194
|
hooks: raw.hooks,
|
|
@@ -208,14 +222,32 @@ function stripDefaultTaskCompletion<T extends { completion?: CompletionConfig }>
|
|
|
208
222
|
return rest as T;
|
|
209
223
|
}
|
|
210
224
|
|
|
211
|
-
|
|
225
|
+
// `continue_from` is a prompt-only field — it tells AI drivers with
|
|
226
|
+
// session-resume capability to thread off an upstream prompt task's context.
|
|
227
|
+
// A command task runs as a plain shell subprocess and has no session to
|
|
228
|
+
// resume, so any `continue_from` on a command task is dead weight. Drop it
|
|
229
|
+
// at serialization time so YAML on disk never carries the stale field after
|
|
230
|
+
// a user toggles task mode from prompt → command. The tagma-yaml agent's
|
|
231
|
+
// system prompt (apps/editor/server/opencode-seed.ts) documents this
|
|
232
|
+
// stripping — keep them in sync.
|
|
233
|
+
function stripPromptOnlyFieldsFromCommandTask<
|
|
234
|
+
T extends { command?: string; continue_from?: string },
|
|
235
|
+
>(task: T): T {
|
|
236
|
+
if (task.command === undefined || task.continue_from === undefined) return task;
|
|
237
|
+
const { continue_from: _cf, ...rest } = task;
|
|
238
|
+
return rest as T;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function stripForSerialization<T extends PipelineConfig | RawPipelineConfig>(
|
|
212
242
|
config: T,
|
|
213
243
|
): T {
|
|
214
244
|
return {
|
|
215
245
|
...config,
|
|
216
246
|
tracks: config.tracks.map((track) => ({
|
|
217
247
|
...track,
|
|
218
|
-
tasks: track.tasks.map((task) =>
|
|
248
|
+
tasks: track.tasks.map((task) =>
|
|
249
|
+
stripPromptOnlyFieldsFromCommandTask(stripDefaultTaskCompletion(task)),
|
|
250
|
+
),
|
|
219
251
|
})),
|
|
220
252
|
} as T;
|
|
221
253
|
}
|
|
@@ -228,7 +260,7 @@ function stripDefaultCompletionsForSerialization<T extends PipelineConfig | RawP
|
|
|
228
260
|
*/
|
|
229
261
|
export function serializePipeline(config: PipelineConfig | RawPipelineConfig): string {
|
|
230
262
|
return yaml.dump(
|
|
231
|
-
{ pipeline:
|
|
263
|
+
{ pipeline: stripForSerialization(config) },
|
|
232
264
|
{ lineWidth: 120, indent: 2 },
|
|
233
265
|
);
|
|
234
266
|
}
|
|
@@ -302,7 +334,7 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
302
334
|
...(track.on_failure && track.on_failure !== 'skip_downstream'
|
|
303
335
|
? { on_failure: track.on_failure }
|
|
304
336
|
: {}),
|
|
305
|
-
...(track.permissions && !permissionsEqual(track.permissions, DEFAULT_PERMISSIONS)
|
|
337
|
+
...(track.permissions && !permissionsEqual(track.permissions, config.permissions ?? DEFAULT_PERMISSIONS)
|
|
306
338
|
? { permissions: track.permissions }
|
|
307
339
|
: {}),
|
|
308
340
|
tasks,
|
|
@@ -314,6 +346,9 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
|
|
|
314
346
|
...(config.driver ? { driver: config.driver } : {}),
|
|
315
347
|
...(config.model ? { model: config.model } : {}),
|
|
316
348
|
...(config.reasoning_effort ? { reasoning_effort: config.reasoning_effort } : {}),
|
|
349
|
+
...(config.permissions && !permissionsEqual(config.permissions, DEFAULT_PERMISSIONS)
|
|
350
|
+
? { permissions: config.permissions }
|
|
351
|
+
: {}),
|
|
317
352
|
...(config.timeout ? { timeout: config.timeout } : {}),
|
|
318
353
|
...(config.plugins?.length ? { plugins: config.plugins } : {}),
|
|
319
354
|
...(config.hooks ? { hooks: config.hooks } : {}),
|
package/src/task-ref.ts
CHANGED
|
@@ -68,6 +68,7 @@ export function buildTaskIndex(config: RawPipelineConfig | PipelineConfig): Task
|
|
|
68
68
|
const bareToQualified = new Map<string, string>();
|
|
69
69
|
for (const track of config.tracks ?? []) {
|
|
70
70
|
if (!track?.id) continue;
|
|
71
|
+
if (!Array.isArray(track.tasks)) continue;
|
|
71
72
|
for (const task of track.tasks ?? []) {
|
|
72
73
|
if (!task?.id) continue;
|
|
73
74
|
const qid = qualifyTaskId(track.id, task.id);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { validatePath } from './utils';
|
|
6
|
+
|
|
7
|
+
describe('validatePath', () => {
|
|
8
|
+
test('rejects real parent traversal outside the project root', () => {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'tagma-validate-path-'));
|
|
10
|
+
try {
|
|
11
|
+
expect(() => validatePath('../outside', root)).toThrow(/escapes project root/);
|
|
12
|
+
} finally {
|
|
13
|
+
rmSync(root, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('allows project-local names that merely start with two dots', () => {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), 'tagma-validate-path-'));
|
|
19
|
+
try {
|
|
20
|
+
const inside = join(root, '..inside');
|
|
21
|
+
mkdirSync(inside);
|
|
22
|
+
|
|
23
|
+
expect(validatePath('..inside', root)).toBe(inside);
|
|
24
|
+
} finally {
|
|
25
|
+
rmSync(root, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve, relative, parse as parsePath } from 'path';
|
|
1
|
+
import { isAbsolute, resolve, relative, parse as parsePath, sep } from 'path';
|
|
2
2
|
import { realpathSync, lstatSync, existsSync } from 'fs';
|
|
3
3
|
import { randomBytes } from 'crypto';
|
|
4
4
|
|
|
@@ -38,7 +38,7 @@ export function validatePath(filePath: string, projectRoot: string): string {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const rel = relative(projectRoot, resolved);
|
|
41
|
-
if (rel
|
|
41
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
42
42
|
throw new Error(
|
|
43
43
|
`Security: path "${filePath}" escapes project root. ` +
|
|
44
44
|
`All file references must be within "${projectRoot}".`,
|
|
@@ -83,7 +83,7 @@ export function validatePath(filePath: string, projectRoot: string): string {
|
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
85
|
const realRel = relative(realRoot, real);
|
|
86
|
-
if (realRel
|
|
86
|
+
if (realRel === '..' || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) {
|
|
87
87
|
throw new Error(
|
|
88
88
|
`Security: path "${filePath}" resolves via symlink to "${real}" which escapes project root "${realRoot}".`,
|
|
89
89
|
);
|
package/src/validate-raw.ts
CHANGED
|
@@ -32,6 +32,7 @@ function buildQidIndex(config: RawPipelineConfig): Map<string, QidEntry> {
|
|
|
32
32
|
const idx = new Map<string, QidEntry>();
|
|
33
33
|
for (const track of config.tracks ?? []) {
|
|
34
34
|
if (!track.id) continue;
|
|
35
|
+
if (!Array.isArray(track.tasks)) continue;
|
|
35
36
|
for (const task of track.tasks ?? []) {
|
|
36
37
|
if (!task.id) continue;
|
|
37
38
|
idx.set(qualifyTaskId(track.id, task.id), { track, task });
|
|
@@ -55,6 +56,7 @@ const isValidId = isValidTaskId;
|
|
|
55
56
|
|
|
56
57
|
const VALID_ON_FAILURE = new Set(['skip_downstream', 'stop_all', 'ignore']);
|
|
57
58
|
const VALID_REASONING_EFFORT = new Set(['low', 'medium', 'high']);
|
|
59
|
+
const PERMISSION_FIELDS = ['read', 'write', 'execute'] as const;
|
|
58
60
|
|
|
59
61
|
// Built-in plugin types always known to the SDK core, regardless of which
|
|
60
62
|
// external plugin packages are installed. These MUST stay in sync with the
|
|
@@ -150,8 +152,13 @@ export function validateRaw(
|
|
|
150
152
|
severity: 'warning',
|
|
151
153
|
});
|
|
152
154
|
}
|
|
155
|
+
validatePermissions(config.permissions, 'permissions', errors);
|
|
153
156
|
|
|
154
|
-
if (!
|
|
157
|
+
if (!Array.isArray(config.tracks)) {
|
|
158
|
+
errors.push({ path: 'tracks', message: 'pipeline.tracks must be an array' });
|
|
159
|
+
return errors;
|
|
160
|
+
}
|
|
161
|
+
if (config.tracks.length === 0) {
|
|
155
162
|
errors.push({ path: 'tracks', message: 'At least one track is required' });
|
|
156
163
|
return errors; // No point going further without tracks
|
|
157
164
|
}
|
|
@@ -168,8 +175,13 @@ export function validateRaw(
|
|
|
168
175
|
// ── Per-track validation ──
|
|
169
176
|
const seenTrackIds = new Set<string>();
|
|
170
177
|
for (let ti = 0; ti < config.tracks.length; ti++) {
|
|
171
|
-
const
|
|
178
|
+
const maybeTrack = config.tracks[ti] as unknown;
|
|
172
179
|
const trackPath = `tracks[${ti}]`;
|
|
180
|
+
if (!maybeTrack || typeof maybeTrack !== 'object' || Array.isArray(maybeTrack)) {
|
|
181
|
+
errors.push({ path: trackPath, message: `Track ${ti} must be an object` });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const track = maybeTrack as RawTrackConfig;
|
|
173
185
|
|
|
174
186
|
if (!track.id?.trim()) {
|
|
175
187
|
errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
|
|
@@ -205,6 +217,7 @@ export function validateRaw(
|
|
|
205
217
|
severity: 'warning',
|
|
206
218
|
});
|
|
207
219
|
}
|
|
220
|
+
validatePermissions(track.permissions, `${trackPath}.permissions`, errors);
|
|
208
221
|
|
|
209
222
|
// Track-level middlewares can reference a plugin that was uninstalled
|
|
210
223
|
// after the YAML was written — surface a warning so the user notices
|
|
@@ -222,7 +235,14 @@ export function validateRaw(
|
|
|
222
235
|
}
|
|
223
236
|
}
|
|
224
237
|
|
|
225
|
-
if (!
|
|
238
|
+
if (!Array.isArray(track.tasks)) {
|
|
239
|
+
errors.push({
|
|
240
|
+
path: `${trackPath}.tasks`,
|
|
241
|
+
message: `Track "${track.id || ti}": tasks must be an array`,
|
|
242
|
+
});
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (track.tasks.length === 0) {
|
|
226
246
|
errors.push({
|
|
227
247
|
path: `${trackPath}.tasks`,
|
|
228
248
|
message: `Track "${track.id || ti}": must have at least one task`,
|
|
@@ -302,6 +322,7 @@ export function validateRaw(
|
|
|
302
322
|
severity: 'warning',
|
|
303
323
|
});
|
|
304
324
|
}
|
|
325
|
+
validatePermissions(task.permissions, `${taskPath}.permissions`, errors);
|
|
305
326
|
|
|
306
327
|
// ── Plugin type warnings (trigger / completion / middlewares) ──
|
|
307
328
|
// Only fire when the host supplied a `knownTypes` snapshot, so offline
|
|
@@ -374,7 +395,7 @@ export function validateRaw(
|
|
|
374
395
|
});
|
|
375
396
|
} else if (
|
|
376
397
|
!task.depends_on ||
|
|
377
|
-
!task.depends_on.some((dep) => {
|
|
398
|
+
!task.depends_on.some((dep: string) => {
|
|
378
399
|
const depResolved = resolveTaskRef(dep, track.id, index);
|
|
379
400
|
return depResolved.kind === 'resolved' && depResolved.qid === resolved.qid;
|
|
380
401
|
})
|
|
@@ -400,6 +421,32 @@ export function validateRaw(
|
|
|
400
421
|
return errors;
|
|
401
422
|
}
|
|
402
423
|
|
|
424
|
+
function validatePermissions(
|
|
425
|
+
value: unknown,
|
|
426
|
+
basePath: string,
|
|
427
|
+
errors: ValidationError[],
|
|
428
|
+
): void {
|
|
429
|
+
if (value === undefined) return;
|
|
430
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
431
|
+
errors.push({
|
|
432
|
+
path: basePath,
|
|
433
|
+
message: 'permissions must be an object with read/write/execute booleans',
|
|
434
|
+
});
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const p = value as Record<string, unknown>;
|
|
438
|
+
for (const field of PERMISSION_FIELDS) {
|
|
439
|
+
const path = `${basePath}.${field}`;
|
|
440
|
+
if (!(field in p)) {
|
|
441
|
+
errors.push({ path, message: `permissions.${field} is required` });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (typeof p[field] !== 'boolean') {
|
|
445
|
+
errors.push({ path, message: `permissions.${field} must be a boolean` });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
403
450
|
const VALID_PORT_TYPES: ReadonlySet<PortType> = new Set([
|
|
404
451
|
'string',
|
|
405
452
|
'number',
|
|
@@ -764,6 +811,7 @@ function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationEr
|
|
|
764
811
|
|
|
765
812
|
for (const track of config.tracks) {
|
|
766
813
|
if (!track.id) continue;
|
|
814
|
+
if (!Array.isArray(track.tasks)) continue;
|
|
767
815
|
for (const task of track.tasks ?? []) {
|
|
768
816
|
if (!task.id) continue;
|
|
769
817
|
const qid = qualifyTaskId(track.id, task.id);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { compileYamlContent } from './yaml-compiler';
|
|
3
|
+
|
|
4
|
+
describe('compileYamlContent', () => {
|
|
5
|
+
test('reports YAML syntax failures as parse errors', () => {
|
|
6
|
+
const result = compileYamlContent('pipeline:\n name: [');
|
|
7
|
+
|
|
8
|
+
expect(result.parseOk).toBe(false);
|
|
9
|
+
expect(result.success).toBe(false);
|
|
10
|
+
expect(result.validation.errors).toEqual([]);
|
|
11
|
+
expect(result.summary).toMatch(/^YAML parse error:/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('reports missing top-level pipeline as a validation error', () => {
|
|
15
|
+
const result = compileYamlContent('name: Missing Pipeline\n');
|
|
16
|
+
|
|
17
|
+
expect(result.parseOk).toBe(true);
|
|
18
|
+
expect(result.success).toBe(false);
|
|
19
|
+
expect(result.validation.errors).toEqual([
|
|
20
|
+
{ path: 'pipeline', message: 'Top-level "pipeline" key is required' },
|
|
21
|
+
]);
|
|
22
|
+
expect(result.summary).toBe('Invalid: 1 error(s), 0 warning(s)');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('reports non-array tracks as a validation error, not a parse failure', () => {
|
|
26
|
+
const result = compileYamlContent(`
|
|
27
|
+
pipeline:
|
|
28
|
+
name: Bad
|
|
29
|
+
tracks:
|
|
30
|
+
id: not-an-array
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
expect(result.parseOk).toBe(true);
|
|
34
|
+
expect(result.success).toBe(false);
|
|
35
|
+
expect(result.validation.errors).toEqual([
|
|
36
|
+
{ path: 'tracks', message: 'pipeline.tracks must be an array' },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('reports non-array task lists as validation errors, not validation crashes', () => {
|
|
41
|
+
const result = compileYamlContent(`
|
|
42
|
+
pipeline:
|
|
43
|
+
name: Bad Tasks
|
|
44
|
+
tracks:
|
|
45
|
+
- id: t
|
|
46
|
+
name: T
|
|
47
|
+
tasks:
|
|
48
|
+
id: not-an-array
|
|
49
|
+
`);
|
|
50
|
+
|
|
51
|
+
expect(result.parseOk).toBe(true);
|
|
52
|
+
expect(result.success).toBe(false);
|
|
53
|
+
expect(result.validation.errors).toEqual([
|
|
54
|
+
{ path: 'tracks[0].tasks', message: 'Track "t": tasks must be an array' },
|
|
55
|
+
]);
|
|
56
|
+
expect(result.summary).not.toMatch(/Validation crashed/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('routes schema errors through validation when YAML syntax is valid', () => {
|
|
60
|
+
const result = compileYamlContent(`
|
|
61
|
+
pipeline:
|
|
62
|
+
name: Missing Track Name
|
|
63
|
+
tracks:
|
|
64
|
+
- id: main
|
|
65
|
+
tasks:
|
|
66
|
+
- id: task
|
|
67
|
+
prompt: hello
|
|
68
|
+
`);
|
|
69
|
+
|
|
70
|
+
expect(result.parseOk).toBe(true);
|
|
71
|
+
expect(result.success).toBe(false);
|
|
72
|
+
expect(result.validation.errors).toContainEqual({
|
|
73
|
+
path: 'tracks[0].name',
|
|
74
|
+
message: 'Track name is required',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('validates pipeline, track, and task permissions shape', () => {
|
|
79
|
+
const result = compileYamlContent(`
|
|
80
|
+
pipeline:
|
|
81
|
+
name: Bad Permissions
|
|
82
|
+
permissions: { read: true, write: "yes", execute: false }
|
|
83
|
+
tracks:
|
|
84
|
+
- id: main
|
|
85
|
+
name: Main
|
|
86
|
+
permissions: { read: true, execute: false }
|
|
87
|
+
tasks:
|
|
88
|
+
- id: task
|
|
89
|
+
prompt: hello
|
|
90
|
+
permissions: nope
|
|
91
|
+
`);
|
|
92
|
+
|
|
93
|
+
expect(result.parseOk).toBe(true);
|
|
94
|
+
expect(result.success).toBe(false);
|
|
95
|
+
expect(result.validation.errors).toContainEqual({
|
|
96
|
+
path: 'permissions.write',
|
|
97
|
+
message: 'permissions.write must be a boolean',
|
|
98
|
+
});
|
|
99
|
+
expect(result.validation.errors).toContainEqual({
|
|
100
|
+
path: 'tracks[0].permissions.write',
|
|
101
|
+
message: 'permissions.write is required',
|
|
102
|
+
});
|
|
103
|
+
expect(result.validation.errors).toContainEqual({
|
|
104
|
+
path: 'tracks[0].tasks[0].permissions',
|
|
105
|
+
message: 'permissions must be an object with read/write/execute booleans',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/yaml-compiler.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import yaml from 'js-yaml';
|
|
2
2
|
import { validateRaw } from './validate-raw';
|
|
3
3
|
import type { ValidationError, KnownPluginTypes } from './validate-raw';
|
|
4
4
|
import type { RawPipelineConfig } from './types';
|
|
@@ -27,9 +27,9 @@ export function compileYamlContent(
|
|
|
27
27
|
const timestamp = new Date().toISOString();
|
|
28
28
|
const sourceName = opts.sourceName ?? 'untitled';
|
|
29
29
|
|
|
30
|
-
let
|
|
30
|
+
let doc: unknown;
|
|
31
31
|
try {
|
|
32
|
-
|
|
32
|
+
doc = yaml.load(content);
|
|
33
33
|
} catch (err) {
|
|
34
34
|
return {
|
|
35
35
|
timestamp,
|
|
@@ -41,6 +41,12 @@ export function compileYamlContent(
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const envelopeErrors = validateEnvelope(doc);
|
|
45
|
+
if (envelopeErrors.length > 0) {
|
|
46
|
+
return buildValidationResult(timestamp, sourceName, envelopeErrors);
|
|
47
|
+
}
|
|
48
|
+
const config = (doc as { pipeline: RawPipelineConfig }).pipeline;
|
|
49
|
+
|
|
44
50
|
let errors: ValidationError[];
|
|
45
51
|
try {
|
|
46
52
|
errors = validateRaw(config, opts.knownTypes);
|
|
@@ -55,8 +61,29 @@ export function compileYamlContent(
|
|
|
55
61
|
};
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
return buildValidationResult(timestamp, sourceName, errors);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateEnvelope(doc: unknown): ValidationError[] {
|
|
68
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !('pipeline' in doc)) {
|
|
69
|
+
return [{ path: 'pipeline', message: 'Top-level "pipeline" key is required' }];
|
|
70
|
+
}
|
|
71
|
+
const pipeline = (doc as Record<string, unknown>).pipeline;
|
|
72
|
+
if (!pipeline || typeof pipeline !== 'object' || Array.isArray(pipeline)) {
|
|
73
|
+
return [{ path: 'pipeline', message: 'pipeline must be an object' }];
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildValidationResult(
|
|
79
|
+
timestamp: string,
|
|
80
|
+
sourceName: string,
|
|
81
|
+
diagnostics: readonly ValidationError[],
|
|
82
|
+
): YamlCompileResult {
|
|
83
|
+
const validationErrors = diagnostics.filter(
|
|
84
|
+
(e) => e.severity === 'error' || e.severity == null,
|
|
85
|
+
);
|
|
86
|
+
const validationWarnings = diagnostics.filter((e) => e.severity === 'warning');
|
|
60
87
|
|
|
61
88
|
return {
|
|
62
89
|
timestamp,
|