@codeyam-editor/codeyam-editor 0.1.4 → 0.1.6

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 (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+ // IAP identity-token caching for the fleet dashboard's per-VM probes.
3
+ //
4
+ // On the GCE operator host the dashboard reaches each VM through its
5
+ // IAP-fronted LB hostname, which requires an OIDC ID token whose audience is
6
+ // the fleet's IAP OAuth client id.
7
+ //
8
+ // There are two mint paths, and they are NOT interchangeable:
9
+ //
10
+ // - `buildGceIdentityTokenRequest` — the metadata server's identity endpoint
11
+ // with `format=full`. The only one IAP actually accepts, because IAP
12
+ // authorizes on the token's `email` claim and only `format=full` carries it.
13
+ // This is the dashboard's path on the operator host.
14
+ // - `gcloudMintArgs` — `gcloud auth print-identity-token`, which mirrors
15
+ // `scripts/operator/fleet-redeploy.sh`. Retained for the off-GCE (laptop)
16
+ // path, where a human account's gcloud session is the credential. It does
17
+ // NOT emit an email claim, so it cannot authenticate the on-GCE probe.
18
+ //
19
+ // The dashboard polls every VM every few seconds, so minting per probe would
20
+ // fork a gcloud process per VM per poll. Instead we mint once and cache until
21
+ // shortly before the token's own expiry.
22
+ //
23
+ // Pure logic only — the token cache, the JWT expiry read, and the gcloud argv
24
+ // are all pure; the caller injects `mintToken` (the actual subprocess) and
25
+ // `now`. Keeps it unit-testable in isolation like vm-urls.js.
26
+
27
+ // Refresh this long before the token's stated expiry. An IAP token lives ~1h;
28
+ // renewing a minute early means an in-flight poll can never race the boundary
29
+ // and get a 401 that would render as "auth misconfigured" on every card.
30
+ const EXPIRY_SKEW_MS = 60_000;
31
+
32
+ // Fallback lifetime when a minted token carries no readable `exp`. Deliberately
33
+ // short: re-minting slightly too often is cheap, serving a dead token is not.
34
+ const FALLBACK_TTL_MS = 5 * 60_000;
35
+
36
+ /** The gcloud argv that mints an IAP identity token for `clientId`.
37
+ * Mirrors fleet-redeploy.sh's `gcloud_sa auth print-identity-token
38
+ * --audiences=<IAP_CLIENT_ID>` so both sides request the same audience. */
39
+ function gcloudMintArgs({ clientId } = {}) {
40
+ const id = clientId == null ? '' : String(clientId).trim();
41
+ if (id === '') throw new Error('gcloudMintArgs requires an IAP client id');
42
+ return ['auth', 'print-identity-token', `--audiences=${id}`];
43
+ }
44
+
45
+ // The GCE metadata server's OIDC identity endpoint. Reachable only from a GCE
46
+ // instance, and only with the `Metadata-Flavor: Google` header.
47
+ const GCE_IDENTITY_URL =
48
+ 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity';
49
+
50
+ /** The metadata-server request that mints an IAP identity token for `audience`.
51
+ *
52
+ * Two details are load-bearing and were validated live against the fleet IAP
53
+ * on 2026-07-19:
54
+ *
55
+ * - `format=full` is what makes the token carry `email` + `email_verified`.
56
+ * IAP authorizes on the email, so the default (`format=standard`) token —
57
+ * and equally `gcloud auth print-identity-token`, which also omits email —
58
+ * is rejected with a 401 no matter how correct the audience is.
59
+ * - `audience` must be the MANUAL OAuth client id the backends are configured
60
+ * with. Google-managed IAP exposes no usable programmatic audience: the
61
+ * client id recovered from IAP's browser 302 is rejected as `Invalid JWT
62
+ * audience`. See scripts/gcp/google-ingress.sh step 8.
63
+ */
64
+ function buildGceIdentityTokenRequest({ audience } = {}) {
65
+ const aud = audience == null ? '' : String(audience).trim();
66
+ if (aud === '') throw new Error('buildGceIdentityTokenRequest requires an IAP client id');
67
+ return {
68
+ url: `${GCE_IDENTITY_URL}?audience=${encodeURIComponent(aud)}&format=full`,
69
+ headers: { 'Metadata-Flavor': 'Google' },
70
+ };
71
+ }
72
+
73
+ /** Which mint path applies, given the host and the configured audience.
74
+ *
75
+ * Three outcomes, and the distinction between the last two is the fix:
76
+ * 'unconfigured' — no client id. There is no audience to mint for, so the
77
+ * probe must report 'auth-misconfigured' rather than mint
78
+ * a token IAP will reject.
79
+ * 'metadata' — on the GCE operator host. Only the metadata endpoint's
80
+ * format=full token carries the `email` claim IAP
81
+ * authorizes on.
82
+ * 'gcloud' — off GCE (laptop), where there is no metadata server and
83
+ * a human gcloud session is the credential.
84
+ */
85
+ function selectMintStrategy({ onGce, clientId } = {}) {
86
+ const id = clientId == null ? '' : String(clientId).trim();
87
+ if (id === '') return 'unconfigured';
88
+ return onGce ? 'metadata' : 'gcloud';
89
+ }
90
+
91
+ /** Read the `exp` claim (ms since epoch) out of a JWT without verifying it.
92
+ * We are not authenticating the token — the LB does that — we only need to
93
+ * know when to stop reusing it. Returns null for anything unparseable, which
94
+ * sends the caller to FALLBACK_TTL_MS. */
95
+ function decodeJwtExpiryMs(token) {
96
+ const raw = token == null ? '' : String(token).trim();
97
+ const parts = raw.split('.');
98
+ if (parts.length !== 3) return null;
99
+ try {
100
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
101
+ const exp = Number(payload && payload.exp);
102
+ if (!Number.isFinite(exp) || exp <= 0) return null;
103
+ return exp * 1000;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /** Build a caching IAP-token provider.
110
+ *
111
+ * `mintToken()` must resolve to a raw token string (or null/'' on failure);
112
+ * `now()` returns epoch ms. Returns `{ getToken }`, where getToken() resolves
113
+ * to a usable token or null. Null is a first-class outcome — the probe layer
114
+ * turns it into 'auth-misconfigured' rather than a false "unreachable", so a
115
+ * broken credential is legible on the card instead of looking like a dead VM.
116
+ *
117
+ * Concurrent callers share one in-flight mint: the dashboard probes N VMs in
118
+ * parallel each poll, and without this a cold cache would fork N gcloud
119
+ * processes simultaneously.
120
+ */
121
+ function createIapTokenProvider({ mintToken, now, skewMs = EXPIRY_SKEW_MS } = {}) {
122
+ if (typeof mintToken !== 'function') {
123
+ throw new Error('createIapTokenProvider requires a mintToken function');
124
+ }
125
+ const clock = typeof now === 'function' ? now : Date.now;
126
+
127
+ let cached = null;
128
+ let expiresAtMs = 0;
129
+ let inFlight = null;
130
+
131
+ async function mintAndCache() {
132
+ const token = await mintToken();
133
+ const value = token == null ? '' : String(token).trim();
134
+ if (value === '') {
135
+ cached = null;
136
+ expiresAtMs = 0;
137
+ return null;
138
+ }
139
+ const exp = decodeJwtExpiryMs(value);
140
+ cached = value;
141
+ expiresAtMs = exp == null ? clock() + FALLBACK_TTL_MS : exp;
142
+ return cached;
143
+ }
144
+
145
+ async function getToken() {
146
+ if (cached !== null && clock() + skewMs < expiresAtMs) return cached;
147
+ if (inFlight === null) {
148
+ inFlight = mintAndCache().finally(() => { inFlight = null; });
149
+ }
150
+ return inFlight;
151
+ }
152
+
153
+ return { getToken };
154
+ }
155
+
156
+ module.exports = {
157
+ EXPIRY_SKEW_MS,
158
+ FALLBACK_TTL_MS,
159
+ GCE_IDENTITY_URL,
160
+ gcloudMintArgs,
161
+ buildGceIdentityTokenRequest,
162
+ selectMintStrategy,
163
+ decodeJwtExpiryMs,
164
+ createIapTokenProvider,
165
+ };
@@ -2,7 +2,7 @@
2
2
 
3
3
  // npm/fleet-job-badge.js — pure verdict for the fleet dashboard's failed-job
4
4
  // badge reconciliation (improve39). A VM's last terminal-error heavy job
5
- // (add/error, reset-all error, reset-editor error, …) used to stick on the
5
+ // (add/error, reset-all error, reset-client error, …) used to stick on the
6
6
  // dashboard card indefinitely — even after the VM was recovered and is healthy
7
7
  // again — because the card renders the last job's error with no check against
8
8
  // the VM's live state, so a recovered VM keeps screaming a red error and
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+ // Pure helpers for running a VM `add` provision as its OWN transient launchd
3
+ // job (`com.codeyam.fleet-add-vm-<n>`) — improve46 Phase 3. The provision job
4
+ // is a SIBLING of `com.codeyam.fleet-dashboard` rather than a `setsid` child of
5
+ // it, so a `launchctl bootout` of the dashboard label (which tears down the
6
+ // whole dashboard launchd job tree regardless of session/process-group) has no
7
+ // authority over the provision. Phases 1 (signal-kill reclassification) and 2
8
+ // (the reinstall in-flight-add guard) still compose with this — Phase 3 reduces
9
+ // how often a bootout reaches the provision at all.
10
+ //
11
+ // All the string-building and parsing lives here so server.js can stay thin and
12
+ // these pieces are unit-testable WITHOUT launchctl (npm/fleet-launchd.test.js) —
13
+ // the same decomposition `npm/fleet-cleanup.js` uses for the reap brain.
14
+ //
15
+ // Stack assumption: launchd is macOS-only. server.js gates the whole own-job
16
+ // path on platform + a `launchctl` probe and falls back to the existing `setsid`
17
+ // detached spawn on cloud containers / CI / Linux, where these helpers are never
18
+ // reached. They are pure either way.
19
+
20
+ // The launchd label for VM-n's transient add-provision job. ONE label per VM
21
+ // number so the re-add collision guard and the completion teardown each address
22
+ // exactly one job. Mirrors the `com.codeyam.fleet-*` label family used by the
23
+ // dashboard's other launchd agents (see scripts/operator/launchd-reinstall.sh).
24
+ function addVmLabel(n) {
25
+ return `com.codeyam.fleet-add-vm-${n}`;
26
+ }
27
+
28
+ // The file the transient job's stdout/stderr are redirected to (the plist's
29
+ // Standard{Out,Error}Path). The dashboard tails this for the in-progress card's
30
+ // live log when the launchd path is taken — the equivalent of the `setsid`
31
+ // path's stdout pipe, which a separate launchd job can't share. Kept DISTINCT
32
+ // from `/tmp/dash-add-vm-<n>.log` (the addVm script's own `cloud:up` redirect)
33
+ // so the provision shell's progress echoes ("provisioning…", "VM-n provisioned")
34
+ // aren't interleaved with the cloud:up build output.
35
+ function addVmJobLogPath(n) {
36
+ return `/tmp/dash-add-vm-${n}.provision.log`;
37
+ }
38
+
39
+ // XML-escape a string for safe inclusion in a plist `<string>` body. Plists are
40
+ // XML, so the five predefined entities must be escaped or a path/env value with
41
+ // `&`/`<`/`>`/quotes would corrupt the plist. Pure.
42
+ function xmlEscape(s) {
43
+ return String(s == null ? '' : s)
44
+ .replace(/&/g, '&amp;')
45
+ .replace(/</g, '&lt;')
46
+ .replace(/>/g, '&gt;')
47
+ .replace(/"/g, '&quot;')
48
+ .replace(/'/g, '&apos;');
49
+ }
50
+
51
+ // Build a transient launchd plist (XML text) that runs `programArguments` ONCE
52
+ // under `label`. RunAtLoad=true so `launchctl bootstrap` fires it immediately;
53
+ // KeepAlive=false so launchd NEVER relaunches the provision after it exits (a
54
+ // one-shot job, not a daemon). stdout/stderr go to `logPath` so the dashboard
55
+ // can tail the live card. `environment` (the bootstrap form env) and
56
+ // `workingDirectory` are optional. Pure — caller writes the returned text to a
57
+ // temp file and bootstraps it.
58
+ function buildAddVmPlist({ label, programArguments, logPath, workingDirectory, environment } = {}) {
59
+ const args = (Array.isArray(programArguments) ? programArguments : [])
60
+ .map((a) => ` <string>${xmlEscape(a)}</string>`)
61
+ .join('\n');
62
+ const lines = [
63
+ '<?xml version="1.0" encoding="UTF-8"?>',
64
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
65
+ '<plist version="1.0">',
66
+ '<dict>',
67
+ ' <key>Label</key>',
68
+ ` <string>${xmlEscape(label)}</string>`,
69
+ ' <key>ProgramArguments</key>',
70
+ ' <array>',
71
+ args,
72
+ ' </array>',
73
+ ' <key>RunAtLoad</key>',
74
+ ' <true/>',
75
+ ' <key>KeepAlive</key>',
76
+ ' <false/>',
77
+ ];
78
+ if (logPath) {
79
+ lines.push(' <key>StandardOutPath</key>', ` <string>${xmlEscape(logPath)}</string>`);
80
+ lines.push(' <key>StandardErrorPath</key>', ` <string>${xmlEscape(logPath)}</string>`);
81
+ }
82
+ if (workingDirectory) {
83
+ lines.push(' <key>WorkingDirectory</key>', ` <string>${xmlEscape(workingDirectory)}</string>`);
84
+ }
85
+ const env = environment && typeof environment === 'object' ? environment : null;
86
+ if (env && Object.keys(env).length > 0) {
87
+ lines.push(' <key>EnvironmentVariables</key>', ' <dict>');
88
+ for (const [k, v] of Object.entries(env)) {
89
+ lines.push(` <key>${xmlEscape(k)}</key>`, ` <string>${xmlEscape(v)}</string>`);
90
+ }
91
+ lines.push(' </dict>');
92
+ }
93
+ lines.push('</dict>', '</plist>', '');
94
+ return lines.join('\n');
95
+ }
96
+
97
+ // Parse the integer pid from `launchctl print gui/$UID/<label>` output. While
98
+ // the job's process is alive the block contains a `pid = N` line; this resolves
99
+ // it so the dashboard records the REAL provision pid into the sidecar `meta.pid`
100
+ // (the field `reattachVerdict`'s `kill -0` re-attach reads) rather than the
101
+ // launchctl invocation's own pid. Returns the integer pid, or null when there's
102
+ // no live process line (job not started yet, already exited, or junk). `pid = 0`
103
+ // is treated as no-pid. Pure: takes the text so it's testable without launchctl.
104
+ function parseLaunchdPrintPid(text) {
105
+ const m = String(text == null ? '' : text).match(/^\s*pid\s*=\s*(\d+)\b/m);
106
+ if (!m) return null;
107
+ const pid = Number(m[1]);
108
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
109
+ }
110
+
111
+ // Filter a captured transcript down to the live-card tail: drop the noisy
112
+ // gcloud/IAP/NumPy lines and keep the last `maxLines`. Pure — the same shaping
113
+ // startJob's `tailLog` applies to the setsid stdout pipe, extracted so the
114
+ // launchd path's logfile tail and the setsid path share ONE tested filter
115
+ // instead of duplicating the regex + slice.
116
+ function filterTailLog(text, maxLines = 14) {
117
+ return String(text == null ? '' : text).split('\n')
118
+ .filter((l) => !/WARNING|NumPy|google\.com\/iap/.test(l))
119
+ .join('\n').trim().split('\n').slice(-maxLines).join('\n');
120
+ }
121
+
122
+ // True when a `spawnSync` result represents a successful launchctl invocation:
123
+ // a defined result with a zero exit status and no spawn error (ENOENT etc.).
124
+ // Pure — lets the bootstrap/`load -w` success checks be one tested predicate.
125
+ function launchctlSpawnSucceeded(result) {
126
+ return !!result && result.status === 0 && !result.error;
127
+ }
128
+
129
+ // Decide what the launchd add-completion watcher should do on one tick, from the
130
+ // durable signals it gathers: the exit marker's code (or null when absent),
131
+ // whether the launchd process is still alive, and how many consecutive ticks it
132
+ // has been gone. Pure state machine — the I/O (reading the marker, `kill -0`,
133
+ // tailing the log) stays in the server.js watcher; this owns the decision so it
134
+ // is exhaustively testable without launchd.
135
+ // - marker present → done, that exit code (the normal path)
136
+ // - process gone for `graceTicks` → done, exit 1 (EXIT trap never wrote a
137
+ // marker — resolve the card as a failure
138
+ // rather than tailing forever)
139
+ // - process gone, under grace → keep waiting, bump goneTicks
140
+ // - process alive → keep waiting, reset goneTicks
141
+ function launchdWatchVerdict({ markerExitCode, pidAlive, goneTicks = 0, graceTicks = 3 } = {}) {
142
+ if (markerExitCode != null) return { done: true, exitCode: markerExitCode, goneTicks: 0 };
143
+ if (!pidAlive) {
144
+ const next = goneTicks + 1;
145
+ if (next >= graceTicks) return { done: true, exitCode: 1, goneTicks: next };
146
+ return { done: false, goneTicks: next };
147
+ }
148
+ return { done: false, goneTicks: 0 };
149
+ }
150
+
151
+ // Decide whether the own-launchd-job path should be taken for a spawn. Pure
152
+ // gate: it's taken only for a `detached` add (the sole caller that passes
153
+ // `detached`) AND on a host where `launchctl` is actually present. Everything
154
+ // else — a non-detached job, or a host with no launchctl (cloud container / CI /
155
+ // Linux) — falls back to the existing `setsid` detached spawn. Centralised here
156
+ // so the gate is one tested predicate, not an inline `&&` at the spawn site.
157
+ function shouldUseLaunchd({ detached, hasLaunchctl } = {}) {
158
+ return !!detached && !!hasLaunchctl;
159
+ }
160
+
161
+ module.exports = {
162
+ addVmLabel,
163
+ addVmJobLogPath,
164
+ xmlEscape,
165
+ buildAddVmPlist,
166
+ parseLaunchdPrintPid,
167
+ filterTailLog,
168
+ launchctlSpawnSucceeded,
169
+ launchdWatchVerdict,
170
+ shouldUseLaunchd,
171
+ };
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+ // Pure session/broker live-status derivation for the Fleet Dashboard's per-VM
3
+ // status LABEL (scripts/operator/fleet-dashboard/server.js requires this). This
4
+ // is the trailing decision of deriveStatus, after every heavy-job / orphan /
5
+ // failed-badge branch has been ruled out: given the reachable VM's session and
6
+ // broker signals, is it building, waiting, or genuinely idle?
7
+ //
8
+ // The bug this fixes (observed live 2026-06-20, VM-1): the label was derived
9
+ // SOLELY from `hasSession` — the editor server's own in-process build-session
10
+ // flag. But a Claude agent's lifetime is owned by the PTY broker, deliberately
11
+ // DECOUPLED from the editor-server lifetime (see docs/process-model.md). After a
12
+ // server restart, or for a broker-resumed/detached agent the server didn't spawn
13
+ // itself, `hasSession` is false while a fully-live agent keeps working in the
14
+ // broker. The card fell through to 'idle' even though the broker dot — driven by
15
+ // the same `brokerStatus` — already showed 'agent-live'. The label and the dot
16
+ // disagreed.
17
+ //
18
+ // The fix consults the already-debounced broker tri-state (from
19
+ // fleet-broker-status.js, threaded in by the caller) so a broker-held live agent
20
+ // reads as working, not idle. `awaitingUserInput` is honored independent of
21
+ // `hasSession`, so a broker-live agent blocked on input reads 'waiting'.
22
+ //
23
+ // The terminal-done refinement (observed live 2026-06-21): a broker-live agent
24
+ // whose WORKFLOW reached its terminal step is parked alive at its prompt but is
25
+ // NOT building — the editor masks the feature and reports `needsCleanup: true`
26
+ // (session_info_response.rs). Without this signal the card showed an active-green
27
+ // "Building" border on a finished, idle VM. We discriminate on `needsCleanup`
28
+ // (a positive terminal signal) rather than feature-emptiness (also briefly true
29
+ // at session START, which would mislabel a just-starting agent as done). This
30
+ // only ever DOWNGRADES a would-be-'building' verdict to 'session-done'; a
31
+ // mid-feature post-restart agent is not terminal (`needsCleanup` falsy) and stays
32
+ // 'building', preserving the 2026-06-20 decoupled-agent fix above.
33
+ //
34
+ // Returns 'waiting' | 'building' | 'session-done' | 'idle' for a reachable VM, or
35
+ // `null` when the VM is unreachable — the caller maps null to its own
36
+ // asleep/unreachable verdict (it owns the running-fleet snapshot this module
37
+ // deliberately does not touch) and otherwise passes the verdict through verbatim
38
+ // as the card's `status`, so 'session-done' must match the card's status class.
39
+ // Precedence is preserved: an unreachable VM returns null regardless of a stale
40
+ // 'agent-live', so the broker branch only ever upgrades a would-be-idle verdict.
41
+ function liveSessionStatus(v, brokerStatus) {
42
+ // Unreachable: we polled nothing reliable. Defer to the caller's
43
+ // asleep/unreachable mapping — a stale broker signal must not mask it.
44
+ if (!v.reachable) return null;
45
+ if (v.hasSession && v.awaitingUserInput) return awaitingVerdict(v);
46
+ if (v.hasSession) return 'building';
47
+ // No editor-owned session, but a live agent may still be working in the PTY
48
+ // broker (decoupled lifetime — see docs/process-model.md). The broker
49
+ // tri-state is already debounced upstream; agent-live ⇒ working, not idle.
50
+ if (brokerStatus === 'agent-live') {
51
+ if (v.awaitingUserInput) return awaitingVerdict(v);
52
+ // The agent is parked alive in the broker's build slot, but its workflow
53
+ // reached the terminal step (editor reports needsCleanup) — finished and
54
+ // idle, not building. A mid-feature post-restart agent is NOT terminal and
55
+ // still reads 'building' (preserves the 2026-06-20 decoupled-agent fix).
56
+ if (v.needsCleanup) return 'session-done';
57
+ return 'building';
58
+ }
59
+ return 'idle';
60
+ }
61
+
62
+ // Does the VM have a task/feature genuinely running? `feature`/`featureStartedAt`
63
+ // (from /api/session-info) are both None at a fresh prompt and Some once a build
64
+ // feature is selected, so either being present means a task is in flight.
65
+ function hasActiveFeature(v) {
66
+ return !!(v.feature || v.featureStartedAt);
67
+ }
68
+
69
+ // Discriminate an `awaitingUserInput` agent between two very different shapes
70
+ // (observed live 2026-06-30, VM-1): an agent parked at its prompt with NO task
71
+ // running is IDLE — ready and waiting for YOU to start something — not the same
72
+ // as a running task BLOCKED on a human answer. Only the latter is 'waiting'
73
+ // (which the needs-you overlay later escalates). A no-feature, non-terminal
74
+ // awaiting agent reads 'idle' so a freshly-launched healthy VM never false-flags
75
+ // "Waiting"/"Needs You". `needsCleanup` (the terminal-done signal the module
76
+ // already trusts) keeps a finished session out of the idle bucket.
77
+ function awaitingVerdict(v) {
78
+ if (!v.needsCleanup && !hasActiveFeature(v)) return 'idle';
79
+ return 'waiting';
80
+ }
81
+
82
+ // Distinguish a freshly-rostered VM whose editor is still STARTING UP — a normal
83
+ // cold boot that hasn't bound :14199 yet, or (the VM-2 case) the ~10–15-min
84
+ // self-host `rebuild-self --force-in-container` compile that recompiles the
85
+ // editor binary from source before the server can listen — from a genuinely DEAD
86
+ // one. deriveStatus (scripts/operator/fleet-dashboard/server.js) calls this only
87
+ // after it has already established the VM is rostered, unreachable, and GCP still
88
+ // lists it RUNNING — i.e. exactly the case that otherwise falls through to the
89
+ // red 'unreachable' that made a successful Add look like a failure.
90
+ //
91
+ // Returns 'editor-starting' when EITHER the VM was launched within the grace
92
+ // window (`recentlyLaunched` — the cheap common case, no SSH needed) OR an
93
+ // in-container probe has CONFIRMED a first-run build is still running
94
+ // (`probeBuilding === true`), which lets the long self-host compile keep reading
95
+ // "Starting editor…" past the window with no fragile fixed timeout. Returns null
96
+ // otherwise so deriveStatus runs its existing unreachable path unchanged — an
97
+ // honest red card the moment the grace window lapses with no confirmed build. A
98
+ // DISTINCT key from the wake-path 'starting' (fleet-sleep-status.js) so the two
99
+ // in-progress states never collide. Pure; the caller owns the time + SSH I/O.
100
+ function classifyStartingVsUnreachable({ recentlyLaunched = false, probeBuilding = null } = {}) {
101
+ if (recentlyLaunched || probeBuilding === true) return 'editor-starting';
102
+ return null;
103
+ }
104
+
105
+ // Classify the in-container startup probe (server.js's editorStartingInContainer
106
+ // one-IAP-SSH round-trip) into the tri-state the verdict cache stores and
107
+ // classifyStartingVsUnreachable consumes. Mirrors fleet-cleanup.js's
108
+ // classifyProjectWiring shape: takes the raw `{ stdout, err }` and parses the
109
+ // three `---CY---`-separated segments the probe emits — the `docker ps` status
110
+ // line, the count of listeners on :14199, and the count of live first-run build
111
+ // processes (`rebuild-self` / `cargo build`):
112
+ // true — container up, :14199 NOT yet bound, and a build process is alive ⇒
113
+ // still building (keeps the card calm-blue past the grace window).
114
+ // false — container up but :14199 already bound (the normal reachable path
115
+ // will win) OR up-and-idle with no build process (let the card flip to
116
+ // honest 'unreachable'); also a clean response with the container down.
117
+ // null — an ssh/docker blip (`err`) ⇒ inconclusive; never upgrades a
118
+ // past-window card (stays honest), re-probed next poll.
119
+ function classifyStartupProbe({ stdout, err } = {}) {
120
+ if (err) return null;
121
+ const parts = String(stdout == null ? '' : stdout).split('---CY---').map((s) => s.trim());
122
+ const containerUp = /^Up\b/m.test(parts[0] || '');
123
+ const portBound = (Number((parts[1] || '0').trim()) || 0) > 0;
124
+ const buildProcAlive = (Number((parts[2] || '0').trim()) || 0) > 0;
125
+ if (!containerUp) return false; // clean response, container down → not building
126
+ if (portBound) return false; // already serving — the reachable path owns it
127
+ return buildProcAlive ? true : false;
128
+ }
129
+
130
+ // Pure gate deciding whether pollStartupProbes should fire the (costly) IAP-SSH
131
+ // build probe for a given VM this tick. We only probe a VM that is PLAUSIBLY
132
+ // still starting — present, unreachable, GCP still lists it RUNNING, not blocked
133
+ // by an active heavy job, and either recently launched OR already confirmed
134
+ // building on a prior tick (so a ~15-min self-host compile keeps being probed
135
+ // past the grace window with no fixed cap). A healthy/reachable VM, an
136
+ // asleep/vanished one, or a long-idle one that simply went unreachable is never
137
+ // probed.
138
+ //
139
+ // The add-job exception (observed live 2026-06-30, VM-1): an active ADD job does
140
+ // NOT block the probe. The long self-host container build runs *under* the add,
141
+ // so we must probe DURING it to prime `wasBuilding` early — otherwise, by the
142
+ // time the add completes the recentlyLaunched grace has already lapsed, no prior
143
+ // probe ever ran (the old `!jobActive` guard suppressed them all), and the
144
+ // still-booting editor falls straight to red 'unreachable' instead of calm
145
+ // 'editor-starting'. Priming `wasBuilding` during the add lets the probe chain
146
+ // carry the card calm past the grace with no fixed cap. Any OTHER active heavy
147
+ // job (rebuild/destroy/reconfigure/…) still gates the probe out — its own status
148
+ // already owns the card. Pure so pollStartupProbes stays a thin SSH loop and this
149
+ // decision matrix is exhaustively unit-testable without booting the server.
150
+ function shouldProbeStartup({ vmPresent = false, reachable = false, gcpRunning = false, jobActive = false, jobIsAdd = false, recentlyLaunched = false, wasBuilding = false } = {}) {
151
+ const blockedByJob = jobActive && !jobIsAdd;
152
+ return vmPresent && !reachable && gcpRunning && !blockedByJob && (recentlyLaunched || wasBuilding);
153
+ }
154
+
155
+ module.exports = { liveSessionStatus, classifyStartingVsUnreachable, classifyStartupProbe, shouldProbeStartup };