@codeyam-editor/codeyam-editor 0.1.5 → 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.js +1884 -210
- package/npm/codesign-plan.js +68 -0
- 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 +143 -5
- package/npm/fleet-build-data.js +60 -5
- package/npm/fleet-cleanup.js +114 -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-live-status.js +40 -8
- package/npm/fleet-memory.js +245 -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-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-running-plan.js +85 -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 +182 -6
- package/npm/scenario-check.js +282 -10
- 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 +286 -12
- 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-DgM0pTAx.js} +1 -1
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -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-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-CBLTGC4-.js → ScenarioDataPanelFullPage-BLqRCxQX.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-QsOiOG9x.js → index-DCHqRrwO.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-B7rI0u3O.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,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The three-state reachability contract for the Fleet Dashboard.
|
|
4
|
+
//
|
|
5
|
+
// A VM's reachability has THREE states, not two:
|
|
6
|
+
// true — a probe reached the VM's editor
|
|
7
|
+
// false — a probe RAN and FAILED (a confirmed outage)
|
|
8
|
+
// null — no verdict at all (never polled, or held inconclusive)
|
|
9
|
+
//
|
|
10
|
+
// `npm/fleet-auth-health.js::classifyReachability` has always modelled this
|
|
11
|
+
// correctly, deliberately returning `{ reachable: null }` for the inconclusive
|
|
12
|
+
// case. Every consumer between it and the browser then coerced that care away
|
|
13
|
+
// with `!!v.reachable` and `{ n, reachable: false }` placeholders, so the null
|
|
14
|
+
// never survived to anyone who could act on it.
|
|
15
|
+
//
|
|
16
|
+
// On 2026-07-30 that collapse served all 13 rostered VMs as `reachable: false`
|
|
17
|
+
// with NO `reachableSource`, while each card's own `backendHealth` read
|
|
18
|
+
// `healthy` and operator auth read clean — a self-contradicting fleet-wide
|
|
19
|
+
// "outage" that had never been probed. It blocked both sanctioned refresh paths
|
|
20
|
+
// (`refresh-client-vm-editor.sh` exits 1, `fleet-advance-to.sh` classifies
|
|
21
|
+
// SKIP-unreachable) and, in a storm, is the signal that has driven reconcile
|
|
22
|
+
// into hard-rebooting healthy VMs.
|
|
23
|
+
//
|
|
24
|
+
// This module is the single home for that vocabulary so the distinction is
|
|
25
|
+
// stated once and grepped, rather than re-derived inline at each consumer. Pure:
|
|
26
|
+
// no I/O, no clock, no state.
|
|
27
|
+
|
|
28
|
+
// The `reachableSource` value for a record carrying no reachability verdict.
|
|
29
|
+
// `reachableSource` otherwise names the signal that DECIDED a verdict ('lb' |
|
|
30
|
+
// 'backend' | 'local' | 'iap', from classifyReachability), which is what makes a
|
|
31
|
+
// card self-diagnosing — a `reachable: false` with a missing source is exactly
|
|
32
|
+
// the fingerprint that identified the 2026-07-30 incident as manufactured by the
|
|
33
|
+
// serializer rather than reported by a probe. 'unpolled' extends that same
|
|
34
|
+
// self-diagnosis to the no-verdict case instead of leaving a silent gap.
|
|
35
|
+
const REACHABILITY_UNPOLLED = 'unpolled';
|
|
36
|
+
|
|
37
|
+
// Does this VM have a DEFINITE reachability verdict — did anyone actually ask?
|
|
38
|
+
// Only a real boolean counts; null/undefined (and any malformed value) read as
|
|
39
|
+
// "no verdict". Consumers use this to tell "never asked" from "asked and got an
|
|
40
|
+
// answer", which is the distinction the whole three-state contract exists for.
|
|
41
|
+
function hasReachabilityVerdict(reachable) {
|
|
42
|
+
return reachable === true || reachable === false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Is this VM CONFIRMED unreachable — probed, and the probe failed?
|
|
46
|
+
//
|
|
47
|
+
// Safety-critical. Reboot, reconcile, and destroy paths must gate on THIS, never
|
|
48
|
+
// on a falsy check: `!reachable` is also true for an unknown, so a `!reachable`
|
|
49
|
+
// guard lets a VM nobody has polled walk into a path that ends in a hard reset.
|
|
50
|
+
// An unknown is not evidence, and evidence is the bar for a destructive action.
|
|
51
|
+
function isConfirmedUnreachable(reachable) {
|
|
52
|
+
return reachable === false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Normalize a raw poll record's reachability into the pair the wire envelope
|
|
56
|
+
// carries: `{ reachable, reachableSource }`.
|
|
57
|
+
//
|
|
58
|
+
// This is the seam where the bug lived. `reachable` is preserved as a genuine
|
|
59
|
+
// tri-state — an ABSENT verdict stays absent rather than being coerced to a
|
|
60
|
+
// definite false — and `reachableSource` falls back to 'unpolled' so a card with
|
|
61
|
+
// no verdict says so out loud instead of looking like an unexplained outage.
|
|
62
|
+
//
|
|
63
|
+
// `v` is the merged poll state (`state.vms[n]`), or a placeholder standing in
|
|
64
|
+
// for a VM with no poll state at all.
|
|
65
|
+
function normalizeReachability(v) {
|
|
66
|
+
const raw = v ? v.reachable : null;
|
|
67
|
+
const reachable = hasReachabilityVerdict(raw) ? raw : null;
|
|
68
|
+
return {
|
|
69
|
+
reachable,
|
|
70
|
+
reachableSource: (v && v.reachableSource)
|
|
71
|
+
|| (reachable === null ? REACHABILITY_UNPOLLED : null),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
REACHABILITY_UNPOLLED,
|
|
77
|
+
hasReachabilityVerdict,
|
|
78
|
+
isConfirmedUnreachable,
|
|
79
|
+
normalizeReachability,
|
|
80
|
+
};
|
package/npm/fleet-reset.js
CHANGED
|
@@ -494,6 +494,36 @@ function needsReconfigureFallback(log) {
|
|
|
494
494
|
return typeof log === 'string' && log.includes('CY_NEEDS_RECONFIGURE');
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
+
// The reset-family actions whose in-container recipe REBUILDS the codeyam-editor
|
|
498
|
+
// binary before restart when the target is a self-host VM. Both resetClientScript
|
|
499
|
+
// and resetEditorStateScript (default rebuildOnSelfHost=true) run selfHostRebuildStep,
|
|
500
|
+
// so on a self-host VM either one triggers a full cargo compile (~10-14 min). The
|
|
501
|
+
// composite reset-all budgets that rebuild separately; update-client (a non-destructive
|
|
502
|
+
// ff-merge with NO rebuild/restart) is deliberately absent.
|
|
503
|
+
const REBUILDS_ON_SELF_HOST = new Set(['reset-client', 'reset-editor']);
|
|
504
|
+
|
|
505
|
+
// Default per-action SHORT budgets (ms) — the historical non-self-host timeout for
|
|
506
|
+
// each reset action, kept so a genuine client-VM hang still surfaces promptly.
|
|
507
|
+
const RESET_SHORT_TIMEOUT_MS = { 'reset-client': 300000, 'reset-editor': 600000 };
|
|
508
|
+
// Default self-host REBUILD budget (ms): the same 27.5-min allowance reset-all
|
|
509
|
+
// already uses for a self-host binary rebuild. Overridable via the rebuildMs arg
|
|
510
|
+
// (server.js resolves it from RESET_REBUILD_TIMEOUT_MS).
|
|
511
|
+
const RESET_REBUILD_TIMEOUT_MS = 1650000;
|
|
512
|
+
|
|
513
|
+
// Select the startJob timeout (ms) for a reset-family action, accounting for the
|
|
514
|
+
// self-host rebuild. On a self-host VM a rebuild-bearing reset (see
|
|
515
|
+
// REBUILDS_ON_SELF_HOST) advances the editor's OWN source and MUST rebuild the
|
|
516
|
+
// binary before restart, a ~10-14 min cargo compile the old flat 5-min budget
|
|
517
|
+
// killed mid-compile — surfacing a false `error` badge on a reset that actually
|
|
518
|
+
// succeeded. A non-self-host client reset genuinely does not rebuild and keeps its
|
|
519
|
+
// short budget. Pure: budgets default to the constants above but are injectable for
|
|
520
|
+
// env-tunability + tests. Unknown/non-rebuild action or non-self-host → short budget.
|
|
521
|
+
function resetJobTimeoutMs({ action, selfHost, shortMs, rebuildMs } = {}) {
|
|
522
|
+
const short = Number.isFinite(shortMs) ? shortMs : RESET_SHORT_TIMEOUT_MS[action];
|
|
523
|
+
const rebuild = Number.isFinite(rebuildMs) ? rebuildMs : RESET_REBUILD_TIMEOUT_MS;
|
|
524
|
+
return selfHost && REBUILDS_ON_SELF_HOST.has(action) ? rebuild : short;
|
|
525
|
+
}
|
|
526
|
+
|
|
497
527
|
module.exports = {
|
|
498
528
|
codeyamDeepCleanSteps,
|
|
499
529
|
codeyamBrokerEnsureStep,
|
|
@@ -504,4 +534,6 @@ module.exports = {
|
|
|
504
534
|
resetEditorStateScript,
|
|
505
535
|
endSessionScript,
|
|
506
536
|
verifyIdleOrEndSessionStep,
|
|
537
|
+
resetJobTimeoutMs,
|
|
538
|
+
REBUILDS_ON_SELF_HOST,
|
|
507
539
|
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Resolve the running-plan fields a fleet card shows — feature, step, mode,
|
|
4
|
+
// selectedPlan, holderSessionId — from the two independent per-VM probes plus
|
|
5
|
+
// the last-known-good state.
|
|
6
|
+
//
|
|
7
|
+
// Why this exists: the card used to hang these fields off `/api/session-info`
|
|
8
|
+
// alone (spread as `...info` in server.js). But a VM whose editor server
|
|
9
|
+
// restarted while the PTY broker kept the agent alive reports
|
|
10
|
+
// `hasSession:false` / `sessionId:null` and session-info then OMITS
|
|
11
|
+
// feature/step/mode/selectedPlan — so an actively-building VM showed a BLANK
|
|
12
|
+
// card. `GET /api/step` reads `editor-step.json` directly, independent of
|
|
13
|
+
// session registration, so it stays populated across that restart. This helper
|
|
14
|
+
// makes `/api/step` the preferred source and falls back to session-info.
|
|
15
|
+
//
|
|
16
|
+
// Stickiness — the subtle part. The last-known `prev` is carried ONLY to bridge
|
|
17
|
+
// a genuine probe failure (the editor answered NOTHING this poll), so a single
|
|
18
|
+
// timed-out cycle can't blank a plan the card is already showing. It is NOT a
|
|
19
|
+
// blanket fallback: when the editor DID answer, the fresh read is authoritative
|
|
20
|
+
// for the plan the VM is executing NOW. Carrying prev unconditionally pinned a
|
|
21
|
+
// COMPLETED plan's feature onto the next one — feature-complete deletes
|
|
22
|
+
// editor-step.json (reset_step_state), and a plan's `feature` is null until
|
|
23
|
+
// step 4, so a stale prev would win over the correct null and the card kept
|
|
24
|
+
// showing plan A while the VM built plan B. So prev is carried only within the
|
|
25
|
+
// SAME plan (matching selectedPlan); a plan change (or going idle) drops it.
|
|
26
|
+
//
|
|
27
|
+
// Precedence per field, when the editor answered: `/api/step` (session-
|
|
28
|
+
// independent) > `/api/session-info` > same-plan `prev`. On a full probe
|
|
29
|
+
// failure: `prev` verbatim. Pure; no I/O — unit-tested in
|
|
30
|
+
// fleet-running-plan.test.js.
|
|
31
|
+
//
|
|
32
|
+
// @param {Object} params
|
|
33
|
+
// @param {?Object} params.step Parsed `/api/step` body (EditorStepState) or
|
|
34
|
+
// null when the VM has no `editor-step.json` (idle) / the probe failed.
|
|
35
|
+
// @param {?Object} params.info Parsed `/api/session-info` body (carries
|
|
36
|
+
// `reachable`), or null on a failed/timed-out probe.
|
|
37
|
+
// @param {?Object} params.prev The previous `state.vms[n]` (carries the
|
|
38
|
+
// last-known feature/step/mode/selectedPlan/holderSessionId). null/undefined
|
|
39
|
+
// on the first poll.
|
|
40
|
+
// @returns {{feature: ?string, step: ?number, mode: ?string,
|
|
41
|
+
// selectedPlan: ?string, holderSessionId: ?string}}
|
|
42
|
+
function resolveRunningPlan({ step, info, prev } = {}) {
|
|
43
|
+
const s = step || {};
|
|
44
|
+
const i = info || {};
|
|
45
|
+
const p = prev || {};
|
|
46
|
+
|
|
47
|
+
// Did the editor answer at all this poll? `/api/step` returning a body proves
|
|
48
|
+
// it; so does a reachable `/api/session-info`. If NEITHER answered, this is a
|
|
49
|
+
// transient outage — hold the last-known-good plan verbatim rather than blank
|
|
50
|
+
// the card for one cycle.
|
|
51
|
+
const editorAnswered = step != null || !!i.reachable;
|
|
52
|
+
if (!editorAnswered) {
|
|
53
|
+
return {
|
|
54
|
+
feature: p.feature || null,
|
|
55
|
+
step: p.step != null ? p.step : null,
|
|
56
|
+
mode: p.mode || null,
|
|
57
|
+
selectedPlan: p.selectedPlan || null,
|
|
58
|
+
holderSessionId: p.holderSessionId || null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Editor answered: the fresh read is authoritative for the current plan.
|
|
63
|
+
const freshSelectedPlan = s.selectedPlan || i.selectedPlan || null;
|
|
64
|
+
// Carry prev ONLY within the same plan — a blip-fallback for a momentarily
|
|
65
|
+
// missing field (e.g. /api/step timed out but session-info answered). Across
|
|
66
|
+
// a plan change or an idle poll, prev is stale and MUST be dropped.
|
|
67
|
+
const samePlan = freshSelectedPlan != null && freshSelectedPlan === (p.selectedPlan || null);
|
|
68
|
+
const carry = samePlan ? p : {};
|
|
69
|
+
|
|
70
|
+
const stepNum =
|
|
71
|
+
s.step != null ? s.step : i.step != null ? i.step : carry.step != null ? carry.step : null;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
feature: s.feature || i.feature || carry.feature || null,
|
|
75
|
+
step: stepNum,
|
|
76
|
+
mode: s.mode || i.mode || carry.mode || null,
|
|
77
|
+
selectedPlan: freshSelectedPlan || carry.selectedPlan || null,
|
|
78
|
+
// session-info never carries holderSessionId — it comes from /api/step
|
|
79
|
+
// (editor-step.json) or the same-plan carry. This is what lets a claim map
|
|
80
|
+
// to the executing VM after an editor restart drops the live sessionId.
|
|
81
|
+
holderSessionId: s.holderSessionId || carry.holderSessionId || null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { resolveRunningPlan };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Per-VM editor session-token cache for the fleet dashboard's gated probes.
|
|
3
|
+
//
|
|
4
|
+
// The post-501766261 editor runs FAIL-CLOSED: every control-API request must
|
|
5
|
+
// carry that VM's own editor session token in `Authorization`, or the editor
|
|
6
|
+
// answers 401. The dashboard reads each token once over the existing SSH/
|
|
7
|
+
// docker-exec path (server.js supplies the impure `fetchToken`) and caches it
|
|
8
|
+
// per VM — the token is stable for the container's lifetime, so re-reading it
|
|
9
|
+
// on every poll would be a wasted gcloud-ssh fork per VM per cycle.
|
|
10
|
+
//
|
|
11
|
+
// Pure logic only — no I/O, no env reads, no clock — so it stays unit-testable
|
|
12
|
+
// in isolation (npm/fleet-session-token-cache.test.js), exactly like the
|
|
13
|
+
// createIapTokenProvider it mirrors. The caller injects `fetchToken(n)`.
|
|
14
|
+
|
|
15
|
+
/** Build a per-VM session-token cache.
|
|
16
|
+
*
|
|
17
|
+
* `fetchToken(n)` resolves to the VM's session token string (or null/'' when
|
|
18
|
+
* it cannot be read). Returns `{ getToken, invalidate }`:
|
|
19
|
+
*
|
|
20
|
+
* getToken(n) — resolves the cached token, fetching once on a cold entry.
|
|
21
|
+
* Concurrent cold calls for the same VM share ONE in-flight
|
|
22
|
+
* fetch (the poll cycle probes several endpoints per VM at
|
|
23
|
+
* once). A null/empty/failed fetch resolves to null and is
|
|
24
|
+
* NOT cached, so the next poll retries rather than sticking a
|
|
25
|
+
* VM on a missing token.
|
|
26
|
+
* invalidate(n) — drop a VM's cached token so the next getToken re-fetches.
|
|
27
|
+
* Called on a fail-closed 401, which is how a container
|
|
28
|
+
* recreate / token rotation self-heals.
|
|
29
|
+
*/
|
|
30
|
+
function createSessionTokenCache({ fetchToken } = {}) {
|
|
31
|
+
if (typeof fetchToken !== 'function') {
|
|
32
|
+
throw new Error('createSessionTokenCache requires a fetchToken(n) function');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cache = new Map(); // n -> non-empty token string
|
|
36
|
+
const inFlight = new Map(); // n -> Promise<string|null>
|
|
37
|
+
|
|
38
|
+
async function getToken(n) {
|
|
39
|
+
if (cache.has(n)) return cache.get(n);
|
|
40
|
+
if (inFlight.has(n)) return inFlight.get(n);
|
|
41
|
+
|
|
42
|
+
const p = Promise.resolve()
|
|
43
|
+
.then(() => fetchToken(n))
|
|
44
|
+
.then((tok) => {
|
|
45
|
+
const value = tok == null ? '' : String(tok).trim();
|
|
46
|
+
// Only a real token is cached; a null/empty/errored read degrades to
|
|
47
|
+
// today's behavior (probe with no session token) and retries next poll.
|
|
48
|
+
if (value !== '') cache.set(n, value);
|
|
49
|
+
return value === '' ? null : value;
|
|
50
|
+
})
|
|
51
|
+
.catch(() => null)
|
|
52
|
+
.finally(() => { inFlight.delete(n); });
|
|
53
|
+
|
|
54
|
+
inFlight.set(n, p);
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function invalidate(n) {
|
|
59
|
+
cache.delete(n);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { getToken, invalidate };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { createSessionTokenCache };
|
package/npm/fleet-setup-steps.js
CHANGED
|
@@ -39,15 +39,6 @@ function parseDriftReason(reason) {
|
|
|
39
39
|
return m ? { expected: m[1].trim(), running: m[2].trim() } : { expected: null, running: null };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
// The re-wire command the ingress-unwired reason embeds after "Re-wire: " (see
|
|
43
|
-
// fleet-public-url.js::ingressUnwiredReason). Surfaced as the setup step's
|
|
44
|
-
// inline action so the calm checklist row carries the exact command, not just a
|
|
45
|
-
// label. null when the reason carries no command tail.
|
|
46
|
-
function parseIngressAction(reason) {
|
|
47
|
-
const m = /Re-wire:\s*(.+)$/.exec(reason);
|
|
48
|
-
return m ? m[1].trim() : null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
42
|
// Partition degradedReasons into { setupSteps, attentionReasons }.
|
|
52
43
|
//
|
|
53
44
|
// setupSteps ordered [{ code, label, detail, action? }] — expected
|
|
@@ -66,11 +57,14 @@ function classifyReasons(degradedReasons = []) {
|
|
|
66
57
|
if (reason.startsWith('claude-auth-not-seeded')) {
|
|
67
58
|
setupSteps.push({ code: 'auth', label: 'Claude login', detail: reason });
|
|
68
59
|
} else if (reason.startsWith('ingress-unwired')) {
|
|
60
|
+
// System-handled: the dashboard reconcile wires the public URL
|
|
61
|
+
// automatically (incl. down+up self-heal for reused-slug orphans). NO
|
|
62
|
+
// operator `action`/command — render as in-progress, never a to-do.
|
|
69
63
|
setupSteps.push({
|
|
70
64
|
code: 'ingress',
|
|
71
65
|
label: 'Public URL wiring',
|
|
72
|
-
detail:
|
|
73
|
-
|
|
66
|
+
detail: 'Wiring the public URL — this happens automatically.',
|
|
67
|
+
auto: true,
|
|
74
68
|
});
|
|
75
69
|
} else if (reason.startsWith('project-dir-drift')) {
|
|
76
70
|
const { expected, running } = parseDriftReason(reason);
|
|
@@ -102,4 +96,17 @@ function hasOnlySetup(degradedReasons = []) {
|
|
|
102
96
|
return setupSteps.length > 0 && attentionReasons.length === 0;
|
|
103
97
|
}
|
|
104
98
|
|
|
105
|
-
|
|
99
|
+
// True when a VM's reasons are ALL expected setup steps AND every one of them is
|
|
100
|
+
// the SAME given code — e.g. `onlySetupCode(reasons, 'auth')` is the gate for the
|
|
101
|
+
// calm, first-class `needs-login` status: the ONLY outstanding item is Claude
|
|
102
|
+
// login, nothing else. A MIXED set (auth + ingress) is not `onlySetupCode('auth')`
|
|
103
|
+
// — it reads the generic `finishing-setup` instead. Builds on the same classifier
|
|
104
|
+
// as hasOnlySetup, so an empty list or any attention reason is false.
|
|
105
|
+
function onlySetupCode(degradedReasons = [], code) {
|
|
106
|
+
const { setupSteps, attentionReasons } = classifyReasons(degradedReasons);
|
|
107
|
+
return setupSteps.length > 0
|
|
108
|
+
&& attentionReasons.length === 0
|
|
109
|
+
&& setupSteps.every((s) => s.code === code);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { classifyReasons, hasOnlySetup, onlySetupCode, SETUP_CODES, SCAFFOLD_PROJECT_DIR };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Push-based fleet-status read for the operator dashboard
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this).
|
|
4
|
+
//
|
|
5
|
+
// Each VM's editor publishes its own status to a git ref
|
|
6
|
+
// `refs/codeyam/fleet-status/<machine>` on its plan-claim heartbeat (see the
|
|
7
|
+
// Rust `control-api::fleet_status_refs` writer + the `plan_claims_sync`
|
|
8
|
+
// pusher). This module is the READ half: the dashboard fetches that WHOLE
|
|
9
|
+
// namespace in ONE `git fetch` and reads every VM's status from the local
|
|
10
|
+
// mirror — O(1) in fleet size, and crucially spawning ZERO `gcloud ssh`.
|
|
11
|
+
//
|
|
12
|
+
// That is the entire point of the push model: the old gather fanned out one
|
|
13
|
+
// heavyweight `gcloud compute ssh … docker exec` per VM per poll (Python +
|
|
14
|
+
// IAP tunnel, ~100 MB apiece), so monitoring cost O(N) SSH round-trips and
|
|
15
|
+
// the operator host had to be over-provisioned just to stay responsive. One
|
|
16
|
+
// fetch replaces all of it.
|
|
17
|
+
//
|
|
18
|
+
// Pure orchestration with injected I/O (`spawn` for the fetch, `readRefs` for
|
|
19
|
+
// the mirror read) so it stays unit-testable with no network and no git —
|
|
20
|
+
// exactly like the other fleet-* modules. Any real gcloud/SSH I/O is
|
|
21
|
+
// DELIBERATELY absent here; a test asserts no spawn command matches /gcloud/.
|
|
22
|
+
|
|
23
|
+
// Force refspec mirroring the whole fleet-status namespace into the local
|
|
24
|
+
// mirror. The leading `+` prunes a machine that stopped publishing (its ref
|
|
25
|
+
// deleted upstream), so a decommissioned VM's status can't linger. Kept
|
|
26
|
+
// byte-identical to the Rust `FLEET_STATUS_FETCH_REFSPEC` constant.
|
|
27
|
+
const FLEET_STATUS_FETCH_REFSPEC =
|
|
28
|
+
'+refs/codeyam/fleet-status/*:refs/codeyam/fleet-status/*';
|
|
29
|
+
|
|
30
|
+
// Parse `git for-each-ref --format='%(refname)%00%(contents)'` output over the
|
|
31
|
+
// fleet-status namespace into an array of payload objects. A ref whose body is
|
|
32
|
+
// not valid JSON is skipped (never throws) so one corrupt ref can't blind the
|
|
33
|
+
// dashboard to the rest of the fleet. NUL separates the ref name from its
|
|
34
|
+
// single-line JSON body; records are newline-separated.
|
|
35
|
+
function parseForEachRef(stdout) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
38
|
+
if (!line.trim()) continue;
|
|
39
|
+
const sep = line.indexOf('\u0000');
|
|
40
|
+
const json = sep >= 0 ? line.slice(sep + 1) : line;
|
|
41
|
+
try {
|
|
42
|
+
const payload = JSON.parse(json);
|
|
43
|
+
if (payload && typeof payload === 'object') out.push(payload);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// Skip an unparseable ref body; the rest of the fleet still reads.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Index an array of status payloads by their `machine`, keeping the LAST
|
|
52
|
+
// occurrence for a machine (there should only be one ref per machine, but be
|
|
53
|
+
// deterministic if a duplicate slips in). Payloads with no `machine` are
|
|
54
|
+
// dropped — they can't be attributed to a VM.
|
|
55
|
+
function indexByMachine(payloads) {
|
|
56
|
+
const vms = {};
|
|
57
|
+
for (const p of payloads || []) {
|
|
58
|
+
if (p && p.machine) vms[p.machine] = p;
|
|
59
|
+
}
|
|
60
|
+
return vms;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Read the whole fleet's pushed status in ONE fetch, then index it by machine.
|
|
64
|
+
//
|
|
65
|
+
// spawn(command, args) — run a process (the single git fetch). May return a
|
|
66
|
+
// promise; awaited. NEVER used for gcloud/SSH.
|
|
67
|
+
// readRefs() — return (or resolve to) the parsed status payloads
|
|
68
|
+
// from the local mirror. Injected so tests need no
|
|
69
|
+
// git; production passes a reader that runs
|
|
70
|
+
// `git for-each-ref` + parseForEachRef.
|
|
71
|
+
// remote — remote to fetch from (default 'origin').
|
|
72
|
+
// projectDir — cwd for the fetch, threaded to spawn's options.
|
|
73
|
+
//
|
|
74
|
+
// Returns `{ vms: { <machine>: payload }, count }`. A fetch failure is
|
|
75
|
+
// swallowed (best-effort, same posture as the Rust sync): the read falls back
|
|
76
|
+
// to whatever is already in the local mirror, so a transient network blip
|
|
77
|
+
// degrades to slightly-stale status rather than a blank dashboard.
|
|
78
|
+
async function gatherFleetStatus({ spawn, readRefs, remote = 'origin', projectDir } = {}) {
|
|
79
|
+
if (typeof spawn !== 'function') throw new Error('gatherFleetStatus requires a spawn function');
|
|
80
|
+
if (typeof readRefs !== 'function') throw new Error('gatherFleetStatus requires a readRefs function');
|
|
81
|
+
try {
|
|
82
|
+
await spawn('git', ['fetch', '--no-write-fetch-head', remote, FLEET_STATUS_FETCH_REFSPEC], { cwd: projectDir });
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Fall through to reading the (possibly stale) local mirror.
|
|
85
|
+
}
|
|
86
|
+
const payloads = await readRefs();
|
|
87
|
+
const vms = indexByMachine(payloads);
|
|
88
|
+
return { vms, count: Object.keys(vms).length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
FLEET_STATUS_FETCH_REFSPEC,
|
|
93
|
+
parseForEachRef,
|
|
94
|
+
indexByMachine,
|
|
95
|
+
gatherFleetStatus,
|
|
96
|
+
};
|
package/npm/fleet-strand.js
CHANGED
|
@@ -24,10 +24,25 @@
|
|
|
24
24
|
// Stack note: this is fleet-operator infrastructure (the GCE fleet that runs
|
|
25
25
|
// codeyam-editor itself). A project that never joins the fleet never reaches it.
|
|
26
26
|
|
|
27
|
-
// The boot
|
|
28
|
-
//
|
|
29
|
-
//
|
|
27
|
+
// The boot guards whose presence makes a binary safe. Single source so the
|
|
28
|
+
// classifier, the capability field the editor advertises, and any log line
|
|
29
|
+
// agree on the strings.
|
|
30
|
+
// SERVE_CWD_GUARD — safe from the launcher-mode strand (serves its cwd
|
|
31
|
+
// project on the contract port instead of re-execing
|
|
32
|
+
// into the Project Launcher on an ephemeral port).
|
|
33
|
+
// CAPTURE_AUTH_GUARD — enforces the capture-session auth guard, so component
|
|
34
|
+
// captures don't 401 when the editor binds a
|
|
35
|
+
// non-loopback address.
|
|
30
36
|
const SERVE_CWD_GUARD = 'serve_cwd_directly';
|
|
37
|
+
const CAPTURE_AUTH_GUARD = 'capture_session_auth';
|
|
38
|
+
|
|
39
|
+
// Per-guard impact clause folded into the stale reason, so a flagged VM's badge
|
|
40
|
+
// names WHY the missing guard matters (not a generic "stale") and the operator
|
|
41
|
+
// knows the concrete failure to expect.
|
|
42
|
+
const GUARD_IMPACT = {
|
|
43
|
+
[SERVE_CWD_GUARD]: 'will strand in Project Launcher mode on its next re-exec',
|
|
44
|
+
[CAPTURE_AUTH_GUARD]: 'component captures will 401 on a non-loopback bind',
|
|
45
|
+
};
|
|
31
46
|
|
|
32
47
|
/** Classify a VM's in-container probe into the launcher-strand vocabulary. The
|
|
33
48
|
* DECISIVE, trigger-independent signal is observable in-VM regardless of what
|
|
@@ -116,19 +131,34 @@ function strandRecoveryAction({ binaryCurrent } = {}) {
|
|
|
116
131
|
* the editor advertising the capability (the `bootGuards` array on the
|
|
117
132
|
* health/session-info payload it already serves) rather than the dashboard
|
|
118
133
|
* guessing from commit ancestry:
|
|
119
|
-
* - `bootGuards` IS an array → authoritative: stale iff it
|
|
134
|
+
* - `bootGuards` IS an array → authoritative: stale iff it is missing ANY
|
|
135
|
+
* required guard. A binary that has `serve_cwd_directly` yet predates
|
|
136
|
+
* `capture_session_auth` (VM-13) must still be flagged, so a single
|
|
137
|
+
* required guard is not enough — the check is over the whole required set,
|
|
138
|
+
* naming the FIRST missing guard (with its impact clause) in the reason.
|
|
120
139
|
* - `bootGuards` ABSENT (an older binary that predates the field — which are
|
|
121
140
|
* exactly the at-risk ones) → fall back to the build-commit comparison the
|
|
122
141
|
* dashboard already computes: flag stale only when the editor source is
|
|
123
142
|
* `editorBehind > 0` commits behind origin, so a current binary that merely
|
|
124
143
|
* predates the FIELD isn't falsely flagged while a genuinely-behind one is.
|
|
125
|
-
*
|
|
126
|
-
|
|
144
|
+
* `requiredGuard` accepts an array (the new multi-guard shape, default
|
|
145
|
+
* `[SERVE_CWD_GUARD, CAPTURE_AUTH_GUARD]`) or a single string (legacy /
|
|
146
|
+
* future-guard reuse). Returns `{ stale, reason }`; `reason` is null when not
|
|
147
|
+
* stale. */
|
|
148
|
+
function classifyBinaryGuard({
|
|
149
|
+
bootGuards,
|
|
150
|
+
editorBehind,
|
|
151
|
+
requiredGuard = [SERVE_CWD_GUARD, CAPTURE_AUTH_GUARD],
|
|
152
|
+
} = {}) {
|
|
153
|
+
const required = Array.isArray(requiredGuard) ? requiredGuard : [requiredGuard];
|
|
127
154
|
if (Array.isArray(bootGuards)) {
|
|
128
|
-
|
|
155
|
+
const missing = required.find((guard) => !bootGuards.includes(guard));
|
|
156
|
+
if (!missing) return { stale: false, reason: null };
|
|
157
|
+
const impact = GUARD_IMPACT[missing];
|
|
158
|
+
const because = impact ? ` — ${impact}` : '';
|
|
129
159
|
return {
|
|
130
160
|
stale: true,
|
|
131
|
-
reason: `running binary advertises bootGuards [${bootGuards.join(', ')}] without '${
|
|
161
|
+
reason: `running binary advertises bootGuards [${bootGuards.join(', ')}] without '${missing}'${because}; rebuild to converge`,
|
|
132
162
|
};
|
|
133
163
|
}
|
|
134
164
|
const behind = Number(editorBehind);
|
|
@@ -143,6 +173,7 @@ function classifyBinaryGuard({ bootGuards, editorBehind, requiredGuard = SERVE_C
|
|
|
143
173
|
|
|
144
174
|
module.exports = {
|
|
145
175
|
SERVE_CWD_GUARD,
|
|
176
|
+
CAPTURE_AUTH_GUARD,
|
|
146
177
|
classifyLauncherStrand,
|
|
147
178
|
shouldAutoRecoverStrand,
|
|
148
179
|
strandRecoveryAction,
|
|
@@ -62,16 +62,22 @@ function elapsedMs(iso, nowMs) {
|
|
|
62
62
|
|
|
63
63
|
// Derive the attention verdict for one VM.
|
|
64
64
|
//
|
|
65
|
-
// deriveAttention({ status, stepStartedAt,
|
|
65
|
+
// deriveAttention({ status, stepStartedAt, nowMs, thresholds })
|
|
66
66
|
// -> { stepElapsedMs, level, needsYou, needsYouElapsedMs }
|
|
67
67
|
//
|
|
68
68
|
// Rules:
|
|
69
69
|
// - `level` ('ok' | 'slow' | 'stuck') is computed ONLY for an actively-
|
|
70
70
|
// building VM (status === 'building'). Thresholds are inclusive:
|
|
71
71
|
// >= stuckMs -> 'stuck', else >= slowMs -> 'slow', else 'ok'.
|
|
72
|
-
// -
|
|
73
|
-
//
|
|
74
|
-
//
|
|
72
|
+
// - "Needs you" flips true ONLY for the genuinely-blocked `waiting` state (a
|
|
73
|
+
// running task awaiting a human), once its elapsed >= needsYouMs. An 'idle'
|
|
74
|
+
// VM (parked at its prompt, no task running) is deliberately NOT flagged no
|
|
75
|
+
// matter how long it sits — idle means "ready for you to start something",
|
|
76
|
+
// not "a task is blocked on you" (observed live 2026-06-30, VM-1, which
|
|
77
|
+
// false-flagged "Needs You" while merely idle). `awaitingInput` alone no
|
|
78
|
+
// longer drives this: liveSessionStatus already collapses an idle awaiting
|
|
79
|
+
// agent to 'idle' and a blocked one to 'waiting', so the status IS the
|
|
80
|
+
// discriminator.
|
|
75
81
|
// - Missing/unparseable stepStartedAt degrades to stepElapsedMs:null,
|
|
76
82
|
// level:'ok', needsYou:false (older editor, no crash).
|
|
77
83
|
function deriveAttention(opts) {
|
|
@@ -79,14 +85,14 @@ function deriveAttention(opts) {
|
|
|
79
85
|
const thresholds = o.thresholds || resolveThresholds({});
|
|
80
86
|
const nowMs = o.nowMs;
|
|
81
87
|
const stepElapsed = elapsedMs(o.stepStartedAt, nowMs);
|
|
82
|
-
const awaiting = o.status === 'waiting' || !!o.awaitingInput;
|
|
83
88
|
|
|
84
89
|
let level = 'ok';
|
|
85
90
|
let needsYou = false;
|
|
86
91
|
let needsYouElapsedMs = null;
|
|
87
92
|
|
|
88
|
-
if (
|
|
89
|
-
//
|
|
93
|
+
if (o.status === 'waiting') {
|
|
94
|
+
// A task genuinely blocked on a human — never "stuck"; flag "needs you" past
|
|
95
|
+
// its threshold. An 'idle' VM never reaches this branch.
|
|
90
96
|
needsYouElapsedMs = stepElapsed;
|
|
91
97
|
needsYou = stepElapsed != null && stepElapsed >= thresholds.needsYouMs;
|
|
92
98
|
} else if (o.status === 'building' && stepElapsed != null) {
|