@codeyam-editor/codeyam-editor 0.1.4 → 0.1.5
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/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +1415 -73
- 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/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +51 -2
- package/npm/fleet-cleanup.js +118 -7
- package/npm/fleet-destroy-safety.js +55 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +135 -0
- package/npm/fleet-prewarm.js +165 -0
- package/npm/fleet-public-url.js +160 -0
- package/npm/fleet-queue.js +38 -1
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +298 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-setup-steps.js +105 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-strand.js +150 -0
- package/npm/fleet-stuck-status.js +111 -0
- package/npm/fleet-vm-access.js +234 -0
- package/npm/fleet-vm-readiness.js +104 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +211 -0
- package/npm/ingress-invoke.js +292 -0
- package/npm/launch-config.js +204 -3
- package/npm/scenario-check.js +603 -32
- package/npm/scenario-handlers.js +78 -12
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +366 -29
- package/npm/script-drift.js +81 -0
- package/npm/utils.js +329 -50
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
- package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
- package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
- package/ui/dist/assets/index-CIXZkwfB.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-QsOiOG9x.js} +9 -9
- package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +2 -2
- 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/useEvents-DggWrtHe.js +0 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-setup-steps.js — partition a VM's degradedReasons into expected
|
|
4
|
+
// first-run SETUP steps vs genuine ATTENTION problems for the fleet dashboard
|
|
5
|
+
// (vm: Dashboard 'Finishing setup' state for expected add steps).
|
|
6
|
+
//
|
|
7
|
+
// A clean greenfield Add lands with a wall of amber `⚠ Add degraded` badges
|
|
8
|
+
// because every unfinished post-provision wiring step is funneled into one flat
|
|
9
|
+
// `degradedReasons` string array and rendered identically. There is no
|
|
10
|
+
// distinction between *expected first-run setup that just hasn't finished yet*
|
|
11
|
+
// (Claude login not seeded, ingress host-rule not wired, project dir still the
|
|
12
|
+
// scaffold `workspace`, no start command on a brand-new project) and *something
|
|
13
|
+
// that actually broke* (broker death, half-provisioned orphan, off-branch
|
|
14
|
+
// drift). This module draws that line: it parses the existing reason-code
|
|
15
|
+
// prefixes and classifies each reason as a calm `setup` step or an `attention`
|
|
16
|
+
// problem, keeping the rules in a tested pure function the way fleet-job-badge.js
|
|
17
|
+
// / fleet-public-url.js do — server.js stays thin and the card colors finally
|
|
18
|
+
// tell the truth (blue = still wiring up, amber = look at this).
|
|
19
|
+
//
|
|
20
|
+
// Stack note: this is fleet-operator infrastructure (the GCE fleet that runs
|
|
21
|
+
// codeyam-editor itself), GCP-specific by design — the same posture as the
|
|
22
|
+
// scripts it backstops. The classification is pure string parsing, no network,
|
|
23
|
+
// so it unit-tests with no fixtures.
|
|
24
|
+
|
|
25
|
+
// The setup-step codes this module emits. A single source so server.js and the
|
|
26
|
+
// card UI agree on the vocabulary.
|
|
27
|
+
const SETUP_CODES = ['auth', 'ingress', 'project-dir', 'start-command'];
|
|
28
|
+
|
|
29
|
+
// The scaffold default project directory a brand-new (not-yet-created) VM runs
|
|
30
|
+
// under. project-dir-drift AWAY from this is a real problem (two real project
|
|
31
|
+
// names disagree); drift while still ON it is just "the greenfield project
|
|
32
|
+
// hasn't been created yet" — an expected setup step.
|
|
33
|
+
const SCAFFOLD_PROJECT_DIR = 'workspace';
|
|
34
|
+
|
|
35
|
+
// Parse `project-dir-drift (expected: X, running: Y)` into its two names.
|
|
36
|
+
// Returns { expected, running } with null fields when the shape doesn't match.
|
|
37
|
+
function parseDriftReason(reason) {
|
|
38
|
+
const m = /expected:\s*([^,]+?)\s*,\s*running:\s*([^)]+?)\s*\)/.exec(reason);
|
|
39
|
+
return m ? { expected: m[1].trim(), running: m[2].trim() } : { expected: null, running: null };
|
|
40
|
+
}
|
|
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
|
+
// Partition degradedReasons into { setupSteps, attentionReasons }.
|
|
52
|
+
//
|
|
53
|
+
// setupSteps ordered [{ code, label, detail, action? }] — expected
|
|
54
|
+
// first-run wiring the card renders as a calm checklist.
|
|
55
|
+
// attentionReasons the original reason strings that signal a genuine
|
|
56
|
+
// problem — rendered amber with the existing ⚠ treatment.
|
|
57
|
+
//
|
|
58
|
+
// Unknown reason codes default to attentionReasons: an unrecognized signal is
|
|
59
|
+
// surfaced loudly, never silently hidden in the calm checklist.
|
|
60
|
+
function classifyReasons(degradedReasons = []) {
|
|
61
|
+
const setupSteps = [];
|
|
62
|
+
const attentionReasons = [];
|
|
63
|
+
const list = Array.isArray(degradedReasons) ? degradedReasons : [];
|
|
64
|
+
for (const raw of list) {
|
|
65
|
+
const reason = String(raw);
|
|
66
|
+
if (reason.startsWith('claude-auth-not-seeded')) {
|
|
67
|
+
setupSteps.push({ code: 'auth', label: 'Claude login', detail: reason });
|
|
68
|
+
} else if (reason.startsWith('ingress-unwired')) {
|
|
69
|
+
setupSteps.push({
|
|
70
|
+
code: 'ingress',
|
|
71
|
+
label: 'Public URL wiring',
|
|
72
|
+
detail: reason,
|
|
73
|
+
action: parseIngressAction(reason),
|
|
74
|
+
});
|
|
75
|
+
} else if (reason.startsWith('project-dir-drift')) {
|
|
76
|
+
const { expected, running } = parseDriftReason(reason);
|
|
77
|
+
if (running === SCAFFOLD_PROJECT_DIR) {
|
|
78
|
+
setupSteps.push({
|
|
79
|
+
code: 'project-dir',
|
|
80
|
+
label: expected ? `Create project ${expected}` : 'Create project',
|
|
81
|
+
detail: reason,
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
// Drift between two REAL project names is a genuine problem, not setup.
|
|
85
|
+
attentionReasons.push(reason);
|
|
86
|
+
}
|
|
87
|
+
} else if (reason.startsWith('start-command')) {
|
|
88
|
+
setupSteps.push({ code: 'start-command', label: 'Start command', detail: reason });
|
|
89
|
+
} else {
|
|
90
|
+
attentionReasons.push(reason);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { setupSteps, attentionReasons };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// True when a VM's reasons are ALL expected setup steps with zero genuine
|
|
97
|
+
// problems — the gate for the calm `finishing-setup` status. Requires at least
|
|
98
|
+
// one setup step (an empty reason list is not "finishing setup", it's just a
|
|
99
|
+
// healthy VM with nothing left to do) and zero attention reasons.
|
|
100
|
+
function hasOnlySetup(degradedReasons = []) {
|
|
101
|
+
const { setupSteps, attentionReasons } = classifyReasons(degradedReasons);
|
|
102
|
+
return setupSteps.length > 0 && attentionReasons.length === 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { classifyReasons, hasOnlySetup, SETUP_CODES, SCAFFOLD_PROJECT_DIR };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure status helpers for the Sleep / Wake feature on the Fleet Dashboard
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). Mirrors the
|
|
4
|
+
// pure-module-plus-colocated-vitest-test shape of npm/fleet-effect-status.js:
|
|
5
|
+
// server.js does the impure fact-gathering (the gcloud running-VM list, the
|
|
6
|
+
// per-VM reachability probe); these predicates turn those facts into the two
|
|
7
|
+
// new card statuses without any I/O, so the whole decision is unit-testable.
|
|
8
|
+
|
|
9
|
+
// Map a RUNNING stop/start job to its in-flight status badge. A Sleep click
|
|
10
|
+
// runs `gcloud compute instances stop` as a 'stop' job → 'stopping'; a Wake
|
|
11
|
+
// click runs `...start` as a 'start' job → 'starting'. Returns null for every
|
|
12
|
+
// other action so deriveStatus's existing per-action chain keeps owning it.
|
|
13
|
+
function sleepJobStatus(action) {
|
|
14
|
+
if (action === 'stop') return 'stopping';
|
|
15
|
+
if (action === 'start') return 'starting';
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Classify an UNREACHABLE rostered VM. The dashboard already polls GCP for the
|
|
20
|
+
// set of RUNNING fleet instances (runningFleetVms) on its slow 60s cycle, so we
|
|
21
|
+
// reuse it at zero extra API cost: a VM GCP still lists as running but whose
|
|
22
|
+
// control API we can't reach is genuinely 'unreachable' (booting, wedged, or
|
|
23
|
+
// the cloudflared tunnel is down); one absent from the running list has been
|
|
24
|
+
// stopped to pause billing — 'asleep'. A null/absent list is treated as "not
|
|
25
|
+
// running" so a cold dashboard with no poll yet defaults to the safe 'asleep'
|
|
26
|
+
// rather than masking a stopped VM as a red unreachable.
|
|
27
|
+
function unreachableStatus(n, runningFleetVms) {
|
|
28
|
+
return Array.isArray(runningFleetVms) && runningFleetVms.includes(n)
|
|
29
|
+
? 'unreachable'
|
|
30
|
+
: 'asleep';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { sleepJobStatus, unreachableStatus };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure classification + self-heal predicates for the fleet dashboard's
|
|
3
|
+
// launcher-strand detection (vm: Fleet converges onto the launcher-strand fix
|
|
4
|
+
// and self-heals the strand regardless of trigger).
|
|
5
|
+
//
|
|
6
|
+
// A cloud VM can strand off its control port `:14199` in bare Project Launcher
|
|
7
|
+
// mode: the editor re-execs into the launcher on an EPHEMERAL port instead of
|
|
8
|
+
// serving its one /workspace project on the contract port, so the dashboard's
|
|
9
|
+
// local-forward probe gets connection-refused and reports a generic
|
|
10
|
+
// "unreachable" — indistinguishable from a real outage. Diagnosed on VM-4
|
|
11
|
+
// (2026-06-25): an add-time binary that predated the `serve_cwd_directly`
|
|
12
|
+
// launcher-strand guard fell through `decide_launch` to the launcher because a
|
|
13
|
+
// stale `/tmp` test-fixture project padded `registry_count` to 2.
|
|
14
|
+
//
|
|
15
|
+
// The completed predecessor plan fixed the editor CODE PATH (serve the cwd
|
|
16
|
+
// project on :14199); this module is the dashboard-side SAFETY NET that detects
|
|
17
|
+
// the strand CONTINUOUSLY — by its trigger-independent SIGNAL SHAPE, not by what
|
|
18
|
+
// caused it — so a running fleet can't silently sit on the landmine. This is the
|
|
19
|
+
// PURE half (classification + decision), mirroring npm/fleet-public-url.js: the
|
|
20
|
+
// docker-exec / curl I/O lives in scripts/operator/fleet-dashboard/server.js's
|
|
21
|
+
// gatherLauncherStrand, the same gather-edge / pure-half split
|
|
22
|
+
// classifyPublicUrlHealth follows. Unit-tested in npm/fleet-strand.test.js.
|
|
23
|
+
//
|
|
24
|
+
// Stack note: this is fleet-operator infrastructure (the GCE fleet that runs
|
|
25
|
+
// codeyam-editor itself). A project that never joins the fleet never reaches it.
|
|
26
|
+
|
|
27
|
+
// The boot guard whose presence makes a binary safe from the launcher-mode
|
|
28
|
+
// strand. Single source so the classifier, the capability field the editor
|
|
29
|
+
// advertises, and any log line agree on the string.
|
|
30
|
+
const SERVE_CWD_GUARD = 'serve_cwd_directly';
|
|
31
|
+
|
|
32
|
+
/** Classify a VM's in-container probe into the launcher-strand vocabulary. The
|
|
33
|
+
* DECISIVE, trigger-independent signal is observable in-VM regardless of what
|
|
34
|
+
* caused the strand (a stale add-time binary, a wiped open-project pointer, any
|
|
35
|
+
* future cause):
|
|
36
|
+
*
|
|
37
|
+
* 'serving' — `editorHealthOk`: the control port answers /api/health,
|
|
38
|
+
* so the editor is serving its project. Nothing to heal.
|
|
39
|
+
* 'launcher-strand'— container up, control port has NO listener, AND a
|
|
40
|
+
* codeyam-editor process is serving the Project Launcher on
|
|
41
|
+
* an ephemeral port. THIS is the strand: :14199 is dark but
|
|
42
|
+
* the editor is alive on the wrong port.
|
|
43
|
+
* 'down' — container up, control port dark, and NO launcher process
|
|
44
|
+
* either: a genuine outage (crashed editor / dead tunnel).
|
|
45
|
+
* Left to the existing unreachable handling — must NOT
|
|
46
|
+
* auto-rebuild, a rebuild is the wrong hammer for a crash.
|
|
47
|
+
* 'unknown' — anything else (container not up, probe failed): absence
|
|
48
|
+
* of evidence, never auto-acts (mirrors
|
|
49
|
+
* classifyPublicUrlHealth's 'unknown' contract).
|
|
50
|
+
*
|
|
51
|
+
* `editorHealthOk` wins first: once the control port answers, the VM is serving
|
|
52
|
+
* no matter what else a slow/racy probe reported. */
|
|
53
|
+
function classifyLauncherStrand({
|
|
54
|
+
controlPortListening,
|
|
55
|
+
containerUp,
|
|
56
|
+
launcherServing,
|
|
57
|
+
editorHealthOk,
|
|
58
|
+
} = {}) {
|
|
59
|
+
if (editorHealthOk) return 'serving';
|
|
60
|
+
if (containerUp && !controlPortListening && launcherServing) return 'launcher-strand';
|
|
61
|
+
if (containerUp && !controlPortListening && !launcherServing) return 'down';
|
|
62
|
+
return 'unknown';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Decide whether a just-landed strand verdict warrants an auto-recovery, the
|
|
66
|
+
* PURE gate the standing self-heal fires through — debounced exactly like
|
|
67
|
+
* shouldAutoRewireIngress so a recovery that doesn't immediately clear can't
|
|
68
|
+
* loop and a single transient poll during a legitimate restart can't act:
|
|
69
|
+
* - `verdict` is 'launcher-strand' (only the strand self-heals; 'down' is a
|
|
70
|
+
* genuine outage the unreachable path owns, 'serving'/'unknown' need nothing),
|
|
71
|
+
* - `strandStreak` has reached `minStreak` consecutive strand polls (a single
|
|
72
|
+
* transient dark-port poll during a restart must not trigger a heavy rebuild),
|
|
73
|
+
* - no recovery is `alreadyRecovering` (one heavy job per VM), AND
|
|
74
|
+
* - the `cooldownMs` window has elapsed since `lastRecoverAt` so a recovery
|
|
75
|
+
* still settling can't re-fire every slow cycle.
|
|
76
|
+
* `hasSession` deliberately does NOT gate: a stranded editor has no live project
|
|
77
|
+
* session to protect (same rationale as the post-reset force=true fallback). A
|
|
78
|
+
* missing/0 `lastRecoverAt` means "never recovered" → the cooldown is satisfied. */
|
|
79
|
+
function shouldAutoRecoverStrand({
|
|
80
|
+
verdict,
|
|
81
|
+
strandStreak,
|
|
82
|
+
alreadyRecovering,
|
|
83
|
+
lastRecoverAt,
|
|
84
|
+
now,
|
|
85
|
+
cooldownMs,
|
|
86
|
+
minStreak = 2,
|
|
87
|
+
} = {}) {
|
|
88
|
+
if (verdict !== 'launcher-strand') return false;
|
|
89
|
+
if (alreadyRecovering) return false;
|
|
90
|
+
if (Number(strandStreak) < Number(minStreak)) return false;
|
|
91
|
+
return (Number(now) - (Number(lastRecoverAt) || 0)) >= Number(cooldownMs);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Choose the recovery SHAPE for a launcher-strand by binary currency. A plain
|
|
95
|
+
* reconfigure KEEPS the editor image, so recreating the container with the SAME
|
|
96
|
+
* pre-guard binary would immediately re-strand — the wrong hammer when a stale
|
|
97
|
+
* binary caused the strand. Returns:
|
|
98
|
+
* 'rebuild' — advance source to origin tip → rebuild-self → restart (the
|
|
99
|
+
* update-reset-everything shape) when the binary is stale OR
|
|
100
|
+
* its currency is UNKNOWN. The dark-control-port strand can't
|
|
101
|
+
* advertise its capability live, and re-stranding on a kept
|
|
102
|
+
* image is the worse failure, so unknown defaults to rebuild.
|
|
103
|
+
* 'reconfigure' — image-kept container recreate, correct ONLY when the running
|
|
104
|
+
* binary is already CURRENT (e.g. a wiped-pointer strand on a
|
|
105
|
+
* fresh binary), where a rebuild would be a needless ~8-min build.
|
|
106
|
+
* `binaryCurrent === true` is the only input that yields 'reconfigure'; null /
|
|
107
|
+
* undefined / false all yield 'rebuild'. */
|
|
108
|
+
function strandRecoveryAction({ binaryCurrent } = {}) {
|
|
109
|
+
return binaryCurrent === true ? 'reconfigure' : 'rebuild';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Classify whether a STILL-SERVING VM's running binary is at risk of the
|
|
113
|
+
* launcher-strand on its next re-exec — the PROACTIVE convergence signal (step
|
|
114
|
+
* 5), distinct from the strand verdict above (which fires only once a VM is
|
|
115
|
+
* ALREADY dark). Whether a binary is guard-capable is answered most robustly by
|
|
116
|
+
* the editor advertising the capability (the `bootGuards` array on the
|
|
117
|
+
* health/session-info payload it already serves) rather than the dashboard
|
|
118
|
+
* guessing from commit ancestry:
|
|
119
|
+
* - `bootGuards` IS an array → authoritative: stale iff it lacks the guard.
|
|
120
|
+
* - `bootGuards` ABSENT (an older binary that predates the field — which are
|
|
121
|
+
* exactly the at-risk ones) → fall back to the build-commit comparison the
|
|
122
|
+
* dashboard already computes: flag stale only when the editor source is
|
|
123
|
+
* `editorBehind > 0` commits behind origin, so a current binary that merely
|
|
124
|
+
* predates the FIELD isn't falsely flagged while a genuinely-behind one is.
|
|
125
|
+
* Returns `{ stale, reason }`; `reason` is null when not stale. */
|
|
126
|
+
function classifyBinaryGuard({ bootGuards, editorBehind, requiredGuard = SERVE_CWD_GUARD } = {}) {
|
|
127
|
+
if (Array.isArray(bootGuards)) {
|
|
128
|
+
if (bootGuards.includes(requiredGuard)) return { stale: false, reason: null };
|
|
129
|
+
return {
|
|
130
|
+
stale: true,
|
|
131
|
+
reason: `running binary advertises bootGuards [${bootGuards.join(', ')}] without '${requiredGuard}'; rebuild to converge`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const behind = Number(editorBehind);
|
|
135
|
+
if (Number.isFinite(behind) && behind > 0) {
|
|
136
|
+
return {
|
|
137
|
+
stale: true,
|
|
138
|
+
reason: `running binary predates the boot-guard capability and editor source is ${behind} commit(s) behind origin; rebuild to converge`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { stale: false, reason: null };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
SERVE_CWD_GUARD,
|
|
146
|
+
classifyLauncherStrand,
|
|
147
|
+
shouldAutoRecoverStrand,
|
|
148
|
+
strandRecoveryAction,
|
|
149
|
+
classifyBinaryGuard,
|
|
150
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure stuck / needs-you attention policy for the Fleet Dashboard's per-VM card
|
|
4
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). It answers a
|
|
5
|
+
// single question the existing status label deliberately does NOT: given how
|
|
6
|
+
// long a VM has been parked on its CURRENT workflow step, is it progressing
|
|
7
|
+
// normally, slowing down, or likely STUCK — and if it is merely waiting on a
|
|
8
|
+
// human, has that wait dragged on long enough to flag "needs you"?
|
|
9
|
+
//
|
|
10
|
+
// Why the STEP timer, not the feature timer: the workflow cursor advances
|
|
11
|
+
// (resetting `started_at` in crates/types/src/step.rs) every time a signature
|
|
12
|
+
// command runs, so step-elapsed is the tightest "is it making progress?" proxy
|
|
13
|
+
// we have. A long feature is normal; a long single step is the smell.
|
|
14
|
+
//
|
|
15
|
+
// This is an ADDITIVE overlay — it never changes the building/waiting/idle
|
|
16
|
+
// status label (that stays in fleet-live-status.js). It only decorates a card
|
|
17
|
+
// with a soft 'slow' note, a hard 'stuck' alarm, or a distinct 'needs you'
|
|
18
|
+
// badge. The split mirrors the established dashboard pure-module pattern
|
|
19
|
+
// (fleet-live-status.js, fleet-broker-status.js, fleet-vm-readiness.js):
|
|
20
|
+
// server.js does the Date.now() I/O and passes the verdict to buildVmEnvelope;
|
|
21
|
+
// the browser renders it.
|
|
22
|
+
|
|
23
|
+
const DEFAULT_SLOW_MINUTES = 15;
|
|
24
|
+
const DEFAULT_STUCK_MINUTES = 30;
|
|
25
|
+
const DEFAULT_NEEDS_YOU_MINUTES = 10;
|
|
26
|
+
|
|
27
|
+
// Parse an env var that should hold a positive number of minutes, falling back
|
|
28
|
+
// to `fallback` when it is unset, empty, non-numeric, or non-positive. Keeping
|
|
29
|
+
// this tolerant means a fat-fingered override degrades to the documented
|
|
30
|
+
// default rather than producing a NaN threshold that no elapsed time can cross.
|
|
31
|
+
function minutesFromEnv(raw, fallback) {
|
|
32
|
+
if (raw == null || raw === '') return fallback;
|
|
33
|
+
const n = Number(raw);
|
|
34
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Resolve the three thresholds (in milliseconds) from a process-env-shaped
|
|
39
|
+
// object. Defaults live here so they are documented and unit-testable in one
|
|
40
|
+
// place: slow 15m, stuck 30m, needs-you 10m. Env overrides:
|
|
41
|
+
// FLEET_STUCK_STEP_SLOW_MINUTES, FLEET_STUCK_STEP_STUCK_MINUTES,
|
|
42
|
+
// FLEET_NEEDS_YOU_MINUTES.
|
|
43
|
+
function resolveThresholds(env) {
|
|
44
|
+
const e = env || {};
|
|
45
|
+
return {
|
|
46
|
+
slowMs: minutesFromEnv(e.FLEET_STUCK_STEP_SLOW_MINUTES, DEFAULT_SLOW_MINUTES) * 60_000,
|
|
47
|
+
stuckMs: minutesFromEnv(e.FLEET_STUCK_STEP_STUCK_MINUTES, DEFAULT_STUCK_MINUTES) * 60_000,
|
|
48
|
+
needsYouMs: minutesFromEnv(e.FLEET_NEEDS_YOU_MINUTES, DEFAULT_NEEDS_YOU_MINUTES) * 60_000,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Compute elapsed ms from an ISO timestamp to nowMs, or null when the timestamp
|
|
53
|
+
// is missing / unparseable. A null elapsed is the graceful-degrade path for an
|
|
54
|
+
// older editor whose /api/session-info predates startedAt/featureStartedAt.
|
|
55
|
+
function elapsedMs(iso, nowMs) {
|
|
56
|
+
if (!iso) return null;
|
|
57
|
+
const started = Date.parse(iso);
|
|
58
|
+
if (!Number.isFinite(started)) return null;
|
|
59
|
+
const delta = nowMs - started;
|
|
60
|
+
return delta >= 0 ? delta : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Derive the attention verdict for one VM.
|
|
64
|
+
//
|
|
65
|
+
// deriveAttention({ status, stepStartedAt, awaitingInput, nowMs, thresholds })
|
|
66
|
+
// -> { stepElapsedMs, level, needsYou, needsYouElapsedMs }
|
|
67
|
+
//
|
|
68
|
+
// Rules:
|
|
69
|
+
// - `level` ('ok' | 'slow' | 'stuck') is computed ONLY for an actively-
|
|
70
|
+
// building VM (status === 'building'). Thresholds are inclusive:
|
|
71
|
+
// >= stuckMs -> 'stuck', else >= slowMs -> 'slow', else 'ok'.
|
|
72
|
+
// - A VM awaiting input (status === 'waiting' or awaitingInput true) never
|
|
73
|
+
// reads 'slow'/'stuck' — it is legitimately blocked on a human. Instead
|
|
74
|
+
// `needsYou` flips true once its elapsed >= needsYouMs.
|
|
75
|
+
// - Missing/unparseable stepStartedAt degrades to stepElapsedMs:null,
|
|
76
|
+
// level:'ok', needsYou:false (older editor, no crash).
|
|
77
|
+
function deriveAttention(opts) {
|
|
78
|
+
const o = opts || {};
|
|
79
|
+
const thresholds = o.thresholds || resolveThresholds({});
|
|
80
|
+
const nowMs = o.nowMs;
|
|
81
|
+
const stepElapsed = elapsedMs(o.stepStartedAt, nowMs);
|
|
82
|
+
const awaiting = o.status === 'waiting' || !!o.awaitingInput;
|
|
83
|
+
|
|
84
|
+
let level = 'ok';
|
|
85
|
+
let needsYou = false;
|
|
86
|
+
let needsYouElapsedMs = null;
|
|
87
|
+
|
|
88
|
+
if (awaiting) {
|
|
89
|
+
// Waiting on a human — never "stuck"; flag "needs you" past its threshold.
|
|
90
|
+
needsYouElapsedMs = stepElapsed;
|
|
91
|
+
needsYou = stepElapsed != null && stepElapsed >= thresholds.needsYouMs;
|
|
92
|
+
} else if (o.status === 'building' && stepElapsed != null) {
|
|
93
|
+
if (stepElapsed >= thresholds.stuckMs) level = 'stuck';
|
|
94
|
+
else if (stepElapsed >= thresholds.slowMs) level = 'slow';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
stepElapsedMs: stepElapsed,
|
|
99
|
+
level,
|
|
100
|
+
needsYou,
|
|
101
|
+
needsYouElapsedMs,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
deriveAttention,
|
|
107
|
+
resolveThresholds,
|
|
108
|
+
DEFAULT_SLOW_MINUTES,
|
|
109
|
+
DEFAULT_STUCK_MINUTES,
|
|
110
|
+
DEFAULT_NEEDS_YOU_MINUTES,
|
|
111
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-vm-access.js — the per-VM IAP access whitelist sidecar
|
|
4
|
+
// (`.codeyam/logs/fleet-vm-access.json`). It records which principals may reach
|
|
5
|
+
// a specific fleet VM's IAP-secured surfaces, keyed by VM number:
|
|
6
|
+
// `{ "<N>": ["user:a@x.com", "group:team@x.com", "domain:partner.com"], ... }`.
|
|
7
|
+
//
|
|
8
|
+
// Why a sidecar and not the roster: the roster (`npm/fleet-roster.js`) is a flat
|
|
9
|
+
// integer array with a hard "sorted, de-duped set of integers" invariant bash
|
|
10
|
+
// consumers depend on. Access is a per-VM list, a different shape, so it lives in
|
|
11
|
+
// a SEPARATE map fronted by a read+write seam modeled exactly on
|
|
12
|
+
// npm/fleet-vm-slugs.js (atomic temp-file + rename, tolerant of a missing or
|
|
13
|
+
// malformed file). The slug sidecar maps a VM number to one hostname label; this
|
|
14
|
+
// one maps a VM number to an access list — same persistence contract, sibling
|
|
15
|
+
// concern.
|
|
16
|
+
//
|
|
17
|
+
// IAP access is granted at the resource level on a VM's per-surface backend
|
|
18
|
+
// services (`vm-<N>-<surface>-bes`); this file is only the source-of-truth list,
|
|
19
|
+
// the gcloud binding is applied by scripts/gcp/iap-access.sh. The owner's
|
|
20
|
+
// project-wide grant (scripts/gcp/google-ingress.sh) is independent — these lists
|
|
21
|
+
// only ADD collaborators to specific VMs.
|
|
22
|
+
//
|
|
23
|
+
// Env override: `VM_ACCESS_FILE` relocates the file (mirrors fleet-vm-slugs.js's
|
|
24
|
+
// `VM_SLUGS_FILE`); resolved once at module load.
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const { rootDir } = require('./utils');
|
|
30
|
+
const { normalizeVmNumber } = require('./vm-urls');
|
|
31
|
+
|
|
32
|
+
const VM_ACCESS_FILE = process.env.VM_ACCESS_FILE
|
|
33
|
+
|| path.join(rootDir, '.codeyam', 'logs', 'fleet-vm-access.json');
|
|
34
|
+
|
|
35
|
+
// The IAM principal types IAP accepts on a backend-service binding. A bare email
|
|
36
|
+
// (no type prefix) defaults to `user:`.
|
|
37
|
+
const PRINCIPAL_TYPES = Object.freeze(['user', 'group', 'domain']);
|
|
38
|
+
|
|
39
|
+
// A DNS domain: at least two dot-separated labels, each a lowercase
|
|
40
|
+
// letters/digits/hyphen label (no leading/trailing hyphen). Used to validate a
|
|
41
|
+
// `domain:` principal and the domain half of a `user:`/`group:` email — a typo'd
|
|
42
|
+
// principal must fail loud before reaching gcloud.
|
|
43
|
+
const DOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
44
|
+
|
|
45
|
+
/** Validate + normalize one IAM principal into the `<type>:<value>` form gcloud
|
|
46
|
+
* expects. Accepts `user:a@x.com`, `group:team@x.com`, `domain:x.com`; a bare
|
|
47
|
+
* `a@x.com` defaults to `user:`. Rejects (loudly) a malformed value so junk
|
|
48
|
+
* never reaches `gcloud iap web add-iam-policy-binding` — the same
|
|
49
|
+
* fail-at-the-boundary posture as normalizeVmSlug. Returns the lowercased,
|
|
50
|
+
* trimmed, type-prefixed principal. */
|
|
51
|
+
function normalizePrincipal(raw) {
|
|
52
|
+
if (raw == null) throw new Error('access principal is required');
|
|
53
|
+
const trimmed = String(raw).trim();
|
|
54
|
+
if (trimmed === '') throw new Error('access principal must not be empty');
|
|
55
|
+
const lower = trimmed.toLowerCase();
|
|
56
|
+
const colon = lower.indexOf(':');
|
|
57
|
+
const hasType = colon !== -1 && PRINCIPAL_TYPES.includes(lower.slice(0, colon));
|
|
58
|
+
const type = hasType ? lower.slice(0, colon) : 'user';
|
|
59
|
+
const value = hasType ? lower.slice(colon + 1) : lower;
|
|
60
|
+
if (value === '') {
|
|
61
|
+
throw new Error(`access principal "${raw}" has an empty value after "${type}:"`);
|
|
62
|
+
}
|
|
63
|
+
if (type === 'domain') {
|
|
64
|
+
if (value.includes('@') || !DOMAIN_RE.test(value)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`access principal "${raw}" is not a valid domain (e.g. domain:example.com)`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
const at = value.indexOf('@');
|
|
71
|
+
if (at <= 0 || value.indexOf('@', at + 1) !== -1) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`access principal "${raw}" is not a valid ${type} email ` +
|
|
74
|
+
`(exactly one "@", e.g. ${type}:a@example.com)`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const domain = value.slice(at + 1);
|
|
78
|
+
if (!DOMAIN_RE.test(domain)) {
|
|
79
|
+
throw new Error(`access principal "${raw}" has an invalid email domain "${domain}"`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return `${type}:${value}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Normalize a whole access list — an array of principals or a comma-separated
|
|
86
|
+
* string — into a deduped, validated array of `<type>:<value>` principals.
|
|
87
|
+
* Blank entries are skipped; a single malformed entry throws (the list is
|
|
88
|
+
* rejected as a whole rather than silently dropping the bad one). Order of first
|
|
89
|
+
* appearance is preserved. */
|
|
90
|
+
function normalizeAccessList(input) {
|
|
91
|
+
const raw = Array.isArray(input)
|
|
92
|
+
? input
|
|
93
|
+
: String(input == null ? '' : input).split(',');
|
|
94
|
+
const out = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const item of raw) {
|
|
97
|
+
const s = String(item).trim();
|
|
98
|
+
if (s === '') continue;
|
|
99
|
+
const principal = normalizePrincipal(s);
|
|
100
|
+
if (!seen.has(principal)) {
|
|
101
|
+
seen.add(principal);
|
|
102
|
+
out.push(principal);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Read the access sidecar, returning a `{ [vmNumber]: principal[] }` object, or
|
|
109
|
+
// null if the file is missing or malformed (treated as "no access lists"). Keys
|
|
110
|
+
// are the string form JSON emits; callers look them up by `String(n)`.
|
|
111
|
+
function readAccess() {
|
|
112
|
+
try {
|
|
113
|
+
const data = JSON.parse(fs.readFileSync(VM_ACCESS_FILE, 'utf8'));
|
|
114
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) return data;
|
|
115
|
+
} catch { /* missing or malformed */ }
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Persist `map` as the sidecar, atomically: write a pid-stamped temp file then
|
|
120
|
+
// `rename` it over the target so a concurrent reader never observes a
|
|
121
|
+
// half-written file (mirrors fleet-vm-slugs.js::writeSlugs). Returns the written
|
|
122
|
+
// map.
|
|
123
|
+
function writeAccess(map) {
|
|
124
|
+
const next = map && typeof map === 'object' && !Array.isArray(map) ? map : {};
|
|
125
|
+
fs.mkdirSync(path.dirname(VM_ACCESS_FILE), { recursive: true });
|
|
126
|
+
const tmp = `${VM_ACCESS_FILE}.${process.pid}.tmp`;
|
|
127
|
+
fs.writeFileSync(tmp, JSON.stringify(next));
|
|
128
|
+
fs.renameSync(tmp, VM_ACCESS_FILE); // atomic replace
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The access list for VM `n` — the principals whitelisted onto its surfaces —
|
|
133
|
+
* or an empty array when it has none (or the file is absent). The number is
|
|
134
|
+
* validated so junk can't sneak through as a lookup key. */
|
|
135
|
+
function accessFor(n) {
|
|
136
|
+
const num = normalizeVmNumber(n);
|
|
137
|
+
const map = readAccess();
|
|
138
|
+
const list = map && map[String(num)];
|
|
139
|
+
return Array.isArray(list) ? list.filter((p) => typeof p === 'string' && p) : [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Replace VM `n`'s access list wholesale with `principals` (validated + deduped
|
|
143
|
+
* via normalizeAccessList). An empty result removes the VM's entry entirely so
|
|
144
|
+
* the sidecar never accumulates empty arrays. Returns the stored list. */
|
|
145
|
+
function setAccess(n, principals) {
|
|
146
|
+
const num = normalizeVmNumber(n);
|
|
147
|
+
const list = normalizeAccessList(principals);
|
|
148
|
+
const map = { ...(readAccess() || {}) };
|
|
149
|
+
if (list.length === 0) delete map[String(num)];
|
|
150
|
+
else map[String(num)] = list;
|
|
151
|
+
writeAccess(map);
|
|
152
|
+
return list;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Add a single principal to VM `n`'s access list (validated + deduped). Adding
|
|
156
|
+
* one already present is an idempotent no-op-shaped write. Returns the new list. */
|
|
157
|
+
function addAccess(n, principal) {
|
|
158
|
+
const num = normalizeVmNumber(n);
|
|
159
|
+
const p = normalizePrincipal(principal);
|
|
160
|
+
const next = accessFor(num);
|
|
161
|
+
if (!next.includes(p)) next.push(p);
|
|
162
|
+
const map = { ...(readAccess() || {}) };
|
|
163
|
+
map[String(num)] = next;
|
|
164
|
+
writeAccess(map);
|
|
165
|
+
return next;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Remove a single principal from VM `n`'s access list. Removing an absent
|
|
169
|
+
* principal is a no-op; emptying the list removes the VM's entry. Returns the
|
|
170
|
+
* new list. */
|
|
171
|
+
function removeAccess(n, principal) {
|
|
172
|
+
const num = normalizeVmNumber(n);
|
|
173
|
+
const p = normalizePrincipal(principal);
|
|
174
|
+
const next = accessFor(num).filter((x) => x !== p);
|
|
175
|
+
const map = { ...(readAccess() || {}) };
|
|
176
|
+
if (next.length === 0) delete map[String(num)];
|
|
177
|
+
else map[String(num)] = next;
|
|
178
|
+
writeAccess(map);
|
|
179
|
+
return next;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Parse the raw stdout of `gcloud projects get-iam-policy ...
|
|
183
|
+
* --format='value(bindings.members)'` into a deduped, trimmed array of
|
|
184
|
+
* principals. gcloud separates members within a binding by `;` and (when not
|
|
185
|
+
* flattened) bindings by newline, so we split on BOTH. Order of first
|
|
186
|
+
* appearance is preserved.
|
|
187
|
+
*
|
|
188
|
+
* This is deliberately NOT normalizePrincipal: it is a *read* of whatever IAM
|
|
189
|
+
* already holds, so it must surface principals verbatim (lowercased only for
|
|
190
|
+
* dedup-stability) — including `serviceAccount:` members that the strict
|
|
191
|
+
* write-path validator would reject. The one thing it drops, besides blanks, is
|
|
192
|
+
* gcloud's `deleted:` tombstone members (e.g.
|
|
193
|
+
* `deleted:user:x@x.com?uid=123`), which name principals that no longer exist
|
|
194
|
+
* and can't be acted on. */
|
|
195
|
+
function parseProjectMembers(stdout) {
|
|
196
|
+
const out = [];
|
|
197
|
+
const seen = new Set();
|
|
198
|
+
for (const raw of String(stdout == null ? '' : stdout).split(/[;\n]/)) {
|
|
199
|
+
const s = raw.trim();
|
|
200
|
+
if (s === '') continue;
|
|
201
|
+
if (s.toLowerCase().startsWith('deleted:')) continue;
|
|
202
|
+
const principal = s.toLowerCase();
|
|
203
|
+
if (!seen.has(principal)) {
|
|
204
|
+
seen.add(principal);
|
|
205
|
+
out.push(principal);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Drop VM `n`'s entire access list, returning the new map. Clearing an absent
|
|
212
|
+
* entry is a no-op. Called on Destroy so a number freed and later re-used
|
|
213
|
+
* doesn't inherit a stale whitelist. */
|
|
214
|
+
function clearAccess(n) {
|
|
215
|
+
const num = normalizeVmNumber(n);
|
|
216
|
+
const map = { ...(readAccess() || {}) };
|
|
217
|
+
delete map[String(num)];
|
|
218
|
+
return writeAccess(map);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
VM_ACCESS_FILE,
|
|
223
|
+
PRINCIPAL_TYPES,
|
|
224
|
+
normalizePrincipal,
|
|
225
|
+
normalizeAccessList,
|
|
226
|
+
parseProjectMembers,
|
|
227
|
+
readAccess,
|
|
228
|
+
writeAccess,
|
|
229
|
+
accessFor,
|
|
230
|
+
setAccess,
|
|
231
|
+
addAccess,
|
|
232
|
+
removeAccess,
|
|
233
|
+
clearAccess,
|
|
234
|
+
};
|