@codeyam-editor/codeyam-editor 0.1.0-staging.g83619a4 → 0.1.0-staging.ga747ed2

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.
Files changed (30) hide show
  1. package/npm/cloud.js +446 -45
  2. package/npm/fleet-add-failure.js +100 -14
  3. package/npm/fleet-auth-health.js +26 -5
  4. package/npm/fleet-build-data.js +10 -2
  5. package/npm/fleet-destroy-safety.js +230 -13
  6. package/npm/fleet-memory.js +237 -8
  7. package/npm/fleet-prewarm.js +54 -0
  8. package/npm/fleet-public-url.js +94 -8
  9. package/npm/fleet-reachability.js +80 -0
  10. package/npm/fleet-vm-access.js +39 -0
  11. package/npm/fleet-vm-probe-target.js +95 -0
  12. package/npm/ingress-invoke.js +70 -8
  13. package/package.json +5 -5
  14. package/ui/dist/assets/{ContainerInspector-Di2c_yrg.js → ContainerInspector-DgM0pTAx.js} +1 -1
  15. package/ui/dist/assets/{EditorShell-BBjfN0wn.js → EditorShell-B7Q06uJ_.js} +59 -59
  16. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  17. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  18. package/ui/dist/assets/{ScenarioDataPanelFullPage-6-39HCFL.js → ScenarioDataPanelFullPage-BLqRCxQX.js} +1 -1
  19. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  20. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  21. package/ui/dist/assets/{index-3Jd_sXhk.js → index-DCHqRrwO.js} +2 -2
  22. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  23. package/ui/dist/index.html +2 -2
  24. package/ui/dist/open-source/codeyam-counter.png +0 -0
  25. package/ui/dist/open-source/el-carot.png +0 -0
  26. package/ui/dist/open-source/tabcommand.png +0 -0
  27. package/ui/dist/assets/ProjectLauncherScreen-C5jktR8F.js +0 -1
  28. package/ui/dist/assets/ScenarioDataPanel-BlYKgvU_.js +0 -5
  29. package/ui/dist/assets/ScenarioRenderer-sh_-nNw8.js +0 -21
  30. package/ui/dist/assets/index-U81w-IPK.css +0 -1
package/npm/cloud.js CHANGED
@@ -965,11 +965,61 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
965
965
  // real use. Unit tests pin their exports so a refactor cannot drop them.
966
966
  // =============================================================================
967
967
 
968
+ // Per-call wall-clock budget for a single gcloud / SSH invocation. A hung
969
+ // `gcloud describe` (metadata / IAP hiccup) used to block the whole
970
+ // `cloud:reconcile-ingress` sweep forever — the only timeout in the reconcile
971
+ // path was the outer 10-minute exec budget in the dashboard, and that never
972
+ // cascaded to the orphaned descendants. Bounding every per-call spawn well under
973
+ // that outer budget means a single hang trips HERE first: the call returns a
974
+ // timed-out result (formatGcloudFailure renders it), the sweep skips that VM,
975
+ // and no descendant is left hanging to pile up. Shared by runGcloud and the two
976
+ // reconcile spawnSync sites (status probe, per-BES timeout converge) so all three
977
+ // reference one value. 2 min is generous for any single describe/update/SSH probe
978
+ // yet comfortably under the 600000 ms outer reconcile budget.
979
+ const GCLOUD_CALL_TIMEOUT_MS = 120000;
980
+
981
+ // The project bootstrap is NOT a probe: it runs `git clone` + `npm install` in
982
+ // the repo root and every `apps[].dir`, each package's `postinstall`, then
983
+ // `npm run setup` — minutes of remote work on a heavy repo. It once ran as ONE
984
+ // blocking `runGcloud("ssh-script")` call, so it inherited GCLOUD_CALL_TIMEOUT_MS
985
+ // (a budget whose own comment scopes it to "any single describe/update/SSH
986
+ // probe"). That SIGTERMd the gcloud child mid-clone; `spawnSync` then returned
987
+ // `status: null`, which `!== 0`, so the timeout was indistinguishable from a
988
+ // genuine remote failure and the caller's abort path DELETED A COMPLETELY
989
+ // HEALTHY VM.
990
+ //
991
+ // Observed twice: 2026-07-28 on client repo `lets-go-compost-portal` (a root
992
+ // postinstall of `playwright install chromium`, ~293 MB), and 2026-07-29
993
+ // provisioning six self-host `codeyam-editor` VMs, whose clone alone exceeds the
994
+ // probe budget — all six were built, torn down mid-clone, and reported as
995
+ // "scripts/gcp-project-bootstrap.sh failed on the VM".
996
+ //
997
+ // The install is now DETACHED (projectBootstrapLaunchCommand + a re-attaching
998
+ // follow loop, outcome read from VM-side state), so no single call carries the
999
+ // whole install any more. This constant is therefore the OVERALL deadline across
1000
+ // re-attaches — the point at which we stop waiting on a still-healthy VM and say
1001
+ // exactly that. Deliberately well above the outer reconcile budget: a long remote
1002
+ // install is not a step in a sweep, so the sweep-relative ceiling that governs
1003
+ // GCLOUD_CALL_TIMEOUT_MS does not apply to it.
1004
+ const PROJECT_BOOTSTRAP_TIMEOUT_MS = 40 * 60 * 1000;
1005
+
1006
+ /** Whether a `spawnSync` result is a LOCAL timeout kill rather than a remote
1007
+ * failure. `spawnSync` reports a timeout as `status: null` plus an ETIMEDOUT
1008
+ * error, so a bare `status !== 0` check conflates the two — which is what made
1009
+ * a timed-out bootstrap look like a broken one. Pure. */
1010
+ function isSpawnTimeout(result) {
1011
+ if (!result) return false;
1012
+ if (result.status !== null && result.status !== undefined) return false;
1013
+ const code = result.error && result.error.code;
1014
+ return code === "ETIMEDOUT" || result.signal === "SIGTERM";
1015
+ }
1016
+
968
1017
  function runGcloud(args, opts = {}) {
969
1018
  return spawnSync("gcloud", args, {
970
1019
  stdio: opts.stdio || "inherit",
971
1020
  input: opts.input,
972
1021
  encoding: opts.encoding || "utf8",
1022
+ timeout: opts.timeout != null ? opts.timeout : GCLOUD_CALL_TIMEOUT_MS,
973
1023
  });
974
1024
  }
