@codeyam-editor/codeyam-editor 0.1.0-staging.g6631ba2 → 0.1.0-staging.gc446a6f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm/cloud.js +181 -17
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +155 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-plans.js +38 -3
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-public-url.js +34 -0
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-vm-access.js +75 -7
- package/npm/fleet-vm-readiness.js +21 -4
- package/npm/ingress-invoke.js +84 -13
- package/npm/scenario-check.js +55 -1
- package/npm/scenario-handlers.js +15 -3
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-mocks.js +129 -26
- package/npm/scenario-playwright.js +75 -7
- package/npm/utils.js +37 -3
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-B3vO6op1.js → ContainerInspector-OQ1X2doF.js} +1 -1
- package/ui/dist/assets/EditorShell-D5woOSRy.css +1 -0
- package/ui/dist/assets/EditorShell-EKM2i9O9.js +232 -0
- package/ui/dist/assets/{ScenarioDataPanel-DIAw2DpS.js → ScenarioDataPanel-BU2tHhl4.js} +3 -3
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CibLNYsH.js → ScenarioDataPanelFullPage-BrbAtXE5.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-d3MLbcAe.js +21 -0
- package/ui/dist/assets/index-D3csSagX.css +1 -0
- package/ui/dist/assets/{index-B14nyUpe.js → index-iE2jFez3.js} +2 -2
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-CndrQhoL.js +0 -191
- package/ui/dist/assets/EditorShell-D7wGyetm.css +0 -1
- package/ui/dist/assets/ScenarioRenderer-rbCbaiLe.js +0 -21
- package/ui/dist/assets/index-DK-ZOfVw.css +0 -1
package/npm/cloud.js
CHANGED
|
@@ -81,6 +81,8 @@ const {
|
|
|
81
81
|
selectOrphanIngressTargets,
|
|
82
82
|
classifyOrphanDownResult,
|
|
83
83
|
backendServicesListInvocation,
|
|
84
|
+
negListInvocation,
|
|
85
|
+
instancesListInvocation,
|
|
84
86
|
ingressConvergeDecision,
|
|
85
87
|
detachedAddTailInvocation,
|
|
86
88
|
ingressInflightMarkerPath,
|
|
@@ -157,11 +159,17 @@ const KNOWN_FLAGS = [
|
|
|
157
159
|
// default is loud — a tokenless VM cannot sync plan claims and must never
|
|
158
160
|
// report healthy silently. Use only when push genuinely isn't wanted.
|
|
159
161
|
"allow-no-git-credentials",
|
|
162
|
+
// Provision the VM in admin mode: forward CODEYAM_ADMIN=1 into the container
|
|
163
|
+
// so the Home-tab Setup door renders. Equivalent to `CODEYAM_ADMIN=1 npm run
|
|
164
|
+
// cloud:up …`; absent = strict non-admin (the safe default). Survives
|
|
165
|
+
// re-provision because it rides the compose-render substitution, unlike a
|
|
166
|
+
// manual VM compose edit.
|
|
167
|
+
"admin",
|
|
160
168
|
];
|
|
161
169
|
|
|
162
170
|
// Flags that take no value (presence = true). parseFlags must not demand a
|
|
163
171
|
// value for these or `--no-tunnel` would error "requires a value".
|
|
164
|
-
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials"];
|
|
172
|
+
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials", "admin"];
|
|
165
173
|
|
|
166
174
|
const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
|
|
167
175
|
const INSTANCE_TAG = "codeyam-editor";
|
|
@@ -1494,6 +1502,43 @@ function bootstrapLogTail(cfg, lines = 40) {
|
|
|
1494
1502
|
return String(r.stdout || "").trim() || "(log unavailable)";
|
|
1495
1503
|
}
|
|
1496
1504
|
|
|
1505
|
+
/** Assemble the extra provision-time env forwarded into the bootstrap shell,
|
|
1506
|
+
* where it feeds the compose-render substitution. Currently just the admin
|
|
1507
|
+
* toggle: CODEYAM_ADMIN gates the Home-tab Setup door and is read from the
|
|
1508
|
+
* editor-server process env (handlers.rs::admin_enabled, strict literal "1").
|
|
1509
|
+
* The operator opts in with `CODEYAM_ADMIN=1 npm run cloud:up …` or `--admin`
|
|
1510
|
+
* (which lands as cfg.admin); anything else forwards "" so a normal launch —
|
|
1511
|
+
* and a re-provision without the flag — is strict non-admin. Pure over its
|
|
1512
|
+
* inputs so cloud.test.js can pin it without touching gcloud. */
|
|
1513
|
+
function buildProvisionEnv(env = process.env, flags = {}) {
|
|
1514
|
+
const admin = flags.admin === true || env.CODEYAM_ADMIN === "1";
|
|
1515
|
+
return { CODEYAM_ADMIN: admin ? "1" : "" };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** Build the one-shot SSH shell command that reconciles a running VM's admin
|
|
1519
|
+
* mode without a full re-provision: rewrite the rendered compose's
|
|
1520
|
+
* CODEYAM_ADMIN line to `value` — or insert it under `environment:` on a legacy
|
|
1521
|
+
* VM whose compose predates the placeholder — then recreate the editor service
|
|
1522
|
+
* so admin_enabled() re-reads it. `value` is always "1" or "" (the caller
|
|
1523
|
+
* normalizes), so inlining it into sed is safe. Pure/string-only so
|
|
1524
|
+
* cloud.test.js can assert its shape without SSHing. */
|
|
1525
|
+
function buildReconcileAdminCommand(repoName, value) {
|
|
1526
|
+
const f = "docker-compose.cloud.yml";
|
|
1527
|
+
const entry = ` - CODEYAM_ADMIN=${value}`;
|
|
1528
|
+
// Capture only the leading indentation (\1) and re-emit the literal key, so a
|
|
1529
|
+
// value that starts with a digit (e.g. "1") never lands right after the
|
|
1530
|
+
// backreference — `\11` would be read by sed as group 11, not `\1` + "1".
|
|
1531
|
+
return (
|
|
1532
|
+
`cd ~/projects/${repoName} && ` +
|
|
1533
|
+
`if grep -qE '^[[:space:]]*- CODEYAM_ADMIN=' ${f}; then ` +
|
|
1534
|
+
`sed -i -E 's#^([[:space:]]*)- CODEYAM_ADMIN=.*#\\1- CODEYAM_ADMIN=${value}#' ${f}; ` +
|
|
1535
|
+
`else ` +
|
|
1536
|
+
`sed -i -E 's#^([[:space:]]*environment:[[:space:]]*)$#\\1\\n${entry}#' ${f}; ` +
|
|
1537
|
+
`fi && ` +
|
|
1538
|
+
`docker compose -f ${f} up -d editor`
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1497
1542
|
async function ensureProjectBootstrapped(cfg) {
|
|
1498
1543
|
const repoName = resolveProject(cfg).name;
|
|
1499
1544
|
console.error(`Checking project bootstrap state for ${repoName}...`);
|
|
@@ -1547,12 +1592,18 @@ async function ensureProjectBootstrapped(cfg) {
|
|
|
1547
1592
|
const ingressEnv = {};
|
|
1548
1593
|
const ingressPlan = ingressWiringPlan(cfg);
|
|
1549
1594
|
if (ingressPlan.wire) ingressEnv.FLEET_DOMAIN = ingressPlan.domain;
|
|
1595
|
+
// Forward the admin-mode toggle (Home-tab Setup door) into the compose
|
|
1596
|
+
// render. cfg.admin carries the `--admin` flag; process.env.CODEYAM_ADMIN
|
|
1597
|
+
// carries the `CODEYAM_ADMIN=1 npm run cloud:up` form. Default "" =
|
|
1598
|
+
// non-admin, so this leaves the normal launch and re-provision unchanged.
|
|
1599
|
+
const provisionEnv = buildProvisionEnv(process.env, cfg);
|
|
1550
1600
|
const command = sshScriptCommand(scriptBody, scriptArgs, {
|
|
1551
1601
|
GITHUB_TOKEN: resolveGithubToken(),
|
|
1552
1602
|
CY_GIT_USER_NAME: laptopGit.name,
|
|
1553
1603
|
CY_GIT_USER_EMAIL: laptopGit.email,
|
|
1554
1604
|
...ingressEnv,
|
|
1555
1605
|
...forwardEnv,
|
|
1606
|
+
...provisionEnv,
|
|
1556
1607
|
});
|
|
1557
1608
|
const result = runGcloud(
|
|
1558
1609
|
buildGcloudArgs("ssh-script", cfg, { command }),
|
|
@@ -1840,6 +1891,15 @@ function classifyGitRemoteError(stderr) {
|
|
|
1840
1891
|
) {
|
|
1841
1892
|
return "network";
|
|
1842
1893
|
}
|
|
1894
|
+
// A greenfield project (`--new-project` scaffold) has no origin remote and an
|
|
1895
|
+
// unborn `main`, so `git ls-remote origin` fails with "does not appear to be a
|
|
1896
|
+
// git repository". No token would fix this — there is simply nothing to push
|
|
1897
|
+
// yet — so it is neither `credential` nor `network` nor `git-spawn`. Keep the
|
|
1898
|
+
// match narrow: `repository not found` is a genuine access failure against a
|
|
1899
|
+
// real remote and must NOT be swallowed here.
|
|
1900
|
+
if (lower.includes("does not appear to be a git repository")) {
|
|
1901
|
+
return "no-remote";
|
|
1902
|
+
}
|
|
1843
1903
|
return "other";
|
|
1844
1904
|
}
|
|
1845
1905
|
|
|
@@ -1877,6 +1937,14 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
|
|
|
1877
1937
|
reason: "git binary failed to launch — not a credential gap, not re-seeding",
|
|
1878
1938
|
};
|
|
1879
1939
|
}
|
|
1940
|
+
if (category === "no-remote") {
|
|
1941
|
+
return {
|
|
1942
|
+
heal: false,
|
|
1943
|
+
category,
|
|
1944
|
+
reason:
|
|
1945
|
+
"no git remote configured — greenfield project, nothing to push yet (not a credential gap)",
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1880
1948
|
return {
|
|
1881
1949
|
heal: true,
|
|
1882
1950
|
category,
|
|
@@ -3697,22 +3765,31 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3697
3765
|
sweepOrphanIngress({ cfg, domain, roster, projectDir });
|
|
3698
3766
|
}
|
|
3699
3767
|
|
|
3700
|
-
/** Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress):
|
|
3701
|
-
*
|
|
3702
|
-
* has no in-flight add. Enumerates the live
|
|
3703
|
-
* VM
|
|
3704
|
-
*
|
|
3705
|
-
*
|
|
3706
|
-
*
|
|
3707
|
-
*
|
|
3708
|
-
*
|
|
3709
|
-
* never
|
|
3710
|
-
*
|
|
3711
|
-
*
|
|
3712
|
-
*
|
|
3768
|
+
/** Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): LB
|
|
3769
|
+
* resources whose owning VM number is gone from the roster (a destroyed VM) and
|
|
3770
|
+
* has no in-flight add. Enumerates the live backend services AND zonal NEGs,
|
|
3771
|
+
* UNIONs their owning VM numbers (so a partial teardown whose NEG outlived its
|
|
3772
|
+
* backend service — the VM-5/6 case — still surfaces its VM as a candidate),
|
|
3773
|
+
* maps each name back to its VM number, subtracts the roster + in-flight adds +
|
|
3774
|
+
* live instances (the false-orphan guards), and runs the VERIFIED
|
|
3775
|
+
* `vm-ingress.sh <N> down` (asserts neg=no bes=no host=no, with retries) for
|
|
3776
|
+
* each orphan — so an orphan is genuinely cleared or surfaced as a real failure,
|
|
3777
|
+
* never a silent leak and never a manual command. Emits a machine-readable
|
|
3778
|
+
* `CY_ORPHAN_SWEEP <json>` summary line the dashboard parses to reconcile its
|
|
3779
|
+
* orphan-attention banner; the summary carries `stillPresent` (VM numbers that
|
|
3780
|
+
* still own ≥1 LB resource after the sweep) so the dashboard can clear a stale
|
|
3781
|
+
* banner for a VM that is now back in the roster or has no residual resources.
|
|
3782
|
+
* Best-effort per VM: one `down` failing never aborts the rest of the sweep.
|
|
3783
|
+
* Fail-safe: if the live-instance list can't be fetched, teardown is skipped for
|
|
3784
|
+
* this pass (a live-but-unrostered VM must never be torn down) while the banner
|
|
3785
|
+
* reconcile still runs. Returns the `{ cleaned, failed, skipped, stillPresent }`
|
|
3786
|
+
* summary (null when the backend-service enumeration itself failed) — the same
|
|
3787
|
+
* shape it prints — so the flow is directly assertable. `deps` is injectable so
|
|
3788
|
+
* the enumerate → select → down flow is testable without gcloud. */
|
|
3713
3789
|
function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {}, deps = {}) {
|
|
3714
3790
|
const gi = deps.googleIngress || require("./google-ingress.js");
|
|
3715
3791
|
const fcl = deps.fleetCleanup || require("./fleet-cleanup.js");
|
|
3792
|
+
const frecon = deps.fleetReconcile || require("./fleet-reconcile.js");
|
|
3716
3793
|
const run = deps.spawnSync || spawnSync;
|
|
3717
3794
|
|
|
3718
3795
|
const listInv = backendServicesListInvocation({ project: cfg && cfg.project });
|
|
@@ -3724,11 +3801,31 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
|
|
|
3724
3801
|
console.error(` orphan sweep skipped — could not list backend services (${tail}).`);
|
|
3725
3802
|
return null;
|
|
3726
3803
|
}
|
|
3727
|
-
const
|
|
3804
|
+
const besVmNumbers = String(listRes.stdout || "")
|
|
3728
3805
|
.split("\n")
|
|
3729
3806
|
.map((line) => gi.vmNumberFromResourceName(line.trim()))
|
|
3730
3807
|
.filter((n) => n != null);
|
|
3731
3808
|
|
|
3809
|
+
// Also enumerate zonal NEGs and union their owners in: a partial teardown can
|
|
3810
|
+
// leave a `vm-<N>-<surface>-neg` behind after its backend service is already
|
|
3811
|
+
// gone, so a backend-service-only candidate set misses that residue. Best-effort
|
|
3812
|
+
// — a NEG-list failure never aborts the backend-service-driven sweep.
|
|
3813
|
+
let negVmNumbers = [];
|
|
3814
|
+
const negInv = negListInvocation({ project: cfg && cfg.project });
|
|
3815
|
+
const negRes = run(negInv.command, negInv.args, { encoding: "utf8" });
|
|
3816
|
+
if (negRes && negRes.status === 0) {
|
|
3817
|
+
negVmNumbers = String(negRes.stdout || "")
|
|
3818
|
+
.split("\n")
|
|
3819
|
+
.map((line) => gi.vmNumberFromResourceName(line.trim()))
|
|
3820
|
+
.filter((n) => n != null);
|
|
3821
|
+
} else {
|
|
3822
|
+
const tail = (
|
|
3823
|
+
(negRes && (negRes.stderr || (negRes.error && negRes.error.message))) || ""
|
|
3824
|
+
).toString().trim().split("\n").pop() || "error";
|
|
3825
|
+
console.error(` orphan sweep: could not list NEGs (${tail}) — sweeping backend services only.`);
|
|
3826
|
+
}
|
|
3827
|
+
const ingressVmNumbers = [...new Set([...besVmNumbers, ...negVmNumbers])].sort((a, b) => a - b);
|
|
3828
|
+
|
|
3732
3829
|
// A VM in the roster, or with an in-flight `add-<n>.json` sidecar (provisioning,
|
|
3733
3830
|
// not yet rostered), is live — never an orphan.
|
|
3734
3831
|
const pendingDir = process.env.PENDING_JOBS_DIR || path.join(projectDir, ".codeyam/logs/pending-jobs");
|
|
@@ -3736,9 +3833,34 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
|
|
|
3736
3833
|
.listPendingJobs(pendingDir)
|
|
3737
3834
|
.filter((j) => j.action === "add")
|
|
3738
3835
|
.map((j) => j.n);
|
|
3739
|
-
const orphans = selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds });
|
|
3740
3836
|
|
|
3741
|
-
|
|
3837
|
+
// Defensive live-instance guard: never tear down a VM whose GCP instance is
|
|
3838
|
+
// actually running (a roster drift could leave a live VM unrostered; a teardown
|
|
3839
|
+
// that SUCCEEDED against it would knock it offline). `liveVmNumbers === null`
|
|
3840
|
+
// means the list couldn't be fetched — fail safe by skipping teardown this pass
|
|
3841
|
+
// (below) rather than risk a false teardown. The banner reconcile still runs.
|
|
3842
|
+
let liveVmNumbers = null;
|
|
3843
|
+
const instInv = instancesListInvocation({ project: cfg && cfg.project });
|
|
3844
|
+
const instRes = run(instInv.command, instInv.args, { encoding: "utf8" });
|
|
3845
|
+
if (instRes && instRes.status === 0) {
|
|
3846
|
+
liveVmNumbers = frecon.parseRunningFleetVms(instRes.stdout, fleetInstancePrefix());
|
|
3847
|
+
} else {
|
|
3848
|
+
const tail = (
|
|
3849
|
+
(instRes && (instRes.stderr || (instRes.error && instRes.error.message))) || ""
|
|
3850
|
+
).toString().trim().split("\n").pop() || "error";
|
|
3851
|
+
console.error(` orphan sweep: could not list instances (${tail}) — skipping teardown this pass (banners still reconciled).`);
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
const orphans = liveVmNumbers === null
|
|
3855
|
+
? []
|
|
3856
|
+
: selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds, liveVmNumbers });
|
|
3857
|
+
|
|
3858
|
+
// stillPresent = every VM that owned an LB resource going in, minus the ones we
|
|
3859
|
+
// verifiably clean below. The dashboard clears a banner for any orphan VM not in
|
|
3860
|
+
// this set (or now back in the roster). Computed up front so the no-orphans and
|
|
3861
|
+
// fail-safe-skip paths report it too — that is what unfreezes a VM-3-style banner
|
|
3862
|
+
// (re-rostered, so never a sweep target) with no teardown at all.
|
|
3863
|
+
const summary = { cleaned: [], failed: [], skipped: inFlightAdds.slice(), stillPresent: ingressVmNumbers.slice() };
|
|
3742
3864
|
if (!orphans.length) {
|
|
3743
3865
|
console.error(" orphan sweep: no orphaned LB ingress found.");
|
|
3744
3866
|
console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
|
|
@@ -3775,6 +3897,11 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
|
|
|
3775
3897
|
console.error(` VM-${n}: orphaned ingress → auto-clean FAILED (${tail.slice(-200)})`);
|
|
3776
3898
|
}
|
|
3777
3899
|
}
|
|
3900
|
+
// A verifiably-cleaned VM no longer owns any LB resource — drop it from
|
|
3901
|
+
// stillPresent so the dashboard clears its banner (failed VMs stay, keeping
|
|
3902
|
+
// their real attention item).
|
|
3903
|
+
const cleanedSet = new Set(summary.cleaned);
|
|
3904
|
+
summary.stillPresent = ingressVmNumbers.filter((n) => !cleanedSet.has(n));
|
|
3778
3905
|
console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
|
|
3779
3906
|
return summary;
|
|
3780
3907
|
}
|
|
@@ -3880,6 +4007,39 @@ async function cmdReconcileCredentials(args, projectDir = rootDir) {
|
|
|
3880
4007
|
);
|
|
3881
4008
|
}
|
|
3882
4009
|
|
|
4010
|
+
/** `cloud:reconcile-admin` — flip admin mode on/off for an already-running VM
|
|
4011
|
+
* WITHOUT a full re-provision. Resolves the desired value from this invocation
|
|
4012
|
+
* (`--admin` / `CODEYAM_ADMIN`, else off), rewrites the rendered compose's
|
|
4013
|
+
* CODEYAM_ADMIN line, and recreates the editor service — matching what a manual
|
|
4014
|
+
* compose edit + `docker compose up -d editor` does today, so the durable admin
|
|
4015
|
+
* setting no longer requires a full re-provision to change. */
|
|
4016
|
+
async function cmdReconcileAdmin(args, projectDir = rootDir) {
|
|
4017
|
+
ensureGcloud();
|
|
4018
|
+
const flags = parseFlags(args);
|
|
4019
|
+
const cfg = loadCloudConfig(projectDir);
|
|
4020
|
+
if (!cfg) {
|
|
4021
|
+
throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
4022
|
+
}
|
|
4023
|
+
const desired =
|
|
4024
|
+
buildProvisionEnv(process.env, flags).CODEYAM_ADMIN === "1" ? "1" : "";
|
|
4025
|
+
const repoName = resolveProject(cfg).name;
|
|
4026
|
+
console.error(
|
|
4027
|
+
`Reconciling admin mode to ${desired === "1" ? "ON" : "off"} on ${cfg.instanceName}...`,
|
|
4028
|
+
);
|
|
4029
|
+
const result = runGcloud(
|
|
4030
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
4031
|
+
command: buildReconcileAdminCommand(repoName, desired),
|
|
4032
|
+
}),
|
|
4033
|
+
{ stdio: "inherit" },
|
|
4034
|
+
);
|
|
4035
|
+
if (result.status !== 0) {
|
|
4036
|
+
throw new Error(`Failed to reconcile admin mode on ${cfg.instanceName}.`);
|
|
4037
|
+
}
|
|
4038
|
+
console.error(
|
|
4039
|
+
` ✓ editor service recreated; /api/config now reports admin=${desired === "1" ? "true" : "false"}.`,
|
|
4040
|
+
);
|
|
4041
|
+
}
|
|
4042
|
+
|
|
3883
4043
|
/** Provision-time guarantee that a just-provisioned VM can actually reach
|
|
3884
4044
|
* GitHub. Probes the staging file + in-container `git ls-remote origin` and
|
|
3885
4045
|
* routes the outcome through the SAME {@link decideCredentialHeal} classifier
|
|
@@ -4011,6 +4171,7 @@ const HANDLERS = {
|
|
|
4011
4171
|
"reconcile-ingress": cmdReconcileIngress,
|
|
4012
4172
|
"wire-add-tail": cmdWireAddTail,
|
|
4013
4173
|
"reconcile-credentials": cmdReconcileCredentials,
|
|
4174
|
+
"reconcile-admin": cmdReconcileAdmin,
|
|
4014
4175
|
access: cmdAccess,
|
|
4015
4176
|
};
|
|
4016
4177
|
|
|
@@ -4080,6 +4241,9 @@ module.exports = {
|
|
|
4080
4241
|
loadCloudConfig,
|
|
4081
4242
|
saveCloudConfig,
|
|
4082
4243
|
loadOrInitCloudConfig,
|
|
4244
|
+
buildProvisionEnv,
|
|
4245
|
+
buildReconcileAdminCommand,
|
|
4246
|
+
cmdReconcileAdmin,
|
|
4083
4247
|
buildEditorLocalEnvPatch,
|
|
4084
4248
|
buildEditorLocalSeedWriteCommand,
|
|
4085
4249
|
seedPreviewOrigin,
|
package/npm/empty-project-dir.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Launcher-side
|
|
4
|
+
* Launcher-side mirrors of `is_empty_project_dir` and
|
|
5
|
+
* `looks_like_onboardable_project` in
|
|
5
6
|
* `crates/codeyam-editor/src/commands/init.rs`. The Rust side decides
|
|
6
|
-
* which `/codeyam-…` skill the init binary points at;
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
7
|
+
* which `/codeyam-…` skill the init binary points at; these JS mirrors
|
|
8
|
+
* decide the same for the launcher's post-launch tail banner. Keep both
|
|
9
|
+
* predicates in sync with Rust — the test `empty-project-dir.test.js`
|
|
10
|
+
* and Rust's `is_empty_project_dir_*` / `looks_like_onboardable_project_*`
|
|
11
|
+
* tests pin the contracts.
|
|
11
12
|
*
|
|
12
13
|
* A "fresh" project directory has no non-hidden entries — only things
|
|
13
14
|
* like `.git/`, `.gitignore`, or other dotfiles. Hidden entries don't
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
const fs = require("node:fs");
|
|
23
|
+
const path = require("node:path");
|
|
22
24
|
|
|
23
25
|
function isEmptyProjectDir(projectDir) {
|
|
24
26
|
let entries;
|
|
@@ -30,4 +32,87 @@ function isEmptyProjectDir(projectDir) {
|
|
|
30
32
|
return entries.every((name) => name.startsWith("."));
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
// Root files that signal a real project: dependency/build manifests,
|
|
36
|
+
// lockfiles, and the static-site `index.html`. Mirror of the Rust
|
|
37
|
+
// `MANIFEST_MARKERS` list.
|
|
38
|
+
const MANIFEST_MARKERS = new Set([
|
|
39
|
+
"package.json",
|
|
40
|
+
"Cargo.toml",
|
|
41
|
+
"pyproject.toml",
|
|
42
|
+
"requirements.txt",
|
|
43
|
+
"setup.py",
|
|
44
|
+
"go.mod",
|
|
45
|
+
"Gemfile",
|
|
46
|
+
"pom.xml",
|
|
47
|
+
"build.gradle",
|
|
48
|
+
"build.gradle.kts",
|
|
49
|
+
"composer.json",
|
|
50
|
+
"pubspec.yaml",
|
|
51
|
+
"Package.swift",
|
|
52
|
+
"mix.exs",
|
|
53
|
+
"deno.json",
|
|
54
|
+
"CMakeLists.txt",
|
|
55
|
+
"Makefile",
|
|
56
|
+
"tsconfig.json",
|
|
57
|
+
"index.html",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// Subdirectories that signal an organized source tree. Mirror of the Rust
|
|
61
|
+
// `SOURCE_DIR_MARKERS` list.
|
|
62
|
+
const SOURCE_DIR_MARKERS = new Set([
|
|
63
|
+
"src",
|
|
64
|
+
"app",
|
|
65
|
+
"lib",
|
|
66
|
+
"pages",
|
|
67
|
+
"components",
|
|
68
|
+
"crates",
|
|
69
|
+
"packages",
|
|
70
|
+
"source",
|
|
71
|
+
"include",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Does this directory contain a *project* worth onboarding, as opposed to
|
|
76
|
+
* being empty or holding only a stray file or two? Mirror of Rust
|
|
77
|
+
* `looks_like_onboardable_project`. A folder "contains a project" when it
|
|
78
|
+
* holds a recognized manifest / lockfile, a `.csproj` / `.xcodeproj`, a root
|
|
79
|
+
* `index.html`, OR an organized source subdirectory. Intentionally
|
|
80
|
+
* marker-based, not file-count based: a home dir full of loose files is still
|
|
81
|
+
* not a project. A read error returns false (not a project → the
|
|
82
|
+
* auto-init/open path), matching Rust's `Err(_) => false`.
|
|
83
|
+
*/
|
|
84
|
+
function looksLikeOnboardableProject(projectDir) {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const name = entry.name;
|
|
93
|
+
if (MANIFEST_MARKERS.has(name)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (name.endsWith(".csproj") || name.endsWith(".xcodeproj")) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
// Source-tree markers only count as directories, not like-named files.
|
|
100
|
+
if (SOURCE_DIR_MARKERS.has(name)) {
|
|
101
|
+
let isDir = entry.isDirectory();
|
|
102
|
+
// A symlinked source dir surfaces as a symlink Dirent; resolve it.
|
|
103
|
+
if (!isDir && entry.isSymbolicLink()) {
|
|
104
|
+
try {
|
|
105
|
+
isDir = fs.statSync(path.join(projectDir, name)).isDirectory();
|
|
106
|
+
} catch {
|
|
107
|
+
isDir = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (isDir) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { isEmptyProjectDir, looksLikeOnboardableProject };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fc = require('./fleet-cleanup');
|
|
4
|
+
|
|
5
|
+
// npm/fleet-add-failure.js — pure classifier for a FAILED fleet-dashboard `add`
|
|
6
|
+
// provision. Before this, every failed add rendered an identical red "add error"
|
|
7
|
+
// badge whose only action was "Clear badge", and a restart mid-add collapsed the
|
|
8
|
+
// message to a content-free `(re-attached after restart — detached provision
|
|
9
|
+
// exited 1)`. The real cause — captured to the provision transcript on disk — was
|
|
10
|
+
// thrown away. This happened live with VM9/VM10: both aborted at the streamed-
|
|
11
|
+
// script drift guard because a bootstrap script had uncommitted local edits, but
|
|
12
|
+
// the operator saw only "exited 1" and had to dig through /tmp/dash-add-vm-N.log.
|
|
13
|
+
//
|
|
14
|
+
// classifyAddFailure turns a transcript tail + exit code into a differentiated,
|
|
15
|
+
// ACTIONABLE verdict: a plain-language headline, a concrete recovery instruction,
|
|
16
|
+
// and — crucially — a `retryable` flag that is true ONLY for failures where the
|
|
17
|
+
// classifier can PROVE nothing was provisioned, so a one-click retry can never
|
|
18
|
+
// silently orphan or duplicate a half-provisioned GCP box. The decision logic
|
|
19
|
+
// lives here as a pure module (like fleet-job-badge.js::canClearBadge) so the
|
|
20
|
+
// whole failure matrix is unit-tested without a live VM; server.js is the thin
|
|
21
|
+
// I/O shell that gathers the transcript and turns the verdict into a card + a
|
|
22
|
+
// server-re-guarded retry action.
|
|
23
|
+
|
|
24
|
+
// The SINGLE source of truth for which categories are safe to auto-retry: only
|
|
25
|
+
// failures where nothing was provisioned. `retryable` on every returned verdict
|
|
26
|
+
// is stamped from this Set, so the invariant `retryable === has(category)` holds
|
|
27
|
+
// by construction and the server can re-guard a retry against the same Set.
|
|
28
|
+
const ADD_FAILURE_RETRYABLE = new Set(['preflight-drift', 'instance-vanished']);
|
|
29
|
+
|
|
30
|
+
// Collect the ` - <path>` bullets the drift guard prints AFTER the "following
|
|
31
|
+
// files differ" header (see npm/cloud.js), stopping at the first non-bullet line
|
|
32
|
+
// so an unrelated "- " elsewhere in the transcript can't leak in. Returns [] when
|
|
33
|
+
// no drift block is present.
|
|
34
|
+
function extractDriftFiles(logTail) {
|
|
35
|
+
const lines = String(logTail || '').split('\n');
|
|
36
|
+
const files = [];
|
|
37
|
+
let inBlock = false;
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (!inBlock) {
|
|
40
|
+
if (/following files differ/i.test(line)) inBlock = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const m = line.match(/^\s+-\s+(.+?)\s*$/);
|
|
44
|
+
if (m) { files.push(m[1]); continue; }
|
|
45
|
+
break; // first non-bullet after the header ends the block
|
|
46
|
+
}
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Pure transform: the last `maxLines` non-empty lines of a transcript, each
|
|
51
|
+
// right-trimmed. Extracted from readProvisionTail (server.js) so the aggregation
|
|
52
|
+
// is unit-tested and that function stays a thin filesystem-boundary call. Returns
|
|
53
|
+
// '' for empty/whitespace-only input.
|
|
54
|
+
function lastNonEmptyLines(text, maxLines = 50) {
|
|
55
|
+
const lines = String(text || '')
|
|
56
|
+
.split('\n')
|
|
57
|
+
.map((l) => l.replace(/\s+$/, ''))
|
|
58
|
+
.filter((l) => l.length > 0)
|
|
59
|
+
.slice(-maxLines);
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
|
|
64
|
+
// can never drift from the category.
|
|
65
|
+
function buildFailureVerdict(category, headline, recovery, extra) {
|
|
66
|
+
const out = { category, headline, recovery, retryable: ADD_FAILURE_RETRYABLE.has(category) };
|
|
67
|
+
if (extra && extra.files) out.files = extra.files;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Classify a failed add from its transcript tail + exit code.
|
|
72
|
+
//
|
|
73
|
+
// logTail the last lines of the provision transcript (string; '' when the
|
|
74
|
+
// log was unreadable — must never throw)
|
|
75
|
+
// exitCode the provision's exit code (number; used for signal-kill detection)
|
|
76
|
+
//
|
|
77
|
+
// Returns { category, headline, recovery, retryable, files? }.
|
|
78
|
+
//
|
|
79
|
+
// Detection is ORDER-SENSITIVE. The provision shell echoes cloud:up's tail, so a
|
|
80
|
+
// drift / instance-vanished / container-stuck transcript ALSO contains the
|
|
81
|
+
// generic `cloud:up FAILED (exit N)` line. Each specific (and every retryable)
|
|
82
|
+
// signature is therefore tested BEFORE the generic cloud-up-failed signature, or
|
|
83
|
+
// a retryable drift would misclassify as a non-retryable cloud:up failure and the
|
|
84
|
+
// operator would lose the safe one-click recovery.
|
|
85
|
+
function classifyAddFailure({ logTail, exitCode } = {}) {
|
|
86
|
+
const log = String(logTail || '');
|
|
87
|
+
|
|
88
|
+
// 1. preflight-drift — the streamed-script drift guard aborted BEFORE any
|
|
89
|
+
// gcloud call, so nothing was provisioned. Retryable once the file is fixed.
|
|
90
|
+
// We match the unambiguous abort header / verdict label, NOT the bare
|
|
91
|
+
// `CODEYAM_SKIP_SCRIPT_DRIFT_CHECK` token: that token ALSO appears in the
|
|
92
|
+
// "bypass active — skipping drift check" warning, so keying on it would
|
|
93
|
+
// misclassify a bypass-active add that later failed for a real reason as a
|
|
94
|
+
// (retryable!) drift — inviting an unsafe retry. The header below is printed
|
|
95
|
+
// only on a genuine drift abort.
|
|
96
|
+
if (/Streamed scripts carry unreviewed local content/i.test(log)
|
|
97
|
+
|| /UNCOMMITTED LOCAL EDITS/.test(log)) {
|
|
98
|
+
const files = extractDriftFiles(log);
|
|
99
|
+
const fileList = files.length ? files.join(', ') : 'the streamed scripts';
|
|
100
|
+
const fix = files.length
|
|
101
|
+
? `Commit & push them, or discard with \`git checkout -- ${files.join(' ')}\`, then Retry add.`
|
|
102
|
+
: 'Commit & push them, or discard the local edits, then Retry add.';
|
|
103
|
+
return buildFailureVerdict('preflight-drift', 'Blocked by local edits',
|
|
104
|
+
`Uncommitted local edits in ${fileList} tripped the streamed-script drift guard, which aborts before any cloud resource is created. ${fix}`,
|
|
105
|
+
{ files });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. instance-vanished — the GCP instance is confirmed gone; nothing to orphan.
|
|
109
|
+
if (/instance .* is gone/i.test(log)) {
|
|
110
|
+
return buildFailureVerdict('instance-vanished', 'Instance never came up',
|
|
111
|
+
'The GCP instance is gone — provisioning failed before the container started, so nothing was left behind. Retrying the add is safe.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. container-stuck — the instance exists but the editor container never
|
|
115
|
+
// became healthy, so a half-provisioned box may exist. Not retryable.
|
|
116
|
+
if (/container never came up/i.test(log)) {
|
|
117
|
+
return buildFailureVerdict('container-stuck', 'Container never became healthy',
|
|
118
|
+
'The instance came up but the editor container never became healthy — it may be half-provisioned. Reconcile or destroy this VM before re-adding.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 4. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
|
|
122
|
+
// vanished / stuck signatures above did not match. A partial instance may
|
|
123
|
+
// exist, so not retryable.
|
|
124
|
+
if (/cloud:up FAILED \(exit/i.test(log)) {
|
|
125
|
+
return buildFailureVerdict('cloud-up-failed', 'Provisioning failed',
|
|
126
|
+
'cloud:up failed during provisioning, so a partial instance may exist. Reconcile or destroy this VM before re-adding.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 5. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
|
|
130
|
+
// Killed / out-of-memory marker. The real VM state is unknown. Not retryable.
|
|
131
|
+
if (fc.isSignalKillExit(Number(exitCode)) || /\bKilled\b/.test(log) || /Out of memory/i.test(log)) {
|
|
132
|
+
return buildFailureVerdict('interrupted', 'Provisioning interrupted',
|
|
133
|
+
'The provision was interrupted (killed or out of memory), so the VM state is unknown. Reconcile or destroy this VM before re-adding.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 6. unknown — fallback. Never invite an unproven retry.
|
|
137
|
+
return buildFailureVerdict('unknown', 'Add failed',
|
|
138
|
+
'The add failed for an unclassified reason — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Pure guard for the operator "Retry add" action: given a VM number and its
|
|
142
|
+
// carried job, decide whether a one-click retry is SAFE. Retry is allowed ONLY
|
|
143
|
+
// for a terminal add-error whose classifier verdict is retryable (a category
|
|
144
|
+
// where nothing was provisioned). Returns { ok, reason } — reason is a stable
|
|
145
|
+
// code the server shell (actionRetryAdd) maps to an operator message. Mirrors
|
|
146
|
+
// fleet-job-badge.js::canClearBadge so the handler stays a thin I/O shell and the
|
|
147
|
+
// safety decision is unit-tested without a live dashboard.
|
|
148
|
+
function canRetryAdd({ n, job } = {}) {
|
|
149
|
+
if (!Number.isInteger(n) || n < 1) return { ok: false, reason: 'invalid-vm' };
|
|
150
|
+
if (!job || job.action !== 'add' || job.state !== 'error') return { ok: false, reason: 'no-failed-add' };
|
|
151
|
+
if (!job.retryable) return { ok: false, reason: 'not-retryable' };
|
|
152
|
+
return { ok: true, reason: null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, ADD_FAILURE_RETRYABLE };
|