Merge branch 'worktree-agent-a39a116f38472effe' into claude/cool-newton-7nm8r9
13 files+893−1090/13 viewed
| 81 | 81 | ] | |
| 82 | 82 | ||
| 83 | 83 | [[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]] | |
| 84 | 96 | name = "aho-corasick" | |
| 85 | 97 | version = "1.1.5" | |
| 86 | 98 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 740 | 752 | ] | |
| 741 | 753 | ||
| 742 | 754 | [[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]] | |
| 743 | 767 | name = "ff" | |
| 744 | 768 | version = "0.14.0" | |
| 745 | 769 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 930 | 954 | "g1t-kit", | |
| 931 | 955 | "g1t-secrets", | |
| 932 | 956 | "hex", | |
| 957 | + | "rusqlite", | |
| 933 | 958 | "serde", | |
| 934 | 959 | "serde_json", | |
| 935 | 960 | "worker", | |
| ⋯ | |||
| 1315 | 1340 | ||
| 1316 | 1341 | [[package]] | |
| 1317 | 1342 | 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" | |
| 1318 | 1352 | version = "0.17.1" | |
| 1319 | 1353 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1320 | 1354 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | |
| ⋯ | |||
| 1324 | 1358 | ||
| 1325 | 1359 | [[package]] | |
| 1326 | 1360 | 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" | |
| 1327 | 1370 | version = "0.12.2" | |
| 1328 | 1371 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1329 | 1372 | checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" | |
| 1330 | 1373 | dependencies = [ | |
| 1331 | − | "hashbrown", | |
| 1374 | + | "hashbrown 0.17.1", | |
| 1332 | 1375 | ] | |
| 1333 | 1376 | ||
| 1334 | 1377 | [[package]] | |
| ⋯ | |||
| 1624 | 1667 | checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" | |
| 1625 | 1668 | dependencies = [ | |
| 1626 | 1669 | "equivalent", | |
| 1627 | − | "hashbrown", | |
| 1670 | + | "hashbrown 0.17.1", | |
| 1628 | 1671 | ] | |
| 1629 | 1672 | ||
| 1630 | 1673 | [[package]] | |
| ⋯ | |||
| 1755 | 1798 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | |
| 1756 | 1799 | ||
| 1757 | 1800 | [[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]] | |
| 1758 | 1812 | name = "litemap" | |
| 1759 | 1813 | version = "0.8.3" | |
| 1760 | 1814 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 2444 | 2498 | ] | |
| 2445 | 2499 | ||
| 2446 | 2500 | [[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]] | |
| 2447 | 2515 | name = "russh" | |
| 2448 | 2516 | version = "0.63.3" | |
| 2449 | 2517 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3502 | 3570 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | |
| 3503 | 3571 | ||
| 3504 | 3572 | [[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]] | |
| 3505 | 3579 | name = "version_check" | |
| 3506 | 3580 | version = "0.9.5" | |
| 3507 | 3581 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3966 | 4040 | checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18" | |
| 3967 | 4041 | dependencies = [ | |
| 3968 | 4042 | "arraydeque", | |
| 3969 | − | "hashlink", | |
| 4043 | + | "hashlink 0.12.2", | |
| 3970 | 4044 | ] | |
| 3971 | 4045 | ||
| 3972 | 4046 | [[package]] | |
| ⋯ | |||
| 3993 | 4067 | ] | |
| 3994 | 4068 | ||
| 3995 | 4069 | [[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]] | |
| 3996 | 4090 | name = "zerofrom" | |
| 3997 | 4091 | version = "0.1.8" | |
| 3998 | 4092 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 243 | 243 | GitHub, a tag before a branch of the same name, and looks again at | |
| 244 | 244 | most every 5 minutes, so a tag that moves is followed within minutes. | |
| 245 | 245 | 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. | |
| 248 | 250 | ||
| 249 | 251 | While GitHub doesn't answer, a ref reads as the commit it last named, and | |
| 250 | 252 | the job says so in a warning; an action g1t keeps still downloads. An | |
| ⋯ | |||
| 1166 | 1168 | logs** shows or hides the times; your browser remembers which. | |
| 1167 | 1169 | - A download (the job's `log.txt` and the run's zip) has the time at the | |
| 1168 | 1170 | 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. | |
| 1169 | 1183 | - If the page cannot reach g1t for a moment, it says **Reconnecting to the | |
| 1170 | 1184 | log** and tries again, waiting longer each time up to half a minute. | |
| 1171 | 1185 | A tab you are not looking at stops asking, and catches up when you come | |
| 4 | 4 | * its errors and warnings marked. | |
| 5 | 5 | */ | |
| 6 | 6 | 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"; | |
| 8 | 8 | import { Link } from "react-router"; | |
| 9 | 9 | ||
| 10 | 10 | import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts"; | |
| ⋯ | |||
| 12 | 12 | import { Card } from "./ui/card"; | |
| 13 | 13 | import { Hint } from "./ui/hint"; | |
| 14 | 14 | 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"; | |
| 16 | 17 | import { type PollAnswer, nextPoll, retryAfterMs } from "../lib/log-poll"; | |
| 17 | 18 | ||
| 18 | 19 | /** | |
| ⋯ | |||
| 147 | 148 | ); | |
| 148 | 149 | } | |
| 149 | 150 | ||
| 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 }; | |
| 152 | 153 | ||
| 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) { | |
| 154 | 155 | const label = /^(Error|Warning|Notice): /; | |
| 155 | 156 | const text = line.text.replace(label, ""); | |
| 156 | 157 | const ansi = line.ansi?.replace(label, ""); | |
| 158 | + | const id = anchor ? `${anchor}:${number}` : undefined; | |
| 157 | 159 | 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 | + | )} | |
| 160 | 171 | {timestamps && ( | |
| 161 | 172 | <time dateTime={line.at} className="w-[24ch] shrink-0 select-none text-faint/70 tabular-nums"> | |
| 162 | 173 | {line.at ? shownTime(line.at) : ""} | |
| ⋯ | |||
| 171 | 182 | ); | |
| 172 | 183 | }, sameLine); | |
| 173 | 184 | ||
| 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. */ | |
| 175 | 186 | function sameLine(a: LogLineProps, b: LogLineProps): boolean { | |
| 176 | 187 | return ( | |
| 177 | 188 | a.number === b.number && | |
| 178 | 189 | a.query === b.query && | |
| 179 | 190 | 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 | |
| 184 | 194 | ); | |
| 185 | 195 | } | |
| 186 | 196 | ||
| 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 | + | ||
| 187 | 218 | /** | |
| 188 | 219 | * A step's log. With `query`, only the lines holding it (any case), groups | |
| 189 | 220 | * 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). | |
| 190 | 225 | */ | |
| 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); | |
| 196 | 343 | 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 | + | )} | |
| 201 | 357 | </div> | |
| 202 | 358 | ); | |
| 203 | − | } | |
| 359 | + | }); | |
| 204 | 360 | 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]! }} />} | |
| 227 | 365 | </div> | |
| 228 | 366 | ); | |
| 229 | 367 | } | |
| 230 | 368 | ||
| 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. */ | |
| 232 | 373 | export type JobLog = { | |
| 233 | − | byStep: Map<number, string>; | |
| 374 | + | byStep: Map<number, StepLog>; | |
| 234 | 375 | /** When a chunk of each step's last arrived, by this browser's clock. */ | |
| 235 | 376 | grewAt: Map<number, number>; | |
| 236 | 377 | /** The last ask failed, and the page is waiting to ask again. */ | |
| ⋯ | |||
| 244 | 385 | * `running` is whether one of the job's steps is running now. | |
| 245 | 386 | */ | |
| 246 | 387 | 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()); | |
| 248 | 392 | const [grewAt, setGrewAt] = useState<Map<number, number>>(new Map()); | |
| 249 | 393 | const [retrying, setRetrying] = useState(false); | |
| 250 | 394 | const after = useRef(0); | |
| ⋯ | |||
| 252 | 396 | runningNow.current = running; | |
| 253 | 397 | useEffect(() => { | |
| 254 | 398 | after.current = 0; | |
| 255 | − | setChunks([]); | |
| 399 | + | steps.current = new Map(); | |
| 400 | + | setByStep(new Map()); | |
| 256 | 401 | setGrewAt(new Map()); | |
| 257 | 402 | }, [url]); | |
| 258 | 403 | useEffect(() => { | |
| ⋯ | |||
| 283 | 428 | if (page.chunks.length > 0) { | |
| 284 | 429 | after.current = page.chunks[page.chunks.length - 1]!.seq; | |
| 285 | 430 | 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)); | |
| 287 | 433 | setGrewAt((known) => { | |
| 288 | 434 | const next = new Map(known); | |
| 289 | 435 | for (const chunk of page.chunks) next.set(chunk.step, now); | |
| ⋯ | |||
| 315 | 461 | document.removeEventListener("visibilitychange", shown); | |
| 316 | 462 | }; | |
| 317 | 463 | }, [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]); | |
| 323 | 464 | return { byStep, grewAt, retrying }; | |
| 324 | 465 | } | |
| 325 | 466 | ||
| 3 | 3 | ||
| 4 | 4 | import type { JobLogText } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 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 | + | }); | |
| 7 | 112 | ||
| 8 | 113 | const LOG = ["Job: test", "##[group]Run cargo test", "cargo test --locked", "##[endgroup]", "test greet ... FAILED", "##[error]Process completed with exit code 101."].join("\n"); | |
| 9 | 114 |
| 58 | 58 | return lineOf("text", raw, at); | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 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 | + | ||
| 61 | 147 | /** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */ | |
| 62 | 148 | 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; | |
| 84 | 150 | } | |
| 85 | 151 | ||
| 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[] { | |
| 88 | 157 | const wanted = query.trim().toLowerCase(); | |
| 89 | 158 | 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); | |
| 96 | 164 | } | |
| 165 | + | if (log.tail && log.tail.line.text.toLowerCase().includes(wanted)) found.push(log.tail); | |
| 166 | + | searched.set(log, { query: wanted, found }); | |
| 97 | 167 | return found; | |
| 98 | 168 | } | |
| 99 | 169 | ||
| 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 | + | ||
| 100 | 210 | /** `text` cut where `query` (any case) appears, each piece marked as a match or not. */ | |
| 101 | 211 | export function highlight(text: string, query: string): { text: string; match: boolean }[] { | |
| 102 | 212 | const wanted = query.trim().toLowerCase(); |
| 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 | + | }); |
| 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 | + | } |
| 49 | 49 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu"; | |
| 50 | 50 | import { Hint } from "../../components/ui/hint"; | |
| 51 | 51 | import { useWorkflowReason } from "../../components/mirror"; | |
| 52 | − | import { lastStamp, searchLog } from "../../lib/log-lines"; | |
| 52 | + | import { type StepLog, searchStepLog } from "../../lib/log-lines"; | |
| 53 | 53 | import { quietWords } from "../../lib/log-poll"; | |
| 54 | 54 | import { listArtifacts, withLegacyArtifacts } from "../../lib/run-artifacts.server"; | |
| 55 | 55 | import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/run-artifacts"; | |
| ⋯ | |||
| 140 | 140 | ||
| 141 | 141 | function StepRow({ | |
| 142 | 142 | step, | |
| 143 | − | text, | |
| 143 | + | log, | |
| 144 | 144 | defaultOpen, | |
| 145 | 145 | query = "", | |
| 146 | 146 | matches = 0, | |
| ⋯ | |||
| 148 | 148 | lastOutput = null, | |
| 149 | 149 | now, | |
| 150 | 150 | end, | |
| 151 | + | target = null, | |
| 151 | 152 | }: { | |
| 152 | 153 | step: StepState; | |
| 153 | − | text: string | undefined; | |
| 154 | + | log: StepLog | undefined; | |
| 154 | 155 | defaultOpen: boolean; | |
| 155 | 156 | /** A search of the job's log: only its matching lines are shown. */ | |
| 156 | 157 | query?: string; | |
| ⋯ | |||
| 162 | 163 | now: number; | |
| 163 | 164 | /** Marks the end of the running step's log, for following it. */ | |
| 164 | 165 | end?: Ref<HTMLDivElement>; | |
| 166 | + | /** The line of this step a link names (`#step:3:12`). */ | |
| 167 | + | target?: number | null; | |
| 165 | 168 | }) { | |
| 166 | 169 | const running = step.status === "in_progress"; | |
| 167 | 170 | const quiet = running && !query ? quietWords(step.startedAt ? new Date(step.startedAt).getTime() : null, lastOutput, now) : null; | |
| ⋯ | |||
| 181 | 184 | </span> | |
| 182 | 185 | </summary> | |
| 183 | 186 | <div className="border-t border-line bg-bg/60"> | |
| 184 | − | {text === undefined && !running ? ( | |
| 187 | + | {log === undefined && !running ? ( | |
| 185 | 188 | <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output."}</p> | |
| 186 | 189 | ) : ( | |
| 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} /> | |
| 188 | 191 | )} | |
| 189 | 192 | {quiet && ( | |
| 190 | 193 | <p className="flex items-center gap-2 px-4 py-2 font-mono text-xs text-muted" aria-live="polite" suppressHydrationWarning> | |
| ⋯ | |||
| 198 | 201 | ); | |
| 199 | 202 | } | |
| 200 | 203 | ||
| 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 | + | ||
| 201 | 227 | /** Whether the viewer wants each log line's time shown, kept in this browser. */ | |
| 202 | 228 | function useTimestamps(): [boolean, (on: boolean) => void] { | |
| 203 | 229 | const [on, setOn] = useState(false); | |
| ⋯ | |||
| 478 | 504 | const [query, setQuery] = useState(""); | |
| 479 | 505 | const [timestamps, setTimestamps] = useTimestamps(); | |
| 480 | 506 | 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(); | |
| 482 | 509 | // The running step's log is followed while its end is in view, unless a | |
| 483 | 510 | // failed step is the one held open. | |
| 484 | 511 | const followed = !failed && running ? running.number : null; | |
| 485 | − | const { end, behind, jump } = useFollow(followed, runningText); | |
| 512 | + | const { end, behind, jump } = useFollow(followed, runningLog); | |
| 486 | 513 | // When the running step last printed: its last line's time, else when | |
| 487 | 514 | // its last chunk came. | |
| 488 | 515 | const lastOutput = useMemo(() => { | |
| 489 | 516 | if (!running) return null; | |
| 490 | − | const at = runningText ? lastStamp(runningText) : undefined; | |
| 517 | + | const at = runningLog?.lastAt; | |
| 491 | 518 | 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]); | |
| 494 | 521 | const searching = query.trim().length > 0; | |
| 495 | 522 | const setUp: StepState = { | |
| 496 | 523 | number: 0, | |
| ⋯ | |||
| 502 | 529 | }; | |
| 503 | 530 | // While searching, the steps with a match, each with how many. | |
| 504 | 531 | 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 | + | }) | |
| 506 | 536 | .filter(({ matches }) => !searching || matches > 0); | |
| 507 | 537 | const total = steps.reduce((sum, { matches }) => sum + matches, 0); | |
| 508 | 538 | return ( | |
| ⋯ | |||
| 605 | 635 | // Searching opens every step with a match; clearing it puts them back. | |
| 606 | 636 | key={`${step.number}:${searching}`} | |
| 607 | 637 | 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)} | |
| 610 | 640 | query={searching ? query : ""} | |
| 611 | 641 | matches={matches} | |
| 612 | 642 | timestamps={timestamps} | |
| 613 | 643 | now={now} | |
| 614 | 644 | lastOutput={step.number === running?.number ? lastOutput : null} | |
| 615 | 645 | end={step.number === followed ? end : undefined} | |
| 646 | + | target={step.number === target?.step ? target.line : null} | |
| 616 | 647 | /> | |
| 617 | 648 | ))} | |
| 618 | 649 | </Card> | |
| 71 | 71 | CANCEL.take(); | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | + | #[derive(Clone)] | |
| 74 | 75 | pub(crate) struct Api { | |
| 75 | 76 | pub(crate) base: String, | |
| 76 | 77 | pub(crate) job: String, | |
| ⋯ | |||
| 170 | 171 | sent: Instant, | |
| 171 | 172 | /// The problem matchers steps added, which last the whole job. | |
| 172 | 173 | 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, | |
| 173 | 178 | /// Everything sent so far, so a test reads the whole log however its | |
| 174 | 179 | /// batches fell (`buffered`). | |
| 175 | 180 | #[cfg(test)] | |
| ⋯ | |||
| 188 | 193 | last: Instant::now(), | |
| 189 | 194 | sent: Instant::now(), | |
| 190 | 195 | matchers: super::matchers::Matchers::default(), | |
| 196 | + | chunk: 0, | |
| 191 | 197 | #[cfg(test)] | |
| 192 | 198 | flushed: String::new(), | |
| 193 | 199 | } | |
| ⋯ | |||
| 263 | 269 | let text = std::mem::take(&mut self.buffer); | |
| 264 | 270 | #[cfg(test)] | |
| 265 | 271 | 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 }) | |
| 267 | 280 | } | |
| 268 | 281 | ||
| 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 | + | ||
| 269 | 317 | pub(crate) fn steps(&self, names: &[String]) { | |
| 270 | 318 | self.api.report(json!({ "kind": "steps", "steps": names })); | |
| 271 | 319 | } | |
| ⋯ | |||
| 416 | 464 | Log::new(Api::offline(), mask::all_variants(secrets.iter().copied())) | |
| 417 | 465 | } | |
| 418 | 466 | ||
| 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 | + | ||
| 419 | 539 | #[test] | |
| 420 | 540 | fn outputs_holding_a_secret_are_left_out() { | |
| 421 | 541 | let mut log = log(&["s3cr3t-token"]); | |
| 23 | 23 | use super::{Frame, Job, Post, PostRun}; | |
| 24 | 24 | ||
| 25 | 25 | 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); | |
| 26 | 29 | ||
| 27 | 30 | /// Where an action comes from. | |
| 28 | 31 | enum Source { | |
| ⋯ | |||
| 226 | 229 | self.log.line(&format!("##[error]`{owner}/{repo}@{git_ref}` is not a name g1t can fetch.")); | |
| 227 | 230 | return None; | |
| 228 | 231 | } | |
| 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 { | |
| 230 | 244 | Ok(found) if found["source"] == "g1t" => self.fetch_g1t_action(&on_g1t, owner, repo, git_ref, &found), | |
| 231 | 245 | Ok(found) => { | |
| 232 | 246 | if let Some(note) = found["note"].as_str() { | |
| ⋯ | |||
| 290 | 304 | let dir = super::paths::under_home(ACTIONS_DIR).join(owner).join(repo).join(git_ref); | |
| 291 | 305 | self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}' (SHA:{sha}) from g1t")); | |
| 292 | 306 | 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 | + | ); | |
| 294 | 313 | let unpacked = match downloaded { | |
| 295 | 314 | Ok(_) => { | |
| 296 | 315 | let _ = std::fs::remove_dir_all(&dir); | |
| 20 | 20 | serde_json = { workspace = true, features = ["preserve_order"] } | |
| 21 | 21 | worker.workspace = true | |
| 22 | 22 | ||
| 23 | + | [dev-dependencies] | |
| 24 | + | rusqlite = { version = "0.32", features = ["bundled"] } | |
| 25 | + | ||
| 23 | 26 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 24 | 27 | # time. | |
| 25 | 28 | [package.metadata.wasm-pack.profile.release] |
| 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); |
| 30 | 30 | const MAX_CHUNK_BYTES: usize = 256 * 1024; | |
| 31 | 31 | const MAX_ANNOTATIONS: usize = 50; | |
| 32 | 32 | /// 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"; | |
| 36 | 40 | /// The most one step's job summary keeps, in bytes, and how many steps of a | |
| 37 | 41 | /// job may have one, as on GitHub. | |
| 38 | 42 | pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024; | |
| ⋯ | |||
| 2509 | 2513 | // and lose a chunk to the primary key. | |
| 2510 | 2514 | self.db | |
| 2511 | 2515 | .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 | + | ])? | |
| 2513 | 2522 | .run() | |
| 2514 | 2523 | .await?; | |
| 2515 | 2524 | } | |
| ⋯ | |||
| 2674 | 2683 | ||
| 2675 | 2684 | ||
| 2676 | 2685 | #[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)] | |
| 2677 | 2732 | mod stopping { | |
| 2678 | 2733 | use super::stops_runs; | |
| 2679 | 2734 | use g1t_contracts::events::Event; | |