@codeyam-editor/codeyam-editor 0.1.3 → 0.1.4
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/npm/cloud.js +289 -34
- package/npm/container.js +11 -0
- package/npm/disk-pressure.js +89 -0
- package/npm/editor-dev.js +11 -0
- package/npm/editor.js +12 -0
- package/npm/fleet-build-data.js +81 -0
- package/npm/fleet-cleanup.js +27 -0
- package/npm/fleet-job-badge.js +45 -1
- package/npm/fleet-plans.js +96 -0
- package/npm/fleet-reconcile.js +58 -1
- package/npm/launch-config.js +109 -4
- package/npm/scenario-check.js +81 -1
- package/npm/scenario-handlers.js +16 -0
- package/npm/utils.js +138 -7
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-Bnghtj8a.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
- package/ui/dist/assets/index-BGqairFV.css +1 -0
- package/ui/dist/assets/{index-KDFAgt2e.js → index-BuK0lizt.js} +9 -9
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
- package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Assemble the per-VM wire object the dashboard's /data response carries to the
|
|
4
|
+
// browser. This is the single source of truth for a card's transport shape: the
|
|
5
|
+
// card UI (scripts/operator/fleet-dashboard/public/index.html) reads `v.<field>`
|
|
6
|
+
// off each entry, so every field the card renders MUST be emitted here.
|
|
7
|
+
//
|
|
8
|
+
// It was split out of server.js's buildData() so the shape is unit-testable.
|
|
9
|
+
// The regression that motivated the split: `currentProvider` (the VM's
|
|
10
|
+
// configured build agent) was merged onto the poll state AND read by the card to
|
|
11
|
+
// render the 🤖 agent badge, but it was silently missing from this envelope — so
|
|
12
|
+
// it lived in server memory and never reached the browser, and the badge never
|
|
13
|
+
// appeared. A hand-maintained object literal inside a 197KB monolith has no
|
|
14
|
+
// guard against that drift; a pure function does.
|
|
15
|
+
//
|
|
16
|
+
// `v` is the merged poll state (state.vms[n]). `ctx` carries the derived,
|
|
17
|
+
// cross-VM, and side-effecting values buildData computes per VM (status,
|
|
18
|
+
// debounced broker tri-state, reconciled job, queue position, launch cfg, …) —
|
|
19
|
+
// the things this pure function can't know on its own.
|
|
20
|
+
function buildVmEnvelope(v, ctx) {
|
|
21
|
+
return {
|
|
22
|
+
n: ctx.n,
|
|
23
|
+
url: v.url || null,
|
|
24
|
+
reachable: !!v.reachable,
|
|
25
|
+
hasSession: !!v.hasSession,
|
|
26
|
+
// Three-state: true = broker confirmed up, false = confirmed down, null =
|
|
27
|
+
// editor unreachable so broker state is unknown. Never conflate "down" with
|
|
28
|
+
// "unreachable" — that mismatch is what made the card show "broker down"
|
|
29
|
+
// while a direct pgrep proved the broker was up.
|
|
30
|
+
brokerLive: v.reachable ? !!v.agentBrokerLive : null,
|
|
31
|
+
// Broker tri-state: 'down' | 'idle' | 'agent-live' | 'unknown' (derived
|
|
32
|
+
// upstream, debounced). Richer than brokerLive's is-an-agent-live boolean.
|
|
33
|
+
brokerStatus: ctx.brokerStatus,
|
|
34
|
+
awaitingInput: !!v.awaitingUserInput,
|
|
35
|
+
feature: v.feature || null,
|
|
36
|
+
step: v.step != null ? v.step : null,
|
|
37
|
+
mode: v.mode || null,
|
|
38
|
+
label: v.label || null,
|
|
39
|
+
status: ctx.status,
|
|
40
|
+
orphan: ctx.orphan || null,
|
|
41
|
+
degradedReasons: ctx.degradedReasons || [],
|
|
42
|
+
isHead: !!ctx.isHead,
|
|
43
|
+
// True when this VM holds the queue head but is unreachable (operator
|
|
44
|
+
// decides manually whether to release it).
|
|
45
|
+
headHolderUnreachable: !!ctx.headHolderUnreachable,
|
|
46
|
+
queuePos: ctx.queuePos != null ? ctx.queuePos : null,
|
|
47
|
+
queueLen: ctx.queueLen,
|
|
48
|
+
job: ctx.job || null,
|
|
49
|
+
config: ctx.config || null,
|
|
50
|
+
project: ctx.project != null ? ctx.project : null,
|
|
51
|
+
variant: ctx.variant != null ? ctx.variant : null,
|
|
52
|
+
cardIdentity: ctx.cardIdentity,
|
|
53
|
+
brokerHistory: Array.isArray(v.brokerHistory) ? v.brokerHistory : [],
|
|
54
|
+
brokerDeath: ctx.brokerDeath,
|
|
55
|
+
// Auth state + reason + token expiry. Only surfaced when the editor is
|
|
56
|
+
// reachable (an unreachable VM's cached auth is stale and misleading).
|
|
57
|
+
providerAuth: v.reachable && v.providerAuth ? v.providerAuth : null,
|
|
58
|
+
effects: v.effects || null,
|
|
59
|
+
// The VM's live-configured build agent (lowercase provider), reported by
|
|
60
|
+
// /api/session-info and merged (sticky) onto the poll state. Drives the
|
|
61
|
+
// card's 🤖 agent badge. null for an older VM whose editor predates the
|
|
62
|
+
// field (the card omits the badge rather than rendering a blank chip).
|
|
63
|
+
currentProvider: v.currentProvider || null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The fields the card UI depends on being present in every /data VM entry. This
|
|
68
|
+
// is the contract `buildVmEnvelope` must satisfy: the regression that prompted
|
|
69
|
+
// this module was a field the card read but the envelope didn't emit, so the
|
|
70
|
+
// test asserts the envelope keys are a superset of this list. Adding a field the
|
|
71
|
+
// card renders means adding it here AND in buildVmEnvelope — the test fails loud
|
|
72
|
+
// if you do one without the other.
|
|
73
|
+
const REQUIRED_CARD_FIELDS = [
|
|
74
|
+
'n', 'url', 'reachable', 'hasSession', 'brokerLive', 'brokerStatus',
|
|
75
|
+
'awaitingInput', 'feature', 'step', 'mode', 'label', 'status', 'orphan',
|
|
76
|
+
'degradedReasons', 'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen',
|
|
77
|
+
'job', 'config', 'project', 'variant', 'cardIdentity', 'brokerHistory',
|
|
78
|
+
'brokerDeath', 'providerAuth', 'effects', 'currentProvider',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
module.exports = { buildVmEnvelope, REQUIRED_CARD_FIELDS };
|
package/npm/fleet-cleanup.js
CHANGED
|
@@ -206,6 +206,32 @@ function reattachVerdict({ markerPresent, exitCode, childAlive } = {}) {
|
|
|
206
206
|
return 'reconcile';
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
// improve45 — map a reattachVerdict tag to the dashboard's NEXT action, now that
|
|
210
|
+
// a clean exit marker is NECESSARY BUT NOT SUFFICIENT to roster. A detached
|
|
211
|
+
// `cloud:up` can exit 0 AFTER the image build but BEFORE `docker compose up`
|
|
212
|
+
// ever runs — an ssh broken-pipe or dashboard SIGKILL landing in that window,
|
|
213
|
+
// with the wrapping EXIT trap still recording 0 (the live VM2 signature: built
|
|
214
|
+
// image, repo cloned, ZERO editor containers). So 'done' no longer means "roster
|
|
215
|
+
// it"; it means "confirm the container is actually up first." Both the clean-exit
|
|
216
|
+
// ('done') and the no-usable-marker ('reconcile') cases therefore route through
|
|
217
|
+
// the SAME gcloud container + wiring probe (reconcileViaGcloud → fc.reapVerdict),
|
|
218
|
+
// which rosters only a genuinely-up + wired add and routes a half-provisioned one
|
|
219
|
+
// to recover-bootstrap. 'error' (real non-zero exit) and 'running' (child still
|
|
220
|
+
// alive) are unchanged. reattachVerdict stays the honest classifier of the
|
|
221
|
+
// child's exit SIGNAL; this helper expresses how that signal is CONSUMED now that
|
|
222
|
+
// exit-0 no longer implies rostering. Pure: server.js maps the returned action to
|
|
223
|
+
// its side-effecting step, so the matrix is exhaustively testable without a VM.
|
|
224
|
+
// verdict next step meaning
|
|
225
|
+
// 'done' 'probe' confirm the container before rostering
|
|
226
|
+
// 'reconcile' 'probe' no usable marker — let the gcloud probe decide
|
|
227
|
+
// 'error' 'surface-error' real failure — show add-failed with the code
|
|
228
|
+
// 'running' 'watch' child still alive — keep polling for the marker
|
|
229
|
+
function reattachNextStep(verdict) {
|
|
230
|
+
if (verdict === 'error') return 'surface-error';
|
|
231
|
+
if (verdict === 'running') return 'watch';
|
|
232
|
+
return 'probe'; // 'done' and 'reconcile' both confirm via the gcloud probe
|
|
233
|
+
}
|
|
234
|
+
|
|
209
235
|
// Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of
|
|
210
236
|
// recoveries ALREADY persisted on the sidecar, return the next attempt number,
|
|
211
237
|
// whether the reap should proceed (re-run cloud:up) or treat the VM as
|
|
@@ -294,4 +320,5 @@ module.exports = {
|
|
|
294
320
|
readExitMarker,
|
|
295
321
|
clearExitMarker,
|
|
296
322
|
reattachVerdict,
|
|
323
|
+
reattachNextStep,
|
|
297
324
|
};
|
package/npm/fleet-job-badge.js
CHANGED
|
@@ -36,4 +36,48 @@ function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degrad
|
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// Given a reconcile verdict (from shouldReconcileFailedBadge) and whether the VM
|
|
40
|
+
// is now idle, decide whether the recovered badge should be FULLY CLEARED — the
|
|
41
|
+
// job entry dropped from state.jobs so the card goes clean — rather than merely
|
|
42
|
+
// muted as a "recovered (was: …)" note (improve41).
|
|
43
|
+
//
|
|
44
|
+
// badgeReconciled the shouldReconcileFailedBadge verdict for this VM
|
|
45
|
+
// idle the VM answered this poll, is not running a build session,
|
|
46
|
+
// and its broker is not actively serving an agent
|
|
47
|
+
//
|
|
48
|
+
// A recovered VM that is also idle has demonstrably finished recovering, so the
|
|
49
|
+
// stale error is dropped entirely. A recovered-but-not-yet-idle VM (mid-build,
|
|
50
|
+
// or a poll hasn't confirmed idle) keeps the muted note so the prior log stays
|
|
51
|
+
// visible during that transient window. Never clears a badge that wasn't first
|
|
52
|
+
// judged recovered — an unreachable / drifted / off-branch error stays surfaced.
|
|
53
|
+
function shouldClearReconciledBadge({ badgeReconciled, idle } = {}) {
|
|
54
|
+
return !!badgeReconciled && !!idle;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Pure guard for the operator "Clear badge" action (improve41). Decides whether
|
|
58
|
+
// VM-n's persisted job entry may be dropped after an out-of-band recovery,
|
|
59
|
+
// independent of the server's state / fs I/O so the cases are unit-tested
|
|
60
|
+
// without a live dashboard. The shell (server.js) supplies the live signals and
|
|
61
|
+
// turns the verdict into the {ok,msg} response + the actual delete + snapshot.
|
|
62
|
+
//
|
|
63
|
+
// rostered the VM number is in the live roster
|
|
64
|
+
// job the carried job record ({ state, ... }) or null/undefined
|
|
65
|
+
//
|
|
66
|
+
// Only a TERMINAL job (error/done) on a rostered VM may be cleared, OR a terminal
|
|
67
|
+
// failed add (add/error) on a non-rostered VM (which deletes the card + add-N sidecar):
|
|
68
|
+
// any other job state / non-rostered combination is not clearable.
|
|
69
|
+
// Returns { ok, reason, cleanup } — reason is a stable code the shell maps to an
|
|
70
|
+
// operator message, and cleanup is the cleanup action to perform ('add-failed' vs 'badge-only').
|
|
71
|
+
function canClearBadge({ rostered, job } = {}) {
|
|
72
|
+
if (!rostered) {
|
|
73
|
+
if (job && job.action === 'add' && job.state === 'error') {
|
|
74
|
+
return { ok: true, reason: null, cleanup: 'add-failed' };
|
|
75
|
+
}
|
|
76
|
+
return { ok: false, reason: 'not-rostered' };
|
|
77
|
+
}
|
|
78
|
+
if (!job) return { ok: false, reason: 'no-badge' };
|
|
79
|
+
if (job.state !== 'error' && job.state !== 'done') return { ok: false, reason: 'job-active' };
|
|
80
|
+
return { ok: true, reason: null, cleanup: 'badge-only' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { shouldReconcileFailedBadge, shouldClearReconciledBadge, canClearBadge };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Indexes the currently-reachable VMs by their live sessionId.
|
|
5
|
+
* @param {Array|Object} vms The merged VM states on the dashboard.
|
|
6
|
+
* @returns {Object} A map of sessionId -> VM number (n).
|
|
7
|
+
*/
|
|
8
|
+
function indexVmsBySessionId(vms) {
|
|
9
|
+
const map = {};
|
|
10
|
+
if (!vms) return map;
|
|
11
|
+
const list = Array.isArray(vms) ? vms : Object.values(vms);
|
|
12
|
+
for (const vm of list) {
|
|
13
|
+
if (vm && vm.sessionId && vm.n !== undefined) {
|
|
14
|
+
map[vm.sessionId] = vm.n;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return map;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Computes which dependencies are unsatisfied (i.e. not in the completedSlugs set).
|
|
22
|
+
* @param {string[]} dependsOn Prerequisite slugs.
|
|
23
|
+
* @param {Set|string[]} completedSlugs Set or array of completed plan slugs.
|
|
24
|
+
* @returns {string[]} The unsatisfied dependency slugs.
|
|
25
|
+
*/
|
|
26
|
+
function unsatisfiedDeps(dependsOn, completedSlugs) {
|
|
27
|
+
const deps = Array.isArray(dependsOn) ? dependsOn : [];
|
|
28
|
+
const completedSet = completedSlugs instanceof Set ? completedSlugs : new Set(completedSlugs || []);
|
|
29
|
+
return deps.filter(dep => !completedSet.has(dep));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves claims and dependency states for each queued plan.
|
|
34
|
+
* @param {Object} params
|
|
35
|
+
* @param {Object[]} params.plans Array of PlanSummary objects from /api/plans.
|
|
36
|
+
* @param {Object[]} params.claims Array of PlanClaim objects from /api/plan-claims.
|
|
37
|
+
* @param {Set|string[]} params.completedSlugs Completed plan slugs.
|
|
38
|
+
* @param {Object} params.vmsBySessionId Map of sessionId -> VM number.
|
|
39
|
+
* @returns {Object[]} Annotated plan rows.
|
|
40
|
+
*/
|
|
41
|
+
function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
|
|
42
|
+
const list = Array.isArray(plans) ? plans : [];
|
|
43
|
+
const claimsList = Array.isArray(claims) ? claims : [];
|
|
44
|
+
|
|
45
|
+
return list.map(plan => {
|
|
46
|
+
// 1. Extract plan fields
|
|
47
|
+
const slug = plan.slug;
|
|
48
|
+
const title = plan.title;
|
|
49
|
+
const mode = plan.mode || null;
|
|
50
|
+
const order = plan.order != null ? plan.order : null;
|
|
51
|
+
const prefix = plan.prefix || null;
|
|
52
|
+
|
|
53
|
+
// 2. Resolve dependency-blocked state
|
|
54
|
+
const dependsOn = Array.isArray(plan.depends_on) ? plan.depends_on : (plan.dependsOn ? plan.dependsOn : []);
|
|
55
|
+
const blockedBy = unsatisfiedDeps(dependsOn, completedSlugs);
|
|
56
|
+
|
|
57
|
+
// 3. Resolve claim state
|
|
58
|
+
const claim = claimsList.find(c => c.slug === slug);
|
|
59
|
+
let claimInfo = null;
|
|
60
|
+
if (claim) {
|
|
61
|
+
const claimedBy = claim.claimedBy || claim.claimed_by || {};
|
|
62
|
+
const sessionId = claimedBy.sessionId || claimedBy.session_id || null;
|
|
63
|
+
const vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
|
|
64
|
+
|
|
65
|
+
claimInfo = {
|
|
66
|
+
vm,
|
|
67
|
+
machine: claimedBy.machine || null,
|
|
68
|
+
agent: claimedBy.agentProvider || claimedBy.agent_provider || null,
|
|
69
|
+
sessionAlive: claim.sessionAlive !== undefined ? claim.sessionAlive : (claim.session_alive !== undefined ? claim.session_alive : null),
|
|
70
|
+
source: claim.source || null,
|
|
71
|
+
currentStep: claimedBy.currentStep || claimedBy.current_step || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
slug,
|
|
77
|
+
title,
|
|
78
|
+
mode,
|
|
79
|
+
order,
|
|
80
|
+
prefix,
|
|
81
|
+
blockedBy,
|
|
82
|
+
claim: claimInfo
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const REQUIRED_PLAN_ROW_FIELDS = [
|
|
88
|
+
'slug', 'title', 'mode', 'order', 'prefix', 'blockedBy', 'claim'
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
indexVmsBySessionId,
|
|
93
|
+
unsatisfiedDeps,
|
|
94
|
+
resolvePlanRows,
|
|
95
|
+
REQUIRED_PLAN_ROW_FIELDS
|
|
96
|
+
};
|
package/npm/fleet-reconcile.js
CHANGED
|
@@ -33,4 +33,61 @@ function computeUnrostered(running, shown) {
|
|
|
33
33
|
.sort((a, b) => a - b);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// Map a single degraded reason to the live health signal that resolves it.
|
|
37
|
+
// Returns true only when that signal now passes; a reason with no mapped signal
|
|
38
|
+
// returns false (retained — a genuine config problem, never a false-green).
|
|
39
|
+
// Start with the two observed classes (improve41); extend the table as new
|
|
40
|
+
// degraded reasons gain a corresponding live signal.
|
|
41
|
+
function reasonClearedByHealth(reason, { reachableServing, authed }) {
|
|
42
|
+
const r = String(reason || '');
|
|
43
|
+
if (/claude auth|claude\.json|login/i.test(r)) return authed;
|
|
44
|
+
if (/container|never came up|provisioning failed/i.test(r)) return reachableServing;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Given a terminal add-job badge and the VM's live health, return the corrected
|
|
49
|
+
// badge — or null when nothing changes. Conservative by construction: clears a
|
|
50
|
+
// degraded reason only when its live health signal now passes; flips a bare
|
|
51
|
+
// `error` add to `done` when the VM is reachable + serving; leaves any reason
|
|
52
|
+
// with no health signal intact (no false-green). The poll loop supplies the live
|
|
53
|
+
// facts (I/O at the edge) and applies the result; the verdict itself is pure.
|
|
54
|
+
// job: { state: 'done'|'error', degraded, degradedReasons }
|
|
55
|
+
// health: { reachable, editorHttpOk, providerAuthState }
|
|
56
|
+
// → null | { state, degraded, degradedReasons, transition }
|
|
57
|
+
function reconcileTerminalAddBadge({ job, health } = {}) {
|
|
58
|
+
if (!job || !health) return null;
|
|
59
|
+
const reachableServing = !!(health.reachable && health.editorHttpOk);
|
|
60
|
+
const authed = health.providerAuthState === 'authenticated';
|
|
61
|
+
|
|
62
|
+
// Bare `error` add (no degraded reasons): flip to `done` once the VM is
|
|
63
|
+
// reachable + serving — the VM3 case (a manual `cloud:up` recovered the editor
|
|
64
|
+
// outside the dashboard's view).
|
|
65
|
+
if (job.state === 'error') {
|
|
66
|
+
const reasons = job.degradedReasons || [];
|
|
67
|
+
if (reasons.length === 0 && reachableServing) {
|
|
68
|
+
return { state: 'done', degraded: false, degradedReasons: [], transition: 'error→done (editor 200)' };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Terminal `done` that is degraded: clear each reason whose live signal now
|
|
74
|
+
// passes; retain the rest. Drop `degraded` only when no reasons remain.
|
|
75
|
+
if (job.state === 'done' && job.degraded) {
|
|
76
|
+
const reasons = job.degradedReasons || [];
|
|
77
|
+
const remaining = reasons.filter((reason) => !reasonClearedByHealth(reason, { reachableServing, authed }));
|
|
78
|
+
if (remaining.length === reasons.length) return null; // nothing cleared → no-op
|
|
79
|
+
const cleared = reasons.length - remaining.length;
|
|
80
|
+
return {
|
|
81
|
+
state: 'done',
|
|
82
|
+
degraded: remaining.length > 0,
|
|
83
|
+
degradedReasons: remaining,
|
|
84
|
+
transition: remaining.length > 0
|
|
85
|
+
? `degraded: cleared ${cleared} reason(s), ${remaining.length} remain`
|
|
86
|
+
: 'degraded→clear (health restored)',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { parseRunningFleetVms, computeUnrostered, reconcileTerminalAddBadge };
|
package/npm/launch-config.js
CHANGED
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
// runs on a VM; the history is the cache that makes the last choice the default
|
|
9
9
|
// and past launches one click to re-run.
|
|
10
10
|
|
|
11
|
-
const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename } = require("./cloud");
|
|
11
|
+
const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } = require("./cloud");
|
|
12
|
+
|
|
13
|
+
/** The build agents a VM's editor terminal can run, as the keys of cloud.js's
|
|
14
|
+
* AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
|
|
15
|
+
* source of truth with the agent→API-key-env-var mapping (cloud.js) and the
|
|
16
|
+
* gcp-bootstrap CLI installer, so adding an agent is still a one-line change in
|
|
17
|
+
* AGENT_ENV_VARS rather than a list to keep in sync here. */
|
|
18
|
+
const AGENTS = Object.freeze(Object.keys(AGENT_ENV_VARS));
|
|
12
19
|
|
|
13
20
|
const EMPTY_CONFIG = Object.freeze({
|
|
14
21
|
clientRepo: "",
|
|
@@ -18,6 +25,9 @@ const EMPTY_CONFIG = Object.freeze({
|
|
|
18
25
|
newProjectName: "",
|
|
19
26
|
editorVariant: "dev",
|
|
20
27
|
editorBranch: "",
|
|
28
|
+
// Build agent provider the VM's editor terminal runs (claude | codex | gemini).
|
|
29
|
+
// Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag.
|
|
30
|
+
agent: "claude",
|
|
21
31
|
});
|
|
22
32
|
|
|
23
33
|
/** Coerce a raw form/query object into the canonical launch-config shape: trim
|
|
@@ -26,12 +36,17 @@ const EMPTY_CONFIG = Object.freeze({
|
|
|
26
36
|
function normalizeLaunchConfig(input = {}) {
|
|
27
37
|
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
28
38
|
const editorVariant = EDITOR_VARIANTS.includes(input.editorVariant) ? input.editorVariant : "dev";
|
|
39
|
+
// Coerce an unknown/absent agent to the default rather than rejecting — the
|
|
40
|
+
// form's agent selector only ever submits a known value, so an out-of-set
|
|
41
|
+
// agent is a stale query param, not an error.
|
|
42
|
+
const agent = AGENTS.includes(input.agent) ? input.agent : "claude";
|
|
29
43
|
return {
|
|
30
44
|
clientRepo: str(input.clientRepo),
|
|
31
45
|
clientBranch: str(input.clientBranch),
|
|
32
46
|
newProjectName: str(input.newProjectName),
|
|
33
47
|
editorVariant,
|
|
34
48
|
editorBranch: str(input.editorBranch),
|
|
49
|
+
agent,
|
|
35
50
|
};
|
|
36
51
|
}
|
|
37
52
|
|
|
@@ -111,13 +126,25 @@ function variantImageTag(variant) {
|
|
|
111
126
|
/** Build the `cloud:up` flag array for a launch config + instance name. The
|
|
112
127
|
* editor source is the canonical codeyam-editor repo (dev variant builds the
|
|
113
128
|
* image from editorBranch); the client repo/branch is what lands in
|
|
114
|
-
* /workspace.
|
|
115
|
-
|
|
129
|
+
* /workspace. The build agent rides on `cfg.agent` (normalizeLaunchConfig
|
|
130
|
+
* guarantees a known value, defaulting to claude), so a single config object
|
|
131
|
+
* carries every launch choice. Decoupled exactly as cloud.js consumes them. */
|
|
132
|
+
function cloudUpArgs(cfg, instanceName, project) {
|
|
116
133
|
const args = ["--instance-name", instanceName];
|
|
134
|
+
// Pass the authoritative GCP project (the dashboard's resolved PROJECT)
|
|
135
|
+
// explicitly so cloud:up never relies on the spawning shell's ambient
|
|
136
|
+
// `gcloud config get-value project`. Critically, a `--project` flag OVERRIDES
|
|
137
|
+
// a persisted .codeyam/cloud.json `project` (cloud.js merges
|
|
138
|
+
// `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
|
|
139
|
+
// gcloud fallback — the sticky-wrong-project failure mode where a stale
|
|
140
|
+
// "gcp-proj" in cloud.json wedges every re-provision with a firewall-create
|
|
141
|
+
// PERMISSION_DENIED. Omitted when unset to preserve the pre-existing arg shape
|
|
142
|
+
// (cloud.js then falls back to the gcloud default, as before).
|
|
143
|
+
if (project) args.push("--project", project);
|
|
117
144
|
// Greenfield (new folder) vs clone an existing repo.
|
|
118
145
|
if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
|
|
119
146
|
else args.push("--repo", cfg.clientRepo);
|
|
120
|
-
args.push("--agent", agent, "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
|
|
147
|
+
args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
|
|
121
148
|
if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
|
|
122
149
|
if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
|
|
123
150
|
// The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
|
|
@@ -237,6 +264,40 @@ function distinctConfigs(history, limit = 12) {
|
|
|
237
264
|
return out;
|
|
238
265
|
}
|
|
239
266
|
|
|
267
|
+
/** Display-side companion to `distinctConfigs`: derive the two label strings the
|
|
268
|
+
* "recent launches" picker renders for one config, so two configs that differ
|
|
269
|
+
* only by build agent (or greenfield project name) look different instead of
|
|
270
|
+
* collapsing to identical rows. `distinctConfigs` de-dups on the FULL config, so
|
|
271
|
+
* the rows are genuinely distinct launches — the old inline label just never
|
|
272
|
+
* surfaced the distinguishing fields (agent, greenfield name).
|
|
273
|
+
*
|
|
274
|
+
* Returns { primary, secondary }:
|
|
275
|
+
* - primary: greenfield (`!clientRepo && newProjectName`) → `new: <name>` so
|
|
276
|
+
* two different greenfield projects no longer both read `(none)`;
|
|
277
|
+
* else `repoBasename(clientRepo)` + (`@<branch>` when set); else
|
|
278
|
+
* `(none)` when there's neither a repo nor a project name.
|
|
279
|
+
* - secondary: `editor <variant>` + (`@<editorBranch>` when set) + ` · <agent>`.
|
|
280
|
+
*
|
|
281
|
+
* Normalizes through EMPTY_CONFIG so a pre-`agent` history entry still renders
|
|
282
|
+
* `· claude`. Reuses `repoBasename` (cloud.js) so server/browser basename
|
|
283
|
+
* semantics stay single-sourced. Pure; no I/O. */
|
|
284
|
+
function recentLaunchDisplay(config) {
|
|
285
|
+
const cfg = { ...EMPTY_CONFIG, ...(config || {}) };
|
|
286
|
+
let primary;
|
|
287
|
+
if (!cfg.clientRepo && cfg.newProjectName) {
|
|
288
|
+
primary = `new: ${cfg.newProjectName}`;
|
|
289
|
+
} else if (cfg.clientRepo) {
|
|
290
|
+
primary = repoBasename(cfg.clientRepo) + (cfg.clientBranch ? `@${cfg.clientBranch}` : "");
|
|
291
|
+
} else {
|
|
292
|
+
primary = "(none)";
|
|
293
|
+
}
|
|
294
|
+
const secondary =
|
|
295
|
+
`editor ${cfg.editorVariant || "dev"}` +
|
|
296
|
+
(cfg.editorBranch ? `@${cfg.editorBranch}` : "") +
|
|
297
|
+
` · ${cfg.agent || "claude"}`;
|
|
298
|
+
return { primary, secondary };
|
|
299
|
+
}
|
|
300
|
+
|
|
240
301
|
/** The default config to pre-fill the launch form: the most recent launch's
|
|
241
302
|
* config, or an empty config when there's no history. */
|
|
242
303
|
function defaultConfig(history) {
|
|
@@ -591,7 +652,48 @@ function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, edit
|
|
|
591
652
|
return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
|
|
592
653
|
}
|
|
593
654
|
|
|
655
|
+
/** Get authenticated Git URL for checking client branch. */
|
|
656
|
+
function getAuthRepoUrl(repo, token) {
|
|
657
|
+
if (!repo) return "";
|
|
658
|
+
if (!token) return repo;
|
|
659
|
+
const sshRegex = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/;
|
|
660
|
+
const sshMatch = repo.match(sshRegex);
|
|
661
|
+
if (sshMatch) {
|
|
662
|
+
return `https://x-access-token:${token}@github.com/${sshMatch[1]}/${sshMatch[2]}`;
|
|
663
|
+
}
|
|
664
|
+
if (repo.startsWith("https://github.com/")) {
|
|
665
|
+
return repo.replace(/^https:\/\/github\.com\//, `https://x-access-token:${token}@github.com/`);
|
|
666
|
+
}
|
|
667
|
+
return repo;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Interpret the output of git ls-remote to see if a branch exists. */
|
|
671
|
+
function interpretLsRemote({ stdout = "", code = 0, err = null, repo = "", branch = "" } = {}) {
|
|
672
|
+
const b = (branch || "").trim();
|
|
673
|
+
if (!b) {
|
|
674
|
+
return { ok: true, msg: "using repo default branch" };
|
|
675
|
+
}
|
|
676
|
+
if (err || code !== 0) {
|
|
677
|
+
const errMsg = (err && (err.message || String(err))) || "command failed";
|
|
678
|
+
return {
|
|
679
|
+
ok: false,
|
|
680
|
+
soft: true,
|
|
681
|
+
msg: `couldn't verify branch: ${errMsg.trim()}`
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
const trimmed = (stdout || "").trim();
|
|
685
|
+
if (trimmed) {
|
|
686
|
+
return { ok: true };
|
|
687
|
+
} else {
|
|
688
|
+
return {
|
|
689
|
+
ok: false,
|
|
690
|
+
msg: `branch '${b}' not found in ${repo || "repository"}`
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
594
695
|
module.exports = {
|
|
696
|
+
AGENTS,
|
|
595
697
|
EMPTY_CONFIG,
|
|
596
698
|
normalizeLaunchConfig,
|
|
597
699
|
validateLaunchConfig,
|
|
@@ -606,6 +708,7 @@ module.exports = {
|
|
|
606
708
|
queuedStatusForAction,
|
|
607
709
|
pushHistory,
|
|
608
710
|
distinctConfigs,
|
|
711
|
+
recentLaunchDisplay,
|
|
609
712
|
defaultConfig,
|
|
610
713
|
configForVm,
|
|
611
714
|
resolveCardIdentity,
|
|
@@ -619,4 +722,6 @@ module.exports = {
|
|
|
619
722
|
editorSourceShellResolve,
|
|
620
723
|
rebuildStrategy,
|
|
621
724
|
decideStartJob,
|
|
725
|
+
getAuthRepoUrl,
|
|
726
|
+
interpretLsRemote,
|
|
622
727
|
};
|
package/npm/scenario-check.js
CHANGED
|
@@ -275,6 +275,76 @@ function pushRedirectMismatchIssue(issues, requestedUrl, frame, response, config
|
|
|
275
275
|
);
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
// Read-only page-state snapshot for `capture-state`: the full localStorage
|
|
279
|
+
// map, a bounded sample of visible text nodes (document order), and — when a
|
|
280
|
+
// selector is given — that element's text. Evaluated in-page against the
|
|
281
|
+
// settled frame so it reflects exactly what a real capture saw (the proxy
|
|
282
|
+
// already injected the scenario's seed into the served HTML). Every read is
|
|
283
|
+
// individually guarded so a sandboxed/cross-origin localStorage never throws
|
|
284
|
+
// the whole capture; the worst case is an empty section, not a failure.
|
|
285
|
+
async function dumpPageState(frame, selector) {
|
|
286
|
+
return frame.evaluate((sel) => {
|
|
287
|
+
const localStorage = {};
|
|
288
|
+
try {
|
|
289
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
290
|
+
const key = window.localStorage.key(i);
|
|
291
|
+
if (key != null) localStorage[key] = window.localStorage.getItem(key);
|
|
292
|
+
}
|
|
293
|
+
} catch (_) {
|
|
294
|
+
/* localStorage may be unavailable (sandboxed/opaque origin) */
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const visibleText = [];
|
|
298
|
+
try {
|
|
299
|
+
// Reject text inside non-rendered tags (SCRIPT/STYLE/etc.) so an
|
|
300
|
+
// injected proxy script or inline CSS never masquerades as on-screen
|
|
301
|
+
// text — that noise is exactly what makes a state dump misleading.
|
|
302
|
+
const SKIP_TAGS = new Set([
|
|
303
|
+
"SCRIPT",
|
|
304
|
+
"STYLE",
|
|
305
|
+
"NOSCRIPT",
|
|
306
|
+
"TEMPLATE",
|
|
307
|
+
"HEAD",
|
|
308
|
+
"TITLE",
|
|
309
|
+
]);
|
|
310
|
+
const walker = document.createTreeWalker(
|
|
311
|
+
document.body,
|
|
312
|
+
NodeFilter.SHOW_TEXT,
|
|
313
|
+
{
|
|
314
|
+
acceptNode(node) {
|
|
315
|
+
const parent = node.parentElement;
|
|
316
|
+
if (parent && SKIP_TAGS.has(parent.tagName)) {
|
|
317
|
+
return NodeFilter.FILTER_REJECT;
|
|
318
|
+
}
|
|
319
|
+
return (node.textContent || "").trim()
|
|
320
|
+
? NodeFilter.FILTER_ACCEPT
|
|
321
|
+
: NodeFilter.FILTER_REJECT;
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
let node;
|
|
326
|
+
while ((node = walker.nextNode()) && visibleText.length < 40) {
|
|
327
|
+
const text = (node.textContent || "").replace(/\s+/g, " ").trim();
|
|
328
|
+
if (text) visibleText.push(text);
|
|
329
|
+
}
|
|
330
|
+
} catch (_) {
|
|
331
|
+
/* no body / detached document */
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let selectorText = null;
|
|
335
|
+
if (sel) {
|
|
336
|
+
try {
|
|
337
|
+
const el = document.querySelector(sel);
|
|
338
|
+
if (el) selectorText = (el.textContent || "").replace(/\s+/g, " ").trim();
|
|
339
|
+
} catch (_) {
|
|
340
|
+
/* invalid selector — leave selectorText null */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { localStorage, visibleText, selectorText };
|
|
345
|
+
}, selector || null);
|
|
346
|
+
}
|
|
347
|
+
|
|
278
348
|
// `preflight` is injectable (defaulting to the real app-port reachability
|
|
279
349
|
// check) so unit tests that mock the browser can stay network-free.
|
|
280
350
|
async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) {
|
|
@@ -460,13 +530,22 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
|
|
|
460
530
|
await page.screenshot({ path: outputPath, fullPage: false });
|
|
461
531
|
}
|
|
462
532
|
|
|
463
|
-
|
|
533
|
+
const result = buildResult({
|
|
464
534
|
loaded,
|
|
465
535
|
hasContent,
|
|
466
536
|
issues,
|
|
467
537
|
outputPath,
|
|
468
538
|
url: frame.url() || url,
|
|
469
539
|
});
|
|
540
|
+
|
|
541
|
+
// `capture-state` mode: attach the read-only page-state snapshot so the
|
|
542
|
+
// backend can report localStorage + rendered text. Off by default, so a
|
|
543
|
+
// normal error-check capture is byte-for-byte unchanged.
|
|
544
|
+
if (config.captureState) {
|
|
545
|
+
result.state = await dumpPageState(frame, config.stateSelector);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return result;
|
|
470
549
|
} catch (error) {
|
|
471
550
|
pushIssue(
|
|
472
551
|
issues,
|
|
@@ -506,6 +585,7 @@ async function main() {
|
|
|
506
585
|
|
|
507
586
|
module.exports = {
|
|
508
587
|
runScenarioCheck,
|
|
588
|
+
dumpPageState,
|
|
509
589
|
readStackLoadingMarkers,
|
|
510
590
|
applyBrowserState,
|
|
511
591
|
isCrossOriginRequest,
|
package/npm/scenario-handlers.js
CHANGED
|
@@ -48,6 +48,22 @@ function handleConsoleMessage(message) {
|
|
|
48
48
|
return null;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Ignore the browser's blocked-script warning for sandboxed mockup-preview
|
|
52
|
+
// frames. Mockup previews render untrusted AI-generated HTML inside a
|
|
53
|
+
// `sandbox=""` iframe; the HTML-injection proxy injects an error-capture
|
|
54
|
+
// <script> tag, which the browser then refuses to run, emitting
|
|
55
|
+
// "Blocked script execution ... because the frame is sandboxed". That block
|
|
56
|
+
// is the capture's own injected script being denied — benign for capture
|
|
57
|
+
// purposes. Match narrowly on BOTH the block phrase and the "sandboxed"
|
|
58
|
+
// signature so a genuine non-sandbox CSP block ("Blocked script execution"
|
|
59
|
+
// without "sandboxed") still surfaces as a real issue.
|
|
60
|
+
if (
|
|
61
|
+
text.includes("Blocked script execution") &&
|
|
62
|
+
text.includes("sandboxed")
|
|
63
|
+
) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
return createIssue("console", text);
|
|
52
68
|
}
|
|
53
69
|
|