@codeyam-editor/codeyam-editor 0.1.5 → 0.1.7

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 (80) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud.js +2079 -242
  3. package/npm/codesign-plan.js +68 -0
  4. package/npm/compose-conflict.js +59 -0
  5. package/npm/empty-project-dir.js +92 -7
  6. package/npm/fleet-add-failure.js +361 -0
  7. package/npm/fleet-asset-version.js +37 -0
  8. package/npm/fleet-auth-health.js +143 -5
  9. package/npm/fleet-build-data.js +60 -5
  10. package/npm/fleet-cleanup.js +243 -9
  11. package/npm/fleet-destroy-safety.js +230 -13
  12. package/npm/fleet-early-roster.js +87 -0
  13. package/npm/fleet-effect-poll.js +110 -0
  14. package/npm/fleet-env.js +86 -0
  15. package/npm/fleet-ghcr-secret.js +129 -0
  16. package/npm/fleet-git-maintenance.js +142 -0
  17. package/npm/fleet-host-op-limiter.js +68 -0
  18. package/npm/fleet-iap-token.js +165 -0
  19. package/npm/fleet-job-badge.js +28 -12
  20. package/npm/fleet-live-status.js +40 -8
  21. package/npm/fleet-memory.js +289 -9
  22. package/npm/fleet-plans.js +98 -4
  23. package/npm/fleet-pool.js +75 -16
  24. package/npm/fleet-prewarm.js +87 -11
  25. package/npm/fleet-probe-transport.js +155 -0
  26. package/npm/fleet-project-drift.js +58 -0
  27. package/npm/fleet-public-url.js +234 -0
  28. package/npm/fleet-queue.js +58 -1
  29. package/npm/fleet-reachability-debounce.js +69 -0
  30. package/npm/fleet-reachability.js +80 -0
  31. package/npm/fleet-reset.js +32 -0
  32. package/npm/fleet-revision.js +104 -0
  33. package/npm/fleet-running-plan.js +115 -0
  34. package/npm/fleet-session-token-cache.js +65 -0
  35. package/npm/fleet-setup-steps.js +19 -12
  36. package/npm/fleet-status-refs.js +96 -0
  37. package/npm/fleet-strand.js +39 -8
  38. package/npm/fleet-stuck-status.js +13 -7
  39. package/npm/fleet-vm-access.js +170 -0
  40. package/npm/fleet-vm-probe-target.js +282 -0
  41. package/npm/fleet-vm-readiness.js +136 -3
  42. package/npm/google-ingress.js +15 -0
  43. package/npm/ingress-invoke.js +478 -0
  44. package/npm/launch-config.js +302 -10
  45. package/npm/scenario-check.js +318 -12
  46. package/npm/scenario-handlers.js +85 -27
  47. package/npm/scenario-interactivity.js +145 -6
  48. package/npm/scenario-issues.js +13 -1
  49. package/npm/scenario-mocks.js +129 -26
  50. package/npm/scenario-playwright.js +330 -22
  51. package/npm/script-drift.js +72 -1
  52. package/npm/utils.js +37 -3
  53. package/package.json +5 -5
  54. package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  55. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  56. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  57. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  58. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  59. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  60. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  61. package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  62. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  63. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  64. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  65. package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
  66. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  67. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  68. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  69. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  70. package/ui/dist/index.html +4 -4
  71. package/ui/dist/open-source/codeyam-counter.png +0 -0
  72. package/ui/dist/open-source/el-carot.png +0 -0
  73. package/ui/dist/open-source/tabcommand.png +0 -0
  74. package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
  75. package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
  76. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
  77. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
  78. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
  79. package/ui/dist/assets/index-CIXZkwfB.css +0 -1
  80. package/ui/dist/assets/react-CSS0HapR.js +0 -28
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ // Pure helpers for scripts/package-mcpb.js's darwin code-signing step.
3
+ //
4
+ // Dependency-free and unit-tested (npm/codesign-plan.test.js) so the packaging
5
+ // script keeps the sign decision + argv rendering out of its execSync path:
6
+ // signDarwinBinary stays thin boundary glue while every branch of "do we sign,
7
+ // and with which command" is exercised here without invoking codesign.
8
+
9
+ /**
10
+ * The `codesign` argv for a Developer ID signature with a secure timestamp.
11
+ * Pure — takes the identity and binary path, returns the flag list. `--force`
12
+ * replaces cargo's ad-hoc signature (codesign refuses an already-signed binary
13
+ * otherwise); `--timestamp` embeds the secure timestamp a later notarization
14
+ * needs.
15
+ */
16
+ function codesignArgs(identity, binaryPath) {
17
+ return ["--force", "--timestamp", "--sign", identity, binaryPath];
18
+ }
19
+
20
+ /**
21
+ * The shell-quoted `codesign …` command string for signing `binaryPath` with
22
+ * `identity`. Pure: quotes each argv element so a path or identity with spaces
23
+ * survives the shell.
24
+ */
25
+ function codesignCommand(identity, binaryPath) {
26
+ const quoted = codesignArgs(identity, binaryPath)
27
+ .map((a) => `"${a}"`)
28
+ .join(" ");
29
+ return `codesign ${quoted}`;
30
+ }
31
+
32
+ /**
33
+ * Decide whether/how to sign the staged darwin binary, given the target
34
+ * `platform`, the configured Developer ID `identity`, the `hostPlatform`
35
+ * (process.platform), and a `serverLabel` for the warning text. Pure — no I/O,
36
+ * no env, no clock — so the decision and its warnings are unit-testable.
37
+ *
38
+ * Returns `{ action: "sign", identity }` when a real Developer ID signature is
39
+ * possible, otherwise `{ action: "skip", warning? }`: the non-darwin target is a
40
+ * silent skip (its binary is not Mach-O), while a missing identity or a
41
+ * non-macOS host skips WITH a warning so an unsigned local bundle stays testable
42
+ * on the machine that built it rather than failing the build.
43
+ */
44
+ function signPlan({ platform, identity, hostPlatform, serverLabel }) {
45
+ if (platform !== "darwin") {
46
+ return { action: "skip" };
47
+ }
48
+ if (!identity) {
49
+ return {
50
+ action: "skip",
51
+ warning:
52
+ `⚠️ ${serverLabel} is ad-hoc signed — set MCPB_SIGN_IDENTITY (or ` +
53
+ `APPLE_SIGNING_IDENTITY) to a Developer ID identity to sign it. An unsigned ` +
54
+ `binary has no stable Keychain identity and re-prompts for access.`,
55
+ };
56
+ }
57
+ if (hostPlatform !== "darwin") {
58
+ return {
59
+ action: "skip",
60
+ warning:
61
+ `⚠️ cannot codesign ${serverLabel} on ${hostPlatform} — ` +
62
+ `sign the darwin binary on a macOS host.`,
63
+ };
64
+ }
65
+ return { action: "sign", identity };
66
+ }
67
+
68
+ module.exports = { codesignArgs, codesignCommand, signPlan };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // npm/compose-conflict.js — classify a failed `docker compose up -d` on a fleet
4
+ // VM, so `cloud:up` can tell "another run beat me to this container" apart from
5
+ // "provisioning genuinely broke".
6
+ //
7
+ // Why this exists: on 2026-08-07 two concurrent `cloud:up` runs raced to start
8
+ // the editor container on one fresh VM. The loser's `up -d` exited non-zero with
9
+ // the docker daemon's container-name conflict, `cloud.js` flattened that into a
10
+ // generic "docker compose up -d failed on the VM", and the abort handler
11
+ // escalated it all the way to deleting a VM the winner had already brought up
12
+ // and rostered. A name conflict is the opposite of a failure — it is proof the
13
+ // shared goal (container running) was reached by someone else.
14
+ //
15
+ // Matching a daemon error string is exactly the kind of thing that rots when
16
+ // docker rephrases it, so it lives here as a named, unit-tested function with
17
+ // the real observed message pinned as a fixture — not as an inline regex at the
18
+ // call site.
19
+
20
+ // The stable fragments of the daemon's conflict message. The container id and
21
+ // name vary per run and docker's exact phrasing has changed across versions, so
22
+ // we require all three fragments rather than the whole sentence. Observed
23
+ // verbatim in the VM-4 add log:
24
+ //
25
+ // Error response from daemon: Conflict. The container name
26
+ // "/codeyam-editor-editor-1" is already in use by container "c9d448c6…".
27
+ // You have to remove (or rename) that container to be able to reuse that name.
28
+ //
29
+ // Compose emits it twice (once prefixed `service:editor:1`, once bare) and
30
+ // interleaves its own progress lines, so the match is substring-based over the
31
+ // whole captured stream rather than line- or anchor-based.
32
+ const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already in use/i];
33
+
34
+ /** Classify the outcome of `docker compose up -d`.
35
+ *
36
+ * Returns one of:
37
+ * - `'ok'` — the command succeeded; there is no failure to classify. Guards
38
+ * against a caller that classifies unconditionally, so a zero status can
39
+ * never be read as a conflict.
40
+ * - `'name-conflict'` — the daemon refused because a container of that name
41
+ * already exists, i.e. a concurrent run already started it. The caller
42
+ * re-probes for a running container and converges instead of throwing.
43
+ * - `'failed'` — anything else. Existing behavior: a real provisioning
44
+ * failure.
45
+ *
46
+ * Pure: `{ status, stderr } → tag`, with no I/O, so the whole matrix is
47
+ * testable without a VM. `stderr` may be null/undefined (the pre-fix call site
48
+ * captured nothing), which classifies as `'failed'` — the conservative
49
+ * direction, since converging requires positive evidence of a conflict. */
50
+ function classifyComposeUpFailure({ status, stderr } = {}) {
51
+ if (status === 0) return 'ok';
52
+ const text = String(stderr == null ? '' : stderr);
53
+ if (NAME_CONFLICT_FRAGMENTS.every((re) => re.test(text))) {
54
+ return 'name-conflict';
55
+ }
56
+ return 'failed';
57
+ }
58
+
59
+ module.exports = { classifyComposeUpFailure };
@@ -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,361 @@
1
+ 'use strict';
2
+
3
+ const fc = require('./fleet-cleanup');
4
+ const { parseRosterMarkers } = require('./fleet-early-roster');
5
+
6
+ // npm/fleet-add-failure.js — pure classifier for a FAILED fleet-dashboard `add`
7
+ // provision. Before this, every failed add rendered an identical red "add error"
8
+ // badge whose only action was "Clear badge", and a restart mid-add collapsed the
9
+ // message to a content-free `(re-attached after restart — detached provision
10
+ // exited 1)`. The real cause — captured to the provision transcript on disk — was
11
+ // thrown away. This happened live with VM9/VM10: both aborted at the streamed-
12
+ // script drift guard because a bootstrap script had uncommitted local edits, but
13
+ // the operator saw only "exited 1" and had to dig through /tmp/dash-add-vm-N.log.
14
+ //
15
+ // classifyAddFailure turns a transcript tail + exit code into a differentiated,
16
+ // ACTIONABLE verdict: a plain-language headline, a concrete recovery instruction,
17
+ // and — crucially — a `retryable` flag that is true ONLY where a one-click retry
18
+ // provably cannot orphan or duplicate a GCP box: either nothing was provisioned,
19
+ // or the remaining work is re-entrant so the retry converges on the existing VM.
20
+ // Both grounds are spelled out on ADD_FAILURE_RETRYABLE below. The decision logic
21
+ // lives here as a pure module (like fleet-job-badge.js::canClearBadge) so the
22
+ // whole failure matrix is unit-tested without a live VM; server.js is the thin
23
+ // I/O shell that gathers the transcript and turns the verdict into a card + a
24
+ // server-re-guarded retry action.
25
+
26
+ // The SINGLE source of truth for which categories are safe to auto-retry.
27
+ // `retryable` on every returned verdict is stamped from this Set, so the
28
+ // invariant `retryable === has(category)` holds by construction and the server
29
+ // can re-guard a retry against the same Set.
30
+ //
31
+ // A category earns membership on EITHER of two grounds — both of which
32
+ // guarantee a one-click retry cannot silently orphan or duplicate a GCP box:
33
+ //
34
+ // 1. NOTHING WAS PROVISIONED. The classifier can prove no cloud resource was
35
+ // created (`preflight-drift` aborts before the first gcloud call;
36
+ // `instance-vanished` confirms the instance is gone). Retry starts clean.
37
+ //
38
+ // 2. THE REMAINING WORK IS RE-ENTRANT. A real VM exists, but every step the
39
+ // retry would re-run is skip-on-exists or re-attaching, so the retry
40
+ // CONVERGES on the existing box instead of building a second one.
41
+ // `project-bootstrap-timeout` is this shape: the VM is up and its detached
42
+ // bootstrap is still running, `ensureInstance` reuses the live instance,
43
+ // `ensureBootstrapped` skips on the present image, and
44
+ // `ensureProjectBootstrapped` re-attaches to the in-flight install rather
45
+ // than starting a second `npm install` over the first.
46
+ //
47
+ // Ground 2 is deliberately narrow — it is NOT "the VM probably still works".
48
+ // `container-stuck` and `incomplete-workspace` also leave a live VM, but their
49
+ // remaining work is NOT provably re-entrant, so they stay non-retryable.
50
+ const ADD_FAILURE_RETRYABLE = new Set([
51
+ 'preflight-drift',
52
+ 'instance-vanished',
53
+ 'project-bootstrap-timeout',
54
+ ]);
55
+
56
+ // Collect the ` - <path>` bullets the drift guard prints AFTER the "following
57
+ // files differ" header (see npm/cloud.js), stopping at the first non-bullet line
58
+ // so an unrelated "- " elsewhere in the transcript can't leak in. Returns [] when
59
+ // no drift block is present.
60
+ function extractDriftFiles(logTail) {
61
+ const lines = String(logTail || '').split('\n');
62
+ const files = [];
63
+ let inBlock = false;
64
+ for (const line of lines) {
65
+ if (!inBlock) {
66
+ if (/following files differ/i.test(line)) inBlock = true;
67
+ continue;
68
+ }
69
+ const m = line.match(/^\s+-\s+(.+?)\s*$/);
70
+ if (m) { files.push(m[1]); continue; }
71
+ break; // first non-bullet after the header ends the block
72
+ }
73
+ return files;
74
+ }
75
+
76
+ // Pure transform: the last `maxLines` non-empty lines of a transcript, each
77
+ // right-trimmed. Extracted from readProvisionTail (server.js) so the aggregation
78
+ // is unit-tested and that function stays a thin filesystem-boundary call. Returns
79
+ // '' for empty/whitespace-only input.
80
+ function lastNonEmptyLines(text, maxLines = 50) {
81
+ const lines = String(text || '')
82
+ .split('\n')
83
+ .map((l) => l.replace(/\s+$/, ''))
84
+ .filter((l) => l.length > 0)
85
+ .slice(-maxLines);
86
+ return lines.join('\n');
87
+ }
88
+
89
+ // True iff the provision transcript reached the early-roster marker (`CY_ROSTER
90
+ // <n>`) — i.e. the instance exists and the container gate passed, so the VM is
91
+ // rostered. Reuses parseRosterMarkers (npm/fleet-early-roster.js) so the
92
+ // `CY_ROSTER` grammar has a single owner rather than a second inlined regex. Any
93
+ // valid marker means "rostered"; the specific VM number is irrelevant here — a
94
+ // provision transcript only ever carries its own VM's marker. Never throws.
95
+ function transcriptRostered(logTail) {
96
+ return parseRosterMarkers(String(logTail || '')).length > 0;
97
+ }
98
+
99
+ // The categories that describe an add whose outcome is NOT yet proven bad — the
100
+ // calm, in-flight side of the classifier. `converging` is a rostered box whose
101
+ // ingress is still wiring; `verifying` is the residue: no failure signature
102
+ // matched and nothing durable proves the VM is dead. Both render a calm card and
103
+ // neither may use the word "failed". Consumed by holdConvergingBadge and by
104
+ // deriveStatus (server.js) to pick the matching calm status.
105
+ const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying']);
106
+
107
+ // Is a non-zero add outcome CORROBORATED by a durable fact — i.e. do we have
108
+ // positive proof of death, rather than merely an absence of proof of life?
109
+ //
110
+ // This is the conjunction that gates the red `unknown` verdict. Before it, the
111
+ // classifier's fallthrough treated "no recognized success signature" as failure,
112
+ // so "we don't know" rendered as a terminal red "Add failed" telling the operator
113
+ // to destroy a VM whose container was Up and whose editor answered /api/health
114
+ // 200 (VM-4, 2026-07-31). Red now requires BOTH halves:
115
+ //
116
+ // 1. A non-zero exit code. A *missing* code is not proof of death — today it
117
+ // usually means the wrapper was signal-killed while its provision ran on,
118
+ // which is exactly the case that was misreading as failure. `null`,
119
+ // `undefined`, a non-integer, and 0 all fail this half.
120
+ // 2. At least one durable corroborating fact:
121
+ // - `instanceAlive === false` GCP says the instance is gone.
122
+ // - `containerHealthy === false` the in-VM editor was probed and is not
123
+ // answering — the box exists but never came up.
124
+ // - `wiringDeadlineBlown` the wire-add-tail gave up (the failed
125
+ // marker is the tail's own honest deadline signal).
126
+ //
127
+ // Every fact is TRI-STATE on purpose: `undefined`/`null` means "not probed" and
128
+ // corroborates nothing. Only an explicit `false` counts, so an unprobed VM can
129
+ // never be escalated to red by absence of evidence.
130
+ function addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
131
+ const code = exitCode == null ? NaN : Number(exitCode);
132
+ if (!Number.isInteger(code) || code === 0) return false;
133
+ return instanceAlive === false || containerHealthy === false || !!wiringDeadlineBlown;
134
+ }
135
+
136
+ // Resolve the two independent health signals into the single tri-state
137
+ // `containerHealthy` fact addFailureCorroborated consumes. Pure so the precedence
138
+ // — which decides whether a VM can be escalated to red — is unit-tested; the
139
+ // server (addFailureFacts) supplies the live signals.
140
+ //
141
+ // reachable the editor answered over the LB.
142
+ // inVmHealthy the STRAND_PROBE /api/health read from INSIDE the container.
143
+ //
144
+ // LB reachability wins when present: it is the stronger witness (it proves the
145
+ // whole URL→ingress→backend path, not just the process). Otherwise the in-VM
146
+ // witness answers — this is the still-wiring case, where the LB necessarily
147
+ // cannot see a VM whose ingress is what we are waiting on, yet the editor itself
148
+ // is demonstrably up.
149
+ //
150
+ // Returns `null` when NEITHER signal was gathered. That is the load-bearing case:
151
+ // an unprobed VM must corroborate nothing, so it can never be escalated to red by
152
+ // absence of evidence. `false` is reserved for a VM that was genuinely probed and
153
+ // did not answer.
154
+ function containerHealthWitness({ reachable, inVmHealthy } = {}) {
155
+ if (reachable) return true;
156
+ if (inVmHealthy != null) return !!inVmHealthy;
157
+ return null;
158
+ }
159
+
160
+ // Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
161
+ // can never drift from the category.
162
+ function buildFailureVerdict(category, headline, recovery, extra) {
163
+ const out = { category, headline, recovery, retryable: ADD_FAILURE_RETRYABLE.has(category) };
164
+ if (extra && extra.files) out.files = extra.files;
165
+ return out;
166
+ }
167
+
168
+ // Classify a failed add from its transcript tail + exit code.
169
+ //
170
+ // logTail the last lines of the provision transcript (string; '' when the
171
+ // log was unreadable — must never throw)
172
+ // exitCode the provision's exit code (number; used for signal-kill detection
173
+ // and as the first half of the proof-of-death conjunction)
174
+ //
175
+ // The remaining inputs are the durable facts the server gathers (the module
176
+ // stays pure — it never probes). All are tri-state; omit any that was not
177
+ // probed and it corroborates nothing. See addFailureCorroborated.
178
+ //
179
+ // instanceAlive does GCP still list the instance?
180
+ // containerHealthy did the in-VM editor answer /api/health?
181
+ // wiringDeadlineBlown did the add-time wire tail drop its failed marker?
182
+ //
183
+ // Returns { category, headline, recovery, retryable, files? }.
184
+ //
185
+ // Detection is ORDER-SENSITIVE. The provision shell echoes cloud:up's tail, so a
186
+ // drift / instance-vanished / container-stuck transcript ALSO contains the
187
+ // generic `cloud:up FAILED (exit N)` line. Each specific (and every retryable)
188
+ // signature is therefore tested BEFORE the generic cloud-up-failed signature, or
189
+ // a retryable drift would misclassify as a non-retryable cloud:up failure and the
190
+ // operator would lose the safe one-click recovery.
191
+ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
192
+ const log = String(logTail || '');
193
+
194
+ // 1. preflight-drift — the streamed-script drift guard aborted BEFORE any
195
+ // gcloud call, so nothing was provisioned. Retryable once the file is fixed.
196
+ // We match the unambiguous abort header / verdict label, NOT the bare
197
+ // `CODEYAM_SKIP_SCRIPT_DRIFT_CHECK` token: that token ALSO appears in the
198
+ // "bypass active — skipping drift check" warning, so keying on it would
199
+ // misclassify a bypass-active add that later failed for a real reason as a
200
+ // (retryable!) drift — inviting an unsafe retry. The header below is printed
201
+ // only on a genuine drift abort.
202
+ if (/Streamed scripts carry unreviewed local content/i.test(log)
203
+ || /UNCOMMITTED LOCAL EDITS/.test(log)) {
204
+ const files = extractDriftFiles(log);
205
+ const fileList = files.length ? files.join(', ') : 'the streamed scripts';
206
+ const fix = files.length
207
+ ? `Commit & push them, or discard with \`git checkout -- ${files.join(' ')}\`, then Retry add.`
208
+ : 'Commit & push them, or discard the local edits, then Retry add.';
209
+ return buildFailureVerdict('preflight-drift', 'Blocked by local edits',
210
+ `Uncommitted local edits in ${fileList} tripped the streamed-script drift guard, which aborts before any cloud resource is created. ${fix}`,
211
+ { files });
212
+ }
213
+
214
+ // 2. instance-vanished — the GCP instance is confirmed gone; nothing to orphan.
215
+ if (/instance .* is gone/i.test(log)) {
216
+ return buildFailureVerdict('instance-vanished', 'Instance never came up',
217
+ 'The GCP instance is gone — provisioning failed before the container started, so nothing was left behind. Retrying the add is safe.');
218
+ }
219
+
220
+ // 3. project-bootstrap-timeout — the VM came up and its DETACHED project
221
+ // bootstrap is still running; we merely stopped waiting on it. This is the
222
+ // VM-8 / six-self-host-VM incident: a heavy `npm install` (a ~293 MB
223
+ // playwright postinstall, a large `git clone`) outran a local budget, the
224
+ // SIGTERM'd gcloud child returned `status: null`, and the abort path read
225
+ // that as a broken bootstrap script and DELETED A COMPLETELY HEALTHY VM.
226
+ //
227
+ // Retryable on re-entrancy grounds (see ADD_FAILURE_RETRYABLE): the retry
228
+ // re-attaches to the in-flight install rather than starting a second one.
229
+ // Tested BEFORE cloud-up-failed because the provision shell echoes cloud:up's
230
+ // tail, so this transcript ALSO carries the generic `cloud:up FAILED (exit N)`
231
+ // line — matching that first would strip the safe recovery and steer the
232
+ // operator to destroy a healthy, still-provisioning VM. The signature is the
233
+ // wording ensureProjectBootstrapped (npm/cloud.js) throws for both its
234
+ // overall-deadline and its launch-budget timeouts.
235
+ if (/project bootstrap exceeded the local/i.test(log)) {
236
+ return buildFailureVerdict('project-bootstrap-timeout', 'Still installing — we stopped waiting',
237
+ 'The VM provisioned fine and its project bootstrap is still running on it; only the local wait budget elapsed. Nothing is broken and nothing was left half-built. Retry the add to re-attach to the running install — each bootstrap step is skip-on-exists. Do NOT destroy this VM.');
238
+ }
239
+
240
+ // 4. container-stuck — the instance exists but the editor container never
241
+ // became healthy, so a half-provisioned box may exist. Not retryable.
242
+ if (/container never came up/i.test(log)) {
243
+ return buildFailureVerdict('container-stuck', 'Container never became healthy',
244
+ 'The instance came up but the editor container never became healthy — it may be half-provisioned. Reconcile or destroy this VM before re-adding.');
245
+ }
246
+
247
+ // 5. incomplete-workspace — the container came up but the project was never
248
+ // scaffolded: no `.codeyam/editor.json` exists under ~/projects on the VM,
249
+ // so the greenfield `init` did not complete onboarding. This is specific
250
+ // and more actionable than the generic cloud-up-failed below, so it is
251
+ // tested first. The VM exists and is half-provisioned, so it is NOT
252
+ // auto-retryable — reconcile finishes the scaffold, then re-add.
253
+ if (/no \.codeyam\/editor\.json found under ~\/projects/i.test(log)) {
254
+ return buildFailureVerdict('incomplete-workspace', 'Workspace never scaffolded',
255
+ 'The container came up but the project was never scaffolded (no .codeyam/editor.json on the VM), so provisioning did not finish onboarding. Reconcile this VM to complete the scaffold, then re-add.');
256
+ }
257
+
258
+ // 6. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
259
+ // vanished / stuck signatures above did not match. A partial instance may
260
+ // exist, so not retryable.
261
+ if (/cloud:up FAILED \(exit/i.test(log)) {
262
+ return buildFailureVerdict('cloud-up-failed', 'Provisioning failed',
263
+ 'cloud:up failed during provisioning, so a partial instance may exist. Reconcile or destroy this VM before re-adding.');
264
+ }
265
+
266
+ // 7. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
267
+ // Killed / out-of-memory marker. The real VM state is unknown. Not retryable.
268
+ if (fc.isSignalKillExit(Number(exitCode)) || /\bKilled\b/.test(log) || /Out of memory/i.test(log)) {
269
+ return buildFailureVerdict('interrupted', 'Provisioning interrupted',
270
+ 'The provision was interrupted (killed or out of memory), so the VM state is unknown. Reconcile or destroy this VM before re-adding.');
271
+ }
272
+
273
+ // 8. converging — the add reached the early-roster marker (`CY_ROSTER`): the
274
+ // container gate passed, the instance is real, and the VM is rostered, yet it
275
+ // exited non-zero WITHOUT matching any specific hard-failure signature above.
276
+ // By construction of the provision script, every genuine hard failure (drift,
277
+ // instance-vanished, container-stuck, incomplete-workspace, cloud-up-failed,
278
+ // interrupted) is printed BEFORE `CY_ROSTER`, so a rostered transcript that
279
+ // fell through to here is a still-wiring box owned by the detached
280
+ // wire-add-tail / reconcile-ingress — NOT an add failure. Surface it as an
281
+ // honest in-flight state instead of the scary red `unknown`. Not retryable:
282
+ // the VM exists and its detached wiring owns finishing it, so a one-click
283
+ // retry could duplicate/orphan a live box. Tested LAST so a hard-failure
284
+ // signature (all printed pre-roster) always wins — `converging` can never
285
+ // shadow a proven failure.
286
+ if (transcriptRostered(log)) {
287
+ return buildFailureVerdict('converging', 'Add still converging',
288
+ 'The container is up and the VM is rostered; the LB ingress is still wiring in the background. This resolves on its own once the editor is reachable over the LB — no action needed unless it persists past the wiring deadline.');
289
+ }
290
+
291
+ // 9. The residue: no failure signature above matched. The polarity here is
292
+ // INVERTED from the original design. It used to read "no recognized success
293
+ // signature ⇒ failed", which made red the DEFAULT for "we don't know" — so a
294
+ // VM whose container was Up and whose editor answered /api/health 200 got a
295
+ // terminal red "Add failed" card advising the operator to destroy it (VM-4,
296
+ // 2026-07-31; the wrapper had been killed mid-flight, so its transcript was
297
+ // truncated before CY_ROSTER and it never reached the `converging` arm above).
298
+ //
299
+ // Red now requires positive proof of death, and everything else is in-flight:
300
+ //
301
+ // 9a. unknown — a non-zero exit CORROBORATED by a durable fact (see
302
+ // addFailureCorroborated). The box is provably dead or provably never
303
+ // came up, so the loud, terminal escalation is warranted and unchanged.
304
+ // Still never invites an unproven retry.
305
+ if (addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown })) {
306
+ return buildFailureVerdict('unknown', 'Add failed',
307
+ 'The add failed for an unclassified reason and the VM is confirmed dead or never came up — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
308
+ }
309
+
310
+ // 9b. verifying — the honest default. No failure signature, and nothing
311
+ // durable proves this VM is dead: it may still be provisioning or
312
+ // wiring, and the ingress can keep propagating for minutes after the
313
+ // wrapper is gone. Calm copy, transcript still one click away. NOT
314
+ // retryable — nothing is proven about the box either way, so a one-click
315
+ // retry could still duplicate a live GCP instance (the ADD_FAILURE_RETRYABLE
316
+ // grounds are unchanged by this inversion).
317
+ return buildFailureVerdict('verifying', 'Add outcome unconfirmed',
318
+ 'No failure signature was found in the transcript and nothing proves this VM is dead, so the add may still be converging — the ingress keeps propagating for minutes after the provision wrapper exits. Inspect the transcript below; reconcile this VM only if it is still unresolved past the wiring deadline.');
319
+ }
320
+
321
+ // Pure guard for the operator "Retry add" action: given a VM number and its
322
+ // carried job, decide whether a one-click retry is SAFE. Retry is allowed ONLY
323
+ // for a terminal add-error whose classifier verdict is retryable (a category
324
+ // where nothing was provisioned). Returns { ok, reason } — reason is a stable
325
+ // code the server shell (actionRetryAdd) maps to an operator message. Mirrors
326
+ // fleet-job-badge.js::canClearBadge so the handler stays a thin I/O shell and the
327
+ // safety decision is unit-tested without a live dashboard.
328
+ function canRetryAdd({ n, job } = {}) {
329
+ if (!Number.isInteger(n) || n < 1) return { ok: false, reason: 'invalid-vm' };
330
+ if (!job || job.action !== 'add' || job.state !== 'error') return { ok: false, reason: 'no-failed-add' };
331
+ if (!job.retryable) return { ok: false, reason: 'not-retryable' };
332
+ return { ok: true, reason: null };
333
+ }
334
+
335
+ // Pure badge-hold predicate for deriveStatus (server.js): hold the calm in-flight
336
+ // card — instead of the red add-failed — while the add is still converging.
337
+ //
338
+ // The hold used to require `addTailInflight`, i.e. that the detached wire-add-tail
339
+ // PROCESS was still alive. That premise was wrong: the tail's exit says nothing
340
+ // about LB convergence, because the EXTERNAL_MANAGED backend services keep
341
+ // propagating for minutes afterwards. VM-4's backends were still being created at
342
+ // 18:17-18:20Z, long after its job had been declared failed at 18:15Z — the tail
343
+ // had exited, so the calm hold released and the card went red on a healthy VM.
344
+ //
345
+ // The hold is now driven by the question that actually matters: is this add still
346
+ // converging, and has its wiring deadline genuinely blown? `wiringDeadlineBlown`
347
+ // (the failed marker the tail drops when it gives up) stays the release signal —
348
+ // tail liveness is no longer a requirement for calm, so the calm state survives
349
+ // the multi-minute propagation window instead of ending at process exit.
350
+ //
351
+ // Both calm categories qualify (see CALM_ADD_CATEGORIES): a rostered box that is
352
+ // still wiring, and the unproven residue. Any proven-failure category returns
353
+ // false and falls through to the red add-failed status — the operator-chosen
354
+ // escalation, which this predicate narrows but never softens. Kept pure (like
355
+ // canRetryAdd / canClearBadge) so the badge decision is unit-tested without a
356
+ // live dashboard.
357
+ function holdConvergingBadge({ category, wiringDeadlineBlown } = {}) {
358
+ return CALM_ADD_CATEGORIES.has(category) && !wiringDeadlineBlown;
359
+ }
360
+
361
+ module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ // Pure content-hash UI versioning for the fleet dashboard's cache-busting
3
+ // (vm: Honest VM Card Display + Dashboard Cache-Busting).
4
+ //
5
+ // The dashboard serves its single-file UI (public/index.html) with `no-store`,
6
+ // so a hard-refresh always fetches the new code — but an already-OPEN tab never
7
+ // refetches the HTML and silently runs the pre-restart UI until the operator
8
+ // manually reloads. To fix that, the server stamps a short content hash of the
9
+ // served HTML into every /data response; the client records the version it
10
+ // booted with and `location.reload()`s on a mismatch, so a dashboard restart
11
+ // with new UI reaches an open tab automatically.
12
+ //
13
+ // This module is the PURE half — a deterministic hash of the HTML string, no
14
+ // I/O — mirroring the pure/impure split the other fleet-* modules follow
15
+ // (server.js owns reading the file; this owns turning bytes into a version).
16
+ // Unit-tested in npm/fleet-asset-version.test.js.
17
+ //
18
+ // Stack note: this is fleet-operator dashboard infrastructure (the GCE fleet
19
+ // that runs codeyam-editor itself). A project that never runs the dashboard
20
+ // never reaches it.
21
+ const crypto = require('crypto');
22
+
23
+ // computeAssetVersion returns a stable, short (12 hex chars) fingerprint of the
24
+ // served UI. Same HTML in ⇒ same version out (so an unchanged deploy never
25
+ // spuriously reloads open tabs); any byte change ⇒ a different version (so a
26
+ // real UI change always reloads them). 12 hex chars of SHA-1 is ample to make
27
+ // an accidental collision between two distinct UI builds negligible while
28
+ // keeping the badge short.
29
+ function computeAssetVersion(html) {
30
+ return crypto
31
+ .createHash('sha1')
32
+ .update(String(html == null ? '' : html))
33
+ .digest('hex')
34
+ .slice(0, 12);
35
+ }
36
+
37
+ module.exports = { computeAssetVersion };