Skip to content
264 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1/**
2 * A job's log as GitHub shows one: lines, folded `##[group]`s, errors and
3 * warnings marked; searching it; and the files of a run's log archive.
4 */
5
6import type { JobLogText } from "@g1t/contracts";
7
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.8import { stripAnsi } from "./ansi.ts";
9
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)10export type LineKind = "text" | "error" | "warning" | "notice" | "debug" | "command";
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.11/**
12 * A line: its kind and its text, plain (what search reads). `at` is when
13 * the runner wrote it, from the time GitHub's logs stamp at the start of
14 * every line; `ansi` is its text with the colour codes it came with, when
15 * it had any.
16 */
17export type Line = { kind: LineKind; text: string; at?: string; ansi?: string };
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)18export type Block =
19 | { kind: "line"; line: Line; number: number }
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.20 | { kind: "group"; title: string; lines: { line: Line; number: number }[]; at?: string };
21
22/** The time GitHub's logs stamp at the start of a line: `2026-10-12T09:41:07.1234567Z `. */
23const STAMP = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z) /;
24
25/** A line's stamped time and the rest of it; no time for a line without one (a log from before). */
26export function splitStamp(raw: string): { at?: string; rest: string } {
27 const found = STAMP.exec(raw);
28 return found ? { at: found[1], rest: raw.slice(found[0].length) } : { rest: raw };
29}
30
31/** The time of a log's last stamped line, if it has one. */
32export function lastStamp(text: string): string | undefined {
33 let end = text.length;
34 while (end > 0) {
35 const start = text.lastIndexOf("\n", end - 1);
36 const at = splitStamp(text.slice(start + 1, end)).at;
37 if (at) return at;
38 end = start;
39 }
40 return undefined;
41}
42
43function lineOf(kind: LineKind, rest: string, at: string | undefined): Line {
44 const out: Line = { kind, text: stripAnsi(rest) };
45 if (at) out.at = at;
46 if (out.text !== rest) out.ansi = rest;
47 return out;
48}
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)49
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.50export function classify(stamped: string): Line | "group-end" | { group: string; at?: string } {
51 const { at, rest: raw } = splitStamp(stamped);
52 if (raw.startsWith("##[group]")) return at ? { group: stripAnsi(raw.slice(9)), at } : { group: stripAnsi(raw.slice(9)) };
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)53 if (raw.startsWith("##[endgroup]")) return "group-end";
54 for (const kind of ["error", "warning", "notice", "debug"] as const) {
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.55 if (raw.startsWith(`##[${kind}]`)) return lineOf(kind, raw.slice(kind.length + 4), at);
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)56 }
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.57 if (raw.startsWith("[command]")) return lineOf("command", raw.slice(9), at);
58 return lineOf("text", raw, at);
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)59}
60
A very long step log stays fast. Each chunk of a job's log is read once, into its step's log, as it arrives (`appendLog` in lib/log-lines.ts), where before every chunk joined the step's text again and read all of it from the start: a group a chunk leaves open holds the next chunk's lines, a line still being written shows until it ends, and a search reads the lines already read, once a change. A step of more than 2,000 rows draws only those on screen and 800 pixels either side (lib/log-window.ts), each row one line high until it is drawn and measured, so the space kept above and below is right however lines wrap; groups fold in the page's state rather than in a `details`, and search, following the newest line and Jump to latest work as on a short log. Each line's number links to it as GitHub's do (`#step:3:12`), opening its step and its group, scrolling to it and marking it. With a synthetic 50,000-line log in 500 chunks of 100 lines, reading it took 24.6 s in all and 105 ms for the last chunk before, and 0.5 s and 1.3 ms now; in Chromium the page drew the whole log in 2.2 s and each new chunk in 213 ms (157,000 elements), and now in 51 ms and 17 ms (385 elements). The Actions guide says so.61/** A line and its number in the step's log. */
62export 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 */
69export 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
87export 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 */
96export 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. */
115function 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. */
143export function readLog(text: string): StepLog {
144 return appendLog(emptyLog(), text.endsWith("\n") || text === "" ? text : `${text}\n`);
145}
146
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)147/** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */
148export function blocks(text: string): Block[] {
A very long step log stays fast. Each chunk of a job's log is read once, into its step's log, as it arrives (`appendLog` in lib/log-lines.ts), where before every chunk joined the step's text again and read all of it from the start: a group a chunk leaves open holds the next chunk's lines, a line still being written shows until it ends, and a search reads the lines already read, once a change. A step of more than 2,000 rows draws only those on screen and 800 pixels either side (lib/log-window.ts), each row one line high until it is drawn and measured, so the space kept above and below is right however lines wrap; groups fold in the page's state rather than in a `details`, and search, following the newest line and Jump to latest work as on a short log. Each line's number links to it as GitHub's do (`#step:3:12`), opening its step and its group, scrolling to it and marking it. With a synthetic 50,000-line log in 500 chunks of 100 lines, reading it took 24.6 s in all and 105 ms for the last chunk before, and 0.5 s and 1.3 ms now; in Chromium the page drew the whole log in 2.2 s and each new chunk in 213 ms (157,000 elements), and now in 51 ms and 17 ms (385 elements). The Actions guide says so.149 return readLog(text).blocks;
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)150}
151
A very long step log stays fast. Each chunk of a job's log is read once, into its step's log, as it arrives (`appendLog` in lib/log-lines.ts), where before every chunk joined the step's text again and read all of it from the start: a group a chunk leaves open holds the next chunk's lines, a line still being written shows until it ends, and a search reads the lines already read, once a change. A step of more than 2,000 rows draws only those on screen and 800 pixels either side (lib/log-window.ts), each row one line high until it is drawn and measured, so the space kept above and below is right however lines wrap; groups fold in the page's state rather than in a `details`, and search, following the newest line and Jump to latest work as on a short log. Each line's number links to it as GitHub's do (`#step:3:12`), opening its step and its group, scrolling to it and marking it. With a synthetic 50,000-line log in 500 chunks of 100 lines, reading it took 24.6 s in all and 105 ms for the last chunk before, and 0.5 s and 1.3 ms now; in Chromium the page drew the whole log in 2.2 s and each new chunk in 213 ms (157,000 elements), and now in 51 ms and 17 ms (385 elements). The Actions guide says so.152/** The last search of each log, so a step's count and its lines are found once a change. */
153const 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. */
156export function searchStepLog(log: StepLog, query: string): Numbered[] {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)157 const wanted = query.trim().toLowerCase();
158 if (!wanted) return [];
A very long step log stays fast. Each chunk of a job's log is read once, into its step's log, as it arrives (`appendLog` in lib/log-lines.ts), where before every chunk joined the step's text again and read all of it from the start: a group a chunk leaves open holds the next chunk's lines, a line still being written shows until it ends, and a search reads the lines already read, once a change. A step of more than 2,000 rows draws only those on screen and 800 pixels either side (lib/log-window.ts), each row one line high until it is drawn and measured, so the space kept above and below is right however lines wrap; groups fold in the page's state rather than in a `details`, and search, following the newest line and Jump to latest work as on a short log. Each line's number links to it as GitHub's do (`#step:3:12`), opening its step and its group, scrolling to it and marking it. With a synthetic 50,000-line log in 500 chunks of 100 lines, reading it took 24.6 s in all and 105 ms for the last chunk before, and 0.5 s and 1.3 ms now; in Chromium the page drew the whole log in 2.2 s and each new chunk in 213 ms (157,000 elements), and now in 51 ms and 17 ms (385 elements). The Actions guide says so.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);
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)164 }
A very long step log stays fast. Each chunk of a job's log is read once, into its step's log, as it arrives (`appendLog` in lib/log-lines.ts), where before every chunk joined the step's text again and read all of it from the start: a group a chunk leaves open holds the next chunk's lines, a line still being written shows until it ends, and a search reads the lines already read, once a change. A step of more than 2,000 rows draws only those on screen and 800 pixels either side (lib/log-window.ts), each row one line high until it is drawn and measured, so the space kept above and below is right however lines wrap; groups fold in the page's state rather than in a `details`, and search, following the newest line and Jump to latest work as on a short log. Each line's number links to it as GitHub's do (`#step:3:12`), opening its step and its group, scrolling to it and marking it. With a synthetic 50,000-line log in 500 chunks of 100 lines, reading it took 24.6 s in all and 105 ms for the last chunk before, and 0.5 s and 1.3 ms now; in Chromium the page drew the whole log in 2.2 s and each new chunk in 213 ms (157,000 elements), and now in 51 ms and 17 ms (385 elements). The Actions guide says so.165 if (log.tail && log.tail.line.text.toLowerCase().includes(wanted)) found.push(log.tail);
166 searched.set(log, { query: wanted, found });
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)167 return found;
168}
169
A very long step log stays fast. Each chunk of a job's log is read once, into its step's log, as it arrives (`appendLog` in lib/log-lines.ts), where before every chunk joined the step's text again and read all of it from the start: a group a chunk leaves open holds the next chunk's lines, a line still being written shows until it ends, and a search reads the lines already read, once a change. A step of more than 2,000 rows draws only those on screen and 800 pixels either side (lib/log-window.ts), each row one line high until it is drawn and measured, so the space kept above and below is right however lines wrap; groups fold in the page's state rather than in a `details`, and search, following the newest line and Jump to latest work as on a short log. Each line's number links to it as GitHub's do (`#step:3:12`), opening its step and its group, scrolling to it and marking it. With a synthetic 50,000-line log in 500 chunks of 100 lines, reading it took 24.6 s in all and 105 ms for the last chunk before, and 0.5 s and 1.3 ms now; in Chromium the page drew the whole log in 2.2 s and each new chunk in 213 ms (157,000 elements), and now in 51 ms and 17 ms (385 elements). The Actions guide says so.170/** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */
171export 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 */
180export type Row =
181 | { kind: "line"; entry: Numbered }
182 | { kind: "group"; block: number; title: string; at?: string; open: boolean; size: number };
183
184export 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. */
200export 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. */
206export function rowKey(row: Row): string {
207 return row.kind === "line" ? `L${row.entry.number}` : `G${row.block}`;
208}
209
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)210/** `text` cut where `query` (any case) appears, each piece marked as a match or not. */
211export function highlight(text: string, query: string): { text: string; match: boolean }[] {
212 const wanted = query.trim().toLowerCase();
213 if (!wanted) return [{ text, match: false }];
214 const lower = text.toLowerCase();
215 const out: { text: string; match: boolean }[] = [];
216 let at = 0;
217 for (let found = lower.indexOf(wanted); found !== -1; found = lower.indexOf(wanted, at)) {
218 if (found > at) out.push({ text: text.slice(at, found), match: false });
219 out.push({ text: text.slice(found, found + wanted.length), match: true });
220 at = found + wanted.length;
221 }
222 if (at < text.length) out.push({ text: text.slice(at), match: false });
223 return out;
224}
225
226/** A name safe as a file name in an archive, as the API's archive names them. */
227export function fileName(name: string): string {
228 const cleaned = Array.from(name)
229 .map((c) => (/[/\\:*?"<>|]/.test(c) || c.charCodeAt(0) < 32 ? "_" : c))
230 .slice(0, 100)
231 .join("")
232 .trim()
233 .replace(/^\.+|\.+$/g, "");
234 return cleaned || "job";
235}
236
237/** A job's whole log as one text. */
238export function jobText(job: JobLogText): string {
239 if (job.omitted) return "This job's log was left out: the run's logs are larger than one download holds. Download it on its own.\n";
240 return job.chunks.map((chunk) => chunk.text).join("");
241}
242
243/**
244 * The files of a run's log archive: `{n}_{job}.txt` with each job's whole
245 * log, and `{job}/{step}_{step name}.txt` for each of its steps.
246 */
247export function archiveFiles(jobs: JobLogText[]): { path: string; text: string }[] {
248 const files: { path: string; text: string }[] = [];
249 jobs.forEach((job, index) => {
250 const name = fileName(job.name);
251 files.push({ path: `${index + 1}_${name}.txt`, text: jobText(job) });
252 if (job.omitted) return;
253 const steps = [...new Set(job.chunks.map((chunk) => chunk.step))].sort((a, b) => a - b);
254 for (const step of steps) {
255 const title = step === 0 ? "Set up job" : (job.steps.find((s) => s.number === step)?.name ?? `Step ${step}`);
256 const text = job.chunks
257 .filter((chunk) => chunk.step === step)
258 .map((chunk) => chunk.text)
259 .join("");
260 files.push({ path: `${name}/${step}_${fileName(title)}.txt`, text });
261 }
262 });
263 return files;
264}

This file's history is long; its oldest lines are credited to the oldest commit read.