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
2 changes: 1 addition & 1 deletion apps/mobile/src/computer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function ComputerEntry() {
<Monitor size={13} color={colors.muted} />
<Text style={{ fontSize: 12, color: colors.muted }}>
Computer
{active ? " · take control" : available ? " · ready" : " · offline"}
{!available ? " · offline" : active ? " · take control" : " · ready"}
</Text>
<View
style={{
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,17 @@ export async function createApp(
await next();
});
app.get("/api/workspace", async (c) => {
const snapshot = await workspace.snapshot(c.get("owner"), c.req.query("q"));
const [snapshot, reachable] = await Promise.all([
workspace.snapshot(c.get("owner"), c.req.query("q")),
browser.reachable(),
]);
snapshot.browsers = snapshot.browsers.map((s) => browser.decorate(c.get("owner"), s));
// A configured worker that does not answer is offline, not ready.
snapshot.connections = snapshot.connections.map((connection) =>
connection.id === "browser" && connection.status === "connected" && !reachable
? { ...connection, status: "unavailable" }
: connection,
);
return c.json(snapshot);
});
app.route("/api/agent", agentRoutes(agent));
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,28 @@ type ChatBrowser = { id: string; sessionId: string };

export class BrowserService {
private readonly queues = new Map<string, Promise<unknown>>();
private health?: { checkedAt: number; reachable: Promise<boolean> };
constructor(
private readonly db: Store,
private readonly config: Config,
private readonly auth: Auth,
private readonly files: Files,
private readonly now: () => number = Date.now,
) {}
/** Whether the configured worker answers its health check, cached briefly for snapshots. */
reachable(): Promise<boolean> {
if (!this.config.workerUrl || !this.config.workerToken) return Promise.resolve(false);
const now = this.now();
if (this.health && now - this.health.checkedAt < 15_000) return this.health.reachable;
const reachable = fetch(`${this.config.workerUrl}/health`, {
signal: AbortSignal.timeout(2000),
}).then(
(response) => response.ok,
() => false,
);
this.health = { checkedAt: now, reachable };
return reachable;
}
private async serial<T>(id: string, operation: () => Promise<T>): Promise<T> {
const next = (this.queues.get(id) ?? Promise.resolve()).catch(() => {}).then(operation);
this.queues.set(id, next);
Expand Down
2 changes: 1 addition & 1 deletion packages/domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export interface ActivityEntry {
export interface Connection {
id: string;
name: string;
status: "connected" | "disconnected" | "sample" | "unconfigured";
status: "connected" | "disconnected" | "sample" | "unconfigured" | "unavailable";
account?: string;
capabilities: string[];
}
Expand Down
44 changes: 44 additions & 0 deletions tests/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,3 +530,47 @@ test("egress proxy blocks HTTP and CONNECT traffic to local network destinations
await proxy.close();
}
});

test("the browser counts as connected only while its worker answers", async (t) => {
let up = true;
let clock = 0;
const { db, config } = await browserFixture(t, (path) =>
up && path === "/health" ? { data: { status: "ok" } } : { status: 503, data: {} },
);
const auth = new Auth(db, config, "test-signing-key");
const service = new BrowserService(db, config, auth, new Files(db, config, auth), () => clock);
assert.equal(await service.reachable(), true);
up = false;
assert.equal(await service.reachable(), true, "a recent answer is reused briefly");
clock += 15_000;
assert.equal(await service.reachable(), false);
const unconfigured = new BrowserService(
db,
{ ...config, workerUrl: undefined },
auth,
new Files(db, config, auth),
);
assert.equal(await unconfigured.reachable(), false);
});

test("the workspace reports the browser offline while its worker's health check fails", async (t) => {
for (const up of [true, false]) {
const { db, config } = await browserFixture(t, (path) =>
up && path === "/health" ? { data: { status: "ok" } } : { status: 503, data: {} },
);
const { app, auth, agent } = await createApp(db, config);
t.after(() => agent.stop());
const { token } = await auth.session();
const response = await app.request("/api/workspace", {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(response.status, 200);
const { connections } = (await response.json()) as {
connections: { id: string; status: string }[];
};
assert.equal(
connections.find((connection) => connection.id === "browser")?.status,
up ? "connected" : "unavailable",
);
}
});
Loading