@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,297 @@
|
|
|
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
|
+
// (for up adds) project-wiring verdict, 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, container up, project wired (or unknown) ensure roster + recordLaunch, then clear
|
|
84
|
+
// 'half-provisioned' add up, container up, but project NOT wired surface needs-recovery card (finish-wire from cfg)
|
|
85
|
+
// 'recover-bootstrap' add instance up but container missing/down re-run cloud:up to finish the half-done bootstrap
|
|
86
|
+
// 'clear-only' destroy sidecar but instance still exists just clear the sidecar
|
|
87
|
+
// 'leave' gcloud uncertain (exists === null) leave for the next boot
|
|
88
|
+
// The (exists, containerUp) split distinguishes a finished add from a container-
|
|
89
|
+
// down half-provisioned one: a dashboard SIGKILL'd mid-`cloud:up` leaves a GCP
|
|
90
|
+
// instance with no docker/container, which the old (exists-only) matrix wrongly
|
|
91
|
+
// classified 'reconcile-add' — adding an unreachable VM to the roster. The
|
|
92
|
+
// `projectWired` axis closes the remaining gap (improve39 VM-7): an instance +
|
|
93
|
+
// container that ARE up but whose `/workspace` is unwired (no git remote, empty
|
|
94
|
+
// manifest) is NOT a finished add — it's half-provisioned and must surface a
|
|
95
|
+
// recoverable card, not be silently reconciled "done". Only a POSITIVE unwired
|
|
96
|
+
// signal (`projectWired === false`) downgrades to 'half-provisioned'; an
|
|
97
|
+
// unknown/unprobed wiring (`null`/`undefined`) stays 'reconcile-add' so a flaky
|
|
98
|
+
// probe can't strand a healthy VM. `containerUp`/`projectWired` are only consulted
|
|
99
|
+
// for an add whose instance exists; destroy and the instance-gone add path ignore
|
|
100
|
+
// them. Splitting the matrix out lets the table be exhaustively tested without any
|
|
101
|
+
// of the side-effects in server.js.
|
|
102
|
+
function reapVerdict({ action, exists, containerUp, projectWired }) {
|
|
103
|
+
if (exists === null) return 'leave';
|
|
104
|
+
if (action === 'destroy') return exists ? 'clear-only' : 'cleanup';
|
|
105
|
+
if (action === 'add') {
|
|
106
|
+
if (!exists) return 'cleanup';
|
|
107
|
+
if (!containerUp) return 'recover-bootstrap';
|
|
108
|
+
return projectWired === false ? 'half-provisioned' : 'reconcile-add';
|
|
109
|
+
}
|
|
110
|
+
return 'leave';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Classify whether a VM's editor `/workspace` is actually wired to a client
|
|
114
|
+
// project, from a best-effort probe of two signals: the git remote URL and
|
|
115
|
+
// whether `package.json` carries a real `name`. Pure: server.js runs the SSH +
|
|
116
|
+
// `docker exec` probe and hands the raw stdout/err here so the decision is
|
|
117
|
+
// unit-testable without a live VM.
|
|
118
|
+
// - err present (ssh/docker blip, timeout) → null (unknown — do NOT
|
|
119
|
+
// strand a healthy VM on a flaky probe; reapVerdict treats null as wired)
|
|
120
|
+
// - a git remote OR a non-empty package.json name → true (wired)
|
|
121
|
+
// - neither (empty / no-git workspace) → false (half-provisioned —
|
|
122
|
+
// the VM-7 signature: `/workspace` with no remote and an empty manifest)
|
|
123
|
+
// The probe emits the two signals separated by the `---CY---` sentinel so this
|
|
124
|
+
// parser is independent of locale/line-ending noise in the ssh transport.
|
|
125
|
+
function classifyProjectWiring({ stdout, err } = {}) {
|
|
126
|
+
if (err) return null;
|
|
127
|
+
const parts = String(stdout == null ? '' : stdout).split('---CY---').map((s) => s.trim());
|
|
128
|
+
const remote = parts[0] || '';
|
|
129
|
+
const nameCount = Number((parts[1] || '0').trim()) || 0;
|
|
130
|
+
if (remote || nameCount > 0) return true;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Durable exit marker for a detached `add` provision (improve40). The add's
|
|
135
|
+
// `cloud:up` shell is spawned in its OWN session (detached) so a dashboard
|
|
136
|
+
// SIGKILL — the 2GB launchd hard cap, exactly the kill that aborted the VM-7
|
|
137
|
+
// add mid-flight — no longer reaches it; the provision keeps running. When that
|
|
138
|
+
// detached shell finally exits it writes `add-<n>.exit` (its trailing exit code
|
|
139
|
+
// + an ISO timestamp) into PENDING_JOBS_DIR, alongside the `add-<n>.json`
|
|
140
|
+
// sidecar. The marker is the GROUND-TRUTH completion signal the next boot reads
|
|
141
|
+
// instead of inferring `in-flight → error` from the lost child: a marker means
|
|
142
|
+
// "the provision finished while the dashboard was away," its code says whether
|
|
143
|
+
// it finished OK. The name is derived here so the shell `trap` (server.js) and
|
|
144
|
+
// the JS reader build the exact same path.
|
|
145
|
+
function exitMarkerName(n) {
|
|
146
|
+
return `add-${n}.exit`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Parse an exit-marker file body into { exitCode, finishedAt }, or null when the
|
|
150
|
+
// body is missing/blank/malformed. Format is two lines — the numeric exit code
|
|
151
|
+
// then an ISO timestamp — written by the shell trap. A non-integer first line is
|
|
152
|
+
// treated as no-marker (null) so a half-written / corrupt marker falls back to
|
|
153
|
+
// the gcloud reconcile path rather than being read as a bogus exit code. Pure:
|
|
154
|
+
// takes the text so it's unit-testable without touching disk.
|
|
155
|
+
function parseExitMarker(text) {
|
|
156
|
+
if (text == null) return null;
|
|
157
|
+
const lines = String(text).split('\n').map((s) => s.trim()).filter(Boolean);
|
|
158
|
+
if (!lines.length) return null;
|
|
159
|
+
const code = Number(lines[0]);
|
|
160
|
+
if (!Number.isInteger(code)) return null;
|
|
161
|
+
return { exitCode: code, finishedAt: lines[1] || null };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Read VM-n's exit marker from `dir`, or null when it's absent/unreadable/
|
|
165
|
+
// malformed (the still-running and never-written cases both surface as null).
|
|
166
|
+
function readExitMarker(dir, n) {
|
|
167
|
+
try { return parseExitMarker(fs.readFileSync(path.join(dir, exitMarkerName(n)), 'utf8')); }
|
|
168
|
+
catch { return null; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Remove a consumed exit marker; a missing file is the already-cleared path, not
|
|
172
|
+
// an error (mirrors clearPendingJob).
|
|
173
|
+
function clearExitMarker(dir, n) {
|
|
174
|
+
try { fs.unlinkSync(path.join(dir, exitMarkerName(n))); }
|
|
175
|
+
catch (_) { /* missing is fine */ }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Decide how a snapshot-restored `add` should be re-attached after a dashboard
|
|
179
|
+
// restart, BEFORE any gcloud probe. Pure: takes the durable signals the boot
|
|
180
|
+
// path gathers — whether the exit marker exists, its recorded code, and whether
|
|
181
|
+
// the detached provision child is still alive (recorded pid, `kill -0`).
|
|
182
|
+
// verdict when dispatch
|
|
183
|
+
// 'done' marker present, exit code 0 roster + recordLaunch, clear sidecar/marker
|
|
184
|
+
// 'error' marker present, non-zero exit code surface the real failure, clear sidecar/marker
|
|
185
|
+
// 'running' no marker yet, child still alive RE-ATTACH: keep polling, do NOT mark error
|
|
186
|
+
// 'reconcile' child gone, no usable marker fall through to fc.reapVerdict (gcloud probe)
|
|
187
|
+
// This is the durable fix's brain: a provision that survived the kill (still
|
|
188
|
+
// running, or finished while we were away) is classified from GROUND TRUTH, so
|
|
189
|
+
// it is never blanket-converted `in-flight → error` the way the snapshot-only
|
|
190
|
+
// path did (the improve39 VM-7 false `add-failed`). Only when the child is gone
|
|
191
|
+
// AND left no parseable marker do we fall back to inferring state from gcloud —
|
|
192
|
+
// `reconcile` hands that exact (exists, containerUp, projectWired) decision to
|
|
193
|
+
// fc.reapVerdict. A marker whose first line isn't an integer is treated as
|
|
194
|
+
// absent (parseExitMarker → null) so a corrupt marker reconciles rather than
|
|
195
|
+
// asserting a bogus code. The matrix is exhaustively tested without a live VM.
|
|
196
|
+
function reattachVerdict({ markerPresent, exitCode, childAlive } = {}) {
|
|
197
|
+
if (markerPresent) {
|
|
198
|
+
// Guard the null/undefined coercion BEFORE Number() — `Number(null)` is 0,
|
|
199
|
+
// which would masquerade as a clean exit. A marker with no parseable code
|
|
200
|
+
// (half-written / corrupt) must reconcile, not assert success.
|
|
201
|
+
const code = exitCode == null ? NaN : Number(exitCode);
|
|
202
|
+
if (Number.isInteger(code)) return code === 0 ? 'done' : 'error';
|
|
203
|
+
return 'reconcile'; // marker present but no parseable code — let gcloud decide
|
|
204
|
+
}
|
|
205
|
+
if (childAlive) return 'running';
|
|
206
|
+
return 'reconcile';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of
|
|
210
|
+
// recoveries ALREADY persisted on the sidecar, return the next attempt number,
|
|
211
|
+
// whether the reap should proceed (re-run cloud:up) or treat the VM as
|
|
212
|
+
// exhausted (leave the sidecar + surface a needs-manual-recovery card), and the
|
|
213
|
+
// cap. Keeps the off-by-one (prior 3 → attempt 4 → stop) out of server.js so it
|
|
214
|
+
// can be unit-tested without the side-effecting re-run.
|
|
215
|
+
function recoverBootstrapDecision({ recoverAttempts, max = 3 }) {
|
|
216
|
+
const prior = Number(recoverAttempts) || 0;
|
|
217
|
+
const attempt = prior + 1;
|
|
218
|
+
return { attempt, proceed: attempt <= max, max };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// improve40 — select the orphaned `add` jobs that must be reconciled from the
|
|
222
|
+
// VM's REAL provision state even though their per-job sidecar is GONE. The
|
|
223
|
+
// durable re-attach path (sidecar + exit marker + recorded pid) cannot fire for
|
|
224
|
+
// these — a restart left only the snapshot record — so the snapshot job's
|
|
225
|
+
// stashed `config` is the sole cfg source and a live gcloud probe is the only
|
|
226
|
+
// ground truth. Pure: takes the restored jobs map + the set of `<action>-<n>`
|
|
227
|
+
// sidecar keys the sidecar reap already owns; returns the [{ n, cfg }] the boot
|
|
228
|
+
// path hands to reconcileViaGcloud. A job is selected when ALL hold:
|
|
229
|
+
// - it is an `add`,
|
|
230
|
+
// - it is flagged `restartOrphaned` (restoreFromSnapshot downgraded a
|
|
231
|
+
// running add → error because the dashboard restarted before it finished —
|
|
232
|
+
// this is the ONLY error state we re-probe; an add that failed IN-PROCESS
|
|
233
|
+
// carries a plain terminal `error` with no flag and stays surfaced), AND
|
|
234
|
+
// - NO `add-<n>` sidecar exists (a sidecar means the sidecar reap already
|
|
235
|
+
// owns it — never double-reconcile).
|
|
236
|
+
// `cfg` rides from the job's `config`; null when the snapshot never carried one
|
|
237
|
+
// (older snapshot) — the caller still probes, but a roster reconcile then can't
|
|
238
|
+
// recordLaunch, exactly mirroring the sidecar path's `meta.cfg`-absent case.
|
|
239
|
+
function orphanedAddsToReconcile({ jobs, sidecarKeys } = {}) {
|
|
240
|
+
const sidecars = sidecarKeys instanceof Set ? sidecarKeys : new Set(sidecarKeys || []);
|
|
241
|
+
const src = (jobs && typeof jobs === 'object') ? jobs : {};
|
|
242
|
+
const out = [];
|
|
243
|
+
for (const [k, j] of Object.entries(src)) {
|
|
244
|
+
if (!j || typeof j !== 'object') continue;
|
|
245
|
+
if (j.action !== 'add') continue;
|
|
246
|
+
if (!j.restartOrphaned) continue;
|
|
247
|
+
if (sidecars.has(`add-${k}`)) continue;
|
|
248
|
+
out.push({ n: Number(k), cfg: j.config || null });
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// improve41 — the destroy-direction mirror of orphanedAddsToReconcile: decide
|
|
254
|
+
// what to do with a VM that is STILL in the roster but whose GCP instance state
|
|
255
|
+
// we have just re-probed. The bug this closes: a Destroy that races a dashboard
|
|
256
|
+
// restart can leave the instance deleted while the startup reap took the
|
|
257
|
+
// `clear-only` path (instance still present at reap time) and dropped the only
|
|
258
|
+
// sidecar — so nothing ever runs roster cleanup and the VM polls forever
|
|
259
|
+
// "unreachable." A sidecar-independent reconcile, keyed on REAL GCP existence
|
|
260
|
+
// rather than on a pending-job sidecar, catches it. Pure: server.js runs the
|
|
261
|
+
// gcloud describe (→ exists, via classifyDescribeError) and reads session /
|
|
262
|
+
// pending-add state, and hands the three facts here so the matrix is testable
|
|
263
|
+
// without a live VM.
|
|
264
|
+
// verdict when dispatch
|
|
265
|
+
// 'cleanup' instance gone AND the VM is idle runDestroyCleanup
|
|
266
|
+
// 'leave' exists uncertain (null), OR the VM is busy do nothing this pass
|
|
267
|
+
// 'keep' instance still exists do nothing (healthy)
|
|
268
|
+
// Fail-safe by construction: cleanup fires ONLY on a DEFINITIVE absence
|
|
269
|
+
// (exists === false) — a flaky/transient describe (null) is always 'leave', so a
|
|
270
|
+
// gcloud blip can never evict a healthy VM (mirroring reapVerdict's `leave`
|
|
271
|
+
// posture). "Busy" (a live session or an in-flight add to this number) also
|
|
272
|
+
// holds the entry for an operator rather than auto-removing — the shouldn't-
|
|
273
|
+
// happen-but-fail-safe case where a vanished instance still reports a session.
|
|
274
|
+
function reconcileRosteredVm({ exists, hasSession, hasPendingAdd } = {}) {
|
|
275
|
+
if (exists === null || exists === undefined) return "leave";
|
|
276
|
+
if (exists) return "keep";
|
|
277
|
+
if (hasSession || hasPendingAdd) return "leave";
|
|
278
|
+
return "cleanup";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = {
|
|
282
|
+
parsePendingJobFilename,
|
|
283
|
+
persistPendingJob,
|
|
284
|
+
clearPendingJob,
|
|
285
|
+
listPendingJobs,
|
|
286
|
+
classifyDescribeError,
|
|
287
|
+
reapVerdict,
|
|
288
|
+
reconcileRosteredVm,
|
|
289
|
+
orphanedAddsToReconcile,
|
|
290
|
+
classifyProjectWiring,
|
|
291
|
+
recoverBootstrapDecision,
|
|
292
|
+
exitMarkerName,
|
|
293
|
+
parseExitMarker,
|
|
294
|
+
readExitMarker,
|
|
295
|
+
clearExitMarker,
|
|
296
|
+
reattachVerdict,
|
|
297
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure predicate module for the Fleet Dashboard's per-button "will clicking
|
|
3
|
+
// this change the VM, or is it already in that state?" indicators
|
|
4
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). Given an
|
|
5
|
+
// already-gathered FACTS object — no I/O, no git/docker exec; server.js does
|
|
6
|
+
// the impure fact-gathering — computeButtonEffects returns a per-button verdict
|
|
7
|
+
// map. Mirrors the pure-module-plus-colocated-vitest-test shape of
|
|
8
|
+
// npm/fleet-reset.js so the whole decision table is unit-testable without
|
|
9
|
+
// spawning a process.
|
|
10
|
+
//
|
|
11
|
+
// Each verdict is { effect, reason }:
|
|
12
|
+
// - 'changes' — clicking does something; `reason` says what.
|
|
13
|
+
// - 'noop' — already in that state; clicking changes nothing.
|
|
14
|
+
// - 'recommended' — soft signal (run-setup only): a reset/update landed after
|
|
15
|
+
// the last successful setup. NEVER a confident green no-op.
|
|
16
|
+
// - 'unknown' — not yet checked, VM unreachable, or a needed fact is
|
|
17
|
+
// missing. We never CLAIM a safe no-op we couldn't verify —
|
|
18
|
+
// silent failure here would be worse than no indicator.
|
|
19
|
+
//
|
|
20
|
+
// The predicate for each button is derived from what that button actually
|
|
21
|
+
// touches (its tooltip in public/index.html is the source of truth): the sync
|
|
22
|
+
// buttons key off a git commit comparison, the reset buttons off
|
|
23
|
+
// working-tree/session/`.codeyam` state, and rebuild off a build-commit
|
|
24
|
+
// comparison.
|
|
25
|
+
|
|
26
|
+
// Short sha for compact reason strings. Tolerates null / short input.
|
|
27
|
+
function short(sha) {
|
|
28
|
+
return sha ? String(sha).slice(0, 7) : '?';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// "origin/<branch>" — or a bare "origin" when the branch is unknown.
|
|
32
|
+
function originRef(branch) {
|
|
33
|
+
return branch ? `origin/${branch}` : 'origin';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "N commit(s) behind origin/<branch>" with correct singular/plural.
|
|
37
|
+
function behindReason(behind, branch) {
|
|
38
|
+
return `${behind} commit${behind === 1 ? '' : 's'} behind ${originRef(branch)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Compose a short reason listing which signals make a reset non-trivial, so the
|
|
42
|
+
// operator sees WHAT a Reset would discard rather than a bare "changes". The
|
|
43
|
+
// `agent` (a live build agent in the broker) leads the list — it's the most
|
|
44
|
+
// consequential thing a reset's `pty-broker stop` would silently discard.
|
|
45
|
+
function resetReason({ agent, session, dirty, behind, codeyam }) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
if (agent) parts.push('running build agent');
|
|
48
|
+
if (dirty) parts.push('uncommitted changes');
|
|
49
|
+
if (behind) parts.push('source behind origin');
|
|
50
|
+
if (session) parts.push('active session');
|
|
51
|
+
if (codeyam) parts.push('.codeyam state');
|
|
52
|
+
return parts.length ? `will discard: ${parts.join(', ')}` : 'will reset state';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The action ids governed by an effect indicator — the same ids server.js /
|
|
56
|
+
// index.html use for the buttons. Reconfigure / Env / Destroy are deliberately
|
|
57
|
+
// absent (one-shots, not converge-to-a-state actions, so a no-op predicate is
|
|
58
|
+
// meaningless for them).
|
|
59
|
+
const ACTION_IDS = [
|
|
60
|
+
'update-client-source',
|
|
61
|
+
'update-editor-source',
|
|
62
|
+
'reset-editor-source',
|
|
63
|
+
'reset-client',
|
|
64
|
+
'rebuild-binary',
|
|
65
|
+
'update-reset-everything',
|
|
66
|
+
'run-setup',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// facts (a plain object gathered by server.js — no I/O):
|
|
70
|
+
// {
|
|
71
|
+
// reachable: boolean,
|
|
72
|
+
// hasSession: boolean,
|
|
73
|
+
// client: { headSha, originSha, behind, dirty } | null, // /workspace
|
|
74
|
+
// editor: { headSha, originSha, behind, dirty, buildCommit } | null,
|
|
75
|
+
// codeyamDirty: boolean,
|
|
76
|
+
// setupRanSince: boolean, // a setup completed after the last reset/update
|
|
77
|
+
// clientBranch: string | null, // for reason text only
|
|
78
|
+
// editorBranch: string | null, // for reason text only
|
|
79
|
+
// }
|
|
80
|
+
function computeButtonEffects(facts) {
|
|
81
|
+
const f = facts || {};
|
|
82
|
+
|
|
83
|
+
// Unreachable VM (or no facts at all): every governed button is unknown. We
|
|
84
|
+
// could not reach the editor to gather facts, so never render a green no-op.
|
|
85
|
+
if (!f.reachable) {
|
|
86
|
+
const out = {};
|
|
87
|
+
for (const id of ACTION_IDS) out[id] = { effect: 'unknown', reason: 'VM unreachable' };
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const client = f.client || null;
|
|
92
|
+
const editor = f.editor || null;
|
|
93
|
+
const hasSession = !!f.hasSession;
|
|
94
|
+
const codeyamDirty = !!f.codeyamDirty;
|
|
95
|
+
// A live build agent in the broker. A reset's first step is `pty-broker stop`,
|
|
96
|
+
// which kills it regardless of whether a workflow session is registered — so
|
|
97
|
+
// this is discardable work even when hasSession is false. Explicit-true only:
|
|
98
|
+
// a null/absent value (older or unreachable VM) must NOT flip a genuine noop.
|
|
99
|
+
const brokerLive = f.brokerLive === true;
|
|
100
|
+
|
|
101
|
+
// (1) Update client — ff-advance /workspace to origin tip. Changes iff behind.
|
|
102
|
+
const updateClient = client == null
|
|
103
|
+
? { effect: 'unknown', reason: 'client state not checked' }
|
|
104
|
+
: client.behind > 0
|
|
105
|
+
? { effect: 'changes', reason: behindReason(client.behind, f.clientBranch) }
|
|
106
|
+
: { effect: 'noop', reason: 'up to date with ' + originRef(f.clientBranch) };
|
|
107
|
+
|
|
108
|
+
// (2) Update editor — advance the editor source the binary builds from.
|
|
109
|
+
const updateEditor = editor == null
|
|
110
|
+
? { effect: 'unknown', reason: 'editor state not checked' }
|
|
111
|
+
: editor.behind > 0
|
|
112
|
+
? { effect: 'changes', reason: behindReason(editor.behind, f.editorBranch) }
|
|
113
|
+
: { effect: 'noop', reason: 'up to date with ' + originRef(f.editorBranch) };
|
|
114
|
+
|
|
115
|
+
// (5) Rebuild binary — advances source then compiles. Changes iff the running
|
|
116
|
+
// binary's build commit differs from the editor origin tip. Unknown when we
|
|
117
|
+
// don't have both commits to compare.
|
|
118
|
+
const rebuild = (editor == null || !editor.buildCommit || !editor.originSha)
|
|
119
|
+
? { effect: 'unknown', reason: 'binary/source commit not checked' }
|
|
120
|
+
: editor.buildCommit !== editor.originSha
|
|
121
|
+
? { effect: 'changes', reason: `binary at ${short(editor.buildCommit)}, ${originRef(f.editorBranch)} at ${short(editor.originSha)}` }
|
|
122
|
+
: { effect: 'noop', reason: 'binary built from ' + originRef(f.editorBranch) + ' tip' };
|
|
123
|
+
|
|
124
|
+
// (3) Reset editor — discards uncommitted + session, advances source, wipes
|
|
125
|
+
// .codeyam. No-op ONLY when clean at origin tip with no session.
|
|
126
|
+
const resetEditor = editor == null
|
|
127
|
+
? { effect: 'unknown', reason: 'editor state not checked' }
|
|
128
|
+
: (brokerLive || hasSession || editor.dirty || editor.behind > 0 || codeyamDirty)
|
|
129
|
+
? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: editor.dirty, behind: editor.behind > 0, codeyam: codeyamDirty }) }
|
|
130
|
+
: { effect: 'noop', reason: 'already clean at origin tip, no session' };
|
|
131
|
+
|
|
132
|
+
// (3b) Reset client — hard-clean /workspace + .codeyam, drop the session.
|
|
133
|
+
// No source-advance, so `behind` is NOT part of this predicate.
|
|
134
|
+
const resetClient = client == null
|
|
135
|
+
? { effect: 'unknown', reason: 'client state not checked' }
|
|
136
|
+
: (brokerLive || client.dirty || codeyamDirty || hasSession)
|
|
137
|
+
? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: client.dirty, behind: false, codeyam: codeyamDirty }) }
|
|
138
|
+
: { effect: 'noop', reason: 'already clean, no session' };
|
|
139
|
+
|
|
140
|
+
// (4b) Update & Reset Everything — composite. Changes iff ANY component would
|
|
141
|
+
// change: rebuild-binary, reset-editor-source, or the client being
|
|
142
|
+
// behind/dirty. Reason summarizes which parts.
|
|
143
|
+
const everything = (() => {
|
|
144
|
+
if (editor == null || client == null) {
|
|
145
|
+
return { effect: 'unknown', reason: 'state not checked' };
|
|
146
|
+
}
|
|
147
|
+
const parts = [];
|
|
148
|
+
if (rebuild.effect === 'changes') parts.push('rebuild');
|
|
149
|
+
if (resetEditor.effect === 'changes') parts.push('reset editor');
|
|
150
|
+
if (client.behind > 0 || client.dirty) parts.push('reset client');
|
|
151
|
+
if (parts.length > 0) return { effect: 'changes', reason: parts.join(' + ') };
|
|
152
|
+
// No part would change. But if a component was itself unverifiable, don't
|
|
153
|
+
// claim a confident no-op — fall back to unknown.
|
|
154
|
+
if (rebuild.effect === 'unknown' || resetEditor.effect === 'unknown') {
|
|
155
|
+
return { effect: 'unknown', reason: 'state not checked' };
|
|
156
|
+
}
|
|
157
|
+
return { effect: 'noop', reason: 'everything already at origin tip, no session' };
|
|
158
|
+
})();
|
|
159
|
+
|
|
160
|
+
// (run-setup) Honest soft signal — there is no cheap upstream proof that
|
|
161
|
+
// `npm run setup` would change nothing (DB/migration state isn't
|
|
162
|
+
// introspected). `recommended` when a reset/update landed after the last
|
|
163
|
+
// successful setup; otherwise `unknown`. Never a confident green no-op.
|
|
164
|
+
const runSetup = f.setupRanSince
|
|
165
|
+
? { effect: 'unknown', reason: 'no setup-affecting change since last setup' }
|
|
166
|
+
: { effect: 'recommended', reason: 'a reset/update landed since the last setup' };
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
'update-client-source': updateClient,
|
|
170
|
+
'update-editor-source': updateEditor,
|
|
171
|
+
'reset-editor-source': resetEditor,
|
|
172
|
+
'reset-client': resetClient,
|
|
173
|
+
'rebuild-binary': rebuild,
|
|
174
|
+
'update-reset-everything': everything,
|
|
175
|
+
'run-setup': runSetup,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { computeButtonEffects, ACTION_IDS };
|
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 };
|