@codeyam-editor/codeyam-editor 0.1.15 → 0.1.16

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.
@@ -59,6 +59,27 @@ function parsePublishedAppPort(stdout) {
59
59
  return isValidPort(port) ? port : null;
60
60
  }
61
61
 
62
+ const PUBLISHED_BEGIN = "<<<PUBLISHED_PORTS>>>";
63
+ const PUBLISHED_END = "<<<END_PUBLISHED_PORTS>>>";
64
+
65
+ /** The published-port read wrapped in begin/end markers, so it can ride an SSH
66
+ * round-trip that also prints other output (the dashboard folds it into its
67
+ * existing per-VM fact gather rather than paying a second SSH). */
68
+ function buildPublishedAppPortMarkedCommand() {
69
+ return `echo '${PUBLISHED_BEGIN}'; ${buildPublishedAppPortReadCommand()}; echo '${PUBLISHED_END}'`;
70
+ }
71
+
72
+ /** Pull the published app port out of that marked section, or null when the
73
+ * section is missing or truncated — never a guess. */
74
+ function extractPublishedAppPort(stdout) {
75
+ if (typeof stdout !== "string") return null;
76
+ const begin = stdout.indexOf(PUBLISHED_BEGIN);
77
+ if (begin === -1) return null;
78
+ const end = stdout.indexOf(PUBLISHED_END, begin);
79
+ if (end === -1) return null;
80
+ return parsePublishedAppPort(stdout.slice(begin + PUBLISHED_BEGIN.length, end));
81
+ }
82
+
62
83
  /** Parse `cloud-session-idle.sh` stdout into `idle` | `active` | null. Null
63
84
  * (unknown) is deliberately NOT idle — the same asymmetry that script and
64
85
  * `converge-vm.sh --converge-env` honour. */
