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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/cli/src/launch-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -73,6 +75,7 @@ export interface NormalizedBridge {
port: number;
name: string;
publicUrl?: string;
sweepIntervalMs?: number;
}

export interface NormalizedLocalShip {
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/tests/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
17 changes: 11 additions & 6 deletions apps/docs/src/content/docs/concepts/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
35 changes: 35 additions & 0 deletions apps/docs/src/content/docs/concepts/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/guides/configuring-a-fleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
41 changes: 41 additions & 0 deletions apps/docs/src/content/docs/guides/managing-workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 57 additions & 7 deletions apps/docs/src/content/docs/reference/bridge-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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
Expand All @@ -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`.

Expand All @@ -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
Expand Down Expand Up @@ -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: <ship>`; `unknown repo: <repoName>`; both `branch` and `issueNumber`, or neither; a blank `branch`; an `issueNumber` that is not a positive integer. |
| `400` | Invalid repo/workspace identifier; `unknown ship: <ship>`; `unknown repo: <repoName>`; 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 "<ship>" is offline`. |
| `409` | `workspace already exists: <repo>/<name>`; a create for that key is already in progress; the key's create outcome is indeterminate; the target ship was removed mid-request. |
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ loads the persisted ship roster, connects to every ship, and serves the API.
| --- | --- | --- | --- |
| `-p, --port` | `<port>` | `4800` | Port the HTTP + WebSocket API listens on. Must parse as an integer. |
| `-n, --name` | `<name>` | `bridge` | Human-facing name of this bridge. Any non-empty string. |
| `-d, --data-directory` | `<dir>` | `./.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` | `<dir>` | `./.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` | `<url>` | `http://localhost:<port>` | 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 `<repo>/<name>` at startup, the bridge
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/src/content/docs/reference/fleet-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>` | 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
Expand Down
24 changes: 23 additions & 1 deletion apps/docs/src/content/docs/reference/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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`.
Expand Down
27 changes: 24 additions & 3 deletions apps/docs/src/content/docs/reference/ship-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 2 additions & 0 deletions packages/fleet-bridge/src/api/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ 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()),
}),
},
)
.post("/workspaces/sweep", () => manager.sweepEphemeral())
.post(
"/workspaces/:repo/:name/branch",
async ({ params, body }) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/fleet-bridge/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof BridgeConfigSchema>;

export const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;

export function defaultPublicUrl(port: number): string {
return `http://localhost:${port}`;
}
Expand Down
Loading