@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.
Files changed (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1415 -73
  3. package/npm/container.js +3 -0
  4. package/npm/disk-pressure.js +78 -0
  5. package/npm/editor-dev.js +96 -15
  6. package/npm/editor.js +85 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +51 -2
  10. package/npm/fleet-cleanup.js +118 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +1 -1
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +135 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +43 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +204 -3
  35. package/npm/scenario-check.js +603 -32
  36. package/npm/scenario-handlers.js +78 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +329 -50
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-BuK0lizt.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  61. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-BGqairFV.css +0 -1
  66. 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
+ };