@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.
- package/npm/cli.js +5 -1
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +3166 -150
- package/npm/codesign-plan.js +68 -0
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +252 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +213 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +109 -5
- package/npm/fleet-cleanup.js +232 -16
- package/npm/fleet-destroy-safety.js +272 -0
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-effect-status.js +33 -32
- 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-host.js +74 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +155 -0
- package/npm/fleet-memory.js +245 -9
- package/npm/fleet-plans.js +233 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +241 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +394 -0
- package/npm/fleet-queue.js +95 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +330 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-running-plan.js +85 -0
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +112 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +181 -0
- package/npm/fleet-stuck-status.js +117 -0
- package/npm/fleet-vm-access.js +404 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +237 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +226 -0
- package/npm/ingress-invoke.js +770 -0
- package/npm/launch-config.js +386 -9
- package/npm/scenario-check.js +880 -37
- package/npm/scenario-handlers.js +154 -30
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +144 -21
- package/npm/scenario-playwright.js +649 -38
- package/npm/script-drift.js +152 -0
- package/npm/utils.js +365 -52
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
- 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-S3Qk1Y_d.js +1 -0
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -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/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
- 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
|
+
};
|
package/npm/fleet-job-badge.js
CHANGED
|
@@ -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-
|
|
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, '&')
|
|
45
|
+
.replace(/</g, '<')
|
|
46
|
+
.replace(/>/g, '>')
|
|
47
|
+
.replace(/"/g, '"')
|
|
48
|
+
.replace(/'/g, ''');
|
|
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 };
|