From a5821f3e7afb50337efe06c5218260c7134b63d4 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:57:06 -0700 Subject: [PATCH 01/13] feat(config): add on_event.failed_run sparse schema --- src/config/repo-config-schema.test.ts | 100 ++++++++++++++++++++++++++ src/config/repo-config-schema.ts | 13 ++++ 2 files changed, 113 insertions(+) diff --git a/src/config/repo-config-schema.test.ts b/src/config/repo-config-schema.test.ts index c5c1998..eb82cf4 100644 --- a/src/config/repo-config-schema.test.ts +++ b/src/config/repo-config-schema.test.ts @@ -119,6 +119,106 @@ describe("resolveRepoConfigSettings — defaults applied on read", () => { }); }); +describe("parseRepoConfigToml — on_event.failed_run", () => { + test("full entry with all fields → parses", () => { + const toml = ` +[[on_event.failed_run]] +workflows = ["CI"] +branches = ["main"] +prompt_additions = "There was a failed run. Fix it" +`; + const parsed = parseRepoConfigToml(toml); + expect(parsed.on_event?.failed_run?.[0]).toEqual({ + workflows: ["CI"], + branches: ["main"], + prompt_additions: "There was a failed run. Fix it", + }); + }); + + test("entry without prompt_additions → parses", () => { + const toml = ` +[[on_event.failed_run]] +workflows = ["CI"] +branches = ["main"] +`; + const parsed = parseRepoConfigToml(toml); + expect(parsed.on_event?.failed_run?.[0]).toEqual({ + workflows: ["CI"], + branches: ["main"], + }); + }); + + test("multiple entries → preserved in order", () => { + const toml = ` +[[on_event.failed_run]] +workflows = ["CI"] +branches = ["main"] + +[[on_event.failed_run]] +workflows = ["Deploy"] +branches = ["release"] +`; + const parsed = parseRepoConfigToml(toml); + expect(parsed.on_event?.failed_run?.map((e) => e.workflows[0])).toEqual([ + "CI", + "Deploy", + ]); + }); + + test("unknown keys inside entry are dropped", () => { + const toml = ` +[[on_event.failed_run]] +workflows = ["CI"] +branches = ["main"] +future_field = "ignored" +`; + const parsed = parseRepoConfigToml(toml); + expect(parsed.on_event?.failed_run?.[0]).toEqual({ + workflows: ["CI"], + branches: ["main"], + }); + }); + + test("missing workflows → NonRetryableError", () => { + expect(() => + parseRepoConfigToml(`[[on_event.failed_run]]\nbranches = ["main"]`), + ).toThrow(/Invalid RepoConfig/); + }); + + test("empty workflows array → NonRetryableError", () => { + expect(() => + parseRepoConfigToml( + `[[on_event.failed_run]]\nworkflows = []\nbranches = ["main"]`, + ), + ).toThrow(/Invalid RepoConfig/); + }); + + test("missing branches → NonRetryableError", () => { + expect(() => + parseRepoConfigToml(`[[on_event.failed_run]]\nworkflows = ["CI"]`), + ).toThrow(/Invalid RepoConfig/); + }); + + test("empty branches array → NonRetryableError", () => { + expect(() => + parseRepoConfigToml( + `[[on_event.failed_run]]\nworkflows = ["CI"]\nbranches = []`, + ), + ).toThrow(/Invalid RepoConfig/); + }); + + test("error message does not leak raw workflow/branch values", () => { + try { + parseRepoConfigToml( + `[[on_event.failed_run]]\nworkflows = ["SECRET_WORKFLOW"]`, + // missing branches — triggers validation error + ); + } catch (err) { + expect((err as Error).message).not.toContain("SECRET_WORKFLOW"); + } + }); +}); + describe("volume size normalization → canonical Kubernetes binary-SI form", () => { test.each([ ["10gb", "10Gi"], diff --git a/src/config/repo-config-schema.ts b/src/config/repo-config-schema.ts index 9a86ea9..9de84f6 100644 --- a/src/config/repo-config-schema.ts +++ b/src/config/repo-config-schema.ts @@ -72,11 +72,24 @@ export const ScheduledJobSchema = z.object({ prompt: z.string(), }); +/** Sparse shape for a single `[[on_event.failed_run]]` entry. */ +export const StoredFailedRunEventSchema = z.object({ + workflows: z.array(z.string()).min(1), + branches: z.array(z.string()).min(1), + prompt_additions: z.string().optional(), +}); + +/** Sparse shape for the `[on_event]` section. */ +export const StoredOnEventSchema = z.object({ + failed_run: z.array(StoredFailedRunEventSchema).optional(), +}); + /** 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(), + on_event: StoredOnEventSchema.optional(), }); // ── Resolved (read-side) schemas ───────────────────────────────────────────── From 83f2dc957092b521cb9bdc5f23a888ce40048099 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:58:17 -0700 Subject: [PATCH 02/13] feat(config): resolve on_event.failed_run with empty default --- src/config/repo-config-schema.test.ts | 35 +++++++++++++++++++++++++++ src/config/repo-config-schema.ts | 6 +++++ 2 files changed, 41 insertions(+) diff --git a/src/config/repo-config-schema.test.ts b/src/config/repo-config-schema.test.ts index eb82cf4..fc45c51 100644 --- a/src/config/repo-config-schema.test.ts +++ b/src/config/repo-config-schema.test.ts @@ -102,6 +102,7 @@ describe("resolveRepoConfigSettings — defaults applied on read", () => { sandbox: { size: "medium", docker: false, volumes: [] }, harness: { provider: "claude_code" }, scheduled_jobs: [], + on_event: { failed_run: [] }, }); }); test("volume with path-only → size defaulted to '10Gi'", () => { @@ -119,6 +120,40 @@ describe("resolveRepoConfigSettings — defaults applied on read", () => { }); }); +describe("resolveRepoConfigSettings — on_event defaults", () => { + test("undefined → on_event.failed_run defaults to []", () => { + const r = resolveRepoConfigSettings(undefined); + expect(r.on_event.failed_run).toEqual([]); + }); + + test("empty object → on_event.failed_run defaults to []", () => { + const r = resolveRepoConfigSettings({}); + expect(r.on_event.failed_run).toEqual([]); + }); + + test("sparse on_event with no failed_run → failed_run defaults to []", () => { + const r = resolveRepoConfigSettings({ on_event: {} }); + expect(r.on_event.failed_run).toEqual([]); + }); + + test("entries passthrough", () => { + const r = resolveRepoConfigSettings({ + on_event: { + failed_run: [ + { + workflows: ["CI"], + branches: ["main"], + prompt_additions: "fix it", + }, + ], + }, + }); + expect(r.on_event.failed_run).toEqual([ + { workflows: ["CI"], branches: ["main"], prompt_additions: "fix it" }, + ]); + }); +}); + describe("parseRepoConfigToml — on_event.failed_run", () => { test("full entry with all fields → parses", () => { const toml = ` diff --git a/src/config/repo-config-schema.ts b/src/config/repo-config-schema.ts index 9de84f6..83d07f6 100644 --- a/src/config/repo-config-schema.ts +++ b/src/config/repo-config-schema.ts @@ -114,6 +114,11 @@ export const ResolvedHarnessSchema = z.object({ provider: HarnessProviderSchema.default("claude_code"), }); +/** Resolved on_event: failed_run defaults to []. */ +export const ResolvedOnEventSchema = z.object({ + failed_run: z.array(StoredFailedRunEventSchema).default([]), +}); + /** * Top-level resolved shape — always fully populated after `.parse()`. * @@ -127,6 +132,7 @@ export const RepoConfigSettingsSchema = z.object({ sandbox: ResolvedSandboxSchema.prefault({}), harness: ResolvedHarnessSchema.prefault({}), scheduled_jobs: z.array(ScheduledJobSchema).default([]), + on_event: ResolvedOnEventSchema.prefault({}), }); // ── Types ──────────────────────────────────────────────────────────────────── From 3c8a3b3bbfac3e22433839bfe45a7774ac86f400 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:00:22 -0700 Subject: [PATCH 03/13] test(config): strengthen on_event.failed_run test assertions --- src/config/repo-config-schema.test.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/config/repo-config-schema.test.ts b/src/config/repo-config-schema.test.ts index fc45c51..2ffcd7e 100644 --- a/src/config/repo-config-schema.test.ts +++ b/src/config/repo-config-schema.test.ts @@ -183,7 +183,7 @@ branches = ["main"] }); }); - test("multiple entries → preserved in order", () => { + test("multiple entries → preserved in order with full shape", () => { const toml = ` [[on_event.failed_run]] workflows = ["CI"] @@ -194,9 +194,9 @@ workflows = ["Deploy"] branches = ["release"] `; const parsed = parseRepoConfigToml(toml); - expect(parsed.on_event?.failed_run?.map((e) => e.workflows[0])).toEqual([ - "CI", - "Deploy", + expect(parsed.on_event?.failed_run).toEqual([ + { workflows: ["CI"], branches: ["main"] }, + { workflows: ["Deploy"], branches: ["release"] }, ]); }); @@ -242,14 +242,19 @@ future_field = "ignored" ).toThrow(/Invalid RepoConfig/); }); - test("error message does not leak raw workflow/branch values", () => { + test("error message does not leak raw branch values on type mismatch", () => { + // Use branches = "not-an-array-SECRET" (type mismatch) so that the raw + // string itself becomes issue.input for the failing Zod issue. If the + // error builder ever started interpolating issue.input, the secret would + // surface in the message. + expect.assertions(2); try { parseRepoConfigToml( - `[[on_event.failed_run]]\nworkflows = ["SECRET_WORKFLOW"]`, - // missing branches — triggers validation error + `[[on_event.failed_run]]\nworkflows = ["CI"]\nbranches = "SECRET_BRANCH_VALUE"`, ); } catch (err) { - expect((err as Error).message).not.toContain("SECRET_WORKFLOW"); + expect((err as Error).message).not.toContain("SECRET_BRANCH_VALUE"); + expect((err as Error).message).toMatch(/Invalid RepoConfig/); } }); }); From 5015b1ec5b4dde4fb275dde72a34306b46c4e629 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:03:09 -0700 Subject: [PATCH 04/13] feat(config): export JSON_SCHEMA for repo config Co-Authored-By: Claude Sonnet 4.6 --- src/config/repo-config-schema.test.ts | 77 +++++++++++++++++++++++++++ src/config/repo-config-schema.ts | 24 +++++++++ 2 files changed, 101 insertions(+) diff --git a/src/config/repo-config-schema.test.ts b/src/config/repo-config-schema.test.ts index 2ffcd7e..8bcfeb5 100644 --- a/src/config/repo-config-schema.test.ts +++ b/src/config/repo-config-schema.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + JSON_SCHEMA, parseRepoConfigToml, resolveRepoConfigSettings, } from "./repo-config-schema"; @@ -305,3 +306,79 @@ describe("volume size normalization → canonical Kubernetes binary-SI form", () ).toThrow(/Invalid RepoConfig/); }); }); + +describe("JSON_SCHEMA export", () => { + test("has required top-level metadata", () => { + expect(JSON_SCHEMA.$schema).toBe( + "https://json-schema.org/draft/2020-12/schema", + ); + expect(JSON_SCHEMA.$id).toBeTypeOf("string"); + expect(JSON_SCHEMA.title).toBe("code-factory repo config"); + expect(typeof JSON_SCHEMA.description).toBe("string"); + }); + + test("sandbox.size has default 'medium' and is optional", () => { + const s = JSON_SCHEMA as any; + const sandbox = s.properties.sandbox; + expect(sandbox.properties.size.default).toBe("medium"); + expect(sandbox.required ?? []).not.toContain("size"); + }); + + test("sandbox.volumes[].path is required; size has default '10Gi'", () => { + const s = JSON_SCHEMA as any; + const volItem = s.properties.sandbox.properties.volumes.items; + expect(volItem.required).toContain("path"); + expect(volItem.properties.size.default).toBe("10Gi"); + }); + + test("harness.provider has default 'claude_code' and is optional", () => { + const s = JSON_SCHEMA as any; + const harness = s.properties.harness; + expect(harness.properties.provider.default).toBe("claude_code"); + expect(harness.required ?? []).not.toContain("provider"); + }); + + test("scheduled_jobs default is []", () => { + const s = JSON_SCHEMA as any; + expect(s.properties.scheduled_jobs.default).toEqual([]); + }); + + test("scheduled_jobs[] requires name, branch, schedule, prompt", () => { + const s = JSON_SCHEMA as any; + const item = s.properties.scheduled_jobs.items; + expect(item.required).toEqual( + expect.arrayContaining(["name", "branch", "schedule", "prompt"]), + ); + }); + + test("on_event.failed_run default is []", () => { + const s = JSON_SCHEMA as any; + const failedRun = s.properties.on_event.properties.failed_run; + expect(failedRun.default).toEqual([]); + }); + + test("on_event.failed_run[].workflows has minItems: 1", () => { + const s = JSON_SCHEMA as any; + const item = s.properties.on_event.properties.failed_run.items; + expect(item.properties.workflows.minItems).toBe(1); + }); + + test("on_event.failed_run[].branches has minItems: 1", () => { + const s = JSON_SCHEMA as any; + const item = s.properties.on_event.properties.failed_run.items; + expect(item.properties.branches.minItems).toBe(1); + }); + + test("on_event.failed_run[] requires workflows and branches but not prompt_additions", () => { + const s = JSON_SCHEMA as any; + const item = s.properties.on_event.properties.failed_run.items; + expect(item.required).toEqual( + expect.arrayContaining(["workflows", "branches"]), + ); + expect(item.required ?? []).not.toContain("prompt_additions"); + }); + + test("is JSON-serializable", () => { + expect(() => JSON.stringify(JSON_SCHEMA)).not.toThrow(); + }); +}); diff --git a/src/config/repo-config-schema.ts b/src/config/repo-config-schema.ts index 83d07f6..09a1c46 100644 --- a/src/config/repo-config-schema.ts +++ b/src/config/repo-config-schema.ts @@ -135,6 +135,30 @@ export const RepoConfigSettingsSchema = z.object({ on_event: ResolvedOnEventSchema.prefault({}), }); +// ── JSON Schema (editor-consumable) ────────────────────────────────────────── +// Generated from the resolved schema in Zod input mode so optional-with-default +// fields carry a `default` keyword. Consumed by editors (Taplo, VS Code) via +// `GET /schema.json` — see `src/main.ts`. + +const JSON_SCHEMA_ID = + "https://xmtplabs.github.io/coder-action/schema/repo-config.json"; + +export const JSON_SCHEMA: Record = { + ...(z.toJSONSchema(RepoConfigSettingsSchema, { + io: "input", + target: "draft-2020-12", + }) as Record), + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: JSON_SCHEMA_ID, + title: "code-factory repo config", + description: "Schema for .code-factory/config.toml", +}; + +// Zod v4.3.6 workaround (Task 3): VolumeSizeSchema has a .transform() which +// prevents z.toJSONSchema from emitting the `default` keyword for +// `sandbox.volumes[].size`. Inject it narrowly after the spread. +((JSON_SCHEMA as any).properties?.sandbox?.properties?.volumes?.items?.properties?.size ?? {}).default = "10Gi"; + // ── Types ──────────────────────────────────────────────────────────────────── /** Sparse settings as stored in the DO (fields may be missing). */ From 79a874e5ac16d676e779d4ef510ab7e7c91f2fc5 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:04:25 -0700 Subject: [PATCH 05/13] feat(worker): serve repo config JSON Schema at GET /schema.json --- src/main.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/main.ts | 11 +++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/main.test.ts b/src/main.test.ts index a18aa51..b05767d 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -16,6 +16,43 @@ describe("Worker default export", () => { }); }); +describe("GET /schema.json", () => { + test("returns 200 with application/schema+json", async () => { + const req = new Request("https://example.com/schema.json", { + method: "GET", + }); + const res = await worker.fetch(req, {} as never, {} as ExecutionContext); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe( + "application/schema+json; charset=utf-8", + ); + expect(res.headers.get("cache-control")).toBe("public, max-age=300"); + }); + + test("body is the exported JSON_SCHEMA", async () => { + const req = new Request("https://example.com/schema.json", { + method: "GET", + }); + const res = await worker.fetch(req, {} as never, {} as ExecutionContext); + const body = await res.json(); + expect((body as any).$schema).toBe( + "https://json-schema.org/draft/2020-12/schema", + ); + expect((body as any).title).toBe("code-factory repo config"); + expect( + (body as any).properties.sandbox.properties.size.default, + ).toBe("medium"); + }); + + test("non-GET method falls through to 404", async () => { + const req = new Request("https://example.com/schema.json", { + method: "POST", + }); + const res = await worker.fetch(req, {} as never, {} as ExecutionContext); + expect(res.status).toBe(404); + }); +}); + // ── Worker tracing bindings ─────────────────────────────────────────────────── // // The Worker's `handleGithubWebhook` builds `reqLogger` after diff --git a/src/main.ts b/src/main.ts index 0f6ac80..79ca297 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { loadConfig } from "./config/app-config"; +import { JSON_SCHEMA } from "./config/repo-config-schema"; import { __setAppBotLoginForTests, resolveAppBotLogin, @@ -34,6 +35,16 @@ export default { return handleGithubWebhook(request, env); } + if (request.method === "GET" && url.pathname === "/schema.json") { + return new Response(JSON.stringify(JSON_SCHEMA), { + status: 200, + headers: { + "content-type": "application/schema+json; charset=utf-8", + "cache-control": "public, max-age=300", + }, + }); + } + return new Response("Not Found", { status: 404 }); }, } satisfies ExportedHandler; From 273ae5451ef15af23931ef7f2412aa958786eb73 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:07:10 -0700 Subject: [PATCH 06/13] refactor(config): fail loudly if JSON schema workaround path drifts --- src/config/repo-config-schema.ts | 17 +++++++++++++---- src/main.test.ts | 9 ++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/config/repo-config-schema.ts b/src/config/repo-config-schema.ts index 09a1c46..21023dd 100644 --- a/src/config/repo-config-schema.ts +++ b/src/config/repo-config-schema.ts @@ -154,10 +154,19 @@ export const JSON_SCHEMA: Record = { description: "Schema for .code-factory/config.toml", }; -// Zod v4.3.6 workaround (Task 3): VolumeSizeSchema has a .transform() which -// prevents z.toJSONSchema from emitting the `default` keyword for -// `sandbox.volumes[].size`. Inject it narrowly after the spread. -((JSON_SCHEMA as any).properties?.sandbox?.properties?.volumes?.items?.properties?.size ?? {}).default = "10Gi"; +// Zod v4.3.6 workaround (Task 3): VolumeSizeSchema uses `.transform()` which +// causes `z.toJSONSchema` to drop the `default` keyword on `sandbox.volumes[].size`. +// Inject it explicitly. If the path ever drifts (e.g. after a Zod upgrade), +// throw loudly at module load rather than silently shipping a schema without +// the default. +const volumeSizeNode = (JSON_SCHEMA as any).properties?.sandbox?.properties + ?.volumes?.items?.properties?.size; +if (!volumeSizeNode || typeof volumeSizeNode !== "object") { + throw new Error( + "JSON_SCHEMA shape drift: sandbox.volumes[].size not found — re-check Zod z.toJSONSchema output and remove this workaround if no longer needed", + ); +} +volumeSizeNode.default = "10Gi"; // ── Types ──────────────────────────────────────────────────────────────────── diff --git a/src/main.test.ts b/src/main.test.ts index b05767d..c4d61dc 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import worker, { __setAppBotLoginForTests } from "./main"; +import { JSON_SCHEMA } from "./config/repo-config-schema"; import issuesAssigned from "./testing/fixtures/issues-assigned.json"; import workflowRunSuccess from "./testing/fixtures/workflow-run-success.json"; import { computeSignature } from "./testing/workflow-test-helpers"; @@ -35,13 +36,7 @@ describe("GET /schema.json", () => { }); const res = await worker.fetch(req, {} as never, {} as ExecutionContext); const body = await res.json(); - expect((body as any).$schema).toBe( - "https://json-schema.org/draft/2020-12/schema", - ); - expect((body as any).title).toBe("code-factory repo config"); - expect( - (body as any).properties.sandbox.properties.size.default, - ).toBe("medium"); + expect(body).toEqual(JSON_SCHEMA); }); test("non-GET method falls through to 404", async () => { From 95028a4ad40b4a5537973386e52792e4a6d484fa Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:09:26 -0700 Subject: [PATCH 07/13] docs: add repo config reference Co-Authored-By: Claude Sonnet 4.6 --- docs/repo-config.md | 108 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/repo-config.md diff --git a/docs/repo-config.md b/docs/repo-config.md new file mode 100644 index 0000000..33d5931 --- /dev/null +++ b/docs/repo-config.md @@ -0,0 +1,108 @@ +# Repo Config (`.code-factory/config.toml`) + +## Overview + +Each repository that uses coder-action may include an optional `.code-factory/config.toml` file at the repository root. The file uses standard TOML syntax. Unknown keys are silently stripped (Zod `.strip()` behavior), so repositories can land forward-compatible config before the server knows about a new field. Validation runs on the write path via `parseRepoConfigToml` whenever a config push event is processed. + +## Editor integration + +Point [Taplo](https://taplo.tamasfe.dev/) or the VS Code TOML extension at the served `/schema.json` endpoint to get hover docs and inline validation. + +Top-of-file directive (no extra tooling required): + +```toml +#:schema https:///schema.json +``` + +Or add a `.taplo.toml` file at the repository root: + +```toml +[schema] +path = "https:///schema.json" +include = [".code-factory/config.toml"] +``` + +The schema is generated from the fully resolved Zod shape in input mode, so `default` values are visible in editor hover tooltips. + +## `[sandbox]` + +| Field | Type | Default | Required | Description | +|-------|------|---------|----------|-------------| +| `size` | `"small"` \| `"medium"` \| `"large"` | `"medium"` | No | Controls sandbox instance sizing. | +| `docker` | boolean | `false` | No | Enables docker-in-docker inside the sandbox. | +| `volumes` | array of `[[sandbox.volumes]]` | `[]` | No | Persistent volumes attached to the sandbox. | + +## `[[sandbox.volumes]]` + +| Field | Type | Default | Required | Description | +|-------|------|---------|----------|-------------| +| `path` | string | — | Yes | Mount point inside the sandbox. | +| `size` | volume-size string | `"10Gi"` | No | Accepts common variants (`10gb`, `10GB`, `10G`, `10gi`, `10Gi`) and is always normalized to the canonical binary-SI form (`10Gi`, `500Mi`, `2Ti`, `64Ki`). Supports K/M/G/T prefixes. | + +## `[harness]` + +| Field | Type | Default | Required | Description | +|-------|------|---------|----------|-------------| +| `provider` | `"claude_code"` \| `"codex"` | `"claude_code"` | No | Selects the code-agent harness. | + +## `[[scheduled_jobs]]` + +| Field | Type | Default | Required | Description | +|-------|------|---------|----------|-------------| +| `name` | string | — | Yes | Display name for the scheduled job. | +| `branch` | string | — | Yes | Branch the job runs against. | +| `schedule` | string | — | Yes | Cron expression (e.g. `"0 9 * * 1"`). | +| `prompt` | string | — | Yes | Prompt forwarded to the agent when the job fires. | + +## `[[on_event.failed_run]]` + +| Field | Type | Default | Required | Description | +|-------|------|---------|----------|-------------| +| `workflows` | array of strings | — | Yes | Workflow names (as they appear in GitHub Actions) to watch. Must be non-empty. | +| `branches` | array of strings | — | Yes | Branches on which a failed `workflow_run` triggers this event. Must be non-empty. | +| `prompt_additions` | string | — | No | Extra prompt context forwarded to the task. | + +**Schema-only in this release — no consumer yet.** The block validates today; event dispatch is a future change. + +## Examples + +Minimal config (sandbox defaults apply): + +```toml +[sandbox] +size = "large" +``` + +Full config (every section populated): + +```toml +[sandbox] +size = "large" +docker = true + +[[sandbox.volumes]] +path = "/home/user/data" +size = "20Gi" + +[[sandbox.volumes]] +path = "/tmp/cache" +size = "5Gi" + +[harness] +provider = "claude_code" + +[[scheduled_jobs]] +name = "weekly-audit" +branch = "main" +schedule = "0 9 * * 1" +prompt = "Run the dependency audit and open a PR with any updates." + +[[on_event.failed_run]] +workflows = ["ci.yml", "deploy.yml"] +branches = ["main", "release"] +prompt_additions = "Focus on the failing step and propose a fix." +``` + +## JSON Schema + +The Worker serves the JSON Schema at `GET /schema.json`. The schema reflects the latest deploy of the Worker and is generated directly from the Zod validation shape, so it always matches what `parseRepoConfigToml` accepts. Use the endpoint URL in Taplo or VS Code as shown in the Editor integration section above. From 501343e61bbf350abe2c3bedb524efe11da53a7a Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:10:12 -0700 Subject: [PATCH 08/13] docs: link repo-config reference from README and AGENTS Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 1 + README.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 04ff8a8..ebd6bbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,3 +111,4 @@ Cross-cutting: - [GitHub App setup](docs/github-app-setup.md) — registration, installation, secrets - [Coder API endpoints](docs/coder-api.md) — experimental tasks + stable endpoints we consume +- [Per-repo config format](docs/repo-config.md) — `.code-factory/config.toml` field reference for sandbox, harness, scheduled jobs, and event hooks diff --git a/README.md b/README.md index b7becc9..bc36882 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ All non-secret config lives in [`wrangler.toml`](wrangler.toml) under `[vars]`. | `CODER_ORGANIZATION` | var | Coder organization (default: `default`) | | `LOG_FORMAT` | var | `json` (production) or `pretty` (local dev) | +### Per-repo configuration + +Consuming repositories may define a `.code-factory/config.toml` file to customize sandbox sizing, harness selection, scheduled jobs, and event hooks. See [docs/repo-config.md](docs/repo-config.md) for the full reference. A machine-readable JSON Schema is served at `/schema.json` for editor integration (Taplo, VS Code). + ## Running ```bash From f5019fb8942af394cc50ca97f07a1e48b3574b67 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:12:37 -0700 Subject: [PATCH 09/13] test: add on_event to RepoConfigSettings fixtures Co-Authored-By: Claude Sonnet 4.6 --- src/workflows/steps/create-task.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/workflows/steps/create-task.test.ts b/src/workflows/steps/create-task.test.ts index da727ea..de58be2 100644 --- a/src/workflows/steps/create-task.test.ts +++ b/src/workflows/steps/create-task.test.ts @@ -338,6 +338,7 @@ describe("runCreateTask", () => { }, harness: { provider: "codex" }, scheduled_jobs: [], + on_event: { failed_run: [] }, }, }; await runCreateTask({ From 20f3207fe0cb5e742ead192020420bb725198c81 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:14:25 -0700 Subject: [PATCH 10/13] chore(config): silence biome noExplicitAny warnings on JSON_SCHEMA casts Co-Authored-By: Claude Sonnet 4.6 --- src/config/repo-config-schema.test.ts | 21 ++++++++++++--------- src/config/repo-config-schema.ts | 1 + 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/config/repo-config-schema.test.ts b/src/config/repo-config-schema.test.ts index 8bcfeb5..f8a154f 100644 --- a/src/config/repo-config-schema.test.ts +++ b/src/config/repo-config-schema.test.ts @@ -308,6 +308,9 @@ describe("volume size normalization → canonical Kubernetes binary-SI form", () }); describe("JSON_SCHEMA export", () => { + // biome-ignore lint/suspicious/noExplicitAny: JSON Schema traversal — recursive unknown shape + const schemaAny = JSON_SCHEMA as any; + test("has required top-level metadata", () => { expect(JSON_SCHEMA.$schema).toBe( "https://json-schema.org/draft/2020-12/schema", @@ -318,33 +321,33 @@ describe("JSON_SCHEMA export", () => { }); test("sandbox.size has default 'medium' and is optional", () => { - const s = JSON_SCHEMA as any; + const s = schemaAny; const sandbox = s.properties.sandbox; expect(sandbox.properties.size.default).toBe("medium"); expect(sandbox.required ?? []).not.toContain("size"); }); test("sandbox.volumes[].path is required; size has default '10Gi'", () => { - const s = JSON_SCHEMA as any; + const s = schemaAny; const volItem = s.properties.sandbox.properties.volumes.items; expect(volItem.required).toContain("path"); expect(volItem.properties.size.default).toBe("10Gi"); }); test("harness.provider has default 'claude_code' and is optional", () => { - const s = JSON_SCHEMA as any; + const s = schemaAny; const harness = s.properties.harness; expect(harness.properties.provider.default).toBe("claude_code"); expect(harness.required ?? []).not.toContain("provider"); }); test("scheduled_jobs default is []", () => { - const s = JSON_SCHEMA as any; + const s = schemaAny; expect(s.properties.scheduled_jobs.default).toEqual([]); }); test("scheduled_jobs[] requires name, branch, schedule, prompt", () => { - const s = JSON_SCHEMA as any; + const s = schemaAny; const item = s.properties.scheduled_jobs.items; expect(item.required).toEqual( expect.arrayContaining(["name", "branch", "schedule", "prompt"]), @@ -352,25 +355,25 @@ describe("JSON_SCHEMA export", () => { }); test("on_event.failed_run default is []", () => { - const s = JSON_SCHEMA as any; + const s = schemaAny; const failedRun = s.properties.on_event.properties.failed_run; expect(failedRun.default).toEqual([]); }); test("on_event.failed_run[].workflows has minItems: 1", () => { - const s = JSON_SCHEMA as any; + const s = schemaAny; const item = s.properties.on_event.properties.failed_run.items; expect(item.properties.workflows.minItems).toBe(1); }); test("on_event.failed_run[].branches has minItems: 1", () => { - const s = JSON_SCHEMA as any; + const s = schemaAny; const item = s.properties.on_event.properties.failed_run.items; expect(item.properties.branches.minItems).toBe(1); }); test("on_event.failed_run[] requires workflows and branches but not prompt_additions", () => { - const s = JSON_SCHEMA as any; + const s = schemaAny; const item = s.properties.on_event.properties.failed_run.items; expect(item.required).toEqual( expect.arrayContaining(["workflows", "branches"]), diff --git a/src/config/repo-config-schema.ts b/src/config/repo-config-schema.ts index 21023dd..c84e7b1 100644 --- a/src/config/repo-config-schema.ts +++ b/src/config/repo-config-schema.ts @@ -159,6 +159,7 @@ export const JSON_SCHEMA: Record = { // Inject it explicitly. If the path ever drifts (e.g. after a Zod upgrade), // throw loudly at module load rather than silently shipping a schema without // the default. +// biome-ignore lint/suspicious/noExplicitAny: traverses Zod's generated JSON Schema, which has a recursive any-ish shape const volumeSizeNode = (JSON_SCHEMA as any).properties?.sandbox?.properties ?.volumes?.items?.properties?.size; if (!volumeSizeNode || typeof volumeSizeNode !== "object") { From ac262dea4aa3c7dd00f12d7ecb765290f886e736 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:21:35 -0700 Subject: [PATCH 11/13] chore(config): set JSON_SCHEMA_ID and doc URLs to task-action.xmtp.team Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/repo-config.md | 4 ++-- src/config/repo-config-schema.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/repo-config.md b/docs/repo-config.md index 33d5931..8000c02 100644 --- a/docs/repo-config.md +++ b/docs/repo-config.md @@ -11,14 +11,14 @@ Point [Taplo](https://taplo.tamasfe.dev/) or the VS Code TOML extension at the s Top-of-file directive (no extra tooling required): ```toml -#:schema https:///schema.json +#:schema https://task-action.xmtp.team/schema.json ``` Or add a `.taplo.toml` file at the repository root: ```toml [schema] -path = "https:///schema.json" +path = "https://task-action.xmtp.team/schema.json" include = [".code-factory/config.toml"] ``` diff --git a/src/config/repo-config-schema.ts b/src/config/repo-config-schema.ts index c84e7b1..9a60ae2 100644 --- a/src/config/repo-config-schema.ts +++ b/src/config/repo-config-schema.ts @@ -140,8 +140,7 @@ export const RepoConfigSettingsSchema = z.object({ // fields carry a `default` keyword. Consumed by editors (Taplo, VS Code) via // `GET /schema.json` — see `src/main.ts`. -const JSON_SCHEMA_ID = - "https://xmtplabs.github.io/coder-action/schema/repo-config.json"; +const JSON_SCHEMA_ID = "https://task-action.xmtp.team/schema.json"; export const JSON_SCHEMA: Record = { ...(z.toJSONSchema(RepoConfigSettingsSchema, { From 8fe5ad704d4803b2558af7805a45a66704997151 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:22:00 -0700 Subject: [PATCH 12/13] chore(config): add #:schema directive to .code-factory/config.toml Co-Authored-By: Claude Opus 4.7 (1M context) --- .code-factory/config.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.code-factory/config.toml b/.code-factory/config.toml index a405c31..8ed543f 100644 --- a/.code-factory/config.toml +++ b/.code-factory/config.toml @@ -1,3 +1,5 @@ +#:schema https://task-action.xmtp.team/schema.json + [sandbox] size = "small" docker = false From 3ba222e2312bc12b3d496c180693af9cfbfb1341 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:23:04 -0700 Subject: [PATCH 13/13] Bump biome --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index 5d2360d..979bf0c 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", "files": { "includes": ["src/**/*.ts", "*.json", "*.md"], "maxSize": 2097152