Public: anyone can read this; the crew can change it.
`enter` on a run in the terminal opens its log, the last 200 lines, refreshing while it runs, as MCP `run_log` does #7306
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7293-enter-on-a-run-in-the-terminal-opens-its → main 4 commits fork 7 threads · 7 unresolved
README.md +2
| @@ -289,6 +289,8 @@ one: the library of clones, then a lazygit-shaped working-tree screen — | ||
| 289 | 289 | `space` stages, `enter` opens the hunks, `s` stages one, `c` commits, `P` |
| 290 | 290 | pushes, `b` branches, `l` log, `z` stashes, `?` shows every binding. |
| 291 | 291 | |
| 292 | In the SSH terminal, `enter` on a run opens its log (the last 200 lines, following new ones while it runs; "Not found" where the web would say so), and `o` there prints the web address (`src/tui/screens/runlog.ts`). | |
| 293 | ||
| 292 | 294 | Build it once and put it on your PATH: |
| 293 | 295 | |
| 294 | 296 | sh |
docs/TOUR.md +2
| @@ -34,6 +34,8 @@ to filter, `?` for the keys, `:` for the command palette, `t` for the theme. | ||
| 34 | 34 | |
| 35 | 35 | **Secrets.** For a forge admin, `:` → *Secrets* lists sealed secret names (never values); `a` sets one, `d` removes one after a confirm, `s` changes the place. See [](OPERATING.md#secrets-in-the-terminal). |
| 36 | 36 | |
| 37 | **Run logs.** In the terminal, `enter` on a run (in the runs list, a pull, an issue or the deploys) opens its log: the state line, then the last 200 lines, scrolled to the end and following new lines while the run is queued or running (`j`/`k` and the page keys scroll; scrolling up keeps your place). A log you may not read is "Not found", as `run_log` and the REST route say. `o` there prints the web address (`src/tui/screens/runlog.ts`). | |
| 38 | ||
| 37 | 39 | **Milestones.** `:` → *Milestones in a repository* → `owner/name` lists a |
| 38 | 40 | repository's limbs in the order the crew takes ready work (`enter` opens a |
| 39 | 41 | limb's own limbs). A person who administers the repository presses `r` to |
src/actions/runs.ts +5 −2
| @@ -32,7 +32,7 @@ import type { User } from "../core/users.ts"; | ||
| 32 | 32 | import { abortAgentRun } from "../crew/running.ts"; |
| 33 | 33 | import { queueAgentRun, taskOf } from "../crew/dispatch.ts"; |
| 34 | 34 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 35 | import { readRunLog, runLogPath } from "../runs/log.ts"; | |
| 35 | import { readRunLog, readRunLogTail, runLogPath } from "../runs/log.ts"; | |
| 36 | 36 | import { firstErrorLine } from "../runs/error_line.ts"; |
| 37 | 37 | import { requireReader, requireWriter, type AccessError } from "./access.ts"; |
| 38 | 38 | |
| @@ -219,13 +219,16 @@ export function runLog( | ||
| 219 | 219 | actor: User | undefined, |
| 220 | 220 | repo: Repo, |
| 221 | 221 | runId: string, |
| 222 | tail?: number, | |
| 222 | 223 | ): Result<RunLogView, "not_found"> { |
| 223 | 224 | const found = getRepoRun(app, actor, repo, runId); |
| 224 | 225 | if (!found.ok) return found; |
| 225 | 226 | const logHidden = !mayReadRunLog(app, actor, repo); |
| 226 | 227 | const { lines, truncated } = logHidden |
| 227 | 228 | ? { lines: [], truncated: false } |
| 228 | : readRunLog(app.config.dataDir, runId); | |
| 229 | : tail === undefined | |
| 230 | ? readRunLog(app.config.dataDir, runId) | |
| 231 | : readRunLogTail(app.config.dataDir, runId, tail); | |
| 229 | 232 | return ok({ |
| 230 | 233 | run: found.value, |
| 231 | 234 | lines, |
src/tui/screens/common.ts +4
| @@ -19,6 +19,7 @@ import { SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts"; | ||
| 19 | 19 | import { ORIGIN_ALREADY_CONNECTED } from "../../actions/origins.ts"; |
| 20 | 20 | import { NEW_ACCOUNT_GUARD_MESSAGE } from "../../core/capacity.ts"; |
| 21 | 21 | import { PRIVATE_ADDRESS_REFUSED } from "../../kernel/address_words.ts"; |
| 22 | import type { ForgeEvent } from "../../core/events.ts"; | |
| 22 | 23 | import type { Session } from "../session.ts"; |
| 23 | 24 | import { G, Screen as Surface, type Rect } from "../term/screen.ts"; |
| 24 | 25 | import { style } from "../term/style.ts"; |
| @@ -662,6 +663,8 @@ export interface LiveOptions { | ||
| 662 | 663 | readonly repoId?: string; |
| 663 | 664 | /** Only these event kinds. Omit for all of them. */ |
| 664 | 665 | readonly kinds?: readonly string[]; |
| 666 | /** Only events this accepts. Omit for all of them. */ | |
| 667 | readonly where?: (event: ForgeEvent) => boolean; | |
| 665 | 668 | } |
| 666 | 669 | |
| 667 | 670 | /** |
| @@ -688,6 +691,7 @@ export function live( | ||
| 688 | 691 | const off = f.app.bus.subscribe((event) => { |
| 689 | 692 | if (o.repoId !== undefined && event.repoId !== o.repoId) return; |
| 690 | 693 | if (o.kinds !== undefined && !o.kinds.includes(event.kind)) return; |
| 694 | if (o.where !== undefined && !o.where(event)) return; | |
| 691 | 695 | if (timer !== null) return; |
| 692 | 696 | timer = setTimeout(() => { |
| 693 | 697 | timer = null; |
src/tui/screens/pull.ts +2 −1
| @@ -116,7 +116,8 @@ import { FINISH_REFUSALS, finishConflicts } from "../../actions/finish-conflicts | ||
| 116 | 116 | import { PROPOSE_WHY, proposalOf, proposeResolution } from "../../crew/proposal.ts"; |
| 117 | 117 | import { conflictScreen } from "./conflicts.ts"; |
| 118 | 118 | import { reviewScreen } from "./review.ts"; |
| 119 | import { RunList, openRunLog, runTone } from "./runlist.ts"; | |
| 119 | import { RunList, openRunLog } from "./runlist.ts"; | |
| 120 | import { runTone } from "./runwords.ts"; | |
| 120 | 121 | import { Composer, threadLines, type ThreadEntry } from "./thread.ts"; |
| 121 | 122 | import { local, localRepo, whereRows, whereStrip } from "./where.ts"; |
| 122 | 123 | import { openAppTerminal } from "./apptty.ts"; |
src/tui/screens/runlist.ts +5 −41
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Runs, as rows: the vocabulary a screen borrows to show one — a pull's |
| 3 | 3 | * checks, an issue's crew activity. One place decides what "failed" looks |
| 4 | * like so it looks the same everywhere. A run's live log is a web page | |
| 5 | * (`/:owner/:name/runs/:id`); `openRunLog` hands its address over. | |
| 4 | * like so it looks the same everywhere. `openRunLog` opens a run's log screen. | |
| 6 | 5 | * |
| 7 | 6 | * A run row says four things in order: its state (a dot in the tone and the |
| 8 | 7 | * word), what it was (`deploy production`, `agent robo`), why (the reason), |
| @@ -20,7 +19,6 @@ import type { Ctx } from "../app/app.ts"; | ||
| 20 | 19 | import type { Rect, Screen as Surface } from "../term/screen.ts"; |
| 21 | 20 | import type { Tokens } from "../ui/theme.ts"; |
| 22 | 21 | import { icon, type IconSet } from "../ui/icons.ts"; |
| 23 | import type { Tone } from "../ui/widgets.ts"; | |
| 24 | 22 | import type { Session } from "../session.ts"; |
| 25 | 23 | import { |
| 26 | 24 | ListState, |
| @@ -29,24 +27,9 @@ import { | ||
| 29 | 27 | metaOf, |
| 30 | 28 | relTime, |
| 31 | 29 | type ListRow, |
| 32 | openOnWeb, | |
| 33 | 30 | } from "./common.ts"; |
| 34 | ||
| 35 | /** The tone a run's state wears — the same reading on every surface. */ | |
| 36 | export function runTone(state: RunState): Tone | undefined { | |
| 37 | switch (state) { | |
| 38 | case "ok": | |
| 39 | return "success"; | |
| 40 | case "failed": | |
| 41 | return "danger"; | |
| 42 | case "running": | |
| 43 | return "attn"; | |
| 44 | case "cancelled": | |
| 45 | return "secondary"; | |
| 46 | case "queued": | |
| 47 | return undefined; | |
| 48 | } | |
| 49 | } | |
| 31 | import { runLogScreen } from "./runlog.ts"; | |
| 32 | import { costText, envLabel, runTone } from "./runwords.ts"; | |
| 50 | 33 | |
| 51 | 34 | /** Whether the dot is hollow: nothing has happened yet, or nothing will. */ |
| 52 | 35 | export const runHollow = (state: RunState): boolean => |
| @@ -70,16 +53,6 @@ export function runTitle( | ||
| 70 | 53 | return set === "nerd" ? `${icon("bot", set)} ${who}` : `agent ${who}`; |
| 71 | 54 | } |
| 72 | 55 | |
| 73 | /** `production`, or `pull #4` — the environment as a person says it. */ | |
| 74 | export function envLabel(env: string): string { | |
| 75 | return env.startsWith("pull-") ? `pull #${env.slice(5)}` : env || "—"; | |
| 76 | } | |
| 77 | ||
| 78 | /** The dollars a run spent, or nothing when it spent none. */ | |
| 79 | export function costText(costUsd: number): string { | |
| 80 | return costUsd > 0 ? `$${costUsd.toFixed(2)}` : ""; | |
| 81 | } | |
| 82 | ||
| 83 | 56 | /** The moment a run is best dated by: when it finished, started, or was made. */ |
| 84 | 57 | export function runWhen(run: Run): string { |
| 85 | 58 | return run.finishedAt ?? run.startedAt ?? run.createdAt; |
| @@ -193,21 +166,12 @@ export class RunList { | ||
| 193 | 166 | } |
| 194 | 167 | } |
| 195 | 168 | |
| 196 | /** | |
| 197 | * `enter` on a run: its log, cancel and steer live on the web page, so the | |
| 198 | * terminal hands over the address. With no public URL configured the path | |
| 199 | * is still the answer, said as one. | |
| 200 | */ | |
| 169 | /** `enter` on a run: its log, in the terminal; `o` there prints the web address. */ | |
| 201 | 170 | export function openRunLog( |
| 202 | 171 | f: Session, |
| 203 | 172 | ctx: Ctx, |
| 204 | 173 | repo: Repo, |
| 205 | 174 | run: Run, |
| 206 | 175 | ): void { |
| 207 | openOnWeb( | |
| 208 | f, | |
| 209 | ctx, | |
| 210 | `/${repo.ownerName}/${repo.name}/runs/${run.id}`, | |
| 211 | "the run's log", | |
| 212 | ); | |
| 176 | ctx.push(runLogScreen(f, ctx, repo, run.id)); | |
| 213 | 177 | } |
src/tui/screens/runlog.ts added +169
| @@ -0,0 +1,169 @@ | ||
| 1 | /** | |
| 2 | * A run's log: its state line, then the last 200 lines, scrolled to the end. | |
| 3 | * Read through `runLog` — the call MCP `run_log` and the run page make — so a | |
| 4 | * hidden log or an unreadable repository says "not found" and draws nothing. | |
| 5 | * It reads once, appends new lines from the log's own listener, and refreshes | |
| 6 | * the state line on this run's events; a viewer who scrolled up keeps their | |
| 7 | * place, one at the end follows the new lines. | |
| 8 | */ | |
| 9 | ||
| 10 | import { runLog, type RunLogView } from "../../actions/runs.ts"; | |
| 11 | import type { Repo } from "../../core/repos.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { style } from "../term/style.ts"; | |
| 16 | import { truncate } from "../term/width.ts"; | |
| 17 | import { fill, len, split } from "../ui/layout.ts"; | |
| 18 | import { emptyState } from "../ui/widgets.ts"; | |
| 19 | import { subscribeRunLog } from "../../runs/log.ts"; | |
| 20 | import { Scroll, live, openOnWeb } from "./common.ts"; | |
| 21 | import { costText, envLabel, runTone } from "./runwords.ts"; | |
| 22 | ||
| 23 | const LOG_LINES = 200; | |
| 24 | ||
| 25 | const isLive = (state: string): boolean => state === "queued" || state === "running"; | |
| 26 | ||
| 27 | export function runLogScreen( | |
| 28 | f: Session, | |
| 29 | ctx: Ctx, | |
| 30 | repo: Repo, | |
| 31 | runId: string, | |
| 32 | ): Screen { | |
| 33 | const scroll = new Scroll(); | |
| 34 | let view: RunLogView | undefined; | |
| 35 | let lines: string[] = []; | |
| 36 | let loaded = false; | |
| 37 | let follow = true; | |
| 38 | ||
| 39 | const read = (): void => { | |
| 40 | const got = runLog(f.app, f.user, repo, runId, LOG_LINES); | |
| 41 | // A hidden log is "not found", as the REST route says it. | |
| 42 | view = got.ok && !got.value.logHidden ? got.value : undefined; | |
| 43 | lines = // The tail window can hold a few more than asked for. | |
| 44 | lines = view === undefined ? [] : view.lines.slice(-LOG_LINES); | |
| 45 | loaded = true; | |
| 46 | if (follow) scroll.top = Number.MAX_SAFE_INTEGER; | |
Non-blocking: |
||
| 47 | ctx.invalidate(); | |
| 48 | }; | |
| 49 | ||
| 50 | const web = (): void => | |
| 51 | openOnWeb(f, ctx, `/${repo.ownerName}/${repo.name}/runs/${runId}`, "the run's log"); | |
| 52 | ||
| 53 | const self: Screen = { | |
| 54 | title: "Run log", | |
| 55 | get keys() { | |
| 56 | return [ | |
| 57 | binding(["j", "k"], "scroll"), | |
| 58 | binding(["pgup", "pgdn"], "page"), | |
| 59 | binding(["g", "G"], "top, end"), | |
| 60 | binding(["o"], "open on the web"), | |
| 61 | binding(["r"], "refresh"), | |
| 62 | ]; | |
| 63 | }, | |
outdated
Polling: each tick rereads up to 512 KB of the log file per open screen. Use |
||
| 64 | load(signal) { | |
| 65 | read(); | |
| 66 | // New lines arrive from the log's own listener, never from a reread. | |
| 67 | subscribeRunLog( | |
| 68 | runId, | |
| 69 | (line) => { | |
| 70 | if (view === undefined) return; | |
| 71 | lines.push(line); | |
| 72 | if (lines.length > LOG_LINES) lines.splice(0, lines.length - LOG_LINES); | |
| 73 | if (follow) scroll.top = Number.MAX_SAFE_INTEGER; | |
| 74 | ctx.invalidate(); | |
| 75 | }, | |
| 76 | signal, | |
| 77 | ); | |
| 78 | // The run starting or ending, or the gate moving, re-reads the state | |
| 79 | // line; a finished run has nothing left to follow. | |
outdated
Fires for every run event in the repo and rereads when view is undefined. Filter on the event's run id and skip when nothing was loaded. |
||
| 80 | live( | |
| 81 | f, | |
| 82 | signal, | |
| 83 | { | |
| 84 | repoId: repo.id, | |
| 85 | kinds: ["run.queued", "run.started", "run.finished"], | |
| 86 | where: (event) => event.payload.runId === runId, | |
| 87 | }, | |
| 88 | () => { | |
| 89 | // Nothing loaded (hidden, not found) or finished: nothing to follow. | |
| 90 | if (view === undefined || !isLive(view.run.state)) return; | |
| 91 | read(); | |
| 92 | }, | |
| 93 | ); | |
| 94 | return Promise.resolve(); | |
| 95 | }, | |
| 96 | render(area, s) { | |
| 97 | const c = ctx.theme.c; | |
| 98 | if (view === undefined) { | |
| 99 | emptyState(s, area, c, { | |
| 100 | title: loaded ? "Not found" : "Reading…", | |
| 101 | body: loaded ? "There is no such run here." : "Reading the log", | |
| 102 | }); | |
| 103 | return; | |
| 104 | } | |
| 105 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 106 | const run = view.run; | |
| 107 | const tone = runTone(run.state); | |
| 108 | const state = [ | |
| 109 | run.state, | |
| 110 | run.kind === "deploy" ? `deploy ${envLabel(run.env)}` : run.kind, | |
| 111 | run.summary, | |
| 112 | costText(run.costUsd), | |
| 113 | isLive(run.state) ? "live" : "", | |
| 114 | ] | |
| 115 | .filter((p) => p !== "") | |
| 116 | .join(" · "); | |
| 117 | s.text( | |
| 118 | head!.x, | |
| 119 | head!.y, | |
| 120 | truncate(state, head!.w), | |
| 121 | style(tone === undefined ? c.mutedFg : c.fg, c.bg), | |
| 122 | head!.x + head!.w, | |
| 123 | ); | |
| 124 | if (lines.length === 0) { | |
| 125 | emptyState(s, body!, c, { | |
| 126 | title: "No log yet", | |
| 127 | body: isLive(run.state) | |
| 128 | ? "Waiting for the first line." | |
| 129 | : "This run wrote nothing.", | |
| 130 | }); | |
| 131 | return; | |
| 132 | } | |
| 133 | scroll.height = Math.max(1, body!.h); | |
| 134 | const last = Math.max(0, lines.length - body!.h); | |
| 135 | scroll.top = Math.min(scroll.top, last); | |
| 136 | follow = scroll.top >= last; | |
| 137 | for (let i = 0; i < body!.h; i++) { | |
| 138 | const line = lines[scroll.top + i]; | |
| 139 | if (line === undefined) break; | |
| 140 | s.text( | |
| 141 | body!.x, | |
| 142 | body!.y + i, | |
| 143 | truncate(line.replace(/[\u0000-\u001f\u007f]/g, " "), body!.w), | |
| 144 | style(c.fg, c.bg), | |
| 145 | body!.x + body!.w, | |
| 146 | ); | |
| 147 | } | |
| 148 | }, | |
| 149 | key(e) { | |
| 150 | if (e.key === "o" && !e.ctrl && !e.alt) { | |
| 151 | web(); | |
| 152 | return true; | |
| 153 | } | |
| 154 | if (e.key === "r" && !e.ctrl && !e.alt) { | |
| 155 | read(); | |
| 156 | return true; | |
| 157 | } | |
| 158 | if (view === undefined) return false; | |
| 159 | const last = Math.max(0, lines.length - scroll.height); | |
| 160 | scroll.top = Math.min(scroll.top, last); | |
| 161 | const took = scroll.key(e, lines.length + scroll.height); | |
| 162 | scroll.top = Math.min(scroll.top, last); | |
| 163 | follow = scroll.top >= last; | |
| 164 | if (took) ctx.invalidate(); | |
| 165 | return took; | |
| 166 | }, | |
| 167 | }; | |
| 168 | return self; | |
| 169 | } |
src/tui/screens/runwords.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | /** | |
| 2 | * How a run reads: the tone its state wears, the environment as a person says | |
| 3 | * it, the dollars it spent. Shared by the runs list and the run's log so | |
| 4 | * neither imports the other. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { RunState } from "../../core/runs.ts"; | |
| 8 | import type { Tone } from "../ui/widgets.ts"; | |
| 9 | ||
| 10 | /** The tone a run's state wears — the same reading on every surface. */ | |
| 11 | export function runTone(state: RunState): Tone | undefined { | |
| 12 | switch (state) { | |
| 13 | case "ok": | |
| 14 | return "success"; | |
| 15 | case "failed": | |
| 16 | return "danger"; | |
| 17 | case "running": | |
| 18 | return "attn"; | |
| 19 | case "cancelled": | |
| 20 | return "secondary"; | |
| 21 | case "queued": | |
| 22 | return undefined; | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | /** `production`, or `pull #4` — the environment as a person says it. */ | |
| 27 | export function envLabel(env: string): string { | |
| 28 | return env.startsWith("pull-") ? `pull #${env.slice(5)}` : env || "—"; | |
| 29 | } | |
| 30 | ||
| 31 | /** The dollars a run spent, or nothing when it spent none. */ | |
| 32 | export function costText(costUsd: number): string { | |
| 33 | return costUsd > 0 ? `$${costUsd.toFixed(2)}` : ""; | |
| 34 | } |
test/tui-apps.test.ts +3 −1
| @@ -303,7 +303,9 @@ describe("terminal: apps, runs and the crew", () => { | ||
| 303 | 303 | expect(h.text()).toContain("opened pull #2 with the melody"); |
| 304 | 304 | expect(h.text()).toContain("$0.42"); |
| 305 | 305 | await h.press("tab", "enter"); |
| 306 | await h.until(`/alice/site/runs/${run.id}`); | |
| 306 | await h.until("cloning alice/site"); | |
| 307 | // The log screen's state line, not the web address. | |
| 308 | expect(h.text()).toContain("ok · agent · opened pull #2 with the melody · $0.42"); | |
| 307 | 309 | } finally { |
| 308 | 310 | h.stop(); |
| 309 | 311 | } |
test/tui-runlog.test.ts added +173
| @@ -0,0 +1,173 @@ | ||
| 1 | /** `enter` on a run opens its log in the terminal, as MCP `run_log` reads it (#7306). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { appendFileSync } from "node:fs"; | |
| 7 | import { announceRun } from "../src/actions/runs.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 10 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 11 | import type { Repo } from "../src/core/repos.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import { appendRunLog, runLogPath } from "../src/runs/log.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { runLogScreen } from "../src/tui/screens/runlog.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | let dir: string; | |
| 24 | let forge: Forge; | |
| 25 | let repo: Repo; | |
| 26 | let alice: User; | |
| 27 | let bob: User; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dir = await mkdtemp(join(tmpdir(), "forge-tui-runlog-")); | |
| 31 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 32 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 33 | if (!a.ok) throw new Error("signup failed"); | |
| 34 | alice = a.value; | |
| 35 | const b = await createUser(forge.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-passphrase" }); | |
| 36 | if (!b.ok) throw new Error("bob failed"); | |
| 37 | bob = b.value; | |
| 38 | const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" }); | |
| 39 | if (!r.ok) throw new Error("repo failed"); | |
| 40 | repo = r.value; | |
| 41 | }); | |
| 42 | afterAll(async () => { | |
| 43 | forge.close?.(); | |
| 44 | await rm(dir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | const open = async (user: User, runId: string) => { | |
| 48 | const { app, session } = createForgeApp( | |
| 49 | forge, | |
| 50 | { sshUsername: user.username, remoteAddress: "127.0.0.1", user }, | |
| 51 | { terminal: headlessTerminal(100, 20), exitHooks: false }, | |
| 52 | ); | |
| 53 | app.start(); | |
| 54 | await settleApp(app); | |
| 55 | app.push(runLogScreen(session, app.ctx, repo, runId)); | |
| 56 | await settleApp(app); | |
| 57 | return app; | |
| 58 | }; | |
| 59 | ||
| 60 | test("a finished run shows its state and its last 200 lines, at the end", async () => { | |
| 61 | const run = createRun(forge.db, { repoId: repo.id, kind: "agent" }); | |
| 62 | claimRun(forge.db, run.id); | |
| 63 | for (let i = 1; i <= 300; i++) appendRunLog(dir, run.id, `line-${i}`); | |
| 64 | finishRun(forge.db, run.id, { state: "failed", summary: "BOOM-SUMMARY" }); | |
| 65 | const app = await open(alice, run.id); | |
| 66 | const text = app.screen.toText(); | |
| 67 | expect(text).toContain("failed"); | |
| 68 | expect(text).toContain("BOOM-SUMMARY"); | |
| 69 | expect(text).toContain("line-300"); | |
| 70 | expect(text).not.toContain("\nline-2\n"); | |
| 71 | app.dispatch({ type: "key", key: "g", ctrl: false, alt: false, shift: false }); | |
| 72 | await settleApp(app); | |
| 73 | expect(app.screen.toText()).toContain("line-101"); | |
| 74 | expect(app.screen.toText()).not.toContain("\nline-100\n"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a log the viewer may not read is not found and nothing of it is drawn", async () => { | |
| 78 | const run = createRun(forge.db, { repoId: repo.id, kind: "agent" }); | |
| 79 | claimRun(forge.db, run.id); | |
| 80 | appendRunLog(dir, run.id, "SECRET-LINE"); | |
| 81 | finishRun(forge.db, run.id, { state: "ok", summary: "SECRET-SUMMARY" }); | |
| 82 | const app = await open(bob, run.id); | |
| 83 | const text = app.screen.toText(); | |
| 84 | expect(text).toContain("Not found"); | |
| 85 | expect(text).not.toContain("SECRET-LINE"); | |
| 86 | expect(text).not.toContain("SECRET-SUMMARY"); | |
| 87 | }); | |
| 88 | ||
| 89 | const key = async (app: Awaited<ReturnType<typeof open>>, k: string) => { | |
| 90 | app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 91 | await settleApp(app); | |
| 92 | }; | |
| 93 | ||
| 94 | test("a running run's new lines appear as they are written, with no reread", async () => { | |
| 95 | const run = createRun(forge.db, { repoId: repo.id, kind: "agent" }); | |
outdated
This 2.3 s wall-clock sleep is slow and flake-prone. Once the screen subscribes, the new line shows up after |
||
| 96 | claimRun(forge.db, run.id); | |
| 97 | appendRunLog(dir, run.id, "first-line"); | |
| 98 | const app = await open(alice, run.id); | |
| 99 | expect(app.screen.toText()).toContain("first-line"); | |
| 100 | appendRunLog(dir, run.id, "later-line"); | |
| 101 | await settleApp(app); | |
| 102 | expect(app.screen.toText()).toContain("later-line"); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a viewer at the end follows new lines; one who scrolled up keeps their place", async () => { | |
| 106 | const run = createRun(forge.db, { repoId: repo.id, kind: "agent" }); | |
| 107 | claimRun(forge.db, run.id); | |
| 108 | for (let i = 1; i <= 100; i++) appendRunLog(dir, run.id, `row-${i}`); | |
| 109 | const app = await open(alice, run.id); | |
| 110 | expect(app.screen.toText()).toContain("row-100"); | |
| 111 | appendRunLog(dir, run.id, "row-101"); | |
| 112 | await settleApp(app); | |
| 113 | expect(app.screen.toText()).toContain("row-101"); | |
| 114 | await key(app, "g"); | |
| 115 | expect(app.screen.toText()).toContain("\nrow-1\n"); | |
| 116 | appendRunLog(dir, run.id, "row-102"); | |
| 117 | await settleApp(app); | |
| 118 | expect(app.screen.toText()).toContain("\nrow-1\n"); | |
| 119 | expect(app.screen.toText()).not.toContain("row-102"); | |
| 120 | await key(app, "G"); | |
| 121 | expect(app.screen.toText()).toContain("row-102"); | |
| 122 | }); | |
| 123 | ||
| 124 | test("the end of the run shows on the state line, and a finished run is not reread", async () => { | |
| 125 | const run = createRun(forge.db, { repoId: repo.id, kind: "agent" }); | |
| 126 | claimRun(forge.db, run.id); | |
| 127 | appendRunLog(dir, run.id, "working"); | |
| 128 | const app = await open(alice, run.id); | |
| 129 | expect(app.screen.toText()).toContain("running"); | |
| 130 | const done = finishRun(forge.db, run.id, { state: "ok", summary: "ALL-DONE" }); | |
| 131 | if (done === undefined) throw new Error("finish failed"); | |
Non-blocking: the 80 ms setTimeout is still a timed wait. A second bus event for a different run, delivered after the sneak, would give a deterministic signal. |
||
| 132 | announceRun(forge, done); | |
| 133 | await settleUntil(app, "ALL-DONE"); | |
| 134 | expect(app.screen.toText()).not.toContain("live"); | |
| 135 | // A reread would show a line written behind the listener's back. Another | |
| 136 | // run's event after the sneak is a signal that the bus has been drained. | |
| 137 | appendFileSync(runLogPath(dir, run.id), "SNEAKED\n"); | |
| 138 | announceRun(forge, done); | |
| 139 | await new Promise<void>((resolve) => { | |
| 140 | const off = forge.bus.subscribe(() => { | |
| 141 | off(); | |
| 142 | setTimeout(resolve, 80); | |
| 143 | }); | |
| 144 | announceRun(forge, done); | |
| 145 | }); | |
| 146 | await settleApp(app); | |
| 147 | expect(app.screen.toText()).not.toContain("SNEAKED"); | |
| 148 | }); | |
| 149 | ||
| 150 | test("another run's events do not reread this one", async () => { | |
| 151 | const run = createRun(forge.db, { repoId: repo.id, kind: "agent" }); | |
| 152 | claimRun(forge.db, run.id); | |
| 153 | appendRunLog(dir, run.id, "mine"); | |
| 154 | const other = createRun(forge.db, { repoId: repo.id, kind: "agent" }); | |
| 155 | const app = await open(alice, run.id); | |
| 156 | appendFileSync(runLogPath(dir, run.id), "SNEAKED\n"); | |
| 157 | announceRun(forge, other); | |
| 158 | await new Promise((resolve) => setTimeout(resolve, 120)); | |
| 159 | await settleApp(app); | |
| 160 | expect(app.screen.toText()).not.toContain("SNEAKED"); | |
| 161 | }); | |
| 162 | ||
| 163 | test("r rereads the log, and o prints the web address", async () => { | |
| 164 | const run = createRun(forge.db, { repoId: repo.id, kind: "agent" }); | |
| 165 | claimRun(forge.db, run.id); | |
| 166 | appendRunLog(dir, run.id, "before"); | |
| 167 | const app = await open(alice, run.id); | |
| 168 | appendFileSync(runLogPath(dir, run.id), "BEHIND-THE-LISTENER\n"); | |
| 169 | await key(app, "r"); | |
| 170 | expect(app.screen.toText()).toContain("BEHIND-THE-LISTENER"); | |
| 171 | await key(app, "o"); | |
| 172 | expect(app.screen.toText()).toContain(`/alice/lamp/runs/${run.id}`); | |
| 173 | }); |
Stale: nothing rereads every 2 s now. Rewrite this comment to match the listener-based design.