Public: anyone can read this; the crew can change it.

A machine's shell page says a detached session's stop time and picks it up where it was #10654

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10546-a-machine-s-shell-page-says-a-detached-s → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/10546-a-machine-s-shell-page-says-a-detached-s was merged into main as 0f70204.

Review

Sign in to review this change.
6 files changed +131 −8
docs/features/my-shells.md +10
@@ -20,6 +20,16 @@ records `end_reason = 'stopped'`. MCP: `sessions` with `kind: "shells"` and `act
2020
2121 Pinned by `test/my-shells.test.ts` and `test/tui-my-shells.test.ts`.
2222
23 ## Attached or detached (#10546)
24
25 Closing the tab does not end a shell: tmux keeps it, and the machine's line says so. On Settings › Machines (the page)
26 and on the terminal's machines screen, a machine you hold a shell on reads **attached** while a tab is open, and
27 **detached, stops at HH:MM UTC unless you come back** once the last tab has gone. The time is in UTC, not your local
28 zone: it is the later of your last keystroke and the shell's last output, plus the idle limit. Opening the shell again
29 joins the same session with its scrollback and the line goes back to attached. The line shows only your own shells,
30 never another person's; a machine with no live shell shows no line. Both doors build it with `stateLine` and
31 `machineStates` in `src/machines/shell.ts`; it sits in the row's meta so it wraps on a narrow screen.
32
2333 ## App terminals (#10549)
2434
2535 An app's terminal (`tty` in forge.json, over SSH or the forge's UI) is held in `src/apps/tty_registry.ts` while it is open: app, environment, holder, start, last input and output. `myShells` lists it as a `kind: "app"` row beside the machine shells, on the account page and in `sessions` (`kind: "shells"`). Stop and Stop all idle hang up its pty (SIGHUP, its process group ends); one idle for the shell idle limit stops itself. Only live terminals are listed: an ended one leaves no row.
src/machines/shell.ts +30
@@ -29,6 +29,7 @@ import type { App } from "../app.ts";
2929 import { cellCounters, openCell, resolveLimits, type Cell, type CellSpec } from "../apps/contain.ts";
3030 import { appTtysOf, stopAllAppTtys, stopAppTty, stopIdleAppTtys } from "../apps/tty_registry.ts";
3131 import { startTerminal, type TerminalProgram } from "../apps/tty.ts";
32 import { scheduleClock } from "../apps/feature_schedule.ts";
3233 import { holdOpen } from "../http/caller.ts";
3334 import { canWrite, getRepo, getRepoById, type Repo } from "../core/repos.ts";
3435 import { getUserById, isActive } from "../core/users.ts";
@@ -434,6 +435,35 @@ export function idle(session: { lastInput: number; lastOutput: number }, limitMs
434435 return now - session.lastInput >= limitMs && now - session.lastOutput >= limitMs;
435436 }
outdated

Both doors: no src/tui/ screen reads machineStates, and there is no docs/features entry.

436437
438 /** One live shell of a person's on a machine: attached while a connection is open, else detached and due to stop. */
439 export interface MachineState {
440 readonly attached: boolean;
441 /** When an idle session ends (ms since epoch): the later of the last keystroke and output, plus the idle limit. */
442 readonly stopsAt: number;
443 }
444
445 /** "attached", or "detached, stops at HH:MM UTC unless you come back"; undefined when no shell is live. Both doors say it. */
446 export function stateLine(state: MachineState | undefined): string | undefined {
447 if (state === undefined) return undefined;
448 return state.attached ? "attached" : `detached, stops at ${scheduleClock(state.stopsAt)} UTC unless you come back`;
449 }
450
451 /** A person's live shells in one repository, by machine name; only theirs, never another person's. */
452 export function machineStates(app: App, repoId: string, userId: string): Map<string, MachineState> {
453 const opts = options.get(app);
454 const idleMs = opts === undefined ? shellIdleMs(app.db) : idleLimit(opts);
455 const states = new Map<string, MachineState>();
456 for (const s of sessionsOf(app).values()) {
457 if (s.userId !== userId || s.repoId !== repoId || s.ended) continue;
458 const row = app.db
459 .query<{ machine: string }, [string]>("SELECT machine FROM machine_sessions WHERE id = ?")
460 .get(s.row);
461 if (row === null) continue;
462 states.set(row.machine, { attached: s.peers.size > 0, stopsAt: Math.max(s.lastInput, s.lastOutput) + idleMs });
463 }
464 return states;
465 }
466
437467 function end(app: App, session: Session, reason: EndReason): void {
438468 if (session.ended) return;
439469 session.ended = true;
src/tui/screens/machines.ts +9 −2
@@ -9,7 +9,7 @@
99 import { canWrite, type Repo } from "../../core/repos.ts";
1010 import { repoMachines } from "../../crew/machines_read.ts";
1111 import type { Machine } from "../../crew/machines.ts";
12 import { ENDED, lastSessions, type SessionRecord } from "../../machines/shell.ts";
12 import { ENDED, lastSessions, machineStates, stateLine, type MachineState, type SessionRecord } from "../../machines/shell.ts";
1313 import type { Ctx, Screen } from "../app/app.ts";
1414 import { binding } from "../app/keys.ts";
1515 import { style } from "../term/style.ts";
@@ -32,6 +32,12 @@ export function machinesLine(list: readonly Machine[]): string {
3232 return `Machines: ${list.map((m) => (m.tools.length === 0 ? m.name : `${m.name} (${m.tools.join(", ")})`)).join("; ")}`;
3333 }
3434
35 /** Your live shells here, one line each in the page's words: "standard: attached", or detached with its stop time (UTC). */
36 export function shellStatesLine(states: ReadonlyMap<string, MachineState>): string | undefined {
37 const parts = [...states].map(([name, st]) => `${name}: ${stateLine(st)}`);
38 return parts.length === 0 ? undefined : `Your shell — ${parts.join("; ")}`;
39 }
40
3541 export function machinesScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
3642 const state = new ListState();
3743 let machines: readonly Machine[] = [];
@@ -46,13 +52,14 @@ export function machinesScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4652 },
4753 render(area, s) {
4854 const c = ctx.theme.c;
49 const [head, list] = split(area, "v", [len(3), fill(1)]);
55 const [head, list] = split(area, "v", [len(4), fill(1)]);
5056 const listed = rows();
5157 const lines = !may()
5258 ? ["A repository's machines and shell sessions are its writers'."]
5359 : [
5460 machinesLine(machines),
5561 listed.length === 0 ? "No shell has been opened here yet." : "Last sessions, newest first.",
62 ...(shellStatesLine(machineStates(f.app, repo.id, f.user!.id)) ?? []),
5663 ];
5764 lines.forEach((line, i) =>
5865 s.text(head!.x, head!.y + i, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w),
src/web/pages/machines.ts +14 −6
@@ -17,7 +17,7 @@ import { canAdmin, canWrite, type Repo } from "../../core/repos.ts";
1717 import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1818 import { repoMachines } from "../../crew/machines_read.ts";
1919 import type { Ctx } from "../../http/context.ts";
20 import { ENDED, lastSessions, type SessionRecord } from "../../machines/shell.ts";
20 import { ENDED, lastSessions, machineStates, stateLine, type MachineState, type SessionRecord } from "../../machines/shell.ts";
2121 import {
2222 crumbsFor,
2323 forbiddenPage,
@@ -58,16 +58,24 @@ export async function machinesSettingsPage(ctx: Ctx): Promise<Response> {
5858 if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return forbiddenPage(ctx, NOT_YOURS_TO_DO);
5959 const config = await timePhaseAsync(ctx.request, "machines", () => repoMachines(ctx.app, repo));
6060 const sessions = timePhase(ctx.request, "sessions", () => lastSessions(ctx.app.db, repo.id));
61 return timePhase(ctx.request, "render", () => render(ctx, repo, config.machines, sessions));
61 const states = machineStates(ctx.app, repo.id, ctx.auth.user!.id);
62 return timePhase(ctx.request, "render", () => render(ctx, repo, config.machines, sessions, states));
63 }
64
65 /** The state line as muted text in a row's meta, which wraps; nothing when no shell is live. */
66 function stateMeta(state: MachineState | undefined) {
67 const line = stateLine(state);
68 if (state === undefined || line === undefined) return html``;
69 return html` · <span class="muted" data-shell-state="${state.attached ? "attached" : "detached"}">${line}</span>`;
outdated

Duplicate clock helper; reuse the existing one. Also say UTC in the line.

6270 }
6371
6472 /** One data-nav link per machine, to its shell; none when there are no machines. */
65 export function machineRows(repo: Repo, list: readonly Machine[]) {
73 export function machineRows(repo: Repo, list: readonly Machine[], states: ReadonlyMap<string, MachineState> = new Map()) {
6674 return html`${list.map((m) =>
6775 row({
outdated

Drop state!. A long muted sentence in trail may not wrap at 360px (principle 3). Verify, or move it to meta.

6876 lead: icon("terminal"),
6977 title: m.name,
70 meta: html`Tools: ${m.tools.length === 0 ? "none" : m.tools.join(", ")} · Reach: ${m.reach.length === 0 ? "the forge only" : "the forge and its apps"}`,
78 meta: html`Tools: ${m.tools.length === 0 ? "none" : m.tools.join(", ")} · Reach: ${m.reach.length === 0 ? "the forge only" : "the forge and its apps"}${stateMeta(states.get(m.name))}`,
7179 href: shellPath(repo, m.name),
7280 nav: true,
7381 data: { machine: m.name },
@@ -75,12 +83,12 @@ export function machineRows(repo: Repo, list: readonly Machine[]) {
7583 )}`;
7684 }
7785
78 function render(ctx: Ctx, repo: Repo, list: readonly Machine[], sessions: readonly SessionRecord[]): Response {
86 function render(ctx: Ctx, repo: Repo, list: readonly Machine[], sessions: readonly SessionRecord[], states: ReadonlyMap<string, MachineState>): Response {
7987 const machines = html`<section class="settings-block">
8088 <h2>Machines</h2>
8189 <p class="muted">A machine is where the crew shell runs; each writer gets a directory and home of their own on it.</p>
8290 <ul class="list setting-list" id="machines">
83 ${machineRows(repo, list)}
91 ${machineRows(repo, list, states)}
8492 </ul>
8593 </section>`;
8694 // The mark is not colour alone: a running shell carries the word in a badge,
test/machine-shell.test.ts +59
@@ -23,6 +23,8 @@ import { idle, shellIdleMs, closeStaleSessions, recordStart, shellCell, workspac
2323 import { BUILT_IN } from "../src/crew/machines.ts";
2424 import {
2525 lastSessions,
26 machineStates,
27 stateLine,
2628 liveSessions,
2729 parseControl,
2830 setShellOptions,
@@ -38,6 +40,7 @@ let servers: Servers;
3840 let repo: Repo;
3941 let base: string;
4042 let staleRow: string;
43 let aliceId: string;
4144 const cookies: Record<string, string> = {};
4245
4346 async function person(name: string, isAdmin = false): Promise<User> {
@@ -52,6 +55,7 @@ beforeAll(async () => {
5255 app = createApp({ dataDir, port: 0 });
5356 setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 1000 });
5457 const alice = await person("alice");
58 aliceId = alice.id;
5559 await person("bob");
5660 const made = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "lab", visibility: "public" });
5761 if (!made.ok) throw new Error(String(made.error));
@@ -348,3 +352,58 @@ test("the repository's settings show a writer the last sessions, and a reader no
348352 await reader.body?.cancel();
349353 expect(reader.status).toBe(403);
350354 });
355
356 test("a closed socket leaves the session detached with its stop time on the machines page; reconnecting joins it", async () => {
357 stopShells(app);
358 setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 1000 });
359 const page = async () => (await fetch(`${ORIGIN()}/alice/lab/settings/machines`, { headers: { cookie: cookies["alice"]! } })).text();
360 const first = await open("alice");
361 first.ws.send(new TextEncoder().encode("echo kept''-line\n"));
362 await until(() => first.seen().includes("kept-line"));
363 expect(await page()).toContain('data-shell-state="attached"');
364
365 first.ws.close();
366 await until(() => first.ws.readyState === WebSocket.CLOSED);
367 await until(() => machineStates(app, repo.id, aliceId).get("standard")?.attached === false);
368 expect(liveSessions(app)).toBe(1);
369 const state = machineStates(app, repo.id, aliceId).get("standard")!;
370 const html = await page();
371 expect(html).toContain('data-shell-state="detached"');
372 expect(html).toContain(`detached, stops at ${new Date(state.stopsAt).toISOString().slice(11, 16)} UTC unless you come back`);
373
374 const second = await open("alice");
375 expect(liveSessions(app)).toBe(1);
376 second.ws.send(new TextEncoder().encode("echo back''-again\n"));
377 await until(() => second.seen().includes("back-again"));
378 expect(await page()).toContain('data-shell-state="attached"');
379 expect(await page()).not.toContain("detached, stops at");
380
381 const closed = new Promise((r) => second.ws.addEventListener("close", r));
382 second.ws.send(new TextEncoder().encode("exit\n"));
383 await closed;
384 await until(() => liveSessions(app) === 0);
385 });
386
387 test("stateLine words attached and detached (UTC), and says nothing without a shell", () => {
388 expect(stateLine(undefined)).toBeUndefined();
389 expect(stateLine({ attached: true, stopsAt: 0 })).toBe("attached");
390 expect(stateLine({ attached: false, stopsAt: Date.UTC(2026, 10, 10, 14, 5) })).toBe("detached, stops at 14:05 UTC unless you come back");
391 });
392
393 test("machineStates is the asker's own: another person, another repository and a guest see no shell", async () => {
394 stopShells(app);
395 const bob = await app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'bob'").get();
396 const shut = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'shut'").get();
397 const mine = await open("alice");
398 mine.ws.send(new TextEncoder().encode("echo own''ed\n"));
399 await until(() => mine.seen().includes("owned"));
400 expect(machineStates(app, repo.id, aliceId).get("standard")?.attached).toBe(true);
401 expect(machineStates(app, repo.id, bob!.id).size).toBe(0);
402 expect(machineStates(app, repo.id, "").size).toBe(0);
403 expect(machineStates(app, shut!.id, aliceId).size).toBe(0);
404 const closed = new Promise((r) => mine.ws.addEventListener("close", r));
405 mine.ws.send(new TextEncoder().encode("exit\n"));
406 await closed;
407 await until(() => liveSessions(app) === 0);
408 expect(machineStates(app, repo.id, aliceId).size).toBe(0);
409 });
test/tui-my-shells.test.ts +9
@@ -85,3 +85,12 @@ test("s stops the picked live shell and i stops idle ones, with the page's recei
8585 expect(tui.screen.toText()).toContain("No idle shell to stop.");
8686 tui.stop();
8787 });
88
89 test("the machines screen line names each machine's state with the page's words", async () => {
90 const { shellStatesLine } = await import("../src/tui/screens/machines.ts");
91 expect(shellStatesLine(new Map())).toBeUndefined();
92 expect(shellStatesLine(new Map([["standard", { attached: true, stopsAt: 0 }]]))).toBe("Your shell — standard: attached");
93 expect(shellStatesLine(new Map([["big", { attached: false, stopsAt: Date.UTC(2026, 0, 1, 9, 30) }]]))).toBe(
94 "Your shell — big: detached, stops at 09:30 UTC unless you come back",
95 );
96 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs