From 2f5eb864415989a569e0931284722a167ec08f91 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:02:14 +0000 Subject: [PATCH 01/21] chore(wrangler): register RepoConfigWorkflow + RepoConfigDO bindings Add smol-toml dependency and declare the workflow, durable object, and SQLite migration bindings required by the upcoming repo config loader. Implementations of RepoConfigDO and RepoConfigWorkflow land in later tasks; typecheck currently passes because wrangler emits placeholder comments for unresolved class names. --- package-lock.json | 13 +++++++++++++ package.json | 1 + worker-configuration.d.ts | 5 ++++- wrangler.toml | 13 +++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 2ddc154..e80be5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@octokit/graphql": "^9.0.3", "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", + "smol-toml": "^1.6.1", "zod": "^4.3.6" }, "devDependencies": { @@ -2808,6 +2809,18 @@ "dev": true, "license": "ISC" }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 3c2dde2..e9eed52 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@octokit/graphql": "^9.0.3", "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", + "smol-toml": "^1.6.1", "zod": "^4.3.6" }, "devDependencies": { diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 9d010f6..87da2d2 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,9 +1,10 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: e3337410ae23afe4d175d62a4fd87cd4) +// Generated by Wrangler by running `wrangler types` (hash: 1ec4592323332301937cb6362fc8b9d4) // Runtime types generated with workerd@1.20260415.1 2026-04-18 nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/main"); + durableNamespaces: "RepoConfigDO"; } interface Env { AGENT_GITHUB_USERNAME: "xmtp-coder-agent"; @@ -13,7 +14,9 @@ declare namespace Cloudflare { CODER_TEMPLATE_NAME_CODEX: "task-template-codex"; CODER_ORGANIZATION: "default"; LOG_FORMAT: "json"; + REPO_CONFIG_DO: DurableObjectNamespace /* RepoConfigDO */; TASK_RUNNER_WORKFLOW: Workflow[0]['payload']>; + REPO_CONFIG_WORKFLOW: Workflow /* RepoConfigWorkflow */; } } interface Env extends Cloudflare.Env {} diff --git a/wrangler.toml b/wrangler.toml index 19cfa49..b62722b 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -9,6 +9,19 @@ name = "task-runner-workflow" binding = "TASK_RUNNER_WORKFLOW" class_name = "TaskRunnerWorkflow" +[[workflows]] +name = "repo-config-workflow" +binding = "REPO_CONFIG_WORKFLOW" +class_name = "RepoConfigWorkflow" + +[[durable_objects.bindings]] +name = "REPO_CONFIG_DO" +class_name = "RepoConfigDO" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["RepoConfigDO"] + [limits] cpu_ms = 30000 From 9750bf23a3d5765ef33f8dc1527384d5ea5f5520 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:06:17 +0000 Subject: [PATCH 02/21] test(config): add failing tests for RepoConfig schema --- src/config/repo-config-schema.test.ts | 120 ++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/config/repo-config-schema.test.ts diff --git a/src/config/repo-config-schema.test.ts b/src/config/repo-config-schema.test.ts new file mode 100644 index 0000000..38bdbb5 --- /dev/null +++ b/src/config/repo-config-schema.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "vitest"; +import { + parseRepoConfigToml, + resolveRepoConfigSettings, +} from "./repo-config-schema"; + +describe("parseRepoConfigToml — happy paths", () => { + test("empty string → empty sparse settings", () => { + expect(parseRepoConfigToml("")).toEqual({}); + }); + test("whitespace only → empty sparse settings", () => { + expect(parseRepoConfigToml(" \n\n")).toEqual({}); + }); + test("full valid TOML → sparse object with all declared fields", () => { + const toml = ` +[sandbox] +size = "medium" +docker = true + +[[sandbox.volumes]] +path = "/data" +size = "20gb" + +[harness] +provider = "claude" + +[[scheduled_jobs]] +name = "nightly" +branch = "main" +schedule = "0 0 * * *" +prompt = "Do the thing" +`; + const parsed = parseRepoConfigToml(toml); + expect(parsed.sandbox?.size).toBe("medium"); + expect(parsed.sandbox?.docker).toBe(true); + expect(parsed.sandbox?.volumes?.[0]).toEqual({ + path: "/data", + size: "20gb", + }); + expect(parsed.harness?.provider).toBe("claude"); + expect(parsed.scheduled_jobs?.[0]?.name).toBe("nightly"); + }); + test("unknown keys are dropped (write-side loose-parse)", () => { + const parsed = parseRepoConfigToml(`[future_feature]\nkey = "value"`); + expect(parsed).toEqual({}); + }); + test("partial fields do not materialize defaults", () => { + const parsed = parseRepoConfigToml(`[sandbox]\ndocker = true`); + expect(parsed).toEqual({ sandbox: { docker: true } }); + expect(parsed.sandbox?.size).toBeUndefined(); + }); +}); + +describe("parseRepoConfigToml — failure paths throw NonRetryableError", () => { + test("invalid TOML syntax", () => { + expect(() => parseRepoConfigToml("not = toml = bad")).toThrow( + /Invalid TOML/, + ); + }); + test("sandbox.size out of enum", () => { + expect(() => parseRepoConfigToml(`[sandbox]\nsize = "huge"`)).toThrow( + /Invalid RepoConfig/, + ); + }); + test("harness.provider out of enum", () => { + expect(() => parseRepoConfigToml(`[harness]\nprovider = "gemini"`)).toThrow( + /Invalid RepoConfig/, + ); + }); + test("sandbox.volumes entry missing path", () => { + expect(() => + parseRepoConfigToml(`[[sandbox.volumes]]\nsize = "10gb"`), + ).toThrow(/Invalid RepoConfig/); + }); + test("scheduled_jobs entry missing branch", () => { + expect(() => + parseRepoConfigToml( + `[[scheduled_jobs]]\nname = "x"\nschedule = "0 0 * * *"\nprompt = "y"`, + ), + ).toThrow(/Invalid RepoConfig/); + }); + test("error messages do not include raw values (secret-leak guard)", () => { + try { + parseRepoConfigToml(`[harness]\nprovider = "MY_SECRET_LEAK"`); + } catch (err) { + expect((err as Error).message).not.toContain("MY_SECRET_LEAK"); + } + }); +}); + +describe("resolveRepoConfigSettings — defaults applied on read", () => { + test("undefined → full defaults", () => { + const r = resolveRepoConfigSettings(undefined); + expect(r.sandbox.size).toBe("medium"); + expect(r.sandbox.docker).toBe(false); + expect(r.sandbox.volumes).toEqual([]); + expect(r.harness.provider).toBe("claude"); + expect(r.scheduled_jobs).toEqual([]); + }); + test("empty object → full defaults", () => { + expect(resolveRepoConfigSettings({})).toEqual({ + sandbox: { size: "medium", docker: false, volumes: [] }, + harness: { provider: "claude" }, + scheduled_jobs: [], + }); + }); + test("volume with path-only → size defaulted to '10gb'", () => { + const r = resolveRepoConfigSettings({ + sandbox: { volumes: [{ path: "/data" }] }, + }); + expect(r.sandbox.volumes[0]).toEqual({ path: "/data", size: "10gb" }); + }); + test("partial override: explicit size beats default", () => { + const r = resolveRepoConfigSettings({ + sandbox: { size: "large" }, + }); + expect(r.sandbox.size).toBe("large"); + expect(r.sandbox.docker).toBe(false); + }); +}); From 31cb61234fa6e2320024ca7b87d9b4d619337152 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:06:27 +0000 Subject: [PATCH 03/21] feat(config): add sparse+resolved RepoConfig Zod schemas --- src/config/repo-config-schema.ts | 165 +++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/config/repo-config-schema.ts diff --git a/src/config/repo-config-schema.ts b/src/config/repo-config-schema.ts new file mode 100644 index 0000000..2df680f --- /dev/null +++ b/src/config/repo-config-schema.ts @@ -0,0 +1,165 @@ +import { NonRetryableError } from "cloudflare:workflows"; +import { parse } from "smol-toml"; +import { z } from "zod"; + +// ── Shared enums ───────────────────────────────────────────────────────────── + +/** Allowed values for `sandbox.size`. */ +export const SandboxSizeSchema = z.enum(["small", "medium", "large"]); + +/** Allowed values for `harness.provider`. */ +export const HarnessProviderSchema = z.enum(["claude", "codex"]); + +// ── Sparse (stored) schemas ────────────────────────────────────────────────── +// Sparse schemas mirror what users actually wrote in TOML. No `.default()`: +// defaults are applied at read time, not write time, so we can distinguish +// "unset" from "explicitly set to the default value" when needed. + +/** Sparse shape for a single sandbox volume entry. `path` is required. */ +export const StoredSandboxVolumeSchema = z.object({ + path: z.string(), + size: z.string().optional(), +}); + +/** Sparse shape for the `[sandbox]` section. */ +export const StoredSandboxSchema = z.object({ + size: SandboxSizeSchema.optional(), + docker: z.boolean().optional(), + volumes: z.array(StoredSandboxVolumeSchema).optional(), +}); + +/** Sparse shape for the `[harness]` section. */ +export const StoredHarnessSchema = z.object({ + provider: HarnessProviderSchema.optional(), +}); + +/** + * A scheduled job entry. Leaf entries — either present with all fields, or + * absent entirely. No partial storage. + */ +export const ScheduledJobSchema = z.object({ + name: z.string(), + branch: z.string(), + schedule: z.string(), + prompt: z.string(), +}); + +/** Top-level sparse shape as stored by the DO. */ +export const StoredRepoConfigSettingsSchema = z.object({ + sandbox: StoredSandboxSchema.optional(), + harness: StoredHarnessSchema.optional(), + scheduled_jobs: z.array(ScheduledJobSchema).optional(), +}); + +// ── Resolved (read-side) schemas ───────────────────────────────────────────── +// Resolved schemas apply defaults on read so every consumer sees a fully +// populated object without worrying about whether a field was written. + +/** Resolved volume: `size` defaults to `"10gb"` when absent. */ +export const ResolvedSandboxVolumeSchema = z.object({ + path: z.string(), + size: z.string().default("10gb"), +}); + +/** Resolved sandbox: size/docker/volumes all have defaults. */ +export const ResolvedSandboxSchema = z.object({ + size: SandboxSizeSchema.default("medium"), + docker: z.boolean().default(false), + volumes: z.array(ResolvedSandboxVolumeSchema).default([]), +}); + +/** Resolved harness: provider defaults to `"claude"`. */ +export const ResolvedHarnessSchema = z.object({ + provider: HarnessProviderSchema.default("claude"), +}); + +/** + * Top-level resolved shape — always fully populated after `.parse()`. + * + * We use `.prefault({})` on object sub-schemas (not `.default({})`) because + * in Zod v4, `.default(value)` bypasses validation and returns `value` as-is, + * so inner field defaults would NOT be applied. `.prefault({})` substitutes + * `{}` as the input and then runs it through the child schema, correctly + * triggering each inner `.default(...)`. + */ +export const RepoConfigSettingsSchema = z.object({ + sandbox: ResolvedSandboxSchema.prefault({}), + harness: ResolvedHarnessSchema.prefault({}), + scheduled_jobs: z.array(ScheduledJobSchema).default([]), +}); + +// ── Types ──────────────────────────────────────────────────────────────────── + +/** Sparse settings as stored in the DO (fields may be missing). */ +export type StoredRepoConfigSettings = z.infer< + typeof StoredRepoConfigSettingsSchema +>; + +/** Fully resolved settings with defaults applied — safe to consume. */ +export type RepoConfigSettings = z.infer; + +/** Stored RepoConfig envelope (DO record). */ +export type StoredRepoConfig = { + repositoryId: number; + repositoryFullName: string; + installationId: number; + settings: StoredRepoConfigSettings; +}; + +/** Resolved RepoConfig envelope — defaults applied for consumers. */ +export type RepoConfig = { + repositoryId: number; + repositoryFullName: string; + installationId: number; + settings: RepoConfigSettings; +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Apply read-side defaults to a (possibly undefined) sparse settings object. + * Pure — no I/O, no side effects. Safe to call inside `step.do` callbacks. + */ +export function resolveRepoConfigSettings( + stored?: StoredRepoConfigSettings | undefined, +): RepoConfigSettings { + return RepoConfigSettingsSchema.parse(stored ?? {}); +} + +/** + * Parse a TOML string into a sparse, validated `StoredRepoConfigSettings`. + * + * Invariants: + * - Unknown keys are silently dropped (Zod default `.strip()` behavior) so + * repos can land forward-compatible config before the server knows about + * it. + * - No defaults are materialized here — this is the write path. Defaults + * live in `resolveRepoConfigSettings`. + * - Error messages NEVER include raw input values. We assemble messages from + * `issue.path` + `issue.message` only, so a malformed value that happens + * to contain a secret cannot leak into logs or thrown exceptions. + * + * Throws `NonRetryableError` on any failure — TOML syntax or schema violation. + */ +export function parseRepoConfigToml(raw: string): StoredRepoConfigSettings { + let parsed: unknown; + try { + parsed = parse(raw); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new NonRetryableError(`Invalid TOML: ${message}`); + } + + const result = StoredRepoConfigSettingsSchema.safeParse(parsed); + if (!result.success) { + // Build the error message from Zod issue paths + messages ONLY — never + // include `issue.input` or any raw value. See module docstring for the + // secret-leak invariant. + const issues = result.error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("; "); + throw new NonRetryableError(`Invalid RepoConfig: ${issues}`); + } + + return result.data; +} From a4f5a37658a93c9dac669255154093fb81867091 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:09:09 +0000 Subject: [PATCH 04/21] test(durable-objects): add failing RepoConfigDO get/set tests Colocated test suite for the upcoming sqlite-backed RepoConfigDO: binding smoke checks plus five round-trip scenarios (fresh read, defaults applied, overwrite-no-merge, volume path-only default size, and distinct-fullName isolation). Each test uses its own idFromName to avoid cross-test state. --- src/durable-objects/repo-config-do.test.ts | 107 +++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/durable-objects/repo-config-do.test.ts diff --git a/src/durable-objects/repo-config-do.test.ts b/src/durable-objects/repo-config-do.test.ts new file mode 100644 index 0000000..c4607d0 --- /dev/null +++ b/src/durable-objects/repo-config-do.test.ts @@ -0,0 +1,107 @@ +import { env } from "cloudflare:test"; +import { describe, expect, test } from "vitest"; +import type { StoredRepoConfig } from "../config/repo-config-schema"; +import { RepoConfigDO } from "./repo-config-do"; + +const FULL_NAME = "acme/repo"; + +function makeStub() { + const id = env.REPO_CONFIG_DO.idFromName(FULL_NAME); + return env.REPO_CONFIG_DO.get(id); +} + +describe("RepoConfigDO — binding smoke", () => { + test("class is exported and name matches wrangler.toml class_name", () => { + expect(RepoConfigDO.name).toBe("RepoConfigDO"); + }); + test("env.REPO_CONFIG_DO is callable", () => { + expect(env.REPO_CONFIG_DO).toBeDefined(); + expect(typeof env.REPO_CONFIG_DO.idFromName).toBe("function"); + // makeStub is a helper available for parity with workflow-test patterns; + // referenced here so tsc/biome don't flag it unused. + expect(typeof makeStub).toBe("function"); + }); +}); + +describe("RepoConfigDO — get/set round-trip", () => { + test("fresh instance returns null", async () => { + const id = env.REPO_CONFIG_DO.idFromName("acme/fresh"); + const stub = env.REPO_CONFIG_DO.get(id); + await expect(stub.getRepoConfig()).resolves.toBeNull(); + }); + + test("set then get returns resolved shape (defaults applied)", async () => { + const id = env.REPO_CONFIG_DO.idFromName("acme/round-trip"); + const stub = env.REPO_CONFIG_DO.get(id); + const cfg: StoredRepoConfig = { + repositoryId: 1, + repositoryFullName: "acme/round-trip", + installationId: 100, + settings: {}, + }; + await stub.setRepoConfig(cfg); + const read = await stub.getRepoConfig(); + expect(read).not.toBeNull(); + expect(read?.repositoryId).toBe(1); + expect(read?.repositoryFullName).toBe("acme/round-trip"); + expect(read?.installationId).toBe(100); + expect(read?.settings.sandbox.size).toBe("medium"); + expect(read?.settings.sandbox.docker).toBe(false); + expect(read?.settings.sandbox.volumes).toEqual([]); + expect(read?.settings.harness.provider).toBe("claude"); + expect(read?.settings.scheduled_jobs).toEqual([]); + }); + + test("set overwrites prior state (no merge)", async () => { + const id = env.REPO_CONFIG_DO.idFromName("acme/overwrite"); + const stub = env.REPO_CONFIG_DO.get(id); + await stub.setRepoConfig({ + repositoryId: 1, + repositoryFullName: "acme/overwrite", + installationId: 100, + settings: { sandbox: { size: "small" } }, + }); + await stub.setRepoConfig({ + repositoryId: 2, + repositoryFullName: "acme/overwrite", + installationId: 200, + settings: { harness: { provider: "codex" } }, + }); + const read = await stub.getRepoConfig(); + expect(read?.repositoryId).toBe(2); + expect(read?.installationId).toBe(200); + // The overwritten sparse settings contain no sandbox section → size reverts to default "medium" + expect(read?.settings.sandbox.size).toBe("medium"); + expect(read?.settings.harness.provider).toBe("codex"); + }); + + test("volume with path-only returns resolved size '10gb'", async () => { + const id = env.REPO_CONFIG_DO.idFromName("acme/volumes"); + const stub = env.REPO_CONFIG_DO.get(id); + await stub.setRepoConfig({ + repositoryId: 3, + repositoryFullName: "acme/volumes", + installationId: 300, + settings: { sandbox: { volumes: [{ path: "/data" }] } }, + }); + const read = await stub.getRepoConfig(); + expect(read?.settings.sandbox.volumes).toEqual([ + { path: "/data", size: "10gb" }, + ]); + }); + + test("distinct fullNames route to distinct DO instances", async () => { + const idA = env.REPO_CONFIG_DO.idFromName("acme/a"); + const idB = env.REPO_CONFIG_DO.idFromName("acme/b"); + expect(idA.toString()).not.toBe(idB.toString()); + await env.REPO_CONFIG_DO.get(idA).setRepoConfig({ + repositoryId: 1, + repositoryFullName: "acme/a", + installationId: 1, + settings: {}, + }); + await expect( + env.REPO_CONFIG_DO.get(idB).getRepoConfig(), + ).resolves.toBeNull(); + }); +}); From 599df07e336e9601a87f6fbd0d8b383ee079e58d Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:10:57 +0000 Subject: [PATCH 05/21] feat(durable-objects): add RepoConfigDO with synchronous KV storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RepoConfigDO is a sqlite-backed Durable Object (see wrangler.toml migration v1 new_sqlite_classes) that stores one StoredRepoConfig envelope per repository, routed by idFromName(repositoryFullName). It exposes two RPC methods: - setRepoConfig(cfg): persists the sparse envelope via the synchronous ctx.storage.kv.put API (no await). - getRepoConfig(): reads the envelope and projects it into a fully resolved RepoConfig via resolveRepoConfigSettings, returning null when no value has been written. The class is re-exported from src/main.ts so Wrangler can resolve class_name = "RepoConfigDO" on the Worker entry. worker-configuration.d.ts is regenerated via wrangler types so the binding is typed as DurableObjectNamespace and the RPC methods are reachable from callers with full type safety. Per EARS-REQ-12 the DO performs no GitHub or Coder I/O — it is a passive store. --- src/durable-objects/repo-config-do.ts | 48 +++++++++++++++++++++++++++ src/main.ts | 1 + worker-configuration.d.ts | 4 +-- 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/durable-objects/repo-config-do.ts diff --git a/src/durable-objects/repo-config-do.ts b/src/durable-objects/repo-config-do.ts new file mode 100644 index 0000000..ec145a4 --- /dev/null +++ b/src/durable-objects/repo-config-do.ts @@ -0,0 +1,48 @@ +import { DurableObject } from "cloudflare:workers"; +import { + type RepoConfig, + resolveRepoConfigSettings, + type StoredRepoConfig, +} from "../config/repo-config-schema"; + +/** + * Single-key identifier used inside the DO's sqlite-backed KV. The DO is + * dedicated per-repository (routed by `idFromName(repositoryFullName)`), so a + * fixed key is sufficient — we never need to store more than one envelope. + */ +const CONFIG_KEY = "config"; + +/** + * Sqlite-backed Durable Object that stores a single `StoredRepoConfig` + * envelope per repository (one DO instance per `repositoryFullName`). + * + * Passive store only — the DO performs no GitHub or Coder I/O (EARS-REQ-12). + * Write-side validation and resolution happen in the RepoConfigWorkflow; this + * class simply persists a sparse envelope and projects it into a fully + * resolved `RepoConfig` on read. + * + * Storage API: uses the SYNCHRONOUS sqlite KV (`ctx.storage.kv.put/.get`) — + * not the legacy async `ctx.storage.put/.get`. The migration in + * `wrangler.toml` registers `RepoConfigDO` under `new_sqlite_classes`, so the + * sync surface is available. + */ +export class RepoConfigDO extends DurableObject { + async setRepoConfig(cfg: StoredRepoConfig): Promise { + this.ctx.storage.kv.put(CONFIG_KEY, cfg); + } + + async getRepoConfig(): Promise { + const stored = this.ctx.storage.kv.get(CONFIG_KEY) as + | StoredRepoConfig + | undefined; + if (stored === undefined) { + return null; + } + return { + repositoryId: stored.repositoryId, + repositoryFullName: stored.repositoryFullName, + installationId: stored.installationId, + settings: resolveRepoConfigSettings(stored.settings), + }; + } +} diff --git a/src/main.ts b/src/main.ts index e8c45be..bf0025e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import { import type { TaskRunnerWorkflowEnv } from "./workflows/task-runner-workflow"; export { TaskRunnerWorkflow } from "./workflows/task-runner-workflow"; +export { RepoConfigDO } from "./durable-objects/repo-config-do"; export { __setAppBotLoginForTests }; // ── Worker entrypoint ──────────────────────────────────────────────────────── diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 87da2d2..95020d5 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 1ec4592323332301937cb6362fc8b9d4) +// Generated by Wrangler by running `wrangler types` (hash: f7e04cad4376eb89009e32d111a4064d) // Runtime types generated with workerd@1.20260415.1 2026-04-18 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -14,7 +14,7 @@ declare namespace Cloudflare { CODER_TEMPLATE_NAME_CODEX: "task-template-codex"; CODER_ORGANIZATION: "default"; LOG_FORMAT: "json"; - REPO_CONFIG_DO: DurableObjectNamespace /* RepoConfigDO */; + REPO_CONFIG_DO: DurableObjectNamespace; TASK_RUNNER_WORKFLOW: Workflow[0]['payload']>; REPO_CONFIG_WORKFLOW: Workflow /* RepoConfigWorkflow */; } From 5221c19bcbac894c70a2a98ed8a184d994838800 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:14:01 +0000 Subject: [PATCH 06/21] feat(events): add ConfigPushEvent variant to Event union --- src/events/types.test.ts | 5 +++-- src/events/types.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/events/types.test.ts b/src/events/types.test.ts index f2d1dcf..d690b7b 100644 --- a/src/events/types.test.ts +++ b/src/events/types.test.ts @@ -2,14 +2,15 @@ import { describe, expect, test } from "vitest"; import type { Event, EventSource } from "./types"; describe("Event union", () => { - test("has four variants", () => { + test("has five variants", () => { const variants: Event["type"][] = [ "task_requested", "task_closed", "comment_posted", "check_failed", + "config_push", ]; - expect(variants).toHaveLength(4); + expect(variants).toHaveLength(5); }); test("EventSource supports github variant", () => { diff --git a/src/events/types.ts b/src/events/types.ts index f8979bb..643033d 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -61,8 +61,25 @@ export type CheckFailedEvent = { pullRequestNumbers: number[]; }; +export type ConfigPushEvent = { + type: "config_push"; + source: EventSource; + repository: { + id: number; + owner: string; + name: string; + fullName: string; + defaultBranch: string; + }; + head: { + sha: string; + ref: string; + }; +}; + export type Event = | TaskRequestedEvent | TaskClosedEvent | CommentPostedEvent - | CheckFailedEvent; + | CheckFailedEvent + | ConfigPushEvent; From df45ddddf90db6ad901d95dbe08dbbaa68f67277 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:14:12 +0000 Subject: [PATCH 07/21] feat(webhooks): re-export PushPayload from @octokit/webhooks --- src/webhooks/github/payload-types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/webhooks/github/payload-types.ts b/src/webhooks/github/payload-types.ts index 0afda99..ac98170 100644 --- a/src/webhooks/github/payload-types.ts +++ b/src/webhooks/github/payload-types.ts @@ -18,5 +18,6 @@ export type PRReviewCommentEditedPayload = WebhookEventDefinition<"pull-request-review-comment-edited">; export type PRReviewSubmittedPayload = WebhookEventDefinition<"pull-request-review-submitted">; +export type PushPayload = WebhookEventDefinition<"push">; export type WorkflowRunCompletedPayload = WebhookEventDefinition<"workflow-run-completed">; From b2b53670b9747d9b8757704eb44623e57a4400e5 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:15:48 +0000 Subject: [PATCH 08/21] feat(router): route push events on default branch to ConfigPushEvent --- src/testing/fixtures/push-default-branch.json | 18 ++++++++ src/webhooks/github/router.test.ts | 43 ++++++++++++++++++- src/webhooks/github/router.ts | 40 +++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/testing/fixtures/push-default-branch.json diff --git a/src/testing/fixtures/push-default-branch.json b/src/testing/fixtures/push-default-branch.json new file mode 100644 index 0000000..878159b --- /dev/null +++ b/src/testing/fixtures/push-default-branch.json @@ -0,0 +1,18 @@ +{ + "ref": "refs/heads/main", + "before": "0000000000000000000000000000000000000000", + "after": "abcdef1234567890abcdef1234567890abcdef12", + "repository": { + "id": 1185202430, + "name": "coder-action", + "full_name": "xmtplabs/coder-action", + "default_branch": "main", + "owner": { + "login": "xmtplabs", + "id": 1 + } + }, + "installation": { + "id": 118770088 + } +} diff --git a/src/webhooks/github/router.test.ts b/src/webhooks/github/router.test.ts index f15cf4c..ac32e68 100644 --- a/src/webhooks/github/router.test.ts +++ b/src/webhooks/github/router.test.ts @@ -6,6 +6,7 @@ import type { TaskClosedEvent, CommentPostedEvent, CheckFailedEvent, + ConfigPushEvent, Event, } from "../../events/types"; import type { Logger } from "../../utils/logger"; @@ -17,6 +18,7 @@ import issueCommentOnPr from "../../testing/fixtures/issue-comment-on-pr.json"; import prReviewComment from "../../testing/fixtures/pr-review-comment.json"; import prReviewSubmitted from "../../testing/fixtures/pr-review-submitted.json"; import prReviewSubmittedEmpty from "../../testing/fixtures/pr-review-submitted-empty.json"; +import pushDefaultBranch from "../../testing/fixtures/push-default-branch.json"; import workflowRunFailure from "../../testing/fixtures/workflow-run-failure.json"; import workflowRunSuccess from "../../testing/fixtures/workflow-run-success.json"; @@ -476,7 +478,7 @@ describe("WebhookRouter", () => { // ── unknown event ───────────────────────────────────────────────────────── test("unknown event → skipped", async () => { - const result = await router.handleGithubWebhook("push", "delivery-016", { + const result = await router.handleGithubWebhook("fork", "delivery-016", { ref: "refs/heads/main", }); @@ -485,6 +487,45 @@ describe("WebhookRouter", () => { expect(result.reason).toMatch(/unhandled/i); }); + // ── push event routing ──────────────────────────────────────────────────── + + describe("push event routing", () => { + test("push to default branch → config_push event", async () => { + const result = await router.handleGithubWebhook( + "push", + "delivery-push-1", + pushDefaultBranch, + ); + expect(isEvent(result)).toBe(true); + if (!isEvent(result)) throw new Error("expected event"); + const evt = result as ConfigPushEvent; + expect(evt.type).toBe("config_push"); + expect(evt.source.installationId).toBe(INSTALLATION_ID); + expect(evt.repository.id).toBe(1185202430); + expect(evt.repository.owner).toBe("xmtplabs"); + expect(evt.repository.name).toBe("coder-action"); + expect(evt.repository.fullName).toBe("xmtplabs/coder-action"); + expect(evt.repository.defaultBranch).toBe("main"); + expect(evt.head.sha).toBe("abcdef1234567890abcdef1234567890abcdef12"); + expect(evt.head.ref).toBe("refs/heads/main"); + }); + + test("push to non-default branch → skipped", async () => { + const payload = { ...pushDefaultBranch, ref: "refs/heads/feature" }; + const result = await router.handleGithubWebhook("push", "d2", payload); + expect(isSkip(result)).toBe(true); + if (!isSkip(result)) throw new Error("expected skip"); + expect(result.reason).toMatch(/ref/i); + expect(result.reason).toContain("feature"); + }); + + test("push with deleted default-branch after-sha still emits event", async () => { + const payload = { ...pushDefaultBranch, after: "0".repeat(40) }; + const result = await router.handleGithubWebhook("push", "d3", payload); + expect(isEvent(result)).toBe(true); + }); + }); + // ── installationId extraction ───────────────────────────────────────────── test("installationId is extracted from payload.installation.id", async () => { diff --git a/src/webhooks/github/router.ts b/src/webhooks/github/router.ts index 6c7e588..eb377e5 100644 --- a/src/webhooks/github/router.ts +++ b/src/webhooks/github/router.ts @@ -7,6 +7,7 @@ import type { PRReviewCommentCreatedPayload, PRReviewCommentEditedPayload, PRReviewSubmittedPayload, + PushPayload, WorkflowRunCompletedPayload, } from "./payload-types"; import { @@ -22,6 +23,7 @@ import type { TaskClosedEvent, CommentPostedEvent, CheckFailedEvent, + ConfigPushEvent, } from "../../events/types"; // ── Public types ────────────────────────────────────────────────────────────── @@ -130,6 +132,9 @@ export class WebhookRouter { instId, ); + case "push": + return this.routePush(payload as unknown as PushPayload, instId); + default: return { dispatched: false, @@ -422,4 +427,39 @@ export class WebhookRouter { }; return event; } + + private routePush( + payload: PushPayload, + instId: number, + ): ConfigPushEvent | SkipResult { + const defaultBranch = payload.repository.default_branch; + const expectedRef = `refs/heads/${defaultBranch}`; + if (payload.ref !== expectedRef) { + return { + dispatched: false, + reason: `Skipping push: ref "${payload.ref}" is not default branch "${expectedRef}"`, + }; + } + const owner = payload.repository.owner; + const ownerLogin = + (owner != null && "login" in owner ? owner.login : undefined) ?? + (owner != null && "name" in owner ? owner.name : undefined) ?? + ""; + const event: ConfigPushEvent = { + type: "config_push", + source: { type: "github", installationId: instId }, + repository: { + id: payload.repository.id, + owner: ownerLogin, + name: payload.repository.name, + fullName: payload.repository.full_name, + defaultBranch, + }, + head: { + sha: payload.after, + ref: payload.ref, + }, + }; + return event; + } } From 2ee9ac112ce01b92efa24c580de39febdf51b6af Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:17:19 +0000 Subject: [PATCH 09/21] feat(workflow): reject config_push on TaskRunnerWorkflow as NonRetryableError --- src/workflows/task-runner-workflow.test.ts | 28 ++++++++++++++++++++++ src/workflows/task-runner-workflow.ts | 5 ++++ 2 files changed, 33 insertions(+) diff --git a/src/workflows/task-runner-workflow.test.ts b/src/workflows/task-runner-workflow.test.ts index 33f5240..1c1770b 100644 --- a/src/workflows/task-runner-workflow.test.ts +++ b/src/workflows/task-runner-workflow.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test, vi } from "vitest"; import type { CheckFailedEvent, CommentPostedEvent, + ConfigPushEvent, TaskClosedEvent, TaskRequestedEvent, } from "../events/types"; @@ -517,3 +518,30 @@ describe("TaskRunnerWorkflow dispatch — check_failed", () => { // workflow-introspection variant for the same reason as task_requested // above: miniflare's `mockStepResult` treats `null` as "no mock set". }); + +describe("TaskRunnerWorkflow guards — config_push", () => { + test("config_push payload is rejected by TaskRunnerWorkflow (wrong workflow)", async () => { + const instanceId = "config_push-wrongly-dispatched"; + await using instance = await introspectWorkflowInstance( + env.TASK_RUNNER_WORKFLOW, + instanceId, + ); + await instance.modify(async (m) => { + await m.disableSleeps(); + }); + const params: ConfigPushEvent = { + type: "config_push", + source: { type: "github", installationId: 1 }, + repository: { + id: 1, + owner: "a", + name: "r", + fullName: "a/r", + defaultBranch: "main", + }, + head: { sha: "abc", ref: "refs/heads/main" }, + }; + await env.TASK_RUNNER_WORKFLOW.create({ id: instanceId, params }); + await expect(instance.waitForStatus("errored")).resolves.not.toThrow(); + }); +}); diff --git a/src/workflows/task-runner-workflow.ts b/src/workflows/task-runner-workflow.ts index d3b6792..02bda0a 100644 --- a/src/workflows/task-runner-workflow.ts +++ b/src/workflows/task-runner-workflow.ts @@ -3,6 +3,7 @@ import { type WorkflowEvent, type WorkflowStep, } from "cloudflare:workers"; +import { NonRetryableError } from "cloudflare:workflows"; import { createAppAuth } from "@octokit/auth-app"; import { Octokit } from "@octokit/rest"; import { loadConfig } from "../config/app-config"; @@ -103,6 +104,10 @@ export class TaskRunnerWorkflow extends WorkflowEntrypoint< case "check_failed": await runFailedCheck({ step, coder, github, config, event: payload }); break; + case "config_push": + throw new NonRetryableError( + "config_push events must be dispatched to RepoConfigWorkflow", + ); } } } From 69e895441f188fb113c0bcd7adc34b9e59df7390 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:19:14 +0000 Subject: [PATCH 10/21] feat(workflow): branch buildInstanceId for config_push events --- src/workflows/instance-id.test.ts | 39 ++++++++++++++++++++++++++++++- src/workflows/instance-id.ts | 2 ++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/workflows/instance-id.test.ts b/src/workflows/instance-id.test.ts index b62d829..b9b4089 100644 --- a/src/workflows/instance-id.test.ts +++ b/src/workflows/instance-id.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import type { Event } from "../events/types"; +import type { ConfigPushEvent, Event } from "../events/types"; import { buildInstanceId, isDuplicateInstanceError } from "./instance-id"; describe("buildInstanceId", () => { @@ -108,6 +108,43 @@ describe("buildInstanceId", () => { }); }); +const baseConfigPush: ConfigPushEvent = { + type: "config_push", + source: { type: "github", installationId: 1 }, + repository: { + id: 1, + owner: "acme", + name: "repo", + fullName: "acme/repo", + defaultBranch: "main", + }, + head: { + sha: "abcdef1234567890abcdef1234567890abcdef12", + ref: "refs/heads/main", + }, +}; + +describe("buildInstanceId — config_push", () => { + test("composite includes event type, repo name, head SHA, delivery ID", () => { + const id = buildInstanceId(baseConfigPush, "delivery-xyz"); + expect(id.startsWith("config_push-repo-")).toBe(true); + expect(id).toContain("abcdef1234567890"); + }); + test("length is <= 64 after sanitize + truncate", () => { + const id = buildInstanceId(baseConfigPush, "delivery-xyz"); + expect(id.length).toBeLessThanOrEqual(64); + }); + test("output charset is [a-zA-Z0-9_-]", () => { + const id = buildInstanceId(baseConfigPush, "delivery/with.dots"); + expect(/^[a-zA-Z0-9_-]+$/.test(id)).toBe(true); + }); + test("identical delivery IDs collide (dedupe anchor)", () => { + const a = buildInstanceId(baseConfigPush, "same-delivery"); + const b = buildInstanceId(baseConfigPush, "same-delivery"); + expect(a).toBe(b); + }); +}); + describe("isDuplicateInstanceError", () => { test("true when error message contains 'already exists'", () => { expect(isDuplicateInstanceError(new Error("instance already exists"))).toBe( diff --git a/src/workflows/instance-id.ts b/src/workflows/instance-id.ts index f2fecae..138c937 100644 --- a/src/workflows/instance-id.ts +++ b/src/workflows/instance-id.ts @@ -29,6 +29,8 @@ export function buildInstanceId(event: Event, deliveryId: string): string { ? `${event.type}-${event.repository.name}-${n}-${deliveryId}` : `${event.type}-${event.run.id}-${deliveryId}`; } + case "config_push": + return `${event.type}-${event.repository.name}-${event.head.sha}-${deliveryId}`; } })(); return raw.replace(SANITIZE, "-").slice(0, MAX_LEN); From 314fb9665d72630495891b40a3791d0661d165f2 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:19:42 +0000 Subject: [PATCH 11/21] feat(github): add getRepoContentFile with 404-as-null semantics --- src/services/github/client.test.ts | 107 +++++++++++++++++++++++++++++ src/services/github/client.ts | 31 +++++++++ 2 files changed, 138 insertions(+) diff --git a/src/services/github/client.test.ts b/src/services/github/client.test.ts index 055bb79..d068e29 100644 --- a/src/services/github/client.test.ts +++ b/src/services/github/client.test.ts @@ -300,4 +300,111 @@ describe("GitHubClient", () => { expect(lines[0]).toBe("line 101"); }); }); + + describe("getRepoContentFile", () => { + test("returns { contentBase64 } for a file with base64 encoding", async () => { + const octokit = { + rest: { + repos: { + getContent: async () => ({ + data: { + type: "file", + encoding: "base64", + content: "aGVsbG8=", + }, + }), + }, + }, + }; + const client = new GitHubClient( + octokit as unknown as import("./client").Octokit, + new TestLogger(), + ); + await expect( + client.getRepoContentFile("a", "r", ".code-factory/config.toml", "sha"), + ).resolves.toEqual({ contentBase64: "aGVsbG8=" }); + }); + + test("returns null on 404", async () => { + const octokit = { + rest: { + repos: { + getContent: async () => { + const err = new Error("Not Found") as Error & { + status?: number; + }; + err.status = 404; + throw err; + }, + }, + }, + }; + const client = new GitHubClient( + octokit as unknown as import("./client").Octokit, + new TestLogger(), + ); + await expect( + client.getRepoContentFile("a", "r", "x", "sha"), + ).resolves.toBeNull(); + }); + + test("returns null when response is an array (directory listing)", async () => { + const octokit = { + rest: { + repos: { + getContent: async () => ({ data: [] }), + }, + }, + }; + const client = new GitHubClient( + octokit as unknown as import("./client").Octokit, + new TestLogger(), + ); + await expect( + client.getRepoContentFile("a", "r", "x", "sha"), + ).resolves.toBeNull(); + }); + + test("returns null for submodule or symlink", async () => { + const octokit = { + rest: { + repos: { + getContent: async () => ({ + data: { type: "symlink", target: "../elsewhere" }, + }), + }, + }, + }; + const client = new GitHubClient( + octokit as unknown as import("./client").Octokit, + new TestLogger(), + ); + await expect( + client.getRepoContentFile("a", "r", "x", "sha"), + ).resolves.toBeNull(); + }); + + test("re-throws non-404 status codes", async () => { + const octokit = { + rest: { + repos: { + getContent: async () => { + const err = new Error("Server Error") as Error & { + status?: number; + }; + err.status = 500; + throw err; + }, + }, + }, + }; + const client = new GitHubClient( + octokit as unknown as import("./client").Octokit, + new TestLogger(), + ); + await expect( + client.getRepoContentFile("a", "r", "x", "sha"), + ).rejects.toThrow(/Server Error/); + }); + }); }); diff --git a/src/services/github/client.ts b/src/services/github/client.ts index 115e359..376cd03 100644 --- a/src/services/github/client.ts +++ b/src/services/github/client.ts @@ -161,6 +161,37 @@ export class GitHubClient { })); } + async getRepoContentFile( + owner: string, + repo: string, + path: string, + ref: string, + ): Promise<{ contentBase64: string } | null> { + try { + const res = await this.octokit.rest.repos.getContent({ + owner, + repo, + path, + ref, + }); + const data = res.data as + | Array + | { + type?: string; + encoding?: string; + content?: string; + }; + if (Array.isArray(data)) return null; + if (data.type !== "file") return null; + if (data.encoding !== "base64") return null; + if (typeof data.content !== "string") return null; + return { contentBase64: data.content }; + } catch (err: unknown) { + if ((err as { status?: number })?.status === 404) return null; + throw err; + } + } + async getJobLogs( owner: string, repo: string, From ef698f08b4e8001c5ca1a80ae3ce9ad932c36661 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:22:16 +0000 Subject: [PATCH 12/21] test(workflow): add failing tests for runSyncRepoConfig --- src/workflows/steps/sync-repo-config.test.ts | 210 +++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 src/workflows/steps/sync-repo-config.test.ts diff --git a/src/workflows/steps/sync-repo-config.test.ts b/src/workflows/steps/sync-repo-config.test.ts new file mode 100644 index 0000000..82789b6 --- /dev/null +++ b/src/workflows/steps/sync-repo-config.test.ts @@ -0,0 +1,210 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, test, vi } from "vitest"; +import type { StoredRepoConfig } from "../../config/repo-config-schema"; +import type { RepoConfigDO } from "../../durable-objects/repo-config-do"; +import type { ConfigPushEvent } from "../../events/types"; +import type { GitHubClient } from "../../services/github/client"; +import type { Logger } from "../../utils/logger"; +import { runSyncRepoConfig } from "./sync-repo-config"; + +type StepCall = { name: string; returned?: unknown; threw?: unknown }; + +function makeStep() { + const calls: StepCall[] = []; + const step = { + do: async (name: string, fn: () => Promise): Promise => { + try { + const returned = await fn(); + calls.push({ name, returned }); + return returned; + } catch (err) { + calls.push({ name, threw: err }); + throw err; + } + }, + } as unknown as import("cloudflare:workers").WorkflowStep; + return { step, calls }; +} + +const noopLogger: Logger = { + info: () => {}, + debug: () => {}, + warn: () => {}, + error: () => {}, + child: () => noopLogger, +}; + +const toBase64 = (s: string) => Buffer.from(s, "utf8").toString("base64"); + +function makeEvent(): ConfigPushEvent { + return { + type: "config_push", + source: { type: "github", installationId: 100 }, + repository: { + id: 42, + owner: "acme", + name: "repo", + fullName: "acme/repo", + defaultBranch: "main", + }, + head: { sha: "abc123", ref: "refs/heads/main" }, + }; +} + +function makeDO() { + const setRepoConfig = vi.fn(async (_cfg: StoredRepoConfig) => {}); + const stub = { setRepoConfig } as unknown as DurableObjectStub; + const idFromName = vi.fn( + (_name: string) => "stub-id" as unknown as DurableObjectId, + ); + const get = vi.fn((_id: DurableObjectId) => stub); + const REPO_CONFIG_DO = { + idFromName, + get, + } as unknown as DurableObjectNamespace; + return { REPO_CONFIG_DO, idFromName, get, setRepoConfig }; +} + +describe("runSyncRepoConfig", () => { + test("happy path — runs full step sequence and writes StoredRepoConfig to DO", async () => { + const { step, calls } = makeStep(); + const { REPO_CONFIG_DO, idFromName, get, setRepoConfig } = makeDO(); + const toml = [ + "[sandbox]", + 'size = "large"', + "docker = true", + "", + "[harness]", + 'provider = "codex"', + "", + ].join("\n"); + const github = { + getRepoContentFile: vi.fn(async () => ({ contentBase64: toBase64(toml) })), + } as unknown as GitHubClient; + const event = makeEvent(); + + await runSyncRepoConfig({ + step, + github, + env: { REPO_CONFIG_DO }, + event, + logger: noopLogger, + }); + + expect(calls.map((c) => c.name)).toEqual([ + "fetch-config-file", + "parse-and-validate", + "store-repo-config", + ]); + expect(idFromName).toHaveBeenCalledWith("acme/repo"); + expect(get).toHaveBeenCalledTimes(1); + expect(setRepoConfig).toHaveBeenCalledWith({ + repositoryId: 42, + repositoryFullName: "acme/repo", + installationId: 100, + settings: { + sandbox: { size: "large", docker: true }, + harness: { provider: "codex" }, + }, + }); + }); + + test("file absent — only runs fetch-config-file and makes no DO writes", async () => { + const { step, calls } = makeStep(); + const { REPO_CONFIG_DO, idFromName, get, setRepoConfig } = makeDO(); + const github = { + getRepoContentFile: vi.fn(async () => null), + } as unknown as GitHubClient; + const event = makeEvent(); + + await runSyncRepoConfig({ + step, + github, + env: { REPO_CONFIG_DO }, + event, + logger: noopLogger, + }); + + expect(calls.map((c) => c.name)).toEqual(["fetch-config-file"]); + expect(idFromName).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + expect(setRepoConfig).not.toHaveBeenCalled(); + }); + + test("TOML syntax invalid — parse-and-validate throws; store-repo-config not invoked", async () => { + const { step, calls } = makeStep(); + const { REPO_CONFIG_DO, setRepoConfig } = makeDO(); + const github = { + getRepoContentFile: vi.fn(async () => ({ + contentBase64: toBase64("this is = not = valid = toml ["), + })), + } as unknown as GitHubClient; + const event = makeEvent(); + + await expect( + runSyncRepoConfig({ + step, + github, + env: { REPO_CONFIG_DO }, + event, + logger: noopLogger, + }), + ).rejects.toThrow(/Invalid TOML/); + + expect(calls.map((c) => c.name)).toEqual([ + "fetch-config-file", + "parse-and-validate", + ]); + expect(setRepoConfig).not.toHaveBeenCalled(); + }); + + test("Zod invalid — parse-and-validate throws; store-repo-config not invoked", async () => { + const { step, calls } = makeStep(); + const { REPO_CONFIG_DO, setRepoConfig } = makeDO(); + const toml = ["[sandbox]", 'size = "huge"', ""].join("\n"); + const github = { + getRepoContentFile: vi.fn(async () => ({ contentBase64: toBase64(toml) })), + } as unknown as GitHubClient; + const event = makeEvent(); + + await expect( + runSyncRepoConfig({ + step, + github, + env: { REPO_CONFIG_DO }, + event, + logger: noopLogger, + }), + ).rejects.toThrow(/Invalid RepoConfig/); + + expect(calls.map((c) => c.name)).toEqual([ + "fetch-config-file", + "parse-and-validate", + ]); + expect(setRepoConfig).not.toHaveBeenCalled(); + }); + + test("sparse settings preserved — no default materialization on the write path", async () => { + const { step } = makeStep(); + const { REPO_CONFIG_DO, setRepoConfig } = makeDO(); + const toml = ["[sandbox]", "docker = true", ""].join("\n"); + const github = { + getRepoContentFile: vi.fn(async () => ({ contentBase64: toBase64(toml) })), + } as unknown as GitHubClient; + const event = makeEvent(); + + await runSyncRepoConfig({ + step, + github, + env: { REPO_CONFIG_DO }, + event, + logger: noopLogger, + }); + + expect(setRepoConfig).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { sandbox: { docker: true } }, + }), + ); + }); +}); From da9efc9c58247f46300b23c643be54ac8d98917d Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:22:53 +0000 Subject: [PATCH 13/21] feat(workflow): add runSyncRepoConfig step factory --- src/workflows/steps/sync-repo-config.ts | 78 +++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/workflows/steps/sync-repo-config.ts diff --git a/src/workflows/steps/sync-repo-config.ts b/src/workflows/steps/sync-repo-config.ts new file mode 100644 index 0000000..f5f986c --- /dev/null +++ b/src/workflows/steps/sync-repo-config.ts @@ -0,0 +1,78 @@ +import { Buffer } from "node:buffer"; +import type { WorkflowStep } from "cloudflare:workers"; +import { + parseRepoConfigToml, + type StoredRepoConfig, +} from "../../config/repo-config-schema"; +import type { RepoConfigDO } from "../../durable-objects/repo-config-do"; +import type { ConfigPushEvent } from "../../events/types"; +import type { GitHubClient } from "../../services/github/client"; +import type { Logger } from "../../utils/logger"; + +const CONFIG_PATH = ".code-factory/config.toml"; + +export interface RunSyncRepoConfigContext { + step: WorkflowStep; + github: GitHubClient; + env: { REPO_CONFIG_DO: DurableObjectNamespace }; + event: ConfigPushEvent; + logger: Logger; +} + +/** + * Workflow step factory for `config_push`. Fetches `.code-factory/config.toml` + * at the pushed head SHA, parses + validates it, and writes the sparse + * `StoredRepoConfig` envelope into the per-repo `RepoConfigDO`. + * + * Replay-safety: + * - Each step callback returns only structured-cloneable JSON + * (no Buffer, no Octokit/DO stubs leaking out of the closure). + * - `parseRepoConfigToml` throws `NonRetryableError` on syntax/schema + * violations — we let it propagate so the Workflow engine sees the + * instance as terminally errored. + */ +export async function runSyncRepoConfig( + ctx: RunSyncRepoConfigContext, +): Promise { + const { step, github, env, event, logger } = ctx; + const { owner, name, fullName, id: repositoryId } = event.repository; + const { installationId } = event.source; + + const fileResult = await step.do("fetch-config-file", async () => { + const res = await github.getRepoContentFile( + owner, + name, + CONFIG_PATH, + event.head.sha, + ); + if (res === null) return { present: false as const }; + return { present: true as const, contentBase64: res.contentBase64 }; + }); + + if (!fileResult.present) { + logger.info("No repo config file present; skipping DO write", { + fullName, + sha: event.head.sha, + }); + return; + } + + const parseResult = await step.do("parse-and-validate", async () => { + const raw = Buffer.from(fileResult.contentBase64, "base64").toString("utf8"); + const settings = parseRepoConfigToml(raw); + return { settings }; + }); + + await step.do("store-repo-config", async () => { + const id = env.REPO_CONFIG_DO.idFromName(fullName); + const stub = env.REPO_CONFIG_DO.get(id); + const cfg: StoredRepoConfig = { + repositoryId, + repositoryFullName: fullName, + installationId, + settings: parseResult.settings, + }; + await stub.setRepoConfig(cfg); + return { ok: true as const }; + }); +} From b7df00c12a2ba76f1410b595fb125231a0d51d1f Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:25:33 +0000 Subject: [PATCH 14/21] test(workflow): add failing introspection tests for RepoConfigWorkflow --- src/workflows/repo-config-workflow.test.ts | 77 ++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/workflows/repo-config-workflow.test.ts diff --git a/src/workflows/repo-config-workflow.test.ts b/src/workflows/repo-config-workflow.test.ts new file mode 100644 index 0000000..d9a23b9 --- /dev/null +++ b/src/workflows/repo-config-workflow.test.ts @@ -0,0 +1,77 @@ +import { env, introspectWorkflowInstance } from "cloudflare:test"; +import { describe, expect, test } from "vitest"; +import type { ConfigPushEvent } from "../events/types"; +import { RepoConfigWorkflow } from "./repo-config-workflow"; + +// ── Smoke: binding shape ───────────────────────────────────────────────────── + +const base: ConfigPushEvent = { + type: "config_push", + source: { type: "github", installationId: 1 }, + repository: { + id: 10, + owner: "acme", + name: "repo", + fullName: "acme/repo", + defaultBranch: "main", + }, + head: { sha: "abc", ref: "refs/heads/main" }, +}; + +describe("RepoConfigWorkflow", () => { + test("class is exported and its name matches wrangler.toml class_name", () => { + // A rename would orphan in-flight instances — this guards against that. + expect(typeof RepoConfigWorkflow).toBe("function"); + expect(RepoConfigWorkflow.name).toBe("RepoConfigWorkflow"); + }); + + test("env.REPO_CONFIG_WORKFLOW binding exists and is callable", () => { + expect(env.REPO_CONFIG_WORKFLOW).toBeDefined(); + expect(typeof env.REPO_CONFIG_WORKFLOW.create).toBe("function"); + }); +}); + +// ── Introspection-driven dispatch tests ────────────────────────────────────── +// +// Each test mocks every `step.do` result so no live GitHub / DO calls fire. + +describe("RepoConfigWorkflow dispatch — happy path", () => { + test("fetch → parse → store reaches complete", async () => { + const instanceId = "config_push-repo-abc-test-delivery-1"; + await using instance = await introspectWorkflowInstance( + env.REPO_CONFIG_WORKFLOW, + instanceId, + ); + await instance.modify(async (m) => { + await m.mockStepResult( + { name: "fetch-config-file" }, + { present: true, contentBase64: "" }, + ); + await m.mockStepResult({ name: "parse-and-validate" }, { settings: {} }); + await m.mockStepResult({ name: "store-repo-config" }, { ok: true }); + }); + await env.REPO_CONFIG_WORKFLOW.create({ id: instanceId, params: base }); + await expect(instance.waitForStatus("complete")).resolves.not.toThrow(); + }); +}); + +describe("RepoConfigWorkflow dispatch — file absent", () => { + test("present=false → early return, no DO write, complete", async () => { + const instanceId = "config_push-repo-missing-delivery-2"; + await using instance = await introspectWorkflowInstance( + env.REPO_CONFIG_WORKFLOW, + instanceId, + ); + await instance.modify(async (m) => { + await m.mockStepResult( + { name: "fetch-config-file" }, + { present: false }, + ); + // parse-and-validate / store-repo-config intentionally NOT mocked — if + // the workflow tried to run them unmocked it would fail, exposing a + // regression in the early-exit branch. + }); + await env.REPO_CONFIG_WORKFLOW.create({ id: instanceId, params: base }); + await expect(instance.waitForStatus("complete")).resolves.not.toThrow(); + }); +}); From 001e83b98f5b05553a9480279410664167b4f39d Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:27:34 +0000 Subject: [PATCH 15/21] feat(workflow): add RepoConfigWorkflow and branch dispatch --- src/main.ts | 13 ++++- src/workflows/repo-config-workflow.ts | 81 +++++++++++++++++++++++++++ src/workflows/task-runner-workflow.ts | 3 + 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/workflows/repo-config-workflow.ts diff --git a/src/main.ts b/src/main.ts index bf0025e..0f6ac80 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import { import type { TaskRunnerWorkflowEnv } from "./workflows/task-runner-workflow"; export { TaskRunnerWorkflow } from "./workflows/task-runner-workflow"; +export { RepoConfigWorkflow } from "./workflows/repo-config-workflow"; export { RepoConfigDO } from "./durable-objects/repo-config-do"; export { __setAppBotLoginForTests }; @@ -102,7 +103,17 @@ async function handleGithubWebhook( } const instanceId = buildInstanceId(result, deliveryId); try { - await env.TASK_RUNNER_WORKFLOW.create({ id: instanceId, params: result }); + if (result.type === "config_push") { + await env.REPO_CONFIG_WORKFLOW.create({ + id: instanceId, + params: result, + }); + } else { + await env.TASK_RUNNER_WORKFLOW.create({ + id: instanceId, + params: result, + }); + } reqLogger.info("Webhook processed", { handler: result.type, instanceId, diff --git a/src/workflows/repo-config-workflow.ts b/src/workflows/repo-config-workflow.ts new file mode 100644 index 0000000..9210f9e --- /dev/null +++ b/src/workflows/repo-config-workflow.ts @@ -0,0 +1,81 @@ +import { + WorkflowEntrypoint, + type WorkflowEvent, + type WorkflowStep, +} from "cloudflare:workers"; +import { createAppAuth } from "@octokit/auth-app"; +import { Octokit } from "@octokit/rest"; +import type { RepoConfigDO } from "../durable-objects/repo-config-do"; +import type { ConfigPushEvent } from "../events/types"; +import { GitHubClient } from "../services/github/client"; +import { createLogger } from "../utils/logger"; +import { runSyncRepoConfig } from "./steps/sync-repo-config"; + +/** + * Environment bindings required by the repo-config workflow. A superset of the + * task-runner env is not needed — this workflow only authenticates as the + * GitHub App (to fetch the config file at a SHA) and writes to the per-repo + * `RepoConfigDO`. `REPO_CONFIG_WORKFLOW` is included so the binding type on + * `this.env` matches Wrangler's own resolution. + */ +export interface RepoConfigWorkflowEnv { + APP_ID: string; + PRIVATE_KEY: string; + LOG_FORMAT?: string; + REPO_CONFIG_WORKFLOW: Workflow; + REPO_CONFIG_DO: DurableObjectNamespace; +} + +/** + * `RepoConfigWorkflow` runs one instance per accepted `config_push` delivery + * (GitHub push to the repo's default branch that touches + * `.code-factory/config.toml`). It delegates the three-step pipeline + * (fetch → parse → store) to `runSyncRepoConfig`. + * + * Clients (Octokit, GitHubClient) are constructed at the top of `run()` once + * per replay. They must NOT be returned from any `step.do` callback — class + * instances are not structured-cloneable and the workflow engine throws on + * attempted persistence. + */ +export class RepoConfigWorkflow extends WorkflowEntrypoint< + RepoConfigWorkflowEnv, + ConfigPushEvent +> { + async run( + event: WorkflowEvent, + step: WorkflowStep, + ): Promise { + const payload = event.payload; + const sourceTrace = payload.source.trace ?? {}; + const logger = createLogger({ logFormat: this.env.LOG_FORMAT }).child({ + instanceId: event.instanceId, + eventType: payload.type, + repository: payload.repository.fullName, + ...(sourceTrace.rayId ? { rayId: sourceTrace.rayId } : {}), + ...(sourceTrace.traceId ? { traceId: sourceTrace.traceId } : {}), + ...(sourceTrace.spanId ? { spanId: sourceTrace.spanId } : {}), + }); + // Replay-safe breadcrumb: emits an `instanceId`-tagged line on every + // replay so Workers Logs can correlate the run even when all downstream + // side-effects are cached in `step.do` results. + logger.info("Workflow run started", { type: payload.type }); + + const octokit = new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: this.env.APP_ID, + privateKey: this.env.PRIVATE_KEY, + installationId: payload.source.installationId, + }, + }); + const github = new GitHubClient(octokit, logger); + + await runSyncRepoConfig({ + step, + github, + env: this.env, + event: payload, + logger, + }); + } +} diff --git a/src/workflows/task-runner-workflow.ts b/src/workflows/task-runner-workflow.ts index 02bda0a..1f70d7f 100644 --- a/src/workflows/task-runner-workflow.ts +++ b/src/workflows/task-runner-workflow.ts @@ -7,6 +7,7 @@ import { NonRetryableError } from "cloudflare:workflows"; import { createAppAuth } from "@octokit/auth-app"; import { Octokit } from "@octokit/rest"; import { loadConfig } from "../config/app-config"; +import type { RepoConfigDO } from "../durable-objects/repo-config-do"; import type { Event } from "../events/types"; import { CoderService } from "../services/coder/service"; import { GitHubClient } from "../services/github/client"; @@ -35,6 +36,8 @@ export interface TaskRunnerWorkflowEnv { CODER_ORGANIZATION: string; LOG_FORMAT?: string; TASK_RUNNER_WORKFLOW: Workflow; + REPO_CONFIG_WORKFLOW: Workflow; + REPO_CONFIG_DO: DurableObjectNamespace; } /** From 3106fde9f2b91939771a5d74f798f2fc902eceb8 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:35:58 +0000 Subject: [PATCH 16/21] refactor(workflow): consolidate env type + loadConfig; tighten assertions Co-Authored-By: Claude Opus 4.7 --- src/services/github/client.test.ts | 19 ++++++++++++++++ src/webhooks/github/router.test.ts | 2 ++ src/workflows/instance-id.test.ts | 3 ++- src/workflows/repo-config-workflow.ts | 32 +++++++++++---------------- 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/services/github/client.test.ts b/src/services/github/client.test.ts index d068e29..122fed1 100644 --- a/src/services/github/client.test.ts +++ b/src/services/github/client.test.ts @@ -384,6 +384,25 @@ describe("GitHubClient", () => { ).resolves.toBeNull(); }); + test("returns null when encoding is not base64 (e.g., 'none' for >1MB files)", async () => { + const octokit = { + rest: { + repos: { + getContent: async () => ({ + data: { type: "file", encoding: "none", size: 2_000_000 }, + }), + }, + }, + }; + const client = new GitHubClient( + octokit as unknown as import("./client").Octokit, + new TestLogger(), + ); + await expect( + client.getRepoContentFile("a", "r", "x", "sha"), + ).resolves.toBeNull(); + }); + test("re-throws non-404 status codes", async () => { const octokit = { rest: { diff --git a/src/webhooks/github/router.test.ts b/src/webhooks/github/router.test.ts index ac32e68..9ae3bce 100644 --- a/src/webhooks/github/router.test.ts +++ b/src/webhooks/github/router.test.ts @@ -523,6 +523,8 @@ describe("WebhookRouter", () => { const payload = { ...pushDefaultBranch, after: "0".repeat(40) }; const result = await router.handleGithubWebhook("push", "d3", payload); expect(isEvent(result)).toBe(true); + if (!isEvent(result)) throw new Error("expected event"); + expect((result as ConfigPushEvent).head.sha).toBe("0".repeat(40)); }); }); diff --git a/src/workflows/instance-id.test.ts b/src/workflows/instance-id.test.ts index b9b4089..302187f 100644 --- a/src/workflows/instance-id.test.ts +++ b/src/workflows/instance-id.test.ts @@ -119,7 +119,7 @@ const baseConfigPush: ConfigPushEvent = { defaultBranch: "main", }, head: { - sha: "abcdef1234567890abcdef1234567890abcdef12", + sha: "abcdef1234567890abcdef1234567890", ref: "refs/heads/main", }, }; @@ -129,6 +129,7 @@ describe("buildInstanceId — config_push", () => { const id = buildInstanceId(baseConfigPush, "delivery-xyz"); expect(id.startsWith("config_push-repo-")).toBe(true); expect(id).toContain("abcdef1234567890"); + expect(id).toContain("delivery-xyz"); }); test("length is <= 64 after sanitize + truncate", () => { const id = buildInstanceId(baseConfigPush, "delivery-xyz"); diff --git a/src/workflows/repo-config-workflow.ts b/src/workflows/repo-config-workflow.ts index 9210f9e..0182fdd 100644 --- a/src/workflows/repo-config-workflow.ts +++ b/src/workflows/repo-config-workflow.ts @@ -5,26 +5,12 @@ import { } from "cloudflare:workers"; import { createAppAuth } from "@octokit/auth-app"; import { Octokit } from "@octokit/rest"; -import type { RepoConfigDO } from "../durable-objects/repo-config-do"; +import { loadConfig } from "../config/app-config"; import type { ConfigPushEvent } from "../events/types"; import { GitHubClient } from "../services/github/client"; import { createLogger } from "../utils/logger"; import { runSyncRepoConfig } from "./steps/sync-repo-config"; - -/** - * Environment bindings required by the repo-config workflow. A superset of the - * task-runner env is not needed — this workflow only authenticates as the - * GitHub App (to fetch the config file at a SHA) and writes to the per-repo - * `RepoConfigDO`. `REPO_CONFIG_WORKFLOW` is included so the binding type on - * `this.env` matches Wrangler's own resolution. - */ -export interface RepoConfigWorkflowEnv { - APP_ID: string; - PRIVATE_KEY: string; - LOG_FORMAT?: string; - REPO_CONFIG_WORKFLOW: Workflow; - REPO_CONFIG_DO: DurableObjectNamespace; -} +import type { TaskRunnerWorkflowEnv } from "./task-runner-workflow"; /** * `RepoConfigWorkflow` runs one instance per accepted `config_push` delivery @@ -36,9 +22,14 @@ export interface RepoConfigWorkflowEnv { * per replay. They must NOT be returned from any `step.do` callback — class * instances are not structured-cloneable and the workflow engine throws on * attempted persistence. + * + * The env type is shared with `TaskRunnerWorkflow`: the Worker entry's `env` + * is a single object dispatched to both workflows, so divergent env interfaces + * would drift as new bindings are added. Sharing `TaskRunnerWorkflowEnv` + * keeps the contract centralized even if this workflow only reads a subset. */ export class RepoConfigWorkflow extends WorkflowEntrypoint< - RepoConfigWorkflowEnv, + TaskRunnerWorkflowEnv, ConfigPushEvent > { async run( @@ -46,6 +37,9 @@ export class RepoConfigWorkflow extends WorkflowEntrypoint< step: WorkflowStep, ): Promise { const payload = event.payload; + const config = loadConfig( + this.env as unknown as Record, + ); const sourceTrace = payload.source.trace ?? {}; const logger = createLogger({ logFormat: this.env.LOG_FORMAT }).child({ instanceId: event.instanceId, @@ -63,8 +57,8 @@ export class RepoConfigWorkflow extends WorkflowEntrypoint< const octokit = new Octokit({ authStrategy: createAppAuth, auth: { - appId: this.env.APP_ID, - privateKey: this.env.PRIVATE_KEY, + appId: config.appId, + privateKey: config.privateKey, installationId: payload.source.installationId, }, }); From 6d134108b6088147ca19223c6d42020257c620dc Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:39:17 +0000 Subject: [PATCH 17/21] test(integration): cover push event dispatch paths --- src/testing/integration.test.ts | 133 ++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/src/testing/integration.test.ts b/src/testing/integration.test.ts index 9e16347..f535b60 100644 --- a/src/testing/integration.test.ts +++ b/src/testing/integration.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test } from "vitest"; import worker, { __setAppBotLoginForTests } from "../main"; import issueCommentOnIssue from "./fixtures/issue-comment-on-issue.json"; import issuesAssigned from "./fixtures/issues-assigned.json"; +import pushDefaultBranch from "./fixtures/push-default-branch.json"; import workflowRunSuccess from "./fixtures/workflow-run-success.json"; import { buildSignedWebhookRequest, @@ -48,6 +49,7 @@ interface WorkflowCreateArgs { function makeEnv( workflowCreate?: (args: WorkflowCreateArgs) => Promise, + repoConfigCreate?: (args: WorkflowCreateArgs) => Promise, ) { return { ...baseEnv, @@ -56,6 +58,18 @@ function makeEnv( workflowCreate ?? ((args: WorkflowCreateArgs) => Promise.resolve({ id: args.id })), }, + REPO_CONFIG_WORKFLOW: { + create: + repoConfigCreate ?? + ((args: WorkflowCreateArgs) => Promise.resolve({ id: args.id })), + }, + REPO_CONFIG_DO: { + idFromName: () => "stub-id", + get: () => ({ + setRepoConfig: async () => {}, + getRepoConfig: async () => null, + }), + }, } as unknown as Parameters[1]; } @@ -285,3 +299,122 @@ describe("Worker fetch handler — HTTP status surface", () => { expect(res.status).toBe(500); }); }); + +// ── Push event dispatch surface ────────────────────────────────────────────── +// +// The Worker fans `push` deliveries out to two distinct workflow bindings: +// default-branch pushes become `config_push` events routed to +// `REPO_CONFIG_WORKFLOW`, and non-default pushes become a SkipResult with no +// workflow created at all. These tests pin the HTTP surface and exercise the +// REPO_CONFIG_WORKFLOW.create path end-to-end (without replaying inside the +// workflow — that's covered in e2e.test.ts). + +describe("Worker fetch handler — push event dispatch", () => { + test("push to default branch → 202 and REPO_CONFIG_WORKFLOW.create called", async () => { + const taskCalls: WorkflowCreateArgs[] = []; + const repoCfgCalls: WorkflowCreateArgs[] = []; + const env = makeEnv( + async (args) => { + taskCalls.push(args); + return { id: args.id }; + }, + async (args) => { + repoCfgCalls.push(args); + return { id: args.id }; + }, + ); + const req = await buildSignedWebhookRequest({ + secret: TEST_SECRET, + body: JSON.stringify(pushDefaultBranch), + eventName: "push", + deliveryId: "push-default-1", + }); + const res = await worker.fetch(req, env, {} as ExecutionContext); + expect(res.status).toBe(202); + expect(repoCfgCalls).toHaveLength(1); + expect(repoCfgCalls[0]?.id).toMatch(/^config_push-/); + // `buildInstanceId` caps the charset-sanitized output at 64 chars, so + // the deliveryId tail may be truncated — assert the shape, not the tail. + expect(repoCfgCalls[0]?.id.length).toBeLessThanOrEqual(64); + expect(taskCalls).toHaveLength(0); + }); + + test("push to non-default branch → 200, no workflow created (skip)", async () => { + const taskCalls: WorkflowCreateArgs[] = []; + const repoCfgCalls: WorkflowCreateArgs[] = []; + const env = makeEnv( + async (args) => { + taskCalls.push(args); + return { id: args.id }; + }, + async (args) => { + repoCfgCalls.push(args); + return { id: args.id }; + }, + ); + const nonDefaultPush = { + ...pushDefaultBranch, + ref: "refs/heads/feature", + }; + const req = await buildSignedWebhookRequest({ + secret: TEST_SECRET, + body: JSON.stringify(nonDefaultPush), + eventName: "push", + deliveryId: "push-feature-1", + }); + const res = await worker.fetch(req, env, {} as ExecutionContext); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + expect(repoCfgCalls).toHaveLength(0); + expect(taskCalls).toHaveLength(0); + }); + + test("push duplicate delivery → 200 on second call", async () => { + let repoCfgCalled = 0; + const env = makeEnv(undefined, async (args) => { + repoCfgCalled += 1; + if (repoCfgCalled === 1) return { id: args.id }; + throw new Error(`instance "${args.id}" already exists`); + }); + const req1 = await buildSignedWebhookRequest({ + secret: TEST_SECRET, + body: JSON.stringify(pushDefaultBranch), + eventName: "push", + deliveryId: "push-dup-1", + }); + const res1 = await worker.fetch(req1, env, {} as ExecutionContext); + expect(res1.status).toBe(202); + + const req2 = await buildSignedWebhookRequest({ + secret: TEST_SECRET, + body: JSON.stringify(pushDefaultBranch), + eventName: "push", + deliveryId: "push-dup-1", + }); + const res2 = await worker.fetch(req2, env, {} as ExecutionContext); + expect(res2.status).toBe(200); + expect(repoCfgCalled).toBe(2); + }); + + test("unsigned push request → 401 before routing", async () => { + let repoCfgCalled = false; + const env = makeEnv(undefined, () => { + repoCfgCalled = true; + return Promise.resolve({ id: "x" }); + }); + const body = JSON.stringify(pushDefaultBranch); + const req = new Request("https://w/webhooks/github", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-GitHub-Event": "push", + "X-GitHub-Delivery": "push-unsigned-1", + }, + body, + }); + const res = await worker.fetch(req, env, {} as ExecutionContext); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(/missing signature/); + expect(repoCfgCalled).toBe(false); + }); +}); From 70c82bb1d97be4e92a523bcd75d90763e726bfce Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:40:00 +0000 Subject: [PATCH 18/21] test(e2e): cover push-event end-to-end dispatch --- src/testing/e2e.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/testing/e2e.test.ts b/src/testing/e2e.test.ts index 042fc81..b5f695d 100644 --- a/src/testing/e2e.test.ts +++ b/src/testing/e2e.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test } from "vitest"; import worker, { __setAppBotLoginForTests } from "../main"; import issuesAssigned from "./fixtures/issues-assigned.json"; import issuesClosed from "./fixtures/issues-closed.json"; +import pushDefaultBranch from "./fixtures/push-default-branch.json"; import { buildSignedWebhookRequest } from "./workflow-test-helpers"; // Pre-seed the bot-login cache so the handler doesn't call `GET /app`. @@ -120,4 +121,69 @@ describe("e2e: signed webhook → worker → workflow completion", () => { await expect(inst.waitForStatus("complete")).resolves.not.toThrow(); } }); + + test("push to default branch runs RepoConfigWorkflow to completion", async () => { + await using introspector = await introspectWorkflow( + testEnv.REPO_CONFIG_WORKFLOW, + ); + await introspector.modifyAll(async (m) => { + await m.disableSleeps(); + await m.mockStepResult( + { name: "fetch-config-file" }, + { present: true, contentBase64: "" }, + ); + await m.mockStepResult( + { name: "parse-and-validate" }, + { settings: {} }, + ); + await m.mockStepResult({ name: "store-repo-config" }, { ok: true }); + }); + + const req = await buildSignedWebhookRequest({ + secret: testEnv.WEBHOOK_SECRET, + body: JSON.stringify(pushDefaultBranch), + eventName: "push", + deliveryId: "e2e-push-default-1", + }); + const res = await worker.fetch( + req, + testEnv as WorkerEnv, + {} as ExecutionContext, + ); + expect(res.status).toBe(202); + + const instances = introspector.get(); + expect(instances.length).toBeGreaterThanOrEqual(1); + for (const inst of instances) { + await expect(inst.waitForStatus("complete")).resolves.not.toThrow(); + } + }); + + test("push to non-default branch is skipped (no workflow instance)", async () => { + await using repoCfgIntrospector = await introspectWorkflow( + testEnv.REPO_CONFIG_WORKFLOW, + ); + await using taskIntrospector = await introspectWorkflow( + testEnv.TASK_RUNNER_WORKFLOW, + ); + + const nonDefaultPush = { + ...pushDefaultBranch, + ref: "refs/heads/feature", + }; + const req = await buildSignedWebhookRequest({ + secret: testEnv.WEBHOOK_SECRET, + body: JSON.stringify(nonDefaultPush), + eventName: "push", + deliveryId: "e2e-push-feature-1", + }); + const res = await worker.fetch( + req, + testEnv as WorkerEnv, + {} as ExecutionContext, + ); + expect(res.status).toBe(200); + expect(repoCfgIntrospector.get()).toHaveLength(0); + expect(taskIntrospector.get()).toHaveLength(0); + }); }); From f114d6e78e6b760ac10744132eaf712154c27c7a Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:41:57 +0000 Subject: [PATCH 19/21] style: apply biome formatter to repo-config files and push e2e test --- src/testing/e2e.test.ts | 5 +---- src/workflows/repo-config-workflow.test.ts | 5 +---- src/workflows/steps/sync-repo-config.test.ts | 12 +++++++++--- src/workflows/steps/sync-repo-config.ts | 4 +++- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/testing/e2e.test.ts b/src/testing/e2e.test.ts index b5f695d..c5400cb 100644 --- a/src/testing/e2e.test.ts +++ b/src/testing/e2e.test.ts @@ -132,10 +132,7 @@ describe("e2e: signed webhook → worker → workflow completion", () => { { name: "fetch-config-file" }, { present: true, contentBase64: "" }, ); - await m.mockStepResult( - { name: "parse-and-validate" }, - { settings: {} }, - ); + await m.mockStepResult({ name: "parse-and-validate" }, { settings: {} }); await m.mockStepResult({ name: "store-repo-config" }, { ok: true }); }); diff --git a/src/workflows/repo-config-workflow.test.ts b/src/workflows/repo-config-workflow.test.ts index d9a23b9..522adf3 100644 --- a/src/workflows/repo-config-workflow.test.ts +++ b/src/workflows/repo-config-workflow.test.ts @@ -63,10 +63,7 @@ describe("RepoConfigWorkflow dispatch — file absent", () => { instanceId, ); await instance.modify(async (m) => { - await m.mockStepResult( - { name: "fetch-config-file" }, - { present: false }, - ); + await m.mockStepResult({ name: "fetch-config-file" }, { present: false }); // parse-and-validate / store-repo-config intentionally NOT mocked — if // the workflow tried to run them unmocked it would fail, exposing a // regression in the early-exit branch. diff --git a/src/workflows/steps/sync-repo-config.test.ts b/src/workflows/steps/sync-repo-config.test.ts index 82789b6..904cd5b 100644 --- a/src/workflows/steps/sync-repo-config.test.ts +++ b/src/workflows/steps/sync-repo-config.test.ts @@ -79,7 +79,9 @@ describe("runSyncRepoConfig", () => { "", ].join("\n"); const github = { - getRepoContentFile: vi.fn(async () => ({ contentBase64: toBase64(toml) })), + getRepoContentFile: vi.fn(async () => ({ + contentBase64: toBase64(toml), + })), } as unknown as GitHubClient; const event = makeEvent(); @@ -163,7 +165,9 @@ describe("runSyncRepoConfig", () => { const { REPO_CONFIG_DO, setRepoConfig } = makeDO(); const toml = ["[sandbox]", 'size = "huge"', ""].join("\n"); const github = { - getRepoContentFile: vi.fn(async () => ({ contentBase64: toBase64(toml) })), + getRepoContentFile: vi.fn(async () => ({ + contentBase64: toBase64(toml), + })), } as unknown as GitHubClient; const event = makeEvent(); @@ -189,7 +193,9 @@ describe("runSyncRepoConfig", () => { const { REPO_CONFIG_DO, setRepoConfig } = makeDO(); const toml = ["[sandbox]", "docker = true", ""].join("\n"); const github = { - getRepoContentFile: vi.fn(async () => ({ contentBase64: toBase64(toml) })), + getRepoContentFile: vi.fn(async () => ({ + contentBase64: toBase64(toml), + })), } as unknown as GitHubClient; const event = makeEvent(); diff --git a/src/workflows/steps/sync-repo-config.ts b/src/workflows/steps/sync-repo-config.ts index f5f986c..f774e36 100644 --- a/src/workflows/steps/sync-repo-config.ts +++ b/src/workflows/steps/sync-repo-config.ts @@ -58,7 +58,9 @@ export async function runSyncRepoConfig( } const parseResult = await step.do("parse-and-validate", async () => { - const raw = Buffer.from(fileResult.contentBase64, "base64").toString("utf8"); + const raw = Buffer.from(fileResult.contentBase64, "base64").toString( + "utf8", + ); const settings = parseRepoConfigToml(raw); return { settings }; }); From 9ceca4dd98cb5154f3dfdb007efb53ca42df1537 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 07:44:27 +0000 Subject: [PATCH 20/21] chore(wrangler): regenerate types with fully-resolved RepoConfigWorkflow payload The earlier regeneration (commit 2f5eb86) ran before the RepoConfigWorkflow class existed, so wrangler fell back to an unparameterized Workflow type. With the class now present, the generator resolves the payload generic. --- worker-configuration.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 95020d5..20945c8 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: f7e04cad4376eb89009e32d111a4064d) +// Generated by Wrangler by running `wrangler types` (hash: 07a11f3ccfd321a73800e7b66522ab98) // Runtime types generated with workerd@1.20260415.1 2026-04-18 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -16,7 +16,7 @@ declare namespace Cloudflare { LOG_FORMAT: "json"; REPO_CONFIG_DO: DurableObjectNamespace; TASK_RUNNER_WORKFLOW: Workflow[0]['payload']>; - REPO_CONFIG_WORKFLOW: Workflow /* RepoConfigWorkflow */; + REPO_CONFIG_WORKFLOW: Workflow[0]['payload']>; } } interface Env extends Cloudflare.Env {} From cc532f309fe2f8bf9d7a5b59f787e805b477d397 Mon Sep 17 00:00:00 2001 From: xmtp-coder-agent <> Date: Mon, 20 Apr 2026 15:54:31 +0000 Subject: [PATCH 21/21] refactor(durable-objects): split RepoConfigDO storage into per-field KV keys Per reviewer feedback on PR #113: store repositoryId, repositoryFullName, and installationId under their own KV keys, and keep only the sparse settings parsed from the TOML file under the `config` key. The four synchronous .put calls run back-to-back with no awaits between them, so they commit as a single implicit transaction. Co-Authored-By: Claude Opus 4.7 --- src/durable-objects/repo-config-do.test.ts | 21 +++++++++- src/durable-objects/repo-config-do.ts | 47 ++++++++++++++-------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/durable-objects/repo-config-do.test.ts b/src/durable-objects/repo-config-do.test.ts index c4607d0..8ed96c0 100644 --- a/src/durable-objects/repo-config-do.test.ts +++ b/src/durable-objects/repo-config-do.test.ts @@ -1,4 +1,4 @@ -import { env } from "cloudflare:test"; +import { env, runInDurableObject } from "cloudflare:test"; import { describe, expect, test } from "vitest"; import type { StoredRepoConfig } from "../config/repo-config-schema"; import { RepoConfigDO } from "./repo-config-do"; @@ -90,6 +90,25 @@ describe("RepoConfigDO — get/set round-trip", () => { ]); }); + test("setRepoConfig writes identity fields and settings to separate KV keys", async () => { + const id = env.REPO_CONFIG_DO.idFromName("acme/key-layout"); + const stub = env.REPO_CONFIG_DO.get(id); + await stub.setRepoConfig({ + repositoryId: 42, + repositoryFullName: "acme/key-layout", + installationId: 999, + settings: { sandbox: { size: "small" } }, + }); + await runInDurableObject(stub, async (_instance, ctx) => { + expect(ctx.storage.kv.get("repositoryId")).toBe(42); + expect(ctx.storage.kv.get("repositoryFullName")).toBe("acme/key-layout"); + expect(ctx.storage.kv.get("installationId")).toBe(999); + expect(ctx.storage.kv.get("config")).toEqual({ + sandbox: { size: "small" }, + }); + }); + }); + test("distinct fullNames route to distinct DO instances", async () => { const idA = env.REPO_CONFIG_DO.idFromName("acme/a"); const idB = env.REPO_CONFIG_DO.idFromName("acme/b"); diff --git a/src/durable-objects/repo-config-do.ts b/src/durable-objects/repo-config-do.ts index ec145a4..eb7c3f2 100644 --- a/src/durable-objects/repo-config-do.ts +++ b/src/durable-objects/repo-config-do.ts @@ -6,43 +6,56 @@ import { } from "../config/repo-config-schema"; /** - * Single-key identifier used inside the DO's sqlite-backed KV. The DO is - * dedicated per-repository (routed by `idFromName(repositoryFullName)`), so a - * fixed key is sufficient — we never need to store more than one envelope. + * Per-field keys in the DO's sqlite-backed KV. The DO is dedicated + * per-repository (routed by `idFromName(repositoryFullName)`), so each key + * holds at most one value. The `config` key holds only the sparse settings + * from the TOML file; identity fields live in their own keys. */ -const CONFIG_KEY = "config"; +const KEY_REPOSITORY_ID = "repositoryId"; +const KEY_REPOSITORY_FULL_NAME = "repositoryFullName"; +const KEY_INSTALLATION_ID = "installationId"; +const KEY_CONFIG = "config"; /** - * Sqlite-backed Durable Object that stores a single `StoredRepoConfig` - * envelope per repository (one DO instance per `repositoryFullName`). + * Sqlite-backed Durable Object that stores a `StoredRepoConfig` per + * repository (one DO instance per `repositoryFullName`), split across four + * KV keys: identity fields live in their own keys, and the `config` key holds + * only the sparse settings parsed from the TOML file. * * Passive store only — the DO performs no GitHub or Coder I/O (EARS-REQ-12). * Write-side validation and resolution happen in the RepoConfigWorkflow; this - * class simply persists a sparse envelope and projects it into a fully - * resolved `RepoConfig` on read. + * class simply persists each field and projects into a fully resolved + * `RepoConfig` on read. * * Storage API: uses the SYNCHRONOUS sqlite KV (`ctx.storage.kv.put/.get`) — * not the legacy async `ctx.storage.put/.get`. The migration in * `wrangler.toml` registers `RepoConfigDO` under `new_sqlite_classes`, so the - * sync surface is available. + * sync surface is available. The four `.put` calls in `setRepoConfig` run + * back-to-back with no awaits between them, so they commit as a single + * implicit transaction. */ export class RepoConfigDO extends DurableObject { async setRepoConfig(cfg: StoredRepoConfig): Promise { - this.ctx.storage.kv.put(CONFIG_KEY, cfg); + this.ctx.storage.kv.put(KEY_REPOSITORY_ID, cfg.repositoryId); + this.ctx.storage.kv.put(KEY_REPOSITORY_FULL_NAME, cfg.repositoryFullName); + this.ctx.storage.kv.put(KEY_INSTALLATION_ID, cfg.installationId); + this.ctx.storage.kv.put(KEY_CONFIG, cfg.settings); } async getRepoConfig(): Promise { - const stored = this.ctx.storage.kv.get(CONFIG_KEY) as - | StoredRepoConfig + const settings = this.ctx.storage.kv.get(KEY_CONFIG) as + | StoredRepoConfig["settings"] | undefined; - if (stored === undefined) { + if (settings === undefined) { return null; } return { - repositoryId: stored.repositoryId, - repositoryFullName: stored.repositoryFullName, - installationId: stored.installationId, - settings: resolveRepoConfigSettings(stored.settings), + repositoryId: this.ctx.storage.kv.get(KEY_REPOSITORY_ID) as number, + repositoryFullName: this.ctx.storage.kv.get( + KEY_REPOSITORY_FULL_NAME, + ) as string, + installationId: this.ctx.storage.kv.get(KEY_INSTALLATION_ID) as number, + settings: resolveRepoConfigSettings(settings), }; } }