@codeyam-editor/codeyam-editor 0.1.0-staging.4d8da9a → 0.1.0-staging.6286eae
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-branch-switch.js +1 -1
- package/npm/cloud.js +295 -35
- package/npm/disk-pressure.js +17 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-cleanup.js +52 -4
- package/npm/fleet-roster.js +6 -8
- package/npm/launch-config.js +17 -3
- package/npm/scenario-check.js +324 -12
- package/npm/scenario-metrics.js +37 -0
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +158 -25
- package/npm/script-drift.js +81 -0
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-B60iuMwu.js +189 -0
- package/ui/dist/assets/{ScenarioDataPanel-xWYGZOpo.js → ScenarioDataPanel-DcQsF60A.js} +1 -1
- package/ui/dist/assets/{ScenarioDataPanelFullPage-5G6KuZgw.js → ScenarioDataPanelFullPage-C96bhzRK.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CR_HPJIi.js +1 -0
- package/ui/dist/assets/index-CmHp_byD.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-vG9NyAKQ.js} +2 -2
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
package/npm/cloud.js
CHANGED
|
@@ -411,6 +411,46 @@ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
|
|
|
411
411
|
);
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
// A loopback / localhost previewOrigin: `http://127.0.0.1:<port>`,
|
|
415
|
+
// `http://localhost:<port>`, `http://[::1]:<port>`, with or without a port,
|
|
416
|
+
// path, or query. These are reachable only from the editor host itself (or a
|
|
417
|
+
// laptop `-L` forward), never from a remote browser — exactly the dead-end the
|
|
418
|
+
// dedicated_preview_origin fallback lands on when the seed was wrong-moded.
|
|
419
|
+
const LOOPBACK_ORIGIN_RE =
|
|
420
|
+
/^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?:[:/?#]|$)/i;
|
|
421
|
+
|
|
422
|
+
/** Decide whether a VM's current `editor.local.json` carries a WRONG-MODE
|
|
423
|
+
* previewOrigin for a stack that is NOT same-origin-safe (Next.js etc.) and so
|
|
424
|
+
* must be self-healed to a dedicated origin. This is the inverse of
|
|
425
|
+
* `isRottedManagedPreviewOrigin`: it fires when the stack.json race froze the
|
|
426
|
+
* same-origin-safe default (or a loopback bootstrap) onto a client-routed
|
|
427
|
+
* stack — the VM7 failure where the iframe shows `127.0.0.1 refused to
|
|
428
|
+
* connect`. True iff BOTH hold:
|
|
429
|
+
* - the stack is NOT same-origin-safe (a same-origin-safe stack legitimately
|
|
430
|
+
* keeps the `/__codeyam_preview` subpath — never heal it; that is
|
|
431
|
+
* `isRottedManagedPreviewOrigin`'s job in the other direction); and
|
|
432
|
+
* - the `previewOrigin` is the same-origin subpath sentinel, any other
|
|
433
|
+
* relative path, or a loopback/localhost origin — none of which a remote
|
|
434
|
+
* browser can load for a dedicated-origin stack. A real dedicated origin
|
|
435
|
+
* (an absolute non-loopback URL — quick-tunnel or named-tunnel host) is
|
|
436
|
+
* left alone, so re-running the heal once a good origin is in place is a
|
|
437
|
+
* no-op.
|
|
438
|
+
* Symmetric with `isRottedManagedPreviewOrigin` and tested as a pair. Pure —
|
|
439
|
+
* `localJson` is a parsed editor.local.json object. */
|
|
440
|
+
function isWrongModePreviewOrigin(localJson, sameOriginSafe) {
|
|
441
|
+
if (sameOriginSafe) return false;
|
|
442
|
+
if (!localJson || typeof localJson !== "object") return false;
|
|
443
|
+
const origin = localJson.previewOrigin;
|
|
444
|
+
if (typeof origin !== "string" || origin.length === 0) return false;
|
|
445
|
+
// The reserved same-origin subpath, or any relative path (no scheme): these
|
|
446
|
+
// recurse into the editor SPA for a client-routed stack.
|
|
447
|
+
if (origin === SAME_ORIGIN_PREVIEW_PATH) return true;
|
|
448
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(origin)) return true;
|
|
449
|
+
// An absolute loopback/localhost origin: reachable only from the host, dead
|
|
450
|
+
// for a remote browser.
|
|
451
|
+
return LOOPBACK_ORIGIN_RE.test(origin);
|
|
452
|
+
}
|
|
453
|
+
|
|
414
454
|
/** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
|
|
415
455
|
* → {} (the monitor may not have run yet; the seed falls back to localhost). */
|
|
416
456
|
function readCloudflaredUrls() {
|
|
@@ -838,6 +878,14 @@ function loadOrInitCloudConfig(projectDir, flags) {
|
|
|
838
878
|
const persist = (c) => { const { noTunnel, ...rest } = c; return rest; };
|
|
839
879
|
if (existing) {
|
|
840
880
|
const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags);
|
|
881
|
+
const { isPlaceholderProject } = require("./launch-config");
|
|
882
|
+
if (isPlaceholderProject(merged.project)) {
|
|
883
|
+
const configPath = cloudConfigPath(projectDir);
|
|
884
|
+
throw new Error(
|
|
885
|
+
`Failed to load cloud config: project '${merged.project}' in ${configPath} is a test-fixture placeholder. ` +
|
|
886
|
+
`Please configure a real GCP project or set the GCP_PROJECT environment variable.`
|
|
887
|
+
);
|
|
888
|
+
}
|
|
841
889
|
const toSave = persist(merged);
|
|
842
890
|
if (JSON.stringify(toSave) !== JSON.stringify(existing)) {
|
|
843
891
|
saveCloudConfig(projectDir, toSave);
|
|
@@ -847,6 +895,15 @@ function loadOrInitCloudConfig(projectDir, flags) {
|
|
|
847
895
|
|
|
848
896
|
const cfg = reconcileProjectExclusivity({ ...flags }, flags);
|
|
849
897
|
cfg.project = cfg.project || gcloudGetValue("config-get-project");
|
|
898
|
+
|
|
899
|
+
const { isPlaceholderProject } = require("./launch-config");
|
|
900
|
+
if (isPlaceholderProject(cfg.project)) {
|
|
901
|
+
throw new Error(
|
|
902
|
+
`Failed to load cloud config: resolved project '${cfg.project}' is a test-fixture placeholder. ` +
|
|
903
|
+
`Please configure a real GCP project or set the GCP_PROJECT environment variable.`
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
850
907
|
cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
|
|
851
908
|
cfg.agent = cfg.agent || "claude";
|
|
852
909
|
agentEnvVarName(cfg.agent);
|
|
@@ -1651,27 +1708,43 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
|
1651
1708
|
);
|
|
1652
1709
|
|
|
1653
1710
|
// Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
|
|
1654
|
-
// is normally a user pin or a good prior seed and is left untouched.
|
|
1655
|
-
//
|
|
1656
|
-
//
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
1659
|
-
//
|
|
1660
|
-
//
|
|
1661
|
-
//
|
|
1711
|
+
// is normally a user pin or a good prior seed and is left untouched. There are
|
|
1712
|
+
// TWO symmetric heal exceptions, both of which the frozen skip-guard would
|
|
1713
|
+
// otherwise pin forever:
|
|
1714
|
+
// 1. a ROTTED managed cloudflared quick-tunnel URL on a same-origin-safe VM
|
|
1715
|
+
// (the VM-1/2/6 leftover from before the improve38 same-origin
|
|
1716
|
+
// migration): that frozen URL points at a dead rotated tunnel (NXDOMAIN /
|
|
1717
|
+
// blank preview), so we heal it back to the same-origin path; and
|
|
1718
|
+
// 2. a WRONG-MODE previewOrigin on a NOT-same-origin-safe VM (the VM7 race:
|
|
1719
|
+
// the first seed ran before onboarding wrote stack.json, defaulted to
|
|
1720
|
+
// `sameOriginSafe: true`, and seeded `/__codeyam_preview` — which a
|
|
1721
|
+
// remote browser then loads as `127.0.0.1 refused to connect`). Once
|
|
1722
|
+
// stack.json exists, heal it forward to the dedicated origin.
|
|
1723
|
+
// Because every dashboard action (Reset / Update / Add / bootstrap-recovery)
|
|
1724
|
+
// re-runs this seed over cloud:up, either heal reaches every touch-point the
|
|
1725
|
+
// moment stack.json is present — closing the race without re-ordering
|
|
1726
|
+
// provisioning, and without a separate per-VM hand-fix.
|
|
1662
1727
|
const originAlreadySet =
|
|
1663
1728
|
typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
|
|
1664
1729
|
const healing =
|
|
1665
|
-
originAlreadySet &&
|
|
1730
|
+
originAlreadySet &&
|
|
1731
|
+
(isRottedManagedPreviewOrigin(current, sameOriginSafe) ||
|
|
1732
|
+
isWrongModePreviewOrigin(current, sameOriginSafe));
|
|
1666
1733
|
if (originAlreadySet && !healing) {
|
|
1667
1734
|
return;
|
|
1668
1735
|
}
|
|
1669
1736
|
|
|
1670
|
-
// On heal,
|
|
1671
|
-
//
|
|
1672
|
-
//
|
|
1673
|
-
// the
|
|
1674
|
-
|
|
1737
|
+
// On heal, reconcile the `_managedPreviewOrigin` marker to the healed value's
|
|
1738
|
+
// own shape. The rotted→same-origin heal (seed carries NO marker) DROPS any
|
|
1739
|
+
// stale marker (deepMergeJson deletes a key whose overlay value is null) — the
|
|
1740
|
+
// constant same-origin path has nothing for the monitor to manage, exactly
|
|
1741
|
+
// matching the manual `config-override --unset _managedPreviewOrigin` fix. The
|
|
1742
|
+
// wrong-mode→dedicated heal (seed carries the marker) KEEPS it — that origin
|
|
1743
|
+
// IS managed (the monitor's gated rotation push owns it), so the marker must
|
|
1744
|
+
// survive so the next respawn recognises the value as ours.
|
|
1745
|
+
const overlay = healing
|
|
1746
|
+
? { ...seed, _managedPreviewOrigin: seed._managedPreviewOrigin ?? null }
|
|
1747
|
+
: seed;
|
|
1675
1748
|
console.error(
|
|
1676
1749
|
healing
|
|
1677
1750
|
? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
|
|
@@ -1691,6 +1764,82 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
|
1691
1764
|
}
|
|
1692
1765
|
}
|
|
1693
1766
|
|
|
1767
|
+
/** READY-probe auto-heal: after the editor container is verifiably Up, ask the
|
|
1768
|
+
* running editor's own `/api/config` for its stack verdict and effective
|
|
1769
|
+
* preview origin, and heal a wrong-moded seed in place — never failing the add.
|
|
1770
|
+
*
|
|
1771
|
+
* The race this closes (VM7, 2026-06-10): the pre-container `seedPreviewOrigin`
|
|
1772
|
+
* at line ~1906 runs BEFORE onboarding writes stack.json, so it defaults to
|
|
1773
|
+
* `sameOriginSafe: true` and seeds the `/__codeyam_preview` subpath. For a
|
|
1774
|
+
* client-routed stack (Next.js) the editor then reports `previewSameOriginSafe:
|
|
1775
|
+
* false` but, with only the subpath in editor.local.json, falls its
|
|
1776
|
+
* `dedicated_preview_origin` back to `http://127.0.0.1:<appPort>` — which a
|
|
1777
|
+
* remote browser renders as "refused to connect". If stack.json appeared since
|
|
1778
|
+
* the first seed (the editor reads it live from disk), we re-run the seed — now
|
|
1779
|
+
* the wrong-mode heal fires (`isWrongModePreviewOrigin`) — and POST
|
|
1780
|
+
* `/api/editor-reload-config` so the running server serves the healed origin
|
|
1781
|
+
* with no restart.
|
|
1782
|
+
*
|
|
1783
|
+
* Best-effort and non-fatal by contract: a probe failure, a still-absent
|
|
1784
|
+
* stack.json (verdict stays `true`), or a heal that can only reach a loopback
|
|
1785
|
+
* bootstrap origin (the operator's `<N>-preview` tunnel not published yet) all
|
|
1786
|
+
* leave the add green. The cloudflared-monitor's periodic tick (section 2) is
|
|
1787
|
+
* the durable owner that heals post-add flips — onboarding finishing minutes
|
|
1788
|
+
* after READY. */
|
|
1789
|
+
async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT) {
|
|
1790
|
+
const probe = runGcloud(
|
|
1791
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1792
|
+
command: `curl -fsS --max-time 5 http://localhost:${EDITOR_PORT}/api/config 2>/dev/null || echo '{}'`,
|
|
1793
|
+
}),
|
|
1794
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1795
|
+
);
|
|
1796
|
+
if (probe.status !== 0 || typeof probe.stdout !== "string") {
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
let config;
|
|
1800
|
+
try {
|
|
1801
|
+
config = JSON.parse(probe.stdout);
|
|
1802
|
+
} catch {
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
if (!config || typeof config !== "object") return;
|
|
1806
|
+
|
|
1807
|
+
// The editor's own verdict (section 3's `/api/config` field) plus the
|
|
1808
|
+
// wrong-mode predicate (a subpath / relative / loopback origin on a
|
|
1809
|
+
// not-same-origin-safe stack) — the exact dead-end the dedicated-origin
|
|
1810
|
+
// fallback lands on. Same-origin-safe stacks (verdict true) never enter here.
|
|
1811
|
+
if (config.previewSameOriginSafe !== false) return;
|
|
1812
|
+
if (!isWrongModePreviewOrigin(config, false)) return;
|
|
1813
|
+
|
|
1814
|
+
console.error(
|
|
1815
|
+
`READY auto-heal: ${cfg.instanceName} reports a not-same-origin-safe stack ` +
|
|
1816
|
+
`but a wrong-mode previewOrigin (${config.previewOrigin}); re-seeding...`,
|
|
1817
|
+
);
|
|
1818
|
+
// Re-run the seed: stack.json is now readable, so the wrong-mode heal writes
|
|
1819
|
+
// the dedicated origin into editor.local.json (a published `<N>-preview`
|
|
1820
|
+
// tunnel URL, else the localhost bootstrap for the window before the monitor's
|
|
1821
|
+
// first tick).
|
|
1822
|
+
await seedPreviewOrigin(cfg, appPort);
|
|
1823
|
+
// Tell the running server to re-read editor.local.json — no restart (the
|
|
1824
|
+
// footgun that killed VM7's live Claude session).
|
|
1825
|
+
const reload = runGcloud(
|
|
1826
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1827
|
+
command: `curl -fsS --max-time 5 -X POST http://localhost:${EDITOR_PORT}/api/editor-reload-config 2>/dev/null || true`,
|
|
1828
|
+
}),
|
|
1829
|
+
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
1830
|
+
);
|
|
1831
|
+
if (reload.status !== 0) {
|
|
1832
|
+
console.error(
|
|
1833
|
+
` Warning: heal wrote editor.local.json but /api/editor-reload-config did ` +
|
|
1834
|
+
`not return cleanly — the next monitor tick or cloud:up touch-point will reconcile.`,
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
console.error(
|
|
1838
|
+
` Live Preview origin healed. If the operator <N>-preview tunnel is not ` +
|
|
1839
|
+
`published yet, the cloudflared-monitor's periodic tick provides the ongoing heal.`,
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1694
1843
|
/** Write the chosen agent provider and local API keys into editor.local.json on
|
|
1695
1844
|
* the VM BEFORE compose up so the file exists when the bind-mount happens —
|
|
1696
1845
|
* the provider seed MUST run before ensureEditorContainerUp, because the editor
|
|
@@ -1787,22 +1936,18 @@ async function ensureAgentCliInstalled(cfg) {
|
|
|
1787
1936
|
`AGENT="${agent}"`,
|
|
1788
1937
|
"",
|
|
1789
1938
|
"if command -v \"$BINARY\" >/dev/null 2>&1; then",
|
|
1790
|
-
"
|
|
1791
|
-
"
|
|
1792
|
-
" echo \" ✓ $BINARY already installed and executable at /usr/local/bin/claude\"",
|
|
1793
|
-
" exit 0",
|
|
1794
|
-
" fi",
|
|
1795
|
-
" else",
|
|
1796
|
-
" echo \" ✓ $BINARY already installed on PATH\"",
|
|
1797
|
-
" exit 0",
|
|
1798
|
-
" fi",
|
|
1939
|
+
" echo \" ✓ $BINARY already installed on PATH\"",
|
|
1940
|
+
" exit 0",
|
|
1799
1941
|
"fi",
|
|
1800
1942
|
"",
|
|
1801
1943
|
"echo \"Installing $PACKAGE globally in container...\"",
|
|
1802
1944
|
"if [ \"$AGENT\" = \"claude\" ]; then",
|
|
1803
1945
|
" rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
|
|
1804
1946
|
" npm install -g \"$PACKAGE\"",
|
|
1805
|
-
"
|
|
1947
|
+
" RESOLVED=$(find /usr/lib/node_modules/@anthropic-ai -name claude -type f -path '*linux-x64*' 2>/dev/null | head -1)",
|
|
1948
|
+
" if [ -n \"$RESOLVED\" ]; then",
|
|
1949
|
+
" ln -sf \"$RESOLVED\" /usr/local/bin/claude",
|
|
1950
|
+
" fi",
|
|
1806
1951
|
"else",
|
|
1807
1952
|
" npm install -g \"$PACKAGE\"",
|
|
1808
1953
|
"fi",
|
|
@@ -1864,19 +2009,117 @@ function promptForInstanceName(expected) {
|
|
|
1864
2009
|
});
|
|
1865
2010
|
}
|
|
1866
2011
|
|
|
2012
|
+
const STREAMED_SCRIPTS = ["scripts/gcp-bootstrap.sh", "scripts/gcp-project-bootstrap.sh"];
|
|
2013
|
+
|
|
2014
|
+
/** Check if the streamed bootstrap scripts have drifted from the remote repository
|
|
2015
|
+
* and abort provisioning if they are out of date to prevent silent container/VM failures. */
|
|
2016
|
+
function checkScriptDrift(cfg) {
|
|
2017
|
+
if (process.env.CODEYAM_SKIP_SCRIPT_DRIFT_CHECK === "1") {
|
|
2018
|
+
console.error(" [Warning] CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1 active. Skipping streamed-script drift preflight check.");
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const { editorBranch = "main" } = resolveEditorConfig(cfg);
|
|
2023
|
+
|
|
2024
|
+
console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
|
|
2025
|
+
|
|
2026
|
+
// 1. git fetch origin <editorBranch>
|
|
2027
|
+
const fetchResult = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
|
|
2028
|
+
if (fetchResult.status !== 0) {
|
|
2029
|
+
console.error(` [Warning] Failed to fetch origin ${editorBranch} (${(fetchResult.stderr || "").trim() || "unknown error"}). Skipping drift check.`);
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// 2. Find the merge base between HEAD and origin/<editorBranch>
|
|
2034
|
+
const mbResult = spawnSync("git", ["merge-base", "HEAD", `origin/${editorBranch}`], { encoding: "utf8" });
|
|
2035
|
+
let mergeBase = null;
|
|
2036
|
+
if (mbResult.status === 0) {
|
|
2037
|
+
mergeBase = mbResult.stdout.trim();
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
const filesData = [];
|
|
2041
|
+
|
|
2042
|
+
for (const scriptPath of STREAMED_SCRIPTS) {
|
|
2043
|
+
// a. localHash
|
|
2044
|
+
const localHashResult = spawnSync("git", ["hash-object", scriptPath], { encoding: "utf8" });
|
|
2045
|
+
if (localHashResult.status !== 0) {
|
|
2046
|
+
console.error(` [Warning] Failed to compute local hash for ${scriptPath}. Skipping drift check.`);
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
const localHash = localHashResult.stdout.trim();
|
|
2050
|
+
|
|
2051
|
+
// b. remoteHash (from origin/<editorBranch>)
|
|
2052
|
+
let remoteHash = null;
|
|
2053
|
+
const remoteHashResult = spawnSync("git", ["rev-parse", `origin/${editorBranch}:${scriptPath}`], { encoding: "utf8" });
|
|
2054
|
+
if (remoteHashResult.status === 0) {
|
|
2055
|
+
remoteHash = remoteHashResult.stdout.trim();
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// c. baseHash (from mergeBase)
|
|
2059
|
+
let baseHash = null;
|
|
2060
|
+
if (mergeBase) {
|
|
2061
|
+
const baseHashResult = spawnSync("git", ["rev-parse", `${mergeBase}:${scriptPath}`], { encoding: "utf8" });
|
|
2062
|
+
if (baseHashResult.status === 0) {
|
|
2063
|
+
baseHash = baseHashResult.stdout.trim();
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// d. dirty status
|
|
2068
|
+
const statusResult = spawnSync("git", ["status", "--porcelain", scriptPath], { encoding: "utf8" });
|
|
2069
|
+
const dirty = statusResult.status === 0 && statusResult.stdout.trim() !== "";
|
|
2070
|
+
|
|
2071
|
+
filesData.push({
|
|
2072
|
+
path: scriptPath,
|
|
2073
|
+
localHash,
|
|
2074
|
+
remoteHash,
|
|
2075
|
+
baseHash,
|
|
2076
|
+
dirty
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
const { classifyScriptDrift } = require("./script-drift");
|
|
2081
|
+
const verdictResult = classifyScriptDrift({ files: filesData });
|
|
2082
|
+
|
|
2083
|
+
if (verdictResult.verdict === "stale" || verdictResult.verdict === "diverged") {
|
|
2084
|
+
console.error("");
|
|
2085
|
+
console.error("================================================================================");
|
|
2086
|
+
console.error(`✗ ERROR: Streamed scripts have drifted from origin/${editorBranch}!`);
|
|
2087
|
+
console.error(` Verdict: ${verdictResult.verdict.toUpperCase()}`);
|
|
2088
|
+
console.error(" The following files are out of date on your machine:");
|
|
2089
|
+
for (const f of verdictResult.staleFiles) {
|
|
2090
|
+
console.error(` - ${f}`);
|
|
2091
|
+
}
|
|
2092
|
+
console.error("");
|
|
2093
|
+
console.error(" To proceed safely, please update your local checkout first:");
|
|
2094
|
+
console.error(` git pull --no-rebase origin ${editorBranch}`);
|
|
2095
|
+
console.error("");
|
|
2096
|
+
console.error(" If you must bypass this check (emergency provisioning), set:");
|
|
2097
|
+
console.error(" CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1");
|
|
2098
|
+
console.error("================================================================================");
|
|
2099
|
+
console.error("");
|
|
2100
|
+
process.exit(1);
|
|
2101
|
+
} else if (verdictResult.verdict === "local-ahead") {
|
|
2102
|
+
console.error(" [Warning] Streaming locally-modified scripts to VM (developer mode).");
|
|
2103
|
+
} else {
|
|
2104
|
+
console.error(" ✓ Streamed scripts are in sync with remote.");
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
1867
2108
|
// =============================================================================
|
|
1868
2109
|
// Subcommand handlers
|
|
1869
2110
|
// =============================================================================
|
|
1870
2111
|
|
|
1871
|
-
async function cmdUp(args) {
|
|
2112
|
+
async function cmdUp(args, projectDir = rootDir) {
|
|
1872
2113
|
ensureGcloud();
|
|
1873
2114
|
const flags = parseFlags(args);
|
|
1874
|
-
const cfg = loadOrInitCloudConfig(
|
|
2115
|
+
const cfg = loadOrInitCloudConfig(projectDir, flags);
|
|
1875
2116
|
// Fail loud BEFORE creating a GCE instance if an unprovisionable editor
|
|
1876
2117
|
// variant was requested — otherwise we'd spin up a VM, build a dev image,
|
|
1877
2118
|
// and silently ignore the operator's staging/production intent.
|
|
1878
2119
|
assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
|
|
1879
2120
|
|
|
2121
|
+
checkScriptDrift(cfg);
|
|
2122
|
+
|
|
1880
2123
|
await ensureFirewallRule(cfg);
|
|
1881
2124
|
const { createdThisRun } = await ensureInstance(cfg);
|
|
1882
2125
|
|
|
@@ -1904,6 +2147,18 @@ async function cmdUp(args) {
|
|
|
1904
2147
|
// us — a run reported success with no container). A failure here is a loud
|
|
1905
2148
|
// non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
|
|
1906
2149
|
await assertEditorContainerRunning(cfg);
|
|
2150
|
+
// Now that the editor answers on its own surface, ask it whether the
|
|
2151
|
+
// pre-container seed wrong-moded the previewOrigin (the stack.json race) and
|
|
2152
|
+
// heal it in place. Best-effort, never fatal — wrapped so a probe hiccup
|
|
2153
|
+
// can't abort an otherwise-green add.
|
|
2154
|
+
try {
|
|
2155
|
+
await healPreviewOriginAtReady(cfg, appPort);
|
|
2156
|
+
} catch (healErr) {
|
|
2157
|
+
console.error(
|
|
2158
|
+
` Warning: READY preview-origin auto-heal probe failed (${healErr.message}); ` +
|
|
2159
|
+
`the cloudflared-monitor tick will reconcile.`,
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
1907
2162
|
} catch (err) {
|
|
1908
2163
|
if (createdThisRun) {
|
|
1909
2164
|
console.error(
|
|
@@ -1940,10 +2195,10 @@ async function cmdUp(args) {
|
|
|
1940
2195
|
attachKeepAliveAndTeardown(tunnelChild);
|
|
1941
2196
|
}
|
|
1942
2197
|
|
|
1943
|
-
async function cmdTunnel(args) {
|
|
2198
|
+
async function cmdTunnel(args, projectDir = rootDir) {
|
|
1944
2199
|
ensureGcloud();
|
|
1945
2200
|
const flags = parseFlags(args);
|
|
1946
|
-
const existing = loadCloudConfig(
|
|
2201
|
+
const existing = loadCloudConfig(projectDir);
|
|
1947
2202
|
if (!existing) {
|
|
1948
2203
|
throw new Error(
|
|
1949
2204
|
"No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
|
|
@@ -1971,10 +2226,10 @@ async function cmdTunnel(args) {
|
|
|
1971
2226
|
attachKeepAliveAndTeardown(tunnelChild);
|
|
1972
2227
|
}
|
|
1973
2228
|
|
|
1974
|
-
async function cmdStop(args) {
|
|
2229
|
+
async function cmdStop(args, projectDir = rootDir) {
|
|
1975
2230
|
ensureGcloud();
|
|
1976
2231
|
parseFlags(args);
|
|
1977
|
-
const cfg = loadCloudConfig(
|
|
2232
|
+
const cfg = loadCloudConfig(projectDir);
|
|
1978
2233
|
if (!cfg) {
|
|
1979
2234
|
throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
|
|
1980
2235
|
}
|
|
@@ -1990,10 +2245,10 @@ async function cmdStop(args) {
|
|
|
1990
2245
|
);
|
|
1991
2246
|
}
|
|
1992
2247
|
|
|
1993
|
-
async function cmdDestroy(args) {
|
|
2248
|
+
async function cmdDestroy(args, projectDir = rootDir) {
|
|
1994
2249
|
ensureGcloud();
|
|
1995
2250
|
parseFlags(args);
|
|
1996
|
-
const cfg = loadCloudConfig(
|
|
2251
|
+
const cfg = loadCloudConfig(projectDir);
|
|
1997
2252
|
if (!cfg) {
|
|
1998
2253
|
throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
|
|
1999
2254
|
}
|
|
@@ -2027,17 +2282,17 @@ async function cmdDestroy(args) {
|
|
|
2027
2282
|
}
|
|
2028
2283
|
|
|
2029
2284
|
try {
|
|
2030
|
-
fs.unlinkSync(cloudConfigPath(
|
|
2285
|
+
fs.unlinkSync(cloudConfigPath(projectDir));
|
|
2031
2286
|
console.error("Deleted .codeyam/cloud.json");
|
|
2032
2287
|
} catch (e) {
|
|
2033
2288
|
if (e.code !== "ENOENT") throw e;
|
|
2034
2289
|
}
|
|
2035
2290
|
}
|
|
2036
2291
|
|
|
2037
|
-
async function cmdStatus(args) {
|
|
2292
|
+
async function cmdStatus(args, projectDir = rootDir) {
|
|
2038
2293
|
ensureGcloud();
|
|
2039
2294
|
parseFlags(args);
|
|
2040
|
-
const cfg = loadCloudConfig(
|
|
2295
|
+
const cfg = loadCloudConfig(projectDir);
|
|
2041
2296
|
if (!cfg) {
|
|
2042
2297
|
console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
2043
2298
|
return;
|
|
@@ -2131,6 +2386,7 @@ module.exports = {
|
|
|
2131
2386
|
resolveSeedPreviewOrigin,
|
|
2132
2387
|
previewSameOriginSafeFromStack,
|
|
2133
2388
|
isRottedManagedPreviewOrigin,
|
|
2389
|
+
isWrongModePreviewOrigin,
|
|
2134
2390
|
resolveEditorConfig,
|
|
2135
2391
|
validateEditorVariant,
|
|
2136
2392
|
assertVariantProvisionable,
|
|
@@ -2143,9 +2399,11 @@ module.exports = {
|
|
|
2143
2399
|
cloudConfigPath,
|
|
2144
2400
|
loadCloudConfig,
|
|
2145
2401
|
saveCloudConfig,
|
|
2402
|
+
loadOrInitCloudConfig,
|
|
2146
2403
|
buildEditorLocalEnvPatch,
|
|
2147
2404
|
buildEditorLocalSeedWriteCommand,
|
|
2148
2405
|
seedPreviewOrigin,
|
|
2406
|
+
healPreviewOriginAtReady,
|
|
2149
2407
|
buildGcloudArgs,
|
|
2150
2408
|
sshScriptCommand,
|
|
2151
2409
|
bootstrapRunnerScript,
|
|
@@ -2167,6 +2425,8 @@ module.exports = {
|
|
|
2167
2425
|
ensureInstance,
|
|
2168
2426
|
ensureBootstrapped,
|
|
2169
2427
|
assertEditorContainerRunning,
|
|
2428
|
+
checkScriptDrift,
|
|
2429
|
+
STREAMED_SCRIPTS,
|
|
2170
2430
|
// side-effecting handlers
|
|
2171
2431
|
cmdUp,
|
|
2172
2432
|
cmdTunnel,
|
package/npm/disk-pressure.js
CHANGED
|
@@ -80,10 +80,27 @@ function parseDfUsedPct(dfOutput) {
|
|
|
80
80
|
return null;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Classify a disk-guard sidecar age (updatedAt vs current time).
|
|
84
|
+
// If the sidecar is older than 600s (10 min), classify it as stale.
|
|
85
|
+
function classifySidecarAge(updatedAt, currentEpoch) {
|
|
86
|
+
if (typeof updatedAt !== 'number' || typeof currentEpoch !== 'number') return 'stale';
|
|
87
|
+
const age = currentEpoch - updatedAt;
|
|
88
|
+
if (age > 600) return 'stale';
|
|
89
|
+
return 'healthy';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Map or classify a general disk state level including 'unknown-exec-failed'
|
|
93
|
+
function mapDiskStateLevel(usedPct, hasExecError) {
|
|
94
|
+
if (hasExecError) return 'unknown-exec-failed';
|
|
95
|
+
return classifyDiskPressure(usedPct);
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
module.exports = {
|
|
84
99
|
DISK_WARN_PCT,
|
|
85
100
|
DISK_CRITICAL_PCT,
|
|
86
101
|
PRUNABLE_CACHE_DIRS,
|
|
87
102
|
classifyDiskPressure,
|
|
88
103
|
parseDfUsedPct,
|
|
104
|
+
classifySidecarAge,
|
|
105
|
+
mapDiskStateLevel,
|
|
89
106
|
};
|
package/npm/fleet-auth.js
CHANGED
|
@@ -108,4 +108,9 @@ function deriveDefaultAuthAccount(vms, lastAuthAt) {
|
|
|
108
108
|
return best ? best.account : null;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
// Return true if this one VM is eligible for an auth-start action now.
|
|
112
|
+
function isAuthStartable(v, job) {
|
|
113
|
+
return classifyAuthVm(v, job).eligible;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount, isAuthStartable };
|
package/npm/fleet-cleanup.js
CHANGED
|
@@ -297,11 +297,59 @@ function orphanedAddsToReconcile({ jobs, sidecarKeys } = {}) {
|
|
|
297
297
|
// posture). "Busy" (a live session or an in-flight add to this number) also
|
|
298
298
|
// holds the entry for an operator rather than auto-removing — the shouldn't-
|
|
299
299
|
// happen-but-fail-safe case where a vanished instance still reports a session.
|
|
300
|
-
|
|
300
|
+
// verdict when dispatch
|
|
301
|
+
// 'cleanup' instance gone AND the VM is idle runDestroyCleanup
|
|
302
|
+
// 'leave' exists uncertain (null), OR the VM is busy do nothing this pass
|
|
303
|
+
// 'keep' instance still exists do nothing (healthy)
|
|
304
|
+
// 'reset' existing idle instance unreachable too long trigger auto-reset
|
|
305
|
+
// Fail-safe by construction: cleanup fires ONLY on a DEFINITIVE absence
|
|
306
|
+
// (exists === false) — a flaky/transient describe (null) is always 'leave', so a
|
|
307
|
+
// gcloud blip can never evict a healthy VM (mirroring reapVerdict's `leave`
|
|
308
|
+
// posture). "Busy" (a live session or an in-flight add to this number) also
|
|
309
|
+
// holds the entry for an operator rather than auto-removing — the shouldn't-
|
|
310
|
+
// happen-but-fail-safe case where a vanished instance still reports a session.
|
|
311
|
+
// auto-reset escalation (vm: OOM Wedge Prevention):
|
|
312
|
+
// When the VM has been unreachable for at least RESET_UNREACHABLE_THRESHOLD
|
|
313
|
+
// (e.g. 200, which is roughly 10 minutes at a 3s poll rate), we issue a 'reset'
|
|
314
|
+
// verdict. This auto-reset is guarded and bounded by a cooldown window (in ms)
|
|
315
|
+
// and a max attempts limit to prevent infinite reboot loops.
|
|
316
|
+
function reconcileRosteredVm({ exists, hasSession, hasPendingAdd, streak, resetHistory, threshold, cooldownMs, maxAttempts } = {}) {
|
|
301
317
|
if (exists === null || exists === undefined) return "leave";
|
|
302
|
-
if (exists)
|
|
303
|
-
|
|
304
|
-
|
|
318
|
+
if (!exists) {
|
|
319
|
+
if (hasSession || hasPendingAdd) return "leave";
|
|
320
|
+
return "cleanup";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// If the VM exists, check if it's dead-unreachable and eligible for auto-reset.
|
|
324
|
+
if (streak !== undefined && threshold !== undefined) {
|
|
325
|
+
if (streak >= threshold) {
|
|
326
|
+
if (hasSession || hasPendingAdd) {
|
|
327
|
+
// Do NOT auto-reset a VM that is busy (holding a live session or an in-flight add)
|
|
328
|
+
return "keep";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const history = resetHistory || { count: 0, lastResetAt: 0 };
|
|
332
|
+
const attempts = history.count || 0;
|
|
333
|
+
const limit = maxAttempts !== undefined ? maxAttempts : 3;
|
|
334
|
+
|
|
335
|
+
if (attempts >= limit) {
|
|
336
|
+
// Budget exhausted (VM wedges repeatedly) -> park (needs-manual-recovery status)
|
|
337
|
+
return "keep";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const lastReset = history.lastResetAt || 0;
|
|
341
|
+
const cooldown = cooldownMs !== undefined ? cooldownMs : 600000; // default 10 minutes
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
if (now - lastReset < cooldown) {
|
|
344
|
+
// Within cooldown window -> do not reset again yet
|
|
345
|
+
return "keep";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return "reset";
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return "keep";
|
|
305
353
|
}
|
|
306
354
|
|
|
307
355
|
module.exports = {
|
package/npm/fleet-roster.js
CHANGED
|
@@ -31,16 +31,14 @@ function normalizeRoster(arr) {
|
|
|
31
31
|
return [...new Set((arr || []).filter(Number.isInteger))].sort((a, b) => a - b);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
// Read the roster, returning a normalized integer array
|
|
35
|
-
//
|
|
36
|
-
// `readRoster`/`loadVmsState` duplicates shared) so a truncated write or an
|
|
37
|
-
// absent file never throws into a consumer's poll loop.
|
|
34
|
+
// Read the roster, returning a normalized integer array, or null if the file is
|
|
35
|
+
// missing or malformed.
|
|
38
36
|
function readRoster() {
|
|
39
37
|
try {
|
|
40
38
|
const data = JSON.parse(fs.readFileSync(VMS_STATE_FILE, 'utf8'));
|
|
41
39
|
if (Array.isArray(data)) return normalizeRoster(data);
|
|
42
|
-
} catch { /* missing or malformed
|
|
43
|
-
return
|
|
40
|
+
} catch { /* missing or malformed */ }
|
|
41
|
+
return null;
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
// Persist `arr` as the roster, atomically: write a temp file then `rename` it
|
|
@@ -62,13 +60,13 @@ function writeRoster(arr) {
|
|
|
62
60
|
// onSuccess rosters again — is a harmless no-op, which is exactly what closes
|
|
63
61
|
// the race where one writer's roster write could fail.
|
|
64
62
|
function addToRoster(n) {
|
|
65
|
-
return writeRoster([...readRoster(), n]);
|
|
63
|
+
return writeRoster([...(readRoster() || []), n]);
|
|
66
64
|
}
|
|
67
65
|
|
|
68
66
|
// Remove `n` from the roster, returning the new roster. Removing an absent
|
|
69
67
|
// entry is a no-op.
|
|
70
68
|
function removeFromRoster(n) {
|
|
71
|
-
return writeRoster(readRoster().filter((x) => x !== n));
|
|
69
|
+
return writeRoster((readRoster() || []).filter((x) => x !== n));
|
|
72
70
|
}
|
|
73
71
|
|
|
74
72
|
module.exports = {
|
package/npm/launch-config.js
CHANGED
|
@@ -123,6 +123,18 @@ function variantImageTag(variant) {
|
|
|
123
123
|
return null; // dev / unknown — no packaged image
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** Return true if the value matches a known test-fixture placeholder (e.g., "gcp-proj"),
|
|
127
|
+
* is null, empty, or whitespace, signaling that it is not a valid GCP project name.
|
|
128
|
+
* Documented as the home for future fixture sentinels. Pure; no I/O. */
|
|
129
|
+
function isPlaceholderProject(value) {
|
|
130
|
+
if (!value) return true;
|
|
131
|
+
if (typeof value !== "string") return true;
|
|
132
|
+
const trimmed = value.trim();
|
|
133
|
+
if (trimmed === "") return true;
|
|
134
|
+
if (trimmed.toLowerCase() === "gcp-proj") return true;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
126
138
|
/** Build the `cloud:up` flag array for a launch config + instance name. The
|
|
127
139
|
* editor source is the canonical codeyam-editor repo (dev variant builds the
|
|
128
140
|
* image from editorBranch); the client repo/branch is what lands in
|
|
@@ -137,9 +149,10 @@ function cloudUpArgs(cfg, instanceName, project) {
|
|
|
137
149
|
// a persisted .codeyam/cloud.json `project` (cloud.js merges
|
|
138
150
|
// `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
|
|
139
151
|
// gcloud fallback — the sticky-wrong-project failure mode where a stale
|
|
140
|
-
//
|
|
141
|
-
// PERMISSION_DENIED. Omitted when unset to
|
|
142
|
-
// (cloud.js then falls back to the gcloud
|
|
152
|
+
// placeholder in cloud.json (validated via isPlaceholderProject) wedges every
|
|
153
|
+
// re-provision with a firewall-create PERMISSION_DENIED. Omitted when unset to
|
|
154
|
+
// preserve the pre-existing arg shape (cloud.js then falls back to the gcloud
|
|
155
|
+
// default, as before).
|
|
143
156
|
if (project) args.push("--project", project);
|
|
144
157
|
// Greenfield (new folder) vs clone an existing repo.
|
|
145
158
|
if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
|
|
@@ -724,4 +737,5 @@ module.exports = {
|
|
|
724
737
|
decideStartJob,
|
|
725
738
|
getAuthRepoUrl,
|
|
726
739
|
interpretLsRemote,
|
|
740
|
+
isPlaceholderProject,
|
|
727
741
|
};
|