@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.
@@ -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 = 3600;
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));
@@ -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,
@@ -6,6 +6,7 @@ interface RunCommandCall {
6
6
  readonly flags: RunResolverFlags;
7
7
  readonly resolution: RunResolution;
8
8
  readonly task: string;
9
+ readonly ticketId?: string;
9
10
  }
10
11
  type RunCommand = (call: RunCommandCall) => Promise<void> | void;
11
12
  //#endregion
@@ -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 description ? `${title}\n\n${description}` : title;
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* () {
@@ -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<{
@@ -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
- label: z.string().min(1).default("preview")
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 };