@@ -161,6 +182,8 @@ module.exports = {
161
182
  EDITOR_CONTROL_PORT,
162
183
  buildPublishedAppPortReadCommand,
163
184
  parsePublishedAppPort,
185
+ buildPublishedAppPortMarkedCommand,
186
+ extractPublishedAppPort,
164
187
  parseIdleVerdict,
165
188
  appPortDriftRemediations,
166
189
  collectAppPortDrift,
@@ -0,0 +1,309 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-add-failure-evidence.js — the per-arm transcript readers and
4
+ // operator-facing recovery copy behind classifyAddFailure (fleet-add-failure.js).
5
+ // Each arm that needs more than a regex reads its facts out of the provision
6
+ // transcript here, and each arm whose wording varies with those facts builds its
7
+ // recovery line here, so the copy is asserted directly rather than only through
8
+ // the classifier. Pure: no I/O, never throws on empty input.
9
+
10
+ // Name the capacity GCE was short of, from whatever the stockout transcript
11
+ // happens to expose — for the capacity-stockout recovery line. GCE reports this
12
+ // inconsistently: the 2026-08-20 VM-11 transcript carried `vmType:` / `zone:`
13
+ // bullets under errorInfo.metadatas, while the 2026-08-18 VM-13 spelling
14
+ // (ZONE_RESOURCE_POOL_EXHAUSTED_WITH_DETAILS) carried neither. So every part is
15
+ // optional and the phrase degrades to a generic-but-true sentence rather than
16
+ // interpolating `undefined` into operator-facing copy. Extracted alongside
17
+ // nextValidActionRemedy (the same shape: a pure transcript reader that enriches
18
+ // one arm's recovery text) so the degradation is asserted directly.
19
+ //
20
+ // `zone:` is anchored with ^\s* so the neighbouring `zonesAvailable:` bullet —
21
+ // which lists the zones that DID have capacity — cannot be mistaken for the zone
22
+ // that failed. Never throws; returns the generic phrase for empty input.
23
+ function stockoutCapacityPhrase(logTail) {
24
+ const log = String(logTail || '');
25
+ const vmType = (log.match(/^\s*vmType:\s*(\S+)/m) || [])[1];
26
+ const zone = (log.match(/^\s*zone:\s*(\S+)/m) || [])[1];
27
+ return [vmType, 'capacity', zone ? `in ${zone}` : 'in the target zone']
28
+ .filter(Boolean)
29
+ .join(' ');
30
+ }
31
+
32
+ // The spellings git uses for "that branch is not on the remote", each capturing
33
+ // the ref name. The union is not defensive padding — the two bootstrap scripts
34
+ // reach the remote with DIFFERENT git commands, and git words the same condition
35
+ // differently for each:
36
+ // `git fetch <url> <branch>` (clone_editor_repo, scripts/gcp-bootstrap.sh)
37
+ // → fatal: couldn't find remote ref <ref>
38
+ // `git clone --branch <b>` (clone_project, scripts/gcp-project-bootstrap.sh)
39
+ // → fatal: Remote branch <b> not found in upstream origin
40
+ // Keying on only the first — the one the 2026-08-25 transcripts happened to
41
+ // carry — would miss every client-side clone failure. Same union-of-spellings
42
+ // lesson capacity-stockout learned from GCE.
43
+ //
44
+ // Deliberately NOT included: git's `warning: Could not find remote branch …`,
45
+ // which is a submodule-update warning that accompanies a SUCCEEDING command.
46
+ // Matching it here would let an add that failed for an unrelated reason claim
47
+ // this category — and this arm sits above the generic one, so a false positive
48
+ // would suppress the true cause rather than merely add noise.
49
+ const MISSING_REF_PATTERNS = [
50
+ /couldn't find remote ref (\S+)/i,
51
+ /Remote branch (\S+) not found in upstream/i,
52
+ ];
53
+
54
+ // Which branch died, and which config field carried it — read from the transcript.
55
+ //
56
+ // The field matters because clientBranch and editorBranch are configured in
57
+ // different places: an operator who corrects only one re-issues a request that
58
+ // fails identically. It is derived from the log prefix of the nearest preceding
59
+ // marker line, because the two bootstrap scripts brand their output distinctly
60
+ // (`[gcp-bootstrap]` builds the editor image from the editor branch;
61
+ // `[project-bootstrap]` clones the client repo at the client branch). When the
62
+ // transcript carries neither marker the field is left null and the copy names
63
+ // both fields rather than guessing — the same degrade-to-true-but-generic
64
+ // discipline stockoutCapacityPhrase uses. Never throws; returns
65
+ // { ref: null, field: null } for input with no missing-ref signature.
66
+ function missingBranchRefFacts(logTail) {
67
+ const lines = String(logTail || '').split('\n');
68
+ for (let i = 0; i < lines.length; i += 1) {
69
+ let ref = null;
70
+ for (const re of MISSING_REF_PATTERNS) {
71
+ const m = lines[i].match(re);
72
+ if (m) { ref = m[1]; break; }
73
+ }
74
+ if (!ref) continue;
75
+ let field = null;
76
+ for (let j = i; j >= 0; j -= 1) {
77
+ if (/\[project-bootstrap\]/.test(lines[j])) { field = 'clientBranch'; break; }
78
+ if (/\[gcp-bootstrap\]/.test(lines[j])) { field = 'editorBranch'; break; }
79
+ }
80
+ return { ref, field };
81
+ }
82
+ return { ref: null, field: null };
83
+ }
84
+
85
+ // Operator-facing recovery copy for the missing-branch-ref arm.
86
+ //
87
+ // Extracted from the arm for the same reason stockoutCapacityPhrase was — it is
88
+ // the one arm whose copy VARIES with what the transcript happened to expose, so
89
+ // pulling it out lets the wording be asserted directly instead of only through
90
+ // classifyAddFailure, and keeps the arm itself a two-line decision.
91
+ //
92
+ // Two properties are load-bearing and are what the tests pin:
93
+ // - It states the outcome FIRST and never borrows the generic arm's "a partial
94
+ // instance may exist — reconcile or destroy it" language. At this point git
95
+ // has aborted before any image build or container work, so that advice sends
96
+ // the operator hunting for a box that was never created — the inversion that
97
+ // cost the 2026-08-25 recovery its first hour.
98
+ // - The remedy is a DIFFERENT action, never "retry". Re-issuing the same
99
+ // config against a deleted ref fails identically forever, which is exactly
100
+ // why this category is kept out of ADD_FAILURE_RETRYABLE.
101
+ //
102
+ // `field` is optional: when the transcript carries no bootstrap marker the copy
103
+ // names both fields rather than guessing which one carried the dead ref.
104
+ // Pure; no I/O.
105
+ function missingBranchRefRecovery({ ref, field } = {}) {
106
+ const carried = field
107
+ ? `Its ${field} still points at it`
108
+ : 'Its clientBranch and editorBranch both need checking';
109
+ return (
110
+ `Branch '${ref}' is not on origin any more — it was almost certainly pruned when it merged. ` +
111
+ 'Nothing was provisioned: git aborted before any image build or container work, so there is no instance, ' +
112
+ `disk, NEG or backend service to clean up, and nothing to destroy. ${carried}, so retrying as-is will fail ` +
113
+ 'identically every time. Re-add on a branch that still exists (the current fleet branch is the usual answer).'
114
+ );
115
+ }
116
+
117
+ // Facts for the wedged-insert arm: a GCE instance insert that was ACCEPTED and
118
+ // then never finished. Returns { wedged, code, instance } — `code` is the GCE
119
+ // support code when the transcript carried one, so the recovery copy can hand the
120
+ // operator the exact string a Support case needs instead of making them dig it out
121
+ // of the transcript by hand.
122
+ //
123
+ // Two spellings reach here and both are proof, so the match is a UNION:
124
+ // A. The raw GCE prose, which is what the 2026-09-04 VM-8 transcript carried:
125
+ // `Internal error. Please try again or contact Google Support. (Code: '…')`
126
+ // PAIRED with evidence this run actually attempted a create. The pairing is
127
+ // what keeps the arm narrow — the same prose surfacing from some other
128
+ // gcloud call is not an insert that wedged, and must not claim to be one.
129
+ // B. ensureInstance's own fast-fail line (npm/cloud.js), which fires when a
130
+ // re-add finds the slot still parked in PROVISIONING behind an operation
131
+ // that never reached DONE. That path exists precisely so the transcript
132
+ // carries a signature instead of an uninformative 2-minute timeout, so this
133
+ // arm keys on it directly.
134
+ //
135
+ // Deliberately NOT keyed on elapsed time. A duration heuristic ("PROVISIONING for
136
+ // too long") would misfire on a legitimately slow create; an INTERNAL_ERROR on an
137
+ // accepted insert is provable evidence, which is the same signature-over-duration
138
+ // discipline the capacity-stockout arm uses.
139
+ const WEDGED_INSERT_PROSE = /Internal error\. Please try again or contact Google Support/i;
140
+ const WEDGED_INSERT_FASTFAIL = /stuck in PROVISIONING behind a wedged create operation/i;
141
+ const WEDGED_INSERT_CREATE_ATTEMPTED = /not found\s*—\s*creating|Failed to create instance|did not reach RUNNING/i;
142
+
143
+ function wedgedInsertFacts(logTail) {
144
+ const log = String(logTail || '');
145
+ const wedged = WEDGED_INSERT_FASTFAIL.test(log)
146
+ || (WEDGED_INSERT_PROSE.test(log) && WEDGED_INSERT_CREATE_ATTEMPTED.test(log));
147
+ if (!wedged) return { wedged: false, code: null, instance: null };
148
+ return {
149
+ wedged: true,
150
+ code: (log.match(/\(Code:\s*'(-?\d+)'\)/) || [])[1] || null,
151
+ instance: (log.match(/Checking instance\s+(\S+?)\.\.\./) || [])[1] || null,
152
+ };
153
+ }
154
+
155
+ // Operator-facing recovery copy for the wedged-insert arm, extracted for the same
156
+ // reason stockoutCapacityPhrase and missingBranchRefRecovery were: its wording
157
+ // varies with what the transcript exposed, so pulling it out lets the copy be
158
+ // asserted directly rather than only through classifyAddFailure.
159
+ //
160
+ // Three properties are load-bearing and are what the tests pin:
161
+ // - It never borrows the generic arm's "reconcile or destroy it" line. A delete
162
+ // against a wedged slot is ACCEPTED and then queues behind the wedged insert
163
+ // rather than clearing it, so that advice sends the operator to an action that
164
+ // provably does not work — the same inversion that cost the 2026-08-25
165
+ // missing-branch-ref recovery its first hour.
166
+ // - It says plainly that this is not the operator's fault and not their fix.
167
+ // The only thing that resolves it is GCE-side.
168
+ // - It names the Support code when one was captured, because that string is
169
+ // what a Google Cloud Support case is keyed on.
170
+ function wedgedInsertRecovery({ code, instance } = {}) {
171
+ const named = instance || 'the instance';
172
+ const cite = code ? ` citing operation error code ${code}` : '';
173
+ return (
174
+ `GCE accepted the create for ${named} and then never finished it: the insert stayed at status RUNNING, progress 0 ` +
175
+ 'and eventually attached an INTERNAL_ERROR without ever reaching DONE, leaving the instance in PROVISIONING and its ' +
176
+ 'boot disk in CREATING. Nothing on our side failed and there is nothing here to reconcile. Do NOT retry — the ' +
177
+ 'instance name is held by the wedged operation, so a re-add cannot succeed while it is outstanding. Do NOT try to ' +
178
+ 'destroy it either — a delete is accepted but queues behind the wedged insert instead of clearing it. This needs ' +
179
+ `GCE-side resolution: open a Google Cloud Support case${cite}. If you need the slot before Google releases it, add ` +
180
+ 'on a different VM number.'
181
+ );
182
+ }
183
+
184
+ // Facts for the iap-not-ready arm: waitForIapReady (npm/cloud.js) threw its
185
+ // readiness timeout. Returns { timedOut, instance, budgetSecs, cause } — `cause` is
186
+ // the label waitForIapReady derived from its probe stderr (null when none of its
187
+ // signatures matched, or on a transcript from before the label existed), so the
188
+ // recovery copy can name WHICH wait was blown instead of only `return code [255]`.
189
+ //
190
+ // The signature is emitted from exactly one place, and that place runs before any
191
+ // bootstrap work, which is what lets this arm assert the instance is bare.
192
+ const IAP_NOT_READY = /IAP did not become ready for (\S+) within (\d+)s/;
193
+
194
+ function iapNotReadyFacts(logTail) {
195
+ const m = String(logTail || '').match(IAP_NOT_READY);
196
+ if (!m) return { timedOut: false, instance: null, budgetSecs: null, cause: null };
197
+ const cause = (String(logTail).match(/likely cause: ([^;)]+)/) || [])[1] || null;
198
+ return { timedOut: true, instance: m[1], budgetSecs: Number(m[2]), cause: cause && cause.trim() };
199
+ }
200
+
201
+ // Operator-facing recovery copy for the iap-not-ready arm. It states what the
202
+ // generic arm cannot: the instance EXISTS and is BARE. "A partial instance may
203
+ // exist" is what steered VM-4's operator toward destroy + re-add (15-25 minutes)
204
+ // when Retry resumes on the same box in seconds — and it left nothing on the card
205
+ // saying a RUNNING, billing instance was sitting there.
206
+ function iapNotReadyRecovery({ instance, budgetSecs, cause } = {}) {
207
+ const named = instance || 'The instance';
208
+ const within = budgetSecs ? ` within ${budgetSecs}s` : '';
209
+ const why = cause ? ` (likely cause: ${cause})` : '';
210
+ return (
211
+ `${named} was created and is RUNNING, but SSH over IAP did not answer${within}${why}. This is usually a transient ` +
212
+ 'control-plane race. Nothing was installed on it — the wait runs before any bootstrap step — so it is a bare box and ' +
213
+ 'there is nothing partial to reconcile. Retry add resumes on this same instance rather than creating a second one. ' +
214
+ 'Until you retry or destroy it, the instance keeps running and billing.'
215
+ );
216
+ }
217
+
218
+ // Facts for the streamed-library-missing arm: a streamed bootstrap script sourced
219
+ // a `$HOME`-rooted `lib/…` file that was never shipped to the VM, and bash
220
+ // reported `<script>: line N: <path>: No such file or directory`. This took ALL
221
+ // fleet provisioning down twice (lib/npm-pin.sh 2026-09-04, lib/render-cloud-
222
+ // compose.sh 2026-09-18). Returns { missing, script, library } — `script` is the
223
+ // basename of the aborting script, `library` the `lib/…` path relative to $HOME,
224
+ // which is how PROJECT_BOOTSTRAP_LIBS (npm/cloud.js) names its entries.
225
+ const STREAMED_LIBRARY_MISSING = /(\S+): line \d+: \/(?:home\/[^/\s]+|root)\/(lib\/[^\s:]+): No such file or directory/;
226
+
227
+ function streamedLibraryMissingFacts(logTail) {
228
+ const m = String(logTail || '').match(STREAMED_LIBRARY_MISSING);
229
+ if (!m) return { missing: false, script: null, library: null };
230
+ return { missing: true, script: m[1].split('/').pop(), library: m[2] };
231
+ }
232
+
233
+ // Did cloud:up report that it deleted the instance it created this run? The abort
234
+ // path (npm/cloud.js) prints the teardown marker BEFORE it issues the delete, and
235
+ // prints a `during abort teardown` warning when that delete fails — so the marker
236
+ // alone is a claim of intent, and only the marker WITHOUT the warning is evidence
237
+ // that no orphan was left behind.
238
+ function cleanTeardownReported(logTail) {
239
+ const log = String(logTail || '');
240
+ return /tearing down the VM cloud:up created this run \(no orphan left behind\)/.test(log)
241
+ && !/during abort teardown/.test(log);
242
+ }
243
+
244
+ // Operator-facing recovery copy for the streamed-library-missing arm. It names
245
+ // what to fix — register the library so it is streamed — and, when the transcript
246
+ // proves a clean teardown, says there is no orphan to hunt for. Every add on this
247
+ // branch fails identically until the registry is fixed, so it never offers Retry.
248
+ function streamedLibraryMissingRecovery({ script, library, cleanTeardown } = {}) {
249
+ const lib = library || 'a lib/ file';
250
+ const from = script ? ` sourced by ${script}` : '';
251
+ const orphan = cleanTeardown
252
+ ? 'cloud:up already deleted the VM it created, so there is nothing to reconcile or destroy.'
253
+ : 'The transcript does not confirm the VM was deleted — check whether a partial instance exists before re-adding.';
254
+ return (
255
+ `${lib}${from} was never shipped to the VM, so the bootstrap aborted. Add it to PROJECT_BOOTSTRAP_LIBS in npm/cloud.js, ` +
256
+ `push, and re-add — every add from this branch fails the same way until then. ${orphan}`
257
+ );
258
+ }
259
+
260
+ // Did the OPERATOR HOST run out of filesystem? Node spells it `ENOSPC: no space
261
+ // left on device`, the shell `No space left on device`, gcloud `OSError: [Errno
262
+ // 28] No space left on device` — matched on the message, never on the errno
263
+ // number, which means something else on other platforms. A transcript that says a
264
+ // script "failed on the VM" is quoting the VM's own log tail (npm/cloud.js prints
265
+ // that header above it), so an ENOSPC there is the VM's disk, not the host's, and
266
+ // stays with the generic arm.
267
+ const HOST_DISK_EXHAUSTED = /ENOSPC: no space left on device|No space left on device/i;
268
+
269
+ function hostDiskExhausted(logTail) {
270
+ const log = String(logTail || '');
271
+ return HOST_DISK_EXHAUSTED.test(log) && !/failed on the VM/i.test(log);
272
+ }
273
+
274
+ // Operator-facing recovery copy for the host-disk-exhausted arm. The ENOSPC fires
275
+ // on the operator host before `instances create`, so nothing exists on GCP; the
276
+ // fix is freeing the host's filesystem, which every add needs first.
277
+ function hostDiskExhaustedRecovery() {
278
+ return (
279
+ 'The operator host ran out of filesystem space (bytes or inodes) before anything was created, so there is no VM to ' +
280
+ 'reconcile or destroy. Prune old date directories under /root/.config/gcloud/logs — gcloud writes one log per ' +
281
+ 'invocation and is the usual cause — and confirm with `df -i` and `df -h`. Every add fails the same way until then.'
282
+ );
283
+ }
284
+
285
+ // Operator-facing recovery copy for the generic cloud-up-failed arm. "A partial
286
+ // instance may exist" is only true when the transcript does not show cloud:up
287
+ // deleting what it created; saying it regardless sends the operator hunting for
288
+ // an orphan that is not there.
289
+ function cloudUpFailedRecovery({ cleanTeardown } = {}) {
290
+ return cleanTeardown
291
+ ? 'cloud:up failed during provisioning and deleted the VM it created, so nothing was left behind. Read the transcript for the cause before re-adding.'
292
+ : 'cloud:up failed during provisioning, so a partial instance may exist. Reconcile or destroy this VM before re-adding.';
293
+ }
294
+
295
+ module.exports = {
296
+ stockoutCapacityPhrase,
297
+ missingBranchRefFacts,
298
+ missingBranchRefRecovery,
299
+ wedgedInsertFacts,
300
+ wedgedInsertRecovery,
301
+ iapNotReadyFacts,
302
+ iapNotReadyRecovery,
303
+ streamedLibraryMissingFacts,
304
+ cleanTeardownReported,
305
+ streamedLibraryMissingRecovery,
306
+ hostDiskExhausted,
307
+ hostDiskExhaustedRecovery,
308
+ cloudUpFailedRecovery,
309
+ };