@oisincoveney/pipeline 1.11.3 → 1.13.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.
package/README.md CHANGED
@@ -128,6 +128,9 @@ flushing.
128
128
  The console starts the image with `OISIN_PIPELINE_RUNNER_PAYLOAD_JSON` and the
129
129
  runner-side event token. The payload contract is documented in
130
130
  [`docs/pipeline-console-runner-contract.md`](docs/pipeline-console-runner-contract.md).
131
+ The executable contract is exported from
132
+ `@oisincoveney/pipeline/runner-job-contract` for payload construction,
133
+ validation, contract-version checks, and JSON Schema generation.
131
134
  Use `PIPELINE_TARGET_PATH=/path/to/worktree` when the checked-out target repo is
132
135
  mounted somewhere other than the process working directory.
133
136
 
@@ -374,6 +377,17 @@ import {
374
377
  } from "@oisincoveney/pipeline/runtime";
375
378
  ```
376
379
 
380
+ Runner Job producers can import the shared payload contract:
381
+
382
+ ```ts
383
+ import {
384
+ RUNNER_JOB_CONTRACT_VERSION,
385
+ buildRunnerJobPayload,
386
+ parseRunnerJobPayload,
387
+ runnerJobPayloadJsonSchema,
388
+ } from "@oisincoveney/pipeline/runner-job-contract";
389
+ ```
390
+
377
391
  ## Verification
378
392
 
379
393
  Use these commands before committing changes in this repository:
package/dist/config.d.ts CHANGED
@@ -231,10 +231,10 @@ declare const configSchema: z.ZodObject<{
231
231
  }, z.core.$strict>>;
232
232
  output: z.ZodOptional<z.ZodObject<{
233
233
  format: z.ZodEnum<{
234
- json_schema: "json_schema";
235
234
  text: "text";
236
235
  json: "json";
237
236
  jsonl: "jsonl";
237
+ json_schema: "json_schema";
238
238
  }>;
239
239
  repair: z.ZodOptional<z.ZodObject<{
240
240
  enabled: z.ZodOptional<z.ZodBoolean>;
@@ -274,10 +274,10 @@ declare const configSchema: z.ZodObject<{
274
274
  disabled: "disabled";
275
275
  }>>>;
276
276
  output_formats: z.ZodOptional<z.ZodArray<z.ZodEnum<{
277
- json_schema: "json_schema";
278
277
  text: "text";
279
278
  json: "json";
280
279
  jsonl: "jsonl";
280
+ json_schema: "json_schema";
281
281
  }>>>;
282
282
  rules: z.ZodOptional<z.ZodBoolean>;
283
283
  skills: z.ZodOptional<z.ZodBoolean>;
@@ -1,6 +1,6 @@
1
1
  import { PipelineConfigError } from "./config.js";
2
2
  import { runPipelineFromConfig } from "./pipeline-runtime.js";
3
- import { RUNNER_PAYLOAD_ENV, parseRunnerJobPayload, resolveRunnerEventSinkAuthToken } from "./runner-job-contract.js";
3
+ import { RUNNER_PAYLOAD_ENV, parseRunnerJobPayloadWithIssues, resolveRunnerEventSinkAuthToken } from "./runner-job-contract.js";
4
4
  import { createRunnerEventSink } from "./runner-event-sink.js";
5
5
  //#region src/kubernetes-runner.ts
6
6
  const EXIT_PASS = 0;
@@ -11,6 +11,7 @@ const EXIT_STARTUP = 70;
11
11
  async function runKubernetesRunnerJob(options = {}) {
12
12
  const prepared = prepareRunnerJob(options);
13
13
  if (prepared.exitCode !== void 0) return prepared.exitCode;
14
+ if ("validationFailure" in prepared) return reportPreparedValidationFailure(prepared.validationFailure, options);
14
15
  const { authToken, env, payload, stderr } = prepared.job;
15
16
  const controller = new AbortController();
16
17
  const signalEmitter = options.signalEmitter ?? process;
@@ -51,6 +52,7 @@ async function runKubernetesRunnerJob(options = {}) {
51
52
  runId: payload.run.runId,
52
53
  signal: controller.signal,
53
54
  task: payload.task.prompt,
55
+ hookPolicy: { allowCommandHooks: payload.selector.allowCommandHooks },
54
56
  workflowId: payload.selector.workflowId,
55
57
  worktreePath: env.PIPELINE_TARGET_PATH ?? options.cwd ?? process.cwd()
56
58
  });
@@ -77,24 +79,40 @@ function prepareRunnerJob(options) {
77
79
  return { exitCode: EXIT_VALIDATION };
78
80
  }
79
81
  const payload = parsePayload(payloadRaw, stderr);
80
- if (!payload) return { exitCode: EXIT_VALIDATION };
82
+ if (!payload.ok) return { validationFailure: {
83
+ authToken: resolveAuthToken(env, stderr) ?? void 0,
84
+ env,
85
+ error: payload.error,
86
+ recoverable: payload.recoverable,
87
+ stderr
88
+ } };
81
89
  const authToken = resolveAuthToken(env, stderr);
82
90
  if (!authToken) return { exitCode: EXIT_VALIDATION };
83
91
  return { job: {
84
92
  authToken,
85
93
  env,
86
- payload,
94
+ payload: payload.payload,
87
95
  stderr
88
96
  } };
89
97
  }
90
98
  function parsePayload(payloadRaw, stderr) {
91
- try {
92
- return parseRunnerJobPayload(payloadRaw);
93
- } catch (err) {
94
- const message = err instanceof Error ? err.message : String(err);
95
- stderr.write(`${message}\n`);
96
- return null;
99
+ const result = parseRunnerJobPayloadWithIssues(payloadRaw);
100
+ if (!result.ok) stderr.write(`${result.error.message}\n`);
101
+ return result;
102
+ }
103
+ async function reportPreparedValidationFailure(failure, options) {
104
+ if (failure.recoverable && failure.authToken) {
105
+ const sink = createRunnerEventSink({
106
+ authHeader: failure.recoverable.eventSink.authHeader,
107
+ authToken: failure.authToken,
108
+ fetch: options.fetch,
109
+ runId: failure.recoverable.run.runId,
110
+ url: failure.recoverable.eventSink.url
111
+ });
112
+ sink.recordSchemaValidationFailure(failure.error.message, failure.error.issues, failure.recoverable.workflowId);
113
+ await flushAndReport(sink.flush, failure.stderr);
97
114
  }
115
+ return EXIT_VALIDATION;
98
116
  }
99
117
  function resolveAuthToken(env, stderr) {
100
118
  try {
@@ -734,6 +734,7 @@ const SCAFFOLD_FILES = {
734
734
  "",
735
735
  "Use the provided backlog work units as the source of truth when present. Assign each work unit to explicit generated agent nodes with only its `task_context.id`, use only allowed configured profiles, and ensure implementation work has downstream acceptance, verification, or review coverage. Do not invent profiles or node-level skill overrides.",
736
736
  "Do not copy backlog descriptions or acceptance criteria into output; the scheduler hydrates them from the assigned `task_context.id` after parsing.",
737
+ "Preserve Backlog dependency ids as schedule needs edges. A node assigned a dependent work unit must depend on the nodes assigned its prerequisite work units, directly or through an explicit path.",
737
738
  "",
738
739
  "Shape the graph by intent, not by ticket count. Do not create a full RED/GREEN/ACCEPTANCE/VERIFY chain for each backlog ticket unless each step needs ticket-specific evidence. Use one RED node for a group of tickets when they share a test strategy, fan out to parallel GREEN implementation nodes where the work can be implemented independently, and fan back in to shared acceptance or verifier nodes when the same acceptance checklist or real repository commands prove the group. Only serialize ticket nodes when the backlog, a shared migration/schema/API dependency, or implementation risk requires it.",
739
740
  "",
@@ -87,7 +87,27 @@ function createRunnerEventSink(options) {
87
87
  workflowId
88
88
  });
89
89
  },
90
- recordRuntimeEvent
90
+ recordRuntimeEvent,
91
+ recordSchemaValidationFailure(message, issues, workflowId) {
92
+ queue.push({
93
+ ...nextEnvelope(),
94
+ log: {
95
+ level: "warn",
96
+ message: `Runner payload schema validation failed: ${message}`,
97
+ output: { issues },
98
+ workflowId
99
+ },
100
+ type: "runner.schema.validation"
101
+ });
102
+ queue.push({
103
+ ...nextEnvelope(),
104
+ finalResult: {
105
+ outcome: "FAIL",
106
+ workflowId
107
+ },
108
+ type: "workflow.finish"
109
+ });
110
+ }
91
111
  };
92
112
  }
93
113
  async function postBatch(options, fetchImpl, events) {
@@ -0,0 +1,281 @@
1
+ import { PipelineRuntimeEvent } from "./pipeline-runtime.js";
2
+ import { z } from "zod";
3
+
4
+ //#region src/runner-job-contract.d.ts
5
+ declare const RUNNER_PAYLOAD_ENV = "OISIN_PIPELINE_RUNNER_PAYLOAD_JSON";
6
+ declare const RUNNER_JOB_CONTRACT_VERSION = "1";
7
+ declare const runnerEventSinkConfigSchema: z.ZodObject<{
8
+ authHeader: z.ZodString;
9
+ url: z.ZodString;
10
+ }, z.core.$strict>;
11
+ declare const runnerRunIdentitySchema: z.ZodObject<{
12
+ projectId: z.ZodString;
13
+ requestedBy: z.ZodOptional<z.ZodString>;
14
+ runId: z.ZodString;
15
+ }, z.core.$strict>;
16
+ declare const runnerWorkflowSelectorSchema: z.ZodObject<{
17
+ allowCommandHooks: z.ZodDefault<z.ZodBoolean>;
18
+ workflowId: z.ZodString;
19
+ }, z.core.$strict>;
20
+ declare const runnerTaskPromptSchema: z.ZodObject<{
21
+ filePath: z.ZodOptional<z.ZodString>;
22
+ githubUrl: z.ZodOptional<z.ZodString>;
23
+ prompt: z.ZodString;
24
+ taskId: z.ZodString;
25
+ title: z.ZodOptional<z.ZodString>;
26
+ }, z.core.$strict>;
27
+ declare const runnerRepositoryContextSchema: z.ZodObject<{
28
+ branch: z.ZodString;
29
+ cloneUrl: z.ZodString;
30
+ fullName: z.ZodString;
31
+ owner: z.ZodString;
32
+ repo: z.ZodString;
33
+ sha: z.ZodString;
34
+ }, z.core.$strict>;
35
+ declare const runnerMomokayaContextSchema: z.ZodObject<{
36
+ automationNamespace: z.ZodOptional<z.ZodString>;
37
+ previewEnabled: z.ZodBoolean;
38
+ repoKey: z.ZodOptional<z.ZodString>;
39
+ }, z.core.$strict>;
40
+ declare const runnerJobPayloadSchema: z.ZodObject<{
41
+ contractVersion: z.ZodDefault<z.ZodLiteral<"1">>;
42
+ eventSink: z.ZodObject<{
43
+ authHeader: z.ZodString;
44
+ url: z.ZodString;
45
+ }, z.core.$strict>;
46
+ momokaya: z.ZodOptional<z.ZodObject<{
47
+ automationNamespace: z.ZodOptional<z.ZodString>;
48
+ previewEnabled: z.ZodBoolean;
49
+ repoKey: z.ZodOptional<z.ZodString>;
50
+ }, z.core.$strict>>;
51
+ repository: z.ZodOptional<z.ZodObject<{
52
+ branch: z.ZodString;
53
+ cloneUrl: z.ZodString;
54
+ fullName: z.ZodString;
55
+ owner: z.ZodString;
56
+ repo: z.ZodString;
57
+ sha: z.ZodString;
58
+ }, z.core.$strict>>;
59
+ run: z.ZodObject<{
60
+ projectId: z.ZodString;
61
+ requestedBy: z.ZodOptional<z.ZodString>;
62
+ runId: z.ZodString;
63
+ }, z.core.$strict>;
64
+ selector: z.ZodObject<{
65
+ allowCommandHooks: z.ZodDefault<z.ZodBoolean>;
66
+ workflowId: z.ZodString;
67
+ }, z.core.$strict>;
68
+ task: z.ZodObject<{
69
+ filePath: z.ZodOptional<z.ZodString>;
70
+ githubUrl: z.ZodOptional<z.ZodString>;
71
+ prompt: z.ZodString;
72
+ taskId: z.ZodString;
73
+ title: z.ZodOptional<z.ZodString>;
74
+ }, z.core.$strict>;
75
+ }, z.core.$strict>;
76
+ type RunnerEventSinkConfig = z.infer<typeof runnerEventSinkConfigSchema>;
77
+ type RunnerJobPayload = z.infer<typeof runnerJobPayloadSchema>;
78
+ type RunnerMomokayaContext = z.infer<typeof runnerMomokayaContextSchema>;
79
+ type RunnerRepositoryContext = z.infer<typeof runnerRepositoryContextSchema>;
80
+ type RunnerRunIdentity = z.infer<typeof runnerRunIdentitySchema>;
81
+ type RunnerTaskPrompt = z.infer<typeof runnerTaskPromptSchema>;
82
+ type RunnerWorkflowSelector = z.infer<typeof runnerWorkflowSelectorSchema>;
83
+ declare const runnerJobPayloadJsonSchema: z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
84
+ contractVersion: z.ZodDefault<z.ZodLiteral<"1">>;
85
+ eventSink: z.ZodObject<{
86
+ authHeader: z.ZodString;
87
+ url: z.ZodString;
88
+ }, z.core.$strict>;
89
+ momokaya: z.ZodOptional<z.ZodObject<{
90
+ automationNamespace: z.ZodOptional<z.ZodString>;
91
+ previewEnabled: z.ZodBoolean;
92
+ repoKey: z.ZodOptional<z.ZodString>;
93
+ }, z.core.$strict>>;
94
+ repository: z.ZodOptional<z.ZodObject<{
95
+ branch: z.ZodString;
96
+ cloneUrl: z.ZodString;
97
+ fullName: z.ZodString;
98
+ owner: z.ZodString;
99
+ repo: z.ZodString;
100
+ sha: z.ZodString;
101
+ }, z.core.$strict>>;
102
+ run: z.ZodObject<{
103
+ projectId: z.ZodString;
104
+ requestedBy: z.ZodOptional<z.ZodString>;
105
+ runId: z.ZodString;
106
+ }, z.core.$strict>;
107
+ selector: z.ZodObject<{
108
+ allowCommandHooks: z.ZodDefault<z.ZodBoolean>;
109
+ workflowId: z.ZodString;
110
+ }, z.core.$strict>;
111
+ task: z.ZodObject<{
112
+ filePath: z.ZodOptional<z.ZodString>;
113
+ githubUrl: z.ZodOptional<z.ZodString>;
114
+ prompt: z.ZodString;
115
+ taskId: z.ZodString;
116
+ title: z.ZodOptional<z.ZodString>;
117
+ }, z.core.$strict>;
118
+ }, z.core.$strict>>;
119
+ interface RunnerJobPayloadValidationIssue {
120
+ code: string;
121
+ message: string;
122
+ path: string;
123
+ }
124
+ interface RecoverableRunnerJobPayloadEnvelope {
125
+ eventSink: RunnerEventSinkConfig;
126
+ run: RunnerRunIdentity;
127
+ workflowId: string;
128
+ }
129
+ declare class RunnerJobPayloadValidationError extends Error {
130
+ readonly issues: RunnerJobPayloadValidationIssue[];
131
+ constructor(message: string, issues: RunnerJobPayloadValidationIssue[]);
132
+ }
133
+ type RunnerJobPayloadParseResult = {
134
+ ok: true;
135
+ payload: RunnerJobPayload;
136
+ } | {
137
+ error: RunnerJobPayloadValidationError;
138
+ ok: false;
139
+ recoverable?: RecoverableRunnerJobPayloadEnvelope;
140
+ };
141
+ interface BuildRunnerJobPayloadOptions {
142
+ allowCommandHooks?: boolean;
143
+ eventSink: RunnerEventSinkConfig;
144
+ momokaya?: RunnerMomokayaContext;
145
+ repository?: RunnerRepositoryContext;
146
+ run: RunnerRunIdentity;
147
+ task: RunnerTaskPrompt;
148
+ workflowId: string;
149
+ }
150
+ interface CreateRunnerJobPayloadEnvOptions {
151
+ allowCommandHooks?: boolean;
152
+ eventSinkUrl: string;
153
+ projectId: string;
154
+ requestedBy?: string;
155
+ runId: string;
156
+ taskId: string;
157
+ taskPrompt: string;
158
+ workflowId: string;
159
+ }
160
+ interface ResolveRunnerEventSinkAuthTokenOptions {
161
+ env?: Record<string, string | undefined>;
162
+ serviceAccountTokenPath?: string;
163
+ }
164
+ interface RunnerEventMappingContext {
165
+ runId: string;
166
+ sequence?: number;
167
+ timestamp: string;
168
+ }
169
+ interface RunnerWorkflowNodeDetails {
170
+ id: string;
171
+ kind: string;
172
+ needs: string[];
173
+ profile?: string;
174
+ runnerId?: string;
175
+ }
176
+ interface RunnerWorkflowEdgeDetails {
177
+ source: string;
178
+ target: string;
179
+ }
180
+ interface RunnerWorkflowPlanDetails {
181
+ edges?: RunnerWorkflowEdgeDetails[];
182
+ nodeIds?: string[];
183
+ nodes?: RunnerWorkflowNodeDetails[];
184
+ workflowId: string;
185
+ }
186
+ interface RunnerWorkflowEdgeRecordDetails {
187
+ id: string;
188
+ source: string;
189
+ target: string;
190
+ }
191
+ type RunnerNodeStatus = "agent-finished" | "agent-running" | "failed" | "passed" | "running";
192
+ interface RunnerNodeDetails {
193
+ attempt: number;
194
+ exitCode?: number;
195
+ nodeId: string;
196
+ profile?: string;
197
+ runnerId?: string;
198
+ status: RunnerNodeStatus;
199
+ }
200
+ type RunnerGateStatus = "failed" | "passed" | "running";
201
+ interface RunnerGateDetails {
202
+ event?: string;
203
+ evidence?: string[];
204
+ gateId?: string;
205
+ hookId?: string;
206
+ kind?: string;
207
+ label?: string;
208
+ nodeId?: string;
209
+ passed?: boolean;
210
+ reason?: string;
211
+ required?: boolean;
212
+ status: RunnerGateStatus;
213
+ workflowId?: string;
214
+ }
215
+ type RunnerArtifactStatus = "failed" | "passed" | "running";
216
+ interface RunnerArtifactDetails {
217
+ kind: "artifact";
218
+ label: string;
219
+ nodeId: string;
220
+ passed?: boolean;
221
+ path: string;
222
+ reason?: string;
223
+ required: boolean;
224
+ status: RunnerArtifactStatus;
225
+ uri: string;
226
+ }
227
+ type RunnerLogLevel = "info" | "warn";
228
+ interface RunnerLogDetails {
229
+ attempt?: number;
230
+ format?: string;
231
+ level: RunnerLogLevel;
232
+ message: string;
233
+ nodeId?: string;
234
+ output?: unknown;
235
+ passed?: boolean;
236
+ reason?: string;
237
+ workflowId?: string;
238
+ }
239
+ interface RunnerFinalResultDetails {
240
+ outcome: "CANCELLED" | "FAIL" | "PASS";
241
+ workflowId: string;
242
+ }
243
+ interface RunnerEventEnvelope {
244
+ at?: string;
245
+ sequence: number;
246
+ type: string;
247
+ }
248
+ type RunnerEventRecord = (RunnerEventEnvelope & {
249
+ type: "workflow.planned" | "workflow.start";
250
+ workflowPlan: RunnerWorkflowPlanDetails;
251
+ }) | (RunnerEventEnvelope & {
252
+ edge: RunnerWorkflowEdgeRecordDetails;
253
+ type: "workflow.edge";
254
+ }) | (RunnerEventEnvelope & {
255
+ node: RunnerNodeDetails;
256
+ type: "agent.finish" | "agent.start" | "node.finish" | "node.start";
257
+ }) | (RunnerEventEnvelope & {
258
+ gate: RunnerGateDetails;
259
+ type: "gate.finish" | "gate.start" | "hook.finish" | "hook.start";
260
+ }) | (RunnerEventEnvelope & {
261
+ artifact: RunnerArtifactDetails;
262
+ type: "artifact.check.finish" | "artifact.check.start";
263
+ }) | (RunnerEventEnvelope & {
264
+ log: RunnerLogDetails;
265
+ type: "node.output.recorded" | "output.repair" | "run.cancelled" | "runner.schema.validation" | "runtime.observability";
266
+ }) | (RunnerEventEnvelope & {
267
+ finalResult: RunnerFinalResultDetails;
268
+ type: "workflow.finish";
269
+ });
270
+ declare function resolveRunnerEventSinkAuthToken(options?: ResolveRunnerEventSinkAuthTokenOptions): string;
271
+ declare function resolveRunnerEventSinkAuthHeader(options?: ResolveRunnerEventSinkAuthTokenOptions): string;
272
+ declare function createRunnerJobPayloadEnv(options: CreateRunnerJobPayloadEnvOptions): {
273
+ name: typeof RUNNER_PAYLOAD_ENV;
274
+ value: string;
275
+ };
276
+ declare function buildRunnerJobPayload(options: BuildRunnerJobPayloadOptions): RunnerJobPayload;
277
+ declare function parseRunnerJobPayload(rawPayload: string): RunnerJobPayload;
278
+ declare function parseRunnerJobPayloadWithIssues(rawPayload: string): RunnerJobPayloadParseResult;
279
+ declare function mapRuntimeEventToRunnerEventRecords(event: PipelineRuntimeEvent, context: RunnerEventMappingContext): RunnerEventRecord[];
280
+ //#endregion
281
+ export { BuildRunnerJobPayloadOptions, CreateRunnerJobPayloadEnvOptions, RUNNER_JOB_CONTRACT_VERSION, RUNNER_PAYLOAD_ENV, RecoverableRunnerJobPayloadEnvelope, ResolveRunnerEventSinkAuthTokenOptions, RunnerArtifactDetails, RunnerArtifactStatus, RunnerEventMappingContext, RunnerEventRecord, RunnerEventSinkConfig, RunnerFinalResultDetails, RunnerGateDetails, RunnerGateStatus, RunnerJobPayload, RunnerJobPayloadParseResult, RunnerJobPayloadValidationError, RunnerJobPayloadValidationIssue, RunnerLogDetails, RunnerLogLevel, RunnerMomokayaContext, RunnerNodeDetails, RunnerNodeStatus, RunnerRepositoryContext, RunnerRunIdentity, RunnerTaskPrompt, RunnerWorkflowEdgeDetails, RunnerWorkflowEdgeRecordDetails, RunnerWorkflowNodeDetails, RunnerWorkflowPlanDetails, RunnerWorkflowSelector, buildRunnerJobPayload, createRunnerJobPayloadEnv, mapRuntimeEventToRunnerEventRecords, parseRunnerJobPayload, parseRunnerJobPayloadWithIssues, resolveRunnerEventSinkAuthHeader, resolveRunnerEventSinkAuthToken, runnerEventSinkConfigSchema, runnerJobPayloadJsonSchema, runnerJobPayloadSchema, runnerMomokayaContextSchema, runnerRepositoryContextSchema, runnerRunIdentitySchema, runnerTaskPromptSchema, runnerWorkflowSelectorSchema };
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { z } from "zod";
4
4
  //#region src/runner-job-contract.ts
5
5
  const RUNNER_PAYLOAD_ENV = "OISIN_PIPELINE_RUNNER_PAYLOAD_JSON";
6
+ const RUNNER_JOB_CONTRACT_VERSION = "1";
6
7
  const runnerEventSinkConfigSchema = z.object({
7
8
  authHeader: z.string().min(1),
8
9
  url: z.string().url()
@@ -12,17 +13,48 @@ const runnerRunIdentitySchema = z.object({
12
13
  requestedBy: z.string().min(1).optional(),
13
14
  runId: z.string().min(1)
14
15
  }).strict();
15
- const runnerWorkflowSelectorSchema = z.object({ workflowId: z.string().min(1) }).strict();
16
+ const runnerWorkflowSelectorSchema = z.object({
17
+ allowCommandHooks: z.boolean().default(true),
18
+ workflowId: z.string().min(1)
19
+ }).strict();
16
20
  const runnerTaskPromptSchema = z.object({
21
+ filePath: z.string().min(1).optional(),
22
+ githubUrl: z.string().url().optional(),
17
23
  prompt: z.string().min(1),
18
- taskId: z.string().min(1)
24
+ taskId: z.string().min(1),
25
+ title: z.string().min(1).optional()
26
+ }).strict();
27
+ const runnerRepositoryContextSchema = z.object({
28
+ branch: z.string().min(1),
29
+ cloneUrl: z.string().url(),
30
+ fullName: z.string().min(1),
31
+ owner: z.string().min(1),
32
+ repo: z.string().min(1),
33
+ sha: z.string().min(1)
34
+ }).strict();
35
+ const runnerMomokayaContextSchema = z.object({
36
+ automationNamespace: z.string().min(1).optional(),
37
+ previewEnabled: z.boolean(),
38
+ repoKey: z.string().min(1).optional()
19
39
  }).strict();
20
40
  const runnerJobPayloadSchema = z.object({
41
+ contractVersion: z.literal("1", { error: "runner job payload contract version must be 1" }).default("1"),
21
42
  eventSink: runnerEventSinkConfigSchema,
43
+ momokaya: runnerMomokayaContextSchema.optional(),
44
+ repository: runnerRepositoryContextSchema.optional(),
22
45
  run: runnerRunIdentitySchema,
23
46
  selector: runnerWorkflowSelectorSchema,
24
47
  task: runnerTaskPromptSchema
25
48
  }).strict();
49
+ const runnerJobPayloadJsonSchema = z.toJSONSchema(runnerJobPayloadSchema);
50
+ var RunnerJobPayloadValidationError = class extends Error {
51
+ issues;
52
+ constructor(message, issues) {
53
+ super(message);
54
+ this.name = "RunnerJobPayloadValidationError";
55
+ this.issues = issues;
56
+ }
57
+ };
26
58
  const EVENT_AUTH_TOKEN_ENV_KEYS = ["OISIN_PIPELINE_EVENT_AUTH_TOKEN", "PIPELINE_EVENT_API_TOKEN"];
27
59
  const DEFAULT_SERVICE_ACCOUNT_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
28
60
  function resolveRunnerEventSinkAuthToken(options = {}) {
@@ -38,17 +70,79 @@ function resolveRunnerEventSinkAuthToken(options = {}) {
38
70
  }
39
71
  throw new Error(`Runner event auth token is required. Set ${EVENT_AUTH_TOKEN_ENV_KEYS.join(" or ")}, or mount a readable Kubernetes service account token at ${tokenPath}.`);
40
72
  }
73
+ function resolveRunnerEventSinkAuthHeader(options = {}) {
74
+ return `Bearer ${resolveRunnerEventSinkAuthToken(options)}`;
75
+ }
76
+ function createRunnerJobPayloadEnv(options) {
77
+ const payload = buildRunnerJobPayload({
78
+ eventSink: {
79
+ authHeader: "Authorization",
80
+ url: options.eventSinkUrl
81
+ },
82
+ run: {
83
+ projectId: options.projectId,
84
+ requestedBy: options.requestedBy,
85
+ runId: options.runId
86
+ },
87
+ allowCommandHooks: options.allowCommandHooks,
88
+ task: {
89
+ prompt: options.taskPrompt,
90
+ taskId: options.taskId
91
+ },
92
+ workflowId: options.workflowId
93
+ });
94
+ return {
95
+ name: RUNNER_PAYLOAD_ENV,
96
+ value: JSON.stringify(payload)
97
+ };
98
+ }
99
+ function buildRunnerJobPayload(options) {
100
+ return runnerJobPayloadSchema.parse({
101
+ contractVersion: "1",
102
+ eventSink: options.eventSink,
103
+ momokaya: options.momokaya,
104
+ repository: options.repository,
105
+ run: options.run,
106
+ selector: {
107
+ allowCommandHooks: options.allowCommandHooks ?? true,
108
+ workflowId: options.workflowId
109
+ },
110
+ task: options.task
111
+ });
112
+ }
41
113
  function parseRunnerJobPayload(rawPayload) {
114
+ const result = parseRunnerJobPayloadWithIssues(rawPayload);
115
+ if (!result.ok) throw result.error;
116
+ return result.payload;
117
+ }
118
+ function parseRunnerJobPayloadWithIssues(rawPayload) {
42
119
  let parsed;
43
120
  try {
44
121
  parsed = parseJson(rawPayload, "runner payload JSON");
45
122
  } catch (err) {
46
123
  const message = err instanceof Error ? err.message : String(err);
47
- throw new Error(`Malformed runner payload JSON: ${message}`);
124
+ return {
125
+ error: new RunnerJobPayloadValidationError(`Malformed runner payload JSON: ${message}`, [{
126
+ code: "invalid_json",
127
+ message,
128
+ path: "payload"
129
+ }]),
130
+ ok: false
131
+ };
48
132
  }
49
133
  const result = runnerJobPayloadSchema.safeParse(parsed);
50
- if (!result.success) throw new Error(formatRunnerJobPayloadIssues(result.error, parsed));
51
- return result.data;
134
+ if (!result.success) {
135
+ const issues = runnerJobPayloadIssues(result.error);
136
+ return {
137
+ error: new RunnerJobPayloadValidationError(formatRunnerJobPayloadIssues(issues, parsed), issues),
138
+ ok: false,
139
+ ...recoverablePayloadEnvelope(parsed)
140
+ };
141
+ }
142
+ return {
143
+ ok: true,
144
+ payload: result.data
145
+ };
52
146
  }
53
147
  function mapRuntimeEventToRunnerEventRecords(event, context) {
54
148
  const record = {
@@ -261,15 +355,53 @@ function formatLogMessage(output) {
261
355
  return String(output);
262
356
  }
263
357
  }
264
- function formatRunnerJobPayloadIssues(error, payload) {
358
+ function formatRunnerJobPayloadIssues(issues, payload) {
265
359
  const selector = readRecord(readRecord(payload)?.selector);
266
360
  if (selector && !("workflowId" in selector) && Object.keys(selector).length > 0) return `Unsupported selector fields: ${Object.keys(selector).join(", ")}; selector.workflowId is required`;
267
- return error.issues.map((issue) => {
361
+ return issues.map((issue) => `${issue.path || "payload"}: ${issue.message}`).join("; ");
362
+ }
363
+ function runnerJobPayloadIssues(error) {
364
+ return error.issues.flatMap((issue) => {
268
365
  const path = issue.path.join(".");
269
- if (issue.code === "invalid_type" && issue.input === void 0) return `${path || "payload"} is required`;
270
- if (path === "eventSink.url") return "eventSink.url must be a valid URL";
271
- return `${path || "payload"}: ${issue.message}`;
272
- }).join("; ");
366
+ if (issue.code === "unrecognized_keys" && Array.isArray(issue.keys)) return issue.keys.map((key) => ({
367
+ code: issue.code,
368
+ message: "Unrecognized key",
369
+ path: [path, key].filter(Boolean).join(".")
370
+ }));
371
+ if (issue.code === "invalid_type" && issue.input === void 0) return [{
372
+ code: issue.code,
373
+ message: "is required",
374
+ path: path || "payload"
375
+ }];
376
+ if (path === "eventSink.url") return [{
377
+ code: issue.code,
378
+ message: "must be a valid URL",
379
+ path
380
+ }];
381
+ if (path === "contractVersion") return [{
382
+ code: issue.code,
383
+ message: `runner job payload contract version must be 1`,
384
+ path
385
+ }];
386
+ return [{
387
+ code: issue.code,
388
+ message: issue.message,
389
+ path: path || "payload"
390
+ }];
391
+ });
392
+ }
393
+ function recoverablePayloadEnvelope(payload) {
394
+ const envelope = readRecord(payload);
395
+ if (!envelope) return {};
396
+ const eventSink = runnerEventSinkConfigSchema.safeParse(envelope.eventSink);
397
+ const run = runnerRunIdentitySchema.safeParse(envelope.run);
398
+ const workflowId = readRecord(envelope.selector)?.workflowId;
399
+ if (!(eventSink.success && run.success && typeof workflowId === "string")) return {};
400
+ return { recoverable: {
401
+ eventSink: eventSink.data,
402
+ run: run.data,
403
+ workflowId
404
+ } };
273
405
  }
274
406
  function omitUndefined(value) {
275
407
  return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0));
@@ -281,4 +413,4 @@ function assertNever(value) {
281
413
  throw new Error(`Unhandled runtime event: ${String(value)}`);
282
414
  }
283
415
  //#endregion
284
- export { RUNNER_PAYLOAD_ENV, mapRuntimeEventToRunnerEventRecords, parseRunnerJobPayload, resolveRunnerEventSinkAuthToken };
416
+ export { RUNNER_JOB_CONTRACT_VERSION, RUNNER_PAYLOAD_ENV, RunnerJobPayloadValidationError, buildRunnerJobPayload, createRunnerJobPayloadEnv, mapRuntimeEventToRunnerEventRecords, parseRunnerJobPayload, parseRunnerJobPayloadWithIssues, resolveRunnerEventSinkAuthHeader, resolveRunnerEventSinkAuthToken, runnerEventSinkConfigSchema, runnerJobPayloadJsonSchema, runnerJobPayloadSchema, runnerMomokayaContextSchema, runnerRepositoryContextSchema, runnerRunIdentitySchema, runnerTaskPromptSchema, runnerWorkflowSelectorSchema };
@@ -372,6 +372,7 @@ function plannerPrompt(entrypointId, task, baseline, config, planningContext) {
372
372
  "Assign each backlog work unit to explicit generated agent nodes with task_context.id. The scheduler hydrates title, description, and acceptance_criteria after parsing.",
373
373
  "Do not copy backlog descriptions or acceptance criteria into task_context output.",
374
374
  "Implementation work must have downstream acceptance, verification, or review coverage in the generated DAG.",
375
+ "Preserve Backlog dependency ids as schedule needs edges. A node assigned a dependent work unit must depend on the nodes assigned its prerequisite work units, directly or through an explicit path.",
375
376
  "Shape the graph by intent, not by ticket count. Do not create a full RED/GREEN/ACCEPTANCE/VERIFY chain for each backlog ticket unless each step needs ticket-specific evidence.",
376
377
  "Use one RED node for a group of tickets when they share a test strategy, then fan out to parallel GREEN implementation nodes where the work can be implemented independently.",
377
378
  "Use one acceptance or verifier node for multiple GREEN nodes when the same acceptance checklist or real repository commands prove the group.",
@@ -402,6 +403,7 @@ function validateScheduleArtifact(config, artifact, planningContext) {
402
403
  ...workflowReferenceNodeIssues(artifact),
403
404
  ...workflowAssignedWorkUnitIssues(artifact, planningContext.workUnits),
404
405
  ...missingAssignedWorkUnitIssues(artifact, planningContext.workUnits),
406
+ ...workUnitDependencyIssues(artifact, planningContext.workUnits),
405
407
  ...invalidWorkflowPrimitiveIssues(config, artifact),
406
408
  ...implementationCoverageIssues(artifact)
407
409
  ];
@@ -464,6 +466,47 @@ function missingAssignedWorkUnitIssues(artifact, workUnits) {
464
466
  const missing = workUnits.map((unit) => unit.id).filter((id) => !assigned.has(id));
465
467
  return missing.length > 0 ? [`missing assigned backlog work units: ${missing.join(", ")}`] : [];
466
468
  }
469
+ function workUnitDependencyIssues(artifact, workUnits) {
470
+ if (workUnits.length === 0) return [];
471
+ const workUnitIds = new Set(workUnits.map((unit) => unit.id));
472
+ const dependenciesByUnit = new Map(workUnits.map((unit) => [unit.id, (unit.dependencies ?? []).filter((id) => workUnitIds.has(id))]));
473
+ return Object.entries(artifact.workflows).flatMap(([workflowId, workflow]) => {
474
+ const nodes = workflow.nodes.flatMap(flattenWorkflowNode);
475
+ const dependentsByNeed = workflowDependentsByNeed(nodes);
476
+ const nodesByWorkUnit = nodesByAssignedWorkUnit(nodes);
477
+ return nodes.filter(isImplementationNode).flatMap((node) => {
478
+ const dependentId = node.task_context?.id;
479
+ if (!dependentId) return [];
480
+ return (dependenciesByUnit.get(dependentId) ?? []).flatMap((prerequisiteId) => {
481
+ const prerequisiteNodes = nodesByWorkUnit.get(prerequisiteId) ?? [];
482
+ return prerequisiteNodes.some((source) => hasPathToNode(source.id, node.id, dependentsByNeed)) ? [] : [`work unit dependency edge missing in '${workflowId}': '${dependentId}' node '${node.id}' must depend on prerequisite '${prerequisiteId}' nodes ${prerequisiteNodes.map((prerequisite) => `'${prerequisite.id}'`).join(", ")}`];
483
+ });
484
+ });
485
+ });
486
+ }
487
+ function nodesByAssignedWorkUnit(nodes) {
488
+ const grouped = /* @__PURE__ */ new Map();
489
+ for (const node of nodes) {
490
+ const id = node.task_context?.id;
491
+ if (!id) continue;
492
+ const current = grouped.get(id) ?? [];
493
+ current.push(node);
494
+ grouped.set(id, current);
495
+ }
496
+ return grouped;
497
+ }
498
+ function hasPathToNode(sourceId, targetId, dependentsByNeed) {
499
+ const queue = [...dependentsByNeed.get(sourceId) ?? []];
500
+ const seen = /* @__PURE__ */ new Set();
501
+ while (queue.length > 0) {
502
+ const node = queue.shift();
503
+ if (!node || seen.has(node.id)) continue;
504
+ if (node.id === targetId) return true;
505
+ seen.add(node.id);
506
+ queue.push(...dependentsByNeed.get(node.id) ?? []);
507
+ }
508
+ return false;
509
+ }
467
510
  function invalidWorkflowPrimitiveIssues(config, artifact) {
468
511
  const allowed = new Set(Object.keys(config.workflows));
469
512
  return allWorkflowNodes(artifact.workflows).flatMap((node) => {
@@ -543,6 +586,7 @@ function readBacklogTaskFile(path) {
543
586
  parentTaskId: stringFrontmatter(parsed.data.parent_task_id),
544
587
  workUnit: {
545
588
  acceptance_criteria: acceptanceCriteriaFromMarkdown(parsed.content),
589
+ ...optionalStringArrayField("dependencies", stringArrayFrontmatter(parsed.data.dependencies)),
546
590
  ...optionalStringField("description", descriptionFromMarkdown(parsed.content)),
547
591
  id,
548
592
  ...optionalStringField("title", stringFrontmatter(parsed.data.title))
@@ -552,9 +596,16 @@ function readBacklogTaskFile(path) {
552
596
  function stringFrontmatter(value) {
553
597
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
554
598
  }
599
+ function stringArrayFrontmatter(value) {
600
+ if (!Array.isArray(value)) return [];
601
+ return value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
602
+ }
555
603
  function optionalStringField(key, value) {
556
604
  return value ? { [key]: value } : {};
557
605
  }
606
+ function optionalStringArrayField(key, value) {
607
+ return value.length > 0 ? { [key]: value } : {};
608
+ }
558
609
  function descriptionFromMarkdown(content) {
559
610
  const marked = betweenMarkers(content, "<!-- SECTION:DESCRIPTION:BEGIN -->", "<!-- SECTION:DESCRIPTION:END -->");
560
611
  if (marked) return marked;
@@ -133,7 +133,7 @@ Local dry run:
133
133
  ```shell
