From de6a2c58aeffbc89b4fb4bfc67c004005362b3c5 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 3 Aug 2026 19:59:42 -0500 Subject: [PATCH 1/8] Let a ship refuse to delete work no remote has Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-ship/src/api/workspaces.ts | 12 +- packages/fleet-ship/src/workspace-manager.ts | 42 ++++- packages/fleet-ship/tests/api.test.ts | 17 ++ .../tests/workspace-remove-force.test.ts | 158 ++++++++++++++++++ 4 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 packages/fleet-ship/tests/workspace-remove-force.test.ts diff --git a/packages/fleet-ship/src/api/workspaces.ts b/packages/fleet-ship/src/api/workspaces.ts index a0c7f45..744f4d2 100644 --- a/packages/fleet-ship/src/api/workspaces.ts +++ b/packages/fleet-ship/src/api/workspaces.ts @@ -183,10 +183,14 @@ export function workspacesPlugin( await manager.deactivate(params.repo, params.name); return { ok: true as const }; }) - .delete("/workspaces/:repo/:name", async ({ params }) => { - await manager.remove(params.repo, params.name); - return { ok: true as const }; - }) + .delete( + "/workspaces/:repo/:name", + async ({ params, query }) => { + await manager.remove(params.repo, params.name, { force: query.force }); + return { ok: true as const }; + }, + { query: t.Object({ force: t.Optional(t.Boolean()) }) }, + ) .ws("/workspaces/:repo/:name/terminal", { query: t.Object({ takeover: t.Optional(t.Boolean()), diff --git a/packages/fleet-ship/src/workspace-manager.ts b/packages/fleet-ship/src/workspace-manager.ts index df20ace..47daf12 100644 --- a/packages/fleet-ship/src/workspace-manager.ts +++ b/packages/fleet-ship/src/workspace-manager.ts @@ -46,6 +46,11 @@ export interface SwitchBranchOptions { readonly branch: string; } +export interface RemoveOptions { + /** With `false`, refuse to remove a workspace holding work no remote has. */ + readonly force?: boolean; +} + export interface InitAgentOptions { readonly model: string; readonly provider: string; @@ -98,6 +103,31 @@ async function withDestinationRollback(dir: string, work: () => Promise): } } +async function unrecoverableWork(git: Git): Promise { + const held: string[] = []; + + const status = await git.status(); + if (!status.clean) held.push(plural(status.files.length, "uncommitted file")); + + const unpushed = await git.command.tryRun(["log", "--branches", "--not", "--remotes", "--format=%H"]); + if (unpushed.exitCode !== 0) { + held.push(`unpushed commits could not be counted: ${unpushed.stderr.trim() || "git failed"}`); + } else { + const commits = unpushed.stdout.split("\n").filter((line) => line.length > 0).length; + if (commits > 0) held.push(`${plural(commits, "commit")} not on any remote`); + } + + if ((await git.command.tryRun(["rev-parse", "--verify", "--quiet", "refs/stash"])).exitCode === 0) { + held.push("a stash"); + } + + return held; +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + /** * Clone `url` into `dir` and put the workspace on `branch`, a name the remote did not * advertise as either a branch or a tag. The clone's own refs decide whether `branch` @@ -476,7 +506,7 @@ export class WorkspaceManager { this.emit({ type: "workspace.deactivated", ...this.stamp(), workspace }); } - async remove(repoName: string, name: string): Promise { + async remove(repoName: string, name: string, options: RemoveOptions = {}): Promise { const dir = await this.requireWorkspace(repoName, name); // Capture the branch before deleting the directory so the `removed` event can @@ -484,6 +514,16 @@ export class WorkspaceManager { const git = new Git({ cwd: dir }); const branch = await git.currentBranch().catch(() => ""); + if (options.force === false) { + const held = await unrecoverableWork(git); + if (held.length > 0) { + throw new WorkspaceError( + `workspace ${repoName}/${name} holds work that is not on a remote: ${held.join("; ")}`, + 409, + ); + } + } + const sessionName = this.sessionName(repoName, name); if (await this.tmux.hasSession(sessionName)) { await this.tmux.session(sessionName).kill(); diff --git a/packages/fleet-ship/tests/api.test.ts b/packages/fleet-ship/tests/api.test.ts index 33c0d95..c76814d 100644 --- a/packages/fleet-ship/tests/api.test.ts +++ b/packages/fleet-ship/tests/api.test.ts @@ -151,6 +151,23 @@ describe("ship API", () => { }); expect(await call("DELETE", "/workspaces/r/n")).toEqual({ status: 200, body: { ok: true } }); + const removals: unknown[] = []; + const removing = makeApp({ + remove: async (_r: string, _n: string, options: unknown) => { + removals.push(options); + }, + }); + await removing("DELETE", "/workspaces/r/n"); + await removing("DELETE", "/workspaces/r/n?force=false"); + expect(removals).toEqual([{ force: undefined }, { force: false }]); + + const held = makeApp({ + remove: async () => { + throw new WorkspaceError("workspace r/n holds work that is not on a remote: a stash", 409); + }, + }); + expect((await held("DELETE", "/workspaces/r/n?force=false")).status).toBe(409); + const badActivate = makeApp({ activate: async () => { throw new WorkspaceError("workspace already active: r/n", 400); diff --git a/packages/fleet-ship/tests/workspace-remove-force.test.ts b/packages/fleet-ship/tests/workspace-remove-force.test.ts new file mode 100644 index 0000000..bd92ca2 --- /dev/null +++ b/packages/fleet-ship/tests/workspace-remove-force.test.ts @@ -0,0 +1,158 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Git } from "git-bun"; +import { WorkspaceManager, type WorkspaceTmux } from "../src/workspace-manager"; + +const gitAvailable = await (async () => { + try { + return (await Bun.$`git --version`.quiet().nothrow()).exitCode === 0; + } catch { + return false; + } +})(); + +const suite = gitAvailable ? describe : describe.skip; +if (!gitAvailable) console.warn("git not found on PATH — skipping non-forcing removal tests"); + +const noTmux: WorkspaceTmux = { + hasSession: async () => false, + newSession: async () => {}, + session: () => ({ kill: async () => {} }), +}; + +suite("WorkspaceManager.remove with force: false", () => { + let fleetDirectory: string; + let manager: WorkspaceManager; + let sourceRepo: string; + + const workspace = async (name: string): Promise => { + await manager.create({ url: sourceRepo, repoName: "repo", name, branch: "main" }); + const git = new Git({ cwd: manager.workspaceDir("repo", name) }); + await git.setConfig("user.email", "test@example.com"); + await git.setConfig("user.name", "Test"); + return git; + }; + + beforeAll(async () => { + fleetDirectory = await mkdtemp(join(tmpdir(), "fleet-ship-force-fleet-")); + manager = new WorkspaceManager({ fleetDirectory, port: 4700, name: "test-ship" }, noTmux); + + sourceRepo = await mkdtemp(join(tmpdir(), "fleet-ship-force-source-")); + const git = await Git.init(sourceRepo, { initialBranch: "main" }); + await Bun.write(join(sourceRepo, "README.md"), "hello\n"); + await git.add(); + await git.setConfig("user.email", "test@example.com"); + await git.setConfig("user.name", "Test"); + await git.commit("initial commit"); + }); + + afterAll(async () => { + await rm(fleetDirectory, { recursive: true, force: true }); + await rm(sourceRepo, { recursive: true, force: true }); + }); + + test("removes a workspace whose work is all on the remote", async () => { + await workspace("clean"); + + await manager.remove("repo", "clean", { force: false }); + + expect(await manager.has("repo", "clean")).toBe(false); + }); + + test("refuses a workspace with uncommitted changes", async () => { + await workspace("dirty"); + await Bun.write(join(manager.workspaceDir("repo", "dirty"), "README.md"), "changed\n"); + + await expect(manager.remove("repo", "dirty", { force: false })).rejects.toMatchObject({ + status: 409, + message: expect.stringContaining("1 uncommitted file"), + }); + expect(await manager.has("repo", "dirty")).toBe(true); + }); + + test("refuses a workspace holding only untracked files", async () => { + await workspace("untracked"); + await Bun.write(join(manager.workspaceDir("repo", "untracked"), "notes.md"), "wip\n"); + + await expect(manager.remove("repo", "untracked", { force: false })).rejects.toMatchObject({ + status: 409, + message: expect.stringContaining("1 uncommitted file"), + }); + expect(await manager.has("repo", "untracked")).toBe(true); + }); + + test("refuses a commit that no remote has", async () => { + const git = await workspace("ahead"); + await Bun.write(join(manager.workspaceDir("repo", "ahead"), "README.md"), "local\n"); + await git.add(); + await git.commit("local work"); + + await expect(manager.remove("repo", "ahead", { force: false })).rejects.toMatchObject({ + status: 409, + message: expect.stringContaining("1 commit not on any remote"), + }); + expect(await manager.has("repo", "ahead")).toBe(true); + }); + + test("refuses a commit on a branch that is not checked out", async () => { + const git = await workspace("side-branch"); + await git.switchBranch("side", { create: true }); + await Bun.write(join(manager.workspaceDir("repo", "side-branch"), "side.md"), "side\n"); + await git.add(); + await git.commit("side work"); + await git.switchBranch("main"); + + expect((await git.status()).ahead).toBe(0); + await expect(manager.remove("repo", "side-branch", { force: false })).rejects.toMatchObject({ + status: 409, + message: expect.stringContaining("1 commit not on any remote"), + }); + expect(await manager.has("repo", "side-branch")).toBe(true); + }); + + test("refuses a workspace with a stash", async () => { + const git = await workspace("stashed"); + await Bun.write(join(manager.workspaceDir("repo", "stashed"), "README.md"), "stashed\n"); + await git.command.run(["stash", "push", "-m", "wip"]); + + expect((await git.status()).clean).toBe(true); + await expect(manager.remove("repo", "stashed", { force: false })).rejects.toMatchObject({ + status: 409, + message: expect.stringContaining("a stash"), + }); + expect(await manager.has("repo", "stashed")).toBe(true); + }); + + test("reports every kind of held work at once", async () => { + const git = await workspace("everything"); + await Bun.write(join(manager.workspaceDir("repo", "everything"), "README.md"), "committed\n"); + await git.add(); + await git.commit("local work"); + await Bun.write(join(manager.workspaceDir("repo", "everything"), "extra.md"), "extra\n"); + + await expect(manager.remove("repo", "everything", { force: false })).rejects.toMatchObject({ + status: 409, + message: expect.stringContaining("1 uncommitted file; 1 commit not on any remote"), + }); + }); + + test("removes held work when forced, and when force is not asked about", async () => { + const forced = await workspace("forced"); + await Bun.write(join(manager.workspaceDir("repo", "forced"), "README.md"), "gone\n"); + await forced.add(); + await forced.commit("local work"); + + await manager.remove("repo", "forced", { force: true }); + expect(await manager.has("repo", "forced")).toBe(false); + + const defaulted = await workspace("defaulted"); + await Bun.write(join(manager.workspaceDir("repo", "defaulted"), "README.md"), "gone\n"); + await defaulted.add(); + await defaulted.commit("local work"); + + await manager.remove("repo", "defaulted"); + expect(await manager.has("repo", "defaulted")).toBe(false); + }); +}); From ed9f70cc71006106968772531353492f26944b4c Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 3 Aug 2026 20:02:58 -0500 Subject: [PATCH 2/8] Persist ephemeral workspaces in the bridge store Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-bridge/src/store/store.ts | 118 +++++++++++++++--- .../tests/store-validation.test.ts | 66 ++++++++++ packages/fleet-protocol/index.ts | 5 + packages/fleet-protocol/src/workspace.ts | 26 ++++ 4 files changed, 199 insertions(+), 16 deletions(-) diff --git a/packages/fleet-bridge/src/store/store.ts b/packages/fleet-bridge/src/store/store.ts index eb29bfe..c824065 100644 --- a/packages/fleet-bridge/src/store/store.ts +++ b/packages/fleet-bridge/src/store/store.ts @@ -1,11 +1,28 @@ import { lstat, open, rename, unlink } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; -import { FleetIdentifierSchema, RepoSchema, ShipSchema, type Repo, type Ship } from "fleet-protocol"; +import { + EphemeralWorkspaceSchema, + FleetIdentifierSchema, + RepoSchema, + ShipSchema, + type Repo, + type Ship, +} from "fleet-protocol"; import type { z } from "zod"; import { SerialQueue } from "../serial-queue"; +import { workspaceKey } from "../types"; type Persist = (target: string, contents: string) => Promise; +/** An ephemeral workspace as persisted: its public block plus where it lives. */ +export const EphemeralWorkspaceRecordSchema = EphemeralWorkspaceSchema.extend({ + repoName: FleetIdentifierSchema, + name: FleetIdentifierSchema, + ship: FleetIdentifierSchema, +}); + +export type EphemeralWorkspaceRecord = z.infer; + export class RepoAlreadyExistsError extends Error { constructor(readonly repoName: string) { super(`repo already registered: ${repoName}`); @@ -13,7 +30,35 @@ export class RepoAlreadyExistsError extends Error { } } -class JsonCollection { +interface Keying { + /** The map key an item is stored under. */ + readonly of: (item: T) => string; + /** The fields `of` reads, reapplied after an update so a merge cannot move a record. */ + readonly identity: (item: T) => Partial; + readonly validate: (key: string) => void; +} + +function namedKeying(): Keying { + return { + of: (item) => item.name, + identity: (item) => ({ name: item.name }) as Partial, + validate: (key) => { + FleetIdentifierSchema.parse(key); + }, + }; +} + +const ephemeralKeying: Keying = { + of: (record) => workspaceKey(record.repoName, record.name), + identity: (record) => ({ repoName: record.repoName, name: record.name }), + validate: (key) => { + const parts = key.split("/"); + if (parts.length !== 2) throw new Error(`not a workspace key: ${key}`); + for (const part of parts) FleetIdentifierSchema.parse(part); + }, +}; + +class JsonCollection { private map = new Map(); constructor( @@ -21,11 +66,12 @@ class JsonCollection { private readonly schema: z.ZodType, private readonly target: string, private readonly persist: Persist, + private readonly key: Keying, ) {} async read(): Promise> { const items = this.schema.array().parse(await readJsonArray(this.target)); - return new Map(items.map((item) => [item.name, item])); + return new Map(items.map((item) => [this.key.of(item), item])); } adopt(map: Map): void { @@ -36,41 +82,41 @@ class JsonCollection { return this.queue.run(() => [...this.map.values()]); } - get(name: string): Promise { - return this.queue.run(() => this.map.get(name)); + get(key: string): Promise { + return this.queue.run(() => this.map.get(key)); } put(item: T, guard?: (current: ReadonlyMap) => void): Promise { const parsed = this.schema.parse(item); return this.queue.run(async () => { guard?.(this.map); - const next = new Map(this.map).set(parsed.name, parsed); + const next = new Map(this.map).set(this.key.of(parsed), parsed); await this.write(next); this.map = next; return parsed; }); } - update(name: string, values: Partial>): Promise { - FleetIdentifierSchema.parse(name); + update(key: string, values: Partial): Promise { + this.key.validate(key); return this.queue.run(async () => { - const existing = this.map.get(name); + const existing = this.map.get(key); if (!existing) return undefined; - const updated = this.schema.parse({ ...existing, ...values, name }); - const next = new Map(this.map).set(name, updated); + const updated = this.schema.parse({ ...existing, ...values, ...this.key.identity(existing) }); + const next = new Map(this.map).set(key, updated); await this.write(next); this.map = next; return updated; }); } - delete(name: string): Promise { - FleetIdentifierSchema.parse(name); + delete(key: string): Promise { + this.key.validate(key); return this.queue.run(async () => { - const existing = this.map.get(name); + const existing = this.map.get(key); if (!existing) return undefined; const next = new Map(this.map); - next.delete(name); + next.delete(key); await this.write(next); this.map = next; return existing; @@ -80,7 +126,7 @@ class JsonCollection { replaceAll(items: T[]): Promise { const parsed = this.schema.array().parse(items); return this.queue.run(async () => { - const next = new Map(parsed.map((item) => [item.name, item])); + const next = new Map(parsed.map((item) => [this.key.of(item), item])); await this.write(next); this.map = next; }); @@ -96,6 +142,7 @@ export class Store { private readonly queue = new SerialQueue(); private readonly shipCollection: JsonCollection; private readonly repoCollection: JsonCollection; + private readonly ephemeralCollection: JsonCollection; constructor( dataDirectory: string, @@ -107,12 +154,21 @@ export class Store { ShipSchema, join(dataDirectory, "ships.json"), persist, + namedKeying(), ); this.repoCollection = new JsonCollection( this.queue, RepoSchema, join(dataDirectory, "repos.json"), persist, + namedKeying(), + ); + this.ephemeralCollection = new JsonCollection( + this.queue, + EphemeralWorkspaceRecordSchema, + join(dataDirectory, "ephemeral.json"), + persist, + ephemeralKeying, ); } @@ -121,8 +177,10 @@ export class Store { if (this.loaded) return; const ships = await this.shipCollection.read(); const repos = await this.repoCollection.read(); + const ephemeral = await this.ephemeralCollection.read(); this.shipCollection.adopt(ships); this.repoCollection.adopt(repos); + this.ephemeralCollection.adopt(ephemeral); this.loaded = true; }); } @@ -168,6 +226,34 @@ export class Store { async deleteRepo(name: string): Promise { return this.repoCollection.delete(name); } + + async getAllEphemeral(): Promise { + return this.ephemeralCollection.getAll(); + } + + async getEphemeral(repoName: string, name: string): Promise { + return this.ephemeralCollection.get(ephemeralKey(repoName, name)); + } + + async createEphemeral(record: EphemeralWorkspaceRecord): Promise { + return this.ephemeralCollection.put(record); + } + + async updateEphemeral( + repoName: string, + name: string, + values: Partial, + ): Promise { + return this.ephemeralCollection.update(ephemeralKey(repoName, name), values); + } + + async deleteEphemeral(repoName: string, name: string): Promise { + return this.ephemeralCollection.delete(ephemeralKey(repoName, name)); + } +} + +function ephemeralKey(repoName: string, name: string): string { + return workspaceKey(FleetIdentifierSchema.parse(repoName), FleetIdentifierSchema.parse(name)); } async function readJsonArray(target: string): Promise { diff --git a/packages/fleet-bridge/tests/store-validation.test.ts b/packages/fleet-bridge/tests/store-validation.test.ts index 749dac3..bb7ecb6 100644 --- a/packages/fleet-bridge/tests/store-validation.test.ts +++ b/packages/fleet-bridge/tests/store-validation.test.ts @@ -63,4 +63,70 @@ describe("Store validation", () => { expect(await store.getRepo("repo")).toEqual(updated); expect(await store.getRepo("injected")).toBeUndefined(); }); + + const record = { + repoName: "repo", + name: "ws", + ship: "ship", + issueNumber: 37, + branch: "37-add-ephemeral-workspaces", + cleanup: "watching" as const, + blockedReason: null, + blockedAt: null, + pullRequest: null, + }; + + test("ephemeral records round-trip through a reload, keyed by repo and workspace", async () => { + const directory = await mkdtemp(join(tmpdir(), "fleet-bridge-store-")); + directories.push(directory); + const store = new Store(directory); + await store.load(); + + await store.createEphemeral(record); + await store.createEphemeral({ ...record, repoName: "other", issueNumber: 12 }); + expect(await store.getEphemeral("repo", "ws")).toEqual(record); + + const reloaded = new Store(directory); + await reloaded.load(); + expect(await reloaded.getAllEphemeral()).toHaveLength(2); + expect(await reloaded.getEphemeral("other", "ws")).toMatchObject({ issueNumber: 12 }); + + expect(await reloaded.deleteEphemeral("repo", "ws")).toEqual(record); + expect(await reloaded.getEphemeral("repo", "ws")).toBeUndefined(); + expect(await reloaded.deleteEphemeral("repo", "ws")).toBeUndefined(); + }); + + test("updateEphemeral merges without moving the record", async () => { + const directory = await mkdtemp(join(tmpdir(), "fleet-bridge-store-")); + directories.push(directory); + const store = new Store(directory); + await store.load(); + await store.createEphemeral(record); + + const updated = await store.updateEphemeral("repo", "ws", { + repoName: "injected", + name: "injected", + cleanup: "blocked", + blockedReason: "a stash", + blockedAt: "2026-08-03T00:00:00.000Z", + } as never); + expect(updated).toMatchObject({ repoName: "repo", name: "ws", cleanup: "blocked", blockedReason: "a stash" }); + expect(await store.getEphemeral("injected", "injected")).toBeUndefined(); + expect(await store.updateEphemeral("repo", "gone", { cleanup: "blocked" })).toBeUndefined(); + }); + + test("rejects ephemeral records that are invalid on disk or at the boundary", async () => { + const directory = await mkdtemp(join(tmpdir(), "fleet-bridge-store-")); + directories.push(directory); + await Bun.write(join(directory, "ephemeral.json"), JSON.stringify([{ ...record, repoName: "../repo" }])); + await expect(new Store(directory).load()).rejects.toThrow(); + + const clean = await mkdtemp(join(tmpdir(), "fleet-bridge-store-")); + directories.push(clean); + const store = new Store(clean); + await store.load(); + await expect(store.createEphemeral({ ...record, issueNumber: 0 })).rejects.toThrow(); + await expect(store.createEphemeral({ ...record, blockedReason: "x".repeat(201) })).rejects.toThrow(); + await expect(store.getEphemeral("bad/repo", "ws")).rejects.toThrow(); + }); }); diff --git a/packages/fleet-protocol/index.ts b/packages/fleet-protocol/index.ts index 9de3274..4539d45 100644 --- a/packages/fleet-protocol/index.ts +++ b/packages/fleet-protocol/index.ts @@ -15,10 +15,15 @@ export { WorkspaceRefsSchema, AgentStatusSchema, CreateWorkspaceRequestSchema, + EphemeralWorkspaceSchema, AGENT_STATES, + EPHEMERAL_CLEANUP_STATES, + MAX_BLOCKED_REASON_LENGTH, type WorkspaceSummary, } from "./src/workspace"; export type { + EphemeralWorkspace, + EphemeralCleanupState, WorkspaceDiff, WorkspaceRefs, WorkspaceStatus, diff --git a/packages/fleet-protocol/src/workspace.ts b/packages/fleet-protocol/src/workspace.ts index 8a235e5..0432928 100644 --- a/packages/fleet-protocol/src/workspace.ts +++ b/packages/fleet-protocol/src/workspace.ts @@ -89,6 +89,32 @@ export const WorkspaceStatusSchema = z.discriminatedUnion("state", [ export type WorkspaceStatus = z.infer; +export const EPHEMERAL_CLEANUP_STATES = ["watching", "blocked"] as const; + +export type EphemeralCleanupState = (typeof EPHEMERAL_CLEANUP_STATES)[number]; + +/** How long a blocked cleanup's reason may be before the bridge truncates it. */ +export const MAX_BLOCKED_REASON_LENGTH = 200; + +/** + * A workspace the bridge deletes once its issue's pull request closes. + * `pullRequest` is what the last sweep saw — for display, not decisions. + */ +export const EphemeralWorkspaceSchema = z.object({ + issueNumber: z.number().int().positive(), + /** The branch linked to the issue when the workspace was created. */ + branch: z.string(), + cleanup: z.enum(EPHEMERAL_CLEANUP_STATES), + blockedReason: z.string().max(MAX_BLOCKED_REASON_LENGTH).nullable().default(null), + blockedAt: z.string().nullable().default(null), + pullRequest: z + .object({ number: z.number().int().positive(), state: z.string(), url: z.string() }) + .nullable() + .default(null), +}); + +export type EphemeralWorkspace = z.infer; + /** Body of `POST /workspaces/:repo/:name/agent/status` — update the live status. */ export interface UpdateAgentStatusRequest { readonly state: AgentState; From 4677a7ed03b50616c682d3ebb135a5ed3a61b24e Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 3 Aug 2026 20:04:23 -0500 Subject: [PATCH 3/8] Ask a provider for every pull request on a branch Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-bridge/src/providers/github.ts | 12 +++++ .../fleet-bridge/src/providers/provider.ts | 6 +++ packages/fleet-bridge/tests/providers.test.ts | 52 +++++++++++++++++++ .../tests/repo-provider-api.test.ts | 4 ++ .../tests/workspace-from-issue.test.ts | 1 + 5 files changed, 75 insertions(+) diff --git a/packages/fleet-bridge/src/providers/github.ts b/packages/fleet-bridge/src/providers/github.ts index adc0d82..4d42d01 100644 --- a/packages/fleet-bridge/src/providers/github.ts +++ b/packages/fleet-bridge/src/providers/github.ts @@ -274,6 +274,18 @@ export class GitHubProvider implements RepoProvider { return pulls.map((pull) => this.toPullRequestSummary(pull)); } + async pullRequestsForBranch(branch: string): Promise { + // `head` is matched as `:`, which is why a fork's PR does not + // appear here: the branch this asks about is one the bridge made on the repo. + const head = encodeURIComponent(`${this.owner}:${branch}`); + const pulls = await this.request( + `/repos/${this.owner}/${this.repo}/pulls?state=all&head=${head}&per_page=100`, + ); + return pulls + .filter((pull) => pull.head.ref === branch) + .map((pull) => this.toPullRequestSummary(pull)); + } + async getPullRequest(number: number): Promise { const pull = await this.request( `/repos/${this.owner}/${this.repo}/pulls/${number}`, diff --git a/packages/fleet-bridge/src/providers/provider.ts b/packages/fleet-bridge/src/providers/provider.ts index 4314caa..69ffda6 100644 --- a/packages/fleet-bridge/src/providers/provider.ts +++ b/packages/fleet-bridge/src/providers/provider.ts @@ -106,6 +106,12 @@ export interface RepoProvider { listIssues(options?: ListOptions): Promise; getIssue(number: number): Promise; listPullRequests(options?: ListOptions): Promise; + /** + * Every pull request, open or closed, whose head is `branch` on this repo. + * Unlike {@link listPullRequests} the answer is complete, so a caller may read + * "none are open" from it. + */ + pullRequestsForBranch(branch: string): Promise; getPullRequest(number: number): Promise; commentOnIssue(number: number, body: string): Promise; commentOnPullRequest(number: number, body: string): Promise; diff --git a/packages/fleet-bridge/tests/providers.test.ts b/packages/fleet-bridge/tests/providers.test.ts index 6f920eb..15b8b3c 100644 --- a/packages/fleet-bridge/tests/providers.test.ts +++ b/packages/fleet-bridge/tests/providers.test.ts @@ -186,6 +186,58 @@ describe("GitHubProvider", () => { expect(calls[0]!.url).toBe("https://api.github.com/repos/owner/repo/pulls/12"); }); + test("pullRequestsForBranch asks for every state on one owner-qualified head", async () => { + const { fetch, calls } = fakeFetch( + Response.json([ + { + number: 41, + title: "closes it", + state: "closed", + user: { login: "alice" }, + html_url: "https://github.com/owner/repo/pull/41", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + draft: false, + base: { ref: "main" }, + head: { ref: "37-add-ephemeral-workspaces", sha: "abc" }, + }, + ]), + ); + const provider = new GitHubProvider({ owner: "owner", repo: "repo", fetch }); + + const pulls = await provider.pullRequestsForBranch("37-add-ephemeral-workspaces"); + + expect(pulls).toHaveLength(1); + expect(pulls[0]).toMatchObject({ number: 41, state: "closed", headBranch: "37-add-ephemeral-workspaces" }); + const url = new URL(calls[0]!.url); + expect(url.pathname).toBe("/repos/owner/repo/pulls"); + expect(url.searchParams.get("state")).toBe("all"); + expect(url.searchParams.get("head")).toBe("owner:37-add-ephemeral-workspaces"); + expect(url.searchParams.get("per_page")).toBe("100"); + }); + + test("pullRequestsForBranch drops anything whose head is a different branch", async () => { + const { fetch } = fakeFetch( + Response.json([ + { + number: 42, + title: "another branch", + state: "open", + user: null, + html_url: "https://github.com/owner/repo/pull/42", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + draft: false, + base: { ref: "main" }, + head: { ref: "37-add-ephemeral-workspaces-1", sha: "def" }, + }, + ]), + ); + const provider = new GitHubProvider({ owner: "owner", repo: "repo", fetch }); + + expect(await provider.pullRequestsForBranch("37-add-ephemeral-workspaces")).toEqual([]); + }); + test("listIssues filters out elements carrying a pull_request key", async () => { const { fetch, calls } = fakeFetch( Response.json([ diff --git a/packages/fleet-bridge/tests/repo-provider-api.test.ts b/packages/fleet-bridge/tests/repo-provider-api.test.ts index 2a330a2..0e3fe69 100644 --- a/packages/fleet-bridge/tests/repo-provider-api.test.ts +++ b/packages/fleet-bridge/tests/repo-provider-api.test.ts @@ -136,6 +136,10 @@ describe("repo provider API", () => { recorder.listIssuesOptions = options; return [prSummary]; }, + async pullRequestsForBranch() { + guard(); + return [prSummary]; + }, async getPullRequest(number: number) { guard(); recorder.getIssueNumber = number; diff --git a/packages/fleet-bridge/tests/workspace-from-issue.test.ts b/packages/fleet-bridge/tests/workspace-from-issue.test.ts index 896a878..bb4effe 100644 --- a/packages/fleet-bridge/tests/workspace-from-issue.test.ts +++ b/packages/fleet-bridge/tests/workspace-from-issue.test.ts @@ -64,6 +64,7 @@ describe("POST /workspaces from an issue", () => { getInfo: unused, listIssues: unused, listPullRequests: unused, + pullRequestsForBranch: unused, getPullRequest: unused, commentOnIssue: unused, commentOnPullRequest: unused, From 15e279a5a2e0f29f59ab7bec4fda284e0eadaa50 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 3 Aug 2026 20:08:32 -0500 Subject: [PATCH 4/8] Mark a workspace ephemeral when it is created from an issue Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/tests/format.test.ts | 4 +- packages/fleet-bridge/src/api/workspaces.ts | 1 + packages/fleet-bridge/src/fleet-manager.ts | 81 +++++++- packages/fleet-bridge/src/types.ts | 19 +- .../tests/ephemeral-workspaces.test.ts | 189 ++++++++++++++++++ packages/fleet-bridge/tests/events-ws.test.ts | 1 + .../fleet-bridge/tests/fleet-manager.test.ts | 10 +- 7 files changed, 289 insertions(+), 16 deletions(-) create mode 100644 packages/fleet-bridge/tests/ephemeral-workspaces.test.ts diff --git a/apps/cli/tests/format.test.ts b/apps/cli/tests/format.test.ts index 6b682b4..9645c1d 100644 --- a/apps/cli/tests/format.test.ts +++ b/apps/cli/tests/format.test.ts @@ -39,8 +39,8 @@ describe("formatFleetWorkspaceTable", () => { test("includes the owning ship and aligns columns", () => { const out = formatFleetWorkspaceTable([ - { ship: "orca", repoName: "Hello-World", name: "ws1", branch: "master", active: true, agent: null }, - { ship: "a", repoName: "x", name: "y", branch: "main", active: false, agent: null }, + { ship: "orca", repoName: "Hello-World", name: "ws1", branch: "master", active: true, agent: null, ephemeral: null }, + { ship: "a", repoName: "x", name: "y", branch: "main", active: false, agent: null, ephemeral: null }, ]); const lines = out.split("\n"); diff --git a/packages/fleet-bridge/src/api/workspaces.ts b/packages/fleet-bridge/src/api/workspaces.ts index 8d92b12..b01e5b1 100644 --- a/packages/fleet-bridge/src/api/workspaces.ts +++ b/packages/fleet-bridge/src/api/workspaces.ts @@ -89,6 +89,7 @@ export function workspacesPlugin(manager: FleetManager) { // client gets a 400 with a reason instead of a shapeless 422. branch: t.Optional(t.String()), issueNumber: t.Optional(t.Numeric()), + ephemeral: t.Optional(t.Boolean()), }), }, ) diff --git a/packages/fleet-bridge/src/fleet-manager.ts b/packages/fleet-bridge/src/fleet-manager.ts index df8aa87..9e6c5b9 100644 --- a/packages/fleet-bridge/src/fleet-manager.ts +++ b/packages/fleet-bridge/src/fleet-manager.ts @@ -12,6 +12,7 @@ import { type ArmoryManifest, type ArmorySyncState, type CreateRepoInput, + type EphemeralWorkspace, type FleetEvent, type Repo, type SystemResources, @@ -33,7 +34,7 @@ import { type ShipInfo, type ShipSystemResources, } from "./types"; -import { RepoAlreadyExistsError, Store } from "./store/store"; +import { RepoAlreadyExistsError, Store, type EphemeralWorkspaceRecord } from "./store/store"; import { ArmoryMapError, ArmoryNotFoundError, @@ -181,6 +182,8 @@ export interface CreateWorkspaceInput { readonly name: string; readonly branch?: string; readonly issueNumber?: number; + /** Delete this workspace once the issue's pull request closes. Needs `issueNumber`. */ + readonly ephemeral?: boolean; } /** Which of the two mutually exclusive branch sources a create request chose. */ @@ -198,6 +201,8 @@ export class FleetManager { private readonly connections = new Map(); /** Fleet-wide ownership: `/` → owning ship name. */ private readonly index = new Map(); + /** Mirror of the store's ephemeral records, so annotating a summary stays synchronous. */ + private readonly ephemeral = new Map(); /** In-flight and transport-ambiguous creates stay separate from confirmed routing ownership. */ private readonly createReservations = new Map(); private readonly eventListeners = new Set<(event: BridgeWorkspaceEvent) => void>(); @@ -235,6 +240,9 @@ export class FleetManager { /** Throws when two reachable ships hold the same `/`, so the CLI can exit. */ async init(): Promise { await this.store.load(); + for (const record of await this.store.getAllEphemeral()) { + this.ephemeral.set(workspaceKey(record.repoName, record.name), record); + } const records = await this.store.getAllShips(); for (const record of records) { const conn = this.createConnection(record.url, record.name); @@ -278,11 +286,49 @@ export class FleetManager { const workspaces: BridgeWorkspaceSummary[] = []; for (const [key, ship] of this.index) { const workspace = this.connections.get(ship)?.workspaces.get(key); - if (workspace) workspaces.push({ ...workspace, ship }); + if (workspace) workspaces.push(this.annotate(workspace, ship)); } return workspaces; } + private annotate( + workspace: T, + ship: string, + ): T & { ship: string; ephemeral: EphemeralWorkspace | null } { + const record = this.ephemeral.get(workspaceKey(workspace.repoName, workspace.name)); + const ephemeral: EphemeralWorkspace | null = record + ? { + issueNumber: record.issueNumber, + branch: record.branch, + cleanup: record.cleanup, + blockedReason: record.blockedReason, + blockedAt: record.blockedAt, + pullRequest: record.pullRequest, + } + : null; + return { ...workspace, ship, ephemeral }; + } + + private async watchEphemeral(record: EphemeralWorkspaceRecord): Promise { + const stored = await this.store.createEphemeral(record); + this.ephemeral.set(workspaceKey(stored.repoName, stored.name), stored); + this.publishSnapshot(); + } + + private async forgetEphemeral(repoName: string, name: string): Promise { + if (!this.ephemeral.has(workspaceKey(repoName, name))) return; + await this.store.deleteEphemeral(repoName, name); + this.ephemeral.delete(workspaceKey(repoName, name)); + } + + private async forgetEphemeralWhere( + predicate: (record: EphemeralWorkspaceRecord) => boolean, + ): Promise { + for (const record of [...this.ephemeral.values()]) { + if (predicate(record)) await this.forgetEphemeral(record.repoName, record.name); + } + } + private publish(event: BridgeWorkspaceEvent): void { for (const listener of this.eventListeners) listener(event); } @@ -358,6 +404,7 @@ export class FleetManager { for (const [key, reservation] of this.createReservations) { if (reservation.shipName === name) this.createReservations.delete(key); } + await this.forgetEphemeralWhere((record) => record.ship === name); await this.persist(); this.publishSnapshot(); } @@ -428,6 +475,10 @@ export class FleetManager { this.identifier(name, "repo"); const deleted = await this.store.deleteRepo(name); if (!deleted) throw new BridgeError(`repo not found: ${name}`, 404); + // Their provider is gone, so they can never resolve; the workspaces stay as + // ordinary ones. + await this.forgetEphemeralWhere((record) => record.repoName === name); + this.publishSnapshot(); } /** @@ -675,7 +726,7 @@ export class FleetManager { if (!summary) continue; if (filter === "active" && !summary.active) continue; if (filter === "inactive" && summary.active) continue; - rows.push({ ...summary, ship: shipName }); + rows.push(this.annotate(summary, shipName)); } return rows; } @@ -692,7 +743,7 @@ export class FleetManager { if (status.repoName !== repo || status.name !== name) { throw new BridgeError(`ship "${conn.name}" returned a workspace identity that was not requested`, 502); } - return { ...status, ship: conn.name }; + return this.annotate(status, conn.name); } /** `GET /workspaces/:repo/:name/diff` — raw `git diff` text from the owning ship. */ @@ -723,6 +774,9 @@ export class FleetManager { this.identifier(input.repoName, "repo"); this.identifier(input.name, "workspace"); const source = this.branchSource(input); + if (input.ephemeral && !("issueNumber" in source)) { + throw new BridgeError("an ephemeral workspace is created from an issue", 400); + } const conn = this.connections.get(input.ship); if (!conn) throw new BridgeError(`unknown ship: ${input.ship}`, 400); @@ -802,7 +856,21 @@ export class FleetManager { ); } - return { ...summary, ship: conn.name }; + if (input.ephemeral && "issueNumber" in source) { + await this.watchEphemeral({ + repoName: input.repoName, + name: input.name, + ship: conn.name, + issueNumber: source.issueNumber, + branch, + cleanup: "watching", + blockedReason: null, + blockedAt: null, + pullRequest: null, + }); + } + + return this.annotate(summary, conn.name); } catch (err) { if ( err instanceof BridgeError && @@ -900,6 +968,7 @@ export class FleetManager { await this.call(conn, () => conn.client.workspaces({ repo })({ name }).delete() as Promise>, ); + await this.forgetEphemeral(repo, name); } /** @@ -946,7 +1015,7 @@ export class FleetManager { this.publish({ type: event.type, at: event.at, - workspace: { ...event.workspace, ship: conn.name }, + workspace: this.annotate(event.workspace, conn.name), }); } diff --git a/packages/fleet-bridge/src/types.ts b/packages/fleet-bridge/src/types.ts index e6c4252..1dd5f13 100644 --- a/packages/fleet-bridge/src/types.ts +++ b/packages/fleet-bridge/src/types.ts @@ -1,4 +1,10 @@ -import type { ArmorySyncState, SystemResources, WorkspaceStatus, WorkspaceSummary } from "fleet-protocol"; +import type { + ArmorySyncState, + EphemeralWorkspace, + SystemResources, + WorkspaceStatus, + WorkspaceSummary, +} from "fleet-protocol"; /** Whether the bridge currently has a live `/events` connection to a ship. */ export type ShipStatus = "online" | "offline"; @@ -10,9 +16,16 @@ export interface ShipInfo { readonly status: ShipStatus; } -export type BridgeWorkspaceSummary = WorkspaceSummary & { ship: string }; +export type BridgeWorkspaceSummary = WorkspaceSummary & { + ship: string; + /** Null for an ordinary workspace; the bridge, not the ship, knows this. */ + ephemeral: EphemeralWorkspace | null; +}; -export type BridgeWorkspaceStatus = WorkspaceStatus & { ship: string }; +export type BridgeWorkspaceStatus = WorkspaceStatus & { + ship: string; + ephemeral: EphemeralWorkspace | null; +}; export type BridgeWorkspaceEvent = | { diff --git a/packages/fleet-bridge/tests/ephemeral-workspaces.test.ts b/packages/fleet-bridge/tests/ephemeral-workspaces.test.ts new file mode 100644 index 0000000..e910881 --- /dev/null +++ b/packages/fleet-bridge/tests/ephemeral-workspaces.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Repo } from "fleet-protocol"; +import { FleetManager } from "../src/fleet-manager"; +import { createApp } from "../src/api"; +import { Store } from "../src/store/store"; +import type { Issue, PullRequestSummary, RepoProvider } from "../src/providers"; +import { FakeSocket, makeDeps, type FakeShip } from "./helpers"; + +const issue: Issue = { + number: 37, + title: "Add ephemeral workspaces", + state: "open", + author: "octocat", + url: "https://github.com/acme/repo1/issues/37", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + body: "details", + comments: 0, +}; + +const BRANCH = "37-add-ephemeral-workspaces"; + +describe("ephemeral workspaces", () => { + let dir: string; + let manager: FleetManager; + let app: ReturnType; + let ships: Map; + let store: Store; + + function makeProvider(_repo: Repo): RepoProvider { + const unused = () => { + throw new Error("not used by these tests"); + }; + return { + getInfo: unused, + listIssues: unused, + listPullRequests: unused, + getPullRequest: unused, + commentOnIssue: unused, + commentOnPullRequest: unused, + reviewPullRequest: unused, + listChecks: unused, + getFailedLogs: unused, + async pullRequestsForBranch(): Promise { + return []; + }, + async getIssue() { + return issue; + }, + async linkBranchToIssue(_issueNumber: number, branch: string) { + return { name: branch, sha: "sha-of-linked-branch" }; + }, + }; + } + + async function call(method: string, path: string, body?: unknown) { + const res = await app.handle( + new Request(`http://bridge${path}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : undefined }; + } + + async function createEphemeral(name: string, repoName = "repo1") { + return call("POST", "/workspaces", { ship: "ship-a", repoName, name, issueNumber: 37, ephemeral: true }); + } + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "fleet-bridge-ephemeral-")); + FakeSocket.byBase.clear(); + ships = new Map([["http://ship-a", { name: "ship-a", workspaces: [] }]]); + store = new Store(dir); + await store.load(); + await store.createShip({ name: "ship-a", url: "http://ship-a" }); + manager = new FleetManager({ dataDirectory: dir, port: 4903, name: "bridge" }, makeDeps(ships), { + syncTimeoutMs: 50, + store, + providerFor: makeProvider, + }); + await manager.init(); + app = createApp(manager); + expect( + (await call("POST", "/repos", { name: "repo1", url: "https://github.com/acme/repo1", provider: "github" })) + .status, + ).toBe(201); + }); + + afterEach(async () => { + manager.shutdown(); + await rm(dir, { recursive: true, force: true }); + }); + + test("records an ephemeral workspace and reports it everywhere the workspace appears", async () => { + const created = await createEphemeral("thirty-seven"); + + expect(created.status).toBe(201); + expect(created.body.ephemeral).toEqual({ + issueNumber: 37, + branch: BRANCH, + cleanup: "watching", + blockedReason: null, + blockedAt: null, + pullRequest: null, + }); + expect(await store.getEphemeral("repo1", "thirty-seven")).toMatchObject({ ship: "ship-a", branch: BRANCH }); + + const listed = (await call("GET", "/workspaces")).body; + expect(listed[0].ephemeral).toMatchObject({ issueNumber: 37, cleanup: "watching" }); + expect((await call("GET", "/workspaces/repo1/thirty-seven")).body.ephemeral).toMatchObject({ issueNumber: 37 }); + expect(manager.workspaceSnapshot()[0]?.ephemeral).toMatchObject({ issueNumber: 37 }); + }); + + test("leaves an ordinary workspace unannotated", async () => { + const created = await call("POST", "/workspaces", { + ship: "ship-a", + repoName: "repo1", + name: "plain", + branch: "dev", + }); + + expect(created.status).toBe(201); + expect(created.body.ephemeral).toBeNull(); + expect(await store.getEphemeral("repo1", "plain")).toBeUndefined(); + }); + + test("refuses to make a workspace ephemeral without an issue", async () => { + const branchOnly = await call("POST", "/workspaces", { + ship: "ship-a", + repoName: "repo1", + name: "nope", + branch: "dev", + ephemeral: true, + }); + expect(branchOnly.status).toBe(400); + expect(branchOnly.body.error).toContain("created from an issue"); + + const neither = await call("POST", "/workspaces", { + ship: "ship-a", + repoName: "repo1", + name: "nope", + ephemeral: true, + }); + expect(neither.status).toBe(400); + expect(await manager.listWorkspaces()).toHaveLength(0); + }); + + test("keeps records across a bridge restart", async () => { + await createEphemeral("thirty-seven"); + + manager.shutdown(); + const reloadedStore = new Store(dir); + await reloadedStore.load(); + const reloaded = new FleetManager({ dataDirectory: dir, port: 4903, name: "bridge" }, makeDeps(ships), { + syncTimeoutMs: 50, + store: reloadedStore, + providerFor: makeProvider, + }); + await reloaded.init(); + + expect((await reloaded.listWorkspaces())[0]?.ephemeral).toMatchObject({ issueNumber: 37, branch: BRANCH }); + reloaded.shutdown(); + }); + + test("forgets the record when the workspace, its ship, or its repo goes away", async () => { + await createEphemeral("deleted"); + expect((await call("DELETE", "/workspaces/repo1/deleted")).status).toBe(200); + expect(await store.getEphemeral("repo1", "deleted")).toBeUndefined(); + + await createEphemeral("by-repo"); + expect((await call("DELETE", "/repos/repo1")).status).toBe(200); + expect(await store.getEphemeral("repo1", "by-repo")).toBeUndefined(); + expect((await manager.listWorkspaces()).find((w) => w.name === "by-repo")?.ephemeral).toBeNull(); + + expect( + (await call("POST", "/repos", { name: "repo1", url: "https://github.com/acme/repo1", provider: "github" })) + .status, + ).toBe(201); + await createEphemeral("by-ship"); + expect((await call("DELETE", "/ships/ship-a")).status).toBe(200); + expect(await store.getEphemeral("repo1", "by-ship")).toBeUndefined(); + }); +}); diff --git a/packages/fleet-bridge/tests/events-ws.test.ts b/packages/fleet-bridge/tests/events-ws.test.ts index 7e81c5a..4bff2bd 100644 --- a/packages/fleet-bridge/tests/events-ws.test.ts +++ b/packages/fleet-bridge/tests/events-ws.test.ts @@ -12,6 +12,7 @@ function managerStub() { active: true, agent: null, ship: "ship-a", + ephemeral: null, }; const manager = { subscribe(listener: (event: BridgeWorkspaceEvent) => void) { diff --git a/packages/fleet-bridge/tests/fleet-manager.test.ts b/packages/fleet-bridge/tests/fleet-manager.test.ts index 4ec08bd..680d6f5 100644 --- a/packages/fleet-bridge/tests/fleet-manager.test.ts +++ b/packages/fleet-bridge/tests/fleet-manager.test.ts @@ -73,8 +73,8 @@ describe("FleetManager", () => { const rows = (await mgr.listWorkspaces()).sort((a, b) => a.repoName.localeCompare(b.repoName)); expect(rows).toEqual([ - { repoName: "repo1", name: "one", branch: "main", active: true, agent: null, ship: "ship-a" }, - { repoName: "repo2", name: "two", branch: "main", active: false, agent: null, ship: "ship-b" }, + { repoName: "repo1", name: "one", branch: "main", active: true, agent: null, ship: "ship-a", ephemeral: null }, + { repoName: "repo2", name: "two", branch: "main", active: false, agent: null, ship: "ship-b", ephemeral: null }, ]); expect(await mgr.listWorkspaces("active")).toHaveLength(1); expect(await mgr.listWorkspaces("inactive")).toHaveLength(1); @@ -109,7 +109,7 @@ describe("FleetManager", () => { expect(events).toEqual([{ type: "workspace.agent_status_changed", at: "2026-01-01T00:00:00.000Z", - workspace: { ...workspace, ship: "ship-a" }, + workspace: { ...workspace, ship: "ship-a", ephemeral: null }, }]); expect(mgr.workspaceSnapshot()[0]?.agent).toEqual(workspace.agent); unsubscribe(); @@ -231,7 +231,7 @@ describe("FleetManager", () => { name: "feature", branch: "dev", }); - expect(created).toEqual({ repoName: "repo2", name: "feature", branch: "dev", active: false, agent: null, ship: "ship-a" }); + expect(created).toEqual({ repoName: "repo2", name: "feature", branch: "dev", active: false, agent: null, ship: "ship-a", ephemeral: null }); // Optimistically visible immediately. expect((await mgr.listWorkspaces()).some((w) => w.repoName === "repo2" && w.name === "feature")).toBe(true); }); @@ -396,7 +396,7 @@ describe("FleetManager", () => { await expect(create).resolves.toMatchObject({ ship: "ship-a" }); expect((await mgr.listWorkspaces()).filter((workspace) => workspace.name === "one")).toEqual([ - { ...ws("repo1", "one"), ship: "ship-a" }, + { ...ws("repo1", "one"), ship: "ship-a", ephemeral: null }, ]); expect((await mgr.getWorkspace("repo1", "one")).ship).toBe("ship-a"); }); From 5bdc05e106ec41685cbd31714995d2944aa57107 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 3 Aug 2026 20:12:47 -0500 Subject: [PATCH 5/8] Sweep ephemeral workspaces whose pull request has closed Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-bridge/src/api/workspaces.ts | 1 + packages/fleet-bridge/src/config.ts | 4 + packages/fleet-bridge/src/fleet-manager.ts | 178 +++++++++++++++- packages/fleet-bridge/src/index.ts | 2 + .../tests/ephemeral-workspaces.test.ts | 195 +++++++++++++++++- packages/fleet-bridge/tests/helpers.ts | 18 +- 6 files changed, 390 insertions(+), 8 deletions(-) diff --git a/packages/fleet-bridge/src/api/workspaces.ts b/packages/fleet-bridge/src/api/workspaces.ts index b01e5b1..970d372 100644 --- a/packages/fleet-bridge/src/api/workspaces.ts +++ b/packages/fleet-bridge/src/api/workspaces.ts @@ -93,6 +93,7 @@ export function workspacesPlugin(manager: FleetManager) { }), }, ) + .post("/workspaces/sweep", () => manager.sweepEphemeral()) .post( "/workspaces/:repo/:name/branch", async ({ params, body }) => { diff --git a/packages/fleet-bridge/src/config.ts b/packages/fleet-bridge/src/config.ts index fea2019..e0f1385 100644 --- a/packages/fleet-bridge/src/config.ts +++ b/packages/fleet-bridge/src/config.ts @@ -13,10 +13,14 @@ export const BridgeConfigSchema = z.object({ * `resolveBridgeConfig` may omit it; `defaultPublicUrl` fills the gap. */ publicUrl: z.string().min(1).optional(), + /** How often to check ephemeral workspaces for a closed pull request. `0` never checks. */ + sweepIntervalMs: z.number().int().nonnegative().optional(), }); export type BridgeConfig = z.infer; +export const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; + export function defaultPublicUrl(port: number): string { return `http://localhost:${port}`; } diff --git a/packages/fleet-bridge/src/fleet-manager.ts b/packages/fleet-bridge/src/fleet-manager.ts index 9e6c5b9..474becf 100644 --- a/packages/fleet-bridge/src/fleet-manager.ts +++ b/packages/fleet-bridge/src/fleet-manager.ts @@ -4,6 +4,7 @@ import { CreateRepoInputSchema, FleetIdentifierSchema, issueBranchName, + MAX_BLOCKED_REASON_LENGTH, ShipSchema, WorkspaceRefsSchema, WorkspaceSummarySchema, @@ -23,7 +24,7 @@ import { import { Git, GitError, type DiffOptions, type RemoteRef } from "git-bun"; import { TERMINAL_TAKEOVER_QUERY } from "webterm/protocol"; import { ShipConnection, toWsUrl, type ShipConnectionDeps } from "./ship-connection"; -import { defaultPublicUrl, type BridgeConfig } from "./config"; +import { defaultPublicUrl, DEFAULT_SWEEP_INTERVAL_MS, type BridgeConfig } from "./config"; import { workspaceKey, type BridgeWorkspaceEvent, @@ -189,6 +190,29 @@ export interface CreateWorkspaceInput { /** Which of the two mutually exclusive branch sources a create request chose. */ type BranchSource = { readonly branch: string } | { readonly issueNumber: number }; +/** What one pass of the ephemeral sweep did, returned by `POST /workspaces/sweep`. */ +export interface SweepSummary { + /** Records whose pull requests were read this pass. */ + checked: number; + destroyed: number; + blocked: number; + /** Left for a later pass — an offline ship, or a provider that could not answer. */ + skipped: number; + /** Records dropped because the workspace is gone and its ship is online to say so. */ + forgotten: number; +} + +/** The pull request worth showing: an open one, else the most recently opened. */ +function observedPullRequest( + pulls: PullRequestSummary[], +): { number: number; state: string; url: string } | null { + const ranked = [...pulls].sort( + (a, b) => Number(b.state === "open") - Number(a.state === "open") || b.number - a.number, + ); + const pull = ranked[0]; + return pull ? { number: pull.number, state: pull.state, url: pull.url } : null; +} + type EdenResult = { data: T | null; error: unknown }; interface CreateReservation { @@ -205,6 +229,8 @@ export class FleetManager { private readonly ephemeral = new Map(); /** In-flight and transport-ambiguous creates stay separate from confirmed routing ownership. */ private readonly createReservations = new Map(); + private sweepTimer?: ReturnType; + private sweepInFlight?: Promise; private readonly eventListeners = new Set<(event: BridgeWorkspaceEvent) => void>(); private readonly deps?: Partial; private readonly syncTimeoutMs: number; @@ -274,6 +300,8 @@ export class FleetManager { } shutdown(): void { + clearInterval(this.sweepTimer); + this.sweepTimer = undefined; for (const conn of this.connections.values()) conn.close(); } @@ -329,6 +357,148 @@ export class FleetManager { } } + private async reviseEphemeral( + record: EphemeralWorkspaceRecord, + values: Partial, + ): Promise { + if (JSON.stringify({ ...record, ...values }) === JSON.stringify(record)) return; + const stored = await this.store.updateEphemeral(record.repoName, record.name, values); + if (!stored) return; + this.ephemeral.set(workspaceKey(stored.repoName, stored.name), stored); + this.publishSnapshot(); + } + + /** Start the periodic sweep. `intervalMs` of `0` leaves it off. */ + startSweeping(intervalMs: number = this.config.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS): void { + if (intervalMs <= 0 || this.sweepTimer) return; + this.sweepTimer = setInterval(() => void this.sweepEphemeral(), intervalMs); + void this.sweepEphemeral(); + } + + /** `POST /workspaces/sweep`, and the timer's tick. Never runs two passes at once. */ + sweepEphemeral(): Promise { + if (!this.sweepInFlight) { + this.sweepInFlight = this.runSweep().finally(() => { + this.sweepInFlight = undefined; + }); + } + return this.sweepInFlight; + } + + private async runSweep(): Promise { + const tally: SweepSummary = { checked: 0, destroyed: 0, blocked: 0, skipped: 0, forgotten: 0 }; + const byRepo = new Map(); + for (const record of this.ephemeral.values()) { + const records = byRepo.get(record.repoName) ?? []; + records.push(record); + byRepo.set(record.repoName, records); + } + + for (const [repoName, records] of byRepo) { + let provider: RepoProvider; + try { + provider = await this.providerFor(repoName); + } catch (error) { + console.warn( + `fleet-bridge: no provider for repo "${repoName}"; ${records.length} ephemeral workspace(s) left alone: ${(error as Error).message}`, + ); + tally.skipped += records.length; + continue; + } + + for (const [position, record] of records.entries()) { + try { + await this.sweepRecord(provider, record, tally); + } catch (error) { + // A provider that cannot answer must not read as "no pull request". + console.warn( + `fleet-bridge: could not read repo "${repoName}" while sweeping: ${(error as Error).message}`, + ); + tally.skipped += records.length - position; + break; + } + } + } + + return tally; + } + + /** Throws only when the *provider* fails; a ship-side failure is recorded on the tally. */ + private async sweepRecord( + provider: RepoProvider, + record: EphemeralWorkspaceRecord, + tally: SweepSummary, + ): Promise { + const key = workspaceKey(record.repoName, record.name); + const owner = this.index.get(key); + if (owner === undefined) { + const home = this.connections.get(record.ship); + if (home?.status === "online") { + await this.forgetEphemeral(record.repoName, record.name); + tally.forgotten += 1; + } else { + tally.skipped += 1; + } + return; + } + const conn = this.connections.get(owner); + if (!conn || conn.status !== "online") { + tally.skipped += 1; + return; + } + + const pulls = await provider.pullRequestsForBranch(record.branch); + const closable = + pulls.length > 0 + ? !pulls.some((pull) => pull.state === "open") + : (await provider.getIssue(record.issueNumber)).state === "closed"; + const pullRequest = observedPullRequest(pulls); + tally.checked += 1; + + if (!closable) { + await this.reviseEphemeral(record, { + pullRequest, + cleanup: "watching", + blockedReason: null, + blockedAt: null, + }); + return; + } + + try { + await this.call(conn, () => + conn.client + .workspaces({ repo: record.repoName })({ name: record.name }) + .delete(undefined, { query: { force: false } }) as Promise>, + ); + } catch (error) { + if (error instanceof BridgeError && error.status === 409) { + const reason = error.message.slice(0, MAX_BLOCKED_REASON_LENGTH); + if (record.cleanup !== "blocked" || record.blockedReason !== reason) { + console.warn(`fleet-bridge: cannot clean up ${key}: ${reason}`); + } + await this.reviseEphemeral(record, { + pullRequest, + cleanup: "blocked", + blockedReason: reason, + blockedAt: new Date().toISOString(), + }); + tally.blocked += 1; + return; + } + console.warn(`fleet-bridge: could not delete ${key} on ship "${conn.name}": ${(error as Error).message}`); + await this.reviseEphemeral(record, { pullRequest }); + tally.skipped += 1; + return; + } + + conn.workspaces.delete(key); + this.releaseOwnership(key, conn.name); + await this.forgetEphemeral(record.repoName, record.name); + this.publishSnapshot(); + tally.destroyed += 1; + } + private publish(event: BridgeWorkspaceEvent): void { for (const listener of this.eventListeners) listener(event); } @@ -529,10 +699,14 @@ export class FleetManager { /** `ProviderError` from `fn` propagates unchanged so the API can surface its status. */ private async withProvider(name: string, fn: (provider: RepoProvider) => Promise): Promise { + return fn(await this.providerFor(name)); + } + + private async providerFor(name: string): Promise { this.identifier(name, "repo"); const repo = await this.store.getRepo(name); if (!repo) throw new BridgeError(`repo not found: ${name}`, 404); - return fn(this.makeProvider(repo)); + return this.makeProvider(repo); } /** `GET /repos/:name/info`. */ diff --git a/packages/fleet-bridge/src/index.ts b/packages/fleet-bridge/src/index.ts index da77ee0..87b2a74 100755 --- a/packages/fleet-bridge/src/index.ts +++ b/packages/fleet-bridge/src/index.ts @@ -47,6 +47,8 @@ export async function startBridge( void manager.pushArmory(); }); + manager.startSweeping(); + return { manager, watcher }; } diff --git a/packages/fleet-bridge/tests/ephemeral-workspaces.test.ts b/packages/fleet-bridge/tests/ephemeral-workspaces.test.ts index e910881..3190973 100644 --- a/packages/fleet-bridge/tests/ephemeral-workspaces.test.ts +++ b/packages/fleet-bridge/tests/ephemeral-workspaces.test.ts @@ -23,12 +23,31 @@ const issue: Issue = { const BRANCH = "37-add-ephemeral-workspaces"; +function pull(number: number, state: string): PullRequestSummary { + return { + number, + title: "a pull request", + state, + author: "octocat", + url: `https://github.com/acme/repo1/pull/${number}`, + createdAt: "2026-01-03T00:00:00.000Z", + updatedAt: "2026-01-04T00:00:00.000Z", + draft: false, + baseBranch: "main", + headBranch: BRANCH, + }; +} + describe("ephemeral workspaces", () => { let dir: string; let manager: FleetManager; let app: ReturnType; let ships: Map; let store: Store; + let pulls: PullRequestSummary[]; + let issueState: string; + let providerFailsWith: Error | undefined; + let branchReads: number; function makeProvider(_repo: Repo): RepoProvider { const unused = () => { @@ -45,10 +64,13 @@ describe("ephemeral workspaces", () => { listChecks: unused, getFailedLogs: unused, async pullRequestsForBranch(): Promise { - return []; + branchReads += 1; + if (providerFailsWith) throw providerFailsWith; + return pulls; }, async getIssue() { - return issue; + if (providerFailsWith) throw providerFailsWith; + return { ...issue, state: issueState }; }, async linkBranchToIssue(_issueNumber: number, branch: string) { return { name: branch, sha: "sha-of-linked-branch" }; @@ -75,6 +97,10 @@ describe("ephemeral workspaces", () => { beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "fleet-bridge-ephemeral-")); FakeSocket.byBase.clear(); + pulls = []; + issueState = "open"; + providerFailsWith = undefined; + branchReads = 0; ships = new Map([["http://ship-a", { name: "ship-a", workspaces: [] }]]); store = new Store(dir); await store.load(); @@ -186,4 +212,169 @@ describe("ephemeral workspaces", () => { expect((await call("DELETE", "/ships/ship-a")).status).toBe(200); expect(await store.getEphemeral("repo1", "by-ship")).toBeUndefined(); }); + + describe("sweeping", () => { + const shipA = () => ships.get("http://ship-a")!; + + test("destroys the workspace once every pull request on the branch is closed", async () => { + await createEphemeral("done"); + pulls = [pull(41, "closed")]; + + expect(await manager.sweepEphemeral()).toEqual({ + checked: 1, + destroyed: 1, + blocked: 0, + skipped: 0, + forgotten: 0, + }); + expect(shipA().deletes).toEqual([{ repo: "repo1", name: "done", force: false }]); + expect(shipA().workspaces).toHaveLength(0); + expect(await store.getEphemeral("repo1", "done")).toBeUndefined(); + expect(await manager.listWorkspaces()).toHaveLength(0); + }); + + test("keeps watching while a pull request is open, and remembers which one", async () => { + await createEphemeral("open-pr"); + pulls = [pull(41, "closed"), pull(42, "open")]; + + expect(await manager.sweepEphemeral()).toMatchObject({ checked: 1, destroyed: 0 }); + expect(shipA().deletes).toBeUndefined(); + expect((await manager.listWorkspaces())[0]?.ephemeral).toMatchObject({ + cleanup: "watching", + pullRequest: { number: 42, state: "open" }, + }); + }); + + test("waits on an open issue that has no pull request, and cleans up once it closes", async () => { + await createEphemeral("no-pr"); + + expect(await manager.sweepEphemeral()).toMatchObject({ checked: 1, destroyed: 0 }); + expect((await manager.listWorkspaces())[0]?.ephemeral).toMatchObject({ pullRequest: null }); + + issueState = "closed"; + expect(await manager.sweepEphemeral()).toMatchObject({ destroyed: 1 }); + expect(await store.getEphemeral("repo1", "no-pr")).toBeUndefined(); + }); + + test("blocks, explains itself, and retries on the next pass", async () => { + await createEphemeral("held"); + pulls = [pull(41, "closed")]; + shipA().heldWork = "workspace repo1/held holds work that is not on a remote: 2 commits not on any remote"; + + expect(await manager.sweepEphemeral()).toMatchObject({ checked: 1, blocked: 1, destroyed: 0 }); + const blocked = (await manager.listWorkspaces())[0]?.ephemeral; + expect(blocked).toMatchObject({ + cleanup: "blocked", + blockedReason: shipA().heldWork, + pullRequest: { number: 41, state: "closed" }, + }); + expect(blocked?.blockedAt).toBeString(); + expect(shipA().workspaces).toHaveLength(1); + + expect(await manager.sweepEphemeral()).toMatchObject({ blocked: 1 }); + + shipA().heldWork = undefined; + expect(await manager.sweepEphemeral()).toMatchObject({ destroyed: 1 }); + expect(await store.getEphemeral("repo1", "held")).toBeUndefined(); + }); + + test("clears a block when the pull request reopens", async () => { + await createEphemeral("reopened"); + pulls = [pull(41, "closed")]; + shipA().heldWork = "workspace repo1/reopened holds work that is not on a remote: a stash"; + await manager.sweepEphemeral(); + + pulls = [pull(41, "open")]; + expect(await manager.sweepEphemeral()).toMatchObject({ blocked: 0, destroyed: 0 }); + expect((await manager.listWorkspaces())[0]?.ephemeral).toMatchObject({ + cleanup: "watching", + blockedReason: null, + blockedAt: null, + }); + }); + + test("truncates an over-long refusal", async () => { + await createEphemeral("verbose"); + pulls = [pull(41, "closed")]; + shipA().heldWork = "x".repeat(500); + + await manager.sweepEphemeral(); + + expect((await manager.listWorkspaces())[0]?.ephemeral?.blockedReason).toHaveLength(200); + }); + + test("skips an offline ship without blocking it", async () => { + await createEphemeral("offline"); + pulls = [pull(41, "closed")]; + shipA().throws = true; + await manager.listWorkspaces(); + + expect(await manager.sweepEphemeral()).toEqual({ + checked: 0, + destroyed: 0, + blocked: 0, + skipped: 1, + forgotten: 0, + }); + expect(await store.getEphemeral("repo1", "offline")).toMatchObject({ cleanup: "watching" }); + }); + + test("forgets a record whose workspace an online ship no longer has", async () => { + await createEphemeral("vanished"); + shipA().workspaces = []; + await manager.listWorkspaces(); + + expect(await manager.sweepEphemeral()).toMatchObject({ forgotten: 1, checked: 0 }); + expect(await store.getEphemeral("repo1", "vanished")).toBeUndefined(); + }); + + test("leaves a repo alone when its provider cannot answer", async () => { + await createEphemeral("unreadable"); + pulls = [pull(41, "closed")]; + providerFailsWith = new Error("rate limited"); + + expect(await manager.sweepEphemeral()).toMatchObject({ destroyed: 0, blocked: 0, skipped: 1 }); + expect(shipA().deletes).toBeUndefined(); + expect(await store.getEphemeral("repo1", "unreadable")).toMatchObject({ cleanup: "watching" }); + }); + + test("never touches an ordinary workspace", async () => { + await call("POST", "/workspaces", { ship: "ship-a", repoName: "repo1", name: "plain", branch: "dev" }); + pulls = [pull(41, "closed")]; + + expect(await manager.sweepEphemeral()).toEqual({ + checked: 0, + destroyed: 0, + blocked: 0, + skipped: 0, + forgotten: 0, + }); + expect(shipA().workspaces).toHaveLength(1); + }); + + test("runs one pass at a time and reports it over HTTP", async () => { + await createEphemeral("concurrent"); + + const [first, second] = await Promise.all([manager.sweepEphemeral(), manager.sweepEphemeral()]); + expect(branchReads).toBe(1); + expect(first).toEqual(second); + + const res = await call("POST", "/workspaces/sweep"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ checked: 1, destroyed: 0, blocked: 0, skipped: 0, forgotten: 0 }); + }); + + test("the timer sweeps on its own", async () => { + await createEphemeral("timed"); + pulls = [pull(41, "closed")]; + + manager.startSweeping(10); + for (let attempt = 0; attempt < 100 && shipA().workspaces.length > 0; attempt++) { + await Bun.sleep(10); + } + + expect(shipA().workspaces).toHaveLength(0); + expect(await store.getEphemeral("repo1", "timed")).toBeUndefined(); + }); + }); }); diff --git a/packages/fleet-bridge/tests/helpers.ts b/packages/fleet-bridge/tests/helpers.ts index f410502..2e52358 100644 --- a/packages/fleet-bridge/tests/helpers.ts +++ b/packages/fleet-bridge/tests/helpers.ts @@ -17,6 +17,10 @@ export interface FakeShip { armorySyncs?: { bridgeUrl: string; revision: string }[]; /** What `GET /armory` reports; defaults to a ship that has never synced. */ armoryState?: ArmorySyncState; + /** When set, a `force=false` delete is refused with this 409 message. */ + heldWork?: string; + /** Every workspace delete this ship received, in order. */ + deletes?: { repo: string; name: string; force?: boolean }[]; /** All Eden calls resolve to this error `{status, value:{error}}`. */ errorResponse?: { status: number; message: string }; /** All Eden calls throw (simulated network failure). */ @@ -140,12 +144,18 @@ export function makeFakeClient(httpUrl: string, ships: Map) { return { ok: true }; }), }, - delete: () => - wrap(() => { - const s = ship(); + delete: (_body?: unknown, options?: { query?: { force?: boolean } }) => { + const force = options?.query?.force; + const s = ship(); + if (s) (s.deletes ??= []).push({ repo: params.repo, name: params2.name, force }); + if (force === false && s?.heldWork) { + return Promise.resolve({ data: null, error: { status: 409, value: { error: s.heldWork } } }); + } + return wrap(() => { if (s) s.workspaces = s.workspaces.filter((w) => w.repoName !== params.repo || w.name !== params2.name); return { ok: true }; - }), + }); + }, diff: { get: () => wrap(() => `diff for ${params.repo}/${params2.name}`), }, From 1e2490d008775bf0c5851999994fe0d71d0f0856 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 3 Aug 2026 20:20:16 -0500 Subject: [PATCH 6/8] Show ephemeral workspaces and let the create form ask for one Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/CreateWorkspaceModal.tsx | 6 +- .../fleet-client/src/components/Ephemeral.tsx | 63 ++++++++++++++++++ .../fleet-client/src/data/FleetContext.tsx | 10 ++- packages/fleet-client/src/data/eden.ts | 1 + packages/fleet-client/src/data/mock.ts | 65 +++++++++++++++++-- packages/fleet-client/src/data/provider.ts | 1 + packages/fleet-client/src/data/types.ts | 13 +++- .../fleet-client/src/lib/create-workspace.ts | 11 +++- .../fleet-client/src/routes/RepoRoute.tsx | 6 +- .../src/routes/WorkspaceRoute.tsx | 2 + .../tests/create-workspace.test.ts | 17 ++++- packages/fleet-client/tests/ephemeral.test.ts | 45 +++++++++++++ .../tests/workspace-events.test.ts | 1 + .../tests/workspace-mutations.test.ts | 1 + 14 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 packages/fleet-client/src/components/Ephemeral.tsx create mode 100644 packages/fleet-client/tests/ephemeral.test.ts diff --git a/packages/fleet-client/src/components/CreateWorkspaceModal.tsx b/packages/fleet-client/src/components/CreateWorkspaceModal.tsx index 54eacc5..5e9d3b2 100644 --- a/packages/fleet-client/src/components/CreateWorkspaceModal.tsx +++ b/packages/fleet-client/src/components/CreateWorkspaceModal.tsx @@ -45,6 +45,7 @@ export function CreateWorkspaceModal({ repoName, ship, onClose }: Props) { const [branchesError, setBranchesError] = useState(null); const [fromIssue, setFromIssue] = useState(false); + const [ephemeral, setEphemeral] = useState(true); const [issues, setIssues] = useState([]); const [issuesLoaded, setIssuesLoaded] = useState(false); const [issuesLoading, setIssuesLoading] = useState(false); @@ -94,7 +95,7 @@ export function CreateWorkspaceModal({ repoName, ship, onClose }: Props) { }, [fromIssue, issuesLoaded, listRepoIssues, repoName]); const state = branchState(branch, branches); - const input = createWorkspaceInput({ ship: shipName, repoName, name, fromIssue, branch, issue }); + const input = createWorkspaceInput({ ship: shipName, repoName, name, fromIssue, ephemeral, branch, issue }); const { error, pending, submit } = useSubmitAction(() => createWorkspace(input!), onClose); @@ -169,6 +170,9 @@ export function CreateWorkspaceModal({ repoName, ship, onClose }: Props) { ) : issue ? ( ) : null} + + Ephemeral — delete this workspace once the issue's pull request closes + ) : ( diff --git a/packages/fleet-client/src/components/Ephemeral.tsx b/packages/fleet-client/src/components/Ephemeral.tsx new file mode 100644 index 0000000..dd0037e --- /dev/null +++ b/packages/fleet-client/src/components/Ephemeral.tsx @@ -0,0 +1,63 @@ +import type { EphemeralWorkspace } from "fleet-protocol"; +import { cn } from "@/lib/utils"; + +export function ephemeralSummary(ephemeral: EphemeralWorkspace): string { + const parts = [`issue #${ephemeral.issueNumber}`]; + parts.push( + ephemeral.pullRequest + ? `PR #${ephemeral.pullRequest.number} ${ephemeral.pullRequest.state}` + : "no pull request yet", + ); + if (ephemeral.cleanup === "blocked") { + parts.push(`cleanup blocked: ${ephemeral.blockedReason ?? "reason unknown"}`); + } + return parts.join(" · "); +} + +export function EphemeralBadge({ + ephemeral, + className, +}: { + ephemeral: EphemeralWorkspace; + className?: string; +}) { + const blocked = ephemeral.cleanup === "blocked"; + return ( + + {blocked ? "⚠ EPHEMERAL" : "⧗ EPHEMERAL"} + + ); +} + +export function EphemeralNote({ ephemeral }: { ephemeral: EphemeralWorkspace }) { + const blocked = ephemeral.cleanup === "blocked"; + return ( + + + {ephemeral.pullRequest ? ( + + issue #{ephemeral.issueNumber} · PR #{ephemeral.pullRequest.number} {ephemeral.pullRequest.state} ↗ + + ) : ( + + issue #{ephemeral.issueNumber} · no pull request yet + + )} + {blocked && cleanup blocked: {ephemeral.blockedReason ?? "reason unknown"}} + + ); +} diff --git a/packages/fleet-client/src/data/FleetContext.tsx b/packages/fleet-client/src/data/FleetContext.tsx index c552164..7379835 100644 --- a/packages/fleet-client/src/data/FleetContext.tsx +++ b/packages/fleet-client/src/data/FleetContext.tsx @@ -42,6 +42,7 @@ interface FleetValue { name: string; branch?: string; issueNumber?: number; + ephemeral?: boolean; }) => Promise; /** The branches a repo's remote advertises. Fetched on demand by the create form. */ listRepoBranches: (name: string) => Promise; @@ -187,7 +188,14 @@ export function FleetProvider({ children }: { children: ReactNode }) { ); const createWorkspace = useCallback( - async (input: { ship: string; repoName: string; name: string; branch?: string; issueNumber?: number }) => { + async (input: { + ship: string; + repoName: string; + name: string; + branch?: string; + issueNumber?: number; + ephemeral?: boolean; + }) => { await bridge.createWorkspace(input); await refresh(); }, diff --git a/packages/fleet-client/src/data/eden.ts b/packages/fleet-client/src/data/eden.ts index 2ddf8d2..6ecadc8 100644 --- a/packages/fleet-client/src/data/eden.ts +++ b/packages/fleet-client/src/data/eden.ts @@ -178,6 +178,7 @@ export class EdenFleetBridge implements FleetBridge { name: string; branch?: string; issueNumber?: number; + ephemeral?: boolean; }): Promise { const { data, error } = await this.client.workspaces.post(input); if (error) throw edenError(error); diff --git a/packages/fleet-client/src/data/mock.ts b/packages/fleet-client/src/data/mock.ts index cb87c01..ef2165d 100644 --- a/packages/fleet-client/src/data/mock.ts +++ b/packages/fleet-client/src/data/mock.ts @@ -27,9 +27,26 @@ function agent(state: AgentState, description: string, model = "claude-sonnet-4" return { state, description, model, provider: "anthropic", harness: "opencode" }; } -const SEED_WORKSPACES: Workspace[] = [ +type SeedWorkspace = Omit & { ephemeral?: Workspace["ephemeral"] }; + +const SEED_WORKSPACES: Workspace[] = ([ { name: "ws-4f2a", repoName: "api-gateway", ship: "forge-01", branch: "main", active: true, agent: agent("building", "Implementing request routing") }, - { name: "ws-9c11", repoName: "api-gateway", ship: "forge-01", branch: "fix/rate-limit", active: true, agent: agent("verifying", "Running rate-limit tests") }, + { + name: "ws-9c11", + repoName: "api-gateway", + ship: "forge-01", + branch: "88-rate-limit-headers", + active: true, + agent: agent("verifying", "Running rate-limit tests"), + ephemeral: { + issueNumber: 88, + branch: "88-rate-limit-headers", + cleanup: "watching", + blockedReason: null, + blockedAt: null, + pullRequest: { number: 214, state: "open", url: "https://github.com/acme/api-gateway/pull/214" }, + }, + }, { name: "ws-2e70", repoName: "api-gateway", ship: "atlas-7", branch: "release/2.3", active: false, agent: null }, { name: "ws-6b83", repoName: "auth-svc", ship: "forge-02", branch: "main", active: true, agent: agent("planning", "Tracing token refresh flow") }, { name: "ws-d904", repoName: "auth-svc", ship: "nimbus", branch: "feat/oauth-pkce", active: false, agent: null }, @@ -42,7 +59,24 @@ const SEED_WORKSPACES: Workspace[] = [ { name: "ws-0a3e", repoName: "data-pipeline", ship: "forge-02", branch: "spike/backfill", active: false, agent: null }, { name: "ws-b6d1", repoName: "search-idx", ship: "atlas-7", branch: "main", active: true, agent: null }, { name: "ws-e812", repoName: "mobile-bff", ship: "nimbus", branch: "feat/push", active: true, agent: agent("planning", "Reviewing push delivery paths") }, -]; + { + name: "ws-a071", + repoName: "billing", + ship: "nimbus", + branch: "41-proration-rounding", + active: false, + agent: null, + ephemeral: { + issueNumber: 41, + branch: "41-proration-rounding", + cleanup: "blocked", + blockedReason: + "workspace billing/ws-a071 holds work that is not on a remote: 2 commits not on any remote", + blockedAt: "2026-08-02T09:14:00.000Z", + pullRequest: { number: 118, state: "closed", url: "https://github.com/acme/billing/pull/118" }, + }, + }, +] as SeedWorkspace[]).map((workspace) => ({ ephemeral: null, ...workspace })); function key(repo: string, name: string): string { return `${repo}/${name}`; @@ -540,8 +574,12 @@ export class MockFleetBridge implements FleetBridge { name: string; branch?: string; issueNumber?: number; + ephemeral?: boolean; }): Promise { const source = branchSource(input); + if (input.ephemeral && !("issueNumber" in source)) { + throw new Error("an ephemeral workspace is created from an issue"); + } if (!this.ships.some((s) => s.name === input.ship)) throw new Error(`unknown ship: ${input.ship}`); if (!this.repos.some((r) => r.name === input.repoName)) throw new Error(`unknown repo: ${input.repoName}`); if (this.workspaces.some((w) => w.repoName === input.repoName && w.name === input.name)) { @@ -559,6 +597,17 @@ export class MockFleetBridge implements FleetBridge { branch, active: false, agent: null, + ephemeral: + input.ephemeral && "issueNumber" in source + ? { + issueNumber: source.issueNumber, + branch, + cleanup: "watching", + blockedReason: null, + blockedAt: null, + pullRequest: null, + } + : null, }; this.workspaces.push(ws); this.emit({ type: "workspace.created", at: new Date().toISOString(), workspace: { ...ws } }); @@ -568,7 +617,14 @@ export class MockFleetBridge implements FleetBridge { async getWorkspace(repo: string, name: string): Promise { const w = this.find(repo, name); if (!w.active) { - return { state: "inactive", repoName: w.repoName, name: w.name, branch: w.branch, ship: w.ship }; + return { + state: "inactive", + repoName: w.repoName, + name: w.name, + branch: w.branch, + ship: w.ship, + ephemeral: w.ephemeral, + }; } return { state: "active", @@ -580,6 +636,7 @@ export class MockFleetBridge implements FleetBridge { issue: null, mergeRequest: null, ship: w.ship, + ephemeral: w.ephemeral, }; } diff --git a/packages/fleet-client/src/data/provider.ts b/packages/fleet-client/src/data/provider.ts index 95b204f..45943bb 100644 --- a/packages/fleet-client/src/data/provider.ts +++ b/packages/fleet-client/src/data/provider.ts @@ -45,6 +45,7 @@ export interface FleetBridge { name: string; branch?: string; issueNumber?: number; + ephemeral?: boolean; }): Promise; /** `GET /workspaces/:repo/:name` — detailed status (diff, ship, …). */ getWorkspace(repo: string, name: string): Promise; diff --git a/packages/fleet-client/src/data/types.ts b/packages/fleet-client/src/data/types.ts index 23bf537..91eb9c9 100644 --- a/packages/fleet-client/src/data/types.ts +++ b/packages/fleet-client/src/data/types.ts @@ -3,7 +3,7 @@ * exported from `fleet-bridge`, so they are mirrored here. */ -import type { ArmorySyncState, WorkspaceSummary, WorkspaceStatus } from "fleet-protocol"; +import type { ArmorySyncState, EphemeralWorkspace, WorkspaceSummary, WorkspaceStatus } from "fleet-protocol"; export type { Repo } from "fleet-protocol"; export type { @@ -25,7 +25,11 @@ export interface Ship { readonly status: ShipStatus; } -export type Workspace = WorkspaceSummary & { readonly ship: string }; +export type Workspace = WorkspaceSummary & { + readonly ship: string; + /** Null unless the bridge will delete this workspace when its issue closes. */ + readonly ephemeral: EphemeralWorkspace | null; +}; export type WorkspaceEvent = | { readonly type: "sync"; readonly at: string; readonly workspaces: Workspace[] } @@ -61,7 +65,10 @@ export interface RepoIssue { } /** Detail: `WorkspaceStatus` with `ship` guaranteed on both variants. */ -export type WorkspaceDetail = WorkspaceStatus & { readonly ship: string }; +export type WorkspaceDetail = WorkspaceStatus & { + readonly ship: string; + readonly ephemeral: EphemeralWorkspace | null; +}; /** * A row of `GET /armory/ships`. `state` is null when the bridge could not ask diff --git a/packages/fleet-client/src/lib/create-workspace.ts b/packages/fleet-client/src/lib/create-workspace.ts index 7462af1..627f620 100644 --- a/packages/fleet-client/src/lib/create-workspace.ts +++ b/packages/fleet-client/src/lib/create-workspace.ts @@ -64,6 +64,8 @@ export interface CreateWorkspaceForm { readonly name: string; /** Whether the "Create from issue" checkbox is ticked. */ readonly fromIssue: boolean; + /** Whether the "Ephemeral" checkbox is ticked; only issue mode can send it. */ + readonly ephemeral: boolean; readonly branch: string; readonly issue: RepoIssue | null; } @@ -75,6 +77,7 @@ export interface CreateWorkspaceInput { readonly name: string; readonly branch?: string; readonly issueNumber?: number; + readonly ephemeral?: boolean; } /** @@ -92,7 +95,13 @@ export function createWorkspaceInput(form: CreateWorkspaceForm): CreateWorkspace if (form.fromIssue) { if (!form.issue) return null; - return { ship: form.ship, repoName: form.repoName, name, issueNumber: form.issue.number }; + return { + ship: form.ship, + repoName: form.repoName, + name, + issueNumber: form.issue.number, + ephemeral: form.ephemeral, + }; } const branch = form.branch.trim(); diff --git a/packages/fleet-client/src/routes/RepoRoute.tsx b/packages/fleet-client/src/routes/RepoRoute.tsx index 848527b..b59f2d5 100644 --- a/packages/fleet-client/src/routes/RepoRoute.tsx +++ b/packages/fleet-client/src/routes/RepoRoute.tsx @@ -5,6 +5,7 @@ import { useFleet } from "@/data/FleetContext"; import { CreateWorkspaceModal } from "@/components/CreateWorkspaceModal"; import { RowLabel } from "./ReposRoute"; import { agentStateColor } from "@/lib/agent-status"; +import { EphemeralBadge } from "@/components/Ephemeral"; const COLS = "140px 190px 150px 90px 100px minmax(220px,1fr) 150px 120px 120px"; @@ -93,7 +94,10 @@ export function RepoRoute() { )} style={{ gridTemplateColumns: COLS }} > - ◇ {w.name} + + ◇ {w.name} + {w.ephemeral && } + BRANCH ⎇ {w.branch} diff --git a/packages/fleet-client/src/routes/WorkspaceRoute.tsx b/packages/fleet-client/src/routes/WorkspaceRoute.tsx index d082653..62ba7db 100644 --- a/packages/fleet-client/src/routes/WorkspaceRoute.tsx +++ b/packages/fleet-client/src/routes/WorkspaceRoute.tsx @@ -6,6 +6,7 @@ import { WorkspacePanel } from "@/components/WorkspacePanel"; import { SwitchBranchModal } from "@/components/SwitchBranchModal"; import { ConfirmDeleteModal } from "@/routes/ReposRoute"; import { agentStateColor } from "@/lib/agent-status"; +import { EphemeralNote } from "@/components/Ephemeral"; export function WorkspaceRoute() { const { repo = "", name = "" } = useParams(); @@ -48,6 +49,7 @@ export function WorkspaceRoute() { model {agent?.model ?? "—"} provider {agent?.provider ?? "—"} harness {agent?.harness ?? "—"} + {ws.ephemeral && }
diff --git a/packages/fleet-client/tests/create-workspace.test.ts b/packages/fleet-client/tests/create-workspace.test.ts index d1baeaa..16659bf 100644 --- a/packages/fleet-client/tests/create-workspace.test.ts +++ b/packages/fleet-client/tests/create-workspace.test.ts @@ -32,6 +32,7 @@ const form = (patch: Partial = {}): CreateWorkspaceForm => repoName: "api-gateway", name: "ws-1", fromIssue: false, + ephemeral: true, branch: "main", issue: null, ...patch, @@ -106,10 +107,24 @@ describe("createWorkspaceInput", () => { // sources, and `branch: ""` names one. const input = createWorkspaceInput(form({ fromIssue: true, branch: "main", issue: ISSUE })); - expect(input).toEqual({ ship: "forge-01", repoName: "api-gateway", name: "ws-1", issueNumber: 12 }); + expect(input).toEqual({ + ship: "forge-01", + repoName: "api-gateway", + name: "ws-1", + issueNumber: 12, + ephemeral: true, + }); expect(input && "branch" in input).toBe(false); }); + test("only issue mode carries the ephemeral flag", () => { + expect(createWorkspaceInput(form({ fromIssue: true, issue: ISSUE, ephemeral: false }))).toMatchObject({ + ephemeral: false, + }); + const branchMode = createWorkspaceInput(form({ ephemeral: true, branch: "feat/x" })); + expect(branchMode && "ephemeral" in branchMode).toBe(false); + }); + test("issue mode with nothing selected is not submittable", () => { // Even with a perfectly good branch sitting in the other mode's field. expect(createWorkspaceInput(form({ fromIssue: true, branch: "main", issue: null }))).toBeNull(); diff --git a/packages/fleet-client/tests/ephemeral.test.ts b/packages/fleet-client/tests/ephemeral.test.ts new file mode 100644 index 0000000..8f0746f --- /dev/null +++ b/packages/fleet-client/tests/ephemeral.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import type { EphemeralWorkspace } from "fleet-protocol"; +import { ephemeralSummary } from "@/components/Ephemeral"; + +const watching: EphemeralWorkspace = { + issueNumber: 37, + branch: "37-add-ephemeral-workspaces", + cleanup: "watching", + blockedReason: null, + blockedAt: null, + pullRequest: null, +}; + +describe("ephemeralSummary", () => { + test("says when no pull request has been opened yet", () => { + expect(ephemeralSummary(watching)).toBe("issue #37 · no pull request yet"); + }); + + test("names the pull request the last sweep saw", () => { + expect( + ephemeralSummary({ + ...watching, + pullRequest: { number: 41, state: "open", url: "https://example.test/41" }, + }), + ).toBe("issue #37 · PR #41 open"); + }); + + test("carries the refusal when cleanup is blocked", () => { + expect( + ephemeralSummary({ + ...watching, + cleanup: "blocked", + blockedReason: "2 commits not on any remote", + blockedAt: "2026-08-03T00:00:00.000Z", + pullRequest: { number: 41, state: "closed", url: "https://example.test/41" }, + }), + ).toBe("issue #37 · PR #41 closed · cleanup blocked: 2 commits not on any remote"); + }); + + test("does not pretend to know a reason it was not given", () => { + expect(ephemeralSummary({ ...watching, cleanup: "blocked" })).toBe( + "issue #37 · no pull request yet · cleanup blocked: reason unknown", + ); + }); +}); diff --git a/packages/fleet-client/tests/workspace-events.test.ts b/packages/fleet-client/tests/workspace-events.test.ts index a63393d..0b12e71 100644 --- a/packages/fleet-client/tests/workspace-events.test.ts +++ b/packages/fleet-client/tests/workspace-events.test.ts @@ -18,6 +18,7 @@ const workspace = (name: string, agent: AgentStatus | null = null): Workspace => active: agent !== null, agent, ship: "ship-a", + ephemeral: null, }); describe("applyWorkspaceEvent", () => { diff --git a/packages/fleet-client/tests/workspace-mutations.test.ts b/packages/fleet-client/tests/workspace-mutations.test.ts index ef08996..0d430ea 100644 --- a/packages/fleet-client/tests/workspace-mutations.test.ts +++ b/packages/fleet-client/tests/workspace-mutations.test.ts @@ -319,6 +319,7 @@ describe("MockFleetBridge create-workspace surface", () => { branch: "feature/x", active: false, agent: null, + ephemeral: null, }); }); }); From 99c76f04619d2be33cdf7adeae386d7fe3a70bf6 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 3 Aug 2026 20:32:09 -0500 Subject: [PATCH 7/8] Document ephemeral workspaces and the sweep Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/src/launch-config.ts | 3 + apps/docs/src/content/docs/concepts/bridge.md | 17 +++-- .../src/content/docs/concepts/workspaces.md | 35 ++++++++++ .../docs/guides/configuring-a-fleet.md | 2 +- .../docs/guides/managing-workspaces.md | 41 ++++++++++++ .../src/content/docs/reference/bridge-api.md | 64 +++++++++++++++++-- apps/docs/src/content/docs/reference/cli.md | 2 +- .../content/docs/reference/fleet-config.md | 3 +- .../src/content/docs/reference/protocol.md | 24 ++++++- .../src/content/docs/reference/ship-api.md | 27 +++++++- 10 files changed, 198 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/launch-config.ts b/apps/cli/src/launch-config.ts index 077a192..56caf61 100644 --- a/apps/cli/src/launch-config.ts +++ b/apps/cli/src/launch-config.ts @@ -20,6 +20,8 @@ const BridgeSectionSchema = z.object({ * for any ship on another machine. */ publicUrl: z.string().min(1).optional(), + /** How often to check ephemeral workspaces for a closed pull request; `0` never checks. */ + sweepIntervalMs: z.number().int().nonnegative().optional(), }); const GuiSectionSchema = z.object({ @@ -73,6 +75,7 @@ export interface NormalizedBridge { port: number; name: string; publicUrl?: string; + sweepIntervalMs?: number; } export interface NormalizedLocalShip { diff --git a/apps/docs/src/content/docs/concepts/bridge.md b/apps/docs/src/content/docs/concepts/bridge.md index 7976c76..fd9d020 100644 --- a/apps/docs/src/content/docs/concepts/bridge.md +++ b/apps/docs/src/content/docs/concepts/bridge.md @@ -18,17 +18,22 @@ Like a ship, it is configured entirely from flags. ## What the bridge owns, and what it doesn't -The bridge owns exactly two pieces of durable state, both persisted as JSON in -its data directory: +The bridge owns three pieces of durable state, each persisted as JSON in its +data directory: - **`ships.json`** — the roster: each ship's name and URL. - **`repos.json`** — the repo registry: name, clone URL, provider. +- **`ephemeral.json`** — which workspaces to delete once their issue's pull + request closes, and what the last cleanup attempt found. See [ephemeral + workspaces](/concepts/workspaces/#ephemeral-workspaces). -It owns **no** workspace state. Workspaces live on ships, and the bridge's view -of them is derived, in memory, from what the ships report over their `/events` -sockets. Restart the bridge and that view is rebuilt from scratch. +It owns **no** workspace state beyond that last file, which says what should +*become* of a workspace rather than what one is. Workspaces live on ships, and +the bridge's view of them is derived, in memory, from what the ships report over +their `/events` sockets. Restart the bridge and that view is rebuilt from +scratch. -Both files are written atomically (temp file, `fsync`, rename) and every store +All three are written atomically (temp file, `fsync`, rename) and every store operation is serialized through a queue, so a crash mid-write can't leave a half-written roster. diff --git a/apps/docs/src/content/docs/concepts/workspaces.md b/apps/docs/src/content/docs/concepts/workspaces.md index a4d4e8b..d0ea589 100644 --- a/apps/docs/src/content/docs/concepts/workspaces.md +++ b/apps/docs/src/content/docs/concepts/workspaces.md @@ -93,6 +93,41 @@ before the delete, so consumers can identify what went away. Each of those emits an event on `/events` — see [Events](/concepts/events/). +## Ephemeral workspaces + +A workspace created from an issue can be marked **ephemeral**, which asks the +bridge to delete it once the work it was opened for is finished. Nothing about +the workspace on the ship is different; the bridge keeps a record of it in +`ephemeral.json` next to `ships.json`, and acts on that record. + +The bridge re-reads every ephemeral record on a timer — five minutes by default, +set with `sweepIntervalMs`. A pass asks the repo's provider for the pull +requests whose head is the branch that was linked to the issue at create time, +and cleans the workspace up when either: + +- the branch has at least one pull request and **none of them are open** — + merged and closed-without-merging both count; or +- the branch has **no pull requests at all** and the **issue itself is closed**. + +The branch is pinned when the workspace is created. Switching the workspace to +another branch afterwards does not re-point the watch, and does not cancel it. + +Cleanup goes through the ship's non-forcing delete, so it destroys nothing that +cannot be fetched again from the remote. If the workspace holds uncommitted +changes, commits no remote has, or a stash, the ship refuses and the record +turns `blocked`, carrying the ship's own explanation. A blocked workspace stays +where it is, shows the reason wherever the workspace is listed, and is retried +on the next pass — push the work, or delete it by hand, and it goes away. + +Nothing is written to the forge: the branch, the pull request, and the issue are +left exactly as they are. Deleting the head branch after a merge is a repo +setting on the forge itself, not something the bridge does for you. + +A record is dropped — leaving the workspace as an ordinary one — when the repo +is unregistered, when the ship is removed from the fleet, or when the workspace +is deleted by hand. A workspace that has vanished is only forgotten once its +ship is online to say so, so a rebooting ship never quietly disarms the watch. + ## What a workspace reports The list view (`GET /workspaces`) returns a summary per workspace: `repoName`, diff --git a/apps/docs/src/content/docs/guides/configuring-a-fleet.md b/apps/docs/src/content/docs/guides/configuring-a-fleet.md index 4e5abd2..4917073 100644 --- a/apps/docs/src/content/docs/guides/configuring-a-fleet.md +++ b/apps/docs/src/content/docs/guides/configuring-a-fleet.md @@ -66,7 +66,7 @@ ships: | Field | Default | Meaning | | --------------- | ------------------ | ------- | -| `dataDirectory` | `./.fleet/bridge` | Where `ships.json` and `repos.json` are persisted. Resolved to an absolute path. | +| `dataDirectory` | `./.fleet/bridge` | Where `ships.json`, `repos.json` and `ephemeral.json` are persisted. Resolved to an absolute path. | | `port` | `4800` | HTTP + WebSocket port. | | `name` | `bridge` | Human-facing name of the bridge. | diff --git a/apps/docs/src/content/docs/guides/managing-workspaces.md b/apps/docs/src/content/docs/guides/managing-workspaces.md index 9c4af00..70161ac 100644 --- a/apps/docs/src/content/docs/guides/managing-workspaces.md +++ b/apps/docs/src/content/docs/guides/managing-workspaces.md @@ -170,6 +170,47 @@ This kills the tmux session if one is up, then recursively deletes the workspace directory. Uncommitted or unpushed work in that clone is gone — nothing pushes for you. +The ship also takes `?force=false` on that endpoint, which refuses the delete +with a `409` when the clone holds anything no remote has: a dirty working tree +(untracked files included), commits missing from every remote on *any* local +branch, or a stash. The CLI and the web GUI both delete unconditionally — the +non-forcing form is what the bridge's ephemeral cleanup uses. + +## Ephemeral workspaces + +When you create a workspace from an issue in the web GUI, **Ephemeral** is +ticked by default. The bridge then deletes that workspace on its own once every +pull request on the linked branch has closed — or, if no pull request was ever +opened, once the issue itself closes. See +[Workspaces](/concepts/workspaces/#ephemeral-workspaces) for the exact rules. + +Ephemeral workspaces are labelled wherever they appear, with the issue and the +pull request the last sweep saw: + +``` +◇ ws-9c11 ⧗ EPHEMERAL issue #88 · PR #214 open +``` + +Cleanup never destroys work the remote does not have. When it is refused, the +workspace stays put and the label turns red with the reason: + +``` +◇ ws-a071 ⚠ EPHEMERAL issue #41 · PR #118 closed · + cleanup blocked: 2 commits not on any remote +``` + +That is a state you resolve, not one the fleet resolves for you: push the branch +(or delete the workspace yourself), and the next sweep clears it. To check +immediately rather than waiting for the timer: + +```bash +curl -X POST http://localhost:4800/workspaces/sweep +``` + +```json +{ "checked": 3, "destroyed": 1, "blocked": 1, "skipped": 0, "forgotten": 0 } +``` + ## Where this maps in the API Every command above is a thin wrapper over one ship endpoint diff --git a/apps/docs/src/content/docs/reference/bridge-api.md b/apps/docs/src/content/docs/reference/bridge-api.md index 43281a7..71a1898 100644 --- a/apps/docs/src/content/docs/reference/bridge-api.md +++ b/apps/docs/src/content/docs/reference/bridge-api.md @@ -20,11 +20,11 @@ adds ship management, a repo registry, and an aggregate system-resources view. | `GET /workspaces` | Same path. Merged across ships, deduped, each row gains `ship`. | | `GET /workspaces/:repo/:name` | Same path. Proxied live to the owning ship; response gains `ship` on **both** the `active` and `inactive` variants. | | `GET /workspaces/:repo/:name/diff` | Same path and query. Proxied verbatim. | -| `POST /workspaces` | Same path, **different body**: `{ship, repoName, name, branch \| issueNumber}` instead of `{url, repoName, name, branch}`. The clone URL comes from the bridge's repo registry, and the branch may be named outright or derived from an issue. Response gains `ship`. | +| `POST /workspaces` | Same path, **different body**: `{ship, repoName, name, branch \| issueNumber, ephemeral?}` instead of `{url, repoName, name, branch}`. The clone URL comes from the bridge's repo registry, and the branch may be named outright or derived from an issue. Response gains `ship` and `ephemeral`. | | `POST /workspaces/:repo/:name/branch` | Same. | | `POST /workspaces/:repo/:name/activate` | Same. | | `POST /workspaces/:repo/:name/deactivate` | Same. | -| `DELETE /workspaces/:repo/:name` | Same. | +| `DELETE /workspaces/:repo/:name` | Same, minus the ship's `force` query — the bridge always deletes unconditionally here. Its own ephemeral cleanup uses `force=false` against the ship. | | `WS /workspaces/:repo/:name/terminal` | Same path; a bidirectional pipe to the owning ship's terminal. | | `WS /events` | Same path, **different frames**: no top-level `ship`, and every workspace carries `ship`. | | `GET /system-resources` | Same path, **different shape**: an array with one entry per ship. The single-host snapshot moves to `GET /ships/:ship/system-resources`. | @@ -371,10 +371,29 @@ than only the event stream. branch: string; active: boolean; agent: AgentStatus | null; - ship: string; // the extra field + ship: string; // the extra fields + ephemeral: EphemeralWorkspace | null; }[] ``` +`ephemeral` is the bridge's own record — a ship knows nothing about it — and is +`null` for every ordinary workspace: + +```ts +// EphemeralWorkspace +{ + issueNumber: number; + branch: string; // the branch linked to the issue at create time + cleanup: "watching" | "blocked"; + blockedReason: string | null; // the ship's refusal, truncated to 200 chars + blockedAt: string | null; // ISO-8601 + pullRequest: { number: number; state: string; url: string } | null; +} +``` + +`pullRequest` is what the last sweep saw, so it lags the forge by up to one +sweep interval. Render it; do not branch on it. + ### `GET /workspaces/:repo/:name` Proxied live to the owning ship, so the diff is fresh. The response is the @@ -383,11 +402,16 @@ returned — meaning `inactive` responses carry `ship` here even though they do not on a ship. ```ts -{ state: "inactive"; repoName; name; branch; ship: string } +{ state: "inactive"; repoName; name; branch; ship: string; + ephemeral: EphemeralWorkspace | null } { state: "active"; repoName; name; branch; diff; agent; issue: null; - mergeRequest: null; ship: string } + mergeRequest: null; ship: string; ephemeral: EphemeralWorkspace | null } ``` +`issue` and `mergeRequest` are the ship's own fields and are always `null`; +`ephemeral` is the bridge's, and is where an issue-linked workspace's state +actually lives. + The bridge re-validates the ship's response: an unparseable status, or one whose `repoName`/`name` differ from the request, is a `502`. @@ -403,7 +427,7 @@ text. // request body — ship, repoName and name are required; // exactly one of branch / issueNumber must be present { ship: string; repoName: string; name: string; - branch?: string; issueNumber?: number } + branch?: string; issueNumber?: number; ephemeral?: boolean } ``` `ship` names the target host and `repoName` must be a **registered repo**; the @@ -439,7 +463,7 @@ the clone then fails, so a retry reuses it. | Status | Cause | | --- | --- | | `422` | `ship`, `repoName` or `name` is missing. | -| `400` | Invalid repo/workspace identifier; `unknown ship: `; `unknown repo: `; both `branch` and `issueNumber`, or neither; a blank `branch`; an `issueNumber` that is not a positive integer. | +| `400` | Invalid repo/workspace identifier; `unknown ship: `; `unknown repo: `; both `branch` and `issueNumber`, or neither; a blank `branch`; an `issueNumber` that is not a positive integer; `ephemeral` without `issueNumber`. | | provider's status | Any error resolving or linking the issue is passed through with the provider's own status — e.g. `401` with no token, `403` for a token without repo write scope, `404` for an unknown issue, `409` when the branch could be neither created nor found under the requested name. | | `503` | `ship "" is offline`. | | `409` | `workspace already exists: /`; a create for that key is already in progress; the key's create outcome is indeterminate; the target ship was removed mid-request. | @@ -455,6 +479,32 @@ clears itself when the ship reports the workspace, or when that ship is deregistered. ::: +`ephemeral: true` additionally records the workspace for automatic cleanup, and +is only accepted alongside `issueNumber`. The record is written after the ship +confirms the clone, so a failed create leaves nothing behind. See +[`POST /workspaces/sweep`](#post-workspacessweep) and +[Workspaces](/concepts/workspaces/#ephemeral-workspaces). + +### `POST /workspaces/sweep` + +Runs one ephemeral-cleanup pass immediately instead of waiting for the timer +(`sweepIntervalMs`, five minutes by default). No request body. + +```json +{ "checked": 3, "destroyed": 1, "blocked": 1, "skipped": 0, "forgotten": 0 } +``` + +| Field | Meaning | +| --- | --- | +| `checked` | records whose pull requests were read this pass | +| `destroyed` | workspaces deleted | +| `blocked` | cleanups the ship refused because the clone holds work no remote has | +| `skipped` | left for a later pass — an offline ship, or a provider that could not answer | +| `forgotten` | records dropped because the workspace is gone and its ship was online to say so | + +Passes never overlap: calling this while one is running returns that pass's +result rather than starting a second. + ### `POST /workspaces/:repo/:name/branch` ```ts diff --git a/apps/docs/src/content/docs/reference/cli.md b/apps/docs/src/content/docs/reference/cli.md index bc0de09..c4e1e74 100644 --- a/apps/docs/src/content/docs/reference/cli.md +++ b/apps/docs/src/content/docs/reference/cli.md @@ -446,7 +446,7 @@ loads the persisted ship roster, connects to every ship, and serves the API. | --- | --- | --- | --- | | `-p, --port` | `` | `4800` | Port the HTTP + WebSocket API listens on. Must parse as an integer. | | `-n, --name` | `` | `bridge` | Human-facing name of this bridge. Any non-empty string. | -| `-d, --data-directory` | `` | `./.fleet-bridge` | Directory the bridge persists `ships.json` and `repos.json` to, and holds the `armory/` it distributes. Resolved to an absolute path. | +| `-d, --data-directory` | `` | `./.fleet-bridge` | Directory the bridge persists `ships.json`, `repos.json` and `ephemeral.json` to, and holds the `armory/` it distributes. Resolved to an absolute path. | | `--public-url` | `` | `http://localhost:` | URL ships should use to reach this bridge. Handed to each ship so it can pull the [armory](/guides/the-armory/), so it must resolve from the ships' hosts. | If two reachable ships hold the same `/` at startup, the bridge diff --git a/apps/docs/src/content/docs/reference/fleet-config.md b/apps/docs/src/content/docs/reference/fleet-config.md index d4ad567..bfe4889 100644 --- a/apps/docs/src/content/docs/reference/fleet-config.md +++ b/apps/docs/src/content/docs/reference/fleet-config.md @@ -66,10 +66,11 @@ Every field has a default, so `bridge: {}` is valid. | Field | Type | Required | Default | Meaning | | --- | --- | --- | --- | --- | -| `dataDirectory` | string (non-empty) | no | `./.fleet/bridge` | Where the bridge persists `ships.json` and `repos.json`, and where its `armory/` directory lives. Resolved to an absolute path. | +| `dataDirectory` | string (non-empty) | no | `./.fleet/bridge` | Where the bridge persists `ships.json`, `repos.json` and `ephemeral.json`, and where its `armory/` directory lives. Resolved to an absolute path. | | `port` | integer | no | `4800` | Port the bridge's HTTP + WebSocket API listens on. | | `name` | string (non-empty) | no | `bridge` | Human-facing name of the bridge. | | `publicUrl` | string (non-empty) | no | `http://localhost:` | URL **ships** use to reach this bridge. | +| `sweepIntervalMs` | integer ≥ 0 | no | `300000` (5 minutes) | How often to check [ephemeral workspaces](/concepts/workspaces/#ephemeral-workspaces) for a closed pull request. `0` turns the sweep off, leaving `POST /workspaces/sweep` as the only way to run one. | :::note The `dataDirectory` default here (`./.fleet/bridge`) is *not* the same as the diff --git a/apps/docs/src/content/docs/reference/protocol.md b/apps/docs/src/content/docs/reference/protocol.md index 5a87463..8018bb5 100644 --- a/apps/docs/src/content/docs/reference/protocol.md +++ b/apps/docs/src/content/docs/reference/protocol.md @@ -242,7 +242,8 @@ that hosts it. See [events](/concepts/events/). ## Ships and repos These are the records the bridge persists (`ships.json`, `repos.json`) and -serves. +serves. The third file, `ephemeral.json`, holds `EphemeralWorkspaceSchema` below +plus the `repoName`/`name`/`ship` naming the workspace it belongs to. ```ts const ShipSchema = z.object({ @@ -265,6 +266,27 @@ const CreateRepoInputSchema = RepoSchema.omit({ provider: true }) `GET /ships` — the same two fields plus a `status` of `"online" | "offline"` — is a bridge-local type, not part of this package. +## Ephemeral workspaces + +The bridge's per-workspace cleanup state, carried on every workspace the bridge +serves as `ephemeral` (`null` for ordinary workspaces). A ship neither stores nor +reports it. See [ephemeral +workspaces](/concepts/workspaces/#ephemeral-workspaces). + +```ts +const EphemeralWorkspaceSchema = z.object({ + issueNumber: z.number().int().positive(), + branch: z.string(), // linked to the issue at create time, then pinned + cleanup: z.enum(["watching", "blocked"]), + blockedReason: z.string().max(200).nullable().default(null), // the ship's own refusal + blockedAt: z.string().nullable().default(null), // ISO-8601 + pullRequest: z + .object({ number: z.number().int().positive(), state: z.string(), url: z.string() }) + .nullable() + .default(null), +}); +``` + ## System resources A plain interface (no schema), reported by a ship's `GET /system-resources`. diff --git a/apps/docs/src/content/docs/reference/ship-api.md b/apps/docs/src/content/docs/reference/ship-api.md index 0d9eec7..6b5347d 100644 --- a/apps/docs/src/content/docs/reference/ship-api.md +++ b/apps/docs/src/content/docs/reference/ship-api.md @@ -200,9 +200,30 @@ Emits `workspace.deactivated`. Kills the session if one is up, deletes the workspace directory recursively, and clears its agent status. Responds `{ ok: true }`. -Errors: `404` workspace not found. Emits `workspace.removed`, whose -`workspace.branch` is the branch captured immediately before deletion (`""` if -it could not be read). +| Query | Type | Default | Meaning | +| --- | --- | --- | --- | +| `force` | boolean | absent (unconditional) | `false` refuses the delete when the clone holds work no remote has | + +With `force=false` the ship checks three things before touching anything, and +answers `409` naming everything it found — for example +`workspace repo/ws holds work that is not on a remote: 1 uncommitted file; 2 +commits not on any remote`: + +- a working tree that is not clean, untracked files included; +- commits absent from every remote on **any** local branch, not just the one + checked out (`git log --branches --not --remotes`) — so a branch that was + never pushed counts in full; +- a stash. + +A check that cannot be run counts as work held: refusing to delete is the +recoverable mistake. Omitting `force` keeps the unconditional behaviour, which +is what the CLI, the web GUI, and the bridge's own `DELETE` all use; the bridge +passes `force=false` only for [ephemeral +cleanup](/concepts/workspaces/#ephemeral-workspaces). + +Errors: `404` workspace not found; `409` as above. Emits `workspace.removed`, +whose `workspace.branch` is the branch captured immediately before deletion +(`""` if it could not be read). ## `POST /workspaces/:repo/:name/agent/init` From 1e071c6c3d50a3c13a399dfcfe02ad499ae03bf7 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 3 Aug 2026 20:32:09 -0500 Subject: [PATCH 8/8] Drop two comments that restate the store's own code Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-bridge/src/store/store.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/fleet-bridge/src/store/store.ts b/packages/fleet-bridge/src/store/store.ts index c824065..6a85682 100644 --- a/packages/fleet-bridge/src/store/store.ts +++ b/packages/fleet-bridge/src/store/store.ts @@ -14,7 +14,6 @@ import { workspaceKey } from "../types"; type Persist = (target: string, contents: string) => Promise; -/** An ephemeral workspace as persisted: its public block plus where it lives. */ export const EphemeralWorkspaceRecordSchema = EphemeralWorkspaceSchema.extend({ repoName: FleetIdentifierSchema, name: FleetIdentifierSchema, @@ -31,7 +30,6 @@ export class RepoAlreadyExistsError extends Error { } interface Keying { - /** The map key an item is stored under. */ readonly of: (item: T) => string; /** The fields `of` reads, reapplied after an update so a merge cannot move a record. */ readonly identity: (item: T) => Partial;