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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -486,8 +486,57 @@ export async function streamInfraCommand(
486
486
 
487
487
  // ─── GitHub Actions Lab ──────────────────────────────────────────────────
488
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
+
489
537
  export type GhaStreamMessage =
490
538
  | { type: "output"; kind: "stdout" | "stderr" | "info"; text: string }
539
+ | { type: "job"; job: GhaJobSnapshot }
491
540
  | { type: "complete"; runId: string; exitCode: number; durationMs: number }
492
541
  | { type: "error"; error: string };
493
542
 
@@ -538,6 +587,27 @@ export async function streamGhaCommand(
538
587
  if (buffer.trim()) flushBuffer();
539
588
  }
540
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
+
541
611
  export async function fetchInfraRun(runId: string): Promise<InfraRunDetails> {
542
612
  const res = await fetch(`${BASE}/infra/runs/${encodeURIComponent(runId)}`);
543
613
  if (!res.ok) throw new Error(await res.text());
@@ -749,6 +819,17 @@ export async function syncWorkspaceApi(
749
819
  return res.json();
750
820
  }
751
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
+
752
833
  export type ExportWorkspaceResult =
753
834
  | { needsAuth: true; authUrl: string }
754
835
  | {
@@ -775,6 +856,26 @@ export async function exportWorkspaceToDrive(
775
856
  return res.json();
776
857
  }
777
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
+
778
879
  export interface DriveFolder {
779
880
  id: string;
780
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
+ }