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
21 changes: 21 additions & 0 deletions TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@
> **Recently done** at the bottom — including the traps found while doing it, which are the parts worth
> reading before touching the same code. Full write-ups for finished items live in git history.

## P0 — before the next release

### `CredentialRelayTests.testIdleStrangersDoNotBlockAKnownWorkroom` fails under full-suite load (macapp) — #309 follow-up, issue #322

**What:** The test failed once in a full `make app-test` run with `failed - connect 54`
(`ECONNRESET`): one of its `maxConcurrent + 4` idle "stranger" connects to the relay's loopback
listener, or the final real request's connect, was reset rather than accepted. Seen on
`feat/254-remote-pane-parity` (2026-10-04, xcresult `Test-WorkroomApp-2026.10.04_08-52-46`). The
branch doesn't touch `CredentialRelay`.

**Why:** It passed 5 times out of 5 run alone, and in two other full runs the same day, so it looks
load-dependent. That is either a flaky test or a real limit: a listen backlog that the parallel
test workers' load overflows would also turn real git credential requests away while a box is
busy.

**How to start:** Find which connect fails (the strangers' or the real one) and the listener's
`listen` backlog in `CredentialRelay`. Then reproduce under load, for example with the suite's
parallel workers, or with many more strangers than `maxConcurrent`.

**Priority:** P0 (chosen at ship time on #254)

## P1 — before GA

## P2 — perf, correctness, and the next VCS phase
Expand Down
60 changes: 58 additions & 2 deletions docs/designs/remote-workrooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -1707,11 +1707,67 @@ these are the subsystems that actually gate "a remote workroom is a real workroo
hostname. From a remote agent that is a *remote* path labelled local, and
`TerminalLinkOpener.absolutePath(for:cwd:)` (`TerminalLinkOpener.swift:27-31,202`) will then
resolve a cmd-clicked file against it **on the Mac**. Week-one bug; needs a host-tagged cwd and a
link opener that routes through the file service.
link opener that routes through the file service. Done: see "As built (#254)" below.
- Per-workroom credential: enrol on create, revoke on destroy, and revoke on the failure paths too.
It is a broker grant, not a deploy key (OQ20).
- Idle state per remote workroom in the sidebar. **Cost display is deliberately deferred** —
it needs per-provider billing APIs and there is no spending policy yet (open question 7).
it needs per-provider billing APIs and there is no spending policy yet (open question 7). Idle
state done: see "As built (#254)" below.
- **As built (#254, remote pane parity).** Pane close was done first, in #306 (#283). The rest:
- **Cmd-click (C7).** A remote pane's links never reach `TerminalLinkOpener`'s local resolver.
`TerminalSessions.wireRemoteLinks` resolves a clicked path against the shell's directory on the
host, as its agent reports it (`hostCwd`, #245), or the workroom root before the first report.
`TerminalLinkOpener.remoteCandidates` keeps the local parsing (`:line[:col]`, trailing dots)
and drops what the host's file service can't read: anything outside the workroom root, and
`~`, which is this Mac's home. Each candidate is probed with a one-byte read, which the agent
answers from the file's size without sending it (`file.rs`, `read_file`). The first that exists
opens in the in-app viewer as the workroom's preview tab, since no editor on the Mac can open
a file on another machine. Web URLs still go to the Mac's browser.
- **The ⌘-click gate.** `GhosttySurfaceView.mouseDown` passes a ⌘-click on only when the
synchronous `resolveCmdHoverFile` says the word is a file, and only the host knows, a round
trip away. So the gate decides from the word alone (`TerminalLinkOpener.looksLikeRemotePath`):
a candidate in the workroom that has a `/`, a `name.ext` (dotfiles included) or a `:line`
passes, and the click asks the host, connecting it if need be (`RepositoryRouter`). A hover
never asks the host.
The costs: a path-shaped word with no file behind it shows the hand and its click opens nothing
(logged), and a bare name with none of the three (`Gemfile`) isn't clickable in a remote pane.
A URL or a path outside the workroom never passes, so it still reaches libghostty. This was
first built as a cache of the host's answers behind the gate, and each of the three review
passes found a new failure in it: a click made straight after pressing ⌘ lost, every click
taken during an outage, and probes against a dead link piling up while the gate held "yes".
The owner chose the gate that needs no host (decided 2026-10-04).
- **Ports.** `PortForwardingModel` is one model per host (`model(for:)`), kept for the launch,
and the inspector's Ports section shows the selected workroom's host. A remote workroom the
app can't reach shows no Ports section, rather than the Mac's list. A forward never connects a
host; selecting the workroom does. This closed #208's last criterion.
- **Idle state.** A remote workroom's sidebar row carries the wakefulness badge, backed by one
`WakefulnessModel` per remote host (`model(forHost:)`). It polls only while the row is shown
and never connects the host. Its "Keep awake" click does connect it first
(`RemoteHosts.ensureConnected`), as the Mac's own spawns an agent. Quitting drains every host's
in-flight keep, not only the Mac's (`WakefulnessModel.drainAllKeeps`), waiting up to 15 s for a
remote one, which may have to reconnect over ssh first; a keep slower than that can miss the
quit. The Linux agent runs the classifier unconditionally (`serve.rs`), so neither the agent nor
host setup changed. A remote badge's tooltip leaves out the settings-mismatch sentence: that
agent was never started with the Mac's settings.
- **Known limits.** The viewer opens a cmd-clicked file at the top: `FileDescriptor` has no line.
A path outside the workroom (`/etc/hosts`, a sibling checkout) opens nothing. The ceiling prompt
is still raised only for the Mac's agent: a remote host's "Keep awake" works from its badge, but
no prompt card is shown for it, and a remote keep that fails shows nothing either (#324).
`hostCwd` is asked for again each time a command finishes, one round trip later, so a click
inside that round trip after a `cd` resolves against the previous directory. `hostCwd` is the
host's resolved path while the workroom root is the recorded one, so where the root goes
through a symlink (`/home` → `/var/home`) relative cmd-clicks open nothing (#325; the
first-party container image is not affected). A remote forward is reachable by every account on
the Mac, as any `127.0.0.1` forward is; accepted for now, with peer-uid scoping tracked in #323.
- **Path handling, from the final review.** A remote path is split into components, never run
through `URL`: Foundation decomposes Unicode filenames (`é`, C3 A9, becomes `e` + U+0301,
65 CC 81), which a Linux host reads as another name. `..` is resolved here, lexically, because
the agent refuses any path with a `.` or `..` component before it opens anything
(`vcs::relative`); sending `..` through, as one review suggested, made every `../file` click
open nothing (caught on the PR). The cost: `link/../file.rb` names the file beside the link,
not beside its target. A newer click replaces the one before it, and an answer whose pane has
closed opens nothing. An error reading one path moves on to the next candidate; only a
transport failure stops the probe.

## Phase 0 Results

Expand Down
60 changes: 44 additions & 16 deletions macapp/WorkroomApp/Core/AgentPortForwarding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -894,33 +894,57 @@ private final class ForwardedConnection: @unchecked Sendable {
}
}

/// The forwards the user has asked for on the local box, and the listeners serving them.
/// The forwards the user has asked for on one host, and the listeners serving them.
///
/// One list, not one per host: only the local host has an agent today, and `wakefulness()` is shared
/// the same way. Forwards are per HOST rather than per workroom, so two workrooms on this box see
/// the same list.
/// One model per HOST (`model(for:)`), not per workroom: a forward runs on the host's agent
/// connection, so two workrooms on one host see the same list. A remote workroom is its own host
/// (#254), so in practice each remote workroom has its own list and every local one shares this
/// Mac's.
@MainActor
final class PortForwardingModel: ObservableObject {
static let shared = PortForwardingModel()
/// Each host's model, made on first use and kept for the launch: its listeners must outlive the
/// Ports panel showing another workroom and coming back. A host that goes away takes its
/// forwards with its connection (`watch()`), leaving an empty model behind.
private static var models: [HostID: PortForwardingModel] = [:]

static func model(for host: HostID) -> PortForwardingModel {
if let model = models[host] { return model }
let model = PortForwardingModel(host: host, transport: .live(host))
models[host] = model
return model
}

/// How the model reaches the agent, so a test can hand it a scripted agent and a connection
/// stream it controls — the shape `WakefulnessModel.Transport` set.
struct Transport {
/// The service and the lease of the connection it runs on. Never spawns an agent; the reasoning
/// is on `LocalAgentVCS.forwarding()`, the decision point.
/// The service and the lease of the connection it runs on. Never spawns an agent or connects a
/// host; the reasoning is on `LocalAgentVCS.forwarding()`, the decision point.
var forwarding: @Sendable () async throws -> (HostConnectionManager.Lease, AgentForwardService)
var updates: @Sendable () async -> AsyncStream<HostConnectionManager.Snapshot>
/// The connected lease right now, or nil. What `add()` reconciles against: the watch's last
/// snapshot can lag a reconnect that `forwarding` has already seen.
var current: @Sendable () async -> HostConnectionManager.Lease?

static let live = Transport(
forwarding: { try await LocalAgentVCS.shared.forwarding() },
updates: { await HostConnectionManager.shared.updates(for: .local) },
current: {
let snapshot = await HostConnectionManager.shared.snapshot(for: .local)
return snapshot.status == .connected ? snapshot.lease : nil
})
static func live(_ host: HostID) -> Transport {
var transport = on(host, manager: .shared)
if host == .local { transport.forwarding = { try await LocalAgentVCS.shared.forwarding() } }
return transport
}

/// `host`'s connection in `manager`. A remote host's connection is made by selecting its
/// workroom (`RemoteHosts.ensureConnected`, through the inspector's reads), so a forward asks
/// for the one there is and never makes one: the local reasoning in
/// `LocalAgentVCS.forwarding()` holds for a host too, and connecting one could start a
/// container the user stopped.
static func on(_ host: HostID, manager: HostConnectionManager) -> Transport {
Transport(
forwarding: { try await manager.forwarding(host: host) },
updates: { await manager.updates(for: host) },
current: {
let snapshot = await manager.snapshot(for: host)
return snapshot.status == .connected ? snapshot.lease : nil
})
}
}

struct Entry: Identifiable, Equatable {
Expand All @@ -943,11 +967,12 @@ final class PortForwardingModel: ObservableObject {
@Published private(set) var forwards: [Entry] = []
/// What went wrong adding one, or why the last forwards went away. Cleared by the next attempt.
@Published private(set) var message: String?
/// Whether the local agent connection is up, for the row's caption: the controls are useless
/// Whether the host's agent connection is up, for the row's caption: the controls are useless
/// without one, and a caption says so before the user finds out from `+`.
@Published private(set) var connected = false
@Published var draft = ""

let host: HostID
private let transport: Transport
private var listeners: [UUID: PortForward] = [:]
private var watching = false
Expand All @@ -957,7 +982,8 @@ final class PortForwardingModel: ObservableObject {
/// How many snapshots the watch has consumed, for the tests.
private(set) var snapshotsSeen = 0

init(transport: Transport = .live) {
init(host: HostID = .local, transport: Transport) {
self.host = host
self.transport = transport
}

Expand Down Expand Up @@ -1003,6 +1029,8 @@ final class PortForwardingModel: ObservableObject {
/// `VCSError` is not `LocalizedError`, so interpolating it prints its Swift case.
static func describe(_ error: Error) -> String {
switch error {
case RepositoryRoutingError.unavailable(.remote):
return "This workroom's host is not connected."
case RepositoryRoutingError.unavailable:
return "No agent is running on this Mac. Open a workroom to start one."
case VCSError.backendVersion(let detail):
Expand Down
56 changes: 54 additions & 2 deletions macapp/WorkroomApp/Core/AgentWakefulness.swift
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,9 @@ struct AwakeCeilingPromptState: Equatable {
}
}

/// What the UI reads: the local box's wakefulness, polled while it is on screen, plus the ceiling
/// prompt. One instance, because the verdict is per BOX — every workroom on this machine shares it.
/// What the UI reads: a box's wakefulness, polled while it is on screen, plus the ceiling prompt.
/// One instance per BOX, because the verdict is per box: `shared` is this Mac's, which every local
/// workroom shares, and each remote host has its own (`model(forHost:)`, #254).
@MainActor
final class WakefulnessModel: ObservableObject {
static let shared = WakefulnessModel()
Expand All @@ -418,6 +419,23 @@ final class WakefulnessModel: ObservableObject {
status: { try await LocalAgentVCS.shared.wakefulness(connecting: .never).status() },
keep: { try await LocalAgentVCS.shared.wakefulness(connecting: .spawn).keep() },
prompts: { try await LocalAgentVCS.shared.wakefulness(connecting: .reconnect).prompts })

/// A remote host's service in `manager` (#254). A poll and the prompt watch never connect
/// the host: a badge is not a reason to reach it. A "keep" runs `connect` first, as the local
/// transport spawns, because a click is the one caller for which a dropped connection is not an
/// answer (`keep()`).
static func on(
_ host: HostID, manager: HostConnectionManager,
connect: @escaping @Sendable (HostID) async throws -> Void = { _ in }
) -> Transport {
Transport(
status: { try await manager.wakefulness(host: host).status() },
keep: {
try await connect(host)
try await manager.wakefulness(host: host).keep()
},
prompts: { try await manager.wakefulness(host: host).prompts })
}
}

private let transport: Transport
Expand All @@ -426,6 +444,40 @@ final class WakefulnessModel: ObservableObject {
self.transport = transport
}

/// Each remote host's model, made on first use and kept for the launch, as
/// `PortForwardingModel`'s are. This Mac's is `shared`, whose transport may start an agent.
private static var hosts: [UUID: WakefulnessModel] = [:]

static func model(forHost id: UUID) -> WakefulnessModel {
if let model = hosts[id] { return model }
let model = WakefulnessModel(
transport: .on(.remote(id), manager: .shared) {
try await RemoteHosts.shared.ensureConnected($0)
})
hosts[id] = model
return model
}

/// Every model's in-flight "Keep awake", this Mac's and each remote host's, for quit. At once, so
/// the quit waits for the slowest, not their sum. A remote host's badge is its only keep control.
///
/// A remote keep may have to reconnect first, over ssh (`ConnectTimeout 10`), so it gets
/// `remoteKeepDrain`, longer than this Mac's. Longer still is possible (a relayed workroom's
/// relay install runs on that connect), and such a keep can miss the quit; the bound is what a
/// quit is worth.
static func drainAllKeeps() async {
await withTaskGroup(of: Void.self) { group in
group.addTask { await shared.drainKeep() }
for model in hosts.values {
group.addTask { await model.drainKeep(timeout: remoteKeepDrain) }
}
}
}

/// How long quitting waits for a remote host's in-flight keep: ssh's 10 s connect timeout, plus
/// the agent's greeting and the request.
static let remoteKeepDrain: Duration = .seconds(15)

/// Nil until a poll succeeds, and again once one fails: no agent, an agent that predates the
/// service, or a macOS agent (which answers `running: false`) all show nothing rather than a
/// guess.
Expand Down
Loading
Loading