Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

135 lines · 4.0 KBRaw History
1import type { LogLine } from "@diffinery/shared";
2import type { LogBatcher } from "./api.js";
3import { parseCommand } from "./commands.js";
4import type { Masker } from "./mask.js";
5import { nowIso } from "./util.js";
6
7/** Masks and ships log lines; keeps a short tail per step for failure summaries. */
8export class JobLogger {
9 private tails = new Map<number, string[]>();
10
11 constructor(
12 private readonly masker: Masker,
13 private readonly batcher: LogBatcher | null,
14 private readonly echo: boolean,
15 ) {}
16
17 line(step: number, text: string): void {
18 for (const raw of text.split(/\r?\n/)) {
19 const masked = this.masker.mask(raw);
20 const l: LogLine = { t: nowIso(), step, text: masked };
21 this.batcher?.push(l);
22 if (this.echo) process.stdout.write(`[${step}] ${masked}\n`);
23 let tail = this.tails.get(step);
24 if (!tail) this.tails.set(step, (tail = []));
25 tail.push(masked);
26 if (tail.length > 40) tail.splice(0, tail.length - 40);
27 }
28 }
29
30 tail(step: number, n = 20): string[] {
31 // skip runner chrome (##[group] etc.) in summaries
32 return (this.tails.get(step) ?? []).filter((l) => !/^##\[(group|endgroup)\]/.test(l)).slice(-n);
33 }
34}
35
36export interface Annotation {
37 level: "error" | "warning" | "notice";
38 message: string;
39 file?: string;
40 line?: string;
41 title?: string;
42}
43
44export interface OutputSink {
45 logIndex: number;
46 outputs: Record<string, string>;
47 state: Record<string, string>;
48}
49
50/**
51 * Interprets workflow commands on a step's stdout/stderr and forwards the
52 * remaining text to the logger.
53 */
54export class OutputProcessor {
55 private stopToken: string | null = null;
56 private echoCommands = false;
57
58 constructor(
59 private readonly logger: JobLogger,
60 private readonly masker: Masker,
61 private readonly sink: OutputSink,
62 private readonly hooks: {
63 debug: boolean;
64 onAnnotation: (a: Annotation) => void;
65 },
66 ) {}
67
68 private log(text: string): void {
69 this.logger.line(this.sink.logIndex, text);
70 }
71
72 handle(line: string): void {
73 if (this.stopToken !== null) {
74 if (line.trim() === `::${this.stopToken}::`) {
75 this.stopToken = null;
76 return;
77 }
78 this.log(line);
79 return;
80 }
81 const cmd = parseCommand(line);
82 if (!cmd) {
83 this.log(line);
84 return;
85 }
86 if (this.echoCommands && cmd.command !== "add-mask") this.log(line);
87 const p = cmd.properties;
88 switch (cmd.command) {
89 case "add-mask":
90 this.masker.add(cmd.message);
91 return;
92 case "set-output":
93 if (p.name) this.sink.outputs[p.name] = cmd.message;
94 return;
95 case "save-state":
96 if (p.name) this.sink.state[p.name] = cmd.message;
97 return;
98 case "group":
99 this.log(`##[group]${cmd.message}`);
100 return;
101 case "endgroup":
102 this.log("##[endgroup]");
103 return;
104 case "debug":
105 if (this.hooks.debug) this.log(`##[debug]${cmd.message}`);
106 return;
107 case "error":
108 case "warning":
109 case "notice": {
110 const a: Annotation = { level: cmd.command, message: cmd.message };
111 if (p.file) a.file = p.file;
112 if (p.line) a.line = p.line;
113 if (p.title) a.title = p.title;
114 const loc = a.file ? `${a.file}${a.line ? `:${a.line}` : ""}: ` : "";
115 const title = a.title ? `${a.title}: ` : "";
116 this.log(`##[${cmd.command}]${loc}${title}${cmd.message}`);
117 this.hooks.onAnnotation(a);
118 return;
119 }
120 case "stop-commands":
121 if (cmd.message) this.stopToken = cmd.message;
122 return;
123 case "echo":
124 this.echoCommands = cmd.message.trim() === "on";
125 return;
126 case "set-env":
127 case "add-path":
128 this.log(`##[error]The \`${cmd.command}\` command is disabled. Please upgrade to using Environment Files.`);
129 this.hooks.onAnnotation({ level: "error", message: `The \`${cmd.command}\` command is disabled.` });
130 return;
131 default:
132 this.log(line);
133 }
134 }
135}