@codeyam-editor/codeyam-editor 0.1.0-staging.01efa1a
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 +19 -0
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +2184 -0
- package/npm/container.js +54 -0
- package/npm/editor-dev.js +459 -0
- package/npm/editor.js +272 -0
- package/npm/empty-project-dir.js +33 -0
- package/npm/fleet-auth.js +111 -0
- package/npm/fleet-branch.js +49 -0
- package/npm/fleet-broker-death.js +44 -0
- package/npm/fleet-broker-debounce.js +63 -0
- package/npm/fleet-broker-status.js +45 -0
- package/npm/fleet-build-data.js +81 -0
- package/npm/fleet-cleanup.js +297 -0
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-env.js +125 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +83 -0
- package/npm/fleet-memory.js +157 -0
- package/npm/fleet-plans.js +96 -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 +93 -0
- package/npm/fleet-reset.js +222 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +717 -0
- package/npm/open.js +15 -0
- package/npm/preflight-guidance.js +77 -0
- package/npm/scenario-check.js +604 -0
- package/npm/scenario-handlers.js +82 -0
- package/npm/scenario-interactivity.js +245 -0
- package/npm/scenario-issues.js +35 -0
- package/npm/scenario-metrics.js +108 -0
- package/npm/scenario-mocks.js +107 -0
- package/npm/scenario-playwright.js +534 -0
- package/npm/utils.js +823 -0
- package/npm/vm-urls.js +117 -0
- package/package.json +32 -0
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-By-2rl7D.js +187 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-Cg8zOFNZ.js +5 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-WOUYbba7.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CBTlIVPs.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-DzoF9RKw.js +27 -0
- package/ui/dist/assets/index-ckz94rKI.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/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,56 @@
|
|
|
1
|
+
// Group an already-sorted/filtered VM list by client project for the Fleet
|
|
2
|
+
// Dashboard's grouped layout.
|
|
3
|
+
//
|
|
4
|
+
// Returns an ORDERED array of { project, vms } groups: real project groups
|
|
5
|
+
// first, sorted case-insensitively by name (locale compare); the
|
|
6
|
+
// unknown/starting-up bucket (project === null) pinned LAST. Within each group
|
|
7
|
+
// the incoming order of `vms` is preserved untouched, so the caller's active
|
|
8
|
+
// Sort mode continues to drive within-group order — grouping never re-sorts the
|
|
9
|
+
// cards, it only partitions them.
|
|
10
|
+
//
|
|
11
|
+
// `projectKey(v)` resolves the group key for a VM and defaults to the same
|
|
12
|
+
// identity `card()` renders in the dashboard:
|
|
13
|
+
// (v.cardIdentity && v.cardIdentity.project) || v.project || null
|
|
14
|
+
// It is injectable so unit tests can drive grouping without constructing full
|
|
15
|
+
// card payloads. A null/empty key routes the VM into the trailing unknown
|
|
16
|
+
// bucket so VMs mid-provision (blank identity) never silently disappear.
|
|
17
|
+
//
|
|
18
|
+
// Pure; no I/O.
|
|
19
|
+
|
|
20
|
+
function defaultProjectKey(v) {
|
|
21
|
+
return (v && v.cardIdentity && v.cardIdentity.project) || (v && v.project) || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function groupVmsByProject(vms, projectKey = defaultProjectKey) {
|
|
25
|
+
// First pass: stable bucketing. `Map` preserves insertion order, so the
|
|
26
|
+
// first VM of each project fixes that group's relative position before we
|
|
27
|
+
// re-order — but we re-sort named groups below regardless, so insertion
|
|
28
|
+
// order only matters for tie-stability within equal-name groups (none here).
|
|
29
|
+
const named = new Map();
|
|
30
|
+
let unknown = null;
|
|
31
|
+
|
|
32
|
+
for (const v of vms) {
|
|
33
|
+
const raw = projectKey(v);
|
|
34
|
+
const key = raw == null || raw === "" ? null : raw;
|
|
35
|
+
if (key === null) {
|
|
36
|
+
if (!unknown) unknown = { project: null, vms: [] };
|
|
37
|
+
unknown.vms.push(v);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
let group = named.get(key);
|
|
41
|
+
if (!group) {
|
|
42
|
+
group = { project: key, vms: [] };
|
|
43
|
+
named.set(key, group);
|
|
44
|
+
}
|
|
45
|
+
group.vms.push(v);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const groups = Array.from(named.values());
|
|
49
|
+
groups.sort((a, b) =>
|
|
50
|
+
a.project.localeCompare(b.project, undefined, { sensitivity: "base" })
|
|
51
|
+
);
|
|
52
|
+
if (unknown) groups.push(unknown);
|
|
53
|
+
return groups;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { groupVmsByProject, defaultProjectKey };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-job-badge.js — pure verdict for the fleet dashboard's failed-job
|
|
4
|
+
// badge reconciliation (improve39). A VM's last terminal-error heavy job
|
|
5
|
+
// (add/error, reset-all error, reset-editor error, …) used to stick on the
|
|
6
|
+
// dashboard card indefinitely — even after the VM was recovered and is healthy
|
|
7
|
+
// again — because the card renders the last job's error with no check against
|
|
8
|
+
// the VM's live state, so a recovered VM keeps screaming a red error and
|
|
9
|
+
// misleads the operator. This decides whether such a badge should be RECONCILED
|
|
10
|
+
// (presented as "recovered" rather than a live error) because the VM is
|
|
11
|
+
// demonstrably fine now. The live signals are gathered in server.js (the
|
|
12
|
+
// untestable server shell); the verdict lives here so the cases are unit-tested
|
|
13
|
+
// without a live VM.
|
|
14
|
+
|
|
15
|
+
// Should the VM's last terminal-error job badge be reconciled against live
|
|
16
|
+
// health — i.e. presented as "recovered" rather than a live red error?
|
|
17
|
+
//
|
|
18
|
+
// job the carried job record ({ state, action, ... }) or null
|
|
19
|
+
// reachable the VM's editor answered this poll (true = healthy / responding)
|
|
20
|
+
// drifted a project-dir-drift signal is present (reachable-but-unwired /
|
|
21
|
+
// half-provisioned add — genuinely needs-recovery, owned by the
|
|
22
|
+
// companion reconcile-inflight-jobs plan; never falsely cleared here)
|
|
23
|
+
// offBranch the VM's live client branch differs from its target branch
|
|
24
|
+
// degraded the job completed but a wiring sub-step is flagged degraded
|
|
25
|
+
//
|
|
26
|
+
// Returns true only when the job is a terminal error AND the VM is demonstrably
|
|
27
|
+
// fine (reachable, not drifted, on-branch, not degraded). An unreachable /
|
|
28
|
+
// drifted / off-branch VM keeps its error surfaced — a real problem must stay
|
|
29
|
+
// visible.
|
|
30
|
+
function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degraded } = {}) {
|
|
31
|
+
if (!job || job.state !== 'error') return false;
|
|
32
|
+
if (!reachable) return false;
|
|
33
|
+
if (drifted) return false;
|
|
34
|
+
if (offBranch) return false;
|
|
35
|
+
if (degraded) return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Given a reconcile verdict (from shouldReconcileFailedBadge) and whether the VM
|
|
40
|
+
// is now idle, decide whether the recovered badge should be FULLY CLEARED — the
|
|
41
|
+
// job entry dropped from state.jobs so the card goes clean — rather than merely
|
|
42
|
+
// muted as a "recovered (was: …)" note (improve41).
|
|
43
|
+
//
|
|
44
|
+
// badgeReconciled the shouldReconcileFailedBadge verdict for this VM
|
|
45
|
+
// idle the VM answered this poll, is not running a build session,
|
|
46
|
+
// and its broker is not actively serving an agent
|
|
47
|
+
//
|
|
48
|
+
// A recovered VM that is also idle has demonstrably finished recovering, so the
|
|
49
|
+
// stale error is dropped entirely. A recovered-but-not-yet-idle VM (mid-build,
|
|
50
|
+
// or a poll hasn't confirmed idle) keeps the muted note so the prior log stays
|
|
51
|
+
// visible during that transient window. Never clears a badge that wasn't first
|
|
52
|
+
// judged recovered — an unreachable / drifted / off-branch error stays surfaced.
|
|
53
|
+
function shouldClearReconciledBadge({ badgeReconciled, idle } = {}) {
|
|
54
|
+
return !!badgeReconciled && !!idle;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Pure guard for the operator "Clear badge" action (improve41). Decides whether
|
|
58
|
+
// VM-n's persisted job entry may be dropped after an out-of-band recovery,
|
|
59
|
+
// independent of the server's state / fs I/O so the cases are unit-tested
|
|
60
|
+
// without a live dashboard. The shell (server.js) supplies the live signals and
|
|
61
|
+
// turns the verdict into the {ok,msg} response + the actual delete + snapshot.
|
|
62
|
+
//
|
|
63
|
+
// rostered the VM number is in the live roster
|
|
64
|
+
// job the carried job record ({ state, ... }) or null/undefined
|
|
65
|
+
//
|
|
66
|
+
// Only a TERMINAL job (error/done) on a rostered VM may be cleared, OR a terminal
|
|
67
|
+
// failed add (add/error) on a non-rostered VM (which deletes the card + add-N sidecar):
|
|
68
|
+
// any other job state / non-rostered combination is not clearable.
|
|
69
|
+
// Returns { ok, reason, cleanup } — reason is a stable code the shell maps to an
|
|
70
|
+
// operator message, and cleanup is the cleanup action to perform ('add-failed' vs 'badge-only').
|
|
71
|
+
function canClearBadge({ rostered, job } = {}) {
|
|
72
|
+
if (!rostered) {
|
|
73
|
+
if (job && job.action === 'add' && job.state === 'error') {
|
|
74
|
+
return { ok: true, reason: null, cleanup: 'add-failed' };
|
|
75
|
+
}
|
|
76
|
+
return { ok: false, reason: 'not-rostered' };
|
|
77
|
+
}
|
|
78
|
+
if (!job) return { ok: false, reason: 'no-badge' };
|
|
79
|
+
if (job.state !== 'error' && job.state !== 'done') return { ok: false, reason: 'job-active' };
|
|
80
|
+
return { ok: true, reason: null, cleanup: 'badge-only' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { shouldReconcileFailedBadge, shouldClearReconciledBadge, canClearBadge };
|
|
@@ -0,0 +1,157 @@
|
|
|
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 these safety transforms applied to jobs:
|
|
78
|
+
*
|
|
79
|
+
* • `running` WITH a pending-job sidecar → `recovering` with a deferral
|
|
80
|
+
* marker, NOT `error`. A heavy job that stashed a recoverable sidecar
|
|
81
|
+
* (its cfg) must defer its final state to the startup reap, which
|
|
82
|
+
* actually checks gcloud — the blanket `error` was the improve39 VM-7
|
|
83
|
+
* bug: a mid-add restart stranded an instance that was really up as a
|
|
84
|
+
* dead `add-failed`. `recovering` is an honest "the reap will resolve
|
|
85
|
+
* this" surface; the reap then supersedes it (reconcile/half-provisioned
|
|
86
|
+
* /cleanup) once it has the gcloud verdict.
|
|
87
|
+
* • `running` WITHOUT a sidecar → `error` with a log marker, because the
|
|
88
|
+
* child process died with the dashboard and there's nothing to recover
|
|
89
|
+
* from — un-recoverable history, the old behavior.
|
|
90
|
+
* • `queued` → dropped, because the in-memory pendingJobQueue array
|
|
91
|
+
* is gone, so promoteFromJobQueue can never start them. The operator
|
|
92
|
+
* will re-click (the queued click had done no work yet).
|
|
93
|
+
*
|
|
94
|
+
* `sidecarKeys` is the set of `${action}-${n}` strings the caller read from
|
|
95
|
+
* the pending-jobs dir (server.js passes it; tests pass a plain array/Set).
|
|
96
|
+
* Omitted → no job has a sidecar → every running job downgrades to `error`
|
|
97
|
+
* exactly as before, so existing callers/tests are unaffected.
|
|
98
|
+
*
|
|
99
|
+
* Pure; no I/O. */
|
|
100
|
+
function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso, sidecarKeys } = {}) {
|
|
101
|
+
if (!snapshot || typeof snapshot !== 'object') return null;
|
|
102
|
+
if (typeof snapshot.timestamp !== 'string') return null;
|
|
103
|
+
const ts = Date.parse(snapshot.timestamp);
|
|
104
|
+
const now = Date.parse(nowIso);
|
|
105
|
+
if (!Number.isFinite(ts) || !Number.isFinite(now)) return null;
|
|
106
|
+
const ageMs = now - ts;
|
|
107
|
+
const cap = Number(maxAgeMs);
|
|
108
|
+
if (!Number.isFinite(cap) || cap <= 0) return null;
|
|
109
|
+
if (ageMs > cap) return null;
|
|
110
|
+
|
|
111
|
+
const sidecars = sidecarKeys instanceof Set ? sidecarKeys : new Set(sidecarKeys || []);
|
|
112
|
+
const srcJobs = (snapshot.jobs && typeof snapshot.jobs === 'object') ? snapshot.jobs : {};
|
|
113
|
+
const jobs = {};
|
|
114
|
+
for (const [k, j] of Object.entries(srcJobs)) {
|
|
115
|
+
if (!j || typeof j !== 'object') continue;
|
|
116
|
+
if (j.state === 'queued') continue;
|
|
117
|
+
if (j.state === 'running') {
|
|
118
|
+
if (sidecars.has(`${j.action}-${k}`)) {
|
|
119
|
+
jobs[k] = {
|
|
120
|
+
...j,
|
|
121
|
+
state: 'recovering',
|
|
122
|
+
log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — deferring to startup reap)`,
|
|
123
|
+
};
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
jobs[k] = {
|
|
127
|
+
...j,
|
|
128
|
+
state: 'error',
|
|
129
|
+
finishedAt: nowIso,
|
|
130
|
+
// improve40: mark this as a restart-orphan, NOT a genuine in-process
|
|
131
|
+
// failure. The job was `running` when the restart downgraded it and has
|
|
132
|
+
// NO sidecar to reconcile from — so the boot path must probe the VM's
|
|
133
|
+
// REAL provision state (orphanedAddsToReconcile → gcloud reap) rather
|
|
134
|
+
// than leave a dead `add-failed` badge on a VM that may have actually
|
|
135
|
+
// provisioned (the VM-7 hole). An add that failed IN-PROCESS keeps its
|
|
136
|
+
// own terminal `error` with no flag, so it is never re-probed/cleared.
|
|
137
|
+
restartOrphaned: true,
|
|
138
|
+
log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — dashboard restarted before this finished)`,
|
|
139
|
+
};
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
jobs[k] = j;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
jobs,
|
|
146
|
+
vms: (snapshot.vms && typeof snapshot.vms === 'object') ? snapshot.vms : {},
|
|
147
|
+
orphans: (snapshot.orphans && typeof snapshot.orphans === 'object') ? snapshot.orphans : {},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
MEMORY_DEFAULTS,
|
|
153
|
+
decideMemoryDrain,
|
|
154
|
+
buildStateSnapshot,
|
|
155
|
+
decodeStateSnapshot,
|
|
156
|
+
restoreFromSnapshot,
|
|
157
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Indexes the currently-reachable VMs by their live sessionId.
|
|
5
|
+
* @param {Array|Object} vms The merged VM states on the dashboard.
|
|
6
|
+
* @returns {Object} A map of sessionId -> VM number (n).
|
|
7
|
+
*/
|
|
8
|
+
function indexVmsBySessionId(vms) {
|
|
9
|
+
const map = {};
|
|
10
|
+
if (!vms) return map;
|
|
11
|
+
const list = Array.isArray(vms) ? vms : Object.values(vms);
|
|
12
|
+
for (const vm of list) {
|
|
13
|
+
if (vm && vm.sessionId && vm.n !== undefined) {
|
|
14
|
+
map[vm.sessionId] = vm.n;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return map;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Computes which dependencies are unsatisfied (i.e. not in the completedSlugs set).
|
|
22
|
+
* @param {string[]} dependsOn Prerequisite slugs.
|
|
23
|
+
* @param {Set|string[]} completedSlugs Set or array of completed plan slugs.
|
|
24
|
+
* @returns {string[]} The unsatisfied dependency slugs.
|
|
25
|
+
*/
|
|
26
|
+
function unsatisfiedDeps(dependsOn, completedSlugs) {
|
|
27
|
+
const deps = Array.isArray(dependsOn) ? dependsOn : [];
|
|
28
|
+
const completedSet = completedSlugs instanceof Set ? completedSlugs : new Set(completedSlugs || []);
|
|
29
|
+
return deps.filter(dep => !completedSet.has(dep));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves claims and dependency states for each queued plan.
|
|
34
|
+
* @param {Object} params
|
|
35
|
+
* @param {Object[]} params.plans Array of PlanSummary objects from /api/plans.
|
|
36
|
+
* @param {Object[]} params.claims Array of PlanClaim objects from /api/plan-claims.
|
|
37
|
+
* @param {Set|string[]} params.completedSlugs Completed plan slugs.
|
|
38
|
+
* @param {Object} params.vmsBySessionId Map of sessionId -> VM number.
|
|
39
|
+
* @returns {Object[]} Annotated plan rows.
|
|
40
|
+
*/
|
|
41
|
+
function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
|
|
42
|
+
const list = Array.isArray(plans) ? plans : [];
|
|
43
|
+
const claimsList = Array.isArray(claims) ? claims : [];
|
|
44
|
+
|
|
45
|
+
return list.map(plan => {
|
|
46
|
+
// 1. Extract plan fields
|
|
47
|
+
const slug = plan.slug;
|
|
48
|
+
const title = plan.title;
|
|
49
|
+
const mode = plan.mode || null;
|
|
50
|
+
const order = plan.order != null ? plan.order : null;
|
|
51
|
+
const prefix = plan.prefix || null;
|
|
52
|
+
|
|
53
|
+
// 2. Resolve dependency-blocked state
|
|
54
|
+
const dependsOn = Array.isArray(plan.depends_on) ? plan.depends_on : (plan.dependsOn ? plan.dependsOn : []);
|
|
55
|
+
const blockedBy = unsatisfiedDeps(dependsOn, completedSlugs);
|
|
56
|
+
|
|
57
|
+
// 3. Resolve claim state
|
|
58
|
+
const claim = claimsList.find(c => c.slug === slug);
|
|
59
|
+
let claimInfo = null;
|
|
60
|
+
if (claim) {
|
|
61
|
+
const claimedBy = claim.claimedBy || claim.claimed_by || {};
|
|
62
|
+
const sessionId = claimedBy.sessionId || claimedBy.session_id || null;
|
|
63
|
+
const vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
|
|
64
|
+
|
|
65
|
+
claimInfo = {
|
|
66
|
+
vm,
|
|
67
|
+
machine: claimedBy.machine || null,
|
|
68
|
+
agent: claimedBy.agentProvider || claimedBy.agent_provider || null,
|
|
69
|
+
sessionAlive: claim.sessionAlive !== undefined ? claim.sessionAlive : (claim.session_alive !== undefined ? claim.session_alive : null),
|
|
70
|
+
source: claim.source || null,
|
|
71
|
+
currentStep: claimedBy.currentStep || claimedBy.current_step || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
slug,
|
|
77
|
+
title,
|
|
78
|
+
mode,
|
|
79
|
+
order,
|
|
80
|
+
prefix,
|
|
81
|
+
blockedBy,
|
|
82
|
+
claim: claimInfo
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const REQUIRED_PLAN_ROW_FIELDS = [
|
|
88
|
+
'slug', 'title', 'mode', 'order', 'prefix', 'blockedBy', 'claim'
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
indexVmsBySessionId,
|
|
93
|
+
unsatisfiedDeps,
|
|
94
|
+
resolvePlanRows,
|
|
95
|
+
REQUIRED_PLAN_ROW_FIELDS
|
|
96
|
+
};
|
|
@@ -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
|
+
};
|