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 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 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 diff --git a/docs/repo-config.md b/docs/repo-config.md new file mode 100644 index 0000000..8000c02 --- /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://task-action.xmtp.team/schema.json +``` + +Or add a `.taplo.toml` file at the repository root: + +```toml +[schema] +path = "https://task-action.xmtp.team/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. diff --git a/src/config/repo-config-schema.test.ts b/src/config/repo-config-schema.test.ts index c5c1998..f8a154f 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"; @@ -102,6 +103,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 +121,145 @@ 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 = ` +[[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 with full shape", () => { + 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).toEqual([ + { workflows: ["CI"], branches: ["main"] }, + { workflows: ["Deploy"], branches: ["release"] }, + ]); + }); + + 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 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 = ["CI"]\nbranches = "SECRET_BRANCH_VALUE"`, + ); + } catch (err) { + expect((err as Error).message).not.toContain("SECRET_BRANCH_VALUE"); + expect((err as Error).message).toMatch(/Invalid RepoConfig/); + } + }); +}); + describe("volume size normalization → canonical Kubernetes binary-SI form", () => { test.each([ ["10gb", "10Gi"], @@ -165,3 +306,82 @@ describe("volume size normalization → canonical Kubernetes binary-SI form", () ).toThrow(/Invalid RepoConfig/); }); }); + +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", + ); + 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 = 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 = 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 = 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 = schemaAny; + expect(s.properties.scheduled_jobs.default).toEqual([]); + }); + + test("scheduled_jobs[] requires name, branch, schedule, prompt", () => { + const s = schemaAny; + 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 = 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 = 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 = 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 = schemaAny; + 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 9a86ea9..9a60ae2 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 ───────────────────────────────────────────── @@ -101,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()`. * @@ -114,8 +132,42 @@ export const RepoConfigSettingsSchema = z.object({ sandbox: ResolvedSandboxSchema.prefault({}), harness: ResolvedHarnessSchema.prefault({}), scheduled_jobs: z.array(ScheduledJobSchema).default([]), + 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://task-action.xmtp.team/schema.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 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. +// 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") { + 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 ──────────────────────────────────────────────────────────────────── /** Sparse settings as stored in the DO (fields may be missing). */ diff --git a/src/main.test.ts b/src/main.test.ts index a18aa51..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"; @@ -16,6 +17,37 @@ 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).toEqual(JSON_SCHEMA); + }); + + 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; 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({