@async/pipeline 0.2.3 → 0.3.0

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.
Files changed (33) hide show
  1. package/README.md +1 -1
  2. package/dist/internal/core/cache.d.ts.map +1 -1
  3. package/dist/internal/core/cache.js +10 -9
  4. package/dist/internal/core/cache.js.map +1 -1
  5. package/dist/internal/core/declaration.d.ts +15 -0
  6. package/dist/internal/core/declaration.d.ts.map +1 -0
  7. package/dist/internal/core/declaration.js +49 -0
  8. package/dist/internal/core/declaration.js.map +1 -0
  9. package/dist/internal/core/index.d.ts +54 -3
  10. package/dist/internal/core/index.d.ts.map +1 -1
  11. package/dist/internal/core/index.js +361 -44
  12. package/dist/internal/core/index.js.map +1 -1
  13. package/dist/internal/core/runtime.d.ts.map +1 -1
  14. package/dist/internal/core/runtime.js +14 -13
  15. package/dist/internal/core/runtime.js.map +1 -1
  16. package/dist/internal/node/cli.d.ts.map +1 -1
  17. package/dist/internal/node/cli.js +39 -9
  18. package/dist/internal/node/cli.js.map +1 -1
  19. package/dist/internal/node/doctor.d.ts +2 -1
  20. package/dist/internal/node/doctor.d.ts.map +1 -1
  21. package/dist/internal/node/doctor.js +27 -1
  22. package/dist/internal/node/doctor.js.map +1 -1
  23. package/dist/internal/node/github.d.ts +2 -1
  24. package/dist/internal/node/github.d.ts.map +1 -1
  25. package/dist/internal/node/github.js +4 -3
  26. package/dist/internal/node/github.js.map +1 -1
  27. package/dist/internal/node/runner.d.ts +18 -2
  28. package/dist/internal/node/runner.d.ts.map +1 -1
  29. package/dist/internal/node/runner.js +109 -5
  30. package/dist/internal/node/runner.js.map +1 -1
  31. package/dist/internal/node/store.js +2 -1
  32. package/dist/internal/node/store.js.map +1 -1
  33. package/package.json +1 -1
@@ -1,10 +1,12 @@
1
1
  import { assertCacheStore, defaultPipelineCache, defineCache, isCacheDirective, mergeWithDefaultCacheStores, parseCacheRef } from "./cache.js";
2
+ import { assertSupportedDeclaration, brandDeclaration, hasDeclarationKind, readDeclaration } from "./declaration.js";
2
3
  import { pipelineError } from "./errors.js";
3
4
  export * from "./cache.js";
5
+ export * from "./declaration.js";
4
6
  export * from "./errors.js";
5
7
  export function sh(first, ...values) {
6
8
  if (typeof first === "function") {
7
- return { kind: "deferred-shell", command: first };
9
+ return brandDeclaration({ kind: "deferred-shell", command: first }, "deferred-shell");
8
10
  }
9
11
  let command = "";
10
12
  for (let index = 0; index < first.length; index += 1) {
@@ -13,9 +15,11 @@ export function sh(first, ...values) {
13
15
  command += String(values[index]);
14
16
  }
15
17
  }
16
- return { kind: "shell", command };
18
+ return brandDeclaration({ kind: "shell", command }, "shell");
17
19
  }
