create-interview-cockpit 0.22.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.
- package/package.json +1 -1
- package/template/client/src/api.ts +2 -0
- package/template/client/src/components/GhaConcurrencyPanel.tsx +281 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +1238 -33
- package/template/client/src/components/WorkspaceSwitcher.tsx +6 -1
- package/template/client/src/ghaConcurrency.ts +216 -0
- package/template/client/src/githubActionsLab.ts +41 -0
- package/template/client/src/types.ts +17 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +198 -1
- package/template/server/src/google-drive.ts +25 -9
- package/template/server/src/index.ts +0 -1
|
@@ -335,7 +335,12 @@ export default function WorkspaceSwitcher() {
|
|
|
335
335
|
<>
|
|
336
336
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
337
337
|
<button
|
|
338
|
-
onClick={() =>
|
|
338
|
+
onClick={() =>
|
|
339
|
+
runExport(
|
|
340
|
+
folderPicker.ws,
|
|
341
|
+
folderPicker.ws.driveConfig?.folderId,
|
|
342
|
+
)
|
|
343
|
+
}
|
|
339
344
|
className="w-full text-left px-3 py-2 rounded-lg text-xs text-slate-300 hover:bg-slate-800 flex items-center gap-2"
|
|
340
345
|
>
|
|
341
346
|
<FolderOpen size={13} className="text-slate-500 shrink-0" />
|
|
@@ -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"}
|
package/template/cockpit.json
CHANGED
|
@@ -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.
|
|
@@ -105,6 +121,21 @@ const ALLOWED_ACT_FLAGS = new Set([
|
|
|
105
121
|
"-v",
|
|
106
122
|
]);
|
|
107
123
|
|
|
124
|
+
// act asks an interactive first-run question for runner image size unless it
|
|
125
|
+
// already has platform mappings. The lab console cannot answer arrow-key
|
|
126
|
+
// prompts cleanly, so we inject the Medium image mappings by default. Users can
|
|
127
|
+
// still override with -P / --platform in the command.
|
|
128
|
+
const DEFAULT_ACT_PLATFORM_ARGS = [
|
|
129
|
+
"-P",
|
|
130
|
+
"ubuntu-latest=catthehacker/ubuntu:act-latest",
|
|
131
|
+
"-P",
|
|
132
|
+
"ubuntu-24.04=catthehacker/ubuntu:act-24.04",
|
|
133
|
+
"-P",
|
|
134
|
+
"ubuntu-22.04=catthehacker/ubuntu:act-22.04",
|
|
135
|
+
"-P",
|
|
136
|
+
"ubuntu-20.04=catthehacker/ubuntu:act-20.04",
|
|
137
|
+
];
|
|
138
|
+
|
|
108
139
|
// ─── Utilities ───────────────────────────────────────────────────────────
|
|
109
140
|
|
|
110
141
|
function getGhaRunsDir(): string {
|
|
@@ -140,6 +171,69 @@ function assertSafeRelativePath(filePath: string, label: string): void {
|
|
|
140
171
|
}
|
|
141
172
|
}
|
|
142
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
|
+
|
|
143
237
|
function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
|
|
144
238
|
if (!input || typeof input !== "object") {
|
|
145
239
|
throw new Error("workspace payload is required");
|
|
@@ -173,6 +267,7 @@ function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
|
|
|
173
267
|
if (totalBytes > MAX_TOTAL_SOURCE_BYTES) {
|
|
174
268
|
throw new Error("workspace source exceeds the allowed size limit");
|
|
175
269
|
}
|
|
270
|
+
const environment = parseWorkspaceEnvironment(candidate.environment);
|
|
176
271
|
return {
|
|
177
272
|
version: 1,
|
|
178
273
|
label:
|
|
@@ -192,6 +287,7 @@ function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
|
|
|
192
287
|
typeof candidate.defaultWorkflow === "string"
|
|
193
288
|
? candidate.defaultWorkflow
|
|
194
289
|
: undefined,
|
|
290
|
+
...(environment ? { environment } : {}),
|
|
195
291
|
files,
|
|
196
292
|
};
|
|
197
293
|
}
|
|
@@ -258,6 +354,72 @@ async function syncWorkspaceToSession(
|
|
|
258
354
|
return workspaceDir;
|
|
259
355
|
}
|
|
260
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
|
+
|
|
261
423
|
// ─── Command parsing ────────────────────────────────────────────────────
|
|
262
424
|
|
|
263
425
|
function splitCommand(command: string): string[] {
|
|
@@ -304,6 +466,24 @@ interface ParsedActCommand {
|
|
|
304
466
|
displayCommand: string;
|
|
305
467
|
}
|
|
306
468
|
|
|
469
|
+
function hasPlatformOverride(args: string[]): boolean {
|
|
470
|
+
return args.some(
|
|
471
|
+
(arg) =>
|
|
472
|
+
arg === "-P" ||
|
|
473
|
+
arg === "--platform" ||
|
|
474
|
+
arg.startsWith("-P=") ||
|
|
475
|
+
arg.startsWith("--platform="),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function withDefaultActPlatforms(args: string[]): {
|
|
480
|
+
args: string[];
|
|
481
|
+
injected: boolean;
|
|
482
|
+
} {
|
|
483
|
+
if (hasPlatformOverride(args)) return { args, injected: false };
|
|
484
|
+
return { args: [...args, ...DEFAULT_ACT_PLATFORM_ARGS], injected: true };
|
|
485
|
+
}
|
|
486
|
+
|
|
307
487
|
function parseActCommand(command: string): ParsedActCommand {
|
|
308
488
|
const tokens = splitCommand(command);
|
|
309
489
|
if (tokens.length === 0) throw new Error("Type a command to run");
|
|
@@ -681,6 +861,7 @@ export async function streamGhaCommand(
|
|
|
681
861
|
const workspace = parseWorkspace(input.workspace);
|
|
682
862
|
|
|
683
863
|
const parsed = parseActCommand(input.command);
|
|
864
|
+
const platformArgs = withDefaultActPlatforms(parsed.args);
|
|
684
865
|
const sessionKey =
|
|
685
866
|
input.fileId ?? input.questionId ?? `draft-${randomUUID()}`;
|
|
686
867
|
const runId = randomUUID();
|
|
@@ -688,6 +869,8 @@ export async function streamGhaCommand(
|
|
|
688
869
|
const runDir = path.join(getGhaRunsDir(), runId);
|
|
689
870
|
const workspaceDir = await syncWorkspaceToSession(sessionKey, workspace);
|
|
690
871
|
await fs.mkdir(runDir, { recursive: true });
|
|
872
|
+
const environmentArgs = await buildActEnvironmentArgs(runDir, workspace);
|
|
873
|
+
const actArgs = [...platformArgs.args, ...environmentArgs.args];
|
|
691
874
|
|
|
692
875
|
let logs = "";
|
|
693
876
|
let status: "completed" | "failed" = "completed";
|
|
@@ -696,12 +879,26 @@ export async function streamGhaCommand(
|
|
|
696
879
|
|
|
697
880
|
const emit = (msg: GhaStreamMessage) => input.onMessage?.(msg);
|
|
698
881
|
emit({ type: "output", kind: "info", text: parsed.displayCommand });
|
|
882
|
+
if (platformArgs.injected) {
|
|
883
|
+
emit({
|
|
884
|
+
type: "output",
|
|
885
|
+
kind: "info",
|
|
886
|
+
text: "[info] Using default Medium act runner image mappings. Pass -P/--platform to override.\n",
|
|
887
|
+
});
|
|
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
|
+
}
|
|
699
896
|
|
|
700
897
|
// Track per-job status from act's prefixed stdout/stderr lines so the
|
|
701
898
|
// client can render a live DAG in addition to the raw console.
|
|
702
899
|
const tracker = new JobTracker((job) => emit({ type: "job", job }));
|
|
703
900
|
|
|
704
|
-
const child = spawn("act",
|
|
901
|
+
const child = spawn("act", actArgs, {
|
|
705
902
|
cwd: workspaceDir,
|
|
706
903
|
env: {
|
|
707
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
|
|
1354
|
-
|
|
1355
|
-
|
|
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
|
|
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
|
);
|