create-interview-cockpit 0.17.3 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +184 -8
- package/template/client/src/components/GhaHistoryPanel.tsx +194 -0
- package/template/client/src/components/GhaJobsPanel.tsx +432 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +1048 -0
- package/template/client/src/components/InfraLabModal.tsx +993 -262
- package/template/client/src/components/LabsPanel.tsx +71 -5
- package/template/client/src/components/Sidebar.tsx +603 -60
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +294 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +130 -10
- package/template/client/src/types.ts +33 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +793 -0
- package/template/server/src/google-drive.ts +542 -149
- package/template/server/src/index.ts +327 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +3 -1
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import * as storage from "./storage.js";
|
|
6
|
+
|
|
7
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
interface GithubActionsLabWorkspace {
|
|
10
|
+
version: 1;
|
|
11
|
+
label: string;
|
|
12
|
+
activeFile: string;
|
|
13
|
+
files: Record<string, string>;
|
|
14
|
+
defaultEvent?: string;
|
|
15
|
+
defaultWorkflow?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type OutputKind = "stdout" | "stderr" | "info";
|
|
19
|
+
|
|
20
|
+
// Status a job can be in across the lifecycle of one act run.
|
|
21
|
+
// `pending` — declared in the workflow YAML but not yet started
|
|
22
|
+
// `running` — act has emitted its first line for this job
|
|
23
|
+
// `success` — act printed "Job succeeded" (or run finished cleanly while job was running)
|
|
24
|
+
// `failed` — act printed "Job failed" (or run finished non-zero while job was running)
|
|
25
|
+
// `skipped` — act printed a skip line
|
|
26
|
+
export type GhaJobStatus =
|
|
27
|
+
| "pending"
|
|
28
|
+
| "running"
|
|
29
|
+
| "success"
|
|
30
|
+
| "failed"
|
|
31
|
+
| "skipped";
|
|
32
|
+
|
|
33
|
+
export interface GhaStepSnapshot {
|
|
34
|
+
// Step name as printed by act (e.g. "Checkout repo").
|
|
35
|
+
name: string;
|
|
36
|
+
// "Main" for normal steps, "Pre"/"Post" for action lifecycle hooks.
|
|
37
|
+
phase: "Main" | "Pre" | "Post";
|
|
38
|
+
status: GhaJobStatus;
|
|
39
|
+
startedAt?: string;
|
|
40
|
+
endedAt?: string;
|
|
41
|
+
durationMs?: number;
|
|
42
|
+
// Captured raw lines that appeared between this step's start and its
|
|
43
|
+
// terminal marker. Capped per-step so a runaway step can't bloat the
|
|
44
|
+
// metadata file.
|
|
45
|
+
log?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GhaJobSnapshot {
|
|
49
|
+
// Job key as `act` prints it inside the [workflow/job] prefix.
|
|
50
|
+
// For matrix jobs this includes the matrix instance suffix.
|
|
51
|
+
name: string;
|
|
52
|
+
// Workflow display name from the prefix (left of the slash).
|
|
53
|
+
workflow?: string;
|
|
54
|
+
status: GhaJobStatus;
|
|
55
|
+
startedAt?: string;
|
|
56
|
+
endedAt?: string;
|
|
57
|
+
durationMs?: number;
|
|
58
|
+
// Per-step lifecycle parsed from act's `⭐ Run` / `✅ Success` markers.
|
|
59
|
+
steps?: GhaStepSnapshot[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type GhaStreamMessage =
|
|
63
|
+
| { type: "output"; kind: OutputKind; text: string }
|
|
64
|
+
| { type: "job"; job: GhaJobSnapshot }
|
|
65
|
+
| { type: "complete"; runId: string; exitCode: number; durationMs: number }
|
|
66
|
+
| { type: "error"; error: string };
|
|
67
|
+
|
|
68
|
+
// ─── Constants & guards ──────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const MAX_FILE_COUNT = 60;
|
|
71
|
+
const MAX_TOTAL_SOURCE_BYTES = 1_000_000;
|
|
72
|
+
const MAX_LOG_CHARS = 400_000;
|
|
73
|
+
const SOURCE_MANIFEST = ".gha-source-files.json";
|
|
74
|
+
|
|
75
|
+
// `act` accepts these events; we restrict to common ones so the console
|
|
76
|
+
// can't be used to run arbitrary subcommands.
|
|
77
|
+
const ALLOWED_EVENTS = new Set([
|
|
78
|
+
"push",
|
|
79
|
+
"pull_request",
|
|
80
|
+
"workflow_dispatch",
|
|
81
|
+
"release",
|
|
82
|
+
"schedule",
|
|
83
|
+
"issues",
|
|
84
|
+
"issue_comment",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
// Only these top-level `act` flags are honored from console input.
|
|
88
|
+
// Long-form is preferred to keep parsing simple.
|
|
89
|
+
const ALLOWED_ACT_FLAGS = new Set([
|
|
90
|
+
"-l",
|
|
91
|
+
"--list",
|
|
92
|
+
"-n",
|
|
93
|
+
"--dryrun",
|
|
94
|
+
"-W",
|
|
95
|
+
"--workflows",
|
|
96
|
+
"-j",
|
|
97
|
+
"--job",
|
|
98
|
+
"-e",
|
|
99
|
+
"--eventpath",
|
|
100
|
+
"--container-architecture",
|
|
101
|
+
"-P",
|
|
102
|
+
"--platform",
|
|
103
|
+
"--rm",
|
|
104
|
+
"--verbose",
|
|
105
|
+
"-v",
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
// ─── Utilities ───────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function getGhaRunsDir(): string {
|
|
111
|
+
return path.resolve(storage.getContextFilesDir(), "..", "gha-runs");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getGhaSessionsDir(): string {
|
|
115
|
+
return path.resolve(storage.getContextFilesDir(), "..", "gha-sessions");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function stripAnsi(text: string): string {
|
|
119
|
+
return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function appendLog(buffer: string, chunk: string): string {
|
|
123
|
+
if (!chunk || buffer.length >= MAX_LOG_CHARS) return buffer;
|
|
124
|
+
const remaining = MAX_LOG_CHARS - buffer.length;
|
|
125
|
+
if (chunk.length <= remaining) return buffer + chunk;
|
|
126
|
+
return `${buffer}${chunk.slice(0, remaining)}\n[log truncated]\n`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sanitizeKey(value: string): string {
|
|
130
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 120) || "session";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertSafeRelativePath(filePath: string, label: string): void {
|
|
134
|
+
if (!filePath || path.isAbsolute(filePath) || filePath.includes("..")) {
|
|
135
|
+
throw new Error(`${label} must stay within the workspace`);
|
|
136
|
+
}
|
|
137
|
+
// Reject any path that escapes via symlink-style tricks
|
|
138
|
+
if (filePath.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(filePath)) {
|
|
139
|
+
throw new Error(`${label} must be a relative path`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
|
|
144
|
+
if (!input || typeof input !== "object") {
|
|
145
|
+
throw new Error("workspace payload is required");
|
|
146
|
+
}
|
|
147
|
+
const candidate = input as Partial<GithubActionsLabWorkspace> & {
|
|
148
|
+
files?: Record<string, unknown>;
|
|
149
|
+
};
|
|
150
|
+
if (!candidate.files || typeof candidate.files !== "object") {
|
|
151
|
+
throw new Error("workspace.files must be provided");
|
|
152
|
+
}
|
|
153
|
+
const files = Object.fromEntries(
|
|
154
|
+
Object.entries(candidate.files)
|
|
155
|
+
.filter(
|
|
156
|
+
(entry): entry is [string, string] =>
|
|
157
|
+
typeof entry[0] === "string" && typeof entry[1] === "string",
|
|
158
|
+
)
|
|
159
|
+
.map(([name, content]) => [name.trim(), content]),
|
|
160
|
+
);
|
|
161
|
+
const fileNames = Object.keys(files).filter(Boolean);
|
|
162
|
+
if (fileNames.length === 0) {
|
|
163
|
+
throw new Error("workspace must contain at least one file");
|
|
164
|
+
}
|
|
165
|
+
if (fileNames.length > MAX_FILE_COUNT) {
|
|
166
|
+
throw new Error(`workspace exceeds the ${MAX_FILE_COUNT} file limit`);
|
|
167
|
+
}
|
|
168
|
+
let totalBytes = 0;
|
|
169
|
+
for (const name of fileNames) {
|
|
170
|
+
assertSafeRelativePath(name, "Workspace file path");
|
|
171
|
+
totalBytes += Buffer.byteLength(files[name], "utf8");
|
|
172
|
+
}
|
|
173
|
+
if (totalBytes > MAX_TOTAL_SOURCE_BYTES) {
|
|
174
|
+
throw new Error("workspace source exceeds the allowed size limit");
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
version: 1,
|
|
178
|
+
label:
|
|
179
|
+
typeof candidate.label === "string" && candidate.label.trim()
|
|
180
|
+
? candidate.label.trim()
|
|
181
|
+
: "GitHub Actions Lab",
|
|
182
|
+
activeFile:
|
|
183
|
+
typeof candidate.activeFile === "string" && files[candidate.activeFile]
|
|
184
|
+
? candidate.activeFile
|
|
185
|
+
: fileNames[0],
|
|
186
|
+
defaultEvent:
|
|
187
|
+
typeof candidate.defaultEvent === "string"
|
|
188
|
+
? candidate.defaultEvent
|
|
189
|
+
: undefined,
|
|
190
|
+
defaultWorkflow:
|
|
191
|
+
typeof candidate.defaultWorkflow === "string"
|
|
192
|
+
? candidate.defaultWorkflow
|
|
193
|
+
: undefined,
|
|
194
|
+
files,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── File materialization ───────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async function writeWorkspaceFiles(
|
|
201
|
+
workspaceDir: string,
|
|
202
|
+
workspace: GithubActionsLabWorkspace,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
await fs.mkdir(workspaceDir, { recursive: true });
|
|
205
|
+
await Promise.all(
|
|
206
|
+
Object.entries(workspace.files).map(async ([name, content]) => {
|
|
207
|
+
const target = path.join(workspaceDir, name);
|
|
208
|
+
const targetDir = path.dirname(target);
|
|
209
|
+
const rel = path.relative(workspaceDir, target);
|
|
210
|
+
// Defense in depth: make sure the resolved target stays under workspaceDir.
|
|
211
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
212
|
+
throw new Error(`Refusing to write file outside workspace: ${name}`);
|
|
213
|
+
}
|
|
214
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
215
|
+
await fs.writeFile(target, content, "utf8");
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function readJsonFile(filePath: string): Promise<unknown | undefined> {
|
|
221
|
+
try {
|
|
222
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
223
|
+
return JSON.parse(raw);
|
|
224
|
+
} catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function syncWorkspaceToSession(
|
|
230
|
+
sessionKey: string,
|
|
231
|
+
workspace: GithubActionsLabWorkspace,
|
|
232
|
+
): Promise<string> {
|
|
233
|
+
const sessionDir = path.join(getGhaSessionsDir(), sanitizeKey(sessionKey));
|
|
234
|
+
const workspaceDir = path.join(sessionDir, "workspace");
|
|
235
|
+
const manifestPath = path.join(sessionDir, SOURCE_MANIFEST);
|
|
236
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
237
|
+
|
|
238
|
+
const previous = await readJsonFile(manifestPath);
|
|
239
|
+
const previousFiles = Array.isArray(previous)
|
|
240
|
+
? previous.filter((item): item is string => typeof item === "string")
|
|
241
|
+
: [];
|
|
242
|
+
const nextFiles = Object.keys(workspace.files);
|
|
243
|
+
|
|
244
|
+
await Promise.all(
|
|
245
|
+
previousFiles
|
|
246
|
+
.filter((fileName) => !nextFiles.includes(fileName))
|
|
247
|
+
.map(async (fileName) => {
|
|
248
|
+
try {
|
|
249
|
+
await fs.unlink(path.join(workspaceDir, fileName));
|
|
250
|
+
} catch {
|
|
251
|
+
// ignore
|
|
252
|
+
}
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
await writeWorkspaceFiles(workspaceDir, workspace);
|
|
256
|
+
await fs.writeFile(manifestPath, JSON.stringify(nextFiles, null, 2), "utf8");
|
|
257
|
+
return workspaceDir;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Command parsing ────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
function splitCommand(command: string): string[] {
|
|
263
|
+
const tokens: string[] = [];
|
|
264
|
+
let current = "";
|
|
265
|
+
let quote: '"' | "'" | null = null;
|
|
266
|
+
let escape = false;
|
|
267
|
+
for (const char of command.trim()) {
|
|
268
|
+
if (escape) {
|
|
269
|
+
current += char;
|
|
270
|
+
escape = false;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (char === "\\") {
|
|
274
|
+
escape = true;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (quote) {
|
|
278
|
+
if (char === quote) quote = null;
|
|
279
|
+
else current += char;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (char === '"' || char === "'") {
|
|
283
|
+
quote = char;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (/\s/.test(char)) {
|
|
287
|
+
if (current) {
|
|
288
|
+
tokens.push(current);
|
|
289
|
+
current = "";
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
current += char;
|
|
294
|
+
}
|
|
295
|
+
if (quote) throw new Error("Command has an unclosed quote");
|
|
296
|
+
if (current) tokens.push(current);
|
|
297
|
+
return tokens;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface ParsedActCommand {
|
|
301
|
+
event: string | null; // null when -l is used (just listing)
|
|
302
|
+
args: string[];
|
|
303
|
+
displayCommand: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function parseActCommand(command: string): ParsedActCommand {
|
|
307
|
+
const tokens = splitCommand(command);
|
|
308
|
+
if (tokens.length === 0) throw new Error("Type a command to run");
|
|
309
|
+
if (tokens[0] !== "act") {
|
|
310
|
+
throw new Error(
|
|
311
|
+
"Only 'act' commands are supported in this lab. Try: act -l, act push, act workflow_dispatch -j greet, act -n",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const rest = tokens.slice(1);
|
|
316
|
+
let event: string | null = null;
|
|
317
|
+
const args: string[] = [];
|
|
318
|
+
let i = 0;
|
|
319
|
+
|
|
320
|
+
// First non-flag token is treated as the event name (matching real act CLI).
|
|
321
|
+
while (i < rest.length) {
|
|
322
|
+
const tok = rest[i];
|
|
323
|
+
if (!tok.startsWith("-")) {
|
|
324
|
+
if (event !== null) {
|
|
325
|
+
throw new Error(`Unexpected positional argument: ${tok}`);
|
|
326
|
+
}
|
|
327
|
+
if (!ALLOWED_EVENTS.has(tok)) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`Event '${tok}' is not allowed. Allowed: ${Array.from(ALLOWED_EVENTS).join(", ")}`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
event = tok;
|
|
333
|
+
args.push(tok);
|
|
334
|
+
i += 1;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Flag handling — only the allow-listed flags are accepted.
|
|
339
|
+
// Support `--flag=value`, `--flag value`, and short flags.
|
|
340
|
+
const eq = tok.indexOf("=");
|
|
341
|
+
const flagName = eq === -1 ? tok : tok.slice(0, eq);
|
|
342
|
+
if (!ALLOWED_ACT_FLAGS.has(flagName)) {
|
|
343
|
+
throw new Error(`Flag '${flagName}' is not allowed in the act console`);
|
|
344
|
+
}
|
|
345
|
+
args.push(tok);
|
|
346
|
+
|
|
347
|
+
// Flags that take a value
|
|
348
|
+
const takesValue = new Set([
|
|
349
|
+
"-W",
|
|
350
|
+
"--workflows",
|
|
351
|
+
"-j",
|
|
352
|
+
"--job",
|
|
353
|
+
"-e",
|
|
354
|
+
"--eventpath",
|
|
355
|
+
"--container-architecture",
|
|
356
|
+
"-P",
|
|
357
|
+
"--platform",
|
|
358
|
+
]);
|
|
359
|
+
if (takesValue.has(flagName) && eq === -1) {
|
|
360
|
+
const next = rest[i + 1];
|
|
361
|
+
if (!next || next.startsWith("-")) {
|
|
362
|
+
throw new Error(`Flag '${flagName}' requires a value`);
|
|
363
|
+
}
|
|
364
|
+
// For workflow/eventpath, keep it inside workspace
|
|
365
|
+
if (flagName === "-W" || flagName === "--workflows") {
|
|
366
|
+
assertSafeRelativePath(next, "workflow path");
|
|
367
|
+
}
|
|
368
|
+
if (flagName === "-e" || flagName === "--eventpath") {
|
|
369
|
+
assertSafeRelativePath(next, "event path");
|
|
370
|
+
}
|
|
371
|
+
args.push(next);
|
|
372
|
+
i += 2;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
i += 1;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
event,
|
|
380
|
+
args,
|
|
381
|
+
displayCommand: `$ act ${args.join(" ")}\n`.replace(/\s+\n$/, "\n"),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── Run ─────────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
interface RunInput {
|
|
388
|
+
questionId?: string;
|
|
389
|
+
fileId?: string;
|
|
390
|
+
label?: string;
|
|
391
|
+
command: string;
|
|
392
|
+
workspace: unknown;
|
|
393
|
+
onMessage?: (message: GhaStreamMessage) => void;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export interface GhaRunMetadata {
|
|
397
|
+
id: string;
|
|
398
|
+
fileId?: string;
|
|
399
|
+
questionId?: string;
|
|
400
|
+
label: string;
|
|
401
|
+
command: string;
|
|
402
|
+
status: "completed" | "failed";
|
|
403
|
+
startedAt: string;
|
|
404
|
+
completedAt: string;
|
|
405
|
+
durationMs: number;
|
|
406
|
+
exitCode: number;
|
|
407
|
+
error?: string;
|
|
408
|
+
// Parsed live from act's stdout; lets the UI render a job DAG with
|
|
409
|
+
// pending/running/success/failed boxes instead of just raw text.
|
|
410
|
+
jobs?: GhaJobSnapshot[];
|
|
411
|
+
// Captured for the History tab so users can group runs by workflow.
|
|
412
|
+
event?: string;
|
|
413
|
+
workflow?: string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ─── act output → job snapshot parser ───────────────────────────────────
|
|
417
|
+
//
|
|
418
|
+
// act prints every line for a given job with a `[<workflow>/<job>]` prefix,
|
|
419
|
+
// e.g.:
|
|
420
|
+
// [CI/greet ] 🚀 Start image=catthehacker/ubuntu:act-latest
|
|
421
|
+
// [CI/greet ] ✅ Success - Set up job
|
|
422
|
+
// [CI/greet ] 🏁 Job succeeded
|
|
423
|
+
// [CI/build-1 ] 🏁 Job failed
|
|
424
|
+
// We watch for the first line per job (→ running), and the "Job succeeded /
|
|
425
|
+
// Job failed / Job skipped" markers. Anything still running when act exits
|
|
426
|
+
// is finalised based on the process exit code.
|
|
427
|
+
const JOB_PREFIX_RE = /^\[([^\]/]+)\/([^\]]+)\]\s*(.*)$/;
|
|
428
|
+
// act prints `⭐ Run Main <step name>` to mark a step boundary. The emoji is
|
|
429
|
+
// optional in some act versions / when colour is stripped, so the regex
|
|
430
|
+
// tolerates either form.
|
|
431
|
+
const STEP_START_RE = /(?:⭐\s*)?Run\s+(Main|Pre|Post)\s+(.+?)\s*$/;
|
|
432
|
+
// And one of these for the terminal marker:
|
|
433
|
+
// ✅ Success - Main <name>
|
|
434
|
+
// ❌ Failure - Main <name>
|
|
435
|
+
// ⏭ Skipped - Main <name> (also ⚠)
|
|
436
|
+
const STEP_END_RE =
|
|
437
|
+
/(?:[✅❌⏭⚠]\s*)?(Success|Failure|Skipped)\s*-\s*(Main|Pre|Post)\s+(.+?)\s*$/;
|
|
438
|
+
const MAX_STEP_LOG_CHARS = 8_000;
|
|
439
|
+
|
|
440
|
+
class JobTracker {
|
|
441
|
+
private readonly jobs = new Map<string, GhaJobSnapshot>();
|
|
442
|
+
private leftover = "";
|
|
443
|
+
|
|
444
|
+
constructor(private readonly onUpdate: (job: GhaJobSnapshot) => void) {}
|
|
445
|
+
|
|
446
|
+
feed(text: string): void {
|
|
447
|
+
const combined = this.leftover + text;
|
|
448
|
+
const lines = combined.split(/\r?\n/);
|
|
449
|
+
this.leftover = lines.pop() ?? "";
|
|
450
|
+
for (const line of lines) this.processLine(line);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
flush(): void {
|
|
454
|
+
if (this.leftover) {
|
|
455
|
+
this.processLine(this.leftover);
|
|
456
|
+
this.leftover = "";
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
finalize(exitCode: number): void {
|
|
461
|
+
const fallback: GhaJobStatus = exitCode === 0 ? "success" : "failed";
|
|
462
|
+
const endedAt = new Date().toISOString();
|
|
463
|
+
for (const job of this.jobs.values()) {
|
|
464
|
+
// Close any still-open step first so the UI doesn't show a stuck
|
|
465
|
+
// "running" spinner inside a finished job.
|
|
466
|
+
if (job.steps) {
|
|
467
|
+
for (const step of job.steps) {
|
|
468
|
+
if (step.status === "running") {
|
|
469
|
+
step.status = fallback;
|
|
470
|
+
step.endedAt = endedAt;
|
|
471
|
+
if (step.startedAt) {
|
|
472
|
+
step.durationMs =
|
|
473
|
+
new Date(endedAt).getTime() -
|
|
474
|
+
new Date(step.startedAt).getTime();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (job.status === "running") {
|
|
480
|
+
job.status = fallback;
|
|
481
|
+
job.endedAt = endedAt;
|
|
482
|
+
if (job.startedAt) {
|
|
483
|
+
job.durationMs =
|
|
484
|
+
new Date(endedAt).getTime() - new Date(job.startedAt).getTime();
|
|
485
|
+
}
|
|
486
|
+
this.onUpdate({ ...job });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
snapshot(): GhaJobSnapshot[] {
|
|
492
|
+
return Array.from(this.jobs.values()).map((job) => ({
|
|
493
|
+
...job,
|
|
494
|
+
steps: job.steps?.map((s) => ({ ...s })),
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private processLine(line: string): void {
|
|
499
|
+
const match = JOB_PREFIX_RE.exec(line);
|
|
500
|
+
if (!match) return;
|
|
501
|
+
const workflow = match[1].trim();
|
|
502
|
+
const jobName = match[2].trim();
|
|
503
|
+
const rest = match[3] ?? "";
|
|
504
|
+
if (!jobName) return;
|
|
505
|
+
|
|
506
|
+
const existing = this.jobs.get(jobName);
|
|
507
|
+
const now = new Date().toISOString();
|
|
508
|
+
const job: GhaJobSnapshot = existing
|
|
509
|
+
? { ...existing, steps: existing.steps ? [...existing.steps] : [] }
|
|
510
|
+
: {
|
|
511
|
+
name: jobName,
|
|
512
|
+
workflow,
|
|
513
|
+
status: "running",
|
|
514
|
+
startedAt: now,
|
|
515
|
+
steps: [],
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
let touched = !existing; // first sighting always counts as an update
|
|
519
|
+
|
|
520
|
+
// ── Step boundaries ──
|
|
521
|
+
const stepStart = STEP_START_RE.exec(rest);
|
|
522
|
+
const stepEnd = STEP_END_RE.exec(rest);
|
|
523
|
+
if (stepEnd) {
|
|
524
|
+
const phase = stepEnd[2] as "Main" | "Pre" | "Post";
|
|
525
|
+
const name = stepEnd[3];
|
|
526
|
+
const status: GhaJobStatus =
|
|
527
|
+
stepEnd[1] === "Success"
|
|
528
|
+
? "success"
|
|
529
|
+
: stepEnd[1] === "Failure"
|
|
530
|
+
? "failed"
|
|
531
|
+
: "skipped";
|
|
532
|
+
const step =
|
|
533
|
+
job.steps?.find((s) => s.phase === phase && s.name === name) ?? null;
|
|
534
|
+
if (step) {
|
|
535
|
+
step.status = status;
|
|
536
|
+
step.endedAt = now;
|
|
537
|
+
if (step.startedAt) {
|
|
538
|
+
step.durationMs =
|
|
539
|
+
new Date(now).getTime() - new Date(step.startedAt).getTime();
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
// End without a matching start (act sometimes elides start lines
|
|
543
|
+
// for fast steps). Synthesize a zero-duration entry so the UI
|
|
544
|
+
// still shows the step.
|
|
545
|
+
job.steps?.push({ name, phase, status, endedAt: now });
|
|
546
|
+
}
|
|
547
|
+
touched = true;
|
|
548
|
+
} else if (stepStart) {
|
|
549
|
+
const phase = stepStart[1] as "Main" | "Pre" | "Post";
|
|
550
|
+
const name = stepStart[2];
|
|
551
|
+
// De-dupe: if a previous run already opened this same step, just
|
|
552
|
+
// restart it (matrix jobs share the parser instance per name).
|
|
553
|
+
const existingStep = job.steps?.find(
|
|
554
|
+
(s) => s.phase === phase && s.name === name && s.status === "running",
|
|
555
|
+
);
|
|
556
|
+
if (!existingStep) {
|
|
557
|
+
job.steps?.push({
|
|
558
|
+
name,
|
|
559
|
+
phase,
|
|
560
|
+
status: "running",
|
|
561
|
+
startedAt: now,
|
|
562
|
+
log: "",
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
touched = true;
|
|
566
|
+
} else if (job.steps && job.steps.length) {
|
|
567
|
+
// Non-marker content → belongs to the currently running step (if any),
|
|
568
|
+
// so the user can drill into the per-step log just like GitHub.
|
|
569
|
+
const current = [...job.steps]
|
|
570
|
+
.reverse()
|
|
571
|
+
.find((s) => s.status === "running");
|
|
572
|
+
if (current && rest.trim()) {
|
|
573
|
+
const stripped = rest.replace(/^[|\s]+/, "");
|
|
574
|
+
const next = `${current.log ?? ""}${stripped}\n`;
|
|
575
|
+
current.log =
|
|
576
|
+
next.length > MAX_STEP_LOG_CHARS
|
|
577
|
+
? next.slice(0, MAX_STEP_LOG_CHARS) + "\n[step log truncated]\n"
|
|
578
|
+
: next;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── Job terminal markers — act always prints these once per job. ──
|
|
583
|
+
if (/Job succeeded\b/i.test(rest)) {
|
|
584
|
+
job.status = "success";
|
|
585
|
+
job.endedAt = now;
|
|
586
|
+
touched = true;
|
|
587
|
+
} else if (/Job failed\b/i.test(rest)) {
|
|
588
|
+
job.status = "failed";
|
|
589
|
+
job.endedAt = now;
|
|
590
|
+
touched = true;
|
|
591
|
+
} else if (/Job skipped\b/i.test(rest)) {
|
|
592
|
+
job.status = "skipped";
|
|
593
|
+
job.endedAt = now;
|
|
594
|
+
touched = true;
|
|
595
|
+
} else if (!existing) {
|
|
596
|
+
job.status = "running";
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (job.startedAt && job.endedAt) {
|
|
600
|
+
job.durationMs =
|
|
601
|
+
new Date(job.endedAt).getTime() - new Date(job.startedAt).getTime();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (!touched) {
|
|
605
|
+
// Pure mid-step log line — we already mutated the step buffer above,
|
|
606
|
+
// but we still want the UI to refresh so the step log grows live.
|
|
607
|
+
this.jobs.set(jobName, job);
|
|
608
|
+
this.onUpdate({ ...job, steps: job.steps?.map((s) => ({ ...s })) });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
this.jobs.set(jobName, job);
|
|
613
|
+
this.onUpdate({ ...job, steps: job.steps?.map((s) => ({ ...s })) });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ─── Run history listing ────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
export interface GhaRunListOptions {
|
|
620
|
+
questionId?: string;
|
|
621
|
+
fileId?: string;
|
|
622
|
+
limit?: number;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function readRunMetadata(
|
|
626
|
+
runId: string,
|
|
627
|
+
): Promise<GhaRunMetadata | undefined> {
|
|
628
|
+
const file = path.join(getGhaRunsDir(), runId, "metadata.json");
|
|
629
|
+
const raw = await readJsonFile(file);
|
|
630
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
631
|
+
return raw as GhaRunMetadata;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export async function listGhaRuns(
|
|
635
|
+
options: GhaRunListOptions = {},
|
|
636
|
+
): Promise<GhaRunMetadata[]> {
|
|
637
|
+
const dir = getGhaRunsDir();
|
|
638
|
+
let entries: string[] = [];
|
|
639
|
+
try {
|
|
640
|
+
entries = await fs.readdir(dir);
|
|
641
|
+
} catch (err: any) {
|
|
642
|
+
if (err?.code === "ENOENT") return [];
|
|
643
|
+
throw err;
|
|
644
|
+
}
|
|
645
|
+
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
646
|
+
const results: GhaRunMetadata[] = [];
|
|
647
|
+
for (const entry of entries) {
|
|
648
|
+
const meta = await readRunMetadata(entry);
|
|
649
|
+
if (!meta) continue;
|
|
650
|
+
if (options.fileId && meta.fileId !== options.fileId) continue;
|
|
651
|
+
if (options.questionId && meta.questionId !== options.questionId) continue;
|
|
652
|
+
results.push(meta);
|
|
653
|
+
}
|
|
654
|
+
results.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
|
655
|
+
return results.slice(0, limit);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export interface GhaRunDetails extends GhaRunMetadata {
|
|
659
|
+
log: string;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export async function getGhaRun(runId: string): Promise<GhaRunDetails> {
|
|
663
|
+
const meta = await readRunMetadata(runId);
|
|
664
|
+
if (!meta) throw new Error("Run not found");
|
|
665
|
+
let log = "";
|
|
666
|
+
try {
|
|
667
|
+
log = await fs.readFile(
|
|
668
|
+
path.join(getGhaRunsDir(), runId, "run.log"),
|
|
669
|
+
"utf8",
|
|
670
|
+
);
|
|
671
|
+
} catch {
|
|
672
|
+
// log file may have been pruned; surface metadata only
|
|
673
|
+
}
|
|
674
|
+
return { ...meta, log };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export async function streamGhaCommand(
|
|
678
|
+
input: RunInput,
|
|
679
|
+
): Promise<GhaRunMetadata> {
|
|
680
|
+
const workspace = parseWorkspace(input.workspace);
|
|
681
|
+
|
|
682
|
+
const parsed = parseActCommand(input.command);
|
|
683
|
+
const sessionKey =
|
|
684
|
+
input.fileId ?? input.questionId ?? `draft-${randomUUID()}`;
|
|
685
|
+
const runId = randomUUID();
|
|
686
|
+
const startedAt = new Date().toISOString();
|
|
687
|
+
const runDir = path.join(getGhaRunsDir(), runId);
|
|
688
|
+
const workspaceDir = await syncWorkspaceToSession(sessionKey, workspace);
|
|
689
|
+
await fs.mkdir(runDir, { recursive: true });
|
|
690
|
+
|
|
691
|
+
let logs = "";
|
|
692
|
+
let status: "completed" | "failed" = "completed";
|
|
693
|
+
let errorMessage: string | undefined;
|
|
694
|
+
let exitCode = 0;
|
|
695
|
+
|
|
696
|
+
const emit = (msg: GhaStreamMessage) => input.onMessage?.(msg);
|
|
697
|
+
emit({ type: "output", kind: "info", text: parsed.displayCommand });
|
|
698
|
+
|
|
699
|
+
// Track per-job status from act's prefixed stdout/stderr lines so the
|
|
700
|
+
// client can render a live DAG in addition to the raw console.
|
|
701
|
+
const tracker = new JobTracker((job) => emit({ type: "job", job }));
|
|
702
|
+
|
|
703
|
+
const child = spawn("act", parsed.args, {
|
|
704
|
+
cwd: workspaceDir,
|
|
705
|
+
env: {
|
|
706
|
+
...process.env,
|
|
707
|
+
// Avoid TTY-only progress spinners — they spam control codes through SSE
|
|
708
|
+
ACT_LOG_LEVEL: process.env.ACT_LOG_LEVEL || "info",
|
|
709
|
+
// Disable pager-like output
|
|
710
|
+
NO_COLOR: "1",
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
await new Promise<void>((resolve) => {
|
|
715
|
+
let settled = false;
|
|
716
|
+
const finish = () => {
|
|
717
|
+
if (settled) return;
|
|
718
|
+
settled = true;
|
|
719
|
+
resolve();
|
|
720
|
+
};
|
|
721
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
722
|
+
const text = stripAnsi(chunk.toString());
|
|
723
|
+
logs = appendLog(logs, text);
|
|
724
|
+
tracker.feed(text);
|
|
725
|
+
emit({ type: "output", kind: "stdout", text });
|
|
726
|
+
});
|
|
727
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
728
|
+
const text = stripAnsi(chunk.toString());
|
|
729
|
+
logs = appendLog(logs, text);
|
|
730
|
+
tracker.feed(text);
|
|
731
|
+
emit({ type: "output", kind: "stderr", text });
|
|
732
|
+
});
|
|
733
|
+
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
734
|
+
status = "failed";
|
|
735
|
+
exitCode = 1;
|
|
736
|
+
if (err.code === "ENOENT") {
|
|
737
|
+
errorMessage =
|
|
738
|
+
"`act` is not installed on this machine. Install it with `brew install act` (macOS) or follow https://github.com/nektos/act#installation, then click Run again.";
|
|
739
|
+
} else {
|
|
740
|
+
errorMessage = err.message;
|
|
741
|
+
}
|
|
742
|
+
const text = `\n[error] ${errorMessage}\n`;
|
|
743
|
+
logs = appendLog(logs, text);
|
|
744
|
+
emit({ type: "output", kind: "stderr", text });
|
|
745
|
+
finish();
|
|
746
|
+
});
|
|
747
|
+
child.on("close", (code) => {
|
|
748
|
+
exitCode = typeof code === "number" ? code : 1;
|
|
749
|
+
if (exitCode !== 0) {
|
|
750
|
+
status = "failed";
|
|
751
|
+
errorMessage = errorMessage || `act exited with code ${exitCode}`;
|
|
752
|
+
}
|
|
753
|
+
finish();
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// Drain any partial line buffered by the tracker before we finalise.
|
|
758
|
+
tracker.flush();
|
|
759
|
+
tracker.finalize(exitCode);
|
|
760
|
+
|
|
761
|
+
const completedAt = new Date().toISOString();
|
|
762
|
+
const durationMs =
|
|
763
|
+
new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
|
764
|
+
|
|
765
|
+
const metadata: GhaRunMetadata = {
|
|
766
|
+
id: runId,
|
|
767
|
+
...(input.fileId ? { fileId: input.fileId } : {}),
|
|
768
|
+
...(input.questionId ? { questionId: input.questionId } : {}),
|
|
769
|
+
label: input.label?.trim() || workspace.label,
|
|
770
|
+
command: parsed.displayCommand.replace(/^\$\s*/, "").trim(),
|
|
771
|
+
status,
|
|
772
|
+
startedAt,
|
|
773
|
+
completedAt,
|
|
774
|
+
durationMs,
|
|
775
|
+
exitCode,
|
|
776
|
+
...(errorMessage ? { error: errorMessage } : {}),
|
|
777
|
+
jobs: tracker.snapshot(),
|
|
778
|
+
...(parsed.event ? { event: parsed.event } : {}),
|
|
779
|
+
...(workspace.defaultWorkflow
|
|
780
|
+
? { workflow: workspace.defaultWorkflow }
|
|
781
|
+
: {}),
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
await fs.writeFile(
|
|
785
|
+
path.join(runDir, "metadata.json"),
|
|
786
|
+
JSON.stringify(metadata, null, 2),
|
|
787
|
+
"utf8",
|
|
788
|
+
);
|
|
789
|
+
await fs.writeFile(path.join(runDir, "run.log"), logs, "utf8");
|
|
790
|
+
|
|
791
|
+
emit({ type: "complete", runId, exitCode, durationMs });
|
|
792
|
+
return metadata;
|
|
793
|
+
}
|