Skip to content

Commit

Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r9

Claudeagentcommitted Parentsdfefa68062ffb4Browse files
13 files+893−1090/13 viewed
+97−3
8181 ]
8282
8383 [[package]]
84+name = "ahash"
85+version = "0.8.12"
86+source = "registry+https://github.com/rust-lang/crates.io-index"
87+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
88+dependencies = [
89+ "cfg-if",
90+ "once_cell",
91+ "version_check",
92+ "zerocopy",
93+]
94+
95+[[package]]
8496 name = "aho-corasick"
8597 version = "1.1.5"
8698 source = "registry+https://github.com/rust-lang/crates.io-index"
740752 ]
741753
742754 [[package]]
755+name = "fallible-iterator"
756+version = "0.3.0"
757+source = "registry+https://github.com/rust-lang/crates.io-index"
758+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
759+
760+[[package]]
761+name = "fallible-streaming-iterator"
762+version = "0.1.9"
763+source = "registry+https://github.com/rust-lang/crates.io-index"
764+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
765+
766+[[package]]
743767 name = "ff"
744768 version = "0.14.0"
745769 source = "registry+https://github.com/rust-lang/crates.io-index"
930954 "g1t-kit",
931955 "g1t-secrets",
932956 "hex",
957+ "rusqlite",
933958 "serde",
934959 "serde_json",
935960 "worker",
13151340
13161341 [[package]]
13171342 name = "hashbrown"
1343+version = "0.14.5"
1344+source = "registry+https://github.com/rust-lang/crates.io-index"
1345+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
1346+dependencies = [
1347+ "ahash",
1348+]
1349+
1350+[[package]]
1351+name = "hashbrown"
13181352 version = "0.17.1"
13191353 source = "registry+https://github.com/rust-lang/crates.io-index"
13201354 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
13241358
13251359 [[package]]
13261360 name = "hashlink"
1361+version = "0.9.1"
1362+source = "registry+https://github.com/rust-lang/crates.io-index"
1363+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
1364+dependencies = [
1365+ "hashbrown 0.14.5",
1366+]
1367+
1368+[[package]]
1369+name = "hashlink"
13271370 version = "0.12.2"
13281371 source = "registry+https://github.com/rust-lang/crates.io-index"
13291372 checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
13301373 dependencies = [
1331− "hashbrown",
1374+ "hashbrown 0.17.1",
13321375 ]
13331376
13341377 [[package]]
16241667 checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
16251668 dependencies = [
16261669 "equivalent",
1627− "hashbrown",
1670+ "hashbrown 0.17.1",
16281671 ]
16291672
16301673 [[package]]
17551798 checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
17561799
17571800 [[package]]
1801+name = "libsqlite3-sys"
1802+version = "0.30.1"
1803+source = "registry+https://github.com/rust-lang/crates.io-index"
1804+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
1805+dependencies = [
1806+ "cc",
1807+ "pkg-config",
1808+ "vcpkg",
1809+]
1810+
1811+[[package]]
17581812 name = "litemap"
17591813 version = "0.8.3"
17601814 source = "registry+https://github.com/rust-lang/crates.io-index"
24442498 ]
24452499
24462500 [[package]]
2501+name = "rusqlite"
2502+version = "0.32.1"
2503+source = "registry+https://github.com/rust-lang/crates.io-index"
2504+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
2505+dependencies = [
2506+ "bitflags",
2507+ "fallible-iterator",
2508+ "fallible-streaming-iterator",
2509+ "hashlink 0.9.1",
2510+ "libsqlite3-sys",
2511+ "smallvec",
2512+]
2513+
2514+[[package]]
24472515 name = "russh"
24482516 version = "0.63.3"
24492517 source = "registry+https://github.com/rust-lang/crates.io-index"
35023570 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
35033571
35043572 [[package]]
3573+name = "vcpkg"
3574+version = "0.2.15"
3575+source = "registry+https://github.com/rust-lang/crates.io-index"
3576+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
3577+
3578+[[package]]
35053579 name = "version_check"
35063580 version = "0.9.5"
35073581 source = "registry+https://github.com/rust-lang/crates.io-index"
39664040 checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
39674041 dependencies = [
39684042 "arraydeque",
3969− "hashlink",
4043+ "hashlink 0.12.2",
39704044 ]
39714045
39724046 [[package]]
39934067 ]
39944068
39954069 [[package]]
4070+name = "zerocopy"
4071+version = "0.8.62"
4072+source = "registry+https://github.com/rust-lang/crates.io-index"
4073+checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
4074+dependencies = [
4075+ "zerocopy-derive",
4076+]
4077+
4078+[[package]]
4079+name = "zerocopy-derive"
4080+version = "0.8.62"
4081+source = "registry+https://github.com/rust-lang/crates.io-index"
4082+checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
4083+dependencies = [
4084+ "proc-macro2",
4085+ "quote",
4086+ "syn 2.0.119",
4087+]
4088+
4089+[[package]]
39964090 name = "zerofrom"
39974091 version = "0.1.8"
39984092 source = "registry+https://github.com/rust-lang/crates.io-index"
+16−2
243243 GitHub, a tag before a branch of the same name, and looks again at
244244 most every 5 minutes, so a tag that moves is followed within minutes.
245245 3. The first job to use an action at a commit has g1t fetch that commit's
246− archive from GitHub and keep it. Every later job, in any workspace,
247− downloads the same bytes from g1t.
246+ archive from GitHub and keep it, which can take up to two minutes.
247+ While it waits, **Set up job** says so every 5 seconds
248+ (`Waiting for g1t to fetch 'actions/setup-node@v4' (10s)`). Every
249+ later job, in any workspace, downloads the same bytes from g1t.
248250
249251 While GitHub doesn't answer, a ref reads as the commit it last named, and
250252 the job says so in a warning; an action g1t keeps still downloads. An
11661168 logs** shows or hides the times; your browser remembers which.
11671169 - A download (the job's `log.txt` and the run's zip) has the time at the
11681170 start of every line, as GitHub's downloaded logs do.
1171+- Each line's number links to it, as on GitHub: `#step:3:12` is line 12
1172+ of step 3 (0 is **Set up job**). Opening the link opens the step and
1173+ any group the line is in, scrolls to the line and marks it.
1174+- A long step's log stays fast. Each new piece of a running step's log is
1175+ read once as it arrives, and a step of more than 2,000 lines draws only
1176+ the lines on screen and a few around them. Search, groups, line links,
1177+ following the newest line and **Jump to latest** work as they do on a
1178+ short log. Your browser's own find (Ctrl+F) only sees the lines that are
1179+ drawn, so use **Search logs** in a long log.
1180+- The runner numbers each batch of the log it sends. When it sends a
1181+ batch again because g1t's answer didn't reach it, g1t keeps the batch
1182+ once, so a line never shows twice.
11691183 - If the page cannot reach g1t for a moment, it says **Reconnecting to the
11701184 log** and tries again, waiting longer each time up to half a minute.
11711185 A tab you are not looking at stops asking, and catches up when you come
+195−54
44 * its errors and warnings marked.
55 */
66 import { AlertTriangle, Ban, Check, ChevronRight, CircleDashed, CircleSlash, Clock, Hourglass, Info, Loader2, ShieldAlert, X } from "lucide-react";
7−import { Fragment, memo, useEffect, useMemo, useRef, useState } from "react";
7+import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
88 import { Link } from "react-router";
99
1010 import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts";
1212 import { Card } from "./ui/card";
1313 import { Hint } from "./ui/hint";
1414 import { markSpans, parseAnsi, spanLook } from "../lib/ansi";
15−import { type Line, blocks, searchLog } from "../lib/log-lines";
15+import { type Line, type Row, type StepLog, appendLog, emptyLog, groupOf, logRows, rowKey, searchStepLog } from "../lib/log-lines";
16+import { WINDOW_FROM, rowStarts, windowOf } from "../lib/log-window";
1617 import { type PollAnswer, nextPoll, retryAfterMs } from "../lib/log-poll";
1718
1819 /**
147148 );
148149 }
149150
150−/** One line of a step's log: its number, its time when asked for, and its text. Drawn again only when it changes. */
151−type LogLineProps = { line: Line; number: number; query?: string; timestamps?: boolean };
151+/** One line of a step's log: its number (a link to it), its time when asked for, and its text. Drawn again only when it changes. */
152+type LogLineProps = { line: Line; number: number; query?: string; timestamps?: boolean; anchor?: string; targeted?: boolean };
152153
153−const LogLine = memo(function LogLine({ line, number, query = "", timestamps = false }: LogLineProps) {
154+const LogLine = memo(function LogLine({ line, number, query = "", timestamps = false, anchor, targeted = false }: LogLineProps) {
154155 const label = /^(Error|Warning|Notice): /;
155156 const text = line.text.replace(label, "");
156157 const ansi = line.ansi?.replace(label, "");
158+ const id = anchor ? `${anchor}:${number}` : undefined;
157159 return (
158− <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}>
159− <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span>
160+ <div
161+ id={id}
162+ className={`flex scroll-mt-24 gap-4 px-4 hover:bg-raised/40 ${targeted ? "bg-accent/15" : line.kind === "error" ? "bg-danger/5" : ""}`}
163+ >
164+ {id ? (
165+ <a href={`#${id}`} className="w-8 shrink-0 select-none text-right text-faint/70 hover:text-fg hover:underline">
166+ {number}
167+ </a>
168+ ) : (
169+ <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span>
170+ )}
160171 {timestamps && (
161172 <time dateTime={line.at} className="w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
162173 {line.at ? shownTime(line.at) : ""}
171182 );
172183 }, sameLine);
173184
174−/** Whether a line would draw the same: a log read again as it grows makes new objects of the lines it had. */
185+/** Whether a line would draw the same: a log that grows keeps its lines, but a search finds them again. */
175186 function sameLine(a: LogLineProps, b: LogLineProps): boolean {
176187 return (
177188 a.number === b.number &&
178189 a.query === b.query &&
179190 a.timestamps === b.timestamps &&
180− a.line.kind === b.line.kind &&
181− a.line.text === b.line.text &&
182− a.line.ansi === b.line.ansi &&
183− a.line.at === b.line.at
191+ a.anchor === b.anchor &&
192+ a.targeted === b.targeted &&
193+ a.line === b.line
184194 );
185195 }
186196
197+/** A group's title: a row that folds and opens the lines under it. */
198+function GroupRow({ row, timestamps, onToggle }: { row: Extract<Row, { kind: "group" }>; timestamps: boolean; onToggle: (block: number) => void }) {
199+ return (
200+ <button
201+ type="button"
202+ aria-expanded={row.open}
203+ onClick={() => onToggle(row.block)}
204+ className="flex w-full cursor-pointer items-center gap-1.5 px-4 text-left text-fg/85 hover:bg-raised/40"
205+ >
206+ <span className="w-8 shrink-0" />
207+ {timestamps && (
208+ <time dateTime={row.at} className="ml-2.5 w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
209+ {row.at ? shownTime(row.at) : ""}
210+ </time>
211+ )}
212+ <ChevronRight size={12} className={`shrink-0 text-faint transition-transform ${row.open ? "rotate-90" : ""}`} />
213+ <span className="min-w-0 whitespace-pre-wrap break-all">{row.title}</span>
214+ </button>
215+ );
216+}
217+
187218 /**
188219 * A step's log. With `query`, only the lines holding it (any case), groups
189220 * opened, each match marked. With `timestamps`, each line's time beside it.
221+ * `anchor` names the step in each line's link (`step:3` gives `#step:3:12`),
222+ * and `target` is the line such a link names: its group opens, and it is
223+ * scrolled to and marked. A log of more than a few thousand rows draws only
224+ * those on screen (lib/log-window.ts).
190225 */
191−export function LogText({ text, query = "", timestamps = false }: { text: string; query?: string; timestamps?: boolean }) {
192− const parsed = useMemo(() => blocks(text), [text]);
193− const found = useMemo(() => searchLog(text, query), [text, query]);
194− if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>;
195− if (query.trim()) {
226+export function LogText({
227+ log,
228+ query = "",
229+ timestamps = false,
230+ anchor,
231+ target = null,
232+}: {
233+ log: StepLog;
234+ query?: string;
235+ timestamps?: boolean;
236+ anchor?: string;
237+ target?: number | null;
238+}) {
239+ const [open, setOpen] = useState<ReadonlySet<number>>(() => new Set());
240+ const searching = query.trim().length > 0;
241+ const found = useMemo(() => searchStepLog(log, query), [log, query]);
242+ const rows = useMemo<Row[]>(() => (searching ? found.map((entry) => ({ kind: "line", entry })) : logRows(log, open)), [searching, found, log, open]);
243+ // A link to a line in a folded group opens the group.
244+ const targetGroup = target === null ? null : groupOf(log, target);
245+ useEffect(() => {
246+ if (targetGroup !== null) setOpen((known) => (known.has(targetGroup) ? known : new Set(known).add(targetGroup)));
247+ }, [targetGroup]);
248+ const toggle = useCallback((block: number) => {
249+ setOpen((known) => {
250+ const next = new Set(known);
251+ if (!next.delete(block)) next.add(block);
252+ return next;
253+ });
254+ }, []);
255+ if (!log.hasText && !log.tail) return <p className="px-4 py-2 text-xs text-faint">No output.</p>;
256+ return <LogRows rows={rows} query={searching ? query : ""} timestamps={timestamps} anchor={anchor} target={target} onToggle={toggle} />;
257+}
258+
259+/** The rows of a step's log, every one, or past `WINDOW_FROM` those on screen and a few around them. */
260+function LogRows({
261+ rows,
262+ query,
263+ timestamps,
264+ anchor,
265+ target,
266+ onToggle,
267+}: {
268+ rows: Row[];
269+ query: string;
270+ timestamps: boolean;
271+ anchor?: string;
272+ target: number | null;
273+ onToggle: (block: number) => void;
274+}) {
275+ const windowed = rows.length > WINDOW_FROM;
276+ const box = useRef<HTMLDivElement>(null);
277+ /** Each drawn row's height, by its key, as it was last measured. */
278+ const heights = useRef(new Map<string, number>());
279+ const [measured, setMeasured] = useState(0);
280+ /** The part of the log on screen, from its top, in steps of `VIEW_STEP` pixels. */
281+ const [view, setView] = useState<[number, number]>([0, 0]);
282+ const starts = useMemo(
283+ () => (windowed ? rowStarts(rows.length, (index) => heights.current.get(rowKey(rows[index]!))) : null),
284+ // `measured` changes when a row's height does.
285+ // eslint-disable-next-line react-hooks/exhaustive-deps
286+ [windowed, rows, measured],
287+ );
288+ const [from, to] = starts ? windowOf(starts, view[0], view[1]) : [0, rows.length];
289+ // Follow the screen: the view, as the window scrolls or is resized.
290+ useEffect(() => {
291+ if (!windowed) return;
292+ let frame = 0;
293+ const look = () => {
294+ frame = 0;
295+ const rect = box.current?.getBoundingClientRect();
296+ if (!rect) return;
297+ const top = Math.floor(-rect.top / VIEW_STEP) * VIEW_STEP;
298+ const bottom = Math.ceil((window.innerHeight - rect.top) / VIEW_STEP) * VIEW_STEP;
299+ setView((known) => (known[0] === top && known[1] === bottom ? known : [top, bottom]));
300+ };
301+ const soon = () => {
302+ if (!frame) frame = requestAnimationFrame(look);
303+ };
304+ look();
305+ window.addEventListener("scroll", soon, { passive: true });
306+ window.addEventListener("resize", soon);
307+ // A step folded or opened moves the log without a scroll.
308+ document.addEventListener("toggle", soon, true);
309+ return () => {
310+ if (frame) cancelAnimationFrame(frame);
311+ window.removeEventListener("scroll", soon);
312+ window.removeEventListener("resize", soon);
313+ document.removeEventListener("toggle", soon, true);
314+ };
315+ }, [windowed]);
316+ // Measure what was drawn; a row whose height is not what was kept for it
317+ // has its space corrected.
318+ useLayoutEffect(() => {
319+ if (!windowed || !box.current) return;
320+ let changed = false;
321+ for (const element of box.current.querySelectorAll<HTMLElement>("[data-row]")) {
322+ const height = element.offsetHeight;
323+ const key = element.dataset.row!;
324+ if (height > 0 && heights.current.get(key) !== height) {
325+ heights.current.set(key, height);
326+ changed = true;
327+ }
328+ }
329+ if (changed) setMeasured((n) => n + 1);
330+ });
331+ // A link to a line: scroll to it once it is in the log.
332+ const targetRow = target === null ? -1 : rows.findIndex((row) => row.kind === "line" && row.entry.number === target);
333+ const scrolledTo = useRef<number | null>(null);
334+ useLayoutEffect(() => {
335+ if (targetRow === -1 || scrolledTo.current === target || !box.current) return;
336+ scrolledTo.current = target;
337+ const top = box.current.getBoundingClientRect().top + window.scrollY + (starts ? starts[targetRow]! : 0);
338+ if (starts) window.scrollTo({ top: Math.max(0, top - window.innerHeight / 3) });
339+ else document.getElementById(`${anchor}:${target}`)?.scrollIntoView({ block: "center" });
340+ }, [targetRow, target, starts, anchor]);
341+ const drawn = rows.slice(from, to).map((row) => {
342+ const key = rowKey(row);
196343 return (
197− <div className="py-1 font-mono text-xs leading-5">
198− {found.map(({ line, number }) => (
199− <LogLine key={number} line={line} number={number} query={query} timestamps={timestamps} />
200− ))}
344+ <div key={key} data-row={windowed ? key : undefined}>
345+ {row.kind === "line" ? (
346+ <LogLine
347+ line={row.entry.line}
348+ number={row.entry.number}
349+ query={query}
350+ timestamps={timestamps}
351+ anchor={anchor}
352+ targeted={row.entry.number === target}
353+ />
354+ ) : (
355+ <GroupRow row={row} timestamps={timestamps} onToggle={onToggle} />
356+ )}
201357 </div>
202358 );
203− }
359+ });
204360 return (
205− <div className="py-1 font-mono text-xs leading-5">
206− {parsed.map((block, index) =>
207− block.kind === "line" ? (
208− <LogLine key={index} line={block.line} number={block.number} timestamps={timestamps} />
209− ) : (
210− <details key={index} className="group/log">
211− <summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 text-fg/85 hover:bg-raised/40">
212− <span className="w-8 shrink-0" />
213− {timestamps && (
214− <time dateTime={block.at} className="ml-2.5 w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
215− {block.at ? shownTime(block.at) : ""}
216− </time>
217− )}
218− <ChevronRight size={12} className="shrink-0 text-faint transition-transform group-open/log:rotate-90" />
219− {block.title}
220− </summary>
221− {block.lines.map(({ line, number }) => (
222− <LogLine key={number} line={line} number={number} timestamps={timestamps} />
223− ))}
224− </details>
225− ),
226− )}
361+ <div ref={box} className="py-1 font-mono text-xs leading-5">
362+ {starts && <div aria-hidden style={{ height: starts[from] }} />}
363+ {drawn}
364+ {starts && <div aria-hidden style={{ height: starts[rows.length]! - starts[to]! }} />}
227365 </div>
228366 );
229367 }
230368
231−/** A job's log as the page has it: each step's text (0 is the job's setup), when each last grew, and whether asking for more is failing. */
369+/** How finely the view of a windowed log is followed, in pixels: a scroll of less draws nothing new. */
370+const VIEW_STEP = 200;
371+
372+/** A job's log as the page has it: each step's log, read as it came (0 is the job's setup), when each last grew, and whether asking for more is failing. */
232373 export type JobLog = {
233− byStep: Map<number, string>;
374+ byStep: Map<number, StepLog>;
234375 /** When a chunk of each step's last arrived, by this browser's clock. */
235376 grewAt: Map<number, number>;
236377 /** The last ask failed, and the page is waiting to ask again. */
244385 * `running` is whether one of the job's steps is running now.
245386 */
246387 export function useJobLog(url: string, live: boolean, running: boolean): JobLog {
247− const [chunks, setChunks] = useState<LogChunk[]>([]);
388+ // Each chunk is read once, into its step's log, as it arrives: a long
389+ // step's log is not read again from its start for every chunk.
390+ const steps = useRef(new Map<number, StepLog>());
391+ const [byStep, setByStep] = useState<Map<number, StepLog>>(new Map());
248392 const [grewAt, setGrewAt] = useState<Map<number, number>>(new Map());
249393 const [retrying, setRetrying] = useState(false);
250394 const after = useRef(0);
252396 runningNow.current = running;
253397 useEffect(() => {
254398 after.current = 0;
255− setChunks([]);
399+ steps.current = new Map();
400+ setByStep(new Map());
256401 setGrewAt(new Map());
257402 }, [url]);
258403 useEffect(() => {
283428 if (page.chunks.length > 0) {
284429 after.current = page.chunks[page.chunks.length - 1]!.seq;
285430 const now = Date.now();
286− setChunks((known) => [...known, ...page.chunks]);
431+ for (const chunk of page.chunks) steps.current.set(chunk.step, appendLog(steps.current.get(chunk.step) ?? emptyLog(), chunk.text));
432+ setByStep(new Map(steps.current));
287433 setGrewAt((known) => {
288434 const next = new Map(known);
289435 for (const chunk of page.chunks) next.set(chunk.step, now);
315461 document.removeEventListener("visibilitychange", shown);
316462 };
317463 }, [url, live]);
318− const byStep = useMemo(() => {
319− const byStep = new Map<number, string>();
320− for (const chunk of chunks) byStep.set(chunk.step, (byStep.get(chunk.step) ?? "") + chunk.text);
321− return byStep;
322− }, [chunks]);
323464 return { byStep, grewAt, retrying };
324465 }
325466
+106−1
33
44 import type { JobLogText } from "@g1t/contracts";
55
6−import { archiveFiles, blocks, classify, fileName, highlight, lastStamp, searchLog, splitStamp } from "./log-lines.ts";
6+import {
7+ type StepLog,
8+ appendLog,
9+ archiveFiles,
10+ blocks,
11+ classify,
12+ emptyLog,
13+ fileName,
14+ groupOf,
15+ highlight,
16+ lastStamp,
17+ logRows,
18+ readLog,
19+ rowKey,
20+ searchLog,
21+ searchStepLog,
22+ splitStamp,
23+} from "./log-lines.ts";
24+
25+/** `text` read as chunks cut at `cuts` (offsets into it), one at a time. */
26+function readInChunks(text: string, cuts: number[]): StepLog {
27+ let log = emptyLog();
28+ let at = 0;
29+ for (const cut of [...cuts, text.length]) {
30+ log = appendLog(log, text.slice(at, cut));
31+ at = cut;
32+ }
33+ return log;
34+}
35+
36+/** What a log holds, for comparing two ways of reading it. */
37+function shape(log: StepLog) {
38+ return { blocks: log.blocks, lines: log.lines, blockOf: log.blockOf, group: log.group, lastAt: log.lastAt, hasText: log.hasText };
39+}
40+
41+test("a log read chunk by chunk is the log read at once, wherever the chunks fall", () => {
42+ const text = [
43+ "2026-10-12T09:41:07.1000000Z Job: test",
44+ "2026-10-12T09:41:07.2000000Z ##[group]Run cargo test",
45+ "2026-10-12T09:41:07.3000000Z cargo test --locked",
46+ "2026-10-12T09:41:07.4000000Z \u001b[32mok\u001b[0m",
47+ "2026-10-12T09:41:08.0000000Z ##[endgroup]",
48+ "2026-10-12T09:41:09.0000000Z ##[error]Process completed with exit code 101.",
49+ "",
50+ ].join("\n");
51+ const whole = readLog(text);
52+ // Every cut, and many cuts, including inside a line and inside an escape code.
53+ for (let cut = 0; cut <= text.length; cut++) assert.deepEqual(shape(readInChunks(text, [cut])), shape(whole), `cut at ${cut}`);
54+ assert.deepEqual(shape(readInChunks(text, Array.from({ length: text.length }, (_, i) => i))), shape(whole));
55+ assert.equal(whole.lastAt, "2026-10-12T09:41:09.0000000Z");
56+ assert.deepEqual(whole.blocks, blocks(text));
57+});
58+
59+test("a group a chunk leaves open holds the next chunk's lines", () => {
60+ let log = appendLog(emptyLog(), "before\n##[group]Install\none\n");
61+ assert.equal(log.group, 1);
62+ log = appendLog(log, "two\n##[endgroup]\nafter\n");
63+ assert.deepEqual(
64+ log.blocks.map((block) => (block.kind === "line" ? block.line.text : `${block.title}: ${block.lines.map((entry) => entry.line.text).join(",")}`)),
65+ ["before", "Install: one,two", "after"],
66+ );
67+ assert.equal(log.group, null);
68+ assert.equal(groupOf(log, 3), 1);
69+ assert.equal(groupOf(log, 4), null);
70+});
71+
72+test("a line still being written shows, and is read once it ends", () => {
73+ let log = appendLog(emptyLog(), "one\ntw");
74+ assert.equal(log.lines.length, 1);
75+ assert.deepEqual(log.tail, { line: { kind: "text", text: "tw" }, number: 2 });
76+ assert.deepEqual(searchStepLog(log, "TW").map((found) => found.number), [2]);
77+ log = appendLog(log, "o\n");
78+ assert.equal(log.tail, undefined);
79+ assert.deepEqual(log.lines.map((entry) => entry.line.text), ["one", "two"]);
80+ // A group's marker cut in two opens the group once it is whole.
81+ log = appendLog(log, "##[gro");
82+ assert.equal(log.tail, undefined, "half a marker is not a line");
83+ log = appendLog(log, "up]Later\nthree\n");
84+ assert.equal(groupOf(log, 3), 2);
85+ assert.equal(readLog(" \n\n").hasText, false);
86+});
87+
88+test("reading a long log chunk by chunk reads each line once", () => {
89+ const line = (n: number) => `2026-10-12T09:41:07.0000000Z ${n % 500 === 0 ? "##[group]Batch" : n % 500 === 20 ? "##[endgroup]" : `test case_${n} ... ok`}\n`;
90+ let log = emptyLog();
91+ const started = performance.now();
92+ for (let chunk = 0; chunk < 500; chunk++) {
93+ let text = "";
94+ for (let n = chunk * 100; n < chunk * 100 + 100; n++) text += line(n);
95+ log = appendLog(log, text);
96+ }
97+ const took = performance.now() - started;
98+ assert.equal(log.lines.length, 50_000 - 200);
99+ // Reading the whole text again for every chunk took about 25 s here.
100+ assert.ok(took < 2000, `50,000 lines in 500 chunks took ${Math.round(took)} ms`);
101+});
102+
103+test("a log's rows are its lines and groups' titles, open groups' lines under them", () => {
104+ const log = readLog("a\n##[group]G\nb\nc\n##[endgroup]\nd\n");
105+ const keys = (open: number[]) => logRows(log, new Set(open)).map(rowKey);
106+ assert.deepEqual(keys([]), ["L1", "G1", "L4"]);
107+ assert.deepEqual(keys([1]), ["L1", "G1", "L2", "L3", "L4"]);
108+ assert.deepEqual(logRows(log, new Set())[1], { kind: "group", block: 1, title: "G", at: undefined, open: false, size: 2 });
109+ // A search reads a log once a change.
110+ assert.equal(searchStepLog(log, "b"), searchStepLog(log, " B "));
111+});
7112
8113 const LOG = ["Job: test", "##[group]Run cargo test", "cargo test --locked", "##[endgroup]", "test greet ... FAILED", "##[error]Process completed with exit code 101."].join("\n");
9114
+139−29
5858 return lineOf("text", raw, at);
5959 }
6060
61+/** A line and its number in the step's log. */
62+export type Numbered = { line: Line; number: number };
63+
64+/**
65+ * A step's log, read as it arrives (`appendLog`): each chunk's lines are
66+ * read once, however long the log grows, and a group a chunk leaves open
67+ * holds the next chunk's lines until its `##[endgroup]`.
68+ */
69+export type StepLog = {
70+ /** Plain lines and groups, as `blocks` reads them. */
71+ blocks: Block[];
72+ /** Every line in order, groups' lines among them: `lines[n - 1]` is line `n`. */
73+ lines: Numbered[];
74+ /** The index in `blocks` of the block that holds each line, by the same index. */
75+ blockOf: number[];
76+ /** The group still open at the end of what has arrived, by its index in `blocks`. */
77+ group: number | null;
78+ /** What came after the last newline: a line still being written, shown as `tail` meanwhile. */
79+ partial: string;
80+ tail?: Numbered;
81+ /** The time of the last stamped line. */
82+ lastAt?: string;
83+ /** Whether any line holds more than spaces. */
84+ hasText: boolean;
85+};
86+
87+export function emptyLog(): StepLog {
88+ return { blocks: [], lines: [], blockOf: [], group: null, partial: "", hasText: false };
89+}
90+
91+/**
92+ * `log` with `text` added to its end. Only the new lines are read: the
93+ * answer shares `log`'s arrays, adding to them, so `log` is not to be read
94+ * or added to again.
95+ */
96+export function appendLog(log: StepLog, text: string): StepLog {
97+ const next: StepLog = { ...log };
98+ delete next.tail;
99+ const joined = next.partial + text;
100+ const end = joined.lastIndexOf("\n");
101+ const whole = end === -1 ? "" : joined.slice(0, end);
102+ next.partial = end === -1 ? joined : joined.slice(end + 1);
103+ if (end !== -1) {
104+ for (const raw of whole.split("\n")) readLine(next, raw);
105+ }
106+ // A line that may yet be a group's marker (`##[gro`) waits to end.
107+ if (next.partial.trim() && !splitStamp(next.partial).rest.startsWith("##")) {
108+ const line = classify(next.partial);
109+ if (typeof line === "object" && !("group" in line)) next.tail = { line, number: next.lines.length + 1 };
110+ }
111+ return next;
112+}
113+
114+/** Reads one whole line into `log`, in place. */
115+function readLine(log: StepLog, raw: string): void {
116+ // A log ends with a newline; nothing follows the last one.
117+ if (raw === "" && log.lines.length === 0) return;
118+ const line = classify(raw);
119+ if (line === "group-end") {
120+ log.group = null;
121+ return;
122+ }
123+ if ("group" in line) {
124+ log.group = log.blocks.length;
125+ log.blocks.push(line.at ? { kind: "group", title: line.group, lines: [], at: line.at } : { kind: "group", title: line.group, lines: [] });
126+ return;
127+ }
128+ const entry = { line, number: log.lines.length + 1 };
129+ log.lines.push(entry);
130+ if (line.at) log.lastAt = line.at;
131+ if (!log.hasText && line.text.trim()) log.hasText = true;
132+ const open = log.group === null ? undefined : log.blocks[log.group];
133+ if (open?.kind === "group") {
134+ open.lines.push(entry);
135+ log.blockOf.push(log.group!);
136+ } else {
137+ log.blockOf.push(log.blocks.length);
138+ log.blocks.push({ kind: "line", ...entry });
139+ }
140+}
141+
142+/** A whole log, read at once. */
143+export function readLog(text: string): StepLog {
144+ return appendLog(emptyLog(), text.endsWith("\n") || text === "" ? text : `${text}\n`);
145+}
146+
61147 /** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */
62148 export function blocks(text: string): Block[] {
63− const out: Block[] = [];
64− let group: Extract<Block, { kind: "group" }> | null = null;
65− let number = 0;
66− // A log ends with a newline; nothing follows the last one.
67− for (const raw of (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n")) {
68− if (raw === "" && number === 0) continue;
69− const line = classify(raw);
70− if (line === "group-end") {
71− group = null;
72− continue;
73− }
74− if ("group" in line) {
75− group = line.at ? { kind: "group", title: line.group, lines: [], at: line.at } : { kind: "group", title: line.group, lines: [] };
76− out.push(group);
77− continue;
78− }
79− number += 1;
80− if (group) group.lines.push({ line, number });
81− else out.push({ kind: "line", line, number });
82− }
83− return out;
149+ return readLog(text).blocks;
84150 }
85151
86−/** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */
87−export function searchLog(text: string, query: string): { line: Line; number: number }[] {
152+/** The last search of each log, so a step's count and its lines are found once a change. */
153+const searched = new WeakMap<StepLog, { query: string; found: Numbered[] }>();
154+
155+/** The lines of a step's log holding `query` (any case), in order, folded groups' lines among them. */
156+export function searchStepLog(log: StepLog, query: string): Numbered[] {
88157 const wanted = query.trim().toLowerCase();
89158 if (!wanted) return [];
90− const found: { line: Line; number: number }[] = [];
91− for (const block of blocks(text)) {
92− const lines = block.kind === "line" ? [{ line: block.line, number: block.number }] : block.lines;
93− for (const entry of lines) {
94− if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry);
95− }
159+ const known = searched.get(log);
160+ if (known && known.query === wanted) return known.found;
161+ const found: Numbered[] = [];
162+ for (const entry of log.lines) {
163+ if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry);
96164 }
165+ if (log.tail && log.tail.line.text.toLowerCase().includes(wanted)) found.push(log.tail);
166+ searched.set(log, { query: wanted, found });
97167 return found;
98168 }
99169
170+/** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */
171+export function searchLog(text: string, query: string): Numbered[] {
172+ return searchStepLog(readLog(text), query);
173+}
174+
175+/**
176+ * What a step's log draws, one row each, top to bottom: its lines, and
177+ * each group's title, with the lines of the groups in `open` (by their
178+ * index in `blocks`) under it.
179+ */
180+export type Row =
181+ | { kind: "line"; entry: Numbered }
182+ | { kind: "group"; block: number; title: string; at?: string; open: boolean; size: number };
183+
184+export function logRows(log: StepLog, open: ReadonlySet<number>): Row[] {
185+ const rows: Row[] = [];
186+ log.blocks.forEach((block, index) => {
187+ if (block.kind === "line") {
188+ rows.push({ kind: "line", entry: block });
189+ return;
190+ }
191+ const isOpen = open.has(index);
192+ rows.push({ kind: "group", block: index, title: block.title, at: block.at, open: isOpen, size: block.lines.length });
193+ if (isOpen) for (const entry of block.lines) rows.push({ kind: "line", entry });
194+ });
195+ if (log.tail) rows.push({ kind: "line", entry: log.tail });
196+ return rows;
197+}
198+
199+/** The group that holds line `number`, by its index in `blocks`, or null when it is no group's. */
200+export function groupOf(log: StepLog, number: number): number | null {
201+ const index = log.blockOf[number - 1];
202+ return index !== undefined && log.blocks[index]?.kind === "group" ? index : null;
203+}
204+
205+/** A row's key, the same however the rows above it change. */
206+export function rowKey(row: Row): string {
207+ return row.kind === "line" ? `L${row.entry.number}` : `G${row.block}`;
208+}
209+
100210 /** `text` cut where `query` (any case) appears, each piece marked as a match or not. */
101211 export function highlight(text: string, query: string): { text: string; match: boolean }[] {
102212 const wanted = query.trim().toLowerCase();
+28−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { ROW_HEIGHT, rowAt, rowStarts, windowOf } from "./log-window.ts";
5+
6+test("rows start where the rows above them end, measured or not", () => {
7+ const starts = rowStarts(4, (index) => (index === 1 ? 60 : undefined));
8+ assert.deepEqual([...starts], [0, ROW_HEIGHT, ROW_HEIGHT + 60, 2 * ROW_HEIGHT + 60, 3 * ROW_HEIGHT + 60]);
9+ assert.equal(rowAt(starts, 0), 0);
10+ assert.equal(rowAt(starts, ROW_HEIGHT), 1);
11+ assert.equal(rowAt(starts, ROW_HEIGHT + 59), 1);
12+ assert.equal(rowAt(starts, ROW_HEIGHT + 60), 2);
13+ assert.equal(rowAt(starts, 10_000), 3, "past the end is the last row");
14+});
15+
16+test("only the rows on screen and a little around them are drawn", () => {
17+ const starts = rowStarts(50_000, () => undefined);
18+ // The screen shows rows 1000 to 1040.
19+ const [from, to] = windowOf(starts, 1000 * ROW_HEIGHT, 1040 * ROW_HEIGHT, 200);
20+ assert.deepEqual([from, to], [990, 1051]);
21+ // At the very end, following the newest line.
22+ const end = starts[50_000]!;
23+ assert.deepEqual(windowOf(starts, end - 800, end, 200), [49_950, 50_000]);
24+ // The log above or below the screen draws a few rows at the near end.
25+ assert.deepEqual(windowOf(starts, -5000, -4000, 200), [0, 20]);
26+ assert.deepEqual(windowOf(starts, end + 4000, end + 5000, 200), [49_980, 50_000]);
27+ assert.deepEqual(windowOf(rowStarts(0, () => undefined), 0, 100), [0, 0]);
28+});
+58−0
1+/**
2+ * Drawing a long log a screenful at a time. A step's log past a few
3+ * thousand rows draws only the rows on screen and a few around them, with
4+ * space above and below standing in for the rest, so a 50,000-line log
5+ * opens, grows and scrolls as fast as a short one. Rows are one line high
6+ * until they are drawn and measured (a long line wraps), and the space
7+ * kept for them is corrected then.
8+ */
9+
10+/** Past this many rows, a step's log draws only what is on screen. */
11+export const WINDOW_FROM = 2000;
12+/** A row's height before it is measured: one line of `text-xs leading-5`. */
13+export const ROW_HEIGHT = 20;
14+/** How far past the screen rows are drawn, above and below, in pixels. */
15+export const OVERSCAN = 800;
16+
17+/**
18+ * Where each row starts, from the top of the log: `starts[i]` for row `i`,
19+ * and `starts[rows]` for the log's whole height. `height(i)` is a row's
20+ * measured height, or undefined for the estimate.
21+ */
22+export function rowStarts(rows: number, height: (index: number) => number | undefined): Float64Array {
23+ const starts = new Float64Array(rows + 1);
24+ for (let i = 0; i < rows; i++) starts[i + 1] = starts[i]! + (height(i) ?? ROW_HEIGHT);
25+ return starts;
26+}
27+
28+/** The first row that ends below `y`: the row at `y`, or the last. */
29+export function rowAt(starts: Float64Array, y: number): number {
30+ const rows = starts.length - 1;
31+ let low = 0;
32+ let high = rows;
33+ while (low < high) {
34+ const middle = (low + high) >> 1;
35+ if (starts[middle + 1]! <= y) low = middle + 1;
36+ else high = middle;
37+ }
38+ return Math.min(low, Math.max(0, rows - 1));
39+}
40+
41+/**
42+ * The rows to draw, `[from, to)`, for a view from `top` to `bottom`
43+ * (pixels from the top of the log; either may be past its ends), with
44+ * `overscan` pixels more either side.
45+ */
46+export function windowOf(starts: Float64Array, top: number, bottom: number, overscan = OVERSCAN): [number, number] {
47+ const rows = starts.length - 1;
48+ if (rows === 0) return [0, 0];
49+ const total = starts[rows]!;
50+ const from = top - overscan;
51+ const to = bottom + overscan;
52+ if (to <= 0 || from >= total) {
53+ // Off screen: draw a few rows at the end nearest the view, so the
54+ // log has something to measure and its end can be followed.
55+ return from >= total ? [Math.max(0, rows - 20), rows] : [0, Math.min(rows, 20)];
56+ }
57+ return [rowAt(starts, Math.max(0, from)), rowAt(starts, to) + 1];
58+}
+44−13
4949 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
5050 import { Hint } from "../../components/ui/hint";
5151 import { useWorkflowReason } from "../../components/mirror";
52−import { lastStamp, searchLog } from "../../lib/log-lines";
52+import { type StepLog, searchStepLog } from "../../lib/log-lines";
5353 import { quietWords } from "../../lib/log-poll";
5454 import { listArtifacts, withLegacyArtifacts } from "../../lib/run-artifacts.server";
5555 import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/run-artifacts";
140140
141141 function StepRow({
142142 step,
143− text,
143+ log,
144144 defaultOpen,
145145 query = "",
146146 matches = 0,
148148 lastOutput = null,
149149 now,
150150 end,
151+ target = null,
151152 }: {
152153 step: StepState;
153− text: string | undefined;
154+ log: StepLog | undefined;
154155 defaultOpen: boolean;
155156 /** A search of the job's log: only its matching lines are shown. */
156157 query?: string;
162163 now: number;
163164 /** Marks the end of the running step's log, for following it. */
164165 end?: Ref<HTMLDivElement>;
166+ /** The line of this step a link names (`#step:3:12`). */
167+ target?: number | null;
165168 }) {
166169 const running = step.status === "in_progress";
167170 const quiet = running && !query ? quietWords(step.startedAt ? new Date(step.startedAt).getTime() : null, lastOutput, now) : null;
181184 </span>
182185 </summary>
183186 <div className="border-t border-line bg-bg/60">
184− {text === undefined && !running ? (
187+ {log === undefined && !running ? (
185188 <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output."}</p>
186189 ) : (
187− text !== undefined && <LogText text={text} query={query} timestamps={timestamps} />
190+ log !== undefined && <LogText log={log} query={query} timestamps={timestamps} anchor={`step:${step.number}`} target={target} />
188191 )}
189192 {quiet && (
190193 <p className="flex items-center gap-2 px-4 py-2 font-mono text-xs text-muted" aria-live="polite" suppressHydrationWarning>
198201 );
199202 }
200203
204+/**
205+ * The line of a job's log the page's address names, as GitHub's do:
206+ * `#step:3:12` is line 12 of step 3 (0 is Set up job). A line's number
207+ * links to it.
208+ */
209+function useLineTarget(): { step: number; line: number } | null {
210+ const [target, setTarget] = useState<{ step: number; line: number } | null>(null);
211+ useEffect(() => {
212+ const read = () => {
213+ const found = /^#step:(\d+):(\d+)$/.exec(window.location.hash);
214+ setTarget((known) => {
215+ if (!found) return null;
216+ const next = { step: Number(found[1]), line: Number(found[2]) };
217+ return known && known.step === next.step && known.line === next.line ? known : next;
218+ });
219+ };
220+ read();
221+ window.addEventListener("hashchange", read);
222+ return () => window.removeEventListener("hashchange", read);
223+ }, []);
224+ return target;
225+}
226+
201227 /** Whether the viewer wants each log line's time shown, kept in this browser. */
202228 function useTimestamps(): [boolean, (on: boolean) => void] {
203229 const [on, setOn] = useState(false);
478504 const [query, setQuery] = useState("");
479505 const [timestamps, setTimestamps] = useTimestamps();
480506 const now = useNow(live);
481− const runningText = running ? log.get(running.number) : undefined;
507+ const runningLog = running ? log.get(running.number) : undefined;
508+ const target = useLineTarget();
482509 // The running step's log is followed while its end is in view, unless a
483510 // failed step is the one held open.
484511 const followed = !failed && running ? running.number : null;
485− const { end, behind, jump } = useFollow(followed, runningText);
512+ const { end, behind, jump } = useFollow(followed, runningLog);
486513 // When the running step last printed: its last line's time, else when
487514 // its last chunk came.
488515 const lastOutput = useMemo(() => {
489516 if (!running) return null;
490− const at = runningText ? lastStamp(runningText) : undefined;
517+ const at = runningLog?.lastAt;
491518 if (at) return new Date(at).getTime();
492− return runningText ? (grewAt.get(running.number) ?? null) : null;
493− }, [running, runningText, grewAt]);
519+ return runningLog ? (grewAt.get(running.number) ?? null) : null;
520+ }, [running, runningLog, grewAt]);
494521 const searching = query.trim().length > 0;
495522 const setUp: StepState = {
496523 number: 0,
502529 };
503530 // While searching, the steps with a match, each with how many.
504531 const steps = [setUp, ...job.steps]
505− .map((step) => ({ step, matches: searching ? searchLog(log.get(step.number) ?? "", query).length : 0 }))
532+ .map((step) => {
533+ const stepLog = log.get(step.number);
534+ return { step, matches: searching && stepLog ? searchStepLog(stepLog, query).length : 0 };
535+ })
506536 .filter(({ matches }) => !searching || matches > 0);
507537 const total = steps.reduce((sum, { matches }) => sum + matches, 0);
508538 return (
605635 // Searching opens every step with a match; clearing it puts them back.
606636 key={`${step.number}:${searching}`}
607637 step={step}
608− text={log.get(step.number)}
609− defaultOpen={searching || (step.number !== 0 && step.number === (failed ?? running)?.number)}
638+ log={log.get(step.number)}
639+ defaultOpen={searching || step.number === target?.step || (step.number !== 0 && step.number === (failed ?? running)?.number)}
610640 query={searching ? query : ""}
611641 matches={matches}
612642 timestamps={timestamps}
613643 now={now}
614644 lastOutput={step.number === running?.number ? lastOutput : null}
615645 end={step.number === followed ? end : undefined}
646+ target={step.number === target?.step ? target.line : null}
616647 />
617648 ))}
618649 </Card>
+121−1
7171 CANCEL.take();
7272 }
7373
74+#[derive(Clone)]
7475 pub(crate) struct Api {
7576 pub(crate) base: String,
7677 pub(crate) job: String,
170171 sent: Instant,
171172 /// The problem matchers steps added, which last the whole job.
172173 pub(crate) matchers: super::matchers::Matchers,
174+ /// The number of the last batch sent, 1 for the job's first: each
175+ /// batch carries its own, and keeps it when it is sent again, so g1t
176+ /// keeps a batch it already has once.
177+ chunk: u64,
173178 /// Everything sent so far, so a test reads the whole log however its
174179 /// batches fell (`buffered`).
175180 #[cfg(test)]
188193 last: Instant::now(),
189194 sent: Instant::now(),
190195 matchers: super::matchers::Matchers::default(),
196+ chunk: 0,
191197 #[cfg(test)]
192198 flushed: String::new(),
193199 }
263269 let text = std::mem::take(&mut self.buffer);
264270 #[cfg(test)]
265271 self.flushed.push_str(&text);
266− self.api.report(json!({ "kind": "log", "step": self.step, "text": text }));
272+ let report = self.log_report(text);
273+ self.api.report(report);
274+ }
275+
276+ /// The next batch of the log, numbered.
277+ fn log_report(&mut self, text: String) -> Value {
278+ self.chunk += 1;
279+ json!({ "kind": "log", "step": self.step, "chunk": self.chunk, "text": text })
267280 }
268281
282+ /// Does `work` with the job's API on a thread of its own, writing
283+ /// `waiting(seconds)` to the log, and sending it, every `every` it is
284+ /// still going: a wait the job's page would otherwise show as a silent
285+ /// log, such as g1t fetching an action's archive from GitHub.
286+ pub(crate) fn waiting<T: Send + 'static>(
287+ &mut self,
288+ every: Duration,
289+ work: impl FnOnce(Api) -> T + Send + 'static,
290+ waiting: impl Fn(u64) -> String,
291+ ) -> T {
292+ self.flush();
293+ let api = self.api.clone();
294+ let (done, answer) = std::sync::mpsc::channel();
295+ let worker = std::thread::spawn(move || {
296+ let _ = done.send(work(api));
297+ });
298+ let started = Instant::now();
299+ let result = loop {
300+ match answer.recv_timeout(every) {
301+ Ok(result) => break result,
302+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
303+ self.line(&waiting(started.elapsed().as_secs()));
304+ self.flush();
305+ }
306+ // The work panicked: so does the job, as it would have.
307+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => match worker.join() {
308+ Err(panic) => std::panic::resume_unwind(panic),
309+ Ok(()) => unreachable!("the work sends its result before it ends"),
310+ },
311+ }
312+ };
313+ let _ = worker.join();
314+ result
315+ }
316+
269317 pub(crate) fn steps(&self, names: &[String]) {
270318 self.api.report(json!({ "kind": "steps", "steps": names }));
271319 }
416464 Log::new(Api::offline(), mask::all_variants(secrets.iter().copied()))
417465 }
418466
467+ /// A server that answers the first `fail` requests with a 500 and the
468+ /// rest with `{}`, and hands back each request's body.
469+ fn flaky_server(fail: usize, requests: usize) -> (String, std::thread::JoinHandle<Vec<Value>>) {
470+ use std::io::{BufRead, BufReader, Read, Write};
471+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
472+ let base = format!("http://{}", listener.local_addr().unwrap());
473+ let serving = std::thread::spawn(move || {
474+ let mut bodies = Vec::new();
475+ for answered in 0..requests {
476+ let (stream, _) = listener.accept().unwrap();
477+ let mut reader = BufReader::new(stream);
478+ let mut length = 0;
479+ loop {
480+ let mut header = String::new();
481+ reader.read_line(&mut header).unwrap();
482+ if header.trim().is_empty() {
483+ break;
484+ }
485+ if let Some((name, value)) = header.split_once(':')
486+ && name.eq_ignore_ascii_case("content-length")
487+ {
488+ length = value.trim().parse().unwrap();
489+ }
490+ }
491+ let mut body = vec![0; length];
492+ reader.read_exact(&mut body).unwrap();
493+ bodies.push(serde_json::from_slice::<Value>(&body).unwrap());
494+ let status = if answered < fail { "500 Internal Server Error" } else { "200 OK" };
495+ write!(reader.get_mut(), "HTTP/1.1 {status}\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}").unwrap();
496+ }
497+ bodies
498+ });
499+ (base, serving)
500+ }
501+
502+ #[test]
503+ fn each_batch_of_the_log_keeps_its_number_when_sent_again() {
504+ let (base, serving) = flaky_server(1, 3);
505+ let mut log = Log::new(Api { base, job: "job_1".into(), token: "t".into() }, Vec::new());
506+ log.line("one");
507+ log.flush();
508+ log.line("two");
509+ log.flush();
510+ log.flush(); // Nothing waits: no batch, and no number used.
511+ let bodies = serving.join().unwrap();
512+ let sent: Vec<(u64, String)> = bodies
513+ .iter()
514+ .map(|body| (body["report"]["chunk"].as_u64().unwrap(), unstamped(body["report"]["text"].as_str().unwrap())))
515+ .collect();
516+ // The first batch failed and was sent again with its number; the
517+ // next is the job's second.
518+ assert_eq!(sent, [(1, "one\n".into()), (1, "one\n".into()), (2, "two\n".into())]);
519+ assert_eq!(bodies[0], bodies[1], "a batch sent again is the same batch");
520+ }
521+
522+ #[test]
523+ fn a_long_wait_says_so_in_the_log_as_it_goes() {
524+ let mut log = log(&[]);
525+ let answer = log.waiting(Duration::from_millis(50), |_| {
526+ std::thread::sleep(Duration::from_millis(180));
527+ 42
528+ }, |seconds| format!("still waiting ({seconds}s)"));
529+ assert_eq!(answer, 42);
530+ let lines = log.buffered();
531+ let told = lines.lines().filter(|line| line.starts_with("still waiting (")).count();
532+ assert!((2..=4).contains(&told), "a line each time the wait goes on: {lines:?}");
533+ // A wait that ends at once writes nothing.
534+ let mut quick = super::tests::log(&[]);
535+ assert_eq!(quick.waiting(Duration::from_secs(5), |api| api.job, |_| "never".into()), "job_1");
536+ assert_eq!(quick.buffered(), "");
537+ }
538+
419539 #[test]
420540 fn outputs_holding_a_secret_are_left_out() {
421541 let mut log = log(&["s3cr3t-token"]);
+21−2
2323 use super::{Frame, Job, Post, PostRun};
2424
2525 const ACTIONS_DIR: &str = "/home/runner/_actions";
26+/// How often a wait on g1t for an action says it is still waiting, as
27+/// transfers say how far they have got.
28+const WAITING_EVERY: Duration = Duration::from_secs(5);
2629
2730 /// Where an action comes from.
2831 enum Source {
226229 self.log.line(&format!("##[error]`{owner}/{repo}@{git_ref}` is not a name g1t can fetch."));
227230 return None;
228231 }
229− match self.log.api.action(&format!("{owner}/{repo}"), git_ref) {
232+ // The first job to use an action at a commit waits while g1t
233+ // fetches its archive from GitHub, up to two minutes: the log says
234+ // what it is waiting for, and that it still is, as GitHub's does.
235+ self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}'…"));
236+ let repository = format!("{owner}/{repo}");
237+ let asked = (repository.clone(), git_ref.to_owned());
238+ let found = self.log.waiting(
239+ WAITING_EVERY,
240+ move |api| api.action(&asked.0, &asked.1),
241+ |seconds| format!("Waiting for g1t to fetch '{repository}@{git_ref}' ({seconds}s)"),
242+ );
243+ match found {
230244 Ok(found) if found["source"] == "g1t" => self.fetch_g1t_action(&on_g1t, owner, repo, git_ref, &found),
231245 Ok(found) => {
232246 if let Some(note) = found["note"].as_str() {
290304 let dir = super::paths::under_home(ACTIONS_DIR).join(owner).join(repo).join(git_ref);
291305 self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}' (SHA:{sha}) from g1t"));
292306 let archive = self.temp.join(format!("action{}.tar.gz", super::rand_id()));
293− let downloaded = self.log.api.action_archive(&format!("{owner}/{repo}"), sha, &archive);
307+ let asked = (format!("{owner}/{repo}"), sha.to_owned(), archive.clone());
308+ let downloaded = self.log.waiting(
309+ WAITING_EVERY,
310+ move |api| api.action_archive(&asked.0, &asked.1, &asked.2),
311+ |seconds| format!("Downloading '{owner}/{repo}@{git_ref}' from g1t ({seconds}s)"),
312+ );
294313 let unpacked = match downloaded {
295314 Ok(_) => {
296315 let _ = std::fs::remove_dir_all(&dir);
+3−0
2020 serde_json = { workspace = true, features = ["preserve_order"] }
2121 worker.workspace = true
2222
23+[dev-dependencies]
24+rusqlite = { version = "0.32", features = ["bundled"] }
25+
2326 # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
2427 # time.
2528 [package.metadata.wasm-pack.profile.release]
+6−0
1+-- The runner's own number for each batch of a job's log (1 for its first),
2+-- which a batch keeps when the runner sends it again after an answer it did
3+-- not hear: a batch g1t already has is not kept twice. Null for runners that
4+-- do not number their batches; nulls never collide in a unique index.
5+ALTER TABLE logs ADD COLUMN chunk INTEGER;
6+CREATE UNIQUE INDEX logs_by_chunk ON logs (job_id, chunk);
+59−4
3030 const MAX_CHUNK_BYTES: usize = 256 * 1024;
3131 const MAX_ANNOTATIONS: usize = 50;
3232 /// A batch of a job's log, under the next `seq` of that job's, worked out
33−/// in the same statement (SQLite runs one write at a time).
34−const LOG_INSERT: &str =
35− "INSERT INTO logs (job_id, seq, step, text) SELECT ?1, COALESCE(MAX(seq), 0) + 1, ?2, ?3 FROM logs WHERE job_id = ?1";
33+/// in the same statement (SQLite runs one write at a time). `?4` is the
34+/// runner's number for the batch (null from runners that send none): a
35+/// batch sent again, after an answer the runner did not hear, has the
36+/// number of one already kept, and is not kept twice (migration 0016).
37+const LOG_INSERT: &str = "INSERT INTO logs (job_id, seq, step, text, chunk)
38+ SELECT ?1, COALESCE(MAX(seq), 0) + 1, ?2, ?3, ?4 FROM logs WHERE job_id = ?1
39+ ON CONFLICT (job_id, chunk) DO NOTHING";
3640 /// The most one step's job summary keeps, in bytes, and how many steps of a
3741 /// job may have one, as on GitHub.
3842 pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024;
25092513 // and lose a chunk to the primary key.
25102514 self.db
25112515 .prepare(LOG_INSERT)
2512− .bind(&[job.id.as_str().into(), (report["step"].as_u64().unwrap_or(0) as u32).into(), text.into()])?
2516+ .bind(&[
2517+ job.id.as_str().into(),
2518+ (report["step"].as_u64().unwrap_or(0) as u32).into(),
2519+ text.into(),
2520+ report["chunk"].as_u64().map_or(worker::wasm_bindgen::JsValue::NULL, |n| (n as f64).into()),
2521+ ])?
25132522 .run()
25142523 .await?;
25152524 }
26742683
26752684
26762685 #[cfg(test)]
2686+mod log_chunks {
2687+ use rusqlite::{Connection, params};
2688+
2689+ use super::LOG_INSERT;
2690+
2691+ /// The logs table as the migrations leave it.
2692+ fn database() -> Connection {
2693+ let db = Connection::open_in_memory().unwrap();
2694+ for name in ["0001_init.sql", "0016_log_chunks.sql"] {
2695+ let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR"));
2696+ db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
2697+ }
2698+ db
2699+ }
2700+
2701+ fn log(db: &Connection, job: &str) -> Vec<(i64, i64, String)> {
2702+ let mut rows = db.prepare("SELECT seq, step, text FROM logs WHERE job_id = ? ORDER BY seq").unwrap();
2703+ rows.query_map([job], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap().map(Result::unwrap).collect()
2704+ }
2705+
2706+ #[test]
2707+ fn a_batch_sent_again_is_kept_once() {
2708+ let db = database();
2709+ let insert = |job: &str, step: i64, text: &str, chunk: Option<i64>| db.execute(LOG_INSERT, params![job, step, text, chunk]).unwrap();
2710+ assert_eq!(insert("job_1", 0, "one\n", Some(1)), 1);
2711+ // The runner did not hear the answer and sent the batch again.
2712+ assert_eq!(insert("job_1", 0, "one\n", Some(1)), 0);
2713+ assert_eq!(insert("job_1", 1, "two\n", Some(2)), 1);
2714+ // Another job numbers its batches from 1 too.
2715+ assert_eq!(insert("job_2", 0, "other\n", Some(1)), 1);
2716+ // A runner that numbers nothing has every batch kept, as before.
2717+ assert_eq!(insert("job_3", 0, "a\n", None), 1);
2718+ assert_eq!(insert("job_3", 0, "a\n", None), 1);
2719+ assert_eq!(log(&db, "job_1"), [(1, 0, "one\n".into()), (2, 1, "two\n".into())]);
2720+ assert_eq!(log(&db, "job_2"), [(1, 0, "other\n".into())]);
2721+ assert_eq!(log(&db, "job_3").len(), 2);
2722+ // A job run again moves its log aside (`{id}.{attempt}`), and the
2723+ // new attempt's runner numbers from 1 again.
2724+ db.execute("UPDATE logs SET job_id = job_id || '.1' WHERE job_id = 'job_1'", []).unwrap();
2725+ assert_eq!(insert("job_1", 0, "again\n", Some(1)), 1);
2726+ assert_eq!(log(&db, "job_1"), [(1, 0, "again\n".into())]);
2727+ assert_eq!(log(&db, "job_1.1").len(), 2);
2728+ }
2729+}
2730+
2731+#[cfg(test)]
26772732 mod stopping {
26782733 use super::stops_runs;
26792734 use g1t_contracts::events::Event;