Public: anyone can read this; the crew can change it.
A preview of the forge runs a live crew against a copy of production's data #407
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/297-a-preview-of-the-forge-runs-a-live-crew → main 1 commit fork
6 files
changed +76
−3
README.md +5
| @@ -266,6 +266,11 @@ no `claude` on `PATH` — either way **Pick for me** stops being offered and | ||
| 266 | 266 | |
| 267 | 267 | ## The crew |
| 268 | 268 | |
| 269 | In a preview (`FORGE_APP_ENV=preview`, which the platform sets) the crew is | |
| 270 | off: no autonomy tick, no dispatch, no spend; checks still run and everything | |
| 271 | can still be read. The boot log and the studio's crew card say so. Set | |
| 272 | `FORGE_CREW_IN_PREVIEW=1` to run it anyway. | |
| 273 | ||
| 269 | 274 | An **agent** is an account of kind `agent`: no password, no session, and it |
| 270 | 275 | acts only through a token the forge mints for a run and destroys after it. |
| 271 | 276 | An admin creates one (**Settings → Crew**, or `POST /api/v1/agents`) with a |
src/crew/autonomy.ts +2
| @@ -78,6 +78,7 @@ import { grantedAccounts } from "../core/subscriptions.ts"; | ||
| 78 | 78 | import { standing } from "../core/standing.ts"; |
| 79 | 79 | import { pace, type Pace } from "./pacing.ts"; |
| 80 | 80 | import { createLog } from "../kernel/log.ts"; |
| 81 | import { crewOffInPreview } from "./preview.ts"; | |
| 81 | 82 | |
| 82 | 83 | const log = createLog("autonomy"); |
| 83 | 84 | |
| @@ -650,6 +651,7 @@ function ensureDirectionIssue( | ||
| 650 | 651 | |
| 651 | 652 | /** Every repository that runs on its own, one turn each. */ |
| 652 | 653 | export async function autonomyTick(app: App, now = new Date()): Promise<void> { |
| 654 | if (crewOffInPreview()) return; | |
| 653 | 655 | for (const repo of listAutonomousRepos(app.db)) { |
| 654 | 656 | try { |
| 655 | 657 | await sweepGreen(app, repo); |
src/crew/preview.ts added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | /** | |
| 2 | * Is the crew held off here. A preview is a throwaway copy of production's | |
| 3 | * data; an agent run there is real spend and a real push to a copy nobody | |
| 4 | * watches. The platform says which environment this is in `FORGE_APP_ENV` | |
| 5 | * (never the hostname); only the word `preview` holds the crew off, and | |
| 6 | * `FORGE_CREW_IN_PREVIEW=1` turns it back on for a preview of a crew change. | |
| 7 | */ | |
| 8 | ||
| 9 | export const CREW_OFF_IN_PREVIEW = "the crew is off in a preview"; | |
| 10 | ||
| 11 | export function crewOffInPreview( | |
| 12 | env: Record<string, string | undefined> = process.env, | |
| 13 | ): boolean { | |
| 14 | return ( | |
| 15 | (env.FORGE_APP_ENV ?? "").trim().toLowerCase() === "preview" && | |
| 16 | (env.FORGE_CREW_IN_PREVIEW ?? "").trim() !== "1" | |
| 17 | ); | |
| 18 | } |
src/main.ts +7 −1
| @@ -24,6 +24,7 @@ import { CHECK_KINDS, failAbandonedRuns } from "./core/runs.ts"; | ||
| 24 | 24 | import { health } from "./http/health.ts"; |
| 25 | 25 | import { STALE_AFTER_MS } from "./runs/lease.ts"; |
| 26 | 26 | import { startCrew } from "./crew/dispatch.ts"; |
| 27 | import { CREW_OFF_IN_PREVIEW, crewOffInPreview } from "./crew/preview.ts"; | |
| 27 | 28 | import { typeSafeDecider } from "./crew/decide.ts"; |
| 28 | 29 | import { graphInstagram } from "./crew/instagram.ts"; |
| 29 | 30 | import { startNotifier } from "./crew/notify.ts"; |
| @@ -178,7 +179,12 @@ const snapshots = startSnapshots( | ||
| 178 | 179 | passphrase: process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined, |
| 179 | 180 | }, |
| 180 | 181 | ); |
| 181 | const crew = appsOn | |
| 182 | const crewHeldOff = crewOffInPreview(); | |
| 183 | if (appsOn && crewHeldOff) | |
| 184 | app.log.info(CREW_OFF_IN_PREVIEW, { | |
| 185 | why: "FORGE_APP_ENV=preview; set FORGE_CREW_IN_PREVIEW=1 to run it", | |
| 186 | }); | |
| 187 | const crew = appsOn && !crewHeldOff | |
| 182 | 188 | ? await startCrew(app, { |
| 183 | 189 | forgeUrl, |
| 184 | 190 | localUrl: servers.url, |
src/web/studio/crew.ts +7 −2
| @@ -21,6 +21,7 @@ import { CREW_ROLES, listCrewRoles, type CrewRole } from "../../core/agents.ts"; | ||
| 21 | 21 | import type { AppRecord } from "../../core/apps.ts"; |
| 22 | 22 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 23 | 23 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 24 | import { crewOffInPreview } from "../../crew/preview.ts"; | |
| 24 | 25 | import type { Ctx } from "../../http/context.ts"; |
| 25 | 26 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 26 | 27 | import { html, raw, type Html } from "../html.ts"; |
| @@ -169,8 +170,12 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | ||
| 169 | 170 | |
| 170 | 171 | function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { |
| 171 | 172 | const status = repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo); |
| 172 | const state = | |
| 173 | status === undefined | |
| 173 | const state = crewOffInPreview() | |
| 174 | ? html`<p class="muted small"> | |
| 175 | The crew is off in a preview: nothing here plans, picks or runs an | |
| 176 | agent. Set <code>FORGE_CREW_IN_PREVIEW=1</code> to turn it on. | |
| 177 | </p>` | |
| 178 | : status === undefined | |
| 174 | 179 | ? html`<p class="muted small"> |
| 175 | 180 | Off. Every run starts because a person assigned, commented, or a |
| 176 | 181 | check failed. |
test/autonomy.test.ts +37
| @@ -606,3 +606,40 @@ describe("the slot count is the rule the loop enforces", () => { | ||
| 606 | 606 | expect(await driveRepo(app, fresh())).toBe("full"); |
| 607 | 607 | }); |
| 608 | 608 | }); |
| 609 | ||
| 610 | describe("the crew in a preview", () => { | |
| 611 | test("crewOffInPreview holds off only for preview, unless turned back on", async () => { | |
| 612 | const { crewOffInPreview } = await import("../src/crew/preview.ts"); | |
| 613 | expect(crewOffInPreview({ FORGE_APP_ENV: "preview" })).toBe(true); | |
| 614 | expect(crewOffInPreview({ FORGE_APP_ENV: "production" })).toBe(false); | |
| 615 | expect(crewOffInPreview({})).toBe(false); | |
| 616 | expect( | |
| 617 | crewOffInPreview({ FORGE_APP_ENV: "preview", FORGE_CREW_IN_PREVIEW: "1" }), | |
| 618 | ).toBe(false); | |
| 619 | }); | |
| 620 | ||
| 621 | test("an autonomy tick is a no-op in a preview", async () => { | |
| 622 | const { autonomyTick } = await import("../src/crew/autonomy.ts"); | |
| 623 | const dir = await mkdtemp(join(tmpdir(), "forge-prev-")); | |
| 624 | const app = await createApp({ dataDir: dir } as Parameters<typeof createApp>[0]); | |
| 625 | let touched = 0; | |
| 626 | const real = app.db.query.bind(app.db); | |
| 627 | (app.db as { query: unknown }).query = (...a: unknown[]) => { | |
| 628 | touched++; | |
| 629 | return (real as (...x: unknown[]) => unknown)(...a); | |
| 630 | }; | |
| 631 | const before = process.env.FORGE_APP_ENV; | |
| 632 | process.env.FORGE_APP_ENV = "preview"; | |
| 633 | try { | |
| 634 | await autonomyTick(app); | |
| 635 | expect(touched).toBe(0); | |
| 636 | delete process.env.FORGE_APP_ENV; | |
| 637 | await autonomyTick(app); | |
| 638 | expect(touched).toBeGreaterThan(0); | |
| 639 | } finally { | |
| 640 | if (before === undefined) delete process.env.FORGE_APP_ENV; | |
| 641 | else process.env.FORGE_APP_ENV = before; | |
| 642 | await rm(dir, { recursive: true, force: true }); | |
| 643 | } | |
| 644 | }); | |
| 645 | }); |
new above
offline