create-interview-cockpit 0.23.0 → 0.23.1

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.
@@ -0,0 +1,216 @@
1
+ // ─── Shared GitHub Actions concurrency helpers ─────────────────────────
2
+ // Used by the GitHub Actions Lab to evaluate `concurrency:` blocks against
3
+ // a simulated github context AND to enforce GitHub's queue/cancel rules on
4
+ // the actual `act` runs triggered from the lab UI.
5
+
6
+ import { parse as parseYaml } from "yaml";
7
+
8
+ // ─── Types ───────────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Subset of `github.*` context fields used when evaluating a concurrency
12
+ * group expression. Kept narrow on purpose so we don't oversell what the
13
+ * tiny expression engine below can resolve.
14
+ */
15
+ export interface GhaConcurrencyContext {
16
+ event_name: string;
17
+ ref: string;
18
+ head_ref: string;
19
+ workflow: string;
20
+ }
21
+
22
+ export interface ParsedConcurrency {
23
+ groupExpr: string;
24
+ cancelExpr: string;
25
+ }
26
+
27
+ export type GhaConcurrencyRunStatus =
28
+ | "pending"
29
+ | "running"
30
+ | "completed"
31
+ | "cancelled";
32
+
33
+ /**
34
+ * One record per Run button click. Records survive in memory for the
35
+ * lifetime of the modal so the user can see the full timeline of how
36
+ * GitHub's concurrency rules played out.
37
+ */
38
+ export interface GhaConcurrencyRun {
39
+ id: string;
40
+ seq: number;
41
+ command: string;
42
+ /** github.event_name when the run was triggered. */
43
+ eventName: string;
44
+ /** Workflow file path used for the run. */
45
+ workflowPath: string;
46
+ /** Evaluated group key — empty string when no concurrency block exists. */
47
+ groupKey: string;
48
+ /** Evaluated cancel-in-progress flag — false when not declared. */
49
+ cancelInProgress: boolean;
50
+ /** Snapshot of the github.* context the run was evaluated against. */
51
+ context: GhaConcurrencyContext;
52
+ status: GhaConcurrencyRunStatus;
53
+ startedAt?: number;
54
+ endedAt?: number;
55
+ exitCode?: number;
56
+ cancelReason?: string;
57
+ }
58
+
59
+ // ─── Parsing ────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Extract the workflow-level `concurrency` block. Tolerates both shapes
63
+ * GitHub accepts (`concurrency: my-group` and the long `group:` form).
64
+ * Returns null when the workflow doesn't declare concurrency.
65
+ */
66
+ export function parseConcurrencyBlock(
67
+ yaml: string | undefined,
68
+ ): ParsedConcurrency | null {
69
+ if (!yaml) return null;
70
+ let doc: unknown;
71
+ try {
72
+ doc = parseYaml(yaml);
73
+ } catch {
74
+ return null;
75
+ }
76
+ if (!doc || typeof doc !== "object") return null;
77
+ const raw = (doc as Record<string, unknown>).concurrency;
78
+ if (raw == null) return null;
79
+ if (typeof raw === "string") {
80
+ return { groupExpr: raw, cancelExpr: "false" };
81
+ }
82
+ if (typeof raw === "object") {
83
+ const obj = raw as Record<string, unknown>;
84
+ const group = typeof obj.group === "string" ? obj.group : "";
85
+ const cancel = obj["cancel-in-progress"];
86
+ const cancelExpr =
87
+ typeof cancel === "boolean"
88
+ ? String(cancel)
89
+ : typeof cancel === "string"
90
+ ? cancel
91
+ : "false";
92
+ return { groupExpr: group, cancelExpr };
93
+ }
94
+ return null;
95
+ }
96
+
97
+ // ─── Expression engine ─────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Resolve `github.X` against the simulated context. Returns undefined for
101
+ * any other namespace so unknown references render as empty (matching
102
+ * GitHub's behaviour for undefined expressions inside `${{ ... }}`).
103
+ */
104
+ function resolveGithubPath(
105
+ path: string,
106
+ ctx: GhaConcurrencyContext,
107
+ ): string | undefined {
108
+ if (!path.startsWith("github.")) return undefined;
109
+ const key = path.slice("github.".length) as keyof GhaConcurrencyContext;
110
+ const value = ctx[key];
111
+ return typeof value === "string" ? value : undefined;
112
+ }
113
+
114
+ /**
115
+ * Evaluate one `${{ … }}` expression. Supports the subset real workflows
116
+ * use when defining concurrency keys:
117
+ * - github.X
118
+ * - 'string literal'
119
+ * - true / false / integer literals
120
+ * - X || Y (coalesce — first non-empty wins, GitHub semantics)
121
+ * - X == Y (string equality → 'true'/'false')
122
+ */
123
+ function evalConcurrencyExpr(expr: string, ctx: GhaConcurrencyContext): string {
124
+ const trimmed = expr.trim();
125
+ if (!trimmed) return "";
126
+
127
+ // Equality binds tighter than `||` in our tiny grammar (matches the way
128
+ // real workflows are normally written: `event_name == 'pull_request'`).
129
+ if (trimmed.includes("==")) {
130
+ const [lhs, rhs] = trimmed.split("==").map((s) => s.trim());
131
+ const l = evalConcurrencyExpr(lhs, ctx);
132
+ const r = evalConcurrencyExpr(rhs, ctx);
133
+ return l === r ? "true" : "false";
134
+ }
135
+
136
+ // Coalesce: walk left-to-right, return the first non-empty value.
137
+ if (trimmed.includes("||")) {
138
+ for (const part of trimmed.split("||")) {
139
+ const value = evalConcurrencyExpr(part, ctx);
140
+ if (value) return value;
141
+ }
142
+ return "";
143
+ }
144
+
145
+ const literal = trimmed.match(/^'([^']*)'$/);
146
+ if (literal) return literal[1];
147
+
148
+ if (trimmed === "true" || trimmed === "false") return trimmed;
149
+ if (/^-?\d+$/.test(trimmed)) return trimmed;
150
+
151
+ const resolved = resolveGithubPath(trimmed, ctx);
152
+ return resolved ?? "";
153
+ }
154
+
155
+ /**
156
+ * Replace every `${{ … }}` placeholder in `template` with the value its
157
+ * inner expression evaluates to against `ctx`.
158
+ */
159
+ export function renderConcurrencyTemplate(
160
+ template: string,
161
+ ctx: GhaConcurrencyContext,
162
+ ): string {
163
+ if (!template) return "";
164
+ return template.replace(/\$\{\{\s*([\s\S]*?)\s*\}\}/g, (_match, inner) =>
165
+ evalConcurrencyExpr(String(inner), ctx),
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Compute the (groupKey, cancelInProgress) pair for a hypothetical run.
171
+ * When the workflow doesn't declare a concurrency block, returns
172
+ * (empty key, false) which means "no scheduling constraints — treat as
173
+ * its own group with no cancellation".
174
+ */
175
+ export function evaluateConcurrencyFor(
176
+ parsed: ParsedConcurrency | null,
177
+ ctx: GhaConcurrencyContext,
178
+ ): { groupKey: string; cancelInProgress: boolean } {
179
+ if (!parsed) return { groupKey: "", cancelInProgress: false };
180
+ const groupKey = renderConcurrencyTemplate(parsed.groupExpr, ctx);
181
+ const cancelRaw = renderConcurrencyTemplate(parsed.cancelExpr, ctx)
182
+ .trim()
183
+ .toLowerCase();
184
+ return { groupKey, cancelInProgress: cancelRaw === "true" };
185
+ }
186
+
187
+ /**
188
+ * Derive a plausible default github.* context from a selected `act` event
189
+ * name. We don't have the real one act injects, but these defaults are
190
+ * close enough that the evaluated group keys feel realistic.
191
+ */
192
+ export function defaultContextForEvent(
193
+ eventName: string,
194
+ workflowPath: string,
195
+ branch: string,
196
+ prNumber: number,
197
+ headRef: string,
198
+ ): GhaConcurrencyContext {
199
+ const workflowName = workflowPath
200
+ .replace(/^\.github\/workflows\//, "")
201
+ .replace(/\.ya?ml$/, "");
202
+ if (eventName === "pull_request") {
203
+ return {
204
+ event_name: eventName,
205
+ ref: `refs/pull/${prNumber}/merge`,
206
+ head_ref: headRef,
207
+ workflow: workflowName,
208
+ };
209
+ }
210
+ return {
211
+ event_name: eventName,
212
+ ref: `refs/heads/${branch}`,
213
+ head_ref: "",
214
+ workflow: workflowName,
215
+ };
216
+ }
@@ -1,4 +1,5 @@
1
1
  import type { GithubActionsLabWorkspace } from "./types";
2
+ import type { GithubActionsLabEnvironment } from "./types";
2
3
 
3
4
  // ─── Default Lab Template ────────────────────────────────────────────────
4
5
  //
@@ -401,6 +402,41 @@ export const REACT_VITE_TYPESCRIPT_GHA_LAB: GithubActionsLabWorkspace = {
401
402
 
402
403
  // ─── Helpers (mirror infraLab.ts API surface) ────────────────────────────
403
404
 
405
+ function cloneGhaLabEnvironment(
406
+ environment?: GithubActionsLabEnvironment,
407
+ ): GithubActionsLabEnvironment | undefined {
408
+ if (!environment || typeof environment !== "object") return undefined;
409
+
410
+ const cloneEntries = (entries: GithubActionsLabEnvironment["variables"]) =>
411
+ Array.isArray(entries)
412
+ ? entries
413
+ .filter(
414
+ (entry) =>
415
+ entry &&
416
+ typeof entry.name === "string" &&
417
+ typeof entry.value === "string",
418
+ )
419
+ .map((entry) => ({
420
+ name: entry.name,
421
+ value: entry.value,
422
+ ...(entry.enabled === false ? { enabled: false } : {}),
423
+ }))
424
+ : [];
425
+
426
+ const variables = cloneEntries(environment.variables);
427
+ const secrets = cloneEntries(environment.secrets);
428
+ const env = cloneEntries(environment.env);
429
+ if (variables.length === 0 && secrets.length === 0 && env.length === 0) {
430
+ return undefined;
431
+ }
432
+
433
+ return {
434
+ ...(variables.length ? { variables } : {}),
435
+ ...(secrets.length ? { secrets } : {}),
436
+ ...(env.length ? { env } : {}),
437
+ };
438
+ }
439
+
404
440
  export function cloneGhaLabWorkspace(
405
441
  workspace?: GithubActionsLabWorkspace | null,
406
442
  ): GithubActionsLabWorkspace {
@@ -415,6 +451,7 @@ export function cloneGhaLabWorkspace(
415
451
  )
416
452
  ? source.activeFile
417
453
  : (Object.keys(sourceFiles)[0] ?? ".github/workflows/ci.yml");
454
+ const environment = cloneGhaLabEnvironment(source.environment);
418
455
 
419
456
  return {
420
457
  version: 1,
@@ -433,6 +470,7 @@ export function cloneGhaLabWorkspace(
433
470
  ...(source.includeRunHistoryInContext
434
471
  ? { includeRunHistoryInContext: true }
435
472
  : {}),
473
+ ...(environment ? { environment } : {}),
436
474
  };
437
475
  }
438
476
 
@@ -517,6 +555,9 @@ export function parseGhaLabWorkspace(
517
555
  ...(parsed.includeRunHistoryInContext === true
518
556
  ? { includeRunHistoryInContext: true }
519
557
  : {}),
558
+ ...(parsed.environment && typeof parsed.environment === "object"
559
+ ? { environment: parsed.environment as GithubActionsLabEnvironment }
560
+ : {}),
520
561
  });
521
562
  } catch {
522
563
  return null;
@@ -49,6 +49,21 @@ export interface InfraLabWorkspace {
49
49
  files: Record<string, string>;
50
50
  }
51
51
 
52
+ export interface GithubActionsLabEnvironmentEntry {
53
+ name: string;
54
+ value: string;
55
+ enabled?: boolean;
56
+ }
57
+
58
+ export interface GithubActionsLabEnvironment {
59
+ /** Values available to workflows as `${{ vars.NAME }}` through act's --var-file. */
60
+ variables?: GithubActionsLabEnvironmentEntry[];
61
+ /** Values available to workflows as `${{ secrets.NAME }}` through act's --secret-file. */
62
+ secrets?: GithubActionsLabEnvironmentEntry[];
63
+ /** Runner/container environment values available to shell steps as `$NAME`. */
64
+ env?: GithubActionsLabEnvironmentEntry[];
65
+ }
66
+
52
67
  export interface GithubActionsLabWorkspace {
53
68
  version: 1;
54
69
  label: string;
@@ -64,6 +79,8 @@ export interface GithubActionsLabWorkspace {
64
79
  * (job statuses, durations, exit codes) instead of just the YAML.
65
80
  */
66
81
  includeRunHistoryInContext?: boolean;
82
+ /** Local act inputs mirroring GitHub repository variables, secrets, and runner env. */
83
+ environment?: GithubActionsLabEnvironment;
67
84
  }
68
85
 
69
86
  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/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/gitdiffpanel.tsx","./src/components/gitdiffviewermodal.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/ghaconcurrency.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/ghaconcurrencypanel.tsx","./src/components/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/gitdiffpanel.tsx","./src/components/gitdiffviewermodal.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,3 +1,3 @@
1
1
  {
2
- "version": "0.21.0"
2
+ "version": "0.22.0"
3
3
  }
@@ -13,6 +13,19 @@ interface GithubActionsLabWorkspace {
13
13
  files: Record<string, string>;
14
14
  defaultEvent?: string;
15
15
  defaultWorkflow?: string;
16
+ environment?: GithubActionsLabEnvironment;
17
+ }
18
+
19
+ interface GithubActionsLabEnvironmentEntry {
20
+ name: string;
21
+ value: string;
22
+ enabled?: boolean;
23
+ }
24
+
25
+ interface GithubActionsLabEnvironment {
26
+ variables?: GithubActionsLabEnvironmentEntry[];
27
+ secrets?: GithubActionsLabEnvironmentEntry[];
28
+ env?: GithubActionsLabEnvironmentEntry[];
16
29
  }
17
30
 
18
31
  type OutputKind = "stdout" | "stderr" | "info";
@@ -69,8 +82,11 @@ export type GhaStreamMessage =
69
82
 
70
83
  const MAX_FILE_COUNT = 60;
71
84
  const MAX_TOTAL_SOURCE_BYTES = 1_000_000;
85
+ const MAX_GHA_ENV_ENTRY_COUNT = 80;
86
+ const MAX_GHA_ENV_TOTAL_BYTES = 100_000;
72
87
  const MAX_LOG_CHARS = 400_000;
73
88
  const SOURCE_MANIFEST = ".gha-source-files.json";
89
+ const GHA_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
74
90
 
75
91
  // `act` accepts these events; we restrict to common ones so the console
76
92
  // can't be used to run arbitrary subcommands.
@@ -155,6 +171,69 @@ function assertSafeRelativePath(filePath: string, label: string): void {
155
171
  }
156
172
  }
157
173
 
174
+ function parseEnvironmentEntries(
175
+ input: unknown,
176
+ label: string,
177
+ ): GithubActionsLabEnvironmentEntry[] {
178
+ if (!Array.isArray(input)) return [];
179
+ if (input.length > MAX_GHA_ENV_ENTRY_COUNT) {
180
+ throw new Error(
181
+ `${label} exceeds the ${MAX_GHA_ENV_ENTRY_COUNT} item limit`,
182
+ );
183
+ }
184
+
185
+ let totalBytes = 0;
186
+ return input
187
+ .filter((entry): entry is Record<string, unknown> => {
188
+ return !!entry && typeof entry === "object";
189
+ })
190
+ .map((entry) => {
191
+ const name = typeof entry.name === "string" ? entry.name.trim() : "";
192
+ if (!name) return null;
193
+ if (!GHA_ENV_NAME_RE.test(name)) {
194
+ throw new Error(
195
+ `${label} name '${name}' is invalid. Use letters, numbers, and underscores; do not start with a number.`,
196
+ );
197
+ }
198
+ const value = typeof entry.value === "string" ? entry.value : "";
199
+ totalBytes += Buffer.byteLength(name, "utf8");
200
+ totalBytes += Buffer.byteLength(value, "utf8");
201
+ if (totalBytes > MAX_GHA_ENV_TOTAL_BYTES) {
202
+ throw new Error(`${label} values exceed the allowed size limit`);
203
+ }
204
+ return {
205
+ name,
206
+ value,
207
+ ...(entry.enabled === false ? { enabled: false } : {}),
208
+ };
209
+ })
210
+ .filter((entry): entry is GithubActionsLabEnvironmentEntry => !!entry);
211
+ }
212
+
213
+ function parseWorkspaceEnvironment(
214
+ input: unknown,
215
+ ): GithubActionsLabEnvironment | undefined {
216
+ if (!input || typeof input !== "object") return undefined;
217
+ const candidate = input as Record<string, unknown>;
218
+ const variables = parseEnvironmentEntries(
219
+ candidate.variables,
220
+ "GitHub Actions variable",
221
+ );
222
+ const secrets = parseEnvironmentEntries(
223
+ candidate.secrets,
224
+ "GitHub Actions secret",
225
+ );
226
+ const env = parseEnvironmentEntries(candidate.env, "GitHub Actions env");
227
+ if (variables.length === 0 && secrets.length === 0 && env.length === 0) {
228
+ return undefined;
229
+ }
230
+ return {
231
+ ...(variables.length ? { variables } : {}),
232
+ ...(secrets.length ? { secrets } : {}),
233
+ ...(env.length ? { env } : {}),
234
+ };
235
+ }
236
+
158
237
  function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
159
238
  if (!input || typeof input !== "object") {
160
239
  throw new Error("workspace payload is required");
@@ -188,6 +267,7 @@ function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
188
267
  if (totalBytes > MAX_TOTAL_SOURCE_BYTES) {
189
268
  throw new Error("workspace source exceeds the allowed size limit");
190
269
  }
270
+ const environment = parseWorkspaceEnvironment(candidate.environment);
191
271
  return {
192
272
  version: 1,
193
273
  label:
@@ -207,6 +287,7 @@ function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
207
287
  typeof candidate.defaultWorkflow === "string"
208
288
  ? candidate.defaultWorkflow
209
289
  : undefined,
290
+ ...(environment ? { environment } : {}),
210
291
  files,
211
292
  };
212
293
  }
@@ -273,6 +354,72 @@ async function syncWorkspaceToSession(
273
354
  return workspaceDir;
274
355
  }
275
356
 
357
+ function escapeDotenvValue(value: string): string {
358
+ return `"${value
359
+ .replace(/\\/g, "\\\\")
360
+ .replace(/\r/g, "")
361
+ .replace(/\n/g, "\\n")
362
+ .replace(/"/g, '\\"')}"`;
363
+ }
364
+
365
+ function formatEnvironmentFile(
366
+ entries: GithubActionsLabEnvironmentEntry[],
367
+ ): string {
368
+ return `${entries
369
+ .filter((entry) => entry.enabled !== false)
370
+ .map((entry) => `${entry.name}=${escapeDotenvValue(entry.value)}`)
371
+ .join("\n")}\n`;
372
+ }
373
+
374
+ async function buildActEnvironmentArgs(
375
+ runDir: string,
376
+ workspace: GithubActionsLabWorkspace,
377
+ ): Promise<{ args: string[]; summary: string[] }> {
378
+ const args: string[] = [];
379
+ const summary: string[] = [];
380
+ const specs: Array<{
381
+ key: keyof GithubActionsLabEnvironment;
382
+ flag: string;
383
+ fileName: string;
384
+ label: string;
385
+ }> = [
386
+ {
387
+ key: "variables",
388
+ flag: "--var-file",
389
+ fileName: "act-vars.env",
390
+ label: "variable",
391
+ },
392
+ {
393
+ key: "secrets",
394
+ flag: "--secret-file",
395
+ fileName: "act-secrets.env",
396
+ label: "secret",
397
+ },
398
+ {
399
+ key: "env",
400
+ flag: "--env-file",
401
+ fileName: "act-env.env",
402
+ label: "runner env",
403
+ },
404
+ ];
405
+
406
+ for (const spec of specs) {
407
+ const entries = (workspace.environment?.[spec.key] ?? []).filter(
408
+ (entry) => entry.enabled !== false,
409
+ );
410
+ if (entries.length === 0) continue;
411
+
412
+ const filePath = path.join(runDir, spec.fileName);
413
+ await fs.writeFile(filePath, formatEnvironmentFile(entries), "utf8");
414
+ args.push(spec.flag, filePath);
415
+ summary.push(
416
+ `${entries.length} ${spec.label}${entries.length === 1 ? "" : "s"}`,
417
+ );
418
+ }
419
+
420
+ return { args, summary };
421
+ }
422
+
276
423
  // ─── Command parsing ────────────────────────────────────────────────────
277
424
 
278
425
  function splitCommand(command: string): string[] {
@@ -722,6 +869,8 @@ export async function streamGhaCommand(
722
869
  const runDir = path.join(getGhaRunsDir(), runId);
723
870
  const workspaceDir = await syncWorkspaceToSession(sessionKey, workspace);
724
871
  await fs.mkdir(runDir, { recursive: true });
872
+ const environmentArgs = await buildActEnvironmentArgs(runDir, workspace);
873
+ const actArgs = [...platformArgs.args, ...environmentArgs.args];
725
874
 
726
875
  let logs = "";
727
876
  let status: "completed" | "failed" = "completed";
@@ -737,12 +886,19 @@ export async function streamGhaCommand(
737
886
  text: "[info] Using default Medium act runner image mappings. Pass -P/--platform to override.\n",
738
887
  });
739
888
  }
889
+ if (environmentArgs.summary.length > 0) {
890
+ emit({
891
+ type: "output",
892
+ kind: "info",
893
+ text: `[info] Injected ${environmentArgs.summary.join(", ")} into act via temporary files.\n`,
894
+ });
895
+ }
740
896
 
741
897
  // Track per-job status from act's prefixed stdout/stderr lines so the
742
898
  // client can render a live DAG in addition to the raw console.
743
899
  const tracker = new JobTracker((job) => emit({ type: "job", job }));
744
900
 
745
- const child = spawn("act", platformArgs.args, {
901
+ const child = spawn("act", actArgs, {
746
902
  cwd: workspaceDir,
747
903
  env: {
748
904
  ...process.env,
@@ -1163,6 +1163,27 @@ async function getOrCreateFolder(
1163
1163
  return folderId;
1164
1164
  }
1165
1165
 
1166
+ async function resolveExportFolderId(
1167
+ drive: drive_v3.Drive,
1168
+ ws: storage.WorkspaceMeta,
1169
+ targetFolderId?: string,
1170
+ ): Promise<string> {
1171
+ const requestedTarget = targetFolderId?.trim();
1172
+ if (requestedTarget) return requestedTarget;
1173
+
1174
+ // If the workspace is currently scoped to a Drive subfolder (for example
1175
+ // Shared Questions/SBD), push back into that same folder. Falling back to
1176
+ // _export here made topic-level pushes from a selected subfolder land in
1177
+ // Shared Questions/_export instead of beside the original topic folders.
1178
+ const selectedSubfolder = ws.driveConfig?.subFolderId?.trim();
1179
+ if (selectedSubfolder) return selectedSubfolder;
1180
+
1181
+ if (!ws.driveConfig?.folderId) {
1182
+ throw new Error("No Drive folder linked to this workspace");
1183
+ }
1184
+ return getOrCreateFolder(drive, ws.driveConfig.folderId, EXPORT_FOLDER_NAME);
1185
+ }
1186
+
1166
1187
  async function uploadFileToFolder(
1167
1188
  drive: drive_v3.Drive,
1168
1189
  folderId: string,
@@ -1342,7 +1363,6 @@ export async function exportWorkspace(
1342
1363
  }
1343
1364
 
1344
1365
  const drive = await getExportDriveClient();
1345
- const { folderId } = ws.driveConfig;
1346
1366
  const result: ExportResult = {
1347
1367
  topicsExported: 0,
1348
1368
  questionsExported: 0,
@@ -1350,10 +1370,9 @@ export async function exportWorkspace(
1350
1370
  errors: [],
1351
1371
  };
1352
1372
 
1353
- // Use the chosen subfolder directly, or fall back to an "_export" subfolder
1354
- const exportFolderId =
1355
- targetFolderId ??
1356
- (await getOrCreateFolder(drive, folderId, EXPORT_FOLDER_NAME));
1373
+ // Use the chosen subfolder directly. If none was passed, use the workspace's
1374
+ // selected Drive subfolder before falling back to root/_export.
1375
+ const exportFolderId = await resolveExportFolderId(drive, ws, targetFolderId);
1357
1376
  const topics = await storage.getTopicsForWorkspace(workspaceId);
1358
1377
  const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
1359
1378
 
@@ -1415,10 +1434,7 @@ export async function exportTopic(
1415
1434
  }
1416
1435
 
1417
1436
  const drive = await getExportDriveClient();
1418
- const { folderId } = ws.driveConfig;
1419
- const exportFolderId =
1420
- targetFolderId ??
1421
- (await getOrCreateFolder(drive, folderId, EXPORT_FOLDER_NAME));
1437
+ const exportFolderId = await resolveExportFolderId(drive, ws, targetFolderId);
1422
1438
  const topic = (await storage.getTopicsForWorkspace(workspaceId)).find(
1423
1439
  (t) => t.id === topicId,
1424
1440
  );