18
- const AGENT_STEP_FIELDS = new Set(["use", "prompt", "model"]);
20
+ const AGENT_STEP_FIELDS = new Set(["use", "prompt", "model", "stdoutTo"]);
21
+ const DECLARED_AGENT_STEP_FIELDS = new Set(["kind", ...AGENT_STEP_FIELDS]);
22
+ const SHELL_STEP_FIELDS = new Set(["kind", "command"]);
19
23
  export function agent(options) {
20
24
  rejectUnknownFields(AGENT_STEP_FIELDS, options, "agent() step");
21
25
  const use = options.use;
@@ -25,9 +29,19 @@ export function agent(options) {
25
29
  if (typeof options.prompt !== "string" || options.prompt.length === 0) {
26
30
  throw pipelineError("ASYNC_PIPELINE_AGENT_INVALID", 'agent() requires a non-empty "prompt" string.');
27
31
  }
28
- const step = { kind: "agent", use, prompt: options.prompt };
32
+ if (options.stdoutTo !== undefined) {
33
+ if (typeof options.stdoutTo !== "string" || options.stdoutTo.length === 0) {
34
+ throw pipelineError("ASYNC_PIPELINE_AGENT_INVALID", 'agent() "stdoutTo" must be a non-empty path string.');
35
+ }
36
+ if (options.stdoutTo.startsWith("/") || /^[A-Za-z]:[\\/]/.test(options.stdoutTo) || options.stdoutTo.split(/[\\/]+/).includes("..")) {
37
+ throw pipelineError("ASYNC_PIPELINE_AGENT_INVALID", 'agent() "stdoutTo" must be a relative path inside the task\'s working directory; absolute paths and ".." segments are rejected.');
38
+ }
39
+ }
40
+ const step = brandDeclaration({ kind: "agent", use, prompt: options.prompt }, "agent");
29
41
  if (options.model !== undefined)
30
42
  step.model = options.model;
43
+ if (options.stdoutTo !== undefined)
44
+ step.stdoutTo = options.stdoutTo;
31
45
  return step;
32
46
  }
33
47
  export function isAgentStep(step) {
@@ -40,118 +54,176 @@ export function task(definition, run) {
40
54
  if (definition.run !== undefined && run !== undefined) {
41
55
  throw pipelineError("ASYNC_PIPELINE_TASK_ARGUMENT_CONFLICT", "Do not pass a second task argument when config.run is defined.");
42
56
  }
43
- return run === undefined ? definition : { ...definition, run };
57
+ return brandDeclaration(run === undefined ? definition : { ...definition, run }, "task");
44
58
  }
45
59
  export function job(definition) {
46
- return definition;
60
+ return brandDeclaration(definition, "job");
47
61
  }
48
62
  function envVar(name, valuesOrOptions, options = {}) {
49
63
  if (!valuesOrOptions)
50
- return { kind: "async-pipeline.env.var", name };
64
+ return brandDeclaration({ kind: "async-pipeline.env.var", name }, "env.var");
51
65
  if (isDefaultOnlyEnvOptions(valuesOrOptions) && Object.keys(valuesOrOptions).length === 1) {
52
- return { kind: "async-pipeline.env.var", name, default: valuesOrOptions.default };
66
+ return brandDeclaration({ kind: "async-pipeline.env.var", name, default: valuesOrOptions.default }, "env.var");
53
67
  }
54
- return {
68
+ return brandDeclaration({
55
69
  kind: "async-pipeline.env.var",
56
70
  name,
57
71
  values: { ...valuesOrOptions },
58
72
  default: options.default
59
- };
73
+ }, "env.var");
60
74
  }
61
75
  export const env = {
62
76
  secret(name) {
63
- return { kind: "async-pipeline.env.secret", name };
77
+ return brandDeclaration({ kind: "async-pipeline.env.secret", name }, "env.secret");
64
78
  },
65
79
  var: envVar
66
80
  };
67
81
  export const sandbox = {
68
82
  host() {
69
- return { kind: "host" };
83
+ return brandDeclaration({ kind: "host" }, "sandbox.host");
70
84
  },
71
85
  lima(options = {}) {
72
- return { kind: "lima", vm: options.vm };
86
+ return brandDeclaration({ kind: "lima", vm: options.vm }, "sandbox.lima");
73
87
  },
74
88
  docker(options) {
75
- return {
89
+ return brandDeclaration({
76
90
  kind: "docker",
77
91
  image: options.image,
78
92
  workdir: options.workdir,
79
93
  volumes: options.volumes ? options.volumes.map((volume) => ({ ...volume })) : undefined
80
- };
94
+ }, "sandbox.docker");
95
+ },
96
+ container(options) {
97
+ return brandDeclaration({
98
+ kind: "container",
99
+ image: options.image,
100
+ workdir: options.workdir,
101
+ volumes: options.volumes ? options.volumes.map((volume) => ({ ...volume })) : undefined
102
+ }, "sandbox.container");
103
+ }
104
+ };
105
+ export const execution = {
106
+ local(options = {}) {
107
+ return brandDeclaration({
108
+ kind: "local",
109
+ sandbox: options.sandbox,
110
+ provider: options.provider
111
+ }, "execution.local");
112
+ },
113
+ github(options) {
114
+ return brandDeclaration({
115
+ kind: "github",
116
+ sandbox: options.sandbox,
117
+ provider: options.provider,
118
+ runsOn: options.runsOn ? cloneRunsOnEntry(options.runsOn) : undefined,
119
+ runsOnMatrix: options.runsOnMatrix ? options.runsOnMatrix.map(cloneRunsOnEntry) : undefined
120
+ }, "execution.github");
81
121
  }
82
122
  };
83
123
  export const command = {
84
124
  policy(options = {}) {
85
- return {
125
+ return brandDeclaration({
86
126
  rules: options.rules ? options.rules.map(cloneCommandRule) : [],
87
127
  fallback: options.fallback ? cloneCommandAction(options.fallback) : undefined,
88
128
  record: options.record,
89
129
  output: options.output ? { ...options.output } : undefined,
90
130
  shims: options.shims ? [...options.shims] : undefined
91
- };
131
+ }, "command.policy");
92
132
  },
93
133
  rule(options) {
94
- return cloneCommandRule(options);
134
+ return brandDeclaration(cloneCommandRule(options), "command.rule");
95
135
  },
96
136
  allow(options = {}) {
97
- return { kind: "async-pipeline.command.allow", output: options.output ? { ...options.output } : undefined };
137
+ return brandDeclaration({ kind: "async-pipeline.command.allow", output: options.output ? { ...options.output } : undefined }, "command.allow");
98
138
  },
99
139
  deny(options = {}) {
100
- return { kind: "async-pipeline.command.deny", message: options.message, output: options.output ? { ...options.output } : undefined };
140
+ return brandDeclaration({ kind: "async-pipeline.command.deny", message: options.message, output: options.output ? { ...options.output } : undefined }, "command.deny");
101
141
  },
102
142
  mock(options = {}) {
103
- return {
143
+ return brandDeclaration({
104
144
  kind: "async-pipeline.command.mock",
105
145
  code: options.code,
106
146
  stdout: options.stdout,
107
147
  stderr: options.stderr,
108
148
  output: options.output ? { ...options.output } : undefined
109
- };
149
+ }, "command.mock");
110
150
  },
111
151
  requireApproval(options = {}) {
112
- return { kind: "async-pipeline.command.requireApproval", message: options.message, output: options.output ? { ...options.output } : undefined };
152
+ return brandDeclaration({ kind: "async-pipeline.command.requireApproval", message: options.message, output: options.output ? { ...options.output } : undefined }, "command.requireApproval");
113
153
  },
114
154
  requireEnvironment(options) {
115
- return { kind: "async-pipeline.command.requireEnvironment", name: options.name, output: options.output ? { ...options.output } : undefined };
155
+ return brandDeclaration({ kind: "async-pipeline.command.requireEnvironment", name: options.name, output: options.output ? { ...options.output } : undefined }, "command.requireEnvironment");
116
156
  }
117
157
  };
118
158
  export const trigger = {
119
159
  manual() {
120
- return { type: "manual" };
160
+ return brandDeclaration({ type: "manual" }, "trigger.manual");
121
161
  },
122
162
  github(options) {
123
- return {
163
+ return brandDeclaration({
124
164
  type: "github",
125
165
  events: [...options.events],
126
166
  branches: options.branches ? [...options.branches] : undefined,
127
167
  paths: options.paths ? [...options.paths] : undefined,
128
168
  tags: options.tags ? [...options.tags] : undefined
129
- };
169
+ }, "trigger.github");
130
170
  },
131
171
  cron(cron, options = {}) {
132
- return { type: "schedule", cron, timezone: options.timezone };
172
+ return brandDeclaration({ type: "schedule", cron, timezone: options.timezone }, "trigger.cron");
133
173
  },
134
174
  schedule(cron) {
135
- return { type: "schedule", cron };
175
+ return brandDeclaration({ type: "schedule", cron }, "trigger.schedule");
136
176
  }
137
177
  };
138
178
  export function dependsOn(...taskIds) {
139
- return { kind: "async-pipeline.directive.dependsOn", taskIds };
179
+ return brandDeclaration({ kind: "async-pipeline.directive.dependsOn", taskIds }, "directive.dependsOn");
140
180
  }
141
181
  export const source = {
142
182
  git(definition) {
143
- return { ...definition, type: "git" };
183
+ return brandDeclaration({ ...definition, type: "git" }, "source.git");
144
184
  },
145
185
  path(definition) {
146
- return { ...definition, type: "path" };
186
+ return brandDeclaration({ ...definition, type: "path" }, "source.path");
147
187
  }
148
188
  };
149
- const PIPELINE_FIELDS = new Set(["name", "env", "commands", "agents", "sandboxes", "cache", "namedInputs", "taskDefaults", "triggers", "sync", "sources", "tasks", "jobs"]);
189
+ export function tasks(definitions) {
190
+ return brandDeclaration(definitions, "section.tasks");
191
+ }
192
+ export function jobs(definitions) {
193
+ return brandDeclaration(definitions, "section.jobs");
194
+ }
195
+ export function triggers(definitions) {
196
+ return brandDeclaration(definitions, "section.triggers");
197
+ }
198
+ export function sources(definitions) {
199
+ return brandDeclaration(definitions, "section.sources");
200
+ }
201
+ export function taskDefaults(definitions) {
202
+ return brandDeclaration(definitions, "section.taskDefaults");
203
+ }
204
+ export function agents(definitions) {
205
+ return brandDeclaration(definitions, "section.agents");
206
+ }
207
+ export function sandboxes(definitions) {
208
+ return brandDeclaration(definitions, "section.sandboxes");
209
+ }
210
+ const PIPELINE_FIELDS = new Set(["name", "env", "commands", "agents", "sandboxes", "execution", "cache", "namedInputs", "taskDefaults", "triggers", "sync", "sources", "tasks", "jobs"]);
150
211
  const AGENT_PROFILE_FIELDS = new Set(["command", "model"]);
151
212
  const TASK_FIELDS = new Set(["description", "dependsOn", "inputs", "outputs", "cache", "retry", "timeout", "requires", "run", "steps"]);
152
- const JOB_FIELDS = new Set(["description", "target", "trigger", "environment", "env", "requires", "github"]);
213
+ const JOB_FIELDS = new Set(["description", "target", "trigger", "environment", "env", "requires", "execution", "github"]);
214
+ const EXECUTION_PROFILE_FIELDS = new Set(["kind", "sandbox", "provider", "runsOn", "runsOnMatrix"]);
153
215
  const GITHUB_JOB_FIELDS = new Set(["environment", "permissions", "runsOn", "runsOnMatrix"]);
154
216
  const GITHUB_PERMISSION_FIELDS = new Set(["contents", "idToken", "issues", "packages", "pullRequests"]);
217
+ const CONTAINER_PROVIDERS = new Set(["auto", "docker", "apple-container", "lima"]);
218
+ const SECTION_KINDS = {
219
+ agents: "section.agents",
220
+ sandboxes: "section.sandboxes",
221
+ taskDefaults: "section.taskDefaults",
222
+ triggers: "section.triggers",
223
+ sources: "section.sources",
224
+ tasks: "section.tasks",
225
+ jobs: "section.jobs"
226
+ };
155
227
  function rejectUnknownFields(known, value, where) {
156
228
  for (const key of Object.keys(value)) {
157
229
  if (!known.has(key)) {
@@ -177,12 +249,21 @@ function validateDefinitionShape(definition) {
177
249
  throw pipelineError("ASYNC_PIPELINE_AGENT_INVALID", `Agent profile "${id}" requires "model": a non-empty string or env.var(...). The model is the profile's cache identity; the command is deliberately not.`);
178
250
  }
179
251
  }
180
- for (const [id, taskDefinition] of Object.entries(definition.tasks ?? {})) {
181
- rejectUnknownFields(TASK_FIELDS, taskDefinition, `Task "${id}"`);
182
- }
183
252
  for (const [id, defaults] of Object.entries(definition.taskDefaults ?? {})) {
184
253
  rejectUnknownFields(TASK_FIELDS, defaults, `taskDefaults["${id}"]`);
185
254
  }
255
+ for (const [id, profile] of Object.entries(definition.execution ?? {})) {
256
+ rejectUnknownFields(EXECUTION_PROFILE_FIELDS, profile, `Execution profile "${id}"`);
257
+ if (profile.kind !== "local" && profile.kind !== "github") {
258
+ throw pipelineError("ASYNC_PIPELINE_EXECUTION_INVALID", `Execution profile "${id}" requires kind "local" or "github".`);
259
+ }
260
+ if (profile.provider !== undefined && !CONTAINER_PROVIDERS.has(profile.provider)) {
261
+ throw pipelineError("ASYNC_PIPELINE_EXECUTION_INVALID", `Execution profile "${id}" has unsupported provider "${profile.provider}". Use auto, docker, apple-container, or lima.`);
262
+ }
263
+ if (profile.kind === "local" && ("runsOn" in profile || "runsOnMatrix" in profile)) {
264
+ throw pipelineError("ASYNC_PIPELINE_EXECUTION_INVALID", `Execution profile "${id}" is local and cannot set GitHub runner fields. Use execution.github(...) for runsOn or runsOnMatrix.`);
265
+ }
266
+ }
186
267
  for (const [id, jobDefinition] of Object.entries(definition.jobs ?? {})) {
187
268
  rejectUnknownFields(JOB_FIELDS, jobDefinition, `Job "${id}"`);
188
269
  if (jobDefinition.github) {
@@ -193,10 +274,112 @@ function validateDefinitionShape(definition) {
193
274
  }
194
275
  }
195
276
  }
277
+ function normalizePipelineSections(definition) {
278
+ return {
279
+ ...definition,
280
+ agents: normalizeSection("agents", definition.agents),
281
+ sandboxes: normalizeSection("sandboxes", definition.sandboxes),
282
+ taskDefaults: normalizeSection("taskDefaults", definition.taskDefaults),
283
+ triggers: normalizeSection("triggers", definition.triggers),
284
+ sources: normalizeSection("sources", definition.sources),
285
+ tasks: normalizeSection("tasks", definition.tasks),
286
+ jobs: normalizeSection("jobs", definition.jobs)
287
+ };
288
+ }
289
+ function normalizeSection(key, value) {
290
+ if (value === undefined)
291
+ return value;
292
+ if (!isObjectRecord(value)) {
293
+ throw pipelineError("ASYNC_PIPELINE_SECTION_INVALID", `Pipeline section "${key}" must be an object.`);
294
+ }
295
+ const expectedKind = SECTION_KINDS[key];
296
+ const metadata = assertSupportedDeclaration(value);
297
+ if (metadata) {
298
+ if (metadata.kind !== expectedKind) {
299
+ throw pipelineError("ASYNC_PIPELINE_SECTION_KIND_MISMATCH", `Pipeline section "${key}" expected declaration kind "${expectedKind}", received "${metadata.kind}".`);
300
+ }
301
+ return value;
302
+ }
303
+ return brandDeclaration(value, expectedKind);
304
+ }
305
+ function flattenTaskDefinitions(definitions) {
306
+ const tasks = [];
307
+ const seen = new Set();
308
+ function visit(node, path) {
309
+ for (const [key, value] of Object.entries(node)) {
310
+ validateTaskTreeKey(key, path);
311
+ if (!isObjectRecord(value)) {
312
+ throw pipelineError("ASYNC_PIPELINE_TASK_TREE_INVALID", `Task tree entry "${[...path, key].join(".")}" must be an object.`);
313
+ }
314
+ const isTask = isTaskDefinitionNode(value, path);
315
+ if (isTask) {
316
+ const id = taskTreeId(path, key);
317
+ validateLocalTaskId(id);
318
+ if (seen.has(id)) {
319
+ throw pipelineError("ASYNC_PIPELINE_TASK_ID_COLLISION", `Task group entry "${[...path, key].join(".")}" normalizes to duplicate task id "${id}".`);
320
+ }
321
+ seen.add(id);
322
+ rejectUnknownFields(TASK_FIELDS, value, `Task "${id}"`);
323
+ tasks.push({ id, definition: value, groupPath: path });
324
+ continue;
325
+ }
326
+ if (key.includes(".")) {
327
+ throw pipelineError("ASYNC_PIPELINE_TASK_GROUP_INVALID_KEY", `Task group key "${key}" cannot contain ".". Use nested objects instead.`);
328
+ }
329
+ visit(value, [...path, key]);
330
+ }
331
+ }
332
+ visit(definitions, []);
333
+ return tasks;
334
+ }
335
+ function validateTaskTreeKey(key, path) {
336
+ if (!key.trim()) {
337
+ throw pipelineError("ASYNC_PIPELINE_TASK_GROUP_INVALID_KEY", "Task group key cannot be empty.");
338
+ }
339
+ if (key.includes(":")) {
340
+ throw pipelineError("ASYNC_PIPELINE_TASK_GROUP_INVALID_KEY", `Task group key "${key}" cannot contain ":". Use source namespaces through dependsOn instead.`);
341
+ }
342
+ if (path.length > 0 && key.includes(".")) {
343
+ throw pipelineError("ASYNC_PIPELINE_TASK_GROUP_INVALID_KEY", `Nested task group key "${key}" cannot contain ".". Use nested objects instead.`);
344
+ }
345
+ }
346
+ function taskTreeId(path, key) {
347
+ const segments = path.length > 0 && (key === "default" || key === "index") ? path : [...path, key];
348
+ return segments.join(".");
349
+ }
350
+ function isTaskDefinitionNode(value, path) {
351
+ const metadata = assertSupportedDeclaration(value);
352
+ if (metadata?.kind === "task")
353
+ return true;
354
+ if (metadata?.kind.startsWith("section."))
355
+ return false;
356
+ if (metadata)
357
+ return false;
358
+ if (Object.keys(value).some((key) => TASK_FIELDS.has(key)))
359
+ return true;
360
+ return path.length === 0 && Object.keys(value).length === 0;
361
+ }
362
+ function resolveTaskDependencies(taskId, groupPath, dependencies, knownTaskIds) {
363
+ return dependencies.map((dependency) => resolveTaskDependency(taskId, groupPath, dependency, knownTaskIds));
364
+ }
365
+ function resolveTaskDependency(taskId, groupPath, dependency, knownTaskIds) {
366
+ if (isNamespacedTaskRef(dependency) || groupPath.length === 0)
367
+ return dependency;
368
+ const groupCandidate = [...groupPath, dependency].join(".");
369
+ const groupMatch = knownTaskIds.has(groupCandidate);
370
+ const rootMatch = knownTaskIds.has(dependency);
371
+ if (groupMatch && rootMatch && groupCandidate !== dependency) {
372
+ throw pipelineError("ASYNC_PIPELINE_TASK_DEPENDENCY_AMBIGUOUS", `Task "${taskId}" depends on ambiguous local task "${dependency}". Use "${groupCandidate}" or "${dependency}" explicitly.`);
373
+ }
374
+ if (groupMatch)
375
+ return groupCandidate;
376
+ return dependency;
377
+ }
196
378
  export function definePipeline(definition) {
197
379
  return normalizePipeline(definition);
198
380
  }
199
381
  export function normalizePipeline(definition) {
382
+ definition = normalizePipelineSections(definition);
200
383
  validateDefinitionShape(definition);
201
384
  const namedInputs = definition.namedInputs ?? {};
202
385
  const cacheRegistry = normalizeCacheRegistry(definition.cache);
@@ -206,16 +389,18 @@ export function normalizePipeline(definition) {
206
389
  sources[id] = normalizeSource(id, sourceDefinition);
207
390
  }
208
391
  const tasks = {};
209
- for (const [id, taskDefinition] of Object.entries(definition.tasks)) {
392
+ const flattenedTaskDefinitions = flattenTaskDefinitions(definition.tasks);
393
+ const knownTaskIds = new Set(flattenedTaskDefinitions.map((entry) => entry.id));
394
+ for (const { id, definition: taskDefinition, groupPath } of flattenedTaskDefinitions) {
210
395
  validateLocalTaskId(id);
211
396
  const defaults = definition.taskDefaults?.[id] ?? definition.taskDefaults?.[taskName(id)] ?? {};
212
397
  const merged = { ...defaults, ...taskDefinition };
213
398
  const runItems = merged.steps ? [...merged.steps] : runItemsFromDefinition(merged.run);
214
399
  const { steps, cacheDirectives, dependsOnDirectives } = partitionRunItems(runItems);
215
- const liftedDependsOn = uniqueTaskIds([
400
+ const liftedDependsOn = resolveTaskDependencies(id, groupPath, uniqueTaskIds([
216
401
  ...(merged.dependsOn ?? []),
217
402
  ...dependsOnDirectives.flatMap((directive) => directive.taskIds)
218
- ]);
403
+ ]), knownTaskIds);
219
404
  const cache = normalizeCache(merged.cache ?? cacheDirectives[0], cacheRegistry);
220
405
  const retry = normalizeRetry(merged.retry);
221
406
  const timeoutMs = normalizeTimeout(merged.timeout);
@@ -246,6 +431,7 @@ export function normalizePipeline(definition) {
246
431
  commands: definition.commands ? normalizeCommandPolicy(definition.commands) : undefined,
247
432
  agents: normalizeAgents(definition.agents),
248
433
  sandboxes: normalizeSandboxes(definition.sandboxes),
434
+ execution: normalizeExecutionProfiles(definition.execution),
249
435
  cache: cacheRegistry,
250
436
  namedInputs,
251
437
  triggers: definition.triggers ?? {},
@@ -260,6 +446,12 @@ export function normalizePipeline(definition) {
260
446
  function isDefaultOnlyEnvOptions(value) {
261
447
  return typeof value.default === "string";
262
448
  }
449
+ function isObjectRecord(value) {
450
+ return typeof value === "object" && value !== null && !Array.isArray(value);
451
+ }
452
+ function isEnvVarRef(value) {
453
+ return isObjectRecord(value) && value.kind === "async-pipeline.env.var";
454
+ }
263
455
  function normalizeAgents(definitions = {}) {
264
456
  const normalized = {};
265
457
  for (const [id, profile] of Object.entries(definitions)) {
@@ -277,11 +469,18 @@ function normalizeSandboxes(definitions = {}) {
277
469
  }
278
470
  return normalized;
279
471
  }
472
+ function normalizeExecutionProfiles(definitions = {}) {
473
+ const normalized = {};
474
+ for (const [id, definition] of Object.entries(definitions)) {
475
+ normalized[id] = cloneExecutionProfile(definition);
476
+ }
477
+ return normalized;
478
+ }
280
479
  function normalizeCommandPolicy(policy) {
281
480
  return command.policy(policy);
282
481
  }
283
482
  function cloneSandboxDefinition(definition) {
284
- if (definition.kind === "docker") {
483
+ if (definition.kind === "docker" || definition.kind === "container") {
285
484
  return {
286
485
  ...definition,
287
486
  volumes: definition.volumes ? definition.volumes.map((volume) => ({ ...volume })) : undefined
@@ -289,6 +488,19 @@ function cloneSandboxDefinition(definition) {
289
488
  }
290
489
  return { ...definition };
291
490
  }
491
+ function cloneExecutionProfile(definition) {
492
+ if (definition.kind === "github") {
493
+ return {
494
+ ...definition,
495
+ runsOn: definition.runsOn ? cloneRunsOnEntry(definition.runsOn) : undefined,
496
+ runsOnMatrix: definition.runsOnMatrix ? definition.runsOnMatrix.map(cloneRunsOnEntry) : undefined
497
+ };
498
+ }
499
+ return { ...definition };
500
+ }
501
+ function cloneRunsOnEntry(entry) {
502
+ return Array.isArray(entry) ? [...entry] : entry;
503
+ }
292
504
  function cloneCommandRule(rule) {
293
505
  return {
294
506
  exact: rule.exact ? [...rule.exact] : undefined,
@@ -323,7 +535,70 @@ function validateJobRunsOn(jobId, github) {
323
535
  }
324
536
  }
325
537
  }
538
+ function githubConfigFromExecution(profile) {
539
+ if (!profile || profile.kind !== "github")
540
+ return undefined;
541
+ return {
542
+ runsOn: profile.runsOn ? cloneRunsOnEntry(profile.runsOn) : undefined,
543
+ runsOnMatrix: profile.runsOnMatrix ? profile.runsOnMatrix.map(cloneRunsOnEntry) : undefined
544
+ };
545
+ }
546
+ export function githubConfigForJob(pipeline, jobDefinition) {
547
+ const profileGithub = githubConfigFromExecution(jobDefinition.execution ? pipeline.execution[jobDefinition.execution] : undefined);
548
+ if (!profileGithub && !jobDefinition.github)
549
+ return undefined;
550
+ return {
551
+ ...profileGithub,
552
+ ...jobDefinition.github
553
+ };
554
+ }
555
+ function validateExecutionProfiles(pipeline) {
556
+ for (const [id, profile] of Object.entries(pipeline.execution)) {
557
+ if (profile.sandbox !== undefined) {
558
+ const sandboxDefinition = pipeline.sandboxes[profile.sandbox];
559
+ if (!sandboxDefinition) {
560
+ throw pipelineError("ASYNC_PIPELINE_EXECUTION_UNKNOWN_SANDBOX", `Execution profile "${id}" references unknown sandbox "${profile.sandbox}". Declare it under sandboxes.`);
561
+ }
562
+ if (profile.provider !== undefined && sandboxDefinition.kind !== "container") {
563
+ throw pipelineError("ASYNC_PIPELINE_EXECUTION_PROVIDER_MISMATCH", `Execution profile "${id}" sets provider "${profile.provider}", but providers only apply to sandbox.container(...) definitions.`);
564
+ }
565
+ }
566
+ else if (profile.provider !== undefined) {
567
+ throw pipelineError("ASYNC_PIPELINE_EXECUTION_PROVIDER_MISMATCH", `Execution profile "${id}" sets provider "${profile.provider}" without a sandbox.`);
568
+ }
569
+ if (profile.kind === "github") {
570
+ validateJobRunsOn(`execution:${id}`, githubConfigFromExecution(profile));
571
+ }
572
+ }
573
+ }
574
+ function validateJobExecution(pipeline, jobDefinition) {
575
+ if (!jobDefinition.execution)
576
+ return;
577
+ const profile = pipeline.execution[jobDefinition.execution];
578
+ if (!profile) {
579
+ throw pipelineError("ASYNC_PIPELINE_EXECUTION_UNKNOWN", `Job "${jobDefinition.id}" references unknown execution profile "${jobDefinition.execution}". Declare it under execution.`);
580
+ }
581
+ const effectiveGithub = githubConfigForJob(pipeline, jobDefinition);
582
+ validateJobRunsOn(jobDefinition.id, effectiveGithub);
583
+ if (profile.kind === "github" && profile.provider === "apple-container") {
584
+ validateAppleContainerRunsOn(jobDefinition.id, effectiveGithub);
585
+ }
586
+ }
587
+ function validateAppleContainerRunsOn(jobId, github) {
588
+ const entries = github?.runsOnMatrix ?? (github?.runsOn ? [github.runsOn] : []);
589
+ if (entries.length === 0) {
590
+ throw pipelineError("ASYNC_PIPELINE_EXECUTION_RUNNER_UNSUPPORTED", `Job "${jobId}" uses provider "apple-container", which requires an explicit self-hosted macOS runner label set.`);
591
+ }
592
+ for (const entry of entries) {
593
+ const labels = Array.isArray(entry) ? entry : [entry];
594
+ const normalized = labels.map((label) => label.toLowerCase());
595
+ if (!Array.isArray(entry) || !normalized.includes("self-hosted") || !normalized.includes("macos")) {
596
+ throw pipelineError("ASYNC_PIPELINE_EXECUTION_RUNNER_UNSUPPORTED", `Job "${jobId}" uses provider "apple-container", which requires a self-hosted macOS runner label set.`);
597
+ }
598
+ }
599
+ }
326
600
  export function validatePipeline(pipeline) {
601
+ validateExecutionProfiles(pipeline);
327
602
  for (const taskDefinition of Object.values(pipeline.tasks)) {
328
603
  for (const dependency of taskDefinition.dependsOn) {
329
604
  if (!pipeline.tasks[dependency] && !isKnownExternalTaskRef(pipeline, dependency)) {
@@ -353,7 +628,9 @@ export function validatePipeline(pipeline) {
353
628
  throw new Error(`Job "${jobDefinition.id}" references missing trigger "${triggerId}".`);
354
629
  }
355
630
  }
356
- validateJobRunsOn(jobDefinition.id, jobDefinition.github);
631
+ validateJobExecution(pipeline, jobDefinition);
632
+ if (!jobDefinition.execution)
633
+ validateJobRunsOn(jobDefinition.id, jobDefinition.github);
357
634
  }
358
635
  validateSyncConfig(pipeline);
359
636
  // Fail fast on named-input cycles at definePipeline time instead of
@@ -772,7 +1049,8 @@ function partitionRunItems(items) {
772
1049
  const steps = [];
773
1050
  const cacheDirectives = [];
774
1051
  const dependsOnDirectives = [];
775
- for (const item of items) {
1052
+ for (const rawItem of items) {
1053
+ const item = normalizeDeclaredRunItem(rawItem);
776
1054
  if (isCacheDirective(item)) {
777
1055
  cacheDirectives.push(item);
778
1056
  continue;
@@ -788,10 +1066,49 @@ function partitionRunItems(items) {
788
1066
  }
789
1067
  return { steps, cacheDirectives, dependsOnDirectives };
790
1068
  }
1069
+ function normalizeDeclaredRunItem(item) {
1070
+ const metadata = assertSupportedDeclaration(item);
1071
+ if (!metadata || !isObjectRecord(item))
1072
+ return item;
1073
+ if (metadata.kind === "shell") {
1074
+ rejectUnknownFields(SHELL_STEP_FIELDS, item, "shell declaration");
1075
+ const command = item.command;
1076
+ if (typeof command !== "string") {
1077
+ throw pipelineError("ASYNC_PIPELINE_DECLARATION_INVALID", "Shell declaration requires a string command.");
1078
+ }
1079
+ return brandDeclaration({ kind: "shell", command }, "shell");
1080
+ }
1081
+ if (metadata.kind === "deferred-shell") {
1082
+ rejectUnknownFields(SHELL_STEP_FIELDS, item, "deferred shell declaration");
1083
+ const command = item.command;
1084
+ if (typeof command !== "function") {
1085
+ throw pipelineError("ASYNC_PIPELINE_DECLARATION_INVALID", "Deferred shell declaration requires a command function.");
1086
+ }
1087
+ return brandDeclaration({ kind: "deferred-shell", command: command }, "deferred-shell");
1088
+ }
1089
+ if (metadata.kind === "agent") {
1090
+ rejectUnknownFields(DECLARED_AGENT_STEP_FIELDS, item, "agent declaration");
1091
+ const use = item.use;
1092
+ const prompt = item.prompt;
1093
+ if ((typeof use !== "string" && !isEnvVarRef(use)) || (typeof use === "string" && use.length === 0)) {
1094
+ throw pipelineError("ASYNC_PIPELINE_AGENT_INVALID", 'agent declaration requires "use": a non-empty profile id or env.var(...).');
1095
+ }
1096
+ if (typeof prompt !== "string" || prompt.length === 0) {
1097
+ throw pipelineError("ASYNC_PIPELINE_AGENT_INVALID", 'agent declaration requires a non-empty "prompt" string.');
1098
+ }
1099
+ const step = brandDeclaration({ kind: "agent", use: use, prompt }, "agent");
1100
+ if (item.model !== undefined)
1101
+ step.model = item.model;
1102
+ if (item.stdoutTo !== undefined)
1103
+ step.stdoutTo = item.stdoutTo;
1104
+ return step;
1105
+ }
1106
+ return item;
1107
+ }
791
1108
  function isDependsOnDirective(value) {
792
1109
  return Boolean(value)
793
1110
  && typeof value === "object"
794
- && value.kind === "async-pipeline.directive.dependsOn";
1111
+ && (value.kind === "async-pipeline.directive.dependsOn" || hasDeclarationKind(value, "directive.dependsOn"));
795
1112
  }
796
1113
  function uniqueTaskIds(taskIds) {
797
1114
  return [...new Set(taskIds)];