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/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); + }); +}); 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; +} 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..8ed96c0 --- /dev/null +++ b/src/durable-objects/repo-config-do.test.ts @@ -0,0 +1,126 @@ +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"; + +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("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"); + 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(); + }); +}); diff --git a/src/durable-objects/repo-config-do.ts b/src/durable-objects/repo-config-do.ts new file mode 100644 index 0000000..eb7c3f2 --- /dev/null +++ b/src/durable-objects/repo-config-do.ts @@ -0,0 +1,61 @@ +import { DurableObject } from "cloudflare:workers"; +import { + type RepoConfig, + resolveRepoConfigSettings, + type StoredRepoConfig, +} from "../config/repo-config-schema"; + +/** + * 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 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 `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 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. 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(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 settings = this.ctx.storage.kv.get(KEY_CONFIG) as + | StoredRepoConfig["settings"] + | undefined; + if (settings === undefined) { + return null; + } + return { + 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), + }; + } +} 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; diff --git a/src/main.ts b/src/main.ts index e8c45be..0f6ac80 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,8 @@ 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 }; // ── Worker entrypoint ──────────────────────────────────────────────────────── @@ -101,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/services/github/client.test.ts b/src/services/github/client.test.ts index 055bb79..122fed1 100644 --- a/src/services/github/client.test.ts +++ b/src/services/github/client.test.ts @@ -300,4 +300,130 @@ 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("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: { + 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, diff --git a/src/testing/e2e.test.ts b/src/testing/e2e.test.ts index 042fc81..c5400cb 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,66 @@ 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); + }); }); 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/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); + }); +}); 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">; diff --git a/src/webhooks/github/router.test.ts b/src/webhooks/github/router.test.ts index f15cf4c..9ae3bce 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,47 @@ 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); + if (!isEvent(result)) throw new Error("expected event"); + expect((result as ConfigPushEvent).head.sha).toBe("0".repeat(40)); + }); + }); + // ── 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; + } } diff --git a/src/workflows/instance-id.test.ts b/src/workflows/instance-id.test.ts index b62d829..302187f 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,44 @@ 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: "abcdef1234567890abcdef1234567890", + 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"); + expect(id).toContain("delivery-xyz"); + }); + 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); diff --git a/src/workflows/repo-config-workflow.test.ts b/src/workflows/repo-config-workflow.test.ts new file mode 100644 index 0000000..522adf3 --- /dev/null +++ b/src/workflows/repo-config-workflow.test.ts @@ -0,0 +1,74 @@ +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(); + }); +}); diff --git a/src/workflows/repo-config-workflow.ts b/src/workflows/repo-config-workflow.ts new file mode 100644 index 0000000..0182fdd --- /dev/null +++ b/src/workflows/repo-config-workflow.ts @@ -0,0 +1,75 @@ +import { + WorkflowEntrypoint, + type WorkflowEvent, + type WorkflowStep, +} from "cloudflare:workers"; +import { createAppAuth } from "@octokit/auth-app"; +import { Octokit } from "@octokit/rest"; +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"; +import type { TaskRunnerWorkflowEnv } from "./task-runner-workflow"; + +/** + * `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. + * + * 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< + TaskRunnerWorkflowEnv, + ConfigPushEvent +> { + async run( + event: WorkflowEvent, + 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, + 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: config.appId, + privateKey: config.privateKey, + 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/steps/sync-repo-config.test.ts b/src/workflows/steps/sync-repo-config.test.ts new file mode 100644 index 0000000..904cd5b --- /dev/null +++ b/src/workflows/steps/sync-repo-config.test.ts @@ -0,0 +1,216 @@ +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 } }, + }), + ); + }); +}); diff --git a/src/workflows/steps/sync-repo-config.ts b/src/workflows/steps/sync-repo-config.ts new file mode 100644 index 0000000..f774e36 --- /dev/null +++ b/src/workflows/steps/sync-repo-config.ts @@ -0,0 +1,80 @@ +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 }; + }); +} 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..1f70d7f 100644 --- a/src/workflows/task-runner-workflow.ts +++ b/src/workflows/task-runner-workflow.ts @@ -3,9 +3,11 @@ 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"; +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"; @@ -34,6 +36,8 @@ export interface TaskRunnerWorkflowEnv { CODER_ORGANIZATION: string; LOG_FORMAT?: string; TASK_RUNNER_WORKFLOW: Workflow; + REPO_CONFIG_WORKFLOW: Workflow; + REPO_CONFIG_DO: DurableObjectNamespace; } /** @@ -103,6 +107,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", + ); } } } diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 9d010f6..20945c8 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: 07a11f3ccfd321a73800e7b66522ab98) // 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; TASK_RUNNER_WORKFLOW: Workflow[0]['payload']>; + REPO_CONFIG_WORKFLOW: Workflow[0]['payload']>; } } 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