Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ changing credentials, claims, Sandboxes, publishing, or GitHub writes.
- Validate every external or cross-process input with Zod at its boundary.
- Build agent output objects with `z.strictObject`. Bound strings and arrays.
- Keep task inputs and results small and JSON-serializable.
- Open Sandboxes through `withSandbox`. In claiming tasks, call
- Open Sandboxes through `withSandbox`. In GitHub tasks, call
`claim.recordSandbox()` immediately after opening one.
- Use `ExpectedFailure` only for expected messages safe to publish on GitHub.
Keep outcome names in snake_case.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ npx vitest run tests/gateway.test.ts
Follow [docs/runbooks/extend.md](docs/runbooks/extend.md) to:

- add a typed agent and prompt;
- add a claiming or maintenance task;
- add a GitHub or maintenance task;
- route a GitHub event, label, or command;
- update GitHub App requirements;
- test authority and user-visible behavior; and
Expand Down
81 changes: 40 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,63 @@ workflows on Render. It includes a complete issue-to-PR workflow that you can
run as-is, change one agent at a time, or use as the foundation for different
automations.

The template provides the infrastructure common to agent workflows:
Start by [forking this repository](https://github.com/render-examples/software-factory/fork).
Deploy from your fork: its `factory.config.ts` defines the repositories the
factory may access.

- a signed-webhook gateway;
- durable Render Workflows tasks with cancellation and run ownership;
- short-lived Render Sandboxes for repository code and model-directed tools;
- typed agent inputs and outputs;
- GitHub App authentication and artifact markers; and
- validated publishing that keeps write credentials outside Sandboxes.
The recommended setup is agent-guided:

## Included workflow
1. Check the prerequisites in the [Quickstart](docs/quickstart.md).
2. Ask a coding agent to follow the [Setup runbook](docs/runbooks/setup.md).
3. Complete the GitHub, secret, and paid-resource checkpoints when the agent
asks.

```text
issue --factory:triage--> specification
specification --factory:spec-approved--> draft pull request
pull request --factory:pr-review--> COMMENT review
```
The runbook configures the fork before deployment, creates a GitHub App,
deploys the Render Blueprint, connects the webhook, verifies both services,
and runs one Triage smoke test.

**What software-factory solves for you:**
1. Authenticating GitHub events and routing them to durable Render Workflows tasks.
2. Running agents and repository tools inside short-lived, isolated Render Sandboxes.
3. Handling overlapping runs, cancellation, cleanup, and idempotency.
4. Validating typed agent output and safely publishing proposed code to GitHub.
5. Providing deployment configuration, setup scripts, health checks, and structured telemetry.

**What software-factory leaves for you:**
1. The agents, prompts, and workflow logic for your use case.
2. Repository-specific install and verification commands.
3. The GitHub triggers, approval gates, and permissions your workflow needs.
4. Model selection, budgets, and evaluation strategy.
5. Channel integrations.
6. Persistent data store.

- **Triage** reads the issue, discussion, and repository. It asks questions,
## Included workflow

- **Triage:** reads the issue, discussion, and repository. It asks questions,
writes a specification, or declines the request.
- **Spec-to-PR** implements the approved specification, runs your verification
- **Spec-to-PR:** implements the approved specification, runs your verification
commands, and opens a draft pull request.
- **PR Reviewer** runs verification and reviews security, correctness,
- **PR Reviewer:** runs verification and reviews security, correctness,
performance, and tests. It posts a non-approving GitHub COMMENT review.

GitHub holds the discussion, labels, specifications, pull requests, and
reviews. Render Workflows runs the tasks. Sandboxes contain the checkout,
agent loop, and model-directed tools. The included workflow never approves or
merges code.

The three labels above keep each transition human-gated. Apply
`factory:autopilot` to an issue to grant upfront approval for the same stages:
Triage drafts the spec, Spec-to-PR opens a draft pull request, and PR Reviewer
posts its review as child tasks in one Render Workflow. If Triage asks
questions, the workflow stops and continues when a person replies.

## Extend the template

The built-in agents and tasks are examples of the template's extension model:

- Add or replace an agent with a Markdown prompt, a model tier, a bounded Zod
output schema, and the minimum tools it needs.
- Add a claiming task for work attached to an issue or pull request.
- Define a GitHub task for work attached to an issue or pull request.
- Add a plain Workflows task for maintenance or other work without a GitHub
claim.
- Route new GitHub events, labels, or commands through the authenticated
Expand All @@ -53,29 +74,9 @@ publishing boundary are reusable building blocks. Start with the
[extension runbook](docs/runbooks/extend.md), which covers the required files,
tests, authority rules, deploys, and snapshot refreshes.

## Set up

Start by
[forking this repository](https://github.com/render-examples/software-factory/fork).
Deploy from your fork: its `factory.config.ts` defines the repositories the
factory may access.

The recommended setup is agent-guided:

1. Check the prerequisites in the [Quickstart](docs/quickstart.md).
2. Ask a coding agent to follow the
[Setup runbook](docs/runbooks/setup.md).
3. Complete the GitHub, secret, and paid-resource checkpoints when the agent
asks.

The runbook configures the fork before deployment, creates a GitHub App,
deploys the Render Blueprint, connects the webhook, verifies both services,
and runs one Triage smoke test.

## Security

- Model-directed code and tools run inside Sandboxes, not on the Workflows
host.
- Model-directed code and tools run inside Sandboxes, not on the Workflow task.
- GitHub write credentials never enter a Sandbox.
- Clone and fetch credentials are read-only and removed before repository code
or agents run.
Expand All @@ -93,9 +94,7 @@ code or untrusted pull requests.

## Limits

- Main tasks do not chain. Labels, comments, commands, and base-branch pushes
start work through the gateway.
- Verification runs shell commands; browser testing is not built in.
- Verification runs shell commands. Browser testing is not included.
- Turn limits and timeouts bound execution, but the factory has no hard dollar
budget.
- Anyone with `minimumPermission` can start model spend.
Expand Down
2 changes: 1 addition & 1 deletion app/agents/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class AgentError extends Error {
export interface RunAgentOptions {
/** Tools run only here, never in a Workflows task. */
sandbox: Sandbox;
/** Root task run of the claiming task; every event this run emits carries it. */
/** Root task run of the GitHub task; every event this run emits carries it. */
traceId: string;
runId?: string;
repo?: string;
Expand Down
72 changes: 53 additions & 19 deletions app/claims/claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,23 @@ export interface Claim {
}

/** How the work ended. `summary` is shown on GitHub, so it must be safe to publish. */
export interface Outcome {
export interface Outcome<Data = undefined> {
outcome: string;
summary: string;
state?: "done" | "failed";
links?: StatusLink[];
/** Typed data for a parent task. It is returned to Workflows, never rendered on GitHub. */
data?: Data;
}

/** What a claiming task returns to Render Workflows. */
export interface TaskResult {
/** What a GitHub task returns to Render Workflows. */
export interface TaskResult<Data = undefined> {
state: Exclude<RunState, "running">;
outcome: string;
summary: string;
traceId: string;
runId: string;
data?: Data;
}

/** An expected failure: its message is shown on GitHub and the task succeeds. Anything else fails the task. */
Expand All @@ -70,6 +73,8 @@ export interface ClaimOptions {
/** The task's name in the status comment, such as "PR Reviewer". */
title: string;
actor: string;
/** Root tasks may replace another root; subtasks may only continue their own root. */
mode?: "replace" | "continue";
gh?: GitHubClient;
runs?: RunControl;
}
Expand All @@ -79,18 +84,18 @@ export interface ClaimOptions {
* it. withClaim checks the actor's permission, stops the previous run and takes over the comment, keeps it
* current, and records how the run ended.
*/
export async function withClaim(
export async function withClaim<Data = undefined>(
ctx: Pick<TaskContext, "metadata">,
opts: ClaimOptions,
work: (claim: Claim) => Promise<Outcome>,
): Promise<TaskResult> {
work: (claim: Claim) => Promise<Outcome<Data>>,
): Promise<TaskResult<Data>> {
const run = new ClaimedRun(ctx, opts);
try {
if (!(await run.authorized())) return await run.reject();
if (!(await run.authorized())) return await run.reject<Data>();
await run.acquire();
return await run.finish(await work(run));
} catch (error) {
return await run.fail(error);
return await run.fail<Data>(error);
}
}

Expand Down Expand Up @@ -153,30 +158,41 @@ class ClaimedRun implements Claim {
}

/** A rejected trigger never held the claim; setStatus keeps its record from replacing a real run's. */
async reject(): Promise<TaskResult> {
async reject<Data>(): Promise<TaskResult<Data>> {
const summary = `@${this.#opts.actor} needs \`${config.minimumPermission}\` permission on this repository to start it.`;
await finishStatus(this.#status("rejected", summary));
await finishStatus(this.#status("rejected", summary), { rootFinished: this.#opts.mode !== "continue" });
return this.#record("rejected", "permission_denied", summary);
}

/** Stops the previous run and rewrites the status comment to name this one, then confirms no other run's write landed after it. */
async acquire(): Promise<void> {
const { task, title, runs } = this.#opts;
await takeOver({ gh: this.gh, repo: this.repo.fullName, subject: this.subject, task, title, runId: this.runId, runs });
const acquired = await takeOver({
gh: this.gh,
repo: this.repo.fullName,
subject: this.subject,
task,
title,
traceId: this.traceId,
runId: this.runId,
mode: this.#opts.mode,
runs,
});
if (!acquired.acquired) throw new Superseded();
await this.checkpoint();
}

async finish(outcome: Outcome): Promise<TaskResult> {
async finish<Data>(outcome: Outcome<Data>): Promise<TaskResult<Data>> {
const state = outcome.state ?? "done";
if (!(await this.#publish(state, outcome.summary, outcome.links))) throw new Superseded();
return this.#record(state, outcome.outcome, outcome.summary);
return this.#record(state, outcome.outcome, outcome.summary, {}, outcome.data);
}

/**
* A superseded run and an expected failure end the task normally. Anything else is reported on GitHub without
* its message, which can hold secrets or repository output, and rethrown so the task fails.
*/
async fail(error: unknown): Promise<TaskResult> {
async fail<Data>(error: unknown): Promise<TaskResult<Data>> {
if (error instanceof Superseded) return this.#record("cancelled", "superseded", "a newer run took over.");
if (error instanceof ExpectedFailure) {
await this.#publish("failed", error.message);
Expand All @@ -196,23 +212,41 @@ class ClaimedRun implements Claim {
}

async #holdsClaim(): Promise<boolean> {
return (await readStatus(this.gh, this.subject.number))?.data.runId === this.runId;
const status = (await readStatus(this.gh, this.subject.number))?.data;
return status !== undefined && (status.traceId ?? status.runId) === this.traceId;
}

#status<S extends StatusUpdate["state"]>(state: S, summary: string, links?: StatusLink[]): StatusUpdate & { state: S } {
const { task, title } = this.#opts;
return { gh: this.gh, number: this.subject.number, runId: this.runId, task, title, state, summary, links, sandboxId: this.#sandboxId };
return {
gh: this.gh,
number: this.subject.number,
traceId: this.traceId,
runId: this.runId,
task,
title,
state,
summary,
links,
sandboxId: this.#sandboxId,
};
}

/** Writes a final state while this run still holds the claim; false once another run's status is showing. */
async #publish(state: TaskResult["state"], summary: string, links?: StatusLink[]): Promise<boolean> {
if (!(await this.#holdsClaim())) return false;
return await finishStatus(this.#status(state, summary, links));
return await finishStatus(this.#status(state, summary, links), { rootFinished: this.#opts.mode !== "continue" });
}

#record(state: TaskResult["state"], outcome: string, summary: string, extra: Record<string, unknown> = {}): TaskResult {
#record<Data = undefined>(
state: TaskResult["state"],
outcome: string,
summary: string,
extra: Record<string, unknown> = {},
data?: Data,
): TaskResult<Data> {
const durationMs = Date.now() - this.#startedAt;
emit({ ...this.#event, type: "run.finished", payload: { stage: this.#opts.task, state, outcome, durationMs, ...extra } });
return { state, outcome, summary, traceId: this.traceId, runId: this.runId };
return { state, outcome, summary, traceId: this.traceId, runId: this.runId, ...(data === undefined ? {} : { data }) };
}
}
Loading
Loading