@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.
- package/npm/cli.js +5 -1
- package/npm/cloud.js +2079 -242
- package/npm/codesign-plan.js +68 -0
- package/npm/compose-conflict.js +59 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +361 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +143 -5
- package/npm/fleet-build-data.js +60 -5
- package/npm/fleet-cleanup.js +243 -9
- package/npm/fleet-destroy-safety.js +230 -13
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +28 -12
- package/npm/fleet-live-status.js +40 -8
- package/npm/fleet-memory.js +289 -9
- package/npm/fleet-plans.js +98 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +87 -11
- package/npm/fleet-probe-transport.js +155 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +234 -0
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reset.js +32 -0
- package/npm/fleet-revision.js +104 -0
- package/npm/fleet-running-plan.js +115 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +19 -12
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +39 -8
- package/npm/fleet-stuck-status.js +13 -7
- package/npm/fleet-vm-access.js +170 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +136 -3
- package/npm/google-ingress.js +15 -0
- package/npm/ingress-invoke.js +478 -0
- package/npm/launch-config.js +302 -10
- package/npm/scenario-check.js +318 -12
- package/npm/scenario-handlers.js +85 -27
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-mocks.js +129 -26
- package/npm/scenario-playwright.js +330 -22
- package/npm/script-drift.js +72 -1
- package/npm/utils.js +37 -3
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
- package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/index-CjxWhkSy.css +1 -0
- package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
- package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
- package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
- package/ui/dist/assets/index-CIXZkwfB.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
|
@@ -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,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
|
+
};
|
package/npm/fleet-job-badge.js
CHANGED
|
@@ -15,21 +15,37 @@
|
|
|
15
15
|
// Should the VM's last terminal-error job badge be reconciled against live
|
|
16
16
|
// health — i.e. presented as "recovered" rather than a live red error?
|
|
17
17
|
//
|
|
18
|
-
// job
|
|
19
|
-
// reachable
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
18
|
+
// job the carried job record ({ state, action, ... }) or null
|
|
19
|
+
// reachable the VM's editor answered this poll over the LB (true = healthy)
|
|
20
|
+
// inVmHealthy the in-VM editor answered /api/health from INSIDE the container
|
|
21
|
+
// (the STRAND_PROBE `HEALTH=1` witness gathered over docker-exec)
|
|
22
|
+
// drifted a project-dir-drift signal is present (reachable-but-unwired /
|
|
23
|
+
// half-provisioned add — genuinely needs-recovery, owned by the
|
|
24
|
+
// companion reconcile-inflight-jobs plan; never falsely cleared here)
|
|
25
|
+
// offBranch the VM's live client branch differs from its target branch
|
|
26
|
+
// degraded the job completed but a wiring sub-step is flagged degraded
|
|
25
27
|
//
|
|
26
28
|
// Returns true only when the job is a terminal error AND the VM is demonstrably
|
|
27
|
-
// fine (
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
29
|
+
// fine (healthy, not drifted, on-branch, not degraded). A drifted / off-branch /
|
|
30
|
+
// unwitnessed VM keeps its error surfaced — a real problem must stay visible.
|
|
31
|
+
//
|
|
32
|
+
// HEALTH IS WITNESSED TWO WAYS, and either suffices. `reachable` alone made the
|
|
33
|
+
// self-heal unable to fire in exactly the case that needs it most: on the GCE
|
|
34
|
+
// operator host `reachable` is derived from the LB probe, which needs the very
|
|
35
|
+
// ingress the add is still wiring. That is a circular dependency — the badge
|
|
36
|
+
// needs reachable, reachable needs ingress, and ingress is what we are waiting
|
|
37
|
+
// for — so a still-wiring VM sat at unreachableStreak 9 with a red add-error it
|
|
38
|
+
// could not clear by construction (VM-4, 2026-07-31). The dashboard already held
|
|
39
|
+
// the disconfirming evidence: gatherLauncherStrand runs STRAND_PROBE over
|
|
40
|
+
// docker-exec for exactly these unreachable-with-streak VMs and reads HEALTH=1
|
|
41
|
+
// from the in-VM /api/health.
|
|
42
|
+
//
|
|
43
|
+
// This widens ONE input; it does not relax the verdict. Every other guard is
|
|
44
|
+
// untouched, and the "absence of evidence never auto-acts" posture holds — an
|
|
45
|
+
// unprobed VM has neither witness and stays unreconciled.
|
|
46
|
+
function shouldReconcileFailedBadge({ job, reachable, inVmHealthy, drifted, offBranch, degraded } = {}) {
|
|
31
47
|
if (!job || job.state !== 'error') return false;
|
|
32
|
-
if (!reachable) return false;
|
|
48
|
+
if (!reachable && !inVmHealthy) return false;
|
|
33
49
|
if (drifted) return false;
|
|
34
50
|
if (offBranch) return false;
|
|
35
51
|
if (degraded) return false;
|
package/npm/fleet-live-status.js
CHANGED
|
@@ -42,13 +42,13 @@ function liveSessionStatus(v, brokerStatus) {
|
|
|
42
42
|
// Unreachable: we polled nothing reliable. Defer to the caller's
|
|
43
43
|
// asleep/unreachable mapping — a stale broker signal must not mask it.
|
|
44
44
|
if (!v.reachable) return null;
|
|
45
|
-
if (v.hasSession && v.awaitingUserInput) return
|
|
45
|
+
if (v.hasSession && v.awaitingUserInput) return awaitingVerdict(v);
|
|
46
46
|
if (v.hasSession) return 'building';
|
|
47
47
|
// No editor-owned session, but a live agent may still be working in the PTY
|
|
48
48
|
// broker (decoupled lifetime — see docs/process-model.md). The broker
|
|
49
49
|
// tri-state is already debounced upstream; agent-live ⇒ working, not idle.
|
|
50
50
|
if (brokerStatus === 'agent-live') {
|
|
51
|
-
if (v.awaitingUserInput) return
|
|
51
|
+
if (v.awaitingUserInput) return awaitingVerdict(v);
|
|
52
52
|
// The agent is parked alive in the broker's build slot, but its workflow
|
|
53
53
|
// reached the terminal step (editor reports needsCleanup) — finished and
|
|
54
54
|
// idle, not building. A mid-feature post-restart agent is NOT terminal and
|
|
@@ -59,6 +59,26 @@ function liveSessionStatus(v, brokerStatus) {
|
|
|
59
59
|
return 'idle';
|
|
60
60
|
}
|
|
61
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
|
+
|
|
62
82
|
// Distinguish a freshly-rostered VM whose editor is still STARTING UP — a normal
|
|
63
83
|
// cold boot that hasn't bound :14199 yet, or (the VM-2 case) the ~10–15-min
|
|
64
84
|
// self-host `rebuild-self --force-in-container` compile that recompiles the
|
|
@@ -109,15 +129,27 @@ function classifyStartupProbe({ stdout, err } = {}) {
|
|
|
109
129
|
|
|
110
130
|
// Pure gate deciding whether pollStartupProbes should fire the (costly) IAP-SSH
|
|
111
131
|
// build probe for a given VM this tick. We only probe a VM that is PLAUSIBLY
|
|
112
|
-
// still starting — present, unreachable, GCP still lists it RUNNING,
|
|
113
|
-
//
|
|
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
|
|
114
134
|
// building on a prior tick (so a ~15-min self-host compile keeps being probed
|
|
115
135
|
// past the grace window with no fixed cap). A healthy/reachable VM, an
|
|
116
136
|
// asleep/vanished one, or a long-idle one that simply went unreachable is never
|
|
117
|
-
// probed.
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
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);
|
|
121
153
|
}
|
|
122
154
|
|
|
123
155
|
module.exports = { liveSessionStatus, classifyStartingVsUnreachable, classifyStartupProbe, shouldProbeStartup };
|