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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.17.3",
3
+ "version": "0.19.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@ import DocRefModal from "./components/DocRefModal";
9
9
  import AiSettingsModal from "./components/AiSettingsModal";
10
10
  import CodeRunnerModal from "./components/CodeRunnerModal";
11
11
  import InfraLabModal from "./components/InfraLabModal";
12
+ import GithubActionsLabModal from "./components/GithubActionsLabModal";
12
13
  import DeploymentLabModal from "./components/DeploymentLabModal";
13
14
  import CanvasLabModal from "./components/CanvasLabModal";
14
15
  import {
@@ -44,6 +45,7 @@ export default function App() {
44
45
  closeSettings,
45
46
  showCodeRunner,
46
47
  showInfraLab,
48
+ showGhaLab,
47
49
  showDeploymentLab,
48
50
  showCanvasLab,
49
51
  closeCodeRunner,
@@ -191,6 +193,7 @@ export default function App() {
191
193
  {showSettings && <AiSettingsModal />}
192
194
  {showCodeRunner && <CodeRunnerModal />}
193
195
  {showInfraLab && <InfraLabModal />}
196
+ {showGhaLab && <GithubActionsLabModal />}
194
197
  {showDeploymentLab && <DeploymentLabModal />}
195
198
  {showCanvasLab && <CanvasLabModal />}
196
199
  </div>
@@ -5,6 +5,7 @@ import type {
5
5
  ContextFileOrigin,
6
6
  WorkspacesRegistry,
7
7
  InfraLabWorkspace,
8
+ GithubActionsLabWorkspace,
8
9
  } from "./types";
9
10
 
10
11
  const BASE = "/api";
@@ -79,8 +80,8 @@ export interface InfraRunListItem {
79
80
  startedAt: string;
80
81
  completedAt: string;
81
82
  durationMs: number;
82
- provider: "aws";
83
- executionMode: "plan-only" | "localstack";
83
+ provider: "aws" | "docker";
84
+ executionMode: "plan-only" | "localstack" | "docker";
84
85
  diagnostics: InfraDiagnostic[];
85
86
  planSummary?: InfraPlanSummary;
86
87
  error?: string;
@@ -243,6 +244,19 @@ export async function updateQuestion(
243
244
  return res.json();
244
245
  }
245
246
 
247
+ export async function copyQuestion(
248
+ id: string,
249
+ data: { parentQuestionId?: string | null; targetTopicId?: string } = {},
250
+ ): Promise<Question[]> {
251
+ const res = await fetch(`${BASE}/questions/${id}/copy`, {
252
+ method: "POST",
253
+ headers: { "Content-Type": "application/json" },
254
+ body: JSON.stringify(data),
255
+ });
256
+ if (!res.ok) throw new Error(await res.text());
257
+ return res.json();
258
+ }
259
+
246
260
  export async function deleteQuestion(id: string): Promise<void> {
247
261
  await fetch(`${BASE}/questions/${id}`, { method: "DELETE" });
248
262
  }
@@ -470,6 +484,130 @@ export async function streamInfraCommand(
470
484
  }
471
485
  }
472
486
 
487
+ // ─── GitHub Actions Lab ──────────────────────────────────────────────────
488
+
489
+ export type GhaJobStatus =
490
+ | "pending"
491
+ | "running"
492
+ | "success"
493
+ | "failed"
494
+ | "skipped";
495
+
496
+ export interface GhaStepSnapshot {
497
+ name: string;
498
+ phase: "Main" | "Pre" | "Post";
499
+ status: GhaJobStatus;
500
+ startedAt?: string;
501
+ endedAt?: string;
502
+ durationMs?: number;
503
+ log?: string;
504
+ }
505
+
506
+ export interface GhaJobSnapshot {
507
+ name: string;
508
+ workflow?: string;
509
+ status: GhaJobStatus;
510
+ startedAt?: string;
511
+ endedAt?: string;
512
+ durationMs?: number;
513
+ steps?: GhaStepSnapshot[];
514
+ }
515
+
516
+ export interface GhaRunMetadata {
517
+ id: string;
518
+ fileId?: string;
519
+ questionId?: string;
520
+ label: string;
521
+ command: string;
522
+ status: "completed" | "failed";
523
+ startedAt: string;
524
+ completedAt: string;
525
+ durationMs: number;
526
+ exitCode: number;
527
+ error?: string;
528
+ jobs?: GhaJobSnapshot[];
529
+ event?: string;
530
+ workflow?: string;
531
+ }
532
+
533
+ export interface GhaRunDetails extends GhaRunMetadata {
534
+ log: string;
535
+ }
536
+
537
+ export type GhaStreamMessage =
538
+ | { type: "output"; kind: "stdout" | "stderr" | "info"; text: string }
539
+ | { type: "job"; job: GhaJobSnapshot }
540
+ | { type: "complete"; runId: string; exitCode: number; durationMs: number }
541
+ | { type: "error"; error: string };
542
+
543
+ export async function streamGhaCommand(
544
+ input: {
545
+ questionId?: string;
546
+ fileId?: string;
547
+ label?: string;
548
+ command: string;
549
+ workspace: GithubActionsLabWorkspace;
550
+ },
551
+ onMessage: (message: GhaStreamMessage) => void,
552
+ ): Promise<void> {
553
+ const res = await fetch(`${BASE}/gha/run-stream`, {
554
+ method: "POST",
555
+ headers: { "Content-Type": "application/json" },
556
+ body: JSON.stringify(input),
557
+ });
558
+ if (!res.ok || !res.body) {
559
+ const err = await res.text().catch(() => "act run failed");
560
+ throw new Error(err || "act run failed");
561
+ }
562
+ const reader = res.body.getReader();
563
+ const decoder = new TextDecoder();
564
+ let buffer = "";
565
+
566
+ const flushBuffer = () => {
567
+ const events = buffer.replace(/\r/g, "").split("\n\n");
568
+ buffer = events.pop() ?? "";
569
+ for (const event of events) {
570
+ if (!event.trim()) continue;
571
+ const payload = event
572
+ .split("\n")
573
+ .filter((line) => line.startsWith("data:"))
574
+ .map((line) => line.slice(5).trimStart())
575
+ .join("\n");
576
+ if (!payload) continue;
577
+ onMessage(JSON.parse(payload) as GhaStreamMessage);
578
+ }
579
+ };
580
+
581
+ while (true) {
582
+ const { value, done } = await reader.read();
583
+ buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
584
+ flushBuffer();
585
+ if (done) break;
586
+ }
587
+ if (buffer.trim()) flushBuffer();
588
+ }
589
+
590
+ export async function listGhaRuns(params: {
591
+ questionId?: string;
592
+ fileId?: string;
593
+ limit?: number;
594
+ }): Promise<GhaRunMetadata[]> {
595
+ const search = new URLSearchParams();
596
+ if (params.questionId) search.set("questionId", params.questionId);
597
+ if (params.fileId) search.set("fileId", params.fileId);
598
+ if (params.limit) search.set("limit", String(params.limit));
599
+ const qs = search.toString();
600
+ const res = await fetch(`${BASE}/gha/runs${qs ? `?${qs}` : ""}`);
601
+ if (!res.ok) throw new Error(await res.text());
602
+ return res.json();
603
+ }
604
+
605
+ export async function fetchGhaRun(runId: string): Promise<GhaRunDetails> {
606
+ const res = await fetch(`${BASE}/gha/runs/${encodeURIComponent(runId)}`);
607
+ if (!res.ok) throw new Error(await res.text());
608
+ return res.json();
609
+ }
610
+
473
611
  export async function fetchInfraRun(runId: string): Promise<InfraRunDetails> {
474
612
  const res = await fetch(`${BASE}/infra/runs/${encodeURIComponent(runId)}`);
475
613
  if (!res.ok) throw new Error(await res.text());
@@ -664,16 +802,34 @@ export async function activateWorkspaceApi(
664
802
  return res.json();
665
803
  }
666
804
 
667
- export async function syncWorkspaceApi(id: string): Promise<{
668
- topicsUpserted: number;
669
- filesImported: number;
670
- filesSkipped: number;
671
- errors: string[];
672
- }> {
805
+ export type SyncWorkspaceResult =
806
+ | { needsAuth: true; authUrl: string }
807
+ | {
808
+ needsAuth?: false;
809
+ topicsUpserted: number;
810
+ filesImported: number;
811
+ filesSkipped: number;
812
+ errors: string[];
813
+ };
814
+
815
+ export async function syncWorkspaceApi(
816
+ id: string,
817
+ ): Promise<SyncWorkspaceResult> {
673
818
  const res = await fetch(`${BASE}/workspaces/${id}/sync`, { method: "POST" });
674
819
  return res.json();
675
820
  }
676
821
 
822
+ export async function syncTopicApi(
823
+ workspaceId: string,
824
+ topicId: string,
825
+ ): Promise<SyncWorkspaceResult> {
826
+ const res = await fetch(
827
+ `${BASE}/workspaces/${workspaceId}/topics/${topicId}/sync`,
828
+ { method: "POST" },
829
+ );
830
+ return res.json();
831
+ }
832
+
677
833
  export type ExportWorkspaceResult =
678
834
  | { needsAuth: true; authUrl: string }
679
835
  | {
@@ -700,6 +856,26 @@ export async function exportWorkspaceToDrive(
700
856
  return res.json();
701
857
  }
702
858
 
859
+ export async function exportTopicToDrive(
860
+ workspaceId: string,
861
+ topicId: string,
862
+ targetFolderId?: string,
863
+ ): Promise<ExportWorkspaceResult> {
864
+ const res = await fetch(
865
+ `${BASE}/workspaces/${workspaceId}/topics/${topicId}/export-drive`,
866
+ {
867
+ method: "POST",
868
+ headers: { "Content-Type": "application/json" },
869
+ body: JSON.stringify({ targetFolderId }),
870
+ },
871
+ );
872
+ if (!res.ok) {
873
+ const body = await res.json().catch(() => ({}));
874
+ throw new Error((body as any).error || `Export failed (${res.status})`);
875
+ }
876
+ return res.json();
877
+ }
878
+
703
879
  export interface DriveFolder {
704
880
  id: string;
705
881
  name: string;
@@ -0,0 +1,194 @@
1
+ import { useEffect, useState } from "react";
2
+ import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
3
+ import * as api from "../api";
4
+ import type { GhaRunMetadata, GhaRunDetails } from "../api";
5
+ import GhaJobsPanel from "./GhaJobsPanel";
6
+
7
+ // ─── Helpers ─────────────────────────────────────────────────────────────
8
+
9
+ function formatRelative(iso: string): string {
10
+ const ms = Date.now() - new Date(iso).getTime();
11
+ if (ms < 60_000) return "just now";
12
+ const m = Math.floor(ms / 60_000);
13
+ if (m < 60) return `${m}m ago`;
14
+ const h = Math.floor(m / 60);
15
+ if (h < 24) return `${h}h ago`;
16
+ const d = Math.floor(h / 24);
17
+ return `${d}d ago`;
18
+ }
19
+
20
+ function formatDuration(ms: number): string {
21
+ if (ms < 1000) return `${ms}ms`;
22
+ const s = ms / 1000;
23
+ if (s < 60) return `${s.toFixed(1)}s`;
24
+ return `${Math.round(s)}s`;
25
+ }
26
+
27
+ function statusBadge(status: string) {
28
+ const map: Record<string, string> = {
29
+ completed: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
30
+ failed: "bg-red-500/15 text-red-300 border-red-500/30",
31
+ };
32
+ return map[status] ?? "bg-slate-500/15 text-slate-300 border-slate-500/30";
33
+ }
34
+
35
+ // ─── Component ───────────────────────────────────────────────────────────
36
+
37
+ export interface GhaHistoryPanelProps {
38
+ questionId?: string;
39
+ fileId?: string;
40
+ // Controlled flag — when true, the user has opted to include recent run
41
+ // history in the saved lab snapshot so chat LLM context picks it up.
42
+ includeInContext: boolean;
43
+ onToggleIncludeInContext: (next: boolean) => void;
44
+ // Bump this to force a refetch (e.g. after a run finishes).
45
+ refreshNonce: number;
46
+ }
47
+
48
+ export default function GhaHistoryPanel({
49
+ questionId,
50
+ fileId,
51
+ includeInContext,
52
+ onToggleIncludeInContext,
53
+ refreshNonce,
54
+ }: GhaHistoryPanelProps) {
55
+ const [runs, setRuns] = useState<GhaRunMetadata[]>([]);
56
+ const [loading, setLoading] = useState(false);
57
+ const [error, setError] = useState<string | null>(null);
58
+ const [expandedId, setExpandedId] = useState<string | null>(null);
59
+ const [details, setDetails] = useState<Record<string, GhaRunDetails>>({});
60
+
61
+ const load = async () => {
62
+ setLoading(true);
63
+ setError(null);
64
+ try {
65
+ const list = await api.listGhaRuns({ questionId, fileId, limit: 25 });
66
+ setRuns(list);
67
+ } catch (err: any) {
68
+ setError(err?.message || "Failed to load runs");
69
+ } finally {
70
+ setLoading(false);
71
+ }
72
+ };
73
+
74
+ useEffect(() => {
75
+ void load();
76
+ // load when filters or refresh nonce change
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, [questionId, fileId, refreshNonce]);
79
+
80
+ const toggleExpanded = async (runId: string) => {
81
+ if (expandedId === runId) {
82
+ setExpandedId(null);
83
+ return;
84
+ }
85
+ setExpandedId(runId);
86
+ if (!details[runId]) {
87
+ try {
88
+ const full = await api.fetchGhaRun(runId);
89
+ setDetails((prev) => ({ ...prev, [runId]: full }));
90
+ } catch {
91
+ // surface inline; the row already shows summary info
92
+ }
93
+ }
94
+ };
95
+
96
+ return (
97
+ <div className="flex-1 min-h-0 flex flex-col">
98
+ <div className="flex items-center justify-between border-b border-slate-800/60 px-3 py-2 gap-3">
99
+ <label className="flex items-center gap-2 text-[11px] text-slate-300 cursor-pointer select-none">
100
+ <input
101
+ type="checkbox"
102
+ checked={includeInContext}
103
+ onChange={(e) => onToggleIncludeInContext(e.target.checked)}
104
+ className="accent-amber-400"
105
+ />
106
+ Include recent runs in LLM context when saving
107
+ </label>
108
+ <button
109
+ onClick={() => void load()}
110
+ className="p-1 rounded text-slate-400 hover:text-slate-100 hover:bg-slate-800/60"
111
+ title="Refresh"
112
+ >
113
+ <RefreshCw
114
+ className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`}
115
+ />
116
+ </button>
117
+ </div>
118
+
119
+ <div className="flex-1 min-h-0 overflow-auto">
120
+ {error && (
121
+ <div className="m-3 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-[11px] text-red-200">
122
+ {error}
123
+ </div>
124
+ )}
125
+ {!loading && runs.length === 0 && !error && (
126
+ <div className="px-3 py-6 text-center text-xs text-slate-500">
127
+ No previous runs yet. Click Run to record the first one.
128
+ </div>
129
+ )}
130
+ <ul className="divide-y divide-slate-800/60">
131
+ {runs.map((run) => {
132
+ const expanded = expandedId === run.id;
133
+ const detail = details[run.id];
134
+ return (
135
+ <li key={run.id} className="text-[11px]">
136
+ <button
137
+ onClick={() => void toggleExpanded(run.id)}
138
+ className="w-full flex items-center gap-2 px-3 py-2 hover:bg-slate-800/40 text-left"
139
+ >
140
+ {expanded ? (
141
+ <ChevronDown className="w-3 h-3 text-slate-500" />
142
+ ) : (
143
+ <ChevronRight className="w-3 h-3 text-slate-500" />
144
+ )}
145
+ <span
146
+ className={`text-[10px] px-1.5 py-0.5 rounded border ${statusBadge(
147
+ run.status,
148
+ )}`}
149
+ >
150
+ {run.status === "completed" ? "OK" : "FAIL"}
151
+ </span>
152
+ <span className="font-mono text-slate-300 truncate flex-1">
153
+ {run.command}
154
+ </span>
155
+ <span className="text-slate-500">
156
+ {formatDuration(run.durationMs)}
157
+ </span>
158
+ <span className="text-slate-500 w-16 text-right">
159
+ {formatRelative(run.startedAt)}
160
+ </span>
161
+ </button>
162
+ {expanded && (
163
+ <div className="border-t border-slate-800/40 bg-slate-950/50">
164
+ <div className="px-3 py-2">
165
+ {/* Render as a flat list — the current workflow YAML
166
+ may have diverged since this run, so showing the
167
+ DAG would be misleading. The list mirrors what
168
+ actually executed. */}
169
+ <GhaJobsPanel
170
+ mode="list"
171
+ jobs={run.jobs ?? []}
172
+ caption={`Run ${run.id.slice(0, 8)} • exit=${run.exitCode}`}
173
+ />
174
+ </div>
175
+ {detail?.log && (
176
+ <details className="px-3 pb-3">
177
+ <summary className="cursor-pointer text-[10px] text-slate-500 hover:text-slate-300">
178
+ Show raw log ({detail.log.length} chars)
179
+ </summary>
180
+ <pre className="mt-2 max-h-72 overflow-auto rounded bg-black/40 p-2 text-[10px] text-slate-300 whitespace-pre-wrap">
181
+ {detail.log}
182
+ </pre>
183
+ </details>
184
+ )}
185
+ </div>
186
+ )}
187
+ </li>
188
+ );
189
+ })}
190
+ </ul>
191
+ </div>
192
+ </div>
193
+ );
194
+ }