134
134
  export PIPELINE_TARGET_PATH=/path/to/target/repo
135
135
  export OISIN_PIPELINE_EVENT_AUTH_TOKEN=dev-token
136
- export OISIN_PIPELINE_RUNNER_PAYLOAD_JSON='{"eventSink":{"authHeader":"Authorization","url":"http://127.0.0.1:3000/api/pipeline/runs/run-uid-1/events"},"run":{"projectId":"alpha","requestedBy":"@agent","runId":"run-uid-1"},"selector":{"workflowId":"default"},"task":{"prompt":"PIPE-38","taskId":"PIPE-38"}}'
136
+ export OISIN_PIPELINE_RUNNER_PAYLOAD_JSON='{"contractVersion":"1","eventSink":{"authHeader":"Authorization","url":"http://127.0.0.1:3000/api/pipeline/runs/run-uid-1/events"},"run":{"projectId":"alpha","requestedBy":"@agent","runId":"run-uid-1"},"selector":{"allowCommandHooks":true,"workflowId":"default"},"task":{"prompt":"PIPE-38","taskId":"PIPE-38"}}'
137
137
  pipe runner-job
138
138
  ```
139
139
 
@@ -161,7 +161,7 @@ spec:
161
161
  image: ghcr.io/oisin-ee/pipeline-runner:latest
162
162
  env:
163
163
  - name: OISIN_PIPELINE_RUNNER_PAYLOAD_JSON
164
- value: '{"eventSink":{"authHeader":"Authorization","url":"https://console.example/api/pipeline/runs/run-uid-1/events"},"run":{"projectId":"alpha","runId":"run-uid-1"},"selector":{"workflowId":"default"},"task":{"prompt":"PIPE-38","taskId":"PIPE-38"}}'
164
+ value: '{"contractVersion":"1","eventSink":{"authHeader":"Authorization","url":"https://console.example/api/pipeline/runs/run-uid-1/events"},"run":{"projectId":"alpha","runId":"run-uid-1"},"selector":{"allowCommandHooks":true,"workflowId":"default"},"task":{"prompt":"PIPE-38","taskId":"PIPE-38"}}'
165
165
  ```
166
166
 
167
167
  The runner image is configured in `pipeline-console` as
@@ -171,9 +171,22 @@ event sink URL, and auth header. The runner-side
171
171
  `OISIN_PIPELINE_EVENT_AUTH_TOKEN` or `PIPELINE_EVENT_API_TOKEN` must match the
172
172
  console API `PIPELINE_EVENT_API_TOKEN`.
173
173
 
174
+ The runner payload contract lives at
175
+ `@oisincoveney/pipeline/runner-job-contract`. `pipeline-console` should create
176
+ payloads with `buildRunnerJobPayload` and can use
177
+ `runnerJobPayloadJsonSchema` for neutral validation or generated docs. The
178
+ runner image labels `pipeline.oisin.dev.runner-contract-version` and
179
+ `pipeline.oisin.dev.pipeline-package-version`, plus the console
180
+ `runner.expectedContractVersion` setting and
181
+ `pipeline.oisin.dev/runner-contract-version` Job label, give operators a fast
182
+ way to detect image/dependency skew before starting Jobs.
183
+
174
184
  Troubleshooting:
175
185
 
176
186
  - Missing payload: set `OISIN_PIPELINE_RUNNER_PAYLOAD_JSON`; exit code is `64`.
187
+ - Schema validation: unsupported selector fields or incompatible
188
+ `contractVersion` exit `64`; recoverable payloads also post
189
+ `runner.schema.validation` and a failing `workflow.finish` event.
177
190
  - Invalid auth: confirm the runner token matches the console API token; 401/403
178
191
  event sink responses are terminal.
179
192
  - Missing target config: set `PIPELINE_TARGET_PATH` to a repo containing
@@ -10,8 +10,15 @@ console database, event store, Job builder, Kueue watcher, or UI.
10
10
  `pipeline-console` starts the image with one environment variable:
11
11
  `OISIN_PIPELINE_RUNNER_PAYLOAD_JSON`.
12
12
 
13
+ The executable payload contract lives in this package at
14
+ `@oisincoveney/pipeline/runner-job-contract`. Console code must build runner
15
+ payloads through `buildRunnerJobPayload` instead of hand-shaping JSON. The same
16
+ subpath exports `parseRunnerJobPayload`, `RUNNER_JOB_CONTRACT_VERSION`, and
17
+ `runnerJobPayloadJsonSchema` for validation, tests, and docs.
18
+
13
19
  ```json
14
20
  {
21
+ "contractVersion": "1",
15
22
  "eventSink": {
16
23
  "authHeader": "Authorization",
17
24
  "url": "https://console.example/api/pipeline/runs/run-uid-1/events"
@@ -22,6 +29,7 @@ console database, event store, Job builder, Kueue watcher, or UI.
22
29
  "runId": "run-uid-1"
23
30
  },
24
31
  "selector": {
32
+ "allowCommandHooks": true,
25
33
  "workflowId": "epic-drain"
26
34
  },
27
35
  "task": {
@@ -33,7 +41,19 @@ console database, event store, Job builder, Kueue watcher, or UI.
33
41
 
34
42
  `eventSink.url` is the exact append endpoint the runner posts to. The console
35
43
  resolves any requested entrypoint before creating the Job and sends
36
- `selector.workflowId`; the runner rejects unsupported selector modes.
44
+ `selector.workflowId`; the runner rejects unsupported selector modes. The
45
+ `selector.allowCommandHooks` boolean is the runner-side hook policy for command
46
+ hooks in that job. It defaults to `true`, and console callers that disable hooks
47
+ must send `false` through the shared builder.
48
+
49
+ Payloads declare `contractVersion: "1"`. Runner images are labeled with
50
+ `pipeline.oisin.dev.runner-contract-version` and
51
+ `pipeline.oisin.dev.pipeline-package-version`; console deployment config records
52
+ the expected payload contract as `runner.expectedContractVersion` and labels
53
+ created Jobs with `pipeline.oisin.dev/runner-contract-version`. Operators should
54
+ keep the console package dependency, console expected version, and runner image
55
+ label version aligned. A future breaking payload change must increment the
56
+ contract version and ship a compatibility plan.
37
57
 
38
58
  Console-created Jobs are labeled with `kueue.x-k8s.io/queue-name` and
39
59
  `pipeline.oisin.dev/project`, `pipeline.oisin.dev/run-id`,
@@ -63,6 +83,12 @@ Each event has a strictly increasing integer `sequence`, a string `type`, an
63
83
  `node`, `gate`, `artifact`, `log`, and `finalResult`. The console stores
64
84
  non-reserved top-level fields as event payload.
65
85
 
86
+ If payload validation fails but the runner can recover the run identity and
87
+ event sink identity, it posts a `runner.schema.validation` warning event with
88
+ normalized issue details, then posts `workflow.finish` with outcome `FAIL`, and
89
+ exits `64`. If identity is not recoverable, it writes the validation error to
90
+ stderr and exits `64` without posting events.
91
+
66
92
  ## Authentication
67
93
 
68
94
  The payload carries the header name, not the token. The runner sets
package/package.json CHANGED
@@ -62,6 +62,10 @@
62
62
  "types": "./dist/runner.d.ts",
63
63
  "import": "./dist/runner.js"
64
64
  },
65
+ "./runner-job-contract": {
66
+ "types": "./dist/runner-job-contract.d.ts",
67
+ "import": "./dist/runner-job-contract.js"
68
+ },
65
69
  "./runtime": {
66
70
  "types": "./dist/pipeline-runtime.d.ts",
67
71
  "import": "./dist/pipeline-runtime.js"
@@ -91,7 +95,7 @@
91
95
  "prepack": "bun run build:cli"
92
96
  },
93
97
  "type": "module",
94
- "version": "1.11.3",
98
+ "version": "1.13.0",
95
99
  "description": "Config-driven multi-agent pipeline runner for repository work",
96
100
  "main": "./dist/index.js",
97
101
  "types": "./dist/index.d.ts",