@codeyam-editor/codeyam-editor 0.1.0-staging.01efa1a
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/cli.js +19 -0
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +2184 -0
- package/npm/container.js +54 -0
- package/npm/editor-dev.js +459 -0
- package/npm/editor.js +272 -0
- package/npm/empty-project-dir.js +33 -0
- package/npm/fleet-auth.js +111 -0
- package/npm/fleet-branch.js +49 -0
- package/npm/fleet-broker-death.js +44 -0
- package/npm/fleet-broker-debounce.js +63 -0
- package/npm/fleet-broker-status.js +45 -0
- package/npm/fleet-build-data.js +81 -0
- package/npm/fleet-cleanup.js +297 -0
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-env.js +125 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +83 -0
- package/npm/fleet-memory.js +157 -0
- package/npm/fleet-plans.js +96 -0
- package/npm/fleet-pool-policy.js +78 -0
- package/npm/fleet-pool.js +401 -0
- package/npm/fleet-queue.js +40 -0
- package/npm/fleet-reconcile.js +93 -0
- package/npm/fleet-reset.js +222 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +717 -0
- package/npm/open.js +15 -0
- package/npm/preflight-guidance.js +77 -0
- package/npm/scenario-check.js +604 -0
- package/npm/scenario-handlers.js +82 -0
- package/npm/scenario-interactivity.js +245 -0
- package/npm/scenario-issues.js +35 -0
- package/npm/scenario-metrics.js +108 -0
- package/npm/scenario-mocks.js +107 -0
- package/npm/scenario-playwright.js +534 -0
- package/npm/utils.js +823 -0
- package/npm/vm-urls.js +117 -0
- package/package.json +32 -0
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-By-2rl7D.js +187 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-Cg8zOFNZ.js +5 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-WOUYbba7.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CBTlIVPs.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-DzoF9RKw.js +27 -0
- package/ui/dist/assets/index-ckz94rKI.css +1 -0
- package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
- package/ui/dist/assets/react-CSS0HapR.js +28 -0
- package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
- package/ui/dist/assets/xterm--24IGk-x.js +9 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.png +0 -0
- package/ui/dist/index.html +16 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Pure helpers for the fleet-dashboard's parametrized launch + cached history.
|
|
3
|
+
//
|
|
4
|
+
// Dependency-free and unit-tested (npm/launch-config.test.js) so the dashboard
|
|
5
|
+
// server (scripts/operator/fleet-dashboard/server.js) can require them without
|
|
6
|
+
// pulling logic into its poll/HTTP path. A "launch config" is the operator's
|
|
7
|
+
// choice of WHICH client project (repo/branch) + WHICH editor (variant/branch)
|
|
8
|
+
// runs on a VM; the history is the cache that makes the last choice the default
|
|
9
|
+
// and past launches one click to re-run.
|
|
10
|
+
|
|
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));
|
|
19
|
+
|
|
20
|
+
const EMPTY_CONFIG = Object.freeze({
|
|
21
|
+
clientRepo: "",
|
|
22
|
+
clientBranch: "",
|
|
23
|
+
// Greenfield: when set (and clientRepo empty), launch against a NEW empty
|
|
24
|
+
// folder of this name instead of cloning a repo.
|
|
25
|
+
newProjectName: "",
|
|
26
|
+
editorVariant: "dev",
|
|
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",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Coerce a raw form/query object into the canonical launch-config shape: trim
|
|
34
|
+
* strings, default the variant to a known value. Never throws — validation is
|
|
35
|
+
* a separate, explicit step. */
|
|
36
|
+
function normalizeLaunchConfig(input = {}) {
|
|
37
|
+
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
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";
|
|
43
|
+
return {
|
|
44
|
+
clientRepo: str(input.clientRepo),
|
|
45
|
+
clientBranch: str(input.clientBranch),
|
|
46
|
+
newProjectName: str(input.newProjectName),
|
|
47
|
+
editorVariant,
|
|
48
|
+
editorBranch: str(input.editorBranch),
|
|
49
|
+
agent,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Validate a normalized config. Returns { ok, msg } so the HTTP layer can
|
|
54
|
+
* surface the reason without a try/catch. */
|
|
55
|
+
function validateLaunchConfig(cfg) {
|
|
56
|
+
const hasRepo = !!(cfg && cfg.clientRepo);
|
|
57
|
+
const hasNew = !!(cfg && cfg.newProjectName);
|
|
58
|
+
if (hasRepo && hasNew) {
|
|
59
|
+
return { ok: false, msg: "specify either a client repo or a new-project name, not both" };
|
|
60
|
+
}
|
|
61
|
+
if (!hasRepo && !hasNew) {
|
|
62
|
+
return { ok: false, msg: "a client repo or a new-project name is required" };
|
|
63
|
+
}
|
|
64
|
+
if (hasRepo && !/^(https?:\/\/|git@)/.test(cfg.clientRepo)) {
|
|
65
|
+
return { ok: false, msg: "client repo must be an https:// or git@ URL" };
|
|
66
|
+
}
|
|
67
|
+
if (hasNew && !/^[A-Za-z0-9._-]+$/.test(cfg.newProjectName)) {
|
|
68
|
+
return { ok: false, msg: "new-project name may contain only letters, numbers, dot, dash, underscore" };
|
|
69
|
+
}
|
|
70
|
+
if (!EDITOR_VARIANTS.includes(cfg.editorVariant)) {
|
|
71
|
+
return { ok: false, msg: `unknown editor variant: ${cfg.editorVariant}` };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Gate mirror of cloud.js::assertVariantProvisionable — which editor variants
|
|
77
|
+
* can be provisioned onto a cloud VM. All three variants now provision: `dev`
|
|
78
|
+
* builds the editor image from source, while `staging`/`production` run the
|
|
79
|
+
* published npm package (gcp-bootstrap pulls/builds Dockerfile.npm keyed off
|
|
80
|
+
* the variant). An unknown variant is excluded so a typo never reaches a
|
|
81
|
+
* launch. Pure; no I/O. */
|
|
82
|
+
function isVariantProvisionable(variant) {
|
|
83
|
+
return EDITOR_VARIANTS.includes(variant);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Gate for the in-place BINARY-REBUILD operations (the dashboard's "Rebuild
|
|
87
|
+
* binary" / "Update & Reset Everything" actions, and reconfigure's trailing
|
|
88
|
+
* binary refresh). Historically distinct from `isVariantProvisionable` because a
|
|
89
|
+
* packaged variant's rebuild is an npm-reinstall (rebuildStrategy ===
|
|
90
|
+
* "npm-reinstall") and that host-script path was not wired — so this stayed
|
|
91
|
+
* dev-only and packaged rebuild buttons failed loud. With the npm-reinstall
|
|
92
|
+
* branch now wired into `rebuildBinaryHostScript` (it rebuilds the Dockerfile.npm
|
|
93
|
+
* image at the variant's dist-tag instead of source-building Dockerfile), every
|
|
94
|
+
* provisionable variant is also rebuildable, so this now mirrors
|
|
95
|
+
* `isVariantProvisionable` — only an unknown variant is rejected. Pure; no I/O. */
|
|
96
|
+
function isVariantRebuildable(variant) {
|
|
97
|
+
return isVariantProvisionable(variant);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Map an editor variant to the npm dist-tag its package was published under.
|
|
101
|
+
* CI publishes `codeyam.com` → "latest" (production) and `staging.codeyam.com`
|
|
102
|
+
* → "staging" (see .github/workflows/cicd.yml "Set release variant"). `dev` has
|
|
103
|
+
* no published package (it builds the image from source), so return null and let
|
|
104
|
+
* callers reject it. This is the single source of truth for the variant→dist-tag
|
|
105
|
+
* contract — the Dockerfile.npm build-arg, publish-editor-image.sh's `--from-npm`
|
|
106
|
+
* mode, and the future reconfigure path all derive from it so they can't drift.
|
|
107
|
+
* Pure; no I/O. */
|
|
108
|
+
function variantNpmDistTag(variant) {
|
|
109
|
+
if (variant === "production") return "latest";
|
|
110
|
+
if (variant === "staging") return "staging";
|
|
111
|
+
return null; // dev / unknown — no packaged dist-tag
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Map a packaged editor variant to the GHCR image-tag suffix the packaged
|
|
115
|
+
* image is pushed under (`<registry>/codeyam-editor-npm:<suffix>`). Lives beside
|
|
116
|
+
* `variantNpmDistTag` so the registry naming convention and the dist-tag mapping
|
|
117
|
+
* stay one reviewable unit; `scripts/publish-editor-image.sh --from-npm` asserts
|
|
118
|
+
* the literal string this returns. `dev` has no packaged image (built from
|
|
119
|
+
* source), so return null. Pure; no I/O. */
|
|
120
|
+
function variantImageTag(variant) {
|
|
121
|
+
if (variant === "production") return "production-latest";
|
|
122
|
+
if (variant === "staging") return "staging-latest";
|
|
123
|
+
return null; // dev / unknown — no packaged image
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Build the `cloud:up` flag array for a launch config + instance name. The
|
|
127
|
+
* editor source is the canonical codeyam-editor repo (dev variant builds the
|
|
128
|
+
* image from editorBranch); the client repo/branch is what lands in
|
|
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) {
|
|
133
|
+
const args = ["--instance-name", instanceName];
|
|
134
|
+
// Greenfield (new folder) vs clone an existing repo.
|
|
135
|
+
if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
|
|
136
|
+
else args.push("--repo", cfg.clientRepo);
|
|
137
|
+
args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
|
|
138
|
+
if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
|
|
139
|
+
if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
|
|
140
|
+
// The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
|
|
141
|
+
// it lets cloud:up exit cleanly and avoids parallel batches colliding on the
|
|
142
|
+
// fixed local ports 14199/5173 (the cause of multi-add "false failures").
|
|
143
|
+
args.push("--no-tunnel");
|
|
144
|
+
return args;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Classify one container-up wait tick into a terminal/continue decision from
|
|
148
|
+
* what's been observed so far. This is the race-semantics seam fragility #33
|
|
149
|
+
* got wrong (`scripts/operator/fleet-dashboard/server.js::addVm`): the wait
|
|
150
|
+
* must poll container status on its own cadence and treat ANY single `Up` as
|
|
151
|
+
* terminal success.
|
|
152
|
+
*
|
|
153
|
+
* Rules:
|
|
154
|
+
* - everSeenUp ⇒ "up" any single `Up` is terminal —
|
|
155
|
+
* transient probe blips before it
|
|
156
|
+
* are irrelevant.
|
|
157
|
+
* - instanceGone ⇒ "failed" fast-bail: the VM is gone, no
|
|
158
|
+
* point waiting out the deadline.
|
|
159
|
+
* - deadlineSec>0 & elapsed>=it ⇒ "failed" genuine timeout, no `Up` ever seen
|
|
160
|
+
* (a non-positive deadline means no
|
|
161
|
+
* deadline set yet, so never fail).
|
|
162
|
+
* - otherwise ⇒ "waiting"
|
|
163
|
+
*
|
|
164
|
+
* Deliberately NOT an input: whether `cloud:up` is still alive (its `UP_PID`).
|
|
165
|
+
* Under `--no-tunnel` cloud:up exits cleanly the instant the container is up
|
|
166
|
+
* (see cloudUpArgs), so its death is the NORMAL success path and must never
|
|
167
|
+
* decide failure. The bash loop mirrors this — `kill -0 $UP_PID` is logged as
|
|
168
|
+
* informational only. Callers may pass `upPidAlive` for readable test intent;
|
|
169
|
+
* it is ignored by design. */
|
|
170
|
+
function classifyContainerWait({ everSeenUp = false, instanceGone = false, elapsedSec = 0, deadlineSec = 0 } = {}) {
|
|
171
|
+
if (everSeenUp) return "up";
|
|
172
|
+
if (instanceGone) return "failed";
|
|
173
|
+
if (deadlineSec > 0 && elapsedSec >= deadlineSec) return "failed";
|
|
174
|
+
return "waiting";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Decide whether a newly-reserved Add slot should start immediately or be
|
|
178
|
+
* queued, given the current in-flight count + the configured cap. Pure: pulls
|
|
179
|
+
* no globals, has no side effects.
|
|
180
|
+
*
|
|
181
|
+
* Returns "start" when there is headroom under maxConcurrency; "queue"
|
|
182
|
+
* otherwise. A non-positive or non-numeric maxConcurrency falls back to
|
|
183
|
+
* unbounded ("start" always) — the historical behavior before throttling
|
|
184
|
+
* existed, so a misconfigured env var degrades to "no throttle" instead of
|
|
185
|
+
* silently freezing all adds.
|
|
186
|
+
*
|
|
187
|
+
* The caller is responsible for the side effects: pushing to a pending
|
|
188
|
+
* queue + posting a `queued` job entry on "queue", or starting addVm on
|
|
189
|
+
* "start". Decoupling the decision from the I/O makes it unit-testable
|
|
190
|
+
* against arbitrary (cap, active) tuples — the test signal the OOM bug
|
|
191
|
+
* needed and the queue never had. */
|
|
192
|
+
function decideAddSlot({ maxConcurrency, activeCount = 0 } = {}) {
|
|
193
|
+
const cap = Number(maxConcurrency);
|
|
194
|
+
if (!Number.isFinite(cap) || cap <= 0) return "start";
|
|
195
|
+
return Number(activeCount) < cap ? "start" : "queue";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Closed set of "heavy" action names — those that invoke `npm run cloud:up`
|
|
199
|
+
* (over gcloud-ssh) and so contend for the shared spawn budget that prevents
|
|
200
|
+
* the dashboard's launchd-managed node process from OOMing under burst. The
|
|
201
|
+
* set is the single source of truth shared between server.js's throttle gate
|
|
202
|
+
* (tryStartOrQueue) and the per-action queued-status mapping below; adding a
|
|
203
|
+
* new heavy action means editing both this set and queuedStatusForAction.
|
|
204
|
+
* Other actions (set-env, run-setup, reset-*, update-*, destroy) are a single
|
|
205
|
+
* ssh round-trip with no long-running child and bypass the throttle entirely
|
|
206
|
+
* — they can never OOM the dashboard on burst. */
|
|
207
|
+
const HEAVY_ACTIONS = Object.freeze(["add", "rebuild", "reconfigure", "reset-all", "fleet-ready"]);
|
|
208
|
+
|
|
209
|
+
/** Map a heavy action to its per-action "queued" UI status string. Used by
|
|
210
|
+
* server.js's deriveStatus to render the throttled-but-not-yet-started card
|
|
211
|
+
* with a status that matches the eventual running status family
|
|
212
|
+
* (add → add-queued / adding, rebuild → rebuild-queued / rebuilding,
|
|
213
|
+
* reconfigure → reconfigure-queued / reconfiguring). Returns null for any
|
|
214
|
+
* action not in HEAVY_ACTIONS (so the caller can fall through to the
|
|
215
|
+
* generic running-state branch). Pure; no I/O. */
|
|
216
|
+
function queuedStatusForAction(action) {
|
|
217
|
+
switch (action) {
|
|
218
|
+
case "add": return "add-queued";
|
|
219
|
+
case "rebuild": return "rebuild-queued";
|
|
220
|
+
case "reconfigure": return "reconfigure-queued";
|
|
221
|
+
case "reset-all": return "reset-all-queued";
|
|
222
|
+
// fleet-ready runs cloud:up/redeploy children, and deriveStatus already
|
|
223
|
+
// renders a *running* fleet-ready job as "rebuilding"; its queued card
|
|
224
|
+
// mirrors that same family rather than introducing an unstyled
|
|
225
|
+
// fleet-ready-queued status the dashboard CSS has no class for.
|
|
226
|
+
case "fleet-ready": return "rebuild-queued";
|
|
227
|
+
default: return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Push a launch entry onto the newest-first history, capped to `cap`. No
|
|
232
|
+
* cross-VM dedup at write time — the full log is what powers per-VM config
|
|
233
|
+
* lookups (configForVm); the "recent launches" picker de-dups at read time
|
|
234
|
+
* (distinctConfigs). Returns a new array; does not mutate the input. */
|
|
235
|
+
function pushHistory(history, entry, cap = 30) {
|
|
236
|
+
const arr = Array.isArray(history) ? history.slice() : [];
|
|
237
|
+
arr.unshift(entry);
|
|
238
|
+
return arr.slice(0, Math.max(1, cap));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Distinct configs, newest-first, for the "recent launches" picker (so the
|
|
242
|
+
* same config launched on several VMs shows once — the most-recent
|
|
243
|
+
* occurrence). */
|
|
244
|
+
function distinctConfigs(history, limit = 12) {
|
|
245
|
+
const seen = new Set();
|
|
246
|
+
const out = [];
|
|
247
|
+
for (const e of Array.isArray(history) ? history : []) {
|
|
248
|
+
const k = JSON.stringify(e && e.config);
|
|
249
|
+
if (seen.has(k)) continue;
|
|
250
|
+
seen.add(k);
|
|
251
|
+
out.push(e);
|
|
252
|
+
if (out.length >= limit) break;
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Display-side companion to `distinctConfigs`: derive the two label strings the
|
|
258
|
+
* "recent launches" picker renders for one config, so two configs that differ
|
|
259
|
+
* only by build agent (or greenfield project name) look different instead of
|
|
260
|
+
* collapsing to identical rows. `distinctConfigs` de-dups on the FULL config, so
|
|
261
|
+
* the rows are genuinely distinct launches — the old inline label just never
|
|
262
|
+
* surfaced the distinguishing fields (agent, greenfield name).
|
|
263
|
+
*
|
|
264
|
+
* Returns { primary, secondary }:
|
|
265
|
+
* - primary: greenfield (`!clientRepo && newProjectName`) → `new: <name>` so
|
|
266
|
+
* two different greenfield projects no longer both read `(none)`;
|
|
267
|
+
* else `repoBasename(clientRepo)` + (`@<branch>` when set); else
|
|
268
|
+
* `(none)` when there's neither a repo nor a project name.
|
|
269
|
+
* - secondary: `editor <variant>` + (`@<editorBranch>` when set) + ` · <agent>`.
|
|
270
|
+
*
|
|
271
|
+
* Normalizes through EMPTY_CONFIG so a pre-`agent` history entry still renders
|
|
272
|
+
* `· claude`. Reuses `repoBasename` (cloud.js) so server/browser basename
|
|
273
|
+
* semantics stay single-sourced. Pure; no I/O. */
|
|
274
|
+
function recentLaunchDisplay(config) {
|
|
275
|
+
const cfg = { ...EMPTY_CONFIG, ...(config || {}) };
|
|
276
|
+
let primary;
|
|
277
|
+
if (!cfg.clientRepo && cfg.newProjectName) {
|
|
278
|
+
primary = `new: ${cfg.newProjectName}`;
|
|
279
|
+
} else if (cfg.clientRepo) {
|
|
280
|
+
primary = repoBasename(cfg.clientRepo) + (cfg.clientBranch ? `@${cfg.clientBranch}` : "");
|
|
281
|
+
} else {
|
|
282
|
+
primary = "(none)";
|
|
283
|
+
}
|
|
284
|
+
const secondary =
|
|
285
|
+
`editor ${cfg.editorVariant || "dev"}` +
|
|
286
|
+
(cfg.editorBranch ? `@${cfg.editorBranch}` : "") +
|
|
287
|
+
` · ${cfg.agent || "claude"}`;
|
|
288
|
+
return { primary, secondary };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The default config to pre-fill the launch form: the most recent launch's
|
|
292
|
+
* config, or an empty config when there's no history. */
|
|
293
|
+
function defaultConfig(history) {
|
|
294
|
+
const h = Array.isArray(history) && history.length ? history[0] : null;
|
|
295
|
+
return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : { ...EMPTY_CONFIG };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The most recent config used on a specific VM number (for the per-card
|
|
299
|
+
* Reconfigure form + the card's project/variant display). null when unknown
|
|
300
|
+
* (e.g. a VM launched before this feature existed). */
|
|
301
|
+
function configForVm(history, n) {
|
|
302
|
+
const h = (Array.isArray(history) ? history : []).find((e) => e && e.vm === n);
|
|
303
|
+
return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Resolve the card's display identity, preferring LIVE truth (the editor's
|
|
307
|
+
* `/api/session-info` `clientProject` + `editorIdentity`) over STALE launch
|
|
308
|
+
* history. This is the fix for fragility #33's lying label: a CLI branch
|
|
309
|
+
* switch (`git checkout -B … && rebuild-self`) never updates launch history,
|
|
310
|
+
* so a history-sourced label keeps showing the old branch — but the editor's
|
|
311
|
+
* live self-report tracks the actual branch.
|
|
312
|
+
*
|
|
313
|
+
* `live` is the polled VM object ({ clientProject?, editorIdentity? }); an
|
|
314
|
+
* older VM whose editor predates this feature reports neither, so the result
|
|
315
|
+
* falls back to `historyCfg` (a launch-config shape) field-by-field and
|
|
316
|
+
* `source` flags the card as showing inferred-not-live data.
|
|
317
|
+
*
|
|
318
|
+
* Returns { project, clientBranch, editorLabel, source }:
|
|
319
|
+
* - project bare project name (no @branch) — live name wins, else the
|
|
320
|
+
* history repo basename / new-project name.
|
|
321
|
+
* - clientBranch live client branch wins, else historyCfg.clientBranch; null
|
|
322
|
+
* when neither (e.g. a non-git client project).
|
|
323
|
+
* - editorLabel from editorIdentity — the branch when kind === "branch",
|
|
324
|
+
* "npm v<version>" when kind === "npm"; else the history
|
|
325
|
+
* variant(@branch) fallback.
|
|
326
|
+
* - source "live" when editorIdentity drove editorLabel, else
|
|
327
|
+
* "history" — the affordance the card uses to mark inferred
|
|
328
|
+
* data. Pure; no I/O. */
|
|
329
|
+
function resolveCardIdentity(live, historyCfg) {
|
|
330
|
+
const cfg = historyCfg || {};
|
|
331
|
+
const lcp = live && live.clientProject;
|
|
332
|
+
const lei = live && live.editorIdentity;
|
|
333
|
+
|
|
334
|
+
const historyName = cfg.clientRepo ? repoBasename(cfg.clientRepo) : (cfg.newProjectName || "");
|
|
335
|
+
const project = (lcp && lcp.name) || historyName || null;
|
|
336
|
+
|
|
337
|
+
const clientBranch = (lcp && lcp.branch) || cfg.clientBranch || null;
|
|
338
|
+
|
|
339
|
+
let editorLabel;
|
|
340
|
+
let source;
|
|
341
|
+
if (lei && lei.kind === "branch" && lei.branch) {
|
|
342
|
+
editorLabel = lei.branch;
|
|
343
|
+
source = "live";
|
|
344
|
+
} else if (lei && lei.kind === "npm") {
|
|
345
|
+
editorLabel = "npm v" + (lei.version || "?");
|
|
346
|
+
source = "live";
|
|
347
|
+
} else {
|
|
348
|
+
const variant = cfg.editorVariant || "dev";
|
|
349
|
+
editorLabel = variant + (cfg.editorBranch ? "@" + cfg.editorBranch : "");
|
|
350
|
+
source = "history";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { project, clientBranch, editorLabel, source };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Sticky-merge a VM poll's `clientProject` against the last-known-good value
|
|
357
|
+
* so a transient null poll (timeout / partial probe) does not clobber a
|
|
358
|
+
* populated identity. The live `clientProject.branch` is the fleet
|
|
359
|
+
* classifier's first-choice branch source (`plan_actions` in
|
|
360
|
+
* fleet-roll-to-branch.sh); dropping it to null on a missed cycle would
|
|
361
|
+
* regress classification to the stale launch-history branch. Mirrors how the
|
|
362
|
+
* other live fields tolerate a missed poll: a populated `prev` survives a
|
|
363
|
+
* null/absent fresh poll, and a populated fresh poll always wins. Pure; no I/O.
|
|
364
|
+
*
|
|
365
|
+
* @param prev the previous `state.vms[n].clientProject` (null/undefined ok)
|
|
366
|
+
* @param info the fresh poll result object (null when the probe failed)
|
|
367
|
+
* @returns the clientProject to store: fresh value when present, else the
|
|
368
|
+
* last-known-good `prev`, else null. */
|
|
369
|
+
function stickyClientProject(prev, info) {
|
|
370
|
+
const fresh = info && info.clientProject;
|
|
371
|
+
if (fresh) return fresh;
|
|
372
|
+
return prev || null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
|
|
376
|
+
* identity reported in `/api/session-info` (`clientProject.repo`), with
|
|
377
|
+
* launch-history `clientRepo` as a fallback when live is absent. A guest is
|
|
378
|
+
* any VM whose repo basename differs from `CANONICAL_EDITOR_REPO`'s
|
|
379
|
+
* (`codeyam-editor`) — Margo today, future guests tomorrow.
|
|
380
|
+
*
|
|
381
|
+
* The dashboard surfaces the result with a small `🛡 guest` chip so the
|
|
382
|
+
* "leave-the-margo-one-alone"-style caveats become a visible guardrail at
|
|
383
|
+
* glance instead of a memo the operator has to remember. The kind is a
|
|
384
|
+
* *type*, not an alert — the status badge remains the primary signal.
|
|
385
|
+
*
|
|
386
|
+
* Defaults: both signals missing → `"self-host"` (the dominant fleet shape;
|
|
387
|
+
* a falsely visible "guest" badge on a real self-host VM would be more
|
|
388
|
+
* confusing than a missing one). Empty-string repos collapse to the same
|
|
389
|
+
* unknown-→-self-host default. Reuses `CANONICAL_EDITOR_REPO` so the
|
|
390
|
+
* self-host identity stays a single source of truth shared with
|
|
391
|
+
* `cloudUpArgs`. Pure; no I/O. */
|
|
392
|
+
function classifyProjectKind(live, historyCfg) {
|
|
393
|
+
const cfg = historyCfg || {};
|
|
394
|
+
const lcp = live && live.clientProject;
|
|
395
|
+
const liveRepo = lcp && typeof lcp.repo === "string" ? lcp.repo : "";
|
|
396
|
+
const repoUrl = liveRepo || (typeof cfg.clientRepo === "string" ? cfg.clientRepo : "");
|
|
397
|
+
if (!repoUrl) return "self-host";
|
|
398
|
+
const canonical = repoBasename(CANONICAL_EDITOR_REPO);
|
|
399
|
+
return repoBasename(repoUrl) === canonical ? "self-host" : "guest";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Resolve where a VM's codeyam-editor SOURCE lives, given its shape. This is
|
|
403
|
+
* the load-bearing per-VM-shape branch for the dashboard's "update editor
|
|
404
|
+
* source" + "rebuild binary" actions: the editor source — and how you rebuild
|
|
405
|
+
* from it — differs between a self-host VM (the client project IS
|
|
406
|
+
* codeyam-editor) and a non-self-host one (editing some other project).
|
|
407
|
+
*
|
|
408
|
+
* - Self-host: the editor source IS the agent's workspace, so it's `/workspace`
|
|
409
|
+
* INSIDE the container; the rebuild runs there in place (`rebuild-self`).
|
|
410
|
+
* - Non-self-host: the editor binary comes from the `codeyam-editor:local`
|
|
411
|
+
* image, built on the VM HOST. `scripts/gcp-bootstrap.sh` clones the editor
|
|
412
|
+
* repo to `$HOME/codeyam-editor` and builds the image from there, so that is
|
|
413
|
+
* the authoritative image source. A self-host VM ALSO carries a creds-bearing
|
|
414
|
+
* workspace clone at `$HOME/projects/codeyam-editor` (docs/cloud-editor-stability.md
|
|
415
|
+
* "VM-side paths"); the image-rebuild flow prefers it WHEN PRESENT (it can
|
|
416
|
+
* `git pull` private repos), else falls back to the canonical image clone.
|
|
417
|
+
* The caller checks `path` then `fallbackPath` on disk at runtime.
|
|
418
|
+
*
|
|
419
|
+
* Stack assumption: this encodes the cloud-VM editor-source layout (Docker
|
|
420
|
+
* image + ~/projects mount). A non-Docker editor host would need its own
|
|
421
|
+
* resolver branch. Pure; no I/O. */
|
|
422
|
+
function resolveEditorSource({ selfHost = false, home = "$HOME" } = {}) {
|
|
423
|
+
if (selfHost) {
|
|
424
|
+
return { selfHost: true, location: "container", path: "/workspace", fallbackPath: null };
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
selfHost: false,
|
|
428
|
+
location: "host",
|
|
429
|
+
path: `${home}/projects/codeyam-editor`,
|
|
430
|
+
fallbackPath: `${home}/codeyam-editor`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Emit the shell snippet that resolves the host editor-source clone ON THE
|
|
435
|
+
* REMOTE VM, assigning the chosen path to `$ESRC`. Prefer the credentialed
|
|
436
|
+
* projects clone (`src.path`); fall back to the image clone (`src.fallbackPath`)
|
|
437
|
+
* only when the primary has no `.git`.
|
|
438
|
+
*
|
|
439
|
+
* CRITICAL — the paths must EXPAND on the VM, not on the laptop. `resolveEditorSource`
|
|
440
|
+
* returns `$HOME`-relative CONSTANTS (the dashboard doesn't know the VM's home dir),
|
|
441
|
+
* so they go into DOUBLE quotes here, never `shq()`/single-quotes. Single-quoting a
|
|
442
|
+
* `$HOME`-bearing path defeats remote expansion: `cd "$ESRC"` then fails with
|
|
443
|
+
* `cd: $HOME/...: No such file or directory` on every non-self-host VM (the improve34
|
|
444
|
+
* bug). These paths are fixed constants, not user input — no injection surface, so
|
|
445
|
+
* `shq()` (which exists to neutralize user input) is the wrong tool. Pure; no I/O.
|
|
446
|
+
*
|
|
447
|
+
* The primary clone is kept only when it is actually USABLE as an editor source,
|
|
448
|
+
* not merely when a `.git` directory exists. A half-cloned / corrupt tree has a
|
|
449
|
+
* `.git` dir yet can't build — `git rev-parse` fails and there's no Dockerfile, so
|
|
450
|
+
* `docker build "$ESRC"` dies on `open Dockerfile: no such file or directory` (the
|
|
451
|
+
* improve39 VM-9 bug). The gate therefore requires a valid work tree AND a
|
|
452
|
+
* Dockerfile; otherwise it falls back to the canonical image clone
|
|
453
|
+
* `$HOME/codeyam-editor` (the authoritative source provisioning built the image
|
|
454
|
+
* from). This preserves the prefer-credentialed-clone behavior, but only when the
|
|
455
|
+
* credentialed clone actually works. */
|
|
456
|
+
function editorSourceShellResolve(src) {
|
|
457
|
+
const primary = `ESRC="${src.path}"`;
|
|
458
|
+
if (!src.fallbackPath) return primary;
|
|
459
|
+
return `${primary}; { git -C "$ESRC" rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -f "$ESRC/Dockerfile" ]; } || ESRC="${src.fallbackPath}"`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Choose HOW to rebuild the editor binary on a VM, from its shape + the editor
|
|
463
|
+
* variant. The three mechanisms map 1:1 to the EDITOR_VARIANTS contract:
|
|
464
|
+
* - "rebuild-self" — self-host `dev`: compile in place inside the container
|
|
465
|
+
* (`rebuild-self --force-in-container`); /workspace is the
|
|
466
|
+
* editor source.
|
|
467
|
+
* - "image-rebuild" — non-self-host `dev`: the binary ships in the
|
|
468
|
+
* `codeyam-editor:local` image, so it needs a host-side
|
|
469
|
+
* `docker build` + container recreate.
|
|
470
|
+
* - "npm-reinstall" — a packaged variant (`staging`/`production`): the binary
|
|
471
|
+
* is the published editor npm package, so a rebuild is a
|
|
472
|
+
* reinstall, not a compile.
|
|
473
|
+
* Pure; no I/O. `rebuildBinaryHostScript` consumes this to pick its build line
|
|
474
|
+
* (the npm-reinstall branch rebuilds the Dockerfile.npm image at the variant's
|
|
475
|
+
* dist-tag); the self-host axis is detected at runtime
|
|
476
|
+
* on the VM (the `[ -f /workspace/Cargo.toml ]` signal is authoritative even
|
|
477
|
+
* for VMs the dashboard has no launch-history record for). */
|
|
478
|
+
function rebuildStrategy({ selfHost = false, editorVariant = "dev" } = {}) {
|
|
479
|
+
if (editorVariant !== "dev") return "npm-reinstall";
|
|
480
|
+
return selfHost ? "rebuild-self" : "image-rebuild";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Decide whether a new per-VM job may start, given the VM's currently-tracked
|
|
484
|
+
* job. `state.jobs[n]` is a SINGLE slot per VM, so a second `startJob` would
|
|
485
|
+
* OVERWRITE an in-flight job's tracker — the bug where `reapplyVmEnv`'s
|
|
486
|
+
* follow-on `set-env` clobbered a still-running Rebuild's card, so the operator
|
|
487
|
+
* saw a `set-env` completion instead of the rebuild they asked for, with no
|
|
488
|
+
* error surfaced. Any job already `running` blocks a new one:
|
|
489
|
+
* - `idle` — no in-flight job (or the slot's job already finished);
|
|
490
|
+
* the caller starts normally.
|
|
491
|
+
* - `same-action` — a job with the SAME action is already running; reject
|
|
492
|
+
* (the pre-existing duplicate-click guard).
|
|
493
|
+
* - `different-action`— a DIFFERENT action is running; reject rather than
|
|
494
|
+
* clobber its tracker. Operators sequence the two jobs
|
|
495
|
+
* manually (the rare legitimate case).
|
|
496
|
+
* Returns `{ decision: 'start' }` or
|
|
497
|
+
* `{ decision: 'reject', reason, runningAction }` so the caller builds the
|
|
498
|
+
* user-facing message (the two reject reasons word it differently). Pure; no
|
|
499
|
+
* I/O — `existing` is the job record, `requested` the action about to start. */
|
|
500
|
+
function decideStartJob({ existing, requested } = {}) {
|
|
501
|
+
if (!existing || existing.state !== "running") return { decision: "start" };
|
|
502
|
+
const runningAction = existing.action;
|
|
503
|
+
if (runningAction === requested) {
|
|
504
|
+
return { decision: "reject", reason: "same-action", runningAction };
|
|
505
|
+
}
|
|
506
|
+
return { decision: "reject", reason: "different-action", runningAction };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Decide where a freshly-added VM's credentials come from. The dashboard's
|
|
510
|
+
* default is a container→container copy from a live donor VM, but a full
|
|
511
|
+
* teardown empties the roster and leaves no donor — so Add must fall back to
|
|
512
|
+
* operator-configured sources. Precedence, highest first:
|
|
513
|
+
* - "donor" — a reachable donor exists; copy claude + git from it (the
|
|
514
|
+
* unchanged default). `donor` = the chosen VM number.
|
|
515
|
+
* - "host-stash" — no donor, but CLAUDE_CREDS_SRC points at a known-good
|
|
516
|
+
* .credentials.json to push into the new container.
|
|
517
|
+
* - "token" — no donor and no stash, but GITHUB_TOKEN is set (cloud:up
|
|
518
|
+
* seeds git creds at boot). claude auth is NOT seeded.
|
|
519
|
+
* - "none" — nothing available; wire + roster only, claude + git both
|
|
520
|
+
* unseeded.
|
|
521
|
+
* Modes "token" and "none" carry no claude auth, so the caller marks the VM
|
|
522
|
+
* add-degraded with a `claude /login` remediation. `reachableDonors` must be
|
|
523
|
+
* pre-filtered to VMs that actually answered (a destroyed-but-rostered orphan
|
|
524
|
+
* is not a donor — fragility #24). Pure; no I/O. */
|
|
525
|
+
function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubToken = "" } = {}) {
|
|
526
|
+
const donor = Array.isArray(reachableDonors) && reachableDonors.length ? reachableDonors[0] : null;
|
|
527
|
+
if (donor != null) return { mode: "donor", donor };
|
|
528
|
+
if (claudeCredsSrc) return { mode: "host-stash", donor: null };
|
|
529
|
+
if (githubToken) return { mode: "token", donor: null };
|
|
530
|
+
return { mode: "none", donor: null };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Single-quote a string for safe inclusion in a remote shell command. Mirrors
|
|
534
|
+
// the server.js helper so callers don't have to thread one through.
|
|
535
|
+
function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
536
|
+
|
|
537
|
+
/** Build the shell steps that seed a freshly-added VM's claude + git credentials,
|
|
538
|
+
* for the resolved `mode` (see `resolveCredsSource`). Returns an array of shell
|
|
539
|
+
* strings the caller newline-joins into the larger provisioning script — each
|
|
540
|
+
* step is independent; a `CY_DEGRADED <message>` from one does not block the
|
|
541
|
+
* next, and the dashboard's job-attribution path lifts those markers onto the
|
|
542
|
+
* card as `degradedReasons`.
|
|
543
|
+
*
|
|
544
|
+
* Donor mode (the default) copies, container→container from the donor VM:
|
|
545
|
+
* 1. host-file git credentials → `$HOME/.codeyam-cloud-git-credentials`
|
|
546
|
+
* 2. claude `.credentials.json` → `/root/.claude/.credentials.json`
|
|
547
|
+
* 3. claude `.claude.json` → `/root/.claude.json`
|
|
548
|
+
* Step 3 (onboarding state — `hasCompletedOnboarding` + sibling keys) is what
|
|
549
|
+
* keeps the first interactive `claude` TUI from popping the welcome "Select
|
|
550
|
+
* login method" menu; without it the non-interactive `claude --print` probe
|
|
551
|
+
* still passes (so `/api/config` reports `authenticated`), but the Build tab
|
|
552
|
+
* PTY stalls at the picker. Failure of any single step emits its own
|
|
553
|
+
* `CY_DEGRADED` marker — the credentials copy is the load-bearing one; the
|
|
554
|
+
* onboarding-state copy is recoverable via `claude /login`.
|
|
555
|
+
*
|
|
556
|
+
* host-stash / token / none modes don't have a donor `.claude.json` to copy —
|
|
557
|
+
* for now those paths still land degraded for the same reason; a separate plan
|
|
558
|
+
* can extend host-stash with a sibling onboarding-stash env knob.
|
|
559
|
+
*
|
|
560
|
+
* Pure; no I/O. */
|
|
561
|
+
function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n, claudeCredsSrc = "" } = {}) {
|
|
562
|
+
const claudeSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude/.credentials.json && chmod 600 /root/.claude/.credentials.json"`;
|
|
563
|
+
const claudeJsonSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude.json && chmod 600 /root/.claude.json"`;
|
|
564
|
+
const loginHint = `run: docker exec -it ${container} claude /login on ${inst}`;
|
|
565
|
+
if (mode === "donor") {
|
|
566
|
+
return [
|
|
567
|
+
`echo "copying git creds from ${refInst}..."`,
|
|
568
|
+
`${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='cat $HOME/.codeyam-cloud-git-credentials' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command='umask 077; cat > $HOME/.codeyam-cloud-git-credentials' 2>/dev/null || echo "CY_DEGRADED git creds copy failed on VM-${n} — re-copy creds from a live donor (the agent can't git pull/push until then)"`,
|
|
569
|
+
`echo "copying claude auth from ${refInst} (container->container)..."`,
|
|
570
|
+
`${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude/.credentials.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth copied" || echo "CY_DEGRADED claude auth copy failed on VM-${n} — ${loginHint}"`,
|
|
571
|
+
`echo "copying claude.json (onboarding state) from ${refInst} (container->container)..."`,
|
|
572
|
+
`${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeJsonSink)} 2>/dev/null && echo "claude.json copied" || echo "CY_DEGRADED claude.json copy failed on VM-${n} — first interactive claude launch may show 'Select login method' menu; ${loginHint} once to complete onboarding"`,
|
|
573
|
+
];
|
|
574
|
+
}
|
|
575
|
+
if (mode === "host-stash") {
|
|
576
|
+
return [
|
|
577
|
+
`echo "no reachable donor — seeding claude auth from host stash..."`,
|
|
578
|
+
`base64 < ${_shq(claudeCredsSrc)} | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth seeded from host stash" || echo "WARN claude stash seed failed — ${loginHint}"`,
|
|
579
|
+
];
|
|
580
|
+
}
|
|
581
|
+
if (mode === "token") {
|
|
582
|
+
return [
|
|
583
|
+
`echo "no reachable donor and no CLAUDE_CREDS_SRC — git creds seeded by cloud:up; claude auth NOT seeded. ${loginHint}"`,
|
|
584
|
+
];
|
|
585
|
+
}
|
|
586
|
+
return [
|
|
587
|
+
`echo "no reachable donor and no configured creds source — wiring + rostering only; claude AND git auth NOT seeded. ${loginHint}, and set CLAUDE_CREDS_SRC/GITHUB_TOKEN in the dashboard env"`,
|
|
588
|
+
];
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Build the two host-side shell snippets that PRESERVE the container's
|
|
592
|
+
* /root/.claude.json (claude onboarding state) across a non-self-host Rebuild's
|
|
593
|
+
* container recreate. The whole /root/.claude DIRECTORY is bind-mounted from the
|
|
594
|
+
* host ($HOME/.codeyam-cloud-claude → /root/.claude, see docker-compose.cloud.yml),
|
|
595
|
+
* so /root/.claude/.credentials.json survives `docker compose down && up`. But
|
|
596
|
+
* /root/.claude.json is a SIBLING in the container's EPHEMERAL fs — NOT in the
|
|
597
|
+
* bind mount — so a recreate resets it to the image baseline, dropping the
|
|
598
|
+
* operator's `hasCompletedOnboarding`. The first interactive `claude` TUI then
|
|
599
|
+
* pops the "Select login method" welcome menu and the Build tab is blocked,
|
|
600
|
+
* even though the non-interactive `claude --print` auth probe still passes
|
|
601
|
+
* (improve36). The good copy is still in the LIVE container right up until
|
|
602
|
+
* `docker compose down`, so we capture it to a host temp file, then restore it
|
|
603
|
+
* into the freshly-recreated container — no donor required.
|
|
604
|
+
*
|
|
605
|
+
* Returns { capture, restore }, each an array of shell lines for the on-host
|
|
606
|
+
* rebuild script (run via gcloud-ssh, direct `docker exec` — same execution
|
|
607
|
+
* context as gitReseedHostStep). `capture` runs BEFORE `docker compose down`;
|
|
608
|
+
* `restore` runs AFTER `up`, BEFORE the editor verify probe. The lines share a
|
|
609
|
+
* `bakVar` shell variable holding the temp-file path, so they MUST be spread
|
|
610
|
+
* into the SAME bash invocation. A failed restore emits a `CY_DEGRADED` marker
|
|
611
|
+
* (the dashboard lifts it onto the card as a degradedReason) with a
|
|
612
|
+
* `claude /login` remediation — the rebuild itself still succeeded, so the job
|
|
613
|
+
* lands `done`, not `error`. Pure; no I/O. */
|
|
614
|
+
function buildClaudeJsonPreserveSteps({ container, n = "", bakVar = "CY_CLAUDE_JSON_BAK" } = {}) {
|
|
615
|
+
const loginHint = `run: docker exec -it ${container} claude /login`;
|
|
616
|
+
const capture = [
|
|
617
|
+
`${bakVar}=$(mktemp)`,
|
|
618
|
+
`if docker exec ${container} cat /root/.claude.json > "$${bakVar}" 2>/dev/null && [ -s "$${bakVar}" ]; then echo "captured /root/.claude.json ($(wc -c < "$${bakVar}") bytes) to preserve onboarding state across recreate"; else echo "no /root/.claude.json to preserve (new container will use the image baseline)"; : > "$${bakVar}"; fi`,
|
|
619
|
+
];
|
|
620
|
+
const restore = [
|
|
621
|
+
`if [ -s "$${bakVar}" ]; then docker exec -i ${container} bash -c 'umask 077; cat > /root/.claude.json && chmod 600 /root/.claude.json' < "$${bakVar}" && echo "re-seeded /root/.claude.json (onboarding state preserved across recreate)" || echo "CY_DEGRADED claude.json re-seed failed on VM-${n} after Rebuild — first interactive claude launch may show 'Select login method'; ${loginHint} once to complete onboarding"; else echo "no preserved /root/.claude.json; ${loginHint} if the Build tab shows the welcome menu"; fi`,
|
|
622
|
+
`rm -f "$${bakVar}"`,
|
|
623
|
+
];
|
|
624
|
+
return { capture, restore };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Build the shell command that base64-pipes a VM's persisted env over ssh +
|
|
628
|
+
* docker-exec stdin into the container's /workspace/.env (umask 077, chmod
|
|
629
|
+
* 600), chowns it to the workspace owner so the bind-mounted host file is
|
|
630
|
+
* readable by `docker compose --force-recreate` (Rebuild / Reconfigure /
|
|
631
|
+
* Add — host-side, as the unprivileged SSH user, not root), and restarts
|
|
632
|
+
* the dev server. The docker exec runs as root inside the container, so
|
|
633
|
+
* without the chown the file lands `root:root 0600` on the host bind-mount
|
|
634
|
+
* and the next compose recreate fails `open .env: permission denied`
|
|
635
|
+
* (fragility — improve36). `chown --reference=/workspace` inherits whatever
|
|
636
|
+
* uid the image's entrypoint assigned to the workspace (currently 1003 =
|
|
637
|
+
* the host SSH user), so the fix is robust against future UID changes.
|
|
638
|
+
* Values stay out of argv/logs: they ride as base64 over the ssh + docker
|
|
639
|
+
* exec stdin, never as CLI arguments. Pure; no I/O. */
|
|
640
|
+
function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, editorBin } = {}) {
|
|
641
|
+
const sink = `docker exec -i ${container} bash -lc "umask 077; base64 -d > /workspace/.env && chmod 600 /workspace/.env && chown --reference=/workspace /workspace/.env && cd /workspace && ${editorBin} editor restart-dev-server"`;
|
|
642
|
+
return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Get authenticated Git URL for checking client branch. */
|
|
646
|
+
function getAuthRepoUrl(repo, token) {
|
|
647
|
+
if (!repo) return "";
|
|
648
|
+
if (!token) return repo;
|
|
649
|
+
const sshRegex = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/;
|
|
650
|
+
const sshMatch = repo.match(sshRegex);
|
|
651
|
+
if (sshMatch) {
|
|
652
|
+
return `https://x-access-token:${token}@github.com/${sshMatch[1]}/${sshMatch[2]}`;
|
|
653
|
+
}
|
|
654
|
+
if (repo.startsWith("https://github.com/")) {
|
|
655
|
+
return repo.replace(/^https:\/\/github\.com\//, `https://x-access-token:${token}@github.com/`);
|
|
656
|
+
}
|
|
657
|
+
return repo;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Interpret the output of git ls-remote to see if a branch exists. */
|
|
661
|
+
function interpretLsRemote({ stdout = "", code = 0, err = null, repo = "", branch = "" } = {}) {
|
|
662
|
+
const b = (branch || "").trim();
|
|
663
|
+
if (!b) {
|
|
664
|
+
return { ok: true, msg: "using repo default branch" };
|
|
665
|
+
}
|
|
666
|
+
if (err || code !== 0) {
|
|
667
|
+
const errMsg = (err && (err.message || String(err))) || "command failed";
|
|
668
|
+
return {
|
|
669
|
+
ok: false,
|
|
670
|
+
soft: true,
|
|
671
|
+
msg: `couldn't verify branch: ${errMsg.trim()}`
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
const trimmed = (stdout || "").trim();
|
|
675
|
+
if (trimmed) {
|
|
676
|
+
return { ok: true };
|
|
677
|
+
} else {
|
|
678
|
+
return {
|
|
679
|
+
ok: false,
|
|
680
|
+
msg: `branch '${b}' not found in ${repo || "repository"}`
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
module.exports = {
|
|
686
|
+
AGENTS,
|
|
687
|
+
EMPTY_CONFIG,
|
|
688
|
+
normalizeLaunchConfig,
|
|
689
|
+
validateLaunchConfig,
|
|
690
|
+
isVariantProvisionable,
|
|
691
|
+
isVariantRebuildable,
|
|
692
|
+
variantNpmDistTag,
|
|
693
|
+
variantImageTag,
|
|
694
|
+
cloudUpArgs,
|
|
695
|
+
classifyContainerWait,
|
|
696
|
+
decideAddSlot,
|
|
697
|
+
HEAVY_ACTIONS,
|
|
698
|
+
queuedStatusForAction,
|
|
699
|
+
pushHistory,
|
|
700
|
+
distinctConfigs,
|
|
701
|
+
recentLaunchDisplay,
|
|
702
|
+
defaultConfig,
|
|
703
|
+
configForVm,
|
|
704
|
+
resolveCardIdentity,
|
|
705
|
+
stickyClientProject,
|
|
706
|
+
classifyProjectKind,
|
|
707
|
+
resolveCredsSource,
|
|
708
|
+
buildCredsSteps,
|
|
709
|
+
buildClaudeJsonPreserveSteps,
|
|
710
|
+
buildEnvApplyCommand,
|
|
711
|
+
resolveEditorSource,
|
|
712
|
+
editorSourceShellResolve,
|
|
713
|
+
rebuildStrategy,
|
|
714
|
+
decideStartJob,
|
|
715
|
+
getAuthRepoUrl,
|
|
716
|
+
interpretLsRemote,
|
|
717
|
+
};
|