create-interview-cockpit 0.17.3 → 0.18.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 +83 -8
- package/template/client/src/components/GithubActionsLabModal.tsx +746 -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 +400 -14
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +287 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +83 -10
- package/template/client/src/types.ts +27 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +468 -0
- package/template/server/src/google-drive.ts +35 -24
- package/template/server/src/index.ts +241 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +3 -1
|
@@ -0,0 +1,468 @@
|
|
|
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
|
+
export type GhaStreamMessage =
|
|
21
|
+
| { type: "output"; kind: OutputKind; text: string }
|
|
22
|
+
| { type: "complete"; runId: string; exitCode: number; durationMs: number }
|
|
23
|
+
| { type: "error"; error: string };
|
|
24
|
+
|
|
25
|
+
// ─── Constants & guards ──────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const MAX_FILE_COUNT = 60;
|
|
28
|
+
const MAX_TOTAL_SOURCE_BYTES = 1_000_000;
|
|
29
|
+
const MAX_LOG_CHARS = 400_000;
|
|
30
|
+
const SOURCE_MANIFEST = ".gha-source-files.json";
|
|
31
|
+
|
|
32
|
+
// `act` accepts these events; we restrict to common ones so the console
|
|
33
|
+
// can't be used to run arbitrary subcommands.
|
|
34
|
+
const ALLOWED_EVENTS = new Set([
|
|
35
|
+
"push",
|
|
36
|
+
"pull_request",
|
|
37
|
+
"workflow_dispatch",
|
|
38
|
+
"release",
|
|
39
|
+
"schedule",
|
|
40
|
+
"issues",
|
|
41
|
+
"issue_comment",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// Only these top-level `act` flags are honored from console input.
|
|
45
|
+
// Long-form is preferred to keep parsing simple.
|
|
46
|
+
const ALLOWED_ACT_FLAGS = new Set([
|
|
47
|
+
"-l",
|
|
48
|
+
"--list",
|
|
49
|
+
"-n",
|
|
50
|
+
"--dryrun",
|
|
51
|
+
"-W",
|
|
52
|
+
"--workflows",
|
|
53
|
+
"-j",
|
|
54
|
+
"--job",
|
|
55
|
+
"-e",
|
|
56
|
+
"--eventpath",
|
|
57
|
+
"--container-architecture",
|
|
58
|
+
"-P",
|
|
59
|
+
"--platform",
|
|
60
|
+
"--rm",
|
|
61
|
+
"--verbose",
|
|
62
|
+
"-v",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// ─── Utilities ───────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function getGhaRunsDir(): string {
|
|
68
|
+
return path.resolve(storage.getContextFilesDir(), "..", "gha-runs");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getGhaSessionsDir(): string {
|
|
72
|
+
return path.resolve(storage.getContextFilesDir(), "..", "gha-sessions");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stripAnsi(text: string): string {
|
|
76
|
+
return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function appendLog(buffer: string, chunk: string): string {
|
|
80
|
+
if (!chunk || buffer.length >= MAX_LOG_CHARS) return buffer;
|
|
81
|
+
const remaining = MAX_LOG_CHARS - buffer.length;
|
|
82
|
+
if (chunk.length <= remaining) return buffer + chunk;
|
|
83
|
+
return `${buffer}${chunk.slice(0, remaining)}\n[log truncated]\n`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sanitizeKey(value: string): string {
|
|
87
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 120) || "session";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertSafeRelativePath(filePath: string, label: string): void {
|
|
91
|
+
if (!filePath || path.isAbsolute(filePath) || filePath.includes("..")) {
|
|
92
|
+
throw new Error(`${label} must stay within the workspace`);
|
|
93
|
+
}
|
|
94
|
+
// Reject any path that escapes via symlink-style tricks
|
|
95
|
+
if (filePath.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(filePath)) {
|
|
96
|
+
throw new Error(`${label} must be a relative path`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
|
|
101
|
+
if (!input || typeof input !== "object") {
|
|
102
|
+
throw new Error("workspace payload is required");
|
|
103
|
+
}
|
|
104
|
+
const candidate = input as Partial<GithubActionsLabWorkspace> & {
|
|
105
|
+
files?: Record<string, unknown>;
|
|
106
|
+
};
|
|
107
|
+
if (!candidate.files || typeof candidate.files !== "object") {
|
|
108
|
+
throw new Error("workspace.files must be provided");
|
|
109
|
+
}
|
|
110
|
+
const files = Object.fromEntries(
|
|
111
|
+
Object.entries(candidate.files)
|
|
112
|
+
.filter(
|
|
113
|
+
(entry): entry is [string, string] =>
|
|
114
|
+
typeof entry[0] === "string" && typeof entry[1] === "string",
|
|
115
|
+
)
|
|
116
|
+
.map(([name, content]) => [name.trim(), content]),
|
|
117
|
+
);
|
|
118
|
+
const fileNames = Object.keys(files).filter(Boolean);
|
|
119
|
+
if (fileNames.length === 0) {
|
|
120
|
+
throw new Error("workspace must contain at least one file");
|
|
121
|
+
}
|
|
122
|
+
if (fileNames.length > MAX_FILE_COUNT) {
|
|
123
|
+
throw new Error(`workspace exceeds the ${MAX_FILE_COUNT} file limit`);
|
|
124
|
+
}
|
|
125
|
+
let totalBytes = 0;
|
|
126
|
+
for (const name of fileNames) {
|
|
127
|
+
assertSafeRelativePath(name, "Workspace file path");
|
|
128
|
+
totalBytes += Buffer.byteLength(files[name], "utf8");
|
|
129
|
+
}
|
|
130
|
+
if (totalBytes > MAX_TOTAL_SOURCE_BYTES) {
|
|
131
|
+
throw new Error("workspace source exceeds the allowed size limit");
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
version: 1,
|
|
135
|
+
label:
|
|
136
|
+
typeof candidate.label === "string" && candidate.label.trim()
|
|
137
|
+
? candidate.label.trim()
|
|
138
|
+
: "GitHub Actions Lab",
|
|
139
|
+
activeFile:
|
|
140
|
+
typeof candidate.activeFile === "string" && files[candidate.activeFile]
|
|
141
|
+
? candidate.activeFile
|
|
142
|
+
: fileNames[0],
|
|
143
|
+
defaultEvent:
|
|
144
|
+
typeof candidate.defaultEvent === "string"
|
|
145
|
+
? candidate.defaultEvent
|
|
146
|
+
: undefined,
|
|
147
|
+
defaultWorkflow:
|
|
148
|
+
typeof candidate.defaultWorkflow === "string"
|
|
149
|
+
? candidate.defaultWorkflow
|
|
150
|
+
: undefined,
|
|
151
|
+
files,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── File materialization ───────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
async function writeWorkspaceFiles(
|
|
158
|
+
workspaceDir: string,
|
|
159
|
+
workspace: GithubActionsLabWorkspace,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
await fs.mkdir(workspaceDir, { recursive: true });
|
|
162
|
+
await Promise.all(
|
|
163
|
+
Object.entries(workspace.files).map(async ([name, content]) => {
|
|
164
|
+
const target = path.join(workspaceDir, name);
|
|
165
|
+
const targetDir = path.dirname(target);
|
|
166
|
+
const rel = path.relative(workspaceDir, target);
|
|
167
|
+
// Defense in depth: make sure the resolved target stays under workspaceDir.
|
|
168
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
169
|
+
throw new Error(`Refusing to write file outside workspace: ${name}`);
|
|
170
|
+
}
|
|
171
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
172
|
+
await fs.writeFile(target, content, "utf8");
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function readJsonFile(filePath: string): Promise<unknown | undefined> {
|
|
178
|
+
try {
|
|
179
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
180
|
+
return JSON.parse(raw);
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function syncWorkspaceToSession(
|
|
187
|
+
sessionKey: string,
|
|
188
|
+
workspace: GithubActionsLabWorkspace,
|
|
189
|
+
): Promise<string> {
|
|
190
|
+
const sessionDir = path.join(getGhaSessionsDir(), sanitizeKey(sessionKey));
|
|
191
|
+
const workspaceDir = path.join(sessionDir, "workspace");
|
|
192
|
+
const manifestPath = path.join(sessionDir, SOURCE_MANIFEST);
|
|
193
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
194
|
+
|
|
195
|
+
const previous = await readJsonFile(manifestPath);
|
|
196
|
+
const previousFiles = Array.isArray(previous)
|
|
197
|
+
? previous.filter((item): item is string => typeof item === "string")
|
|
198
|
+
: [];
|
|
199
|
+
const nextFiles = Object.keys(workspace.files);
|
|
200
|
+
|
|
201
|
+
await Promise.all(
|
|
202
|
+
previousFiles
|
|
203
|
+
.filter((fileName) => !nextFiles.includes(fileName))
|
|
204
|
+
.map(async (fileName) => {
|
|
205
|
+
try {
|
|
206
|
+
await fs.unlink(path.join(workspaceDir, fileName));
|
|
207
|
+
} catch {
|
|
208
|
+
// ignore
|
|
209
|
+
}
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
await writeWorkspaceFiles(workspaceDir, workspace);
|
|
213
|
+
await fs.writeFile(manifestPath, JSON.stringify(nextFiles, null, 2), "utf8");
|
|
214
|
+
return workspaceDir;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Command parsing ────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
function splitCommand(command: string): string[] {
|
|
220
|
+
const tokens: string[] = [];
|
|
221
|
+
let current = "";
|
|
222
|
+
let quote: '"' | "'" | null = null;
|
|
223
|
+
let escape = false;
|
|
224
|
+
for (const char of command.trim()) {
|
|
225
|
+
if (escape) {
|
|
226
|
+
current += char;
|
|
227
|
+
escape = false;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (char === "\\") {
|
|
231
|
+
escape = true;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (quote) {
|
|
235
|
+
if (char === quote) quote = null;
|
|
236
|
+
else current += char;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (char === '"' || char === "'") {
|
|
240
|
+
quote = char;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (/\s/.test(char)) {
|
|
244
|
+
if (current) {
|
|
245
|
+
tokens.push(current);
|
|
246
|
+
current = "";
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
current += char;
|
|
251
|
+
}
|
|
252
|
+
if (quote) throw new Error("Command has an unclosed quote");
|
|
253
|
+
if (current) tokens.push(current);
|
|
254
|
+
return tokens;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface ParsedActCommand {
|
|
258
|
+
event: string | null; // null when -l is used (just listing)
|
|
259
|
+
args: string[];
|
|
260
|
+
displayCommand: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseActCommand(command: string): ParsedActCommand {
|
|
264
|
+
const tokens = splitCommand(command);
|
|
265
|
+
if (tokens.length === 0) throw new Error("Type a command to run");
|
|
266
|
+
if (tokens[0] !== "act") {
|
|
267
|
+
throw new Error(
|
|
268
|
+
"Only 'act' commands are supported in this lab. Try: act -l, act push, act workflow_dispatch -j greet, act -n",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const rest = tokens.slice(1);
|
|
273
|
+
let event: string | null = null;
|
|
274
|
+
const args: string[] = [];
|
|
275
|
+
let i = 0;
|
|
276
|
+
|
|
277
|
+
// First non-flag token is treated as the event name (matching real act CLI).
|
|
278
|
+
while (i < rest.length) {
|
|
279
|
+
const tok = rest[i];
|
|
280
|
+
if (!tok.startsWith("-")) {
|
|
281
|
+
if (event !== null) {
|
|
282
|
+
throw new Error(`Unexpected positional argument: ${tok}`);
|
|
283
|
+
}
|
|
284
|
+
if (!ALLOWED_EVENTS.has(tok)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Event '${tok}' is not allowed. Allowed: ${Array.from(ALLOWED_EVENTS).join(", ")}`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
event = tok;
|
|
290
|
+
args.push(tok);
|
|
291
|
+
i += 1;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Flag handling — only the allow-listed flags are accepted.
|
|
296
|
+
// Support `--flag=value`, `--flag value`, and short flags.
|
|
297
|
+
const eq = tok.indexOf("=");
|
|
298
|
+
const flagName = eq === -1 ? tok : tok.slice(0, eq);
|
|
299
|
+
if (!ALLOWED_ACT_FLAGS.has(flagName)) {
|
|
300
|
+
throw new Error(`Flag '${flagName}' is not allowed in the act console`);
|
|
301
|
+
}
|
|
302
|
+
args.push(tok);
|
|
303
|
+
|
|
304
|
+
// Flags that take a value
|
|
305
|
+
const takesValue = new Set([
|
|
306
|
+
"-W",
|
|
307
|
+
"--workflows",
|
|
308
|
+
"-j",
|
|
309
|
+
"--job",
|
|
310
|
+
"-e",
|
|
311
|
+
"--eventpath",
|
|
312
|
+
"--container-architecture",
|
|
313
|
+
"-P",
|
|
314
|
+
"--platform",
|
|
315
|
+
]);
|
|
316
|
+
if (takesValue.has(flagName) && eq === -1) {
|
|
317
|
+
const next = rest[i + 1];
|
|
318
|
+
if (!next || next.startsWith("-")) {
|
|
319
|
+
throw new Error(`Flag '${flagName}' requires a value`);
|
|
320
|
+
}
|
|
321
|
+
// For workflow/eventpath, keep it inside workspace
|
|
322
|
+
if (flagName === "-W" || flagName === "--workflows") {
|
|
323
|
+
assertSafeRelativePath(next, "workflow path");
|
|
324
|
+
}
|
|
325
|
+
if (flagName === "-e" || flagName === "--eventpath") {
|
|
326
|
+
assertSafeRelativePath(next, "event path");
|
|
327
|
+
}
|
|
328
|
+
args.push(next);
|
|
329
|
+
i += 2;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
i += 1;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
event,
|
|
337
|
+
args,
|
|
338
|
+
displayCommand: `$ act ${args.join(" ")}\n`.replace(/\s+\n$/, "\n"),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Run ─────────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
interface RunInput {
|
|
345
|
+
questionId?: string;
|
|
346
|
+
fileId?: string;
|
|
347
|
+
label?: string;
|
|
348
|
+
command: string;
|
|
349
|
+
workspace: unknown;
|
|
350
|
+
onMessage?: (message: GhaStreamMessage) => void;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface GhaRunMetadata {
|
|
354
|
+
id: string;
|
|
355
|
+
fileId?: string;
|
|
356
|
+
questionId?: string;
|
|
357
|
+
label: string;
|
|
358
|
+
command: string;
|
|
359
|
+
status: "completed" | "failed";
|
|
360
|
+
startedAt: string;
|
|
361
|
+
completedAt: string;
|
|
362
|
+
durationMs: number;
|
|
363
|
+
exitCode: number;
|
|
364
|
+
error?: string;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function streamGhaCommand(
|
|
368
|
+
input: RunInput,
|
|
369
|
+
): Promise<GhaRunMetadata> {
|
|
370
|
+
const workspace = parseWorkspace(input.workspace);
|
|
371
|
+
|
|
372
|
+
const parsed = parseActCommand(input.command);
|
|
373
|
+
const sessionKey =
|
|
374
|
+
input.fileId ?? input.questionId ?? `draft-${randomUUID()}`;
|
|
375
|
+
const runId = randomUUID();
|
|
376
|
+
const startedAt = new Date().toISOString();
|
|
377
|
+
const runDir = path.join(getGhaRunsDir(), runId);
|
|
378
|
+
const workspaceDir = await syncWorkspaceToSession(sessionKey, workspace);
|
|
379
|
+
await fs.mkdir(runDir, { recursive: true });
|
|
380
|
+
|
|
381
|
+
let logs = "";
|
|
382
|
+
let status: "completed" | "failed" = "completed";
|
|
383
|
+
let errorMessage: string | undefined;
|
|
384
|
+
let exitCode = 0;
|
|
385
|
+
|
|
386
|
+
const emit = (msg: GhaStreamMessage) => input.onMessage?.(msg);
|
|
387
|
+
emit({ type: "output", kind: "info", text: parsed.displayCommand });
|
|
388
|
+
|
|
389
|
+
const child = spawn("act", parsed.args, {
|
|
390
|
+
cwd: workspaceDir,
|
|
391
|
+
env: {
|
|
392
|
+
...process.env,
|
|
393
|
+
// Avoid TTY-only progress spinners — they spam control codes through SSE
|
|
394
|
+
ACT_LOG_LEVEL: process.env.ACT_LOG_LEVEL || "info",
|
|
395
|
+
// Disable pager-like output
|
|
396
|
+
NO_COLOR: "1",
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await new Promise<void>((resolve) => {
|
|
401
|
+
let settled = false;
|
|
402
|
+
const finish = () => {
|
|
403
|
+
if (settled) return;
|
|
404
|
+
settled = true;
|
|
405
|
+
resolve();
|
|
406
|
+
};
|
|
407
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
408
|
+
const text = stripAnsi(chunk.toString());
|
|
409
|
+
logs = appendLog(logs, text);
|
|
410
|
+
emit({ type: "output", kind: "stdout", text });
|
|
411
|
+
});
|
|
412
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
413
|
+
const text = stripAnsi(chunk.toString());
|
|
414
|
+
logs = appendLog(logs, text);
|
|
415
|
+
emit({ type: "output", kind: "stderr", text });
|
|
416
|
+
});
|
|
417
|
+
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
418
|
+
status = "failed";
|
|
419
|
+
exitCode = 1;
|
|
420
|
+
if (err.code === "ENOENT") {
|
|
421
|
+
errorMessage =
|
|
422
|
+
"`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.";
|
|
423
|
+
} else {
|
|
424
|
+
errorMessage = err.message;
|
|
425
|
+
}
|
|
426
|
+
const text = `\n[error] ${errorMessage}\n`;
|
|
427
|
+
logs = appendLog(logs, text);
|
|
428
|
+
emit({ type: "output", kind: "stderr", text });
|
|
429
|
+
finish();
|
|
430
|
+
});
|
|
431
|
+
child.on("close", (code) => {
|
|
432
|
+
exitCode = typeof code === "number" ? code : 1;
|
|
433
|
+
if (exitCode !== 0) {
|
|
434
|
+
status = "failed";
|
|
435
|
+
errorMessage = errorMessage || `act exited with code ${exitCode}`;
|
|
436
|
+
}
|
|
437
|
+
finish();
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const completedAt = new Date().toISOString();
|
|
442
|
+
const durationMs =
|
|
443
|
+
new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
|
444
|
+
|
|
445
|
+
const metadata: GhaRunMetadata = {
|
|
446
|
+
id: runId,
|
|
447
|
+
...(input.fileId ? { fileId: input.fileId } : {}),
|
|
448
|
+
...(input.questionId ? { questionId: input.questionId } : {}),
|
|
449
|
+
label: input.label?.trim() || workspace.label,
|
|
450
|
+
command: parsed.displayCommand.replace(/^\$\s*/, "").trim(),
|
|
451
|
+
status,
|
|
452
|
+
startedAt,
|
|
453
|
+
completedAt,
|
|
454
|
+
durationMs,
|
|
455
|
+
exitCode,
|
|
456
|
+
...(errorMessage ? { error: errorMessage } : {}),
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
await fs.writeFile(
|
|
460
|
+
path.join(runDir, "metadata.json"),
|
|
461
|
+
JSON.stringify(metadata, null, 2),
|
|
462
|
+
"utf8",
|
|
463
|
+
);
|
|
464
|
+
await fs.writeFile(path.join(runDir, "run.log"), logs, "utf8");
|
|
465
|
+
|
|
466
|
+
emit({ type: "complete", runId, exitCode, durationMs });
|
|
467
|
+
return metadata;
|
|
468
|
+
}
|
|
@@ -261,9 +261,6 @@ export async function syncWorkspace(
|
|
|
261
261
|
errors: [],
|
|
262
262
|
};
|
|
263
263
|
|
|
264
|
-
// ── Phase 0: fast wipe (parallel deletes instead of serial deleteTopic calls) ──
|
|
265
|
-
await storage.clearWorkspaceData(workspaceId);
|
|
266
|
-
|
|
267
264
|
const importedQuestionKeys = new Set<string>();
|
|
268
265
|
|
|
269
266
|
// ── Phase 1: fetch all folder listings in parallel ──────────────────────────
|
|
@@ -301,15 +298,14 @@ export async function syncWorkspace(
|
|
|
301
298
|
: Promise.resolve([]),
|
|
302
299
|
]);
|
|
303
300
|
|
|
304
|
-
// ── Phase 2:
|
|
301
|
+
// ── Phase 2: build topic records in memory ─────────────────────────────────
|
|
305
302
|
const topicRecords: storage.Topic[] = folderData.map(({ folder }) => ({
|
|
306
303
|
id: randomUUID(),
|
|
307
304
|
name: folder.name,
|
|
308
305
|
contextFiles: [],
|
|
309
306
|
createdAt: new Date().toISOString(),
|
|
310
307
|
}));
|
|
311
|
-
|
|
312
|
-
result.topicsUpserted = topicRecords.length;
|
|
308
|
+
const extraTopicRecords: storage.Topic[] = [];
|
|
313
309
|
|
|
314
310
|
const topicIdByFolderId = new Map(
|
|
315
311
|
folderData.map(({ folder }, i) => [folder.id, topicRecords[i].id]),
|
|
@@ -394,8 +390,7 @@ export async function syncWorkspace(
|
|
|
394
390
|
contextFiles: [],
|
|
395
391
|
createdAt: new Date().toISOString(),
|
|
396
392
|
};
|
|
397
|
-
|
|
398
|
-
result.topicsUpserted++;
|
|
393
|
+
extraTopicRecords.push(generalTopic);
|
|
399
394
|
|
|
400
395
|
await Promise.all(
|
|
401
396
|
rootFiles.map(async (file) => {
|
|
@@ -420,6 +415,18 @@ export async function syncWorkspace(
|
|
|
420
415
|
);
|
|
421
416
|
}
|
|
422
417
|
|
|
418
|
+
// If downloads hit private Drive files or an old token lacks enough scope,
|
|
419
|
+
// stop before wiping the local workspace. The route will prompt re-auth.
|
|
420
|
+
if (result.errors.some((error) => /\b403\b/.test(error))) {
|
|
421
|
+
return result;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── Phase 3b: fast wipe + write all topics in one batch ────────────────────
|
|
425
|
+
await storage.clearWorkspaceData(workspaceId);
|
|
426
|
+
const allTopicRecords = [...topicRecords, ...extraTopicRecords];
|
|
427
|
+
await storage.replaceAllTopics(allTopicRecords);
|
|
428
|
+
result.topicsUpserted = allTopicRecords.length;
|
|
429
|
+
|
|
423
430
|
// ── Phase 4: save questions in parallel (each is an individual file) ─────────
|
|
424
431
|
const questions = pending.filter((p) => !p.isContextFile);
|
|
425
432
|
const contextFiles = pending.filter((p) => p.isContextFile);
|
|
@@ -563,19 +570,19 @@ export async function syncWorkspace(
|
|
|
563
570
|
|
|
564
571
|
// ── Phase 5: save workspace/topic context file blobs ───────────────────────
|
|
565
572
|
if (pendingWorkspaceFiles.length > 0) {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
573
|
+
// saveWorkspaceContextFile updates workspace-files.json; do this
|
|
574
|
+
// sequentially so concurrent reads/writes do not overwrite metadata.
|
|
575
|
+
for (const { filename, buffer } of pendingWorkspaceFiles) {
|
|
576
|
+
const fileId = randomUUID();
|
|
577
|
+
const text = await extractText(buffer, filename);
|
|
578
|
+
await storage.writeOriginalBlob(fileId, buffer, workspaceId);
|
|
579
|
+
await storage.saveWorkspaceContextFile(
|
|
580
|
+
fileId,
|
|
581
|
+
filename,
|
|
582
|
+
Buffer.from(text, "utf-8"),
|
|
583
|
+
workspaceId,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
579
586
|
result.filesImported += pendingWorkspaceFiles.length;
|
|
580
587
|
}
|
|
581
588
|
|
|
@@ -631,6 +638,10 @@ export async function listDriveSubfolders(
|
|
|
631
638
|
if (!ws?.driveConfig?.folderId) {
|
|
632
639
|
throw new Error("No Drive folder linked to this workspace");
|
|
633
640
|
}
|
|
641
|
+
if (await isExportAuthed()) {
|
|
642
|
+
const drive = await getExportDriveClient();
|
|
643
|
+
return listFoldersAuthed(drive, ws.driveConfig.folderId);
|
|
644
|
+
}
|
|
634
645
|
return listFolders(ws.driveConfig.folderId);
|
|
635
646
|
}
|
|
636
647
|
|
|
@@ -663,11 +674,11 @@ const EXPORT_TOKENS_FILE = path.resolve(
|
|
|
663
674
|
);
|
|
664
675
|
|
|
665
676
|
function createExportOAuthClient() {
|
|
677
|
+
const defaultRedirectUri = `http://localhost:${process.env.GOOGLE_EXPORT_REDIRECT_PORT || "3001"}/api/drive/export-callback`;
|
|
666
678
|
return new google.auth.OAuth2(
|
|
667
679
|
process.env.GOOGLE_CLIENT_ID,
|
|
668
680
|
process.env.GOOGLE_CLIENT_SECRET,
|
|
669
|
-
process.env.GOOGLE_EXPORT_REDIRECT_URI ||
|
|
670
|
-
"http://localhost:3001/api/drive/export-callback",
|
|
681
|
+
process.env.GOOGLE_EXPORT_REDIRECT_URI || defaultRedirectUri,
|
|
671
682
|
);
|
|
672
683
|
}
|
|
673
684
|
|
|
@@ -675,7 +686,7 @@ export function getExportAuthUrl(): string {
|
|
|
675
686
|
const client = createExportOAuthClient();
|
|
676
687
|
return client.generateAuthUrl({
|
|
677
688
|
access_type: "offline",
|
|
678
|
-
scope: ["https://www.googleapis.com/auth/drive
|
|
689
|
+
scope: ["https://www.googleapis.com/auth/drive"],
|
|
679
690
|
prompt: "consent",
|
|
680
691
|
});
|
|
681
692
|
}
|