@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.
Files changed (31) hide show
  1. package/npm/cloud.js +181 -17
  2. package/npm/empty-project-dir.js +92 -7
  3. package/npm/fleet-add-failure.js +155 -0
  4. package/npm/fleet-effect-poll.js +110 -0
  5. package/npm/fleet-plans.js +38 -3
  6. package/npm/fleet-pool.js +75 -16
  7. package/npm/fleet-public-url.js +34 -0
  8. package/npm/fleet-queue.js +58 -1
  9. package/npm/fleet-vm-access.js +75 -7
  10. package/npm/fleet-vm-readiness.js +21 -4
  11. package/npm/ingress-invoke.js +84 -13
  12. package/npm/scenario-check.js +55 -1
  13. package/npm/scenario-handlers.js +15 -3
  14. package/npm/scenario-issues.js +13 -1
  15. package/npm/scenario-mocks.js +129 -26
  16. package/npm/scenario-playwright.js +75 -7
  17. package/npm/utils.js +37 -3
  18. package/package.json +5 -5
  19. package/ui/dist/assets/{ContainerInspector-B3vO6op1.js → ContainerInspector-OQ1X2doF.js} +1 -1
  20. package/ui/dist/assets/EditorShell-D5woOSRy.css +1 -0
  21. package/ui/dist/assets/EditorShell-EKM2i9O9.js +232 -0
  22. package/ui/dist/assets/{ScenarioDataPanel-DIAw2DpS.js → ScenarioDataPanel-BU2tHhl4.js} +3 -3
  23. package/ui/dist/assets/{ScenarioDataPanelFullPage-CibLNYsH.js → ScenarioDataPanelFullPage-BrbAtXE5.js} +1 -1
  24. package/ui/dist/assets/ScenarioRenderer-d3MLbcAe.js +21 -0
  25. package/ui/dist/assets/index-D3csSagX.css +1 -0
  26. package/ui/dist/assets/{index-B14nyUpe.js → index-iE2jFez3.js} +2 -2
  27. package/ui/dist/index.html +2 -2
  28. package/ui/dist/assets/EditorShell-CndrQhoL.js +0 -191
  29. package/ui/dist/assets/EditorShell-D7wGyetm.css +0 -1
  30. package/ui/dist/assets/ScenarioRenderer-rbCbaiLe.js +0 -21
  31. 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): backend
3701
- * services whose owning VM number is gone from the roster (a destroyed VM) and
3702
- * has no in-flight add. Enumerates the live backends, maps each name back to its
3703
- * VM number, subtracts the roster + in-flight adds (the false-orphan guards),
3704
- * and runs the VERIFIED `vm-ingress.sh <N> down` (asserts neg=no bes=no host=no,
3705
- * with retries) for each orphan — so an orphan is genuinely cleared or surfaced
3706
- * as a real failure, never a silent leak and never a manual command. Emits a
3707
- * machine-readable `CY_ORPHAN_SWEEP <json>` summary line the dashboard parses to
3708
- * reconcile its orphan-attention banner. Best-effort per VM: one `down` failing
3709
- * never aborts the rest of the sweep. Returns the `{ cleaned, failed, skipped }`
3710
- * summary (null when the enumeration itself failed) — the same shape it prints —
3711
- * so the flow is directly assertable. `deps` is injectable so the enumerate →
3712
- * select → down flow is testable without gcloud. */
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 ingressVmNumbers = String(listRes.stdout || "")
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
- const summary = { cleaned: [], failed: [], skipped: inFlightAdds.slice() };
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,
@@ -1,13 +1,14 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * Launcher-side mirror of `is_empty_project_dir` in
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; this JS mirror
7
- * decides the same for the launcher's post-launch tail banner. Keep
8
- * both in sync — the test `empty-project-dir.test.js` and Rust's
9
- * `is_empty_project_dir_ignores_hidden_entries` /
10
- * `is_empty_project_dir_detects_visible_entry` pin the contract.
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
- module.exports = { isEmptyProjectDir };
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 };