@oisincoveney/pipeline 3.11.19 → 3.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/dist/argo-workflow.js +4 -1
- package/dist/cli/program.js +49 -0
- package/dist/cli/run-command.d.ts +1 -0
- package/dist/cli/submit-options.js +1 -1
- package/dist/commands/ticket-command.js +19 -4
- package/dist/config/schemas.d.ts +5 -0
- package/dist/config/schemas.js +3 -1
- package/dist/loop/argo-poll.d.ts +19 -0
- package/dist/loop/argo-poll.js +124 -0
- package/dist/loop/backlog-records.js +9 -0
- package/dist/loop/controller-deps.js +227 -0
- package/dist/loop/controller.js +247 -0
- package/dist/loop/gh-checks.js +119 -0
- package/dist/loop/gh-runner.js +39 -0
- package/dist/loop/loop-command.js +92 -0
- package/dist/loop/loop-controller-entrypoint.js +61 -0
- package/dist/loop/merge.js +120 -0
- package/dist/moka-submit.d.ts +10 -0
- package/dist/moka-submit.js +9 -4
- package/dist/runner-command-contract.d.ts +54 -1
- package/dist/runner-command-contract.js +9 -2
- package/dist/runner-event-schema.d.ts +134 -0
- package/dist/runner-event-schema.js +35 -1
- package/dist/runtime/open-pull-request/open-pull-request.js +3 -1
- package/dist/runtime/opencode-session-executor.js +11 -1
- package/dist/runtime/services/kubernetes-argo-service.d.ts +2 -0
- package/dist/runtime/services/kubernetes-argo-service.js +18 -0
- package/dist/tickets/ticket-graph-dto.js +75 -0
- package/package.json +1 -1
package/dist/argo-workflow.js
CHANGED
|
@@ -21,6 +21,9 @@ const RUNNER_RETRY_STRATEGY = {
|
|
|
21
21
|
const RUNNER_OPENCODE_ENV = [{
|
|
22
22
|
name: "CODEX_AUTH_PER_PROJECT_ACCOUNTS",
|
|
23
23
|
value: "0"
|
|
24
|
+
}, {
|
|
25
|
+
name: "PIPELINE_AGENT_TIMEOUT_MS",
|
|
26
|
+
value: "1200000"
|
|
24
27
|
}];
|
|
25
28
|
const DEFAULT_RUNNER_RESOURCES = {
|
|
26
29
|
limits: {
|
|
@@ -32,7 +35,7 @@ const DEFAULT_RUNNER_RESOURCES = {
|
|
|
32
35
|
memory: "5Gi"
|
|
33
36
|
}
|
|
34
37
|
};
|
|
35
|
-
const DEFAULT_RUNNER_DEADLINE_SECONDS =
|
|
38
|
+
const DEFAULT_RUNNER_DEADLINE_SECONDS = 5400;
|
|
36
39
|
const kubernetesNameSchema = z.string().min(1);
|
|
37
40
|
const labelValueSchema = z.string().min(1);
|
|
38
41
|
const stringMapSchema = z.record(z.string().min(1), z.string().min(1));
|
package/dist/cli/program.js
CHANGED
|
@@ -15,6 +15,9 @@ import { registerRunnerCommandCommand } from "../commands/runner-command-command
|
|
|
15
15
|
import { MOKA_RUN_EFFORTS, MOKA_RUN_TARGETS, resolveMokaRun } from "./run-resolver.js";
|
|
16
16
|
import { registerTicketCommand } from "../commands/ticket-command.js";
|
|
17
17
|
import { formatConfigLintWarning, lintPipelineConfig } from "../config/lint.js";
|
|
18
|
+
import { parseLoopFlags, runLoopSubmit } from "../loop/loop-command.js";
|
|
19
|
+
import { runLoopControllerEntrypoint } from "../loop/loop-controller-entrypoint.js";
|
|
20
|
+
import { loadMokaGlobalConfig } from "../moka-global-config.js";
|
|
18
21
|
import { formatPipelineInitResult, initPipelineProject } from "../pipeline-init.js";
|
|
19
22
|
import { createRun, runControlStatusPaths, updateRunController } from "../run-control/store.js";
|
|
20
23
|
import { registerRunControlCommands } from "../run-control/commands.js";
|
|
@@ -357,6 +360,7 @@ function createCliProgram(options = {}) {
|
|
|
357
360
|
].join("\n")).argument("[input...]", "task description, or command argv with --command")).action(async (input, flags) => {
|
|
358
361
|
printMokaSubmitResult(await runMokaSubmitFromCli(input, flags));
|
|
359
362
|
});
|
|
363
|
+
registerLoopCommand(program);
|
|
360
364
|
registerRunnerCommandCommand(program);
|
|
361
365
|
registerBenchCommand(program);
|
|
362
366
|
registerTicketCommand(program, {
|
|
@@ -372,6 +376,51 @@ function createCliProgram(options = {}) {
|
|
|
372
376
|
} });
|
|
373
377
|
return program;
|
|
374
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Register `moka loop` (submit the cloud controller) and the hidden
|
|
381
|
+
* `moka loop-controller` (the in-cluster process that drives the loop). The
|
|
382
|
+
* public command validates the backlog and submits; a cyclic or empty backlog
|
|
383
|
+
* refuses to start with a non-zero exit.
|
|
384
|
+
*/
|
|
385
|
+
function registerLoopCommand(program) {
|
|
386
|
+
program.command("loop").description("Submit a long-running cloud controller that drains the backlog ticket-by-ticket").addOption(new Option$1("--strategy <strategy>", "ready-ticket selection strategy").choices([
|
|
387
|
+
"priority",
|
|
388
|
+
"bfs",
|
|
389
|
+
"dfs"
|
|
390
|
+
]).default("priority")).option("--root <epic-id>", "restrict traversal to this epic subtree").option("--max-remediation-attempts <n>", "bounded fix-up submits before a PR is declared blocked").option("--merge-timeout <n>", "bounded merge polls before an indeterminate PR is declared blocked").action(async (options) => {
|
|
391
|
+
const result = await runLoopSubmit(buildLoopSubmitInput(options));
|
|
392
|
+
console.log(`Loop controller submitted: ${result.workflowName} in ${result.namespace}`);
|
|
393
|
+
});
|
|
394
|
+
program.command("loop-controller", { hidden: true }).description("Internal in-cluster loop controller process").requiredOption("--payload-file <path>", "Path to the runner payload JSON").addOption(new Option$1("--strategy <strategy>", "ready-ticket selection strategy").choices([
|
|
395
|
+
"priority",
|
|
396
|
+
"bfs",
|
|
397
|
+
"dfs"
|
|
398
|
+
]).default("priority")).option("--root <epic-id>", "restrict traversal to this epic subtree").option("--max-remediation-attempts <n>", "bounded fix-up submits").option("--merge-timeout <n>", "bounded merge polls").action(async (flags) => {
|
|
399
|
+
await runLoopControllerEntrypoint({
|
|
400
|
+
flags: parseLoopFlags(flags),
|
|
401
|
+
payloadFile: flags.payloadFile,
|
|
402
|
+
worktreePath: process.env.PIPELINE_TARGET_PATH ?? process.cwd()
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
function buildLoopSubmitInput(options) {
|
|
407
|
+
const cwd = process.env.PIPELINE_TARGET_PATH ?? process.cwd();
|
|
408
|
+
const config = loadPipelineConfig(cwd, { allowMissingLintFileReferences: true });
|
|
409
|
+
const momokaya = loadMokaGlobalConfig()?.momokaya;
|
|
410
|
+
return {
|
|
411
|
+
config,
|
|
412
|
+
eventUrl: momokaya?.submit.eventUrl,
|
|
413
|
+
flags: parseLoopFlags(options),
|
|
414
|
+
gitCredentialsSecretName: momokaya?.submit.gitCredentialsSecretName,
|
|
415
|
+
githubAuthSecretName: momokaya?.submit.githubAuthSecretName,
|
|
416
|
+
kubeconfigPath: momokaya?.kubernetes.kubeconfig,
|
|
417
|
+
namespace: momokaya?.kubernetes.namespace,
|
|
418
|
+
opencodeAuthSecretName: momokaya?.submit.opencodeAuthSecretName,
|
|
419
|
+
opencodeOpenaiAccountsSecretName: momokaya?.submit.opencodeOpenaiAccountsSecretName,
|
|
420
|
+
serviceAccountName: momokaya?.submit.serviceAccountName,
|
|
421
|
+
worktreePath: cwd
|
|
422
|
+
};
|
|
423
|
+
}
|
|
375
424
|
function runLocalResolvedTask(task, execution, runControl) {
|
|
376
425
|
return execute(task, {
|
|
377
426
|
entrypoint: execution.entrypoint,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadPipelineConfig } from "../config/load.js";
|
|
2
2
|
import "../config.js";
|
|
3
|
-
import { loadMokaGlobalConfig } from "../moka-global-config.js";
|
|
4
3
|
import { submitMoka } from "../moka-submit.js";
|
|
4
|
+
import { loadMokaGlobalConfig } from "../moka-global-config.js";
|
|
5
5
|
import { Option } from "commander";
|
|
6
6
|
//#region src/cli/submit-options.ts
|
|
7
7
|
function addMokaSubmitOptions(command) {
|
|
@@ -4,11 +4,11 @@ import "../config.js";
|
|
|
4
4
|
import { RepoIoServiceLive } from "../runtime/services/repo-io-service.js";
|
|
5
5
|
import { createRunnerLaunchPlan, runLaunchPlan } from "../runner.js";
|
|
6
6
|
import { normalizeRunnerOutput } from "../runner-output.js";
|
|
7
|
+
import { buildTicketGraphEffect, scopedTicketIds, sequenceTicketBatchesEffect } from "../tickets/ticket-graph.js";
|
|
7
8
|
import { MOKA_RUN_EFFORTS, MOKA_RUN_TARGETS, resolveMokaRun } from "../cli/run-resolver.js";
|
|
8
9
|
import { BacklogService, BacklogServiceLive } from "../runtime/services/backlog-service.js";
|
|
9
10
|
import { applyTicketPlanEffect } from "../tickets/apply-ticket-plan.js";
|
|
10
11
|
import { loadBacklogTaskStoreEffect } from "../tickets/backlog-task-store.js";
|
|
11
|
-
import { buildTicketGraphEffect, scopedTicketIds, sequenceTicketBatchesEffect } from "../tickets/ticket-graph.js";
|
|
12
12
|
import { renderTicketPlanDryRun } from "../tickets/ticket-plan-render.js";
|
|
13
13
|
import { selectNextTicket, selectReadyTickets } from "../tickets/ticket-selection.js";
|
|
14
14
|
import { Data, Effect } from "effect";
|
|
@@ -47,7 +47,7 @@ function startTicketEffect(worktreePath, flags, runCommand) {
|
|
|
47
47
|
return Effect.gen(function* () {
|
|
48
48
|
const { loaded, selectionOptions } = yield* loadTicketSelectionEffect(worktreePath, flags);
|
|
49
49
|
const selected = yield* readyTicketEffect(selectNextTicket(loaded.graph, selectionOptions));
|
|
50
|
-
const task = ticketRunTask(selected);
|
|
50
|
+
const { task, ticketId } = ticketRunTask(selected);
|
|
51
51
|
const descriptionParts = [task];
|
|
52
52
|
const runFlags = yield* ticketStartRunFlagsEffect(flags);
|
|
53
53
|
const resolution = yield* Effect.try({
|
|
@@ -71,16 +71,31 @@ function startTicketEffect(worktreePath, flags, runCommand) {
|
|
|
71
71
|
descriptionParts,
|
|
72
72
|
flags: runFlags,
|
|
73
73
|
resolution,
|
|
74
|
-
task
|
|
74
|
+
task,
|
|
75
|
+
ticketId
|
|
75
76
|
});
|
|
76
77
|
}
|
|
77
78
|
});
|
|
78
79
|
});
|
|
79
80
|
}
|
|
81
|
+
const BACKLOG_STATUS_DIRECTIVE = `\
|
|
82
|
+
## Backlog ticket management
|
|
83
|
+
|
|
84
|
+
Your first action must be to set this ticket to "In Progress":
|
|
85
|
+
backlog task edit <TICKET_ID> --status "In Progress" --plain
|
|
86
|
+
|
|
87
|
+
Your final action on completion must be to set this ticket to "Done" and update \
|
|
88
|
+
its acceptance criteria through the backlog tools:
|
|
89
|
+
backlog task edit <TICKET_ID> --status "Done" --plain
|
|
90
|
+
|
|
91
|
+
Use backlog tools on your working branch. Do not hand-edit the task markdown file.`;
|
|
80
92
|
function ticketRunTask(ticket) {
|
|
81
93
|
const title = formatNextTicket(ticket);
|
|
82
94
|
const description = ticket.description?.trim();
|
|
83
|
-
return
|
|
95
|
+
return {
|
|
96
|
+
task: `${description ? `${title}\n\n${description}` : title}\n\n${BACKLOG_STATUS_DIRECTIVE.replaceAll("<TICKET_ID>", ticket.id)}`,
|
|
97
|
+
ticketId: ticket.id
|
|
98
|
+
};
|
|
84
99
|
}
|
|
85
100
|
function ticketStartRunFlagsEffect(flags) {
|
|
86
101
|
return Effect.gen(function* () {
|
package/dist/config/schemas.d.ts
CHANGED
|
@@ -536,7 +536,12 @@ declare const configSchema: z.ZodObject<{
|
|
|
536
536
|
delivery: z.ZodOptional<z.ZodObject<{
|
|
537
537
|
pull_request: z.ZodOptional<z.ZodObject<{
|
|
538
538
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
539
|
+
head_branch: z.ZodOptional<z.ZodString>;
|
|
539
540
|
label: z.ZodDefault<z.ZodString>;
|
|
541
|
+
mode: z.ZodDefault<z.ZodEnum<{
|
|
542
|
+
"create-new-pr": "create-new-pr";
|
|
543
|
+
"update-existing-pr": "update-existing-pr";
|
|
544
|
+
}>>;
|
|
540
545
|
}, z.core.$strict>>;
|
|
541
546
|
}, z.core.$strict>>;
|
|
542
547
|
durability: z.ZodOptional<z.ZodObject<{
|
package/dist/config/schemas.js
CHANGED
|
@@ -488,7 +488,9 @@ const repoMapSchema = z.object({
|
|
|
488
488
|
}).strict();
|
|
489
489
|
const deliverySchema = z.object({ pull_request: z.object({
|
|
490
490
|
enabled: z.boolean().default(false),
|
|
491
|
-
|
|
491
|
+
head_branch: z.string().min(1).optional(),
|
|
492
|
+
label: z.string().min(1).default("preview"),
|
|
493
|
+
mode: z.enum(["create-new-pr", "update-existing-pr"]).default("create-new-pr")
|
|
492
494
|
}).strict().optional() }).strict();
|
|
493
495
|
const pipelineFileSchema = z.object({
|
|
494
496
|
default_workflow: z.string(),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/loop/argo-poll.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Minimal slice of CustomObjectsApi needed to read an Argo Workflow.
|
|
6
|
+
* Kept narrow so tests can inject a trivial fake without constructing a full
|
|
7
|
+
* KubeConfig or hitting a real cluster.
|
|
8
|
+
*/
|
|
9
|
+
interface WorkflowReadApi {
|
|
10
|
+
getNamespacedCustomObject(param: {
|
|
11
|
+
group: string;
|
|
12
|
+
name: string;
|
|
13
|
+
namespace: string;
|
|
14
|
+
plural: string;
|
|
15
|
+
version: string;
|
|
16
|
+
}): Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { WorkflowReadApi };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { isRecord } from "../safe-json.js";
|
|
2
|
+
import { Duration, Effect } from "effect";
|
|
3
|
+
import { CustomObjectsApi, KubeConfig } from "@kubernetes/client-node";
|
|
4
|
+
//#region src/loop/argo-poll.ts
|
|
5
|
+
const TERMINAL_PHASES = new Set([
|
|
6
|
+
"Succeeded",
|
|
7
|
+
"Failed",
|
|
8
|
+
"Error"
|
|
9
|
+
]);
|
|
10
|
+
const KNOWN_RUNNING_PHASES = new Set([
|
|
11
|
+
"Running",
|
|
12
|
+
"Pending",
|
|
13
|
+
""
|
|
14
|
+
]);
|
|
15
|
+
function isTerminal(phase) {
|
|
16
|
+
return TERMINAL_PHASES.has(phase);
|
|
17
|
+
}
|
|
18
|
+
/** Exported for reuse in KubernetesArgoService. */
|
|
19
|
+
function classifyArgoPhase(raw) {
|
|
20
|
+
return classifyPhase(raw);
|
|
21
|
+
}
|
|
22
|
+
function classifyPhase(raw) {
|
|
23
|
+
if (TERMINAL_PHASES.has(raw)) {
|
|
24
|
+
if (raw === "Succeeded") return "Succeeded";
|
|
25
|
+
if (raw === "Failed") return "Failed";
|
|
26
|
+
return "Error";
|
|
27
|
+
}
|
|
28
|
+
if (KNOWN_RUNNING_PHASES.has(raw)) {
|
|
29
|
+
if (raw === "Running") return "Running";
|
|
30
|
+
if (raw === "Pending") return "Pending";
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
return "Running";
|
|
34
|
+
}
|
|
35
|
+
function buildWorkflowReadApi(options) {
|
|
36
|
+
const kc = new KubeConfig();
|
|
37
|
+
if (options.kubeconfigPath) kc.loadFromFile(options.kubeconfigPath);
|
|
38
|
+
else kc.loadFromDefault();
|
|
39
|
+
return kc.makeApiClient(CustomObjectsApi);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Fetch the Argo Workflow object and extract `.status.phase`.
|
|
43
|
+
* Unknown phase values are mapped to Running so the poll loop continues
|
|
44
|
+
* until Argo reports a known terminal phase.
|
|
45
|
+
*/
|
|
46
|
+
function getWorkflowPhase(options) {
|
|
47
|
+
return Effect.tryPromise({
|
|
48
|
+
catch: (error) => error,
|
|
49
|
+
try: () => options.workflowReadApi.getNamespacedCustomObject({
|
|
50
|
+
group: "argoproj.io",
|
|
51
|
+
name: options.workflowName,
|
|
52
|
+
namespace: options.namespace,
|
|
53
|
+
plural: "workflows",
|
|
54
|
+
version: "v1alpha1"
|
|
55
|
+
})
|
|
56
|
+
}).pipe(Effect.map((resource) => classifyPhase(extractRawPhase(resource))));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Walk the k8s response record with isRecord guards and pull out the raw phase string.
|
|
60
|
+
* Returns "" (Argo pending) when the field is absent or not a string.
|
|
61
|
+
* Exported for reuse in KubernetesArgoService.
|
|
62
|
+
*/
|
|
63
|
+
function extractArgoRawPhase(resource) {
|
|
64
|
+
return extractRawPhase(resource);
|
|
65
|
+
}
|
|
66
|
+
function extractRawPhase(resource) {
|
|
67
|
+
if (!isRecord(resource)) return "";
|
|
68
|
+
const status = resource.status;
|
|
69
|
+
if (!isRecord(status)) return "";
|
|
70
|
+
const phase = status.phase;
|
|
71
|
+
return typeof phase === "string" ? phase : "";
|
|
72
|
+
}
|
|
73
|
+
const DEFAULT_POLL_INTERVAL_MS = 5e3;
|
|
74
|
+
const DEFAULT_MAX_RETRIES = 10;
|
|
75
|
+
const RETRY_BASE_DELAY_MS = 250;
|
|
76
|
+
/**
|
|
77
|
+
* Poll an Argo Workflow until it reaches a terminal phase (Succeeded/Failed/Error).
|
|
78
|
+
*
|
|
79
|
+
* Transient k8s API errors are retried with exponential backoff up to maxRetries
|
|
80
|
+
* times, and reported via onTransientError on each attempt. Exhausted budget
|
|
81
|
+
* fails the Effect with the last error — never silently resolves to a fake terminal.
|
|
82
|
+
*
|
|
83
|
+
* Uses in-cluster service-account auth by default (loadFromDefault()); pass
|
|
84
|
+
* kubeconfigPath to override, or inject workflowReadApi directly for testing.
|
|
85
|
+
*/
|
|
86
|
+
function pollWorkflowPhaseUntilTerminal(options) {
|
|
87
|
+
const api = options.workflowReadApi ?? buildWorkflowReadApi({ kubeconfigPath: options.kubeconfigPath });
|
|
88
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
89
|
+
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
90
|
+
return pollLoop({
|
|
91
|
+
namespace: options.namespace,
|
|
92
|
+
workflowName: options.workflowName,
|
|
93
|
+
workflowReadApi: api,
|
|
94
|
+
pollIntervalMs,
|
|
95
|
+
maxRetries,
|
|
96
|
+
onTransientError: options.onTransientError,
|
|
97
|
+
errorCount: 0
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function pollLoop(state) {
|
|
101
|
+
return getWorkflowPhase({
|
|
102
|
+
namespace: state.namespace,
|
|
103
|
+
workflowName: state.workflowName,
|
|
104
|
+
workflowReadApi: state.workflowReadApi
|
|
105
|
+
}).pipe(Effect.flatMap((phase) => {
|
|
106
|
+
if (isTerminal(phase)) return Effect.succeed(phase);
|
|
107
|
+
return Effect.sleep(Duration.millis(state.pollIntervalMs)).pipe(Effect.zipRight(pollLoop({
|
|
108
|
+
...state,
|
|
109
|
+
errorCount: 0
|
|
110
|
+
})));
|
|
111
|
+
}), Effect.catchAll((error) => handlePollError(state, error)));
|
|
112
|
+
}
|
|
113
|
+
function handlePollError(state, error) {
|
|
114
|
+
const nextErrorCount = state.errorCount + 1;
|
|
115
|
+
state.onTransientError?.(error, nextErrorCount);
|
|
116
|
+
if (nextErrorCount > state.maxRetries) return Effect.fail(error);
|
|
117
|
+
const delay = Duration.millis(RETRY_BASE_DELAY_MS * 2 ** (nextErrorCount - 1));
|
|
118
|
+
return Effect.sleep(delay).pipe(Effect.zipRight(pollLoop({
|
|
119
|
+
...state,
|
|
120
|
+
errorCount: nextErrorCount
|
|
121
|
+
})));
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
export { classifyArgoPhase, extractArgoRawPhase, pollWorkflowPhaseUntilTerminal };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { RepoIoServiceLive } from "../runtime/services/repo-io-service.js";
|
|
2
|
+
import { loadBacklogTaskStoreEffect } from "../tickets/backlog-task-store.js";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
//#region src/loop/backlog-records.ts
|
|
5
|
+
function loadBacklogRecords(worktreePath) {
|
|
6
|
+
return loadBacklogTaskStoreEffect(worktreePath).pipe(Effect.map((store) => store.tasks), Effect.mapError((error) => new Error(error.message)), Effect.provide(RepoIoServiceLive));
|
|
7
|
+
}
|
|
8
|
+
//#endregion
|
|
9
|
+
export { loadBacklogRecords };
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { runAuthenticatedGit } from "../run-state/git-refs.js";
|
|
2
|
+
import { RunnerEventSinkHttpService, RunnerEventSinkHttpServiceLive } from "../runtime/services/runner-event-sink-http-service.js";
|
|
3
|
+
import { pollWorkflowPhaseUntilTerminal } from "./argo-poll.js";
|
|
4
|
+
import { ticketGraphDtoSchema } from "../tickets/ticket-graph-dto.js";
|
|
5
|
+
import { submitMoka } from "../moka-submit.js";
|
|
6
|
+
import { loadBacklogRecords } from "./backlog-records.js";
|
|
7
|
+
import { classifyRequiredChecks, resolvePrForRun } from "./gh-checks.js";
|
|
8
|
+
import { createGhRunner } from "./gh-runner.js";
|
|
9
|
+
import { mergeForClassification } from "./merge.js";
|
|
10
|
+
import { Effect } from "effect";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
//#region src/loop/controller-deps.ts
|
|
13
|
+
/**
|
|
14
|
+
* Build the production `ControllerDeps`. `context` carries the static run
|
|
15
|
+
* context; `seams` overrides any external boundary (used by tests to avoid
|
|
16
|
+
* GitHub / k8s / git).
|
|
17
|
+
*/
|
|
18
|
+
function buildControllerDeps(context, seams = {}) {
|
|
19
|
+
const gh = seams.gh ?? createGhRunner();
|
|
20
|
+
const generateRunId = seams.generateRunId ?? defaultRunId;
|
|
21
|
+
const submit = seams.submitRun ?? defaultSubmitRun(context, seams.submitMoka ?? submitMoka);
|
|
22
|
+
const loadTasks = seams.loadTasks ?? loadBacklogRecords;
|
|
23
|
+
const gitRefresh = seams.gitRefresh ?? defaultGitRefresh;
|
|
24
|
+
const pollPhase = seams.pollPhase ?? defaultPollPhase;
|
|
25
|
+
return {
|
|
26
|
+
classifyChecks: (pr, runner) => classifyMergeSignal(pr, runner),
|
|
27
|
+
emit: buildEmit(context, seams.postEvent),
|
|
28
|
+
gh,
|
|
29
|
+
loadGraph: () => loadTasks(context.worktreePath),
|
|
30
|
+
maxMergePolls: context.maxMergePolls,
|
|
31
|
+
maxRemediationAttempts: context.maxRemediationAttempts,
|
|
32
|
+
merge: ({ classification, pr }) => mergeForClassification({
|
|
33
|
+
classification,
|
|
34
|
+
gh,
|
|
35
|
+
pr
|
|
36
|
+
}),
|
|
37
|
+
pollPhase: (input) => pollPhase({
|
|
38
|
+
namespace: context.namespace,
|
|
39
|
+
runId: input.runId,
|
|
40
|
+
workflowName: input.workflowName
|
|
41
|
+
}),
|
|
42
|
+
refreshBacklog: () => Effect.tryPromise({
|
|
43
|
+
catch: refreshError,
|
|
44
|
+
try: () => gitRefresh(context.worktreePath)
|
|
45
|
+
}).pipe(Effect.flatMap(() => loadTasks(context.worktreePath))),
|
|
46
|
+
resolvePr: (runId, runner) => resolvePrForRun(runId, runner),
|
|
47
|
+
rootId: context.rootId,
|
|
48
|
+
sleep: (ms) => Effect.sleep(ms),
|
|
49
|
+
strategy: context.strategy,
|
|
50
|
+
submitRun: (input) => adaptSubmit(submit, generateRunId, input)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Return "merged" when GitHub reports the PR landed; otherwise delegate to the
|
|
55
|
+
* required-check classifier. The PR-state read is a single `gh pr view` json
|
|
56
|
+
* call; an unparsable response is surfaced (never silently treated as merged).
|
|
57
|
+
*/
|
|
58
|
+
function classifyMergeSignal(pr, gh) {
|
|
59
|
+
return gh.json([
|
|
60
|
+
"pr",
|
|
61
|
+
"view",
|
|
62
|
+
String(pr.number),
|
|
63
|
+
"--json",
|
|
64
|
+
"state"
|
|
65
|
+
]).pipe(Effect.flatMap((raw) => parsePrState(raw)), Effect.flatMap((state) => state === "MERGED" ? Effect.succeed("merged") : classifyRequiredChecks(pr, gh)));
|
|
66
|
+
}
|
|
67
|
+
function parsePrState(raw) {
|
|
68
|
+
if (typeof raw === "object" && raw !== null && "state" in raw && typeof raw.state === "string") return Effect.succeed(raw.state);
|
|
69
|
+
return Effect.fail(/* @__PURE__ */ new Error("gh pr view response missing string `state`"));
|
|
70
|
+
}
|
|
71
|
+
function adaptSubmit(submit, generateRunId, input) {
|
|
72
|
+
const runId = generateRunId();
|
|
73
|
+
return submit({
|
|
74
|
+
deliveryMode: input.deliveryMode,
|
|
75
|
+
headBranch: input.headBranch,
|
|
76
|
+
repositorySha: input.repositorySha,
|
|
77
|
+
runId,
|
|
78
|
+
task: input.ticketId
|
|
79
|
+
}).pipe(Effect.map((result) => ({
|
|
80
|
+
runId,
|
|
81
|
+
workflowName: result.workflowName
|
|
82
|
+
})));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Default submit seam: shape a graph submitMoka call. For remediation
|
|
86
|
+
* (`update-existing-pr`) it forwards `delivery.mode`, the PR head sha
|
|
87
|
+
* (`repository.sha`) and the PR branch (`repository.headBranch` =
|
|
88
|
+
* `moka/run/<originalRunId>`) so fix-commits APPEND to the existing PR branch.
|
|
89
|
+
*/
|
|
90
|
+
function defaultSubmitRun(context, submit) {
|
|
91
|
+
return (request) => Effect.tryPromise({
|
|
92
|
+
catch: submitError,
|
|
93
|
+
try: () => submit({
|
|
94
|
+
config: context.config,
|
|
95
|
+
delivery: {
|
|
96
|
+
mode: request.deliveryMode,
|
|
97
|
+
pullRequest: true
|
|
98
|
+
},
|
|
99
|
+
eventUrl: context.eventUrl,
|
|
100
|
+
gitCredentialsSecretName: context.gitCredentialsSecretName,
|
|
101
|
+
githubAuthSecretName: context.githubAuthSecretName,
|
|
102
|
+
mode: "full",
|
|
103
|
+
namespace: context.namespace,
|
|
104
|
+
opencodeAuthSecretName: context.opencodeAuthSecretName,
|
|
105
|
+
opencodeOpenaiAccountsSecretName: context.opencodeOpenaiAccountsSecretName,
|
|
106
|
+
repository: submitRepository(context, request),
|
|
107
|
+
run: {
|
|
108
|
+
id: request.runId,
|
|
109
|
+
project: context.project
|
|
110
|
+
},
|
|
111
|
+
serviceAccountName: context.serviceAccountName,
|
|
112
|
+
task: request.task,
|
|
113
|
+
type: "graph"
|
|
114
|
+
})
|
|
115
|
+
}).pipe(Effect.map((result) => ({ workflowName: result.workflowName })));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Repository context for a child submit. Remediation overrides the head sha and
|
|
119
|
+
* head branch so the workspace IS the PR branch; the create path leaves them
|
|
120
|
+
* absent (the runner cuts a fresh `moka/run/<runId>` branch from baseBranch).
|
|
121
|
+
*/
|
|
122
|
+
function submitRepository(context, request) {
|
|
123
|
+
return {
|
|
124
|
+
baseBranch: context.baseBranch,
|
|
125
|
+
...request.headBranch ? { headBranch: request.headBranch } : {},
|
|
126
|
+
...request.repositorySha ? { sha: request.repositorySha } : {},
|
|
127
|
+
url: context.url
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** One owner of LoopControllerEvent → RunnerEventRecord, keyed on event type. */
|
|
131
|
+
const RECORD_BUILDER = {
|
|
132
|
+
"loop.start": (event, envelope) => event.type === "loop.start" ? {
|
|
133
|
+
...envelope,
|
|
134
|
+
loopStart: { strategy: event.strategy },
|
|
135
|
+
type: "loop.start"
|
|
136
|
+
} : unreachable(event),
|
|
137
|
+
"loop.graph.snapshot": (event, envelope) => event.type === "loop.graph.snapshot" ? {
|
|
138
|
+
...envelope,
|
|
139
|
+
loopGraphSnapshot: ticketGraphDtoSchema.parse(event.snapshot),
|
|
140
|
+
type: "loop.graph.snapshot"
|
|
141
|
+
} : unreachable(event),
|
|
142
|
+
"loop.node.transition": (event, envelope) => event.type === "loop.node.transition" ? {
|
|
143
|
+
...envelope,
|
|
144
|
+
loopNodeTransition: {
|
|
145
|
+
loopState: event.loopState,
|
|
146
|
+
ticketId: event.ticketId
|
|
147
|
+
},
|
|
148
|
+
type: "loop.node.transition"
|
|
149
|
+
} : unreachable(event),
|
|
150
|
+
"loop.finish": (event, envelope) => event.type === "loop.finish" ? {
|
|
151
|
+
...envelope,
|
|
152
|
+
loopFinish: {
|
|
153
|
+
blocked: event.blocked,
|
|
154
|
+
passed: event.passed
|
|
155
|
+
},
|
|
156
|
+
type: "loop.finish"
|
|
157
|
+
} : unreachable(event)
|
|
158
|
+
};
|
|
159
|
+
function unreachable(event) {
|
|
160
|
+
throw new Error(`unexpected loop event for builder: ${event.type}`);
|
|
161
|
+
}
|
|
162
|
+
function buildEmit(context, postEvent) {
|
|
163
|
+
const post = postEvent ?? defaultPostEvent(context);
|
|
164
|
+
let sequence = 0;
|
|
165
|
+
return (event) => Effect.promise(() => {
|
|
166
|
+
sequence += 1;
|
|
167
|
+
return post(RECORD_BUILDER[event.type](event, {
|
|
168
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
169
|
+
runId: context.runId,
|
|
170
|
+
sequence
|
|
171
|
+
}));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Default sink: POST the single loop.* record through the authenticated runner
|
|
176
|
+
* event-sink batch endpoint (the same path runtime records use). The records
|
|
177
|
+
* carry the loop.* shapes the console consumes; sequence is monotonic per run.
|
|
178
|
+
*/
|
|
179
|
+
function defaultPostEvent(context) {
|
|
180
|
+
const fetchImpl = globalThis.fetch?.bind(globalThis);
|
|
181
|
+
if (!fetchImpl) throw new Error("Loop controller event sink requires fetch support");
|
|
182
|
+
return (record) => Effect.runPromise(Effect.provide(Effect.flatMap(RunnerEventSinkHttpService, (service) => service.postBatch({
|
|
183
|
+
authHeader: context.eventAuthHeader,
|
|
184
|
+
authToken: context.eventAuthToken,
|
|
185
|
+
events: [record],
|
|
186
|
+
fetch: fetchImpl,
|
|
187
|
+
maxRetries: 0,
|
|
188
|
+
retryDelayMs: 250,
|
|
189
|
+
url: context.eventUrl
|
|
190
|
+
})), RunnerEventSinkHttpServiceLive));
|
|
191
|
+
}
|
|
192
|
+
async function defaultGitRefresh(worktreePath) {
|
|
193
|
+
await runAuthenticatedGit(worktreePath, [
|
|
194
|
+
"fetch",
|
|
195
|
+
"origin",
|
|
196
|
+
"main"
|
|
197
|
+
]);
|
|
198
|
+
await runAuthenticatedGit(worktreePath, [
|
|
199
|
+
"pull",
|
|
200
|
+
"--ff-only",
|
|
201
|
+
"origin",
|
|
202
|
+
"main"
|
|
203
|
+
]);
|
|
204
|
+
}
|
|
205
|
+
function defaultPollPhase(input) {
|
|
206
|
+
return pollWorkflowPhaseUntilTerminal({
|
|
207
|
+
namespace: input.namespace,
|
|
208
|
+
workflowName: input.workflowName
|
|
209
|
+
}).pipe(Effect.mapError(toError));
|
|
210
|
+
}
|
|
211
|
+
function defaultRunId() {
|
|
212
|
+
return `loop-${randomBytes(8).toString("hex")}`;
|
|
213
|
+
}
|
|
214
|
+
function refreshError(error) {
|
|
215
|
+
return /* @__PURE__ */ new Error(`backlog refresh failed: ${messageOf(error)}`);
|
|
216
|
+
}
|
|
217
|
+
function submitError(error) {
|
|
218
|
+
return /* @__PURE__ */ new Error(`child run submit failed: ${messageOf(error)}`);
|
|
219
|
+
}
|
|
220
|
+
function toError(error) {
|
|
221
|
+
return error instanceof Error ? error : new Error(messageOf(error));
|
|
222
|
+
}
|
|
223
|
+
function messageOf(error) {
|
|
224
|
+
return error instanceof Error ? error.message : String(error);
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
export { buildControllerDeps };
|