@codeyam-editor/codeyam-editor 0.1.1 → 0.1.3
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 +1929 -0
- package/npm/container.js +22 -3
- package/npm/editor-dev.js +368 -37
- package/npm/editor.js +133 -52
- 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-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 +39 -0
- package/npm/fleet-memory.js +157 -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 +222 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +622 -0
- package/npm/preflight-guidance.js +77 -0
- package/npm/scenario-check.js +323 -14
- package/npm/scenario-handlers.js +16 -5
- package/npm/scenario-interactivity.js +245 -0
- package/npm/scenario-metrics.js +16 -2
- package/npm/scenario-mocks.js +62 -25
- package/npm/scenario-playwright.js +350 -8
- package/npm/utils.js +562 -22
- 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-CGsBSA6u.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-nuLXJzLf.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
- package/ui/dist/assets/index-KDFAgt2e.js +27 -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,39 @@
|
|
|
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
|
+
module.exports = { shouldReconcileFailedBadge };
|
|
@@ -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,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
|
+
};
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-pool.js — the warm-VM-pool state layer (improve38).
|
|
4
|
+
//
|
|
5
|
+
// Standing up a VM on demand pays for an image pull, a branch rebrand and —
|
|
6
|
+
// the slow part — an interactive auth approval. The warm pool removes that
|
|
7
|
+
// latency from the request path: keep N pre-provisioned, pre-authed, READY
|
|
8
|
+
// idle VMs and CLAIM them in seconds (rebrand-only, no OAuth). This module is
|
|
9
|
+
// the state seam for that pool — it answers "which VMs are idle-and-claimable
|
|
10
|
+
// vs already-assigned" and performs the ATOMIC claim that guarantees two
|
|
11
|
+
// operators never grab the same VM.
|
|
12
|
+
//
|
|
13
|
+
// Why a SEPARATE file from the roster (`fleet-vms.json`): the roster is a
|
|
14
|
+
// shared contract — a plain JSON array of VM-number integers that the
|
|
15
|
+
// dashboard, vm-watchdog, cloudflared-monitor and the bash publishers all
|
|
16
|
+
// read. Widening it to carry per-VM status would break every reader (they
|
|
17
|
+
// `jq -r '.[]'` it as scalars). So pool membership lives alongside the roster
|
|
18
|
+
// in `.codeyam/logs/fleet-pool.json`, keyed by VM number, and the roster keeps
|
|
19
|
+
// meaning "these VM numbers exist". A VM can be in the roster without being in
|
|
20
|
+
// the pool (e.g. an operator's hand-driven VM); the pool only tracks the warm
|
|
21
|
+
// set.
|
|
22
|
+
//
|
|
23
|
+
// On-disk shape (tolerant: a missing or malformed file is the empty pool):
|
|
24
|
+
// { "vms": { "<n>": { status, branch, freshAt, claimedAt } } }
|
|
25
|
+
// status: "pool" idle, claim-ready
|
|
26
|
+
// "assigned" claimed by an operator, rebranded to `branch`
|
|
27
|
+
// branch: the branch the VM is on (base branch while pooled)
|
|
28
|
+
// freshAt: ISO timestamp of the last successful auth/health refresh —
|
|
29
|
+
// drives evict-before-claim of a member whose token lapsed
|
|
30
|
+
// claimedAt: ISO timestamp of the claim (null while pooled)
|
|
31
|
+
//
|
|
32
|
+
// The pure functions (normalize / select / refill / staleness) operate on a
|
|
33
|
+
// plain state object and return a new one — no I/O — so they unit-test without
|
|
34
|
+
// touching disk. readState/writeState/withLock add the atomic file layer, and
|
|
35
|
+
// main() is the CLI seam the bash orchestrators (fleet-claim.sh,
|
|
36
|
+
// fleet-pool-keeper.sh) call instead of re-implementing the logic in shell.
|
|
37
|
+
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
const path = require('path');
|
|
40
|
+
|
|
41
|
+
const { rootDir } = require('./utils');
|
|
42
|
+
|
|
43
|
+
const POOL_STATE_FILE = process.env.POOL_STATE_FILE
|
|
44
|
+
|| path.join(rootDir, '.codeyam', 'logs', 'fleet-pool.json');
|
|
45
|
+
|
|
46
|
+
const POOL = 'pool';
|
|
47
|
+
const ASSIGNED = 'assigned';
|
|
48
|
+
|
|
49
|
+
// ── Pure state helpers ──────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
// Coerce arbitrary parsed JSON into the canonical `{ vms: { <n>: entry } }`
|
|
52
|
+
// shape. Drops entries whose key is not a positive integer or whose status is
|
|
53
|
+
// unrecognized, so a truncated write or a hand-edit never feeds a malformed
|
|
54
|
+
// entry into the claim path. Returned object is a fresh copy — callers mutate
|
|
55
|
+
// it freely.
|
|
56
|
+
function normalizeState(raw) {
|
|
57
|
+
const out = { vms: {} };
|
|
58
|
+
const vms = raw && typeof raw === 'object' ? raw.vms : null;
|
|
59
|
+
if (!vms || typeof vms !== 'object') return out;
|
|
60
|
+
for (const key of Object.keys(vms)) {
|
|
61
|
+
const n = Number(key);
|
|
62
|
+
if (!Number.isInteger(n) || n <= 0) continue;
|
|
63
|
+
const e = vms[key] || {};
|
|
64
|
+
const status = e.status === ASSIGNED ? ASSIGNED : e.status === POOL ? POOL : null;
|
|
65
|
+
if (!status) continue;
|
|
66
|
+
out.vms[n] = {
|
|
67
|
+
status,
|
|
68
|
+
branch: typeof e.branch === 'string' ? e.branch : null,
|
|
69
|
+
freshAt: typeof e.freshAt === 'string' ? e.freshAt : null,
|
|
70
|
+
claimedAt: typeof e.claimedAt === 'string' ? e.claimedAt : null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// VM numbers in a given status, sorted ascending. Ascending order makes claim
|
|
77
|
+
// selection deterministic (lowest-numbered idle VM first) so tests and two
|
|
78
|
+
// concurrent callers agree on which VM a claim takes.
|
|
79
|
+
function membersByStatus(state, status) {
|
|
80
|
+
return Object.keys(state.vms)
|
|
81
|
+
.map(Number)
|
|
82
|
+
.filter((n) => state.vms[n].status === status)
|
|
83
|
+
.sort((a, b) => a - b);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function poolMembers(state) {
|
|
87
|
+
return membersByStatus(state, POOL);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assignedMembers(state) {
|
|
91
|
+
return membersByStatus(state, ASSIGNED);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// True when a pool entry's auth/health is older than `ttlMs` (its token may
|
|
95
|
+
// have lapsed). An entry with no `freshAt` is treated as stale — "freshness
|
|
96
|
+
// not proven" fails closed, mirroring fleet-ready's auth gate. Only meaningful
|
|
97
|
+
// for pooled VMs; an assigned VM is the operator's concern, not the keeper's.
|
|
98
|
+
function isStale(entry, nowMs, ttlMs) {
|
|
99
|
+
if (!entry || entry.status !== POOL) return false;
|
|
100
|
+
if (!entry.freshAt) return true;
|
|
101
|
+
const t = Date.parse(entry.freshAt);
|
|
102
|
+
if (Number.isNaN(t)) return true;
|
|
103
|
+
return nowMs - t > ttlMs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Pool VM numbers whose freshness has lapsed — the evict-before-claim set the
|
|
107
|
+
// keeper drops (and replaces) so a claim never hands a caller a stale VM.
|
|
108
|
+
function staleMembers(state, nowMs, ttlMs) {
|
|
109
|
+
return poolMembers(state).filter((n) => isStale(state.vms[n], nowMs, ttlMs));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// How many NEW pool VMs the keeper must provision to reach `target` idle
|
|
113
|
+
// members. Assigned VMs do not count toward the pool — once claimed they're
|
|
114
|
+
// gone from the warm set until released — so a claim naturally triggers a
|
|
115
|
+
// refill. Never negative (an over-target pool is shrunk by policy, not here).
|
|
116
|
+
function refillCount(state, target) {
|
|
117
|
+
const have = poolMembers(state).length;
|
|
118
|
+
return Math.max(0, target - have);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The heart of the claim: deterministically move up to `count` idle pool VMs to
|
|
122
|
+
// `assigned` on `branch`. Pure — returns { next, claimed, shortfall } without
|
|
123
|
+
// touching disk; the caller persists `next` under the lock. Lowest-numbered
|
|
124
|
+
// VMs are taken first. `shortfall` (requested minus granted) is the signal the
|
|
125
|
+
// orchestrator uses to fall back to on-demand provisioning when the pool can't
|
|
126
|
+
// satisfy the whole request — an empty pool yields claimed=[] , shortfall=count
|
|
127
|
+
// rather than an error.
|
|
128
|
+
function selectForClaim(state, count, branch, nowIso) {
|
|
129
|
+
const next = normalizeState(state);
|
|
130
|
+
const idle = poolMembers(next);
|
|
131
|
+
const take = idle.slice(0, Math.max(0, count));
|
|
132
|
+
for (const n of take) {
|
|
133
|
+
next.vms[n] = {
|
|
134
|
+
...next.vms[n],
|
|
135
|
+
status: ASSIGNED,
|
|
136
|
+
branch,
|
|
137
|
+
claimedAt: nowIso,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { next, claimed: take, shortfall: Math.max(0, count - take.length) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Register (or overwrite) a VM in the pool state. The keeper calls this after
|
|
144
|
+
// provisioning a fresh READY VM (status=pool) and the claim path can re-add an
|
|
145
|
+
// assigned VM. `freshAt` defaults to now for a pool member so a just-verified
|
|
146
|
+
// VM isn't immediately stale.
|
|
147
|
+
function applyAdd(state, n, status, branch, nowIso) {
|
|
148
|
+
const next = normalizeState(state);
|
|
149
|
+
const st = status === ASSIGNED ? ASSIGNED : POOL;
|
|
150
|
+
next.vms[n] = {
|
|
151
|
+
status: st,
|
|
152
|
+
branch: branch || null,
|
|
153
|
+
freshAt: st === POOL ? nowIso : (next.vms[n] && next.vms[n].freshAt) || null,
|
|
154
|
+
claimedAt: st === ASSIGNED ? nowIso : null,
|
|
155
|
+
};
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Return an assigned VM to the idle pool (operator finished with it). A no-op
|
|
160
|
+
// for an absent VM. `freshAt` is reset to now: a just-released VM was live and
|
|
161
|
+
// authed, so it's claim-ready again without waiting for the keeper's refresh.
|
|
162
|
+
function applyRelease(state, n, nowIso) {
|
|
163
|
+
const next = normalizeState(state);
|
|
164
|
+
if (!next.vms[n]) return next;
|
|
165
|
+
next.vms[n] = {
|
|
166
|
+
...next.vms[n],
|
|
167
|
+
status: POOL,
|
|
168
|
+
freshAt: nowIso,
|
|
169
|
+
claimedAt: null,
|
|
170
|
+
};
|
|
171
|
+
return next;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Drop a VM from the pool state entirely (the keeper evicts a stale/unhealthy
|
|
175
|
+
// member before replacing it). A no-op for an absent VM.
|
|
176
|
+
function applyEvict(state, n) {
|
|
177
|
+
const next = normalizeState(state);
|
|
178
|
+
delete next.vms[n];
|
|
179
|
+
return next;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Stamp a pool member as freshly auth/health-verified. The keeper calls this
|
|
183
|
+
// after a successful credential refresh so the member's eviction clock resets.
|
|
184
|
+
function applyMarkFresh(state, n, nowIso) {
|
|
185
|
+
const next = normalizeState(state);
|
|
186
|
+
if (!next.vms[n]) return next;
|
|
187
|
+
next.vms[n] = { ...next.vms[n], freshAt: nowIso };
|
|
188
|
+
return next;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Atomic file layer ───────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
// Synchronous millisecond sleep without a busy CPU spin — used by withLock's
|
|
194
|
+
// retry. Atomics.wait blocks the (single) thread for `ms`; a fresh 4-byte
|
|
195
|
+
// SharedArrayBuffer that nothing else holds means the wait always times out
|
|
196
|
+
// rather than being woken early.
|
|
197
|
+
function sleepSync(ms) {
|
|
198
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Run `fn` while holding a cross-PROCESS mutex around the pool-state file, so a
|
|
202
|
+
// read-modify-write (the claim) is atomic even when two operators run
|
|
203
|
+
// `fleet-claim` at the same moment. The lock is a directory next to the state
|
|
204
|
+
// file: `mkdir` is atomic on POSIX, so exactly one process wins the create and
|
|
205
|
+
// the rest retry. A lock older than `staleMs` is assumed abandoned (a crashed
|
|
206
|
+
// claimer) and stolen, so a dead process can't wedge the pool forever.
|
|
207
|
+
function withLock(fn, { retries = 100, delayMs = 25, staleMs = 30000 } = {}) {
|
|
208
|
+
const lockDir = `${POOL_STATE_FILE}.lock`;
|
|
209
|
+
fs.mkdirSync(path.dirname(POOL_STATE_FILE), { recursive: true });
|
|
210
|
+
let held = false;
|
|
211
|
+
for (let i = 0; i < retries && !held; i += 1) {
|
|
212
|
+
try {
|
|
213
|
+
fs.mkdirSync(lockDir);
|
|
214
|
+
held = true;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (err.code !== 'EEXIST') throw err;
|
|
217
|
+
// Steal an abandoned lock (claimer crashed mid-operation).
|
|
218
|
+
try {
|
|
219
|
+
const age = Date.now() - fs.statSync(lockDir).mtimeMs;
|
|
220
|
+
if (age > staleMs) {
|
|
221
|
+
fs.rmdirSync(lockDir);
|
|
222
|
+
continue; // retry the mkdir immediately
|
|
223
|
+
}
|
|
224
|
+
} catch { /* lock vanished between stat and now — just retry */ }
|
|
225
|
+
sleepSync(delayMs);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!held) throw new Error(`fleet-pool: could not acquire lock ${lockDir} after ${retries} tries`);
|
|
229
|
+
try {
|
|
230
|
+
return fn();
|
|
231
|
+
} finally {
|
|
232
|
+
try { fs.rmdirSync(lockDir); } catch { /* already gone */ }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Read + normalize the pool state. A missing or malformed file is the empty
|
|
237
|
+
// pool `{ vms: {} }` (the documented tolerant contract), so a truncated write
|
|
238
|
+
// never throws into the keeper's reconcile loop.
|
|
239
|
+
function readState() {
|
|
240
|
+
try {
|
|
241
|
+
return normalizeState(JSON.parse(fs.readFileSync(POOL_STATE_FILE, 'utf8')));
|
|
242
|
+
} catch {
|
|
243
|
+
return { vms: {} };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Persist `state` atomically: write a pid-tagged temp file then `rename` over
|
|
248
|
+
// the target so a concurrent reader never observes a half-written file (same
|
|
249
|
+
// discipline as fleet-roster.writeRoster). Returns the normalized state.
|
|
250
|
+
function writeState(state) {
|
|
251
|
+
const next = normalizeState(state);
|
|
252
|
+
fs.mkdirSync(path.dirname(POOL_STATE_FILE), { recursive: true });
|
|
253
|
+
const tmp = `${POOL_STATE_FILE}.${process.pid}.tmp`;
|
|
254
|
+
fs.writeFileSync(tmp, JSON.stringify(next));
|
|
255
|
+
fs.renameSync(tmp, POOL_STATE_FILE);
|
|
256
|
+
return next;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
POOL_STATE_FILE,
|
|
261
|
+
POOL,
|
|
262
|
+
ASSIGNED,
|
|
263
|
+
normalizeState,
|
|
264
|
+
poolMembers,
|
|
265
|
+
assignedMembers,
|
|
266
|
+
isStale,
|
|
267
|
+
staleMembers,
|
|
268
|
+
refillCount,
|
|
269
|
+
selectForClaim,
|
|
270
|
+
applyAdd,
|
|
271
|
+
applyRelease,
|
|
272
|
+
applyEvict,
|
|
273
|
+
applyMarkFresh,
|
|
274
|
+
withLock,
|
|
275
|
+
readState,
|
|
276
|
+
writeState,
|
|
277
|
+
main,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// ── CLI seam ────────────────────────────────────────────────────────────────
|
|
281
|
+
// The bash orchestrators call these subcommands instead of re-deriving the
|
|
282
|
+
// logic in shell. Each mutating subcommand runs its read-modify-write inside
|
|
283
|
+
// withLock so concurrent callers serialize. Output is line-oriented and
|
|
284
|
+
// machine-parseable (space-separated VM numbers / a single integer) so the
|
|
285
|
+
// shell side stays a thin `$(node fleet-pool.js ...)` capture.
|
|
286
|
+
|
|
287
|
+
function parsePoolFlags(argv) {
|
|
288
|
+
const flags = {};
|
|
289
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
290
|
+
const a = argv[i];
|
|
291
|
+
if (a.startsWith('--')) {
|
|
292
|
+
const key = a.slice(2);
|
|
293
|
+
const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : 'true';
|
|
294
|
+
flags[key] = val;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return flags;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function main(argv) {
|
|
301
|
+
const [cmd, ...rest] = argv;
|
|
302
|
+
const flags = parsePoolFlags(rest);
|
|
303
|
+
const now = flags.now || new Date().toISOString();
|
|
304
|
+
|
|
305
|
+
switch (cmd) {
|
|
306
|
+
case 'list':
|
|
307
|
+
process.stdout.write(`${JSON.stringify(readState(), null, 2)}\n`);
|
|
308
|
+
return 0;
|
|
309
|
+
case 'pool-members':
|
|
310
|
+
process.stdout.write(`${poolMembers(readState()).join(' ')}\n`);
|
|
311
|
+
return 0;
|
|
312
|
+
case 'assigned-members':
|
|
313
|
+
process.stdout.write(`${assignedMembers(readState()).join(' ')}\n`);
|
|
314
|
+
return 0;
|
|
315
|
+
case 'refill-count': {
|
|
316
|
+
const target = Number(flags.target);
|
|
317
|
+
if (!Number.isInteger(target) || target < 0) {
|
|
318
|
+
process.stderr.write('fleet-pool: refill-count needs --target <non-negative int>\n');
|
|
319
|
+
return 2;
|
|
320
|
+
}
|
|
321
|
+
process.stdout.write(`${refillCount(readState(), target)}\n`);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
case 'stale': {
|
|
325
|
+
const ttl = Number(flags['ttl-seconds']);
|
|
326
|
+
if (!Number.isInteger(ttl) || ttl < 0) {
|
|
327
|
+
process.stderr.write('fleet-pool: stale needs --ttl-seconds <non-negative int>\n');
|
|
328
|
+
return 2;
|
|
329
|
+
}
|
|
330
|
+
const out = staleMembers(readState(), Date.parse(now), ttl * 1000);
|
|
331
|
+
process.stdout.write(`${out.join(' ')}\n`);
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
case 'claim': {
|
|
335
|
+
const count = Number(flags.count);
|
|
336
|
+
const branch = flags.branch;
|
|
337
|
+
if (!Number.isInteger(count) || count <= 0 || !branch || branch === 'true') {
|
|
338
|
+
process.stderr.write('fleet-pool: claim needs --count <pos int> --branch <name>\n');
|
|
339
|
+
return 2;
|
|
340
|
+
}
|
|
341
|
+
const result = withLock(() => {
|
|
342
|
+
const { next, claimed, shortfall } = selectForClaim(readState(), count, branch, now);
|
|
343
|
+
writeState(next);
|
|
344
|
+
return { claimed, shortfall };
|
|
345
|
+
});
|
|
346
|
+
// VM numbers on stdout (the orchestrator rebrands each); shortfall on
|
|
347
|
+
// stderr so an empty/short pool is visible without polluting the capture.
|
|
348
|
+
process.stdout.write(`${result.claimed.join(' ')}\n`);
|
|
349
|
+
if (result.shortfall > 0) {
|
|
350
|
+
process.stderr.write(`fleet-pool: shortfall ${result.shortfall} (pool could not satisfy full claim)\n`);
|
|
351
|
+
}
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
case 'add': {
|
|
355
|
+
const vm = Number(flags.vm);
|
|
356
|
+
if (!Number.isInteger(vm) || vm <= 0) {
|
|
357
|
+
process.stderr.write('fleet-pool: add needs --vm <pos int>\n');
|
|
358
|
+
return 2;
|
|
359
|
+
}
|
|
360
|
+
const status = flags.status === ASSIGNED ? ASSIGNED : POOL;
|
|
361
|
+
withLock(() => writeState(applyAdd(readState(), vm, status, flags.branch, now)));
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
case 'release': {
|
|
365
|
+
const vm = Number(flags.vm);
|
|
366
|
+
if (!Number.isInteger(vm) || vm <= 0) {
|
|
367
|
+
process.stderr.write('fleet-pool: release needs --vm <pos int>\n');
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
withLock(() => writeState(applyRelease(readState(), vm, now)));
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
case 'evict': {
|
|
374
|
+
const vm = Number(flags.vm);
|
|
375
|
+
if (!Number.isInteger(vm) || vm <= 0) {
|
|
376
|
+
process.stderr.write('fleet-pool: evict needs --vm <pos int>\n');
|
|
377
|
+
return 2;
|
|
378
|
+
}
|
|
379
|
+
withLock(() => writeState(applyEvict(readState(), vm)));
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
case 'mark-fresh': {
|
|
383
|
+
const vm = Number(flags.vm);
|
|
384
|
+
if (!Number.isInteger(vm) || vm <= 0) {
|
|
385
|
+
process.stderr.write('fleet-pool: mark-fresh needs --vm <pos int>\n');
|
|
386
|
+
return 2;
|
|
387
|
+
}
|
|
388
|
+
withLock(() => writeState(applyMarkFresh(readState(), vm, now)));
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
default:
|
|
392
|
+
process.stderr.write(
|
|
393
|
+
'fleet-pool: usage: <list|pool-members|assigned-members|refill-count|stale|claim|add|release|evict|mark-fresh> [--flags]\n',
|
|
394
|
+
);
|
|
395
|
+
return 2;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (require.main === module) {
|
|
400
|
+
process.exit(main(process.argv.slice(2)));
|
|
401
|
+
}
|