create-interview-cockpit 0.18.0 → 0.20.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/api.ts +101 -0
- 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 +583 -76
- package/template/client/src/components/LabsPanel.tsx +11 -1
- package/template/client/src/components/Sidebar.tsx +216 -59
- package/template/client/src/githubActionsLab.ts +239 -2
- package/template/client/src/store.ts +47 -0
- package/template/client/src/types.ts +6 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +327 -1
- package/template/server/src/google-drive.ts +507 -125
- package/template/server/src/index.ts +87 -1
|
@@ -128,6 +128,10 @@ interface Store {
|
|
|
128
128
|
renameWorkspace: (id: string, name: string) => Promise<void>;
|
|
129
129
|
patchWorkspace: (id: string, data: object) => Promise<void>;
|
|
130
130
|
syncWorkspace: (id: string) => Promise<import("./api").SyncWorkspaceResult>;
|
|
131
|
+
syncTopic: (
|
|
132
|
+
workspaceId: string,
|
|
133
|
+
topicId: string,
|
|
134
|
+
) => Promise<import("./api").SyncWorkspaceResult>;
|
|
131
135
|
linkDriveFolder: (
|
|
132
136
|
workspaceId: string,
|
|
133
137
|
url: string,
|
|
@@ -144,6 +148,11 @@ interface Store {
|
|
|
144
148
|
id: string,
|
|
145
149
|
targetFolderId?: string,
|
|
146
150
|
) => Promise<import("./api").ExportWorkspaceResult>;
|
|
151
|
+
exportTopic: (
|
|
152
|
+
workspaceId: string,
|
|
153
|
+
topicId: string,
|
|
154
|
+
targetFolderId?: string,
|
|
155
|
+
) => Promise<import("./api").ExportWorkspaceResult>;
|
|
147
156
|
fetchDriveSubfolders: (id: string) => Promise<import("./api").DriveFolder[]>;
|
|
148
157
|
createDriveSubfolder: (
|
|
149
158
|
id: string,
|
|
@@ -494,6 +503,40 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
494
503
|
return result;
|
|
495
504
|
},
|
|
496
505
|
|
|
506
|
+
syncTopic: async (workspaceId, topicId) => {
|
|
507
|
+
const result = await api.syncTopicApi(workspaceId, topicId);
|
|
508
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
if (workspaceId === get().activeWorkspaceId) {
|
|
512
|
+
const [topics, questions] = await Promise.all([
|
|
513
|
+
api.fetchTopics(),
|
|
514
|
+
api.fetchQuestions(topicId),
|
|
515
|
+
]);
|
|
516
|
+
set((s) => {
|
|
517
|
+
const selectedStillExists = questions.some(
|
|
518
|
+
(q) => q.id === s.selectedQuestionId,
|
|
519
|
+
);
|
|
520
|
+
const selectedWasInTopic = s.currentQuestion?.topicId === topicId;
|
|
521
|
+
return {
|
|
522
|
+
topics,
|
|
523
|
+
questionsByTopic: { ...s.questionsByTopic, [topicId]: questions },
|
|
524
|
+
selectedQuestionId:
|
|
525
|
+
selectedWasInTopic && !selectedStillExists
|
|
526
|
+
? null
|
|
527
|
+
: s.selectedQuestionId,
|
|
528
|
+
currentQuestion:
|
|
529
|
+
selectedWasInTopic && !selectedStillExists
|
|
530
|
+
? null
|
|
531
|
+
: s.currentQuestion,
|
|
532
|
+
};
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
const registry = await api.fetchWorkspaces();
|
|
536
|
+
set({ workspaces: registry.workspaces });
|
|
537
|
+
return result;
|
|
538
|
+
},
|
|
539
|
+
|
|
497
540
|
linkDriveFolder: async (workspaceId, url) => {
|
|
498
541
|
const { registry, folders } = await api.linkDriveFolder(workspaceId, url);
|
|
499
542
|
set({ workspaces: registry.workspaces, driveRootFolders: folders });
|
|
@@ -556,6 +599,10 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
556
599
|
return api.exportWorkspaceToDrive(id, targetFolderId);
|
|
557
600
|
},
|
|
558
601
|
|
|
602
|
+
exportTopic: async (workspaceId, topicId, targetFolderId) => {
|
|
603
|
+
return api.exportTopicToDrive(workspaceId, topicId, targetFolderId);
|
|
604
|
+
},
|
|
605
|
+
|
|
559
606
|
fetchDriveSubfolders: async (id) => {
|
|
560
607
|
return api.fetchDriveSubfolders(id);
|
|
561
608
|
},
|
|
@@ -58,6 +58,12 @@ export interface GithubActionsLabWorkspace {
|
|
|
58
58
|
defaultEvent?: string;
|
|
59
59
|
/** Optional default workflow file path under .github/workflows. */
|
|
60
60
|
defaultWorkflow?: string;
|
|
61
|
+
/**
|
|
62
|
+
* When true, the most recent act runs for this lab are embedded into the
|
|
63
|
+
* saved snapshot so the chat LLM can reason about real execution results
|
|
64
|
+
* (job statuses, durations, exit codes) instead of just the YAML.
|
|
65
|
+
*/
|
|
66
|
+
includeRunHistoryInContext?: boolean;
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
export interface WorkspaceMeta {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/enterpriselocallab.ts","./src/githubactionslab.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/githubactionslabmodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
|
|
1
|
+
{"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/enterpriselocallab.ts","./src/githubactionslab.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/githubactionslabmodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
|
package/template/cockpit.json
CHANGED
|
@@ -17,8 +17,51 @@ interface GithubActionsLabWorkspace {
|
|
|
17
17
|
|
|
18
18
|
type OutputKind = "stdout" | "stderr" | "info";
|
|
19
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
|
+
|
|
20
62
|
export type GhaStreamMessage =
|
|
21
63
|
| { type: "output"; kind: OutputKind; text: string }
|
|
64
|
+
| { type: "job"; job: GhaJobSnapshot }
|
|
22
65
|
| { type: "complete"; runId: string; exitCode: number; durationMs: number }
|
|
23
66
|
| { type: "error"; error: string };
|
|
24
67
|
|
|
@@ -137,7 +180,8 @@ function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
|
|
|
137
180
|
? candidate.label.trim()
|
|
138
181
|
: "GitHub Actions Lab",
|
|
139
182
|
activeFile:
|
|
140
|
-
typeof candidate.activeFile === "string" &&
|
|
183
|
+
typeof candidate.activeFile === "string" &&
|
|
184
|
+
Object.prototype.hasOwnProperty.call(files, candidate.activeFile)
|
|
141
185
|
? candidate.activeFile
|
|
142
186
|
: fileNames[0],
|
|
143
187
|
defaultEvent:
|
|
@@ -362,6 +406,273 @@ export interface GhaRunMetadata {
|
|
|
362
406
|
durationMs: number;
|
|
363
407
|
exitCode: number;
|
|
364
408
|
error?: string;
|
|
409
|
+
// Parsed live from act's stdout; lets the UI render a job DAG with
|
|
410
|
+
// pending/running/success/failed boxes instead of just raw text.
|
|
411
|
+
jobs?: GhaJobSnapshot[];
|
|
412
|
+
// Captured for the History tab so users can group runs by workflow.
|
|
413
|
+
event?: string;
|
|
414
|
+
workflow?: string;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ─── act output → job snapshot parser ───────────────────────────────────
|
|
418
|
+
//
|
|
419
|
+
// act prints every line for a given job with a `[<workflow>/<job>]` prefix,
|
|
420
|
+
// e.g.:
|
|
421
|
+
// [CI/greet ] 🚀 Start image=catthehacker/ubuntu:act-latest
|
|
422
|
+
// [CI/greet ] ✅ Success - Set up job
|
|
423
|
+
// [CI/greet ] 🏁 Job succeeded
|
|
424
|
+
// [CI/build-1 ] 🏁 Job failed
|
|
425
|
+
// We watch for the first line per job (→ running), and the "Job succeeded /
|
|
426
|
+
// Job failed / Job skipped" markers. Anything still running when act exits
|
|
427
|
+
// is finalised based on the process exit code.
|
|
428
|
+
const JOB_PREFIX_RE = /^\[([^\]/]+)\/([^\]]+)\]\s*(.*)$/;
|
|
429
|
+
// act prints `⭐ Run Main <step name>` to mark a step boundary. The emoji is
|
|
430
|
+
// optional in some act versions / when colour is stripped, so the regex
|
|
431
|
+
// tolerates either form.
|
|
432
|
+
const STEP_START_RE = /(?:⭐\s*)?Run\s+(Main|Pre|Post)\s+(.+?)\s*$/;
|
|
433
|
+
// And one of these for the terminal marker:
|
|
434
|
+
// ✅ Success - Main <name>
|
|
435
|
+
// ❌ Failure - Main <name>
|
|
436
|
+
// ⏭ Skipped - Main <name> (also ⚠)
|
|
437
|
+
const STEP_END_RE =
|
|
438
|
+
/(?:[✅❌⏭⚠]\s*)?(Success|Failure|Skipped)\s*-\s*(Main|Pre|Post)\s+(.+?)\s*$/;
|
|
439
|
+
const MAX_STEP_LOG_CHARS = 8_000;
|
|
440
|
+
|
|
441
|
+
class JobTracker {
|
|
442
|
+
private readonly jobs = new Map<string, GhaJobSnapshot>();
|
|
443
|
+
private leftover = "";
|
|
444
|
+
|
|
445
|
+
constructor(private readonly onUpdate: (job: GhaJobSnapshot) => void) {}
|
|
446
|
+
|
|
447
|
+
feed(text: string): void {
|
|
448
|
+
const combined = this.leftover + text;
|
|
449
|
+
const lines = combined.split(/\r?\n/);
|
|
450
|
+
this.leftover = lines.pop() ?? "";
|
|
451
|
+
for (const line of lines) this.processLine(line);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
flush(): void {
|
|
455
|
+
if (this.leftover) {
|
|
456
|
+
this.processLine(this.leftover);
|
|
457
|
+
this.leftover = "";
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
finalize(exitCode: number): void {
|
|
462
|
+
const fallback: GhaJobStatus = exitCode === 0 ? "success" : "failed";
|
|
463
|
+
const endedAt = new Date().toISOString();
|
|
464
|
+
for (const job of this.jobs.values()) {
|
|
465
|
+
// Close any still-open step first so the UI doesn't show a stuck
|
|
466
|
+
// "running" spinner inside a finished job.
|
|
467
|
+
if (job.steps) {
|
|
468
|
+
for (const step of job.steps) {
|
|
469
|
+
if (step.status === "running") {
|
|
470
|
+
step.status = fallback;
|
|
471
|
+
step.endedAt = endedAt;
|
|
472
|
+
if (step.startedAt) {
|
|
473
|
+
step.durationMs =
|
|
474
|
+
new Date(endedAt).getTime() -
|
|
475
|
+
new Date(step.startedAt).getTime();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (job.status === "running") {
|
|
481
|
+
job.status = fallback;
|
|
482
|
+
job.endedAt = endedAt;
|
|
483
|
+
if (job.startedAt) {
|
|
484
|
+
job.durationMs =
|
|
485
|
+
new Date(endedAt).getTime() - new Date(job.startedAt).getTime();
|
|
486
|
+
}
|
|
487
|
+
this.onUpdate({ ...job });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
snapshot(): GhaJobSnapshot[] {
|
|
493
|
+
return Array.from(this.jobs.values()).map((job) => ({
|
|
494
|
+
...job,
|
|
495
|
+
steps: job.steps?.map((s) => ({ ...s })),
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private processLine(line: string): void {
|
|
500
|
+
const match = JOB_PREFIX_RE.exec(line);
|
|
501
|
+
if (!match) return;
|
|
502
|
+
const workflow = match[1].trim();
|
|
503
|
+
const jobName = match[2].trim();
|
|
504
|
+
const rest = match[3] ?? "";
|
|
505
|
+
if (!jobName) return;
|
|
506
|
+
|
|
507
|
+
const existing = this.jobs.get(jobName);
|
|
508
|
+
const now = new Date().toISOString();
|
|
509
|
+
const job: GhaJobSnapshot = existing
|
|
510
|
+
? { ...existing, steps: existing.steps ? [...existing.steps] : [] }
|
|
511
|
+
: {
|
|
512
|
+
name: jobName,
|
|
513
|
+
workflow,
|
|
514
|
+
status: "running",
|
|
515
|
+
startedAt: now,
|
|
516
|
+
steps: [],
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
let touched = !existing; // first sighting always counts as an update
|
|
520
|
+
|
|
521
|
+
// ── Step boundaries ──
|
|
522
|
+
const stepStart = STEP_START_RE.exec(rest);
|
|
523
|
+
const stepEnd = STEP_END_RE.exec(rest);
|
|
524
|
+
if (stepEnd) {
|
|
525
|
+
const phase = stepEnd[2] as "Main" | "Pre" | "Post";
|
|
526
|
+
const name = stepEnd[3];
|
|
527
|
+
const status: GhaJobStatus =
|
|
528
|
+
stepEnd[1] === "Success"
|
|
529
|
+
? "success"
|
|
530
|
+
: stepEnd[1] === "Failure"
|
|
531
|
+
? "failed"
|
|
532
|
+
: "skipped";
|
|
533
|
+
const step =
|
|
534
|
+
job.steps?.find((s) => s.phase === phase && s.name === name) ?? null;
|
|
535
|
+
if (step) {
|
|
536
|
+
step.status = status;
|
|
537
|
+
step.endedAt = now;
|
|
538
|
+
if (step.startedAt) {
|
|
539
|
+
step.durationMs =
|
|
540
|
+
new Date(now).getTime() - new Date(step.startedAt).getTime();
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
// End without a matching start (act sometimes elides start lines
|
|
544
|
+
// for fast steps). Synthesize a zero-duration entry so the UI
|
|
545
|
+
// still shows the step.
|
|
546
|
+
job.steps?.push({ name, phase, status, endedAt: now });
|
|
547
|
+
}
|
|
548
|
+
touched = true;
|
|
549
|
+
} else if (stepStart) {
|
|
550
|
+
const phase = stepStart[1] as "Main" | "Pre" | "Post";
|
|
551
|
+
const name = stepStart[2];
|
|
552
|
+
// De-dupe: if a previous run already opened this same step, just
|
|
553
|
+
// restart it (matrix jobs share the parser instance per name).
|
|
554
|
+
const existingStep = job.steps?.find(
|
|
555
|
+
(s) => s.phase === phase && s.name === name && s.status === "running",
|
|
556
|
+
);
|
|
557
|
+
if (!existingStep) {
|
|
558
|
+
job.steps?.push({
|
|
559
|
+
name,
|
|
560
|
+
phase,
|
|
561
|
+
status: "running",
|
|
562
|
+
startedAt: now,
|
|
563
|
+
log: "",
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
touched = true;
|
|
567
|
+
} else if (job.steps && job.steps.length) {
|
|
568
|
+
// Non-marker content → belongs to the currently running step (if any),
|
|
569
|
+
// so the user can drill into the per-step log just like GitHub.
|
|
570
|
+
const current = [...job.steps]
|
|
571
|
+
.reverse()
|
|
572
|
+
.find((s) => s.status === "running");
|
|
573
|
+
if (current && rest.trim()) {
|
|
574
|
+
const stripped = rest.replace(/^[|\s]+/, "");
|
|
575
|
+
const next = `${current.log ?? ""}${stripped}\n`;
|
|
576
|
+
current.log =
|
|
577
|
+
next.length > MAX_STEP_LOG_CHARS
|
|
578
|
+
? next.slice(0, MAX_STEP_LOG_CHARS) + "\n[step log truncated]\n"
|
|
579
|
+
: next;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ── Job terminal markers — act always prints these once per job. ──
|
|
584
|
+
if (/Job succeeded\b/i.test(rest)) {
|
|
585
|
+
job.status = "success";
|
|
586
|
+
job.endedAt = now;
|
|
587
|
+
touched = true;
|
|
588
|
+
} else if (/Job failed\b/i.test(rest)) {
|
|
589
|
+
job.status = "failed";
|
|
590
|
+
job.endedAt = now;
|
|
591
|
+
touched = true;
|
|
592
|
+
} else if (/Job skipped\b/i.test(rest)) {
|
|
593
|
+
job.status = "skipped";
|
|
594
|
+
job.endedAt = now;
|
|
595
|
+
touched = true;
|
|
596
|
+
} else if (!existing) {
|
|
597
|
+
job.status = "running";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (job.startedAt && job.endedAt) {
|
|
601
|
+
job.durationMs =
|
|
602
|
+
new Date(job.endedAt).getTime() - new Date(job.startedAt).getTime();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (!touched) {
|
|
606
|
+
// Pure mid-step log line — we already mutated the step buffer above,
|
|
607
|
+
// but we still want the UI to refresh so the step log grows live.
|
|
608
|
+
this.jobs.set(jobName, job);
|
|
609
|
+
this.onUpdate({ ...job, steps: job.steps?.map((s) => ({ ...s })) });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
this.jobs.set(jobName, job);
|
|
614
|
+
this.onUpdate({ ...job, steps: job.steps?.map((s) => ({ ...s })) });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ─── Run history listing ────────────────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
export interface GhaRunListOptions {
|
|
621
|
+
questionId?: string;
|
|
622
|
+
fileId?: string;
|
|
623
|
+
limit?: number;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function readRunMetadata(
|
|
627
|
+
runId: string,
|
|
628
|
+
): Promise<GhaRunMetadata | undefined> {
|
|
629
|
+
const file = path.join(getGhaRunsDir(), runId, "metadata.json");
|
|
630
|
+
const raw = await readJsonFile(file);
|
|
631
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
632
|
+
return raw as GhaRunMetadata;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export async function listGhaRuns(
|
|
636
|
+
options: GhaRunListOptions = {},
|
|
637
|
+
): Promise<GhaRunMetadata[]> {
|
|
638
|
+
const dir = getGhaRunsDir();
|
|
639
|
+
let entries: string[] = [];
|
|
640
|
+
try {
|
|
641
|
+
entries = await fs.readdir(dir);
|
|
642
|
+
} catch (err: any) {
|
|
643
|
+
if (err?.code === "ENOENT") return [];
|
|
644
|
+
throw err;
|
|
645
|
+
}
|
|
646
|
+
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
647
|
+
const results: GhaRunMetadata[] = [];
|
|
648
|
+
for (const entry of entries) {
|
|
649
|
+
const meta = await readRunMetadata(entry);
|
|
650
|
+
if (!meta) continue;
|
|
651
|
+
if (options.fileId && meta.fileId !== options.fileId) continue;
|
|
652
|
+
if (options.questionId && meta.questionId !== options.questionId) continue;
|
|
653
|
+
results.push(meta);
|
|
654
|
+
}
|
|
655
|
+
results.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
|
656
|
+
return results.slice(0, limit);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export interface GhaRunDetails extends GhaRunMetadata {
|
|
660
|
+
log: string;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export async function getGhaRun(runId: string): Promise<GhaRunDetails> {
|
|
664
|
+
const meta = await readRunMetadata(runId);
|
|
665
|
+
if (!meta) throw new Error("Run not found");
|
|
666
|
+
let log = "";
|
|
667
|
+
try {
|
|
668
|
+
log = await fs.readFile(
|
|
669
|
+
path.join(getGhaRunsDir(), runId, "run.log"),
|
|
670
|
+
"utf8",
|
|
671
|
+
);
|
|
672
|
+
} catch {
|
|
673
|
+
// log file may have been pruned; surface metadata only
|
|
674
|
+
}
|
|
675
|
+
return { ...meta, log };
|
|
365
676
|
}
|
|
366
677
|
|
|
367
678
|
export async function streamGhaCommand(
|
|
@@ -386,6 +697,10 @@ export async function streamGhaCommand(
|
|
|
386
697
|
const emit = (msg: GhaStreamMessage) => input.onMessage?.(msg);
|
|
387
698
|
emit({ type: "output", kind: "info", text: parsed.displayCommand });
|
|
388
699
|
|
|
700
|
+
// Track per-job status from act's prefixed stdout/stderr lines so the
|
|
701
|
+
// client can render a live DAG in addition to the raw console.
|
|
702
|
+
const tracker = new JobTracker((job) => emit({ type: "job", job }));
|
|
703
|
+
|
|
389
704
|
const child = spawn("act", parsed.args, {
|
|
390
705
|
cwd: workspaceDir,
|
|
391
706
|
env: {
|
|
@@ -407,11 +722,13 @@ export async function streamGhaCommand(
|
|
|
407
722
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
408
723
|
const text = stripAnsi(chunk.toString());
|
|
409
724
|
logs = appendLog(logs, text);
|
|
725
|
+
tracker.feed(text);
|
|
410
726
|
emit({ type: "output", kind: "stdout", text });
|
|
411
727
|
});
|
|
412
728
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
413
729
|
const text = stripAnsi(chunk.toString());
|
|
414
730
|
logs = appendLog(logs, text);
|
|
731
|
+
tracker.feed(text);
|
|
415
732
|
emit({ type: "output", kind: "stderr", text });
|
|
416
733
|
});
|
|
417
734
|
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
@@ -438,6 +755,10 @@ export async function streamGhaCommand(
|
|
|
438
755
|
});
|
|
439
756
|
});
|
|
440
757
|
|
|
758
|
+
// Drain any partial line buffered by the tracker before we finalise.
|
|
759
|
+
tracker.flush();
|
|
760
|
+
tracker.finalize(exitCode);
|
|
761
|
+
|
|
441
762
|
const completedAt = new Date().toISOString();
|
|
442
763
|
const durationMs =
|
|
443
764
|
new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
|
@@ -454,6 +775,11 @@ export async function streamGhaCommand(
|
|
|
454
775
|
durationMs,
|
|
455
776
|
exitCode,
|
|
456
777
|
...(errorMessage ? { error: errorMessage } : {}),
|
|
778
|
+
jobs: tracker.snapshot(),
|
|
779
|
+
...(parsed.event ? { event: parsed.event } : {}),
|
|
780
|
+
...(workspace.defaultWorkflow
|
|
781
|
+
? { workflow: workspace.defaultWorkflow }
|
|
782
|
+
: {}),
|
|
457
783
|
};
|
|
458
784
|
|
|
459
785
|
await fs.writeFile(
|