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
6 changes: 6 additions & 0 deletions apps/docs/docs/concepts/agents-as-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ are explicit, and at least one trigger is required.
The Markdown body is the agent prompt. The frontmatter is execution configuration. Keeping both in
one reviewed file makes a model change, schedule change, or prompt change visible in Git history.

Discovery uses the primary repository's optional project `developmentBranch`, with the connected
GitHub default as fallback. The same source supplies skills and `.facility.yml`, new story bases,
and configuration PR bases. See the [development source contract](../reference/project-manifest.md#development-source).
Changing this setting does not replace queued/running turns' captured agent manifests or move
existing story branches.

Supported triggers are:

- `manual`, used by trusted internal or direct API dispatch;
Expand Down
30 changes: 29 additions & 1 deletion apps/docs/docs/reference/project-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,38 @@ title: Project manifest
# `.facility.yml` reference

The primary repository's `.facility.yml` is the complete workspace environment contract. Facility
loads it from the repository default branch when it starts or prepares a story. The schema is
loads it from the project's selected development branch when it starts or prepares a story, falling
back to the GitHub default branch when no override is set. The schema is
strict: unknown keys, duplicate YAML keys, aliases, invalid names, and wrong value types fail the
operation.

## Development source

In project settings, select an existing primary-repository branch, or leave the field empty to
use the connected GitHub default. The API accepts `PATCH /v1/projects/:projectId` with
`{ "developmentBranch": "develop" }`; use `null` to clear it. Updating this preference requires
both `projects:write` and `repos:write`. Facility checks the branch using the project's active,
organization-scoped GitHub App installation before saving. Missing or malformed branches are
rejected, and unavailable or suspended installations cannot validate changes. Other project
settings are preserved by this narrow update.

The option is stored as `settings.developmentBranch`, outside this manifest: Facility must choose
a ref before reading `.facility.yml`. It does not change GitHub's default branch or reinterpret
legacy `default_branch` settings. Related repositories continue to use their own defaults.

Agents, skills, the workspace manifest, new story branches, and agent-edit/kickstart configuration
PRs all use the selected source. Proposal branches for an override are scoped to that source,
so changing it does not rewrite a configuration PR opened against the previous default. GitHub's default and the selected development branch are protected
from direct configuration writes and story-branch adoption. Existing story branches and worktrees
are retained when the setting or branch tip changes; no reset or automatic rebase occurs. Queued
and running turns retain their agent-manifest snapshots. Subsequent catalog refreshes and workspace
preparations read the current selected source. A branch removed after selection fails source
refresh/preparation rather than silently reading another branch.

After saving, refresh the catalog and verify its source commit. A source change is not a deployment,
merge, workspace wake, or approval to modify production. GitHub protections and human PR review
remain the merge boundary.

## Complete example

```yaml
Expand Down
20 changes: 20 additions & 0 deletions apps/web/app/(app)/projects/[projectId]/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Divider, Eyebrow, PillTag } from "@facility/ui";
import Link from "next/link";
import { ErrorNotice, Offline } from "@/components/offline";
import { DevelopmentBranch } from "@/components/project/development-branch";
import { DisconnectRepository } from "@/components/project/disconnect-repository";
import { NativePreviews } from "@/components/project/native-previews";
import { WorkspaceVariables } from "@/components/story/workspace-variables";
Expand Down Expand Up @@ -41,6 +42,25 @@ export default async function ProjectSettingsPage({
</p>
</div>

{repos.ok && repos.data.find((repo) => repo.role === "primary") && (
<DevelopmentBranch
key={`${projectId}-${String(project.data.settings.developmentBranch ?? "")}`}
projectId={projectId}
branch={
typeof project.data.settings.developmentBranch === "string"
? project.data.settings.developmentBranch
: null
}
defaultBranch={
repos.data.find((repo) => repo.role === "primary")?.defaultBranch ?? "main"
}
canWrite={
can(me.ok ? me.data.permissions : [], "projects:write") &&
can(me.ok ? me.data.permissions : [], "repos:write")
}
/>
)}

<WorkspaceVariables
projectId={projectId}
canExecute={can(me.ok ? me.data.permissions : [], "workspaces:execute")}
Expand Down
103 changes: 103 additions & 0 deletions apps/web/components/project/development-branch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { Button, Eyebrow } from "@facility/ui";
import { useRouter } from "next/navigation";
import { useId, useState } from "react";
import { clientApi } from "@/lib/client-api";

export function DevelopmentBranch({
projectId,
branch,
defaultBranch,
canWrite,
}: {
projectId: string;
branch: string | null;
defaultBranch: string;
canWrite: boolean;
}) {
const router = useRouter();
const id = useId();
const [value, setValue] = useState(branch ?? "");
const [saved, setSaved] = useState(branch ?? "");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
async function save() {
setPending(true);
setError("");
setMessage("");
const developmentBranch = value.trim() || null;
const result = await clientApi<{ settings: { developmentBranch?: string | null } }>(
"PATCH",
`/v1/projects/${encodeURIComponent(projectId)}`,
{
developmentBranch,
idempotency_key: `ui-development-branch-${crypto.randomUUID()}`,
},
);
setPending(false);
if (!result.ok) {
setError(result.message);
return;
}
if (
!result.data?.settings ||
!("developmentBranch" in result.data.settings) ||
result.data.settings.developmentBranch !== developmentBranch
) {
setError(
"The server did not confirm this setting. Refresh after the API deployment completes.",
);
return;
}
setValue(developmentBranch ?? "");
setSaved(developmentBranch ?? "");
setMessage(`Development source saved: ${developmentBranch ?? defaultBranch}.`);
router.refresh();
}
return (
<section className="flex flex-col gap-3">
<Eyebrow>development branch</Eyebrow>
<p className="text-sm text-(--mut)">
Read agents, skills and .facility.yml from this branch of the primary repository. New
stories and configuration pull requests use the same base. Existing story branches and
queued or running turns keep their work.
</p>
<p className="text-sm text-(--mut)">
Leave empty to use the GitHub default: <code>{defaultBranch}</code>.
</p>
<label htmlFor={id} className="text-sm">
Branch name
</label>
<input
id={id}
value={value}
placeholder={defaultBranch}
disabled={!canWrite || pending}
className="border border-(--line) bg-transparent px-3 py-2 font-mono text-sm"
onChange={(event) => {
setValue(event.target.value);
setMessage("");
}}
/>
{canWrite && (
<div>
<Button size="sm" disabled={pending || value.trim() === saved} onClick={save}>
{pending ? "saving…" : "save development branch"}
</Button>
</div>
)}
{error && (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
)}
{message && (
<p role="status" className="text-sm">
{message}
</p>
)}
</section>
);
}
118 changes: 118 additions & 0 deletions apps/web/test/development-branch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { DevelopmentBranch } from "../components/project/development-branch";

const refresh = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) }));
let container: HTMLDivElement, root: Root;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
refresh.mockClear();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
async function render(props = {}) {
await act(async () =>
root.render(
<DevelopmentBranch
projectId="proj_test"
branch={null}
defaultBranch="main"
canWrite={true}
{...props}
/>,
),
);
}
const input = () => container.querySelector("input") as HTMLInputElement;
const button = () => container.querySelector("button") as HTMLButtonElement;
async function change(value: string) {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input(), value);
input().dispatchEvent(new Event("input", { bubbles: true }));
});
}
it("shows fallback and sends only a scoped preference on save", async () => {
const fetch = vi
.fn()
.mockResolvedValue(Response.json({ settings: { developmentBranch: "develop" } }));
vi.stubGlobal("fetch", fetch);
await render();
expect(container.textContent).toContain("GitHub default: main");
expect(button().disabled).toBe(true);
await change("develop");
expect(fetch).not.toHaveBeenCalled();
await act(async () => button().click());
expect(JSON.parse(fetch.mock.calls[0]?.[1].body)).toEqual({
developmentBranch: "develop",
idempotency_key: expect.stringMatching(/^ui-development-branch-/),
});
expect(fetch).toHaveBeenCalledWith(
"/api/v1/projects/proj_test",
expect.objectContaining({ method: "PATCH" }),
);
expect(container.querySelector("[role=status]")?.textContent).toContain("saved: develop");
expect(refresh).toHaveBeenCalledOnce();
});
it("clears the override and requires server confirmation", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(Response.json({ settings: {} }))
.mockResolvedValueOnce(Response.json({ settings: { developmentBranch: null } }));
vi.stubGlobal("fetch", fetch);
await render({ branch: "develop" });
await change("");
await act(async () => button().click());
expect(container.querySelector("[role=alert]")?.textContent).toContain("did not confirm");
expect(refresh).not.toHaveBeenCalled();
await act(async () => button().click());
expect(JSON.parse(fetch.mock.calls[1]?.[1].body).developmentBranch).toBeNull();
expect(container.querySelector("[role=status]")?.textContent).toContain("saved: main");
});
it("shows denied or unavailable updates without claiming success", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
Response.json({ error: { message: "Branch unavailable" } }, { status: 503 }),
),
);
await render();
await change("missing");
await act(async () => button().click());
expect(container.querySelector("[role=alert]")?.textContent).toBe("Branch unavailable");
expect(container.querySelector("[role=status]")).toBeNull();
expect(refresh).not.toHaveBeenCalled();
});
it("disables editing without permission", async () => {
await render({ canWrite: false, branch: "develop" });
expect(input().disabled).toBe(true);
expect(button()).toBeNull();
});
it("disables controls while branch validation is pending", async () => {
let complete: (response: Response) => void = () => undefined;
vi.stubGlobal(
"fetch",
vi.fn().mockReturnValue(
new Promise<Response>((resolve) => {
complete = resolve;
}),
),
);
await render();
await change("develop");
await act(async () => button().click());
expect(input().disabled).toBe(true);
expect(button().textContent).toBe("saving…");
await act(async () => complete(Response.json({ settings: { developmentBranch: "develop" } })));
expect(input().disabled).toBe(false);
});
12 changes: 12 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3966,6 +3966,10 @@
"properties": {
"nativePreviewsEnabled": {
"type": "boolean"
},
"developmentBranch": {
"nullable": true,
"type": "string"
}
},
"additionalProperties": {}
Expand Down Expand Up @@ -4390,12 +4394,20 @@
"properties": {
"nativePreviewsEnabled": {
"type": "boolean"
},
"developmentBranch": {
"nullable": true,
"type": "string"
}
},
"additionalProperties": {}
},
"nativePreviewsEnabled": {
"type": "boolean"
},
"developmentBranch": {
"nullable": true,
"type": "string"
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3707,6 +3707,7 @@ export interface operations {
description?: string;
settings?: {
nativePreviewsEnabled?: boolean;
developmentBranch?: string | null;
} & {
[key: string]: unknown;
};
Expand Down Expand Up @@ -4054,10 +4055,12 @@ export interface operations {
description?: string | null;
settings?: {
nativePreviewsEnabled?: boolean;
developmentBranch?: string | null;
} & {
[key: string]: unknown;
};
nativePreviewsEnabled?: boolean;
developmentBranch?: string | null;
};
};
};
Expand Down
2 changes: 1 addition & 1 deletion services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"build:runtime": "tsup src/index.ts src/dev.ts src/start.ts src/worker.ts src/openapi.ts --format esm",
"typecheck": "tsc --noEmit",
"test": "vitest run --fileParallelism=false",
"test:e2e-workspace": "vitest run --fileParallelism=false test/workspace-runtime.integration.test.ts test/facility-012.e2e.integration.test.ts",
"test:e2e-workspace": "vitest run --fileParallelism=false test/workspace-runtime.integration.test.ts test/facility-012.e2e.integration.test.ts test/development-branch-workspace.integration.test.ts",
"dev": "tsx watch src/dev.ts",
"dev:worker": "tsx watch src/worker.ts",
"start": "node dist/start.js",
Expand Down
Loading
Loading