975
1025
 
@@ -1572,6 +1622,267 @@ function bootstrapLogTail(cfg, lines = 40) {
1572
1622
  return String(r.stdout || "").trim() || "(log unavailable)";
1573
1623
  }
1574
1624
 
1625
+ // ─── Detached PROJECT bootstrap ──────────────────────────────────────────────
1626
+ //
1627
+ // The VM bootstrap above solved a problem the PROJECT bootstrap still had: read
1628
+ // success and failure from VM-SIDE STATE, never from the SSH exit code, so a
1629
+ // dropped follow-session over a healthy VM is not misreported as "failed on the
1630
+ // VM". The project bootstrap ran instead as ONE blocking ssh-script call, so a
1631
+ // local budget elapsing (or an SSH pipe breaking) was indistinguishable from a
1632
+ // genuine remote failure and the caller's abort path DELETED A HEALTHY VM.
1633
+ // Raising the budget alone just moves the cliff; these helpers remove it by
1634
+ // mirroring the VM-bootstrap contract — detached launch, re-attaching follow
1635
+ // loop, VM-side outcome probe.
1636
+ //
1637
+ // All VM-side paths are keyed by repo name so two projects on one VM never
1638
+ // collide, and they sit beside the `.done` marker projectBootstrapDoneMarker
1639
+ // already defines. Pure string builders (like their VM-bootstrap counterparts)
1640
+ // so the whole grammar is unit-testable without a live VM.
1641
+ function projectBootstrapScriptPath(repoName) {
1642
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.sh`;
1643
+ }
1644
+ function projectBootstrapRunnerPath(repoName) {
1645
+ return `$HOME/.codeyam-project-bootstrap-${repoName}-run.sh`;
1646
+ }
1647
+ function projectBootstrapLogPath(repoName) {
1648
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.log`;
1649
+ }
1650
+ function projectBootstrapPidPath(repoName) {
1651
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.pid`;
1652
+ }
1653
+ // The runner's recorded EXIT STATUS. Deliberately `.status`, NOT `.done`:
1654
+ // `.done` is already taken by scripts/gcp-project-bootstrap.sh's own terminal
1655
+ // SUCCESS marker (a timestamp, written only after `set -euo pipefail` carried
1656
+ // every step through — see projectBootstrapDoneMarker). Reusing `.done` for an
1657
+ // exit code would overwrite the success signal that both improve40's false-skip
1658
+ // fix and the follow loop below depend on. Two files, two distinct meanings:
1659
+ // `.done` = "the script reached its last line", `.status` = "the runner exited,
1660
+ // here is its code".
1661
+ function projectBootstrapStatusPath(repoName) {
1662
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.status`;
1663
+ }
1664
+ const PROJECT_BOOTSTRAP_LAUNCHED_MARKER = "CODEYAM_PROJECT_BOOTSTRAP_LAUNCHED";
1665
+ const PROJECT_BOOTSTRAP_STATUS_MARKER = "CYPROJ";
1666
+ // One follow session caps at 15 min; the overall wait caps at
1667
+ // PROJECT_BOOTSTRAP_TIMEOUT_MS across re-attaches before we bail loudly with
1668
+ // the VM-side log location. Unlike the VM-bootstrap follow loop — whose follow
1669
+ // calls inherit the 120 s probe budget and so re-attach every two minutes over
1670
+ // a ~14-min build — the project follow call below is given an explicit local
1671
+ // budget just above this remote deadline, so a long install streams in one
1672
+ // session instead of churning re-attaches.
1673
+ const PROJECT_BOOTSTRAP_FOLLOW_SECS = 900;
1674
+ const PROJECT_BOOTSTRAP_FOLLOW_TIMEOUT_MS = (PROJECT_BOOTSTRAP_FOLLOW_SECS + 60) * 1000;
1675
+
1676
+ // The tiny VM-side wrapper the detached launch execs: run the real bootstrap,
1677
+ // redirect all output to the per-repo log, and record the exit status in the
1678
+ // `.status` sentinel — the single source of truth for "did the runner exit, and
1679
+ // with what?" once the SSH follow-session is gone. Contains NO secrets (tokens
1680
+ // arrive via the launch env prefix, never written to disk).
1681
+ function projectBootstrapRunnerScript(repoName) {
1682
+ return [
1683
+ "#!/usr/bin/env bash",
1684
+ "# Detached project-bootstrap runner (written by npm/cloud.js",
1685
+ "# ensureProjectBootstrapped). Survives SSH disconnect: the laptop only",
1686
+ "# *follows* the log below; this process keeps running under its own session",
1687
+ "# if the follow drops. Records its own PID so the status probe can check",
1688
+ "# liveness via `kill -0` (a `pgrep` on the script path would also match the",
1689
+ "# probe command itself).",
1690
+ `echo "$$" > "${projectBootstrapPidPath(repoName)}"`,
1691
+ `bash "${projectBootstrapScriptPath(repoName)}" "$@" > "${projectBootstrapLogPath(repoName)}" 2>&1`,
1692
+ "status=$?",
1693
+ `echo "$status" > "${projectBootstrapStatusPath(repoName)}"`,
1694
+ `rm -f "${projectBootstrapPidPath(repoName)}"`,
1695
+ "",
1696
+ ].join("\n");
1697
+ }
1698
+
1699
+ // Build the remote command that (1) materializes the bootstrap script + runner
1700
+ // on the VM, (2) clears the stale sentinels, (3) launches the runner DETACHED
1701
+ // (`nohup setsid`, new session, stdio to /dev/null) so it outlives the SSH
1702
+ // session, and (4) prints a launched marker. The detach is the durable fix: the
1703
+ // install no longer dies when the laptop's SSH pipe breaks, and the VM is no
1704
+ // longer torn down because it did.
1705
+ //
1706
+ // BOTH sentinels are cleared, `.status` and `.done`. Clearing `.done` is what
1707
+ // makes the outcome probe honest: after a launch, a present `.done` can only
1708
+ // mean THIS run's script reached its terminal line, never a leftover from a
1709
+ // previous half-wired attempt.
1710
+ function projectBootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}, repoName) {
1711
+ const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
1712
+ const runnerB64 = Buffer.from(projectBootstrapRunnerScript(repoName), "utf8").toString("base64");
1713
+ const argsString = scriptArgs.map(shQuote).join(" ");
1714
+ const envPrefix = envAssignmentPrefix(envVars);
1715
+ // Writes run synchronously (fast, before any backgrounding) so a decode error
1716
+ // aborts before launch; then the runner is detached. The env prefix is on the
1717
+ // setsid command so the tokens are inherited through nohup→setsid→bash but
1718
+ // never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
1719
+ // launch in the background while still confirming it fired.
1720
+ const writes = [
1721
+ `echo '${scriptB64}' | base64 -d > ${projectBootstrapScriptPath(repoName)}`,
1722
+ `echo '${runnerB64}' | base64 -d > ${projectBootstrapRunnerPath(repoName)}`,
1723
+ `rm -f ${projectBootstrapStatusPath(repoName)} ${projectBootstrapDoneMarker(repoName)}`,
1724
+ ].join(" && ");
1725
+ const launch =
1726
+ `${envPrefix}nohup setsid bash ${projectBootstrapRunnerPath(repoName)} ${argsString}`.trimEnd() +
1727
+ ` </dev/null >/dev/null 2>&1 &`;
1728
+ return `${writes}; ${launch} echo ${PROJECT_BOOTSTRAP_LAUNCHED_MARKER}`;
1729
+ }
1730
+
1731
+ // Build the remote command that FOLLOWS the detached install: tail the log live
1732
+ // for the operator, polling for the `.status` sentinel, and exit when the runner
1733
+ // finishes OR a per-session deadline elapses. If the SSH session drops
1734
+ // mid-follow this command dies but the detached install keeps going — the caller
1735
+ // then re-attaches.
1736
+ function projectBootstrapFollowCommand(repoName, deadlineSecs = PROJECT_BOOTSTRAP_FOLLOW_SECS) {
1737
+ const log = projectBootstrapLogPath(repoName);
1738
+ const status = projectBootstrapStatusPath(repoName);
1739
+ return [
1740
+ `touch ${log}`,
1741
+ `tail -n +1 -F ${log} 2>/dev/null & TP=$!`,
1742
+ `waited=0`,
1743
+ `while [ ! -f ${status} ]; do ` +
1744
+ `sleep 3; waited=$((waited+3)); ` +
1745
+ `if [ "$waited" -ge ${deadlineSecs} ]; then kill "$TP" 2>/dev/null; exit 0; fi; ` +
1746
+ `done`,
1747
+ `sleep 1`,
1748
+ `kill "$TP" 2>/dev/null`,
1749
+ ].join("; ");
1750
+ }
1751
+
1752
+ // Build the remote command that reports terminal VM state in one parseable
1753
+ // line: whether the script's terminal `.done` marker exists, the runner's
1754
+ // recorded exit status (or `none`), and whether the runner is still alive.
1755
+ // Cheap — runs over ssh-probe.
1756
+ function projectBootstrapStatusCommand(repoName) {
1757
+ const pid = projectBootstrapPidPath(repoName);
1758
+ const status = projectBootstrapStatusPath(repoName);
1759
+ // Liveness via the runner's PID file + `kill -0`, NOT pgrep: the status
1760
+ // command's own argv contains the bootstrap path, so a `pgrep -f` on it would
1761
+ // match this very probe and always report running. A stale PID file from a
1762
+ // killed runner reads as not-running because `kill -0` fails.
1763
+ return [
1764
+ `done=no; [ -f ${projectBootstrapDoneMarker(repoName)} ] && done=yes`,
1765
+ `st=none; [ -f ${status} ] && st=$(cat ${status} 2>/dev/null)`,
1766
+ `run=no; if [ -f ${pid} ]; then p=$(cat ${pid} 2>/dev/null); ` +
1767
+ `{ [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } && run=yes; fi`,
1768
+ `echo "${PROJECT_BOOTSTRAP_STATUS_MARKER} done=$done status=$st running=$run"`,
1769
+ ].join("; ");
1770
+ }
1771
+
1772
+ // Parse the single status line emitted by projectBootstrapStatusCommand.
1773
+ // Returns `{ done, status, running }` or null when no status line is present
1774
+ // (e.g. the probe ssh itself failed and produced no stdout).
1775
+ function parseProjectBootstrapStatus(stdout) {
1776
+ const m = String(stdout || "").match(
1777
+ new RegExp(`${PROJECT_BOOTSTRAP_STATUS_MARKER} done=(\\S+) status=(\\S+) running=(\\S+)`),
1778
+ );
1779
+ if (!m) return null;
1780
+ return { done: m[1], status: m[2], running: m[3] };
1781
+ }
1782
+
1783
+ // Decide the project-bootstrap outcome from VM-side state alone — never the SSH
1784
+ // exit code. The `.done` marker is the real artifact (the script writes it only
1785
+ // after `set -euo pipefail` carried every step to success), so its presence is
1786
+ // success regardless of how the follow session ended — the same reasoning that
1787
+ // makes the docker image authoritative for the VM bootstrap. A live runner means
1788
+ // "re-attach". A recorded exit status without the marker means the install ran
1789
+ // and failed. Nothing at all means it was never started (or the probe could not
1790
+ // see it).
1791
+ function decideProjectBootstrapOutcome(status) {
1792
+ if (!status) return "absent";
1793
+ const { done, status: exitStatus, running } = status;
1794
+ if (done === "yes") return "success";
1795
+ if (running === "yes") return "running";
1796
+ if (exitStatus && exitStatus !== "none") return "failed";
1797
+ return "absent";
1798
+ }
1799
+
1800
+ // Reduce one follow-loop iteration to an action: "success" | "failed" |
1801
+ // "timeout" | "reattach". Extracted as a pure function because its PRECEDENCE is
1802
+ // load-bearing — it decides whether an operator is told a VM is broken:
1803
+ //
1804
+ // • success and failed outrank the deadline. Otherwise the final poll of a
1805
+ // slow-but-successful bootstrap reports a timeout, and the operator is sent
1806
+ // to reconcile a VM that actually finished.
1807
+ // • a still-running install past the deadline is a TIMEOUT, never a failure.
1808
+ // That is the VM-8 case: healthy VM, still installing, and saying so is what
1809
+ // gets the add classified retryable instead of destroyed.
1810
+ // • an unseen runner ("absent") before the deadline re-attaches rather than
1811
+ // failing — "I could not see it" is not "it broke" — but is still bounded by
1812
+ // the deadline so the loop cannot spin forever.
1813
+ function decideProjectBootstrapFollowAction({ outcome, now, deadline } = {}) {
1814
+ if (outcome === "success") return "success";
1815
+ if (outcome === "failed") return "failed";
1816
+ if (now >= deadline) return "timeout";
1817
+ return "reattach";
1818
+ }
1819
+
1820
+ // Probe the VM for the detached project bootstrap's terminal state and reduce it
1821
+ // to a single outcome string ("success" | "failed" | "running" | "absent").
1822
+ // Used both for the idempotent re-attach decision and after each follow session.
1823
+ function probeProjectBootstrapOutcome(cfg, repoName) {
1824
+ const r = runGcloud(
1825
+ buildGcloudArgs("ssh-probe", cfg, { command: projectBootstrapStatusCommand(repoName) }),
1826
+ { stdio: ["ignore", "pipe", "pipe"] },
1827
+ );
1828
+ return decideProjectBootstrapOutcome(parseProjectBootstrapStatus(r.stdout));
1829
+ }
1830
+
1831
+ /** Assemble the env forwarded into the project-bootstrap shell on the VM.
1832
+ * Extracted from ensureProjectBootstrapped so its CONDITIONAL keys are testable
1833
+ * without a VM — previously you could not assert "FLEET_DOMAIN is omitted for a
1834
+ * non-fleet project" or "a re-provision without --admin stays non-admin"
1835
+ * without invoking the whole gcloud-touching bootstrap.
1836
+ *
1837
+ * Keys, and why each is here:
1838
+ * GITHUB_TOKEN — clone a private client repo.
1839
+ * CY_GIT_USER_{NAME,EMAIL} — rendered into the compose file, then written
1840
+ * to `git config --global` by the entrypoint so
1841
+ * plan_claim_refs::create_claim's commit-tree
1842
+ * call succeeds. Empty is legitimate (a CI
1843
+ * runner with no author); the entrypoint warns
1844
+ * and proceeds, so the keys stay present.
1845
+ * FLEET_DOMAIN — ONLY for a numbered fleet VM with a domain
1846
+ * configured, so the compose render publishes
1847
+ * on 0.0.0.0 for the LB NEG. Omitted entirely
1848
+ * otherwise, preserving the loopback-only
1849
+ * publish a plain client project expects.
1850
+ * <forwarded launch-form keys> — DATABASE_URL et al, whitelisted by the
1851
+ * dashboard via CY_FORWARD_ENV_KEYS, so the
1852
+ * bootstrap-time `npm run setup` sees them on
1853
+ * the first try. Empty on plain `cloud:up`.
1854
+ * CODEYAM_ADMIN — the Home-tab Setup door. Defaults to the
1855
+ * strict non-admin "".
1856
+ *
1857
+ * Later spreads win, matching the original inline assembly exactly. `deps` lets
1858
+ * a test supply the token and git identity instead of reading the real
1859
+ * environment and shelling out to git. */
1860
+ function projectBootstrapEnv(cfg = {}, { env = process.env, githubToken, gitIdentity } = {}) {
1861
+ const git = gitIdentity || resolveLaptopGitIdentity();
1862
+ const out = {
1863
+ GITHUB_TOKEN: githubToken !== undefined ? githubToken : resolveGithubToken(),
1864
+ CY_GIT_USER_NAME: git.name,
1865
+ CY_GIT_USER_EMAIL: git.email,
1866
+ };
1867
+ const ingressPlan = ingressWiringPlan(cfg, env);
1868
+ if (ingressPlan.wire) out.FLEET_DOMAIN = ingressPlan.domain;
1869
+ Object.assign(out, resolveSetupForwardEnv(env));
1870
+ Object.assign(out, buildProvisionEnv(env, cfg));
1871
+ return out;
1872
+ }
1873
+
1874
+ // Fetch the tail of the VM-side project-bootstrap log for an actionable failure
1875
+ // message — the operator sees WHY the install failed, not a bare exit code.
1876
+ function projectBootstrapLogTail(cfg, repoName, lines = 40) {
1877
+ const r = runGcloud(
1878
+ buildGcloudArgs("ssh-probe", cfg, {
1879
+ command: `tail -n ${lines} ${projectBootstrapLogPath(repoName)} 2>/dev/null`,
1880
+ }),
1881
+ { stdio: ["ignore", "pipe", "pipe"] },
1882
+ );
1883
+ return String(r.stdout || "").trim() || "(log unavailable)";
1884
+ }
1885
+
1575
1886
  /** Assemble the extra provision-time env forwarded into the bootstrap shell,
1576
1887
  * where it feeds the compose-render substitution. Currently just the admin
1577
1888
  * toggle: CODEYAM_ADMIN gates the Home-tab Setup door and is read from the
@@ -1609,7 +1920,7 @@ function buildReconcileAdminCommand(repoName, value) {
1609
1920
  );
1610
1921
  }
1611
1922
 
1612
- async function ensureProjectBootstrapped(cfg) {
1923
+ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
1613
1924
  const repoName = resolveProject(cfg).name;
1614
1925
  console.error(`Checking project bootstrap state for ${repoName}...`);
1615
1926
  // Gate on the TERMINAL completion marker the script writes as its very last
@@ -1639,48 +1950,102 @@ async function ensureProjectBootstrapped(cfg) {
1639
1950
  const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
1640
1951
  const scriptBody = readStreamedScript(scriptPath, cfg);
1641
1952
  const scriptArgs = clientProjectBootstrapArgs(cfg);
1642
- // Forward the laptop's git author identity so the rendered compose file
1643
- // carries CY_GIT_USER_{NAME,EMAIL} → the entrypoint writes them to
1644
- // `git config --global` → plan_claim_refs::create_claim's commit-tree call
1645
- // succeeds. Empty strings are fine (laptop has no identity, e.g. a CI
1646
- // runner); the entrypoint warns and proceeds.
1647
- const laptopGit = resolveLaptopGitIdentity();
1648
- // Forward the operator's launch-form env keys (DATABASE_URL, etc.) into the
1649
- // bootstrap shell so the bootstrap-time `npm run setup` sees them on the
1650
- // first try — without this, Prisma `db:push`/`db:seed` fail for lack of
1651
- // env and the bootstrap's fail-soft branch leaves the app schema-less until
1652
- // the post-add `reapplyVmEnv` re-fires setup. See
1653
- // `improve37-launch-form-env-injected-into-cloud-up-bootstrap-not-just-applied-after`.
1654
- // Whitelisted by the dashboard via CY_FORWARD_ENV_KEYS — empty on the
1655
- // standalone `cloud:up` path, so that flow is unchanged.
1656
- const forwardEnv = resolveSetupForwardEnv();
1657
- // Forward the fleet domain so the bootstrap's compose render publishes the
1658
- // editor/app ports on 0.0.0.0 (the LB NEG reaches them on the VM's internal
1659
- // IP) instead of loopback-only. Only when a fleet domain is configured AND
1660
- // this is a numbered fleet VM — a non-fleet client project keeps the
1661
- // loopback-only publish (legacy parity). Absent ⇒ key omitted entirely.
1662
- const ingressEnv = {};
1663
- const ingressPlan = ingressWiringPlan(cfg);
1664
- if (ingressPlan.wire) ingressEnv.FLEET_DOMAIN = ingressPlan.domain;
1665
- // Forward the admin-mode toggle (Home-tab Setup door) into the compose
1666
- // render. cfg.admin carries the `--admin` flag; process.env.CODEYAM_ADMIN
1667
- // carries the `CODEYAM_ADMIN=1 npm run cloud:up` form. Default "" =
1668
- // non-admin, so this leaves the normal launch and re-provision unchanged.
1669
- const provisionEnv = buildProvisionEnv(process.env, cfg);
1670
- const command = sshScriptCommand(scriptBody, scriptArgs, {
1671
- GITHUB_TOKEN: resolveGithubToken(),
1672
- CY_GIT_USER_NAME: laptopGit.name,
1673
- CY_GIT_USER_EMAIL: laptopGit.email,
1674
- ...ingressEnv,
1675
- ...forwardEnv,
1676
- ...provisionEnv,
1677
- });
1678
- const result = runGcloud(
1679
- buildGcloudArgs("ssh-script", cfg, { command }),
1680
- { stdio: "inherit" },
1681
- );
1682
- if (result.status !== 0) {
1683
- throw new Error("scripts/gcp-project-bootstrap.sh failed on the VM");
1953
+ // Git identity, forwarded launch-form keys, fleet-domain ingress and the admin
1954
+ // toggle — see projectBootstrapEnv for what each key is for and when it is
1955
+ // omitted. Extracted so those conditionals are testable without a VM.
1956
+ const bootstrapEnv = projectBootstrapEnv(cfg);
1957
+
1958
+ // Idempotent re-attach: a prior cloud:up whose follow-session dropped may have
1959
+ // left a detached install still running. Converge on it instead of starting a
1960
+ // second `npm install` on top of the first.
1961
+ //
1962
+ // NOTE the deliberate asymmetry with ensureBootstrapped, which DOES return
1963
+ // early on an outcome of "success". There, the done-probe and the outcome probe
1964
+ // read the same artifact (the docker image), so they always agree. Here the
1965
+ // done-probe above is a strictly STRONGER three-way AND (`.done` marker +
1966
+ // ~/projects/<repo>/.codeyam + docker-compose.cloud.yml) than this probe's
1967
+ // `.done`-alone success, and it has already said no. Honoring a bare `.done`
1968
+ // here would re-open the improve40 VM-7 false-skip from the other side, so only
1969
+ // "running" short-circuits the launch.
1970
+ const initial = probeProjectBootstrapOutcome(cfg, repoName);
1971
+ if (initial === "running") {
1972
+ console.error(" ↺ a detached project bootstrap is already in progress — re-attaching to its log...");
1973
+ } else {
1974
+ // "absent" (never started), "failed" (a prior run died), or "success" (a bare
1975
+ // marker the stricter done-probe rejected) → (re)launch a fresh detached
1976
+ // install that will survive an SSH disconnect. Every step of
1977
+ // gcp-project-bootstrap.sh is skip-on-exists, so re-entering is cheap.
1978
+ console.error(" launching detached project bootstrap on the VM (survives SSH disconnect)...");
1979
+ const launch = projectBootstrapLaunchCommand(scriptBody, scriptArgs, bootstrapEnv, repoName);
1980
+ const launched = await runGcloudSshWithRetry(
1981
+ "ssh-script",
1982
+ cfg,
1983
+ { command: launch },
1984
+ { stdio: ["ignore", "pipe", "pipe"], label: "project bootstrap launch", sleepFn },
1985
+ );
1986
+ if (isSpawnTimeout(launched)) {
1987
+ // The LOCAL budget elapsed and we SIGTERMd the gcloud child. Say so:
1988
+ // the launch is a short write-and-detach, so this is an SSH-reachability
1989
+ // problem, NOT a broken bootstrap script — and the VM may be perfectly
1990
+ // healthy. Conflating the two is what destroyed VM-8.
1991
+ throw new Error(
1992
+ `launching the detached project bootstrap exceeded the local gcloud call budget ` +
1993
+ `and was SIGTERMd locally — the VM may be healthy and simply unreachable over SSH ` +
1994
+ `right now. Re-run to re-attach (each bootstrap step is skip-on-exists).`,
1995
+ );
1996
+ }
1997
+ if (launched.status !== 0) {
1998
+ throw new Error(
1999
+ `failed to launch the detached project bootstrap on the VM (gcloud ssh exit ${launched.status}).\n` +
2000
+ formatGcloudFailure(launched),
2001
+ );
2002
+ }
2003
+ }
2004
+
2005
+ // Follow the live log, re-attaching across any follow-session drops, until the
2006
+ // VM reports a terminal state or the overall deadline elapses. Success and
2007
+ // failure are read from VM state (the `.done` marker + the `.status` sentinel),
2008
+ // never the SSH exit code — so "my laptop's SSH dropped" or "a heavy npm
2009
+ // install outran a local budget" no longer masquerades as a broken bootstrap
2010
+ // and no longer gets a healthy VM deleted.
2011
+ const deadline = Date.now() + PROJECT_BOOTSTRAP_TIMEOUT_MS;
2012
+ for (;;) {
2013
+ runGcloud(
2014
+ buildGcloudArgs("ssh-script", cfg, { command: projectBootstrapFollowCommand(repoName) }),
2015
+ { stdio: "inherit", timeout: PROJECT_BOOTSTRAP_FOLLOW_TIMEOUT_MS },
2016
+ );
2017
+ const outcome = probeProjectBootstrapOutcome(cfg, repoName);
2018
+ const action = decideProjectBootstrapFollowAction({
2019
+ outcome,
2020
+ now: Date.now(),
2021
+ deadline,
2022
+ });
2023
+ if (action === "success") {
2024
+ console.error(` ✓ ${repoName} bootstrap complete`);
2025
+ return;
2026
+ }
2027
+ if (action === "failed") {
2028
+ const tail = projectBootstrapLogTail(cfg, repoName);
2029
+ throw new Error(
2030
+ "scripts/gcp-project-bootstrap.sh failed on the VM. " +
2031
+ `Last lines of ${projectBootstrapLogPath(repoName)}:\n${tail}`,
2032
+ );
2033
+ }
2034
+ if (action === "timeout") {
2035
+ // The install is STILL RUNNING on a healthy VM and we simply stopped
2036
+ // waiting. This message is the signature npm/fleet-add-failure.js keys on
2037
+ // to classify the add as `project-bootstrap-timeout` (retryable) instead of
2038
+ // the non-retryable generic cloud-up-failed that tore down VM-8.
2039
+ throw new Error(
2040
+ `project bootstrap exceeded the local ${Math.round(PROJECT_BOOTSTRAP_TIMEOUT_MS / 60000)}-minute budget ` +
2041
+ `— the VM and its detached bootstrap may be healthy and still running. ` +
2042
+ `Re-run to re-attach (each bootstrap step is skip-on-exists), or inspect ` +
2043
+ `${projectBootstrapLogPath(repoName)} on the VM.`,
2044
+ );
2045
+ }
2046
+ // action === "reattach": the follow session ended but the install is alive
2047
+ // (or the probe could not see it yet). Re-attach to its log.
2048
+ console.error(" ↺ follow session ended but the project bootstrap is still running — re-attaching...");
1684
2049
  }
1685
2050
  }
1686
2051
 
@@ -4040,9 +4405,15 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
4040
4405
  zone: cfg.zone,
4041
4406
  slug,
4042
4407
  });
4408
+ // Bound the per-VM status probe: a hung `bash vm-ingress.sh <N> status`
4409
+ // (its gcloud describes stalling on an IAP/metadata hiccup) must not stall the
4410
+ // whole sweep. On timeout res.stdout is empty, so parseIngressStatusVerdict
4411
+ // returns null → an `unknown` verdict → selectIngressReconcileTargets SKIPS
4412
+ // this VM and the sweep continues to the next one.
4043
4413
  const res = spawnSync(inv.command, inv.args, {
4044
4414
  env: { ...process.env, ...inv.env },
4045
4415
  encoding: "utf8",
4416
+ timeout: GCLOUD_CALL_TIMEOUT_MS,
4046
4417
  });
4047
4418
  return { vmNumber: n, slug, verdict: parseIngressStatusVerdict(res.stdout || "") };
4048
4419
  });
@@ -4097,7 +4468,13 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
4097
4468
  for (const surface of gi.SURFACES) {
4098
4469
  const bes = gi.backendServiceName(n, surface);
4099
4470
  const inv = backendServiceTimeoutInvocation({ bes, project: cfg.project });
4100
- const res = spawnSync(inv.command, inv.args, { encoding: "utf8" });
4471
+ // Bound the per-BES converge too: a hung `gcloud backend-services update`
4472
+ // returns a non-zero (timed-out) result, so it falls into the skip branch
4473
+ // below and the sweep moves on to the next BES rather than stalling.
4474
+ const res = spawnSync(inv.command, inv.args, {
4475
+ encoding: "utf8",
4476
+ timeout: GCLOUD_CALL_TIMEOUT_MS,
4477
+ });
4101
4478
  if (res.status === 0) {
4102
4479
  console.error(` ${bes}: timeout → 86400`);
4103
4480
  } else {
@@ -4342,7 +4719,14 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
4342
4719
  });
4343
4720
  const res = run(inv.command, inv.args, { encoding: "utf8", env: { ...process.env, ...inv.env } });
4344
4721
  const verdict = parseIngressTeardownVerdict((res && res.stdout) || "");
4345
- const outcome = classifyOrphanDownResult({ verdict, status: res ? res.status : null });
4722
+ // Pass `signal` too: a teardown the caller's group-kill reaped has a NULL
4723
+ // status, which the classifier would otherwise report as `spawn-failed`
4724
+ // rather than the truthful `killed=SIGKILL`.
4725
+ const outcome = classifyOrphanDownResult({
4726
+ verdict,
4727
+ status: res ? res.status : null,
4728
+ signal: res ? res.signal : null,
4729
+ });
4346
4730
  if (outcome.cleaned) {
4347
4731
  summary.cleaned.push(n);
4348
4732
  console.error(` VM-${n}: orphaned ingress → auto-cleaned (verified clean)`);
@@ -4663,6 +5047,7 @@ async function cloudMain() {
4663
5047
 
4664
5048
  module.exports = {
4665
5049
  // pure helpers
5050
+ isSpawnTimeout,
4666
5051
  agentEnvVarName,
4667
5052
  agentNpmPackage,
4668
5053
  AGENT_ENV_VARS,
@@ -4732,6 +5117,22 @@ module.exports = {
4732
5117
  decideBootstrapOutcome,
4733
5118
  projectBootstrapDoneMarker,
4734
5119
  projectBootstrapDoneProbeCommand,
5120
+ projectBootstrapScriptPath,
5121
+ projectBootstrapRunnerPath,
5122
+ projectBootstrapLogPath,
5123
+ projectBootstrapPidPath,
5124
+ projectBootstrapStatusPath,
5125
+ projectBootstrapRunnerScript,
5126
+ projectBootstrapLaunchCommand,
5127
+ projectBootstrapFollowCommand,
5128
+ projectBootstrapStatusCommand,
5129
+ parseProjectBootstrapStatus,
5130
+ decideProjectBootstrapOutcome,
5131
+ decideProjectBootstrapFollowAction,
5132
+ projectBootstrapEnv,
5133
+ PROJECT_BOOTSTRAP_LAUNCHED_MARKER,
5134
+ PROJECT_BOOTSTRAP_FOLLOW_SECS,
5135
+ ensureProjectBootstrapped,
4735
5136
  SSH_KEEPALIVE_FLAGS,
4736
5137
  resolveSetupForwardEnv,
4737
5138
  FORWARD_ENV_KEYS_ENV,