@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
package/npm/fleet-queue.js
CHANGED
|
@@ -74,4 +74,61 @@ function computeReorderTargets({ order, current }) {
|
|
|
74
74
|
return { ok: true, targets };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
/** Move one uuid within a desired order array.
|
|
78
|
+
*
|
|
79
|
+
* The staged-draft panel shuffles a working uuid order before it is saved.
|
|
80
|
+
* `delta > 0` raises priority (moves the entry TOWARD the head / index 0);
|
|
81
|
+
* `delta < 0` lowers it (toward the tail). The target index is clamped to the
|
|
82
|
+
* array bounds, so a nudge past an end is a no-op and a full-span negative
|
|
83
|
+
* delta lands the entry at the very tail — which is exactly how Demote
|
|
84
|
+
* (head → back of the line) is expressed: `nudgeQueueOrder(order, head, -(N-1))`.
|
|
85
|
+
* Mirrors the editor UI's `useBranchQueueDraft.nudge` (arrayMove(from, from-delta)).
|
|
86
|
+
*
|
|
87
|
+
* `order` is a uuid array; a uuid not present is returned unchanged. Pure — the
|
|
88
|
+
* input array is never mutated.
|
|
89
|
+
*/
|
|
90
|
+
function nudgeQueueOrder(order, uuid, delta) {
|
|
91
|
+
const arr = (order || []).slice();
|
|
92
|
+
const from = arr.findIndex((u) => u === uuid);
|
|
93
|
+
if (from < 0 || !delta) return arr;
|
|
94
|
+
let to = from - delta; // +delta → toward head; -delta → toward tail
|
|
95
|
+
if (to < 0) to = 0;
|
|
96
|
+
if (to > arr.length - 1) to = arr.length - 1;
|
|
97
|
+
if (to === from) return arr;
|
|
98
|
+
const [moved] = arr.splice(from, 1);
|
|
99
|
+
arr.splice(to, 0, moved);
|
|
100
|
+
return arr;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Merge a fresh poll's membership into a staged draft order without discarding
|
|
104
|
+
* the operator's in-progress reorder.
|
|
105
|
+
*
|
|
106
|
+
* - uuids still present live keep their DRAFT position (the working order wins);
|
|
107
|
+
* - draft uuids that vanished upstream are dropped;
|
|
108
|
+
* - live uuids not yet in the draft are appended at the tail so a sibling
|
|
109
|
+
* session's newly-enqueued entry doesn't silently disappear.
|
|
110
|
+
*
|
|
111
|
+
* Pure port of the editor UI's `reconcileDraft` order rule (uuid-only, no meta).
|
|
112
|
+
* Both arguments are uuid arrays; returns the merged uuid array. Never mutates
|
|
113
|
+
* its inputs.
|
|
114
|
+
*/
|
|
115
|
+
function reconcileQueueDraft(draftOrder, liveOrder) {
|
|
116
|
+
const live = liveOrder || [];
|
|
117
|
+
const liveSet = new Set(live);
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const u of draftOrder || []) {
|
|
121
|
+
if (liveSet.has(u) && !seen.has(u)) { out.push(u); seen.add(u); }
|
|
122
|
+
}
|
|
123
|
+
for (const u of live) {
|
|
124
|
+
if (!seen.has(u)) { out.push(u); seen.add(u); }
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
decideAutoReleaseGoneHead,
|
|
131
|
+
computeReorderTargets,
|
|
132
|
+
nudgeQueueOrder,
|
|
133
|
+
reconcileQueueDraft,
|
|
134
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure consecutive-failure debounce for the Fleet Dashboard's per-VM top-level
|
|
3
|
+
// reachability. Mirrors npm/fleet-broker-debounce.js exactly: server.js does the
|
|
4
|
+
// polling and threads the state; this decides the reachability the card renders.
|
|
5
|
+
//
|
|
6
|
+
// The bug this fixes (observed live 2026-07-01, VM6): a healthy VM briefly
|
|
7
|
+
// flashed 'unreachable' and surfaced a reset-client-error even though it was up
|
|
8
|
+
// the whole time. The dashboard polls each VM's editor over a laptop-side
|
|
9
|
+
// SSH/cloudflared forward with NO fast respawner, so a single one-poll hiccup
|
|
10
|
+
// returned reachable:false and the card instantly read 'unreachable' — which
|
|
11
|
+
// then invited the destructive "Reset client" action on a perfectly healthy VM.
|
|
12
|
+
// The single probe is left as-is on the poller side; the right seam for
|
|
13
|
+
// absorbing a one-poll blip is here at the aggregation layer, just like broker.
|
|
14
|
+
//
|
|
15
|
+
// Asymmetric by design: ONLY the flip to 'unreachable' is debounced (it requires
|
|
16
|
+
// `threshold` consecutive failed polls). A reachable poll takes effect
|
|
17
|
+
// immediately — we never DELAY clearing a false alarm, only delay raising one.
|
|
18
|
+
//
|
|
19
|
+
// Three display states the card renders:
|
|
20
|
+
// 'reachable' — the editor answered this poll.
|
|
21
|
+
// 'reconnecting' — a sub-threshold failure streak (1..N-1), OR the GCP side
|
|
22
|
+
// confirms the editor answers over IAP while the local port
|
|
23
|
+
// is dark (a laptop tunnel fault, never a dead VM). Calm; the
|
|
24
|
+
// watchdog respawns the forward. A one-poll hiccup lives here.
|
|
25
|
+
// 'unreachable' — N consecutive failures AND no GCP-side confirmation. The
|
|
26
|
+
// honest sustained-outage state.
|
|
27
|
+
//
|
|
28
|
+
// State carried across polls: { display, failStreak, lastConfirmed }.
|
|
29
|
+
// display — the debounced value the card renders this poll.
|
|
30
|
+
// failStreak — how many consecutive raw failed polls we've seen.
|
|
31
|
+
// lastConfirmed — the last raw 'reachable' we proved (informational; the card
|
|
32
|
+
// holds 'reconnecting' during a pending sub-threshold streak).
|
|
33
|
+
|
|
34
|
+
// ~9s at the dashboard's 3s poll cadence, matching the broker debounce: enough
|
|
35
|
+
// to absorb a transient forward hiccup while still surfacing a real outage
|
|
36
|
+
// quickly. Named so retuning is a one-line edit.
|
|
37
|
+
const UNREACHABLE_DEBOUNCE_THRESHOLD = 3;
|
|
38
|
+
|
|
39
|
+
function debounceReachability(prev, rawReachable, opts) {
|
|
40
|
+
const threshold = (opts && opts.threshold) || UNREACHABLE_DEBOUNCE_THRESHOLD;
|
|
41
|
+
const gcpConfirmedReachable = !!(opts && opts.gcpConfirmedReachable);
|
|
42
|
+
const lastConfirmed = prev ? prev.lastConfirmed : undefined;
|
|
43
|
+
|
|
44
|
+
// Confirmed reachable: the editor answered now. Render immediately, reset the
|
|
45
|
+
// streak, remember this as the last-confirmed value.
|
|
46
|
+
if (rawReachable) {
|
|
47
|
+
return { display: 'reachable', failStreak: 0, lastConfirmed: 'reachable' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Raw failure: count the streak.
|
|
51
|
+
const failStreak = (prev ? prev.failStreak : 0) + 1;
|
|
52
|
+
|
|
53
|
+
// GCP-side confirmation: the editor answers over IAP while the local port is
|
|
54
|
+
// dark. That's a laptop tunnel fault, NOT a dead VM — never escalate to
|
|
55
|
+
// 'unreachable' no matter how long the local forward has been down; the
|
|
56
|
+
// watchdog respawns the forward. Hold the calm 'reconnecting' state.
|
|
57
|
+
if (gcpConfirmedReachable) {
|
|
58
|
+
return { display: 'reconnecting', failStreak, lastConfirmed };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Sub-threshold failure: stay calm ('reconnecting'). Only a SUSTAINED failure
|
|
62
|
+
// past the threshold escalates to the honest 'unreachable'.
|
|
63
|
+
if (failStreak >= threshold) {
|
|
64
|
+
return { display: 'unreachable', failStreak, lastConfirmed };
|
|
65
|
+
}
|
|
66
|
+
return { display: 'reconnecting', failStreak, lastConfirmed };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { debounceReachability, UNREACHABLE_DEBOUNCE_THRESHOLD };
|
|
@@ -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,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure helpers for the fleet dashboard's DEPLOYED-REVISION staleness signal
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this; unit tests in
|
|
4
|
+
// npm/fleet-revision.test.js).
|
|
5
|
+
//
|
|
6
|
+
// The dashboard header has always shown the branch name, which says nothing
|
|
7
|
+
// about WHICH COMMIT of that branch is serving. On 2026-07-31 the operator
|
|
8
|
+
// host's /opt/codeyam-editor sat 104 commits behind origin/editor-improvements-66,
|
|
9
|
+
// so the deployed dashboard predated its own memory fix and was
|
|
10
|
+
// indistinguishable from one at tip — the third recurrence of a landed fix
|
|
11
|
+
// surviving because the served checkout was behind (the June VM-5 slug bug was
|
|
12
|
+
// the same shape).
|
|
13
|
+
//
|
|
14
|
+
// server.js owns the I/O — the bounded `git fetch` / `rev-parse` / `rev-list`
|
|
15
|
+
// children and the slow-cycle memo. The two rules that actually matter are here
|
|
16
|
+
// and are pure: how a failed check merges over the last good one, and what
|
|
17
|
+
// verdict a given count means. Both were previously inline in an async IIFE
|
|
18
|
+
// that no test could reach.
|
|
19
|
+
|
|
20
|
+
/** Merge one revision-check result over the previous memo.
|
|
21
|
+
*
|
|
22
|
+
* THE RULE: a failed check RETAINS the previous `behind` and records `error` —
|
|
23
|
+
* it never reports zero. This is what makes the staleness chip sticky. A
|
|
24
|
+
* broken `git fetch` that reset the count to 0 would present a stale dashboard
|
|
25
|
+
* as current, which is precisely the failure this whole signal exists to catch;
|
|
26
|
+
* degrading to "last known count, plus a note that the check is failing" is
|
|
27
|
+
* the honest answer.
|
|
28
|
+
*
|
|
29
|
+
* A `behind` that is absent, negative, or unparseable counts as no reading and
|
|
30
|
+
* falls back to the previous value the same way. `sha` and `branch` likewise
|
|
31
|
+
* retain their last known values rather than blanking the header on a hiccup.
|
|
32
|
+
*
|
|
33
|
+
* A previous `behind` of null (never successfully counted) stays null — it is
|
|
34
|
+
* deliberately NOT coerced through Number(), because `Number(null) === 0` is
|
|
35
|
+
* the exact coercion that made "no reading" and "zero" indistinguishable in
|
|
36
|
+
* readCgroupMemory.
|
|
37
|
+
*
|
|
38
|
+
* Pure; no I/O. */
|
|
39
|
+
function mergeDashboardRevision({ prev, sha, branch, behind, checkedAt, error } = {}) {
|
|
40
|
+
const base = (prev && typeof prev === 'object') ? prev : {};
|
|
41
|
+
const str = (next, fallback) => ((typeof next === 'string' && next) ? next : (typeof fallback === 'string' && fallback ? fallback : null));
|
|
42
|
+
// Strictly a number: a string or null is NOT coerced, so a caller cannot
|
|
43
|
+
// accidentally turn "no reading" into 0 the way Number(null) would.
|
|
44
|
+
const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null);
|
|
45
|
+
const counted = count(behind);
|
|
46
|
+
return {
|
|
47
|
+
sha: str(sha, base.sha),
|
|
48
|
+
branch: str(branch, base.branch),
|
|
49
|
+
behind: counted === null ? count(base.behind) : counted,
|
|
50
|
+
checkedAt: str(checkedAt, base.checkedAt),
|
|
51
|
+
error: str(error, null),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What does the current revision memo mean for the operator?
|
|
56
|
+
*
|
|
57
|
+
* Split out from the header chip so the verdict is a readable table rather
|
|
58
|
+
* than branches buried in string concatenation — the same reason
|
|
59
|
+
* operatorAuthRecovery was split out of operatorAuthBannerHtml, and the same
|
|
60
|
+
* reason resolveCgroupMemoryDir was split out of readCgroupMemory: logic that
|
|
61
|
+
* can only be reached through a browser inline script or a real cgroup
|
|
62
|
+
* filesystem is logic that ships its bugs green.
|
|
63
|
+
*
|
|
64
|
+
* Levels:
|
|
65
|
+
* 'stale' — behind > 0. Landed fixes are not live; the chip warns.
|
|
66
|
+
* 'current' — behind === 0. Clears the chip, and clears it even when the
|
|
67
|
+
* latest check errored: the count reaching zero is the ONLY
|
|
68
|
+
* thing that clears the warning, and once it has, a later
|
|
69
|
+
* failed fetch has no stale count to keep showing.
|
|
70
|
+
* 'unknown' — no count has ever succeeded AND the check is failing. Worth
|
|
71
|
+
* saying out loud rather than rendering the reassuring nothing
|
|
72
|
+
* that an at-tip dashboard renders.
|
|
73
|
+
* 'unchecked' — no count yet and nothing has failed (the boot window before
|
|
74
|
+
* the first slow-cycle refresh). Renders nothing.
|
|
75
|
+
*
|
|
76
|
+
* Pure; no I/O. */
|
|
77
|
+
function classifyDashboardStaleness({ behind, error } = {}) {
|
|
78
|
+
const counted = (typeof behind === 'number' && Number.isFinite(behind) && behind >= 0) ? behind : null;
|
|
79
|
+
if (counted === null) return error ? 'unknown' : 'unchecked';
|
|
80
|
+
return counted > 0 ? 'stale' : 'current';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The `/data` view of the revision memo, with the staleness VERDICT resolved
|
|
84
|
+
* server-side.
|
|
85
|
+
*
|
|
86
|
+
* The chip is then a pure renderer: `public/index.html` is an inline browser
|
|
87
|
+
* script with no module boundary, so any decision left in it could only ever
|
|
88
|
+
* be source-scan tested — assertions on text rather than on behavior.
|
|
89
|
+
*
|
|
90
|
+
* Lives here rather than in `server.js` for the same reason
|
|
91
|
+
* `classifyDashboardStaleness` does: `server.js` has top-level side effects on
|
|
92
|
+
* require (createServer/listen/setInterval), so nothing there can be executed
|
|
93
|
+
* by a test and a projection defined inside it ships its bugs green.
|
|
94
|
+
*
|
|
95
|
+
* Pure; no I/O. */
|
|
96
|
+
function projectDashboardRevision(memo = {}) {
|
|
97
|
+
return { ...memo, level: classifyDashboardStaleness(memo) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
mergeDashboardRevision,
|
|
102
|
+
classifyDashboardStaleness,
|
|
103
|
+
projectDashboardRevision,
|
|
104
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Resolve the running-plan fields a fleet card shows — feature, step, mode,
|
|
4
|
+
// selectedPlan, holderSessionId, startedAt, featureStartedAt — from the two
|
|
5
|
+
// independent per-VM probes plus the last-known-good state.
|
|
6
|
+
//
|
|
7
|
+
// The two timestamps ride here because they share the restart problem below and
|
|
8
|
+
// nothing else resolves them: `startedAt` / `featureStartedAt` drive the card's
|
|
9
|
+
// "on step" / "on feature" tenure badges AND the server-side slow/stuck/needs-you
|
|
10
|
+
// verdict (fleet-stuck-status.js deriveAttention keys on `startedAt`). A null
|
|
11
|
+
// degrades that verdict to level:'ok', needsYou:false — so a VM whose editor
|
|
12
|
+
// restarted was silently exempt from the very detection it most needed.
|
|
13
|
+
//
|
|
14
|
+
// Why this exists: the card used to hang these fields off `/api/session-info`
|
|
15
|
+
// alone (spread as `...info` in server.js). But a VM whose editor server
|
|
16
|
+
// restarted while the PTY broker kept the agent alive reports
|
|
17
|
+
// `hasSession:false` / `sessionId:null` and session-info then OMITS
|
|
18
|
+
// feature/step/mode/selectedPlan — so an actively-building VM showed a BLANK
|
|
19
|
+
// card. `GET /api/step` reads `editor-step.json` directly, independent of
|
|
20
|
+
// session registration, so it stays populated across that restart. This helper
|
|
21
|
+
// makes `/api/step` the preferred source and falls back to session-info.
|
|
22
|
+
//
|
|
23
|
+
// Stickiness — the subtle part. The last-known `prev` is carried ONLY to bridge
|
|
24
|
+
// a genuine probe failure (the editor answered NOTHING this poll), so a single
|
|
25
|
+
// timed-out cycle can't blank a plan the card is already showing. It is NOT a
|
|
26
|
+
// blanket fallback: when the editor DID answer, the fresh read is authoritative
|
|
27
|
+
// for the plan the VM is executing NOW. Carrying prev unconditionally pinned a
|
|
28
|
+
// COMPLETED plan's feature onto the next one — feature-complete deletes
|
|
29
|
+
// editor-step.json (reset_step_state), and a plan's `feature` is null until
|
|
30
|
+
// step 4, so a stale prev would win over the correct null and the card kept
|
|
31
|
+
// showing plan A while the VM built plan B. So prev is carried only within the
|
|
32
|
+
// SAME plan (matching selectedPlan); a plan change (or going idle) drops it.
|
|
33
|
+
//
|
|
34
|
+
// Precedence per field, when the editor answered: `/api/step` (session-
|
|
35
|
+
// independent) > `/api/session-info` > same-plan `prev`. On a full probe
|
|
36
|
+
// failure: `prev` verbatim. Pure; no I/O — unit-tested in
|
|
37
|
+
// fleet-running-plan.test.js.
|
|
38
|
+
//
|
|
39
|
+
// @param {Object} params
|
|
40
|
+
// @param {?Object} params.step Parsed `/api/step` body (EditorStepState) or
|
|
41
|
+
// null when the VM has no `editor-step.json` (idle) / the probe failed.
|
|
42
|
+
// @param {?Object} params.info Parsed `/api/session-info` body (carries
|
|
43
|
+
// `reachable`), or null on a failed/timed-out probe.
|
|
44
|
+
// @param {?Object} params.prev The previous `state.vms[n]` (carries the
|
|
45
|
+
// last-known feature/step/mode/selectedPlan/holderSessionId/startedAt/
|
|
46
|
+
// featureStartedAt). null/undefined on the first poll.
|
|
47
|
+
// @returns {{feature: ?string, step: ?number, mode: ?string,
|
|
48
|
+
// selectedPlan: ?string, holderSessionId: ?string, startedAt: ?string,
|
|
49
|
+
// featureStartedAt: ?string}}
|
|
50
|
+
function resolveRunningPlan({ step, info, prev } = {}) {
|
|
51
|
+
const s = step || {};
|
|
52
|
+
const i = info || {};
|
|
53
|
+
const p = prev || {};
|
|
54
|
+
|
|
55
|
+
// Did the editor answer at all this poll? `/api/step` returning a body proves
|
|
56
|
+
// it; so does a reachable `/api/session-info`. If NEITHER answered, this is a
|
|
57
|
+
// transient outage — hold the last-known-good plan verbatim rather than blank
|
|
58
|
+
// the card for one cycle.
|
|
59
|
+
const editorAnswered = step != null || !!i.reachable;
|
|
60
|
+
if (!editorAnswered) {
|
|
61
|
+
return {
|
|
62
|
+
feature: p.feature || null,
|
|
63
|
+
step: p.step != null ? p.step : null,
|
|
64
|
+
mode: p.mode || null,
|
|
65
|
+
selectedPlan: p.selectedPlan || null,
|
|
66
|
+
holderSessionId: p.holderSessionId || null,
|
|
67
|
+
// Carried verbatim like the rest: a single timed-out cycle must not blink
|
|
68
|
+
// the tenure badges off and reset the stuck timer back to zero.
|
|
69
|
+
startedAt: p.startedAt || null,
|
|
70
|
+
featureStartedAt: p.featureStartedAt || null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Editor answered: the fresh read is authoritative for the current plan.
|
|
75
|
+
const freshSelectedPlan = s.selectedPlan || i.selectedPlan || null;
|
|
76
|
+
// Carry prev ONLY within the same plan — a blip-fallback for a momentarily
|
|
77
|
+
// missing field (e.g. /api/step timed out but session-info answered). Across
|
|
78
|
+
// a plan change or an idle poll, prev is stale and MUST be dropped.
|
|
79
|
+
const samePlan = freshSelectedPlan != null && freshSelectedPlan === (p.selectedPlan || null);
|
|
80
|
+
const carry = samePlan ? p : {};
|
|
81
|
+
|
|
82
|
+
// The module's precedence rule, in ONE place: /api/step (session-independent)
|
|
83
|
+
// > /api/session-info > same-plan carry. Four fields resolve exactly this way,
|
|
84
|
+
// so hoisting it keeps a future precedence change from being applied to some
|
|
85
|
+
// of them and not others. The two fields that deviate stay longhand below, and
|
|
86
|
+
// their being longhand is the signal that they deviate: `step` needs an
|
|
87
|
+
// explicit null check (0 is a real step number, and `||` would swallow it),
|
|
88
|
+
// and holderSessionId deliberately skips session-info, which never carries it.
|
|
89
|
+
const pick = (key) => s[key] || i[key] || carry[key] || null;
|
|
90
|
+
|
|
91
|
+
const stepNum =
|
|
92
|
+
s.step != null ? s.step : i.step != null ? i.step : carry.step != null ? carry.step : null;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
feature: pick("feature"),
|
|
96
|
+
step: stepNum,
|
|
97
|
+
mode: pick("mode"),
|
|
98
|
+
selectedPlan: freshSelectedPlan || carry.selectedPlan || null,
|
|
99
|
+
// session-info never carries holderSessionId — it comes from /api/step
|
|
100
|
+
// (editor-step.json) or the same-plan carry. This is what lets a claim map
|
|
101
|
+
// to the executing VM after an editor restart drops the live sessionId.
|
|
102
|
+
holderSessionId: s.holderSessionId || carry.holderSessionId || null,
|
|
103
|
+
// Workflow tenure timers. /api/step wins because EditorStepState serializes
|
|
104
|
+
// `started_at` (non-optional, camelCase) straight out of editor-step.json —
|
|
105
|
+
// session-independent, so it survives the editor restart that makes
|
|
106
|
+
// session-info report hasSession:false and omit them entirely. The same-plan
|
|
107
|
+
// `carry` applies unchanged: a completed plan's startedAt must never be
|
|
108
|
+
// pinned onto the next plan, or the badges would show a tenure the new plan
|
|
109
|
+
// has not accrued.
|
|
110
|
+
startedAt: pick("startedAt"),
|
|
111
|
+
featureStartedAt: pick("featureStartedAt"),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
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 };
|