@oisincoveney/pipeline 1.11.3 → 1.12.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 +14 -0
- package/dist/kubernetes-runner.js +27 -9
- package/dist/runner-event-sink.js +21 -1
- package/dist/runner-job-contract.d.ts +281 -0
- package/dist/runner-job-contract.js +144 -12
- package/docs/operator-guide.md +15 -2
- package/docs/pipeline-console-runner-contract.md +27 -1
- package/package.json +5 -1
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:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PipelineConfigError } from "./config.js";
|
|
2
2
|
import { runPipelineFromConfig } from "./pipeline-runtime.js";
|
|
3
|
-
import { RUNNER_PAYLOAD_ENV,
|
|
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 {
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 {
|
|
@@ -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({
|
|
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
|
-
|
|
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)
|
|
51
|
-
|
|
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(
|
|
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
|
|
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 === "
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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 };
|
package/docs/operator-guide.md
CHANGED
|
@@ -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.
|
|
98
|
+
"version": "1.12.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",
|