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 | ||
| 6 | import 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. | 8 | import { stripAnsi } from "./ansi.ts"; |
| 9 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 10 | export 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 | */ | |
| 17 | export 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) | 18 | export 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 `. */ | |
| 23 | const 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). */ | |
| 26 | export 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. */ | |
| 32 | export 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 | ||
| 43 | function 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. | 50 | export 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 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 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 | ||
| 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. */ |
| 148 | export function blocks(text: string): Block[] { | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 149 | return readLog(text).blocks; |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 150 | } |
| 151 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 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[] { | |
| 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 []; | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 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 | } |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 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 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 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 | ||
| 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. */ |
| 211 | export 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. */ | |
| 227 | export 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. */ | |
| 238 | export 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 | */ | |
| 247 | export 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.