@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
package/npm/fleet-env.js CHANGED
@@ -99,6 +99,89 @@ function envToDict(text) {
99
99
  return dict;
100
100
  }
101
101
 
102
+ // Canonicalize a git repo URL for identity comparison. Strips any embedded
103
+ // credentials (`user@` / `user:pass@`), normalizes the two forms GitHub emits
104
+ // — `git@host:owner/repo` (scp-like) and `https://host/owner/repo` — to a
105
+ // single `host/owner/repo` shape, drops a trailing `.git` and trailing
106
+ // slashes, and lowercases the result. Returns '' for null/empty. This makes
107
+ // `https://github.com/sfpatchworks/back-office.git` equal to
108
+ // `git@github.com:sfpatchworks/back-office`, so the per-slot env tag matches
109
+ // the fleet repo regardless of which URL form each was recorded in.
110
+ function normalizeRepoUrl(url) {
111
+ let s = String(url == null ? '' : url).trim();
112
+ if (s === '') return '';
113
+ // scp-like `user@host:owner/repo` → `host/owner/repo` (only when there is no
114
+ // `://` scheme; `[^@/:]+@[^:/]+:` guarantees a single `host:` colon, not a
115
+ // scheme's `://`).
116
+ const scp = s.match(/^[^@/:]+@([^:/]+):(.+)$/);
117
+ if (scp) {
118
+ s = `${scp[1]}/${scp[2]}`;
119
+ } else {
120
+ s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ''); // strip scheme
121
+ s = s.replace(/^[^@/]+@/, ''); // strip credentials
122
+ }
123
+ s = s.replace(/\/+$/, '').replace(/\.git$/i, '');
124
+ return s.toLowerCase();
125
+ }
126
+
127
+ // Classify whether a slot's persisted env may be re-applied to the repo the
128
+ // slot now serves. Returns one of:
129
+ // 'apply' — metaRepo is non-empty and normalizes equal to currentRepo
130
+ // 'skip-mismatch' — metaRepo is non-empty but normalizes differently
131
+ // 'skip-untagged' — metaRepo is empty/absent (a legacy file with no meta
132
+ // sidecar; treated as unattributed and never replayed)
133
+ // This is the guard that stops a prior project's env from silently bleeding
134
+ // onto a new, unrelated project that reuses the same VM slot number.
135
+ function classifyEnvApplicability({ metaRepo, currentRepo } = {}) {
136
+ const tagged = normalizeRepoUrl(metaRepo);
137
+ if (tagged === '') return 'skip-untagged';
138
+ return tagged === normalizeRepoUrl(currentRepo) ? 'apply' : 'skip-mismatch';
139
+ }
140
+
141
+ // Prisma datasource providers we know how to scheme-check a `DATABASE_URL`
142
+ // against. Used to skip the generator block's `provider = "prisma-client-js"`
143
+ // and match only the datasource `provider`.
144
+ const DB_PROVIDERS = new Set([
145
+ 'sqlite', 'postgresql', 'postgres', 'mysql', 'sqlserver', 'mongodb', 'cockroachdb',
146
+ ]);
147
+
148
+ // Advisory: detect a Prisma datasource provider vs `DATABASE_URL` scheme
149
+ // mismatch. Returns a short, actionable warning string, or null. Only fires
150
+ // when `schemaText` declares a recognized datasource `provider` AND `envText`
151
+ // declares a `DATABASE_URL` whose scheme is incompatible with it — currently
152
+ // the two directions that actually broke a live project: provider `sqlite`
153
+ // with a `postgresql`/`postgres` URL, or provider `postgresql`/`postgres`
154
+ // with a `file:` (SQLite) URL. Returns null when there is no schema, no
155
+ // recognized datasource provider, no `DATABASE_URL`, or they agree — so
156
+ // non-Prisma / no-DB projects are a silent no-op. Pure: both texts are passed
157
+ // in by the I/O caller.
158
+ function detectDbProviderMismatch(envText, schemaText) {
159
+ const schema = String(schemaText == null ? '' : schemaText);
160
+ let provider = null;
161
+ const re = /provider\s*=\s*"([^"]+)"/g;
162
+ let m;
163
+ while ((m = re.exec(schema)) !== null) {
164
+ const p = m[1].toLowerCase();
165
+ if (DB_PROVIDERS.has(p)) { provider = p; break; }
166
+ }
167
+ if (!provider) return null;
168
+ const dict = envToDict(envText) || {};
169
+ const url = dict.DATABASE_URL;
170
+ if (!url) return null;
171
+ const scheme = (String(url).match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1];
172
+ if (!scheme) return null;
173
+ const s = scheme.toLowerCase();
174
+ const isPostgres = s === 'postgresql' || s === 'postgres';
175
+ const isSqlite = s === 'file';
176
+ if (provider === 'sqlite' && isPostgres) {
177
+ return `DATABASE_URL scheme "${scheme}" is a Postgres URL but the Prisma schema declares provider = "sqlite" — the app will fail to open its database. Re-enter env with a file: URL, or fix the schema provider.`;
178
+ }
179
+ if ((provider === 'postgresql' || provider === 'postgres') && isSqlite) {
180
+ return `DATABASE_URL scheme "file" is a SQLite URL but the Prisma schema declares provider = "${provider}" — the app expects a Postgres connection string. Re-enter env accordingly.`;
181
+ }
182
+ return null;
183
+ }
184
+
102
185
  // Wrap a parsed launch-form env dict in the cloud:up bootstrap-child-env
103
186
  // shape: spreads `envDict` so the keys land in the child's `process.env`, and
104
187
  // sets `CY_FORWARD_ENV_KEYS` to the comma-separated key list so
@@ -120,6 +203,9 @@ module.exports = {
120
203
  envHasSetupTrigger,
121
204
  envToDict,
122
205
  buildBootstrapChildEnv,
206
+ normalizeRepoUrl,
207
+ classifyEnvApplicability,
208
+ detectDbProviderMismatch,
123
209
  FORWARD_ENV_KEYS_ENV,
124
210
  KEY_RE,
125
211
  };
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-ghcr-secret.js — pure decisions for sourcing the write:packages GHCR
4
+ // push token from GCP Secret Manager instead of a laptop shell env or a baked
5
+ // unit env.
6
+ //
7
+ // Why this exists: the fleet dashboard needs CODEYAM_GHCR_PUSH_TOKEN
8
+ // (write:packages) for self-feeding — after one VM compiles the editor it
9
+ // publishes the binary so the next VM pulls it (~2 min) instead of recompiling
10
+ // (~10 min). On the GCE operator host the token was never wired in at all, so
11
+ // self-feeding was structurally off and the "Self-feeding disabled" banner
12
+ // (npm/fleet-prewarm.js::pushTokenDiagnostic) was permanent — and every
13
+ // reprovision would drop any hand-set value. Reading it from Secret Manager at
14
+ // boot via the attached service account (metadata creds) means the token never
15
+ // lives in the unit env, a git checkout, or a laptop shell, survives reprovision
16
+ // + rotation (a dashboard restart picks up the latest version), and both the
17
+ // banner and the actual `cloud:up` publish get the token from one source.
18
+ //
19
+ // This module holds only the *decisions* (fetch-or-not, which secret, argv,
20
+ // payload parse) so each case is unit-tested without a live gcloud. The I/O — the
21
+ // `${GCLOUD} secrets versions access` subprocess and assigning the resolved value
22
+ // onto process.env — lives in the dashboard server shell that calls these.
23
+
24
+ // The default Secret Manager secret id the operator host reads when no explicit
25
+ // name is configured. Kept in sync with the name provision-operator-host.sh
26
+ // grants the SA accessor on and prints in its create-command hint.
27
+ const DEFAULT_PUSH_TOKEN_SECRET = 'codeyam-ghcr-push-token';
28
+
29
+ // Decide whether to hydrate the push token from Secret Manager, and from which
30
+ // secret. The rule, in precedence order:
31
+ //
32
+ // token present → never fetch. An explicit env var always wins, so
33
+ // the laptop path and any operator override are
34
+ // untouched (strictly back-compatible).
35
+ // explicit secretNameEnv → fetch that named secret (works anywhere gcloud
36
+ // can reach it, laptop included, if the operator
37
+ // opts in by setting CODEYAM_GHCR_PUSH_TOKEN_SECRET).
38
+ // onGce, no env → fetch the DEFAULT secret. The operator host runs
39
+ // with the SA attached (metadata creds), so this is
40
+ // the zero-config self-feeding path.
41
+ // otherwise → don't fetch. A plain laptop with neither the token
42
+ // nor a secret name configured must NOT fire a
43
+ // gcloud call that would just fail — no noise.
44
+ //
45
+ // Returns { fetch:false, reason } or { fetch:true, secretName, reason }.
46
+ function secretFetchPlan({ token, onGce, secretNameEnv } = {}) {
47
+ if (token && String(token).trim()) {
48
+ return { fetch: false, reason: 'env token present — Secret Manager not consulted' };
49
+ }
50
+ const explicit = secretNameEnv && String(secretNameEnv).trim();
51
+ const secretName = explicit ? String(secretNameEnv).trim() : (onGce ? DEFAULT_PUSH_TOKEN_SECRET : '');
52
+ if (!secretName) {
53
+ return { fetch: false, reason: 'no push token, no secret configured, and not on GCE' };
54
+ }
55
+ return {
56
+ fetch: true,
57
+ secretName,
58
+ reason: explicit ? 'explicit CODEYAM_GHCR_PUSH_TOKEN_SECRET' : 'default secret on GCE (SA creds)',
59
+ };
60
+ }
61
+
62
+ // The argv suffix (after the ${GCLOUD} chokepoint prefix) that reads the latest
63
+ // version of a secret. `latest` follows rotation: storing a new secret version
64
+ // and restarting the dashboard picks up the new token with no reprovision. A
65
+ // truthy `project` appends `--project <id>` so the read is pinned to the fleet
66
+ // project rather than gcloud's ambient default; omitted when blank. Keeping the
67
+ // project branch here (not at the call site) leaves the dashboard's I/O edge a
68
+ // branch-free passthrough.
69
+ function secretAccessArgv(secretName, project) {
70
+ const argv = ['secrets', 'versions', 'access', 'latest', '--secret', String(secretName || '')];
71
+ const proj = project && String(project).trim();
72
+ if (proj) argv.push('--project', proj);
73
+ return argv;
74
+ }
75
+
76
+ // Interpret one `gcloud secrets versions access` result into the outcome the
77
+ // dashboard applies: the resolved token (empty on any failure), the console
78
+ // method to log through ('log' | 'error'), and the operator-facing message. This
79
+ // is where the fail-soft branching lives — a gcloud error, an empty payload, and
80
+ // a real token each map to a distinct outcome — so the dashboard's I/O edge stays
81
+ // thin (exec → interpret → assign) and every branch is unit-tested here.
82
+ //
83
+ // hadError truthy when the exec callback received an Error
84
+ // stdout gcloud's stdout (the raw secret payload on success)
85
+ // stderr gcloud's stderr (or the Error message) — tail is surfaced on error
86
+ // secretName the secret that was read, for the message
87
+ // reason the secretFetchPlan reason, for the message
88
+ function interpretSecretAccess({ hadError, stdout, stderr, secretName, reason } = {}) {
89
+ const name = String(secretName || '');
90
+ const why = reason ? ` (${reason})` : '';
91
+ if (hadError) {
92
+ return {
93
+ token: '',
94
+ logLevel: 'error',
95
+ message: `push-token hydrate: could not read Secret Manager secret '${name}'${why}: ${String(stderr || '').slice(-200)}`,
96
+ };
97
+ }
98
+ const token = parseSecretPayload(stdout);
99
+ if (!token) {
100
+ return {
101
+ token: '',
102
+ logLevel: 'error',
103
+ message: `push-token hydrate: secret '${name}' resolved empty — self-feeding stays off`,
104
+ };
105
+ }
106
+ return {
107
+ token,
108
+ logLevel: 'log',
109
+ message: `push-token hydrate: loaded write:packages token from Secret Manager secret '${name}'${why} — self-feeding enabled`,
110
+ };
111
+ }
112
+
113
+ // Parse gcloud's stdout into the token. gcloud prints the raw secret payload with
114
+ // no added framing, so this is just a trim — which also defends against a secret
115
+ // stored with a trailing newline (the common `echo ... | gcloud secrets create`
116
+ // footgun). A GitHub PAT never contains surrounding whitespace, so trimming is
117
+ // safe. Non-string / null input → '' (fail-soft; the banner simply stays).
118
+ function parseSecretPayload(stdout) {
119
+ if (typeof stdout !== 'string') return '';
120
+ return stdout.trim();
121
+ }
122
+
123
+ module.exports = {
124
+ DEFAULT_PUSH_TOKEN_SECRET,
125
+ secretFetchPlan,
126
+ secretAccessArgv,
127
+ parseSecretPayload,
128
+ interpretSecretAccess,
129
+ };
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pure helpers for keeping the operator host's git clone healthy across the
5
+ * dashboard's high-frequency `git fetch origin <branch>` polling.
6
+ *
7
+ * The failure mode this exists to break: `pollPlans` fetches every ~12s in
8
+ * /opt/codeyam-editor. On a full or slow disk an interrupted fetch abandons a
9
+ * multi-hundred-MB `.git/objects/pack/tmp_pack_*` file. Those accumulate (~3GB
10
+ * observed), fill the disk, which makes the NEXT fetch fail sooner and leave
11
+ * another temp pack — a runaway that ends with a 100%-full disk and a wedged
12
+ * dashboard. Two mechanisms break the loop: prune the abandoned temp packs, and
13
+ * back off the fetch cadence while the remote keeps failing so a broken remote
14
+ * costs one attempt a minute instead of five a minute.
15
+ *
16
+ * Everything here is pure so it is unit-testable without a git repo or a clock;
17
+ * server.js owns the exec + the wall clock and passes `nowMs` in.
18
+ */
19
+
20
+ // Backoff bounds. The floor is one normal poll interval (no penalty for a
21
+ // single blip) and the ceiling keeps a long outage at a ~5 min retry rather
22
+ // than backing off to effectively never.
23
+ const DEFAULT_BASE_BACKOFF_MS = 30_000;
24
+ const DEFAULT_MAX_BACKOFF_MS = 300_000;
25
+ // Temp packs younger than this may belong to a fetch that is still running —
26
+ // deleting those would sabotage a healthy fetch, so only reap the abandoned.
27
+ const DEFAULT_STALE_PACK_MIN_AGE_MINUTES = 10;
28
+ // `git gc --auto` is cheap when there is nothing to do, but not free; hourly is
29
+ // often enough to retire loose objects the poll churn leaves behind.
30
+ const DEFAULT_GC_INTERVAL_MS = 3_600_000;
31
+
32
+ /** The zero state for a clone that has not yet been fetched from. */
33
+ function initialFetchState() {
34
+ return { consecutiveFailures: 0, suppressedUntilMs: 0, lastGcAtMs: 0 };
35
+ }
36
+
37
+ /**
38
+ * Exponential backoff for consecutive fetch failures: 0 while healthy, then
39
+ * base, 2x base, 4x base ... capped at maxMs.
40
+ * @param {number} consecutiveFailures How many fetches have failed in a row.
41
+ * @param {Object} [opts] `{ baseMs, maxMs }` overrides.
42
+ * @returns {number} Milliseconds to suppress fetching for. 0 means "fetch now".
43
+ */
44
+ function nextBackoffMs(consecutiveFailures, opts) {
45
+ const baseMs = (opts && opts.baseMs) || DEFAULT_BASE_BACKOFF_MS;
46
+ const maxMs = (opts && opts.maxMs) || DEFAULT_MAX_BACKOFF_MS;
47
+ if (!consecutiveFailures || consecutiveFailures < 1) return 0;
48
+ // 2^30 ms already exceeds any sane maxMs; clamp the exponent so a very long
49
+ // outage cannot overflow into Infinity before the cap is applied.
50
+ const exponent = Math.min(consecutiveFailures - 1, 30);
51
+ return Math.min(baseMs * Math.pow(2, exponent), maxMs);
52
+ }
53
+
54
+ /**
55
+ * Whether a fetch should be attempted now, or skipped because the clone is
56
+ * inside its post-failure backoff window.
57
+ * @param {Object} state A fetch state from initialFetchState().
58
+ * @param {number} nowMs Current epoch ms.
59
+ * @returns {boolean} True when the caller should attempt the fetch.
60
+ */
61
+ function shouldAttemptFetch(state, nowMs) {
62
+ if (!state) return true;
63
+ return nowMs >= (state.suppressedUntilMs || 0);
64
+ }
65
+
66
+ /**
67
+ * Advances the state after a failed fetch: one more consecutive failure, and a
68
+ * suppression window sized by the backoff curve. Returns a NEW state — callers
69
+ * reassign rather than mutate, so a stale reference can never half-apply.
70
+ * @param {Object} state The prior fetch state.
71
+ * @param {number} nowMs Current epoch ms.
72
+ * @param {Object} [opts] `{ baseMs, maxMs }` backoff overrides.
73
+ * @returns {Object} The next fetch state.
74
+ */
75
+ function recordFetchFailure(state, nowMs, opts) {
76
+ const prior = state || initialFetchState();
77
+ const consecutiveFailures = (prior.consecutiveFailures || 0) + 1;
78
+ return {
79
+ ...prior,
80
+ consecutiveFailures,
81
+ suppressedUntilMs: nowMs + nextBackoffMs(consecutiveFailures, opts)
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Clears the failure streak after a successful fetch, so recovery is immediate
87
+ * — one good fetch drops the clone straight back to the normal poll cadence
88
+ * rather than walking the backoff curve back down.
89
+ * @param {Object} state The prior fetch state.
90
+ * @returns {Object} The next fetch state.
91
+ */
92
+ function recordFetchSuccess(state) {
93
+ const prior = state || initialFetchState();
94
+ return { ...prior, consecutiveFailures: 0, suppressedUntilMs: 0 };
95
+ }
96
+
97
+ /**
98
+ * Whether `git gc --auto` is due on this clone.
99
+ * @param {Object} state The fetch state (carries lastGcAtMs).
100
+ * @param {number} nowMs Current epoch ms.
101
+ * @param {number} [intervalMs] How often gc should run.
102
+ * @returns {boolean} True when gc is due.
103
+ */
104
+ function shouldRunGc(state, nowMs, intervalMs) {
105
+ const every = intervalMs || DEFAULT_GC_INTERVAL_MS;
106
+ const last = (state && state.lastGcAtMs) || 0;
107
+ return nowMs - last >= every;
108
+ }
109
+
110
+ /** Stamps the gc clock. Returns a NEW state. */
111
+ function recordGcRun(state, nowMs) {
112
+ return { ...(state || initialFetchState()), lastGcAtMs: nowMs };
113
+ }
114
+
115
+ /**
116
+ * The shell command that reaps abandoned `tmp_pack_*` files from a clone's pack
117
+ * directory. Age-gated so a temp pack belonging to an in-flight fetch is never
118
+ * deleted out from under it, and `-maxdepth 1` keeps it from wandering into
119
+ * whatever else lives under .git.
120
+ * @param {string} repoRoot Absolute path to the clone's working tree.
121
+ * @param {number} [minAgeMinutes] Only delete temp packs older than this.
122
+ * @returns {string} A `find ... -delete` command safe to exec.
123
+ */
124
+ function stalePackPruneCommand(repoRoot, minAgeMinutes) {
125
+ const age = minAgeMinutes === undefined ? DEFAULT_STALE_PACK_MIN_AGE_MINUTES : minAgeMinutes;
126
+ return `find "${repoRoot}/.git/objects/pack" -maxdepth 1 -type f -name 'tmp_pack_*' -mmin +${age} -delete`;
127
+ }
128
+
129
+ module.exports = {
130
+ initialFetchState,
131
+ nextBackoffMs,
132
+ shouldAttemptFetch,
133
+ recordFetchFailure,
134
+ recordFetchSuccess,
135
+ shouldRunGc,
136
+ recordGcRun,
137
+ stalePackPruneCommand,
138
+ DEFAULT_BASE_BACKOFF_MS,
139
+ DEFAULT_MAX_BACKOFF_MS,
140
+ DEFAULT_STALE_PACK_MIN_AGE_MINUTES,
141
+ DEFAULT_GC_INTERVAL_MS
142
+ };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+ // Global host-CPU concurrency limiter for the Fleet Dashboard's gcloud-ssh work
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). No gcloud/SSH I/O
4
+ // itself — this module owns ONE guarantee: at most `concurrency` wrapped tasks
5
+ // run at any instant, the rest queue FIFO. The server funnels its per-VM
6
+ // gcloud-ssh gathers through a single shared instance so the operator host runs
7
+ // a bounded number of `gcloud compute ssh` (Python, ~100 MB RSS + real CPU
8
+ // apiece) processes REGARDLESS of fleet size.
9
+ //
10
+ // The bug this fixes: monitoring fanned out one `gcloud compute ssh … docker
11
+ // exec` per VM per gather with no host-wide cap. `pollEffects` sized its
12
+ // reachable-sweep and launcher-strand-sweep worker pools INDEPENDENTLY
13
+ // (EFFECT_GATHER_CONCURRENCY each), and neither shared a budget with the heavy
14
+ // mutation spawns — so 3 gather SSHs + 3 strand SSHs + N concurrent build SSHs
15
+ // could all run at once. On an 8-vCPU operator host that meant 40–60 load, the
16
+ // gcloud calls hanging in D-state and piling up faster than they completed, and
17
+ // the dashboard itself CPU-starved (local_http=000). A single shared limiter
18
+ // caps the instantaneous gcloud process count so the fan-out can never outrun
19
+ // the host again.
20
+ //
21
+ // Deliberately a general async semaphore (not gcloud-aware): the caller passes a
22
+ // thunk that does the I/O, so this stays pure and unit-testable with no network.
23
+
24
+ // Build a concurrency limiter. `run(fn)` enqueues the zero-arg thunk `fn` and
25
+ // returns a promise that settles with fn's resolved value (or rejects with its
26
+ // error). At most `concurrency` thunks are in flight at once; the rest wait in a
27
+ // FIFO queue and start as slots free. A thunk's slot is released whether it
28
+ // resolves OR rejects, so one failing gcloud call can never leak a permanent
29
+ // slot and starve the queue. `concurrency` is floored to 1 (a 0 / negative /
30
+ // NaN value would otherwise wedge every task in the queue forever).
31
+ function createHostOpLimiter(opts) {
32
+ const o = opts || {};
33
+ const raw = Number(o.concurrency);
34
+ const concurrency = Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1;
35
+ let active = 0;
36
+ const queue = [];
37
+
38
+ const pump = () => {
39
+ while (active < concurrency && queue.length > 0) {
40
+ const { fn, resolve, reject } = queue.shift();
41
+ active += 1;
42
+ // `Promise.resolve().then(fn)` normalizes a thunk that returns a value, a
43
+ // promise, OR throws synchronously into one promise chain — so a synchronous
44
+ // throw releases the slot via the reject path instead of escaping `pump`.
45
+ Promise.resolve().then(fn).then(
46
+ (val) => { active -= 1; pump(); resolve(val); },
47
+ (err) => { active -= 1; pump(); reject(err); },
48
+ );
49
+ }
50
+ };
51
+
52
+ const run = (fn) => new Promise((resolve, reject) => {
53
+ queue.push({ fn, resolve, reject });
54
+ pump();
55
+ });
56
+
57
+ return {
58
+ run,
59
+ // Introspection for tests + operator diagnostics; never load-bearing.
60
+ active: () => active,
61
+ queued: () => queue.length,
62
+ concurrency,
63
+ };
64
+ }
65
+
66
+ module.exports = {
67
+ createHostOpLimiter,
68
+ };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ // Pure operator-host placement resolution for the fleet dashboard
3
+ // (vm-dashboard-on-gce).
4
+ //
5
+ // The dashboard runs in two homes: the operator's laptop (legacy fallback) and a
6
+ // dedicated GCE "operator host" (the primary path). Three things differ by home —
7
+ // how gcloud authenticates, what address the dashboard binds, and how a branch
8
+ // pin is read from the env — and each used to be an inline ternary at the top of
9
+ // server.js's 200KB monolith, untestable. This module is the single home for
10
+ // those decisions, so server.js delegates the RULE and keeps only the I/O, the
11
+ // same split npm/fleet-branch.js (resolveFleetBranch) follows.
12
+ //
13
+ // Pure logic only — no I/O, no env reads beyond the explicit `env` argument a
14
+ // caller passes in — so it stays unit-testable in isolation (npm/fleet-host.test.js).
15
+
16
+ // Env var the systemd unit sets on the GCE operator host. Its presence (truthy)
17
+ // is what flips the dashboard from the laptop defaults to the on-GCE defaults.
18
+ const FLEET_ON_GCE_ENV = 'FLEET_ON_GCE';
19
+
20
+ /** True when the dashboard is running on the GCE operator host, read from the
21
+ * `FLEET_ON_GCE` env. Accepts the conventional truthy spellings `1` / `true` /
22
+ * `yes` (case-insensitive, trimmed); everything else — unset, empty, `0`,
23
+ * `false`, garbage — is false, so a laptop run (which never sets it) stays on
24
+ * the loopback + named-configuration defaults. */
25
+ function onGce(env = {}) {
26
+ return /^(1|true|yes)$/i.test(String(env[FLEET_ON_GCE_ENV] || '').trim());
27
+ }
28
+
29
+ /** Resolve the gcloud invocation prefix the dashboard's `${GCLOUD}` chokepoint
30
+ * uses. On the GCE operator host the attached SA authenticates from instance
31
+ * metadata, so a plain `gcloud` is correct (no key file, no named config). Off
32
+ * GCE (laptop) it pins the non-expiring SA configuration with
33
+ * `--configuration=<config>` so background loops survive OAuth-token expiry.
34
+ * Returns `'gcloud'` on GCE; throws if a non-GCE call omits the config name,
35
+ * since silently emitting a bare `gcloud` there would run on the operator's
36
+ * personal account — the exact regression the chokepoint exists to prevent. */
37
+ function gcloudCommand({ onGce: gce, config } = {}) {
38
+ if (gce) return 'gcloud';
39
+ const c = typeof config === 'string' ? config.trim() : '';
40
+ if (!c) {
41
+ throw new Error('gcloudCommand requires a config name when not on GCE');
42
+ }
43
+ return `gcloud --configuration=${c}`;
44
+ }
45
+
46
+ /** Resolve the address the dashboard binds. An explicit `bind` (the DASH_BIND
47
+ * override) always wins. Otherwise the host serves the LB's zonal NEG so it must
48
+ * bind the instance's internal NIC (`0.0.0.0`, firewalled to the LB + IAP
49
+ * ranges); a laptop run binds loopback only (`127.0.0.1`). A blank/whitespace
50
+ * override counts as absent so a set-but-empty env doesn't bind to an empty
51
+ * string. */
52
+ function dashboardBind({ onGce: gce, bind } = {}) {
53
+ const explicit = typeof bind === 'string' ? bind.trim() : '';
54
+ if (explicit) return explicit;
55
+ return gce ? '0.0.0.0' : '127.0.0.1';
56
+ }
57
+
58
+ /** Resolve the explicit branch pin from the env, or null when unpinned. The
59
+ * operator-host name is `FLEET_BRANCH`; `BRANCH` is the legacy laptop alias and
60
+ * is honored as a fallback. A blank/whitespace value is "no pin" (so the
61
+ * dashboard tracks live git HEAD via resolveFleetBranch). Returns the trimmed
62
+ * pin or null. */
63
+ function branchPin(env = {}) {
64
+ const pin = String(env.FLEET_BRANCH || env.BRANCH || '').trim();
65
+ return pin === '' ? null : pin;
66
+ }
67
+
68
+ module.exports = {
69
+ FLEET_ON_GCE_ENV,
70
+ onGce,
71
+ gcloudCommand,
72
+ dashboardBind,
73
+ branchPin,
74
+ };