@codeyam-editor/codeyam-editor 0.1.1 → 0.1.2
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 -2
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +1521 -0
- package/npm/container.js +22 -3
- package/npm/editor-dev.js +286 -36
- package/npm/editor.js +98 -35
- package/npm/empty-project-dir.js +33 -0
- package/npm/fleet-auth.js +111 -0
- package/npm/fleet-cleanup.js +124 -0
- package/npm/fleet-env.js +125 -0
- package/npm/fleet-memory.js +127 -0
- package/npm/fleet-pool-policy.js +78 -0
- package/npm/fleet-pool.js +401 -0
- package/npm/fleet-queue.js +40 -0
- package/npm/fleet-reconcile.js +36 -0
- package/npm/fleet-reset.js +144 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +544 -0
- package/npm/preflight-guidance.js +68 -0
- package/npm/scenario-check.js +298 -14
- package/npm/scenario-handlers.js +16 -5
- package/npm/scenario-interactivity.js +199 -0
- package/npm/scenario-metrics.js +16 -2
- package/npm/scenario-mocks.js +62 -25
- package/npm/scenario-playwright.js +169 -6
- package/npm/utils.js +507 -21
- package/npm/vm-urls.js +117 -0
- package/package.json +8 -7
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-BUYNFqKK.js +27 -0
- package/ui/dist/assets/index-DVr5rY2d.css +1 -0
- package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
- package/ui/dist/assets/react-CSS0HapR.js +28 -0
- package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
- package/ui/dist/assets/xterm--24IGk-x.js +9 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.png +0 -0
- package/ui/dist/index.html +16 -0
- package/npm/postinstall.js +0 -132
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure helpers for the fleet dashboard's "Authenticate idle VMs" batch flow
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). The decision of
|
|
4
|
+
// WHICH VMs a fleet-auth pass touches — and how to summarize an in-flight
|
|
5
|
+
// batch — lives here so server.js's HTTP orchestration stays thin and the
|
|
6
|
+
// guardrail logic is unit-testable in isolation (npm/fleet-auth.test.js).
|
|
7
|
+
//
|
|
8
|
+
// Why per-VM real `claude /login` and not one shared credential: copying a
|
|
9
|
+
// `.credentials.json` between VMs 401s (Claude rotates/binds the OAuth access
|
|
10
|
+
// token to its originating VM), and the `CLAUDE_CODE_OAUTH_TOKEN` setup-token
|
|
11
|
+
// is limited-scope + non-durable (lost on container recreate). So every VM
|
|
12
|
+
// needs its own interactive login — this batches the human step across the
|
|
13
|
+
// fleet (start every idle VM's login in parallel, approve them as one list)
|
|
14
|
+
// instead of distributing a single token. See docs/cloud-editor-stability.md
|
|
15
|
+
// "Fleet auth".
|
|
16
|
+
|
|
17
|
+
// Classify one VM for the fleet-auth pass. Returns { eligible, reason }.
|
|
18
|
+
//
|
|
19
|
+
// The guardrail mirrors Destroy / Reconfigure / roll: NEVER touch an active
|
|
20
|
+
// session (a mid-build agent must not have its editor driven into a login
|
|
21
|
+
// PTY), and never start an auth pass on a VM that already has a running or
|
|
22
|
+
// queued job. Only a reachable, idle VM the editor reports as `needs-login` is
|
|
23
|
+
// a target. An `unknown` / absent auth state is deliberately NOT a target —
|
|
24
|
+
// the probe is indeterminate (a transient failure on an otherwise-healthy VM),
|
|
25
|
+
// and driving a login we can't confirm is needed risks disrupting a working
|
|
26
|
+
// VM. `v` is the dashboard's per-VM poll state (`state.vms[n]`); `job` is
|
|
27
|
+
// `state.jobs[n]` (may be undefined).
|
|
28
|
+
function classifyAuthVm(v, job) {
|
|
29
|
+
if (job && (job.state === 'running' || job.state === 'queued')) {
|
|
30
|
+
return { eligible: false, reason: 'busy' };
|
|
31
|
+
}
|
|
32
|
+
if (!v || !v.reachable) return { eligible: false, reason: 'unreachable' };
|
|
33
|
+
if (v.hasSession) return { eligible: false, reason: 'active-session' };
|
|
34
|
+
const state = v.providerAuth && v.providerAuth.state;
|
|
35
|
+
if (state === 'authenticated') return { eligible: false, reason: 'already-authenticated' };
|
|
36
|
+
if (state === 'needs-login') return { eligible: true, reason: 'needs-login' };
|
|
37
|
+
return { eligible: false, reason: 'auth-state-unknown' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Select every VM the fleet-auth pass should drive a login on, plus the
|
|
41
|
+
// skipped VMs each with the reason it was excluded (surfaced to the operator
|
|
42
|
+
// so "why didn't VM-7 get authenticated?" is answerable without ssh).
|
|
43
|
+
//
|
|
44
|
+
// roster: number[] (the live VMS array). vms / jobs: maps keyed by VM number.
|
|
45
|
+
// Returns { targets: number[], skipped: [{ n, reason }] }, both sorted by n.
|
|
46
|
+
function selectAuthTargets({ roster, vms, jobs }) {
|
|
47
|
+
const targets = [];
|
|
48
|
+
const skipped = [];
|
|
49
|
+
for (const n of [...(roster || [])].sort((a, b) => a - b)) {
|
|
50
|
+
const c = classifyAuthVm((vms || {})[n], (jobs || {})[n]);
|
|
51
|
+
if (c.eligible) targets.push(n);
|
|
52
|
+
else skipped.push({ n, reason: c.reason });
|
|
53
|
+
}
|
|
54
|
+
return { targets, skipped };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Aggregate an in-flight batch (map of n → { phase }) into counts for the
|
|
58
|
+
// operator report. Phases mirror the editor's /api/auth/status states
|
|
59
|
+
// ('starting' | 'url-ready' | 'authenticated' | 'failed') plus the
|
|
60
|
+
// dashboard-side 'submitting' while a pasted code is being delivered. Pure
|
|
61
|
+
// over the values; `done` is true once no VM is still mid-flight, which is
|
|
62
|
+
// what lets the UI stop polling / show a final "N of M authenticated" line.
|
|
63
|
+
function summarizeAuthBatch(batchVms) {
|
|
64
|
+
const counts = { total: 0, starting: 0, urlReady: 0, submitting: 0, authenticated: 0, failed: 0 };
|
|
65
|
+
for (const n of Object.keys(batchVms || {})) {
|
|
66
|
+
const phase = batchVms[n] && batchVms[n].phase;
|
|
67
|
+
counts.total++;
|
|
68
|
+
if (phase === 'starting') counts.starting++;
|
|
69
|
+
else if (phase === 'url-ready') counts.urlReady++;
|
|
70
|
+
else if (phase === 'submitting') counts.submitting++;
|
|
71
|
+
else if (phase === 'authenticated') counts.authenticated++;
|
|
72
|
+
else if (phase === 'failed') counts.failed++;
|
|
73
|
+
}
|
|
74
|
+
// In-flight = still starting, or awaiting a pasted code, or submitting one.
|
|
75
|
+
// A `failed` VM is terminal until the operator restarts the batch, and an
|
|
76
|
+
// `authenticated` VM is done — so neither counts as in-flight.
|
|
77
|
+
counts.inFlight = counts.starting + counts.urlReady + counts.submitting;
|
|
78
|
+
counts.done = counts.total > 0 && counts.inFlight === 0;
|
|
79
|
+
return counts;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Derive the fleet's current default Claude account (improve38): the account a
|
|
83
|
+
// newly launched VM should authenticate as. Defined as the account of the
|
|
84
|
+
// rostered VM whose auth was updated most recently, where "auth-update time" =
|
|
85
|
+
// max(dashboard-recorded login-completion `lastAuthAt[n]`, the editor-reported
|
|
86
|
+
// `providerAuth.lastRefreshAt`). Re-authenticating ANY existing VM thus
|
|
87
|
+
// re-points the default. Pure over the polled VM snapshot + the lastAuthAt map.
|
|
88
|
+
//
|
|
89
|
+
// Returns the winning VM's account object ({ email, organizationName,
|
|
90
|
+
// organizationType, subscriptionType }), or null when no rostered VM reports an
|
|
91
|
+
// account. Ties — and the no-timestamp case — resolve to the first such VM in
|
|
92
|
+
// `vms` order, so the result is deterministic.
|
|
93
|
+
function deriveDefaultAuthAccount(vms, lastAuthAt) {
|
|
94
|
+
const at = lastAuthAt || {};
|
|
95
|
+
let best = null;
|
|
96
|
+
for (const v of vms || []) {
|
|
97
|
+
const pa = v && v.providerAuth;
|
|
98
|
+
const acct = pa && pa.account;
|
|
99
|
+
if (!acct || !acct.email) continue;
|
|
100
|
+
const refreshMs = pa.lastRefreshAt ? Date.parse(pa.lastRefreshAt) : NaN;
|
|
101
|
+
const loginMs = at[v.n] ? Date.parse(at[v.n]) : NaN;
|
|
102
|
+
const score = Math.max(
|
|
103
|
+
Number.isFinite(refreshMs) ? refreshMs : -Infinity,
|
|
104
|
+
Number.isFinite(loginMs) ? loginMs : -Infinity,
|
|
105
|
+
);
|
|
106
|
+
if (!best || score > best.score) best = { account: acct, score };
|
|
107
|
+
}
|
|
108
|
+
return best ? best.account : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure-ish helpers for the fleet dashboard's pending-job sidecar feature
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). Sidecar
|
|
4
|
+
// serialization, regex parsing of filenames, gcloud-describe error
|
|
5
|
+
// classification, and the reap decision matrix all live here so the
|
|
6
|
+
// `server.js` orchestration code can be left thin and these pieces can be
|
|
7
|
+
// exercised in isolation (npm/fleet-cleanup.test.js).
|
|
8
|
+
//
|
|
9
|
+
// File-touching helpers take the directory as a PARAMETER so tests pass a
|
|
10
|
+
// throwaway tmpdir; the dashboard passes its fixed `PENDING_JOBS_DIR` constant.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// Sidecar filename grammar: `<action>-<n>.json` where action ∈ {add, destroy}.
|
|
16
|
+
// Returns { action, n } on a valid match, null otherwise. Hoisted out of the
|
|
17
|
+
// inline regex so the parser is unit-testable on its own (junk filenames, tmp
|
|
18
|
+
// turds, missing numbers).
|
|
19
|
+
function parsePendingJobFilename(name) {
|
|
20
|
+
const m = String(name == null ? '' : name).match(/^(add|destroy)-(\d+)\.json$/);
|
|
21
|
+
if (!m) return null;
|
|
22
|
+
return { action: m[1], n: Number(m[2]) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Atomically write a sidecar marking VM-n's <action> in flight. Best-effort:
|
|
26
|
+
// any I/O failure is logged, never thrown (the dashboard mustn't crash because
|
|
27
|
+
// .codeyam/logs is missing or read-only).
|
|
28
|
+
function persistPendingJob(dir, n, action, meta) {
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
const f = path.join(dir, `${action}-${n}.json`);
|
|
32
|
+
const tmp = f + '.tmp';
|
|
33
|
+
fs.writeFileSync(tmp, JSON.stringify({ n, action, startedAt: new Date().toISOString(), ...(meta || {}) }));
|
|
34
|
+
fs.renameSync(tmp, f);
|
|
35
|
+
} catch (e) { console.error('persistPendingJob:', e && e.message); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Remove a sidecar; a missing file is the already-cleared path, not an error.
|
|
39
|
+
function clearPendingJob(dir, n, action) {
|
|
40
|
+
try { fs.unlinkSync(path.join(dir, `${action}-${n}.json`)); }
|
|
41
|
+
catch (_) { /* missing is fine */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Enumerate sidecars under `dir`. Returns one entry per RECOGNISED file —
|
|
45
|
+
// junk filenames are dropped silently so an operator's debug touch under
|
|
46
|
+
// pending-jobs/ can't crash the reap. Unparseable JSON meta degrades to `{}`
|
|
47
|
+
// (the sidecar's existence is what matters; meta is best-effort context).
|
|
48
|
+
function listPendingJobs(dir) {
|
|
49
|
+
let entries;
|
|
50
|
+
try { entries = fs.readdirSync(dir); } catch { return []; }
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const name of entries) {
|
|
53
|
+
const parsed = parsePendingJobFilename(name);
|
|
54
|
+
if (!parsed) continue;
|
|
55
|
+
let meta = {};
|
|
56
|
+
try { meta = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')) || {}; }
|
|
57
|
+
catch { /* unparseable — treat as bare {action, n} */ }
|
|
58
|
+
out.push({ n: parsed.n, action: parsed.action, meta });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Classify the outcome of a `gcloud compute instances describe` exec callback.
|
|
64
|
+
// - err is null/undefined → instance exists (`true`)
|
|
65
|
+
// - stderr/err.message names "NOT_FOUND" / "was not found" / "not found"
|
|
66
|
+
// → instance is gone (`false`)
|
|
67
|
+
// - anything else (auth glitch, network blip, timeout) → uncertain (`null`)
|
|
68
|
+
// The `null` case is what makes the reap fail-soft: an uncertain probe leaves
|
|
69
|
+
// the sidecar in place so the next dashboard boot retries instead of clearing
|
|
70
|
+
// state on a flaky gcloud.
|
|
71
|
+
function classifyDescribeError(err, stderr) {
|
|
72
|
+
if (!err) return true;
|
|
73
|
+
const out = String(stderr == null ? '' : stderr) + ' ' + String((err && err.message) || '');
|
|
74
|
+
if (/NOT_FOUND|was not found|not found/i.test(out)) return false;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Decide what the startup reap should do for one sidecar. Pure: takes the
|
|
79
|
+
// decoded sidecar + GCP-existence verdict + (for adds) container-up verdict,
|
|
80
|
+
// returns a tag the caller dispatches on.
|
|
81
|
+
// verdict when dispatch
|
|
82
|
+
// 'cleanup' destroy gone, OR add gone runDestroyCleanup / runAddCleanup
|
|
83
|
+
// 'reconcile-add' add up AND container up ensure roster + recordLaunch, then clear
|
|
84
|
+
// 'recover-bootstrap' add instance up but container missing/down re-run cloud:up to finish the half-done bootstrap
|
|
85
|
+
// 'clear-only' destroy sidecar but instance still exists just clear the sidecar
|
|
86
|
+
// 'leave' gcloud uncertain (exists === null) leave for the next boot
|
|
87
|
+
// The (exists, containerUp) split is what distinguishes a finished add from a
|
|
88
|
+
// half-provisioned one: a dashboard SIGKILL'd mid-`cloud:up` leaves a GCP
|
|
89
|
+
// instance with no docker/container, which the old (exists-only) matrix wrongly
|
|
90
|
+
// classified 'reconcile-add' — adding an unreachable VM to the roster.
|
|
91
|
+
// `containerUp` is only consulted for an add whose instance exists; destroy and
|
|
92
|
+
// the instance-gone add path ignore it. Splitting the matrix out lets the table
|
|
93
|
+
// be exhaustively tested without any of the side-effects in server.js.
|
|
94
|
+
function reapVerdict({ action, exists, containerUp }) {
|
|
95
|
+
if (exists === null) return 'leave';
|
|
96
|
+
if (action === 'destroy') return exists ? 'clear-only' : 'cleanup';
|
|
97
|
+
if (action === 'add') {
|
|
98
|
+
if (!exists) return 'cleanup';
|
|
99
|
+
return containerUp ? 'reconcile-add' : 'recover-bootstrap';
|
|
100
|
+
}
|
|
101
|
+
return 'leave';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of
|
|
105
|
+
// recoveries ALREADY persisted on the sidecar, return the next attempt number,
|
|
106
|
+
// whether the reap should proceed (re-run cloud:up) or treat the VM as
|
|
107
|
+
// exhausted (leave the sidecar + surface a needs-manual-recovery card), and the
|
|
108
|
+
// cap. Keeps the off-by-one (prior 3 → attempt 4 → stop) out of server.js so it
|
|
109
|
+
// can be unit-tested without the side-effecting re-run.
|
|
110
|
+
function recoverBootstrapDecision({ recoverAttempts, max = 3 }) {
|
|
111
|
+
const prior = Number(recoverAttempts) || 0;
|
|
112
|
+
const attempt = prior + 1;
|
|
113
|
+
return { attempt, proceed: attempt <= max, max };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
parsePendingJobFilename,
|
|
118
|
+
persistPendingJob,
|
|
119
|
+
clearPendingJob,
|
|
120
|
+
listPendingJobs,
|
|
121
|
+
classifyDescribeError,
|
|
122
|
+
reapVerdict,
|
|
123
|
+
recoverBootstrapDecision,
|
|
124
|
+
};
|
package/npm/fleet-env.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure helpers for the fleet dashboard's per-VM environment-variable feature
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). Parsing,
|
|
4
|
+
// validation, and formatting only — no I/O, no secret logging — so it stays
|
|
5
|
+
// unit-testable in isolation (npm/fleet-env.test.js), exactly like
|
|
6
|
+
// launch-config.js.
|
|
7
|
+
|
|
8
|
+
// A KEY must be a POSIX-ish env identifier: a letter or underscore followed by
|
|
9
|
+
// letters, digits, or underscores. dotenv / Next.js / Prisma all read this
|
|
10
|
+
// shape; rejecting anything else stops a fat-fingered line from writing a
|
|
11
|
+
// half-broken .env into the VM.
|
|
12
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
13
|
+
|
|
14
|
+
// Env-var name the fleet dashboard sets on the `cloud:up` child to name the
|
|
15
|
+
// subset of `process.env` keys that came from the operator-supplied launch
|
|
16
|
+
// form (`.codeyam/logs/vm-env/<n>.env`). cloud:up reads it at
|
|
17
|
+
// project-bootstrap time (`npm/cloud.js::resolveSetupForwardEnv`) and
|
|
18
|
+
// forwards exactly those keys into the VM's bootstrap shell — so the
|
|
19
|
+
// bootstrap-time `npm run setup` has DB env on the FIRST try, instead of
|
|
20
|
+
// the post-add `reapplyVmEnv` race that left `db:push` failing fail-soft.
|
|
21
|
+
// One source of truth, imported on both sides — drift between dashboard
|
|
22
|
+
// and cloud:up would silently disable the forwarding.
|
|
23
|
+
const FORWARD_ENV_KEYS_ENV = "CY_FORWARD_ENV_KEYS";
|
|
24
|
+
|
|
25
|
+
// Parse a `KEY=value`-per-line .env blob into a validated result.
|
|
26
|
+
// - Blank lines are dropped.
|
|
27
|
+
// - `#` comment lines are preserved (they carry no key).
|
|
28
|
+
// - Every other line must be `KEY=value`; only the FIRST `=` splits, so a
|
|
29
|
+
// value may itself contain `=` (e.g. a connection string) or be empty.
|
|
30
|
+
//
|
|
31
|
+
// Returns { ok, keys, content, error }:
|
|
32
|
+
// ok — true when every non-blank/non-comment line parsed
|
|
33
|
+
// keys — ordered declared keys, no values (safe to display / log)
|
|
34
|
+
// content — normalized text to write to .env (trailing newline), '' on error
|
|
35
|
+
// or when there are no keys
|
|
36
|
+
// error — message naming the 1-based line number on failure; it NEVER
|
|
37
|
+
// echoes the line's value, so a malformed secret can't leak
|
|
38
|
+
function parseEnvVars(text) {
|
|
39
|
+
const rawLines = String(text == null ? '' : text).split(/\r?\n/);
|
|
40
|
+
const fail = (error) => ({ ok: false, keys: [], content: '', error });
|
|
41
|
+
const keys = [];
|
|
42
|
+
const out = [];
|
|
43
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
44
|
+
const trimmed = rawLines[i].trim();
|
|
45
|
+
if (trimmed === '') continue; // drop blank lines
|
|
46
|
+
if (trimmed.startsWith('#')) { out.push(trimmed); continue; } // keep comments
|
|
47
|
+
const eq = trimmed.indexOf('=');
|
|
48
|
+
if (eq <= 0) return fail(`malformed env line ${i + 1} (expected KEY=value)`);
|
|
49
|
+
const key = trimmed.slice(0, eq).trim();
|
|
50
|
+
const value = trimmed.slice(eq + 1);
|
|
51
|
+
if (!KEY_RE.test(key)) {
|
|
52
|
+
return fail(`invalid env key on line ${i + 1} (must match [A-Za-z_][A-Za-z0-9_]*)`);
|
|
53
|
+
}
|
|
54
|
+
if (keys.includes(key)) return fail(`duplicate env key "${key}" on line ${i + 1}`);
|
|
55
|
+
keys.push(key);
|
|
56
|
+
out.push(`${key}=${value}`);
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, keys, content: keys.length ? `${out.join('\n')}\n` : '', error: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Keys only, for masked display / pre-fill — never returns values. Best-effort:
|
|
62
|
+
// returns [] for unparseable input rather than throwing.
|
|
63
|
+
function envKeys(text) {
|
|
64
|
+
const r = parseEnvVars(text);
|
|
65
|
+
return r.ok ? r.keys : [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// True when the parsed env-text declares at least one of `triggerKeys`. Used by
|
|
69
|
+
// the fleet dashboard to decide whether to re-fire `npm run setup` after
|
|
70
|
+
// `/workspace/.env` lands in the container: bootstrap-time setup runs BEFORE
|
|
71
|
+
// env injection, so a project whose setup needs DB env (Prisma db:push, etc.)
|
|
72
|
+
// half-completes silently on first launch. Pure: takes the persisted env text
|
|
73
|
+
// + the trigger list and returns a bool — no I/O, the I/O caller passes the
|
|
74
|
+
// already-read text in. Unparseable input → no keys → false (matches envKeys).
|
|
75
|
+
function envHasSetupTrigger(text, triggerKeys) {
|
|
76
|
+
if (!Array.isArray(triggerKeys) || triggerKeys.length === 0) return false;
|
|
77
|
+
const declared = new Set(envKeys(text));
|
|
78
|
+
return triggerKeys.some((k) => declared.has(k));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Parse a `KEY=value`-per-line .env blob into a `{KEY: value}` dict suitable
|
|
82
|
+
// for spreading into a child-process env. Returns null for unparseable input
|
|
83
|
+
// (matches envKeys's best-effort shape so callers can fall back without
|
|
84
|
+
// re-handling parse failures). Comment lines are dropped. The fleet dashboard
|
|
85
|
+
// uses this to read a VM's persisted env file (`.codeyam/logs/vm-env/<n>.env`)
|
|
86
|
+
// at `cloud:up`-launch time so the bootstrap-time `npm run setup` already has
|
|
87
|
+
// DB env on first try — instead of the post-add `reapplyVmEnv` race where
|
|
88
|
+
// setup fails fail-soft for lack of `DATABASE_URL`.
|
|
89
|
+
function envToDict(text) {
|
|
90
|
+
const r = parseEnvVars(text);
|
|
91
|
+
if (!r.ok) return null;
|
|
92
|
+
const dict = {};
|
|
93
|
+
for (const line of r.content.split('\n')) {
|
|
94
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
95
|
+
const eq = line.indexOf('=');
|
|
96
|
+
if (eq <= 0) continue;
|
|
97
|
+
dict[line.slice(0, eq)] = line.slice(eq + 1);
|
|
98
|
+
}
|
|
99
|
+
return dict;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Wrap a parsed launch-form env dict in the cloud:up bootstrap-child-env
|
|
103
|
+
// shape: spreads `envDict` so the keys land in the child's `process.env`, and
|
|
104
|
+
// sets `CY_FORWARD_ENV_KEYS` to the comma-separated key list so
|
|
105
|
+
// `npm/cloud.js::resolveSetupForwardEnv` can whitelist exactly those keys
|
|
106
|
+
// when forwarding into the VM's bootstrap shell. Returns null on empty input
|
|
107
|
+
// so the dashboard's `startJob` call can omit `env` entirely (preserving
|
|
108
|
+
// `process.env` inheritance for the no-launch-form-env path). A non-object
|
|
109
|
+
// (programmer error) also returns null rather than throwing.
|
|
110
|
+
function buildBootstrapChildEnv(envDict) {
|
|
111
|
+
if (!envDict || typeof envDict !== "object") return null;
|
|
112
|
+
const keys = Object.keys(envDict);
|
|
113
|
+
if (keys.length === 0) return null;
|
|
114
|
+
return { ...envDict, [FORWARD_ENV_KEYS_ENV]: keys.join(",") };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
parseEnvVars,
|
|
119
|
+
envKeys,
|
|
120
|
+
envHasSetupTrigger,
|
|
121
|
+
envToDict,
|
|
122
|
+
buildBootstrapChildEnv,
|
|
123
|
+
FORWARD_ENV_KEYS_ENV,
|
|
124
|
+
KEY_RE,
|
|
125
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure helpers for the fleet dashboard's memory-aware drain + state-snapshot
|
|
3
|
+
// recovery (scripts/operator/fleet-dashboard/server.js requires this). The
|
|
4
|
+
// drain threshold check, snapshot envelope serialization, freshness check, and
|
|
5
|
+
// the running→error downgrade applied on restore all live here so server.js's
|
|
6
|
+
// orchestration stays thin and these pieces can be unit-tested in isolation
|
|
7
|
+
// (npm/fleet-memory.test.js).
|
|
8
|
+
//
|
|
9
|
+
// Defense in depth (improve37): the launchd plist caps the dashboard at 2GB
|
|
10
|
+
// RSS; this module's drain refuses NEW heavy jobs at 1.5GB (soft cap) so RSS
|
|
11
|
+
// can recover before the hard cap fires SIGKILL; the snapshot is the recovery
|
|
12
|
+
// rail when the hard cap fires anyway (or launchd reloads). The pure helpers
|
|
13
|
+
// here are the testable surface — server.js wires the I/O (setInterval, file
|
|
14
|
+
// reads/writes, process.memoryUsage()).
|
|
15
|
+
|
|
16
|
+
// Defaults the dashboard can override via env. Kept here so a test can assert
|
|
17
|
+
// against the same numbers the dashboard ships with.
|
|
18
|
+
const MEMORY_DEFAULTS = Object.freeze({
|
|
19
|
+
drainThresholdMB: 1500,
|
|
20
|
+
drainCheckIntervalMs: 5000,
|
|
21
|
+
snapshotIntervalMs: 30_000,
|
|
22
|
+
snapshotMaxAgeMs: 5 * 60 * 1000,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Should the dashboard refuse to admit new heavy jobs?
|
|
26
|
+
*
|
|
27
|
+
* Returns true when current RSS exceeds the soft drain threshold. A non-
|
|
28
|
+
* positive or non-finite threshold disables the drain (returns false) — a
|
|
29
|
+
* misconfigured env var degrades to "no drain" instead of silently rejecting
|
|
30
|
+
* every heavy job. Bytes on both sides so the caller doesn't have to pick a
|
|
31
|
+
* unit; server.js passes MB→bytes once at module init.
|
|
32
|
+
*
|
|
33
|
+
* Pure; no I/O. */
|
|
34
|
+
function decideMemoryDrain({ rssBytes, drainThresholdBytes } = {}) {
|
|
35
|
+
const rss = Number(rssBytes);
|
|
36
|
+
const cap = Number(drainThresholdBytes);
|
|
37
|
+
if (!Number.isFinite(cap) || cap <= 0) return false;
|
|
38
|
+
if (!Number.isFinite(rss) || rss < 0) return false;
|
|
39
|
+
return rss > cap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build the serialized state snapshot envelope.
|
|
43
|
+
*
|
|
44
|
+
* Keeps only the fields the dashboard needs to recover after a launchd
|
|
45
|
+
* respawn: in-flight jobs, last VM poll merge, operator-visible orphans.
|
|
46
|
+
* Times come from the caller so the same input yields the same output
|
|
47
|
+
* (snapshot equality testable). Pure; no I/O. */
|
|
48
|
+
function buildStateSnapshot({ jobs, vms, orphans, nowIso }) {
|
|
49
|
+
return {
|
|
50
|
+
version: 1,
|
|
51
|
+
timestamp: nowIso,
|
|
52
|
+
jobs: jobs || {},
|
|
53
|
+
vms: vms || {},
|
|
54
|
+
orphans: orphans || {},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Parse a buffer/string into a snapshot, or return null on any failure.
|
|
59
|
+
* Best-effort: a corrupt or partial file (mid-write crash) is silently
|
|
60
|
+
* treated as "no snapshot", which is the safer default than throwing on
|
|
61
|
+
* boot. */
|
|
62
|
+
function decodeStateSnapshot(buf) {
|
|
63
|
+
if (buf == null) return null;
|
|
64
|
+
try {
|
|
65
|
+
const obj = JSON.parse(typeof buf === 'string' ? buf : buf.toString('utf8'));
|
|
66
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
67
|
+
if (typeof obj.timestamp !== 'string') return null;
|
|
68
|
+
return obj;
|
|
69
|
+
} catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Reconcile a restored snapshot into a usable starting state.
|
|
73
|
+
*
|
|
74
|
+
* Returns null when the snapshot is missing, malformed, or older than
|
|
75
|
+
* maxAgeMs (stale snapshots are worse than no snapshot — pollVMs will
|
|
76
|
+
* repopulate from real data). Otherwise returns { jobs, vms, orphans }
|
|
77
|
+
* with two safety transforms applied to jobs:
|
|
78
|
+
*
|
|
79
|
+
* • `running` → `error` with a log marker, because the child process
|
|
80
|
+
* died with the dashboard; we can't possibly have a live job tracker
|
|
81
|
+
* after a process restart.
|
|
82
|
+
* • `queued` → dropped, because the in-memory pendingJobQueue array
|
|
83
|
+
* is gone, so promoteFromJobQueue can never start them. The operator
|
|
84
|
+
* will re-click (the queued click had done no work yet).
|
|
85
|
+
*
|
|
86
|
+
* Pure; no I/O. */
|
|
87
|
+
function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso } = {}) {
|
|
88
|
+
if (!snapshot || typeof snapshot !== 'object') return null;
|
|
89
|
+
if (typeof snapshot.timestamp !== 'string') return null;
|
|
90
|
+
const ts = Date.parse(snapshot.timestamp);
|
|
91
|
+
const now = Date.parse(nowIso);
|
|
92
|
+
if (!Number.isFinite(ts) || !Number.isFinite(now)) return null;
|
|
93
|
+
const ageMs = now - ts;
|
|
94
|
+
const cap = Number(maxAgeMs);
|
|
95
|
+
if (!Number.isFinite(cap) || cap <= 0) return null;
|
|
96
|
+
if (ageMs > cap) return null;
|
|
97
|
+
|
|
98
|
+
const srcJobs = (snapshot.jobs && typeof snapshot.jobs === 'object') ? snapshot.jobs : {};
|
|
99
|
+
const jobs = {};
|
|
100
|
+
for (const [k, j] of Object.entries(srcJobs)) {
|
|
101
|
+
if (!j || typeof j !== 'object') continue;
|
|
102
|
+
if (j.state === 'queued') continue;
|
|
103
|
+
if (j.state === 'running') {
|
|
104
|
+
jobs[k] = {
|
|
105
|
+
...j,
|
|
106
|
+
state: 'error',
|
|
107
|
+
finishedAt: nowIso,
|
|
108
|
+
log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — dashboard restarted before this finished)`,
|
|
109
|
+
};
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
jobs[k] = j;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
jobs,
|
|
116
|
+
vms: (snapshot.vms && typeof snapshot.vms === 'object') ? snapshot.vms : {},
|
|
117
|
+
orphans: (snapshot.orphans && typeof snapshot.orphans === 'object') ? snapshot.orphans : {},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = {
|
|
122
|
+
MEMORY_DEFAULTS,
|
|
123
|
+
decideMemoryDrain,
|
|
124
|
+
buildStateSnapshot,
|
|
125
|
+
decodeStateSnapshot,
|
|
126
|
+
restoreFromSnapshot,
|
|
127
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-pool-policy.js — the warm pool's cost-control policy (improve38).
|
|
4
|
+
//
|
|
5
|
+
// Idle pool VMs cost money, so the pool's target size is not a constant — it's
|
|
6
|
+
// a function of the clock. During working hours the keeper holds `baseSize`
|
|
7
|
+
// claim-ready VMs; off-hours (and on an idle timer) it scales the target down,
|
|
8
|
+
// by default to zero, so an overnight pool doesn't bill for VMs nobody will
|
|
9
|
+
// claim. A claim against the resulting empty pool falls back to on-demand
|
|
10
|
+
// `fleet-ready` (slower, but it works) rather than failing — so scaling to zero
|
|
11
|
+
// is safe, not a service outage.
|
|
12
|
+
//
|
|
13
|
+
// This is the pure decision half, kept out of the keeper's untestable SSH loop:
|
|
14
|
+
// given a config and a clock, what should the pool target be right now? The
|
|
15
|
+
// keeper reads the answer and provisions/evicts toward it.
|
|
16
|
+
|
|
17
|
+
// Resolve the pool config from an env-like object (process.env in production,
|
|
18
|
+
// a literal in tests), applying defaults. All knobs are operator-tunable so the
|
|
19
|
+
// same keeper serves a 0-VM hobby fleet and a 20-VM team pool with no code
|
|
20
|
+
// change.
|
|
21
|
+
// POOL_TARGET base (working-hours) pool size default 4
|
|
22
|
+
// POOL_OFFHOURS_TARGET pool size during off-hours default 0
|
|
23
|
+
// POOL_OFFHOURS_START hour [0-23] off-hours begin (inclusive) default 20
|
|
24
|
+
// POOL_OFFHOURS_END hour [0-23] off-hours end (exclusive) default 8
|
|
25
|
+
// A start==end disables the off-hours window entirely (always baseSize).
|
|
26
|
+
function resolveConfig(env) {
|
|
27
|
+
const e = env || {};
|
|
28
|
+
return {
|
|
29
|
+
baseSize: intOr(e.POOL_TARGET, 4),
|
|
30
|
+
offHoursSize: intOr(e.POOL_OFFHOURS_TARGET, 0),
|
|
31
|
+
offHoursStart: clampHour(e.POOL_OFFHOURS_START, 20),
|
|
32
|
+
offHoursEnd: clampHour(e.POOL_OFFHOURS_END, 8),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Non-negative integer or the default. Sizes can't be negative, so a negative
|
|
37
|
+
// (or non-numeric) value is rejected to the default rather than silently used.
|
|
38
|
+
function intOr(v, dflt) {
|
|
39
|
+
const n = Number(v);
|
|
40
|
+
return Number.isInteger(n) && n >= 0 ? n : dflt;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parse an hour into [0, 23], clamping out-of-range values both directions
|
|
44
|
+
// (a negative → 0, a too-large → 23) so a typo'd config indexes a real hour
|
|
45
|
+
// instead of wedging the window. A non-numeric value falls back to `dflt`.
|
|
46
|
+
function clampHour(v, dflt) {
|
|
47
|
+
const n = Number(v);
|
|
48
|
+
if (!Number.isInteger(n)) return dflt;
|
|
49
|
+
if (n < 0) return 0;
|
|
50
|
+
if (n > 23) return 23;
|
|
51
|
+
return n;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Is `hour` within the off-hours window [start, end)? The window wraps midnight
|
|
55
|
+
// when start > end (the common case: 20:00 → 08:00 spans the night). start==end
|
|
56
|
+
// means "no off-hours window" → always false.
|
|
57
|
+
function isOffHours(hour, start, end) {
|
|
58
|
+
if (start === end) return false;
|
|
59
|
+
if (start < end) return hour >= start && hour < end; // same-day window
|
|
60
|
+
return hour >= start || hour < end; // wraps midnight
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The pool target size for the given clock: offHoursSize inside the off-hours
|
|
64
|
+
// window, baseSize otherwise. `date` is a Date (defaults to now) — the local
|
|
65
|
+
// hour drives the decision, matching the operator's working day.
|
|
66
|
+
function desiredPoolSize(config, date) {
|
|
67
|
+
const cfg = config && typeof config.baseSize === 'number' ? config : resolveConfig(config);
|
|
68
|
+
const d = date || new Date();
|
|
69
|
+
return isOffHours(d.getHours(), cfg.offHoursStart, cfg.offHoursEnd)
|
|
70
|
+
? cfg.offHoursSize
|
|
71
|
+
: cfg.baseSize;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
resolveConfig,
|
|
76
|
+
isOffHours,
|
|
77
|
+
desiredPoolSize,
|
|
78
|
+
};
|