@nimbus-sh/core 0.5.0 → 0.7.0

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.
Files changed (145) hide show
  1. package/README.md +47 -26
  2. package/dist/_shared/byte-stream.d.ts +5 -4
  3. package/dist/_shared/byte-stream.d.ts.map +1 -1
  4. package/dist/_shared/cache-stats.d.ts +23 -1
  5. package/dist/_shared/cache-stats.d.ts.map +1 -1
  6. package/dist/_shared/cache-stats.js +13 -1
  7. package/dist/_shared/exports-resolver.d.ts +1 -1
  8. package/dist/_shared/exports-resolver.js +1 -1
  9. package/dist/_shared/real-node-imports.d.ts +7 -0
  10. package/dist/_shared/real-node-imports.d.ts.map +1 -1
  11. package/dist/_shared/real-node-imports.js +8 -0
  12. package/dist/_shared/retry.js +1 -1
  13. package/dist/_shared/tarball.d.ts +23 -5
  14. package/dist/_shared/tarball.d.ts.map +1 -1
  15. package/dist/_shared/tarball.js +38 -5
  16. package/dist/_shared/vfs-write-ledger.d.ts.map +1 -1
  17. package/dist/_shared/vfs-write-ledger.js +22 -16
  18. package/dist/constants.d.ts +5 -11
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/constants.js +16 -56
  21. package/dist/runtime/assets-loader.js +1 -1
  22. package/dist/runtime/comment-strip.d.ts +0 -35
  23. package/dist/runtime/comment-strip.d.ts.map +1 -1
  24. package/dist/runtime/comment-strip.js +48 -20
  25. package/dist/runtime/esbuild-service.d.ts +38 -42
  26. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  27. package/dist/runtime/esbuild-service.js +494 -197
  28. package/dist/runtime/javascript-ast.d.ts.map +1 -1
  29. package/dist/runtime/javascript-ast.js +45 -7
  30. package/dist/runtime/opentui-wasm-backend.d.ts.map +1 -1
  31. package/dist/runtime/opentui-wasm-backend.js +18 -16
  32. package/dist/runtime/port-registry.d.ts +4 -2
  33. package/dist/runtime/port-registry.d.ts.map +1 -1
  34. package/dist/runtime/port-registry.js +60 -8
  35. package/dist/runtime/virtual-socket-kernel.generated.d.ts +1 -1
  36. package/dist/runtime/virtual-socket-kernel.generated.js +1 -1
  37. package/dist/shell/stdin-adapter.d.ts +11 -0
  38. package/dist/shell/stdin-adapter.d.ts.map +1 -0
  39. package/dist/shell/stdin-adapter.js +59 -0
  40. package/dist/shell/unix-commands.d.ts +2 -1
  41. package/dist/shell/unix-commands.d.ts.map +1 -1
  42. package/dist/shell/unix-commands.js +1078 -50
  43. package/dist/substrate/lifo/commands/io/dd.js +1 -1
  44. package/dist/substrate/lifo/commands/net/curl.d.ts.map +1 -1
  45. package/dist/substrate/lifo/commands/net/curl.js +270 -54
  46. package/dist/substrate/lifo/commands/system/npm.d.ts +18 -1
  47. package/dist/substrate/lifo/commands/system/npm.d.ts.map +1 -1
  48. package/dist/substrate/lifo/commands/system/npm.js +26 -43
  49. package/dist/substrate/lifo/commands/text/head.d.ts.map +1 -1
  50. package/dist/substrate/lifo/commands/text/head.js +2 -1
  51. package/dist/substrate/lifo/commands/text/sed.d.ts.map +1 -1
  52. package/dist/substrate/lifo/commands/text/sed.js +499 -117
  53. package/dist/substrate/lifo/commands/types.d.ts +8 -4
  54. package/dist/substrate/lifo/commands/types.d.ts.map +1 -1
  55. package/dist/substrate/lifo/node-compat/zlib.d.ts +37 -18
  56. package/dist/substrate/lifo/node-compat/zlib.d.ts.map +1 -1
  57. package/dist/substrate/lifo/node-compat/zlib.js +122 -23
  58. package/dist/substrate/lifo/shell/interpreter.d.ts +9 -1
  59. package/dist/substrate/lifo/shell/interpreter.d.ts.map +1 -1
  60. package/dist/substrate/lifo/shell/interpreter.js +79 -29
  61. package/dist/substrate/lifo/shell/pipe.d.ts +16 -0
  62. package/dist/substrate/lifo/shell/pipe.d.ts.map +1 -1
  63. package/dist/substrate/lifo/shell/pipe.js +62 -18
  64. package/dist/substrate/lifo/shell/terminal-stdin.d.ts +20 -3
  65. package/dist/substrate/lifo/shell/terminal-stdin.d.ts.map +1 -1
  66. package/dist/substrate/lifo/shell/terminal-stdin.js +60 -24
  67. package/dist/vfs/seed-project.d.ts.map +1 -1
  68. package/dist/vfs/seed-project.js +1 -1
  69. package/dist/vfs/sqlite-vfs.d.ts +21 -30
  70. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  71. package/dist/vfs/sqlite-vfs.js +113 -29
  72. package/dist/workspace/nimbus-workspace.d.ts +15 -0
  73. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  74. package/dist/workspace/nimbus-workspace.js +20 -3
  75. package/dist/workspace/supervisor-op.d.ts +23 -0
  76. package/dist/workspace/supervisor-op.d.ts.map +1 -0
  77. package/dist/workspace/supervisor-op.js +92 -0
  78. package/package.json +3 -2
  79. package/src/_shared/byte-stream.ts +5 -4
  80. package/src/_shared/cache-stats.ts +27 -1
  81. package/src/_shared/exports-resolver.ts +1 -1
  82. package/src/_shared/real-node-imports.ts +8 -0
  83. package/src/_shared/retry.ts +1 -1
  84. package/src/_shared/tarball.ts +56 -5
  85. package/src/_shared/vfs-write-ledger.ts +22 -16
  86. package/src/constants.ts +30 -60
  87. package/src/runtime/assets-loader.ts +1 -1
  88. package/src/runtime/comment-strip.ts +42 -23
  89. package/src/runtime/esbuild-service.ts +566 -217
  90. package/src/runtime/javascript-ast.ts +40 -9
  91. package/src/runtime/opentui-wasm-backend.ts +19 -17
  92. package/src/runtime/port-registry.ts +66 -8
  93. package/src/runtime/virtual-socket-kernel.generated.ts +1 -1
  94. package/src/shell/stdin-adapter.ts +58 -0
  95. package/src/shell/unix-commands.ts +1054 -50
  96. package/src/substrate/lifo/commands/io/dd.ts +2 -2
  97. package/src/substrate/lifo/commands/net/curl.ts +310 -58
  98. package/src/substrate/lifo/commands/system/npm.ts +58 -38
  99. package/src/substrate/lifo/commands/text/head.ts +2 -1
  100. package/src/substrate/lifo/commands/text/sed.ts +519 -109
  101. package/src/substrate/lifo/commands/types.ts +8 -5
  102. package/src/substrate/lifo/node-compat/zlib.ts +155 -26
  103. package/src/substrate/lifo/shell/interpreter.ts +74 -27
  104. package/src/substrate/lifo/shell/pipe.ts +65 -24
  105. package/src/substrate/lifo/shell/terminal-stdin.ts +66 -30
  106. package/src/vfs/seed-project.ts +3 -3
  107. package/src/vfs/sqlite-vfs.ts +128 -64
  108. package/src/workspace/nimbus-workspace.ts +26 -2
  109. package/src/workspace/supervisor-op.ts +117 -0
  110. package/dist/_shared/install-phase.d.ts +0 -47
  111. package/dist/_shared/install-phase.d.ts.map +0 -1
  112. package/dist/_shared/install-phase.js +0 -1
  113. package/dist/_shared/rpc-dispose.d.ts +0 -4
  114. package/dist/_shared/rpc-dispose.d.ts.map +0 -1
  115. package/dist/_shared/rpc-dispose.js +0 -30
  116. package/dist/_shared/w7-frame.d.ts +0 -79
  117. package/dist/_shared/w7-frame.d.ts.map +0 -1
  118. package/dist/_shared/w7-frame.js +0 -840
  119. package/dist/_shared/weighted-credit-pool.d.ts +0 -61
  120. package/dist/_shared/weighted-credit-pool.d.ts.map +0 -1
  121. package/dist/_shared/weighted-credit-pool.js +0 -196
  122. package/dist/observability/diag-counters.d.ts +0 -236
  123. package/dist/observability/diag-counters.d.ts.map +0 -1
  124. package/dist/observability/diag-counters.js +0 -195
  125. package/dist/observability/heap-estimate.d.ts +0 -225
  126. package/dist/observability/heap-estimate.d.ts.map +0 -1
  127. package/dist/observability/heap-estimate.js +0 -185
  128. package/dist/observability/heavy-alloc-coord.d.ts +0 -93
  129. package/dist/observability/heavy-alloc-coord.d.ts.map +0 -1
  130. package/dist/observability/heavy-alloc-coord.js +0 -181
  131. package/dist/observability/oom-classify.d.ts +0 -138
  132. package/dist/observability/oom-classify.d.ts.map +0 -1
  133. package/dist/observability/oom-classify.js +0 -268
  134. package/dist/observability/oom-discriminator.d.ts +0 -176
  135. package/dist/observability/oom-discriminator.d.ts.map +0 -1
  136. package/dist/observability/oom-discriminator.js +0 -272
  137. package/src/_shared/install-phase.ts +0 -56
  138. package/src/_shared/rpc-dispose.ts +0 -31
  139. package/src/_shared/w7-frame.ts +0 -1013
  140. package/src/_shared/weighted-credit-pool.ts +0 -244
  141. package/src/observability/diag-counters.ts +0 -343
  142. package/src/observability/heap-estimate.ts +0 -331
  143. package/src/observability/heavy-alloc-coord.ts +0 -241
  144. package/src/observability/oom-classify.ts +0 -260
  145. package/src/observability/oom-discriminator.ts +0 -405
@@ -1,260 +0,0 @@
1
- /**
2
- * oom-classify.ts — discriminator for OOM-like errors at Nimbus
3
- * boundaries.
4
- *
5
- * Why this exists
6
- * ───────────────
7
- * Nimbus has at least five distinct "the work failed" failure modes
8
- * that all surface today as either a thrown JS Error or a console
9
- * line:
10
- *
11
- * 1. SQLITE_NOMEM at the storage layer (per-DO SQLite cap, post-
12
- * STOR/SPEC: Address SQLITE_NOMEM issues).
13
- * 2. Isolate memory exhaustion → 'oom'.
14
- * 3. Isolate CPU-budget exhaustion → 'cpu_exceeded'.
15
- * 4. Structured-clone refusal (`Cannot deserialize cloned data`)
16
- * between supervisor ↔ facet RPC (32 MiB cap).
17
- * 5. RPC timeout (TimeoutError from facet-pool's per-task race).
18
- *
19
- * Plus a few platform-side terminations (subrequest cap, condemnation,
20
- * hard eviction) that the user sees but Nimbus has no first-party
21
- * signal for.
22
- *
23
- * Memory and CPU are separate buckets
24
- * ───────────────────────────────────
25
- * workerd models them as distinct trace outcomes — `exceededMemory` vs
26
- * `exceededCpu` (EventOutcome, workerd/io/outcome.capnp) — and words the
27
- * two message families so neither one's signature appears in the other:
28
- *
29
- * memory "Worker has exceeded memory limit."
30
- * "Worker exceeded memory limit."
31
- * "broken.exceededMemory; jsg.Error: Durable Object's isolate
32
- * exceeded its memory limit …"
33
- * "Memory limit exceeded" (RangeError)
34
- * cpu "Worker exceeded CPU time limit."
35
- * "Durable Object exceeded its CPU time limit and was reset."
36
- * "Python Worker exceeded CPU time limit"
37
- *
38
- * Read a low 'cpu_exceeded' count carefully: neither condition reliably
39
- * produces a message. Both are uncatchable inside the isolate that died,
40
- * so the string is only observable by a CALLER across an RPC boundary,
41
- * and Nimbus has repeatedly measured isolates vanishing with no throw at
42
- * all. Absence of 'cpu_exceeded' entries is therefore NOT evidence that
43
- * CPU was not the cause — confirm against `wrangler tail` either way.
44
- *
45
- * Without a classifier, every recordFailure() call has to stringify-
46
- * match its own error site. With this module, we pin the regex once
47
- * and reuse it everywhere.
48
- *
49
- * Inputs we accept
50
- * ────────────────
51
- * The classifier accepts:
52
- * - Error instances (read e.message)
53
- * - plain strings (use directly — for stderr-line classification at
54
- * facet-exit boundaries where we don't have an Error)
55
- * - anything else (return 'unknown')
56
- *
57
- * Output values
58
- * ─────────────
59
- * Same set used by `DiagFailure.cause` in oom-discriminator.ts. Keep
60
- * the union narrow and additive — adding a new value is fine, but
61
- * never re-purpose an existing one.
62
- */
63
-
64
- const OOM_CAUSES = [
65
- 'sqlite_nomem',
66
- 'oom',
67
- 'cpu_exceeded',
68
- 'clone_refused',
69
- 'rpc_timeout',
70
- 'subrequest_cap',
71
- 'dynamic_worker_cap',
72
- 'condemnation',
73
- 'hard_evict',
74
- 'unknown',
75
- ] as const;
76
-
77
- export type OomCause = typeof OOM_CAUSES[number];
78
-
79
- export function isOomCause(input: unknown): input is OomCause {
80
- return OOM_CAUSES.some((cause) => cause === input);
81
- }
82
-
83
- /**
84
- * Classify an error or message string into an OomCause. Returns
85
- * 'unknown' when no signature matches — callers should still record
86
- * the message via DiagFailure.message so a human can later widen the
87
- * classifier rules.
88
- */
89
- export function classifyError(input: unknown): OomCause {
90
- const msg = readMessage(input);
91
- if (msg.length === 0) return 'unknown';
92
- return classifyMessage(msg);
93
- }
94
-
95
- /**
96
- * Variant for callers that already have the message string. Prefer
97
- * classifyError() at boundaries; this is exposed for cases where the
98
- * message has already been extracted (e.g. truncated / sanitised).
99
- */
100
- export function classifyMessage(msg: string): OomCause {
101
- // Use lower-case match for forgiveness; SQLITE_NOMEM is canonically
102
- // upper but stderr can be either.
103
- const m = msg.toLowerCase();
104
-
105
- // SQLITE_NOMEM signals
106
- if (m.includes('sqlite_nomem')) return 'sqlite_nomem';
107
- if (m.includes('out of memory')) return 'sqlite_nomem';
108
- if (m.includes('database or disk is full')) return 'sqlite_nomem';
109
-
110
- // Structured-clone refusal — a 32 MiB-cap cousin
111
- if (m.includes('deserialize cloned data')) return 'clone_refused';
112
- if (m.includes('could not be cloned')) return 'clone_refused';
113
-
114
- // RPC timeout (Nimbus's TimeoutError from facet-pool)
115
- if (m.includes('timeouterror')) return 'rpc_timeout';
116
- if (/\btimed?\s*out\b/.test(m)) return 'rpc_timeout';
117
-
118
- // Subrequest cap (Cloudflare platform)
119
- if (m.includes('too many subrequests')) return 'subrequest_cap';
120
-
121
- // Worker Loader concurrency cap (Cloudflare platform): a Durable Object
122
- // admits ~5-6 concurrent dynamic workers, and loader-cache entries are
123
- // never released — every distinct loader.get(id) permanently consumes a
124
- // slot, so this cap recurs until the DO itself is replaced.
125
- if (m.includes('too many concurrent dynamic workers')) return 'dynamic_worker_cap';
126
-
127
- // Memory exhaustion. workerd trace outcome `exceededMemory`; in-band it
128
- // arrives as a `broken.exceededMemory` actor break, a "…exceeded [its]
129
- // memory limit" kill, or the "Memory limit exceeded" RangeError.
130
- if (m.includes('exceededmemory')) return 'oom';
131
- if (/exceeded (?:its )?memory limit/.test(m)) return 'oom';
132
- if (m.includes('memory limit exceeded')) return 'oom';
133
- if (m.includes('memory limit') && m.includes('reset')) return 'oom';
134
-
135
- // CPU-budget exhaustion. A DIFFERENT platform condition from memory —
136
- // workerd reports it as trace outcome `exceededCpu`. Never fold the two:
137
- // a CPU kill recurs on the same input, a memory kill recurs on the same
138
- // working-set size, and the remedies are opposites.
139
- //
140
- // `cpu time limit` covers both scopes, which differ only by the
141
- // possessive: "Worker exceeded CPU time limit." and "Durable Object
142
- // exceeded its CPU time limit and was reset."
143
- //
144
- // Wall-clock handler timeouts are deliberately NOT matched here.
145
- // "Actor exceeded event execution time and was disconnected." is the
146
- // caller-configurable setHibernatableWebSocketEventTimeout() bound and
147
- // "Alarm exceeded its allowed execution time" is its alarm twin. Both
148
- // are elapsed-time bounds a handler blocked on I/O trips without burning
149
- // CPU, so bucketing them here would repeat the merge this split undoes.
150
- if (m.includes('cpu time limit')) return 'cpu_exceeded';
151
- if (m.includes('exceeded cpu limit')) return 'cpu_exceeded';
152
-
153
- // Explicit eviction (per CF research §A.1.2)
154
- if (m.includes('condemned')) return 'condemnation';
155
- if (m.includes('hard evict')) return 'hard_evict';
156
-
157
- return 'unknown';
158
- }
159
-
160
- /**
161
- * Transient Durable Object reset — the object was reset by the platform
162
- * mid-request for a reason unrelated to the request's own resource use:
163
- * a code deploy rolling over, or a storage-subsystem cold-start hiccup.
164
- * The in-flight request/RPC rejects, but the work itself never ran to a
165
- * conclusion and is safe to re-attempt.
166
- *
167
- * Deliberately narrow: it must NOT match resource kills ("isolate exceeded
168
- * its memory limit and was reset", "Durable Object exceeded its CPU time
169
- * limit and was reset"), which classifyMessage() routes to 'oom' and
170
- * 'cpu_exceeded' — those recur on retry and must surface, not loop. Note
171
- * both end in "and was reset"; the checks below key on the CAUSE clause,
172
- * never on the reset itself.
173
- * Observed verbatim signatures:
174
- * - "Durable Object reset because its code was updated."
175
- * - "Internal error while starting up Durable Object storage caused
176
- * object to be reset; reference = ..."
177
- * - "Internal error in Durable Object storage caused object to be reset;
178
- * reference = ..."
179
- * - "Durable Object storage operation exceeded timeout which caused
180
- * the object to be reset."
181
- *
182
- * The second and third are the SAME condition worded for two moments —
183
- * startup versus a live write — and only the first was matched here, which is
184
- * why an object reset mid-write still failed a whole install. Nimbus has
185
- * measured the live-write wording twice: a 45.7 MB single-turn write reset the
186
- * object once and the same write then succeeded 12/12 on retry
187
- * (`vfs/facet-resident-store.ts`), and it reset a session DO mid-npm-install
188
- * on 2026-08-10 (probe `agentic-cli/new/pi-official-installer`). Retrying is
189
- * bounded, so the one case that is NOT transient — crossing the object's
190
- * storage budget, which the platform also reports this way
191
- * (`loaders/process-fabric.ts`) — recurs, exhausts the budget and surfaces.
192
- */
193
- export function isTransientDoReset(input: unknown): boolean {
194
- const m = readMessage(input).toLowerCase();
195
- if (m.length === 0) return false;
196
- if (m.includes('reset because its code was updated')) return true;
197
- if (m.includes('durable object storage caused object to be reset')) return true;
198
- if (m.includes('storage operation') && m.includes('reset')) return true;
199
- return false;
200
- }
201
-
202
- /**
203
- * Workerd shed the call because the target Durable Object's input-gate
204
- * queue was too deep or too old: "Durable Object is overloaded."
205
- *
206
- * Distinct from `isTransientDoReset` on purpose. The object is alive and
207
- * the work never started, so the call is safe to re-attempt — but the
208
- * cause is queue pressure, so the only useful retry is one that backs off
209
- * long enough for the queue to drain. Callers pair this with a longer
210
- * backoff than a reset retry uses.
211
- */
212
- export function isDoOverloaded(input: unknown): boolean {
213
- return readMessage(input).toLowerCase().includes('durable object is overloaded');
214
- }
215
-
216
- /**
217
- * One line about a failure, in terms someone can act on.
218
- *
219
- * npm install's catch sites each wrote `e?.remoteMessage || e?.message ||
220
- * String(e)`. That reduces a failure to a bare sentence, and when the platform
221
- * declines to describe a rejected Durable Object call the sentence is the word
222
- * `internal error` — which is how `resolver-fanout layer 2 failed: internal
223
- * error` reached users for months carrying nothing at all. The message is
224
- * often the least we know: the error's class, whether the description came
225
- * from a remote isolate, and which condition it classifies as are all still in
226
- * hand, and naming them costs nothing and invents nothing.
227
- */
228
- export function describeError(input: unknown): string {
229
- const remote = typeof input === 'object' && input !== null
230
- ? (input as { remoteMessage?: unknown }).remoteMessage
231
- : undefined;
232
- const message = (typeof remote === 'string' && remote.length > 0)
233
- ? `${remote} (remote)`
234
- : readMessage(input) || String(input);
235
- const name = input instanceof Error && input.name && input.name !== 'Error'
236
- ? `${input.name}: `
237
- : '';
238
- const cause = classifyError(input);
239
- const suffix = cause !== 'unknown' ? ` [${cause}]`
240
- : isTransientDoReset(input) ? ' [transient-do-reset]'
241
- : isDoOverloaded(input) ? ' [do-overloaded]'
242
- : '';
243
- return `${name}${message}${suffix}`;
244
- }
245
-
246
- function readMessage(input: unknown): string {
247
- if (input == null) return '';
248
- if (typeof input === 'string') return input;
249
- if (input instanceof Error) return input.message ?? '';
250
- // Some thrown values are plain objects with .message
251
- if (typeof input === 'object') {
252
- const m = (input as { message?: unknown }).message;
253
- if (typeof m === 'string') return m;
254
- }
255
- try {
256
- return String(input);
257
- } catch {
258
- return '';
259
- }
260
- }
@@ -1,405 +0,0 @@
1
- /**
2
- * oom-discriminator.ts — singleton ring buffer for OOM / failure
3
- * forensics + last-known-RPC-frame + last-known-facet-id +
4
- * session-recovery-event ring, plus snapshot/rehydrate hooks for DO
5
- * storage persistence.
6
- *
7
- * W5 (Lever 5 / J.1.1) — original module. Pairs with oom-classify.ts.
8
- * C'.2 — added the recovery_event ring.
9
- *
10
- * Why singleton-per-isolate
11
- * ─────────────────────────
12
- * Same pattern as src/observability/diag-counters.ts. The supervisor bundle
13
- * is the consumer; all writers (sqlite-vfs, facet-pool, facet-manager,
14
- * supervisor-rpc, npm-installer, nimbus-session) live in the same
15
- * isolate. Module scope provides one process-local diagnostic without
16
- * threading a handle through each writer.
17
- *
18
- * Two distinct rings
19
- * ──────────────────
20
- * - failures: things that failed (the original W5 ring).
21
- * - recoveryEvents: lifecycle transitions of the session. Cold isolate
22
- * boot → 'cold' → 'hydrated' → 'active' → 'drained'.
23
- *
24
- * Bounded-size guarantees
25
- * ───────────────────────
26
- * - failures ring: 50 entries (RING_SIZE).
27
- * - recoveryEvents ring: 50 entries (RECOVERY_RING_SIZE).
28
- * - per-message cap: 200 chars (truncated at insert).
29
- * - per-RPC-frame: single slot, one object.
30
- * - per-facet-id: single slot, one object.
31
- *
32
- * Snapshot size stays bounded even with both rings full.
33
- */
34
-
35
- import { isOomCause, type OomCause } from './oom-classify.js';
36
-
37
- const RING_SIZE = 50;
38
- const RECOVERY_RING_SIZE = 50;
39
- const MESSAGE_CAP = 200;
40
-
41
- export interface RpcFrame {
42
- method: string;
43
- payloadBytes: number;
44
- atMs: number;
45
- }
46
-
47
- export interface FacetId {
48
- codeId: string;
49
- slotIndex: number;
50
- atMs: number;
51
- }
52
-
53
- export interface DiagFailure {
54
- /** ms epoch */
55
- at: number;
56
- /** Lifecycle stage (e.g. 'install', 'resolve', 'rpc', 'facet', 'ws'). */
57
- phase: string;
58
- /** Discriminated cause. See oom-classify.ts. */
59
- cause: OomCause;
60
- /** Best-effort RSS estimate in bytes (peak observed). */
61
- rssEstimateBytes: number;
62
- /** process.memoryUsage().heapUsed if available; 0 in DO contexts. */
63
- heapUsedBytes: number;
64
- /** SqliteVFS LRU hot bytes at time of failure. */
65
- lruBytes: number;
66
- /** Sum of in-flight RPC payload bytes (best-effort). */
67
- inFlightBytes: number;
68
- /** Snapshot of the most recent RPC frame, if any. */
69
- lastRpcFrame: RpcFrame | null;
70
- /** Snapshot of the most recent facet dispatch, if any. */
71
- lastFacetId: FacetId | null;
72
- /** Optional facet exit code (when failure represents a facet termination). */
73
- exitCode?: number;
74
- /** Optional facet pid. */
75
- pid?: number;
76
- /** Free-form message; truncated to MESSAGE_CAP characters. */
77
- message?: string;
78
- }
79
-
80
- /**
81
- * Session lifecycle states for the C'.2 recovery_event ring.
82
- *
83
- * This module records state-machine transitions; it does not enforce them.
84
- *
85
- * State semantics:
86
- * - 'cold' : fresh DO instance, no in-memory session state yet.
87
- * - 'hydrated' : Phase R completed — SQL state read into in-memory cache.
88
- * - 'active' : Phase B+W+O completed — kernel/shell/terminal wired.
89
- * - 'drained' : webSocketError/Close fired; in-memory caches nulled,
90
- * SQL state freshly persisted; awaiting next reconnect.
91
- *
92
- * Trigger labels: human-readable event that caused the transition.
93
- * Examples: 'first-fetch', 'ws-upgrade', 'ws-close', 'ws-error',
94
- * 'isolate-evicted', 'manual-test'.
95
- */
96
- /** Session state union.
97
- *
98
- * The original four are the high-level lifecycle markers:
99
- * cold — never inited
100
- * hydrated — re-init complete (warm path; Phase O skipped)
101
- * active — live shell processing input
102
- * drained — webSocketError/Close fired; SQL freshly persisted
103
- *
104
- * The four [B'.4] additions are the fine-grained phases of
105
- * initSession itself, recorded as the function progresses:
106
- * rehydrate — Phase R: loading state from DO SQLite
107
- * build — Phase B: constructing Kernel/Shell/registry
108
- * wire — Phase W: attaching terminal, replaying scrollback
109
- * online — Phase O: cold-start MOTD + starter hint (cold only;
110
- * on warm re-init this phase is skipped and the
111
- * initSession ends with 'hydrated' instead)
112
- *
113
- * The phase transitions are SUPPLEMENTAL to the high-level markers,
114
- * not a replacement. Probes that look for the legacy `'hydrated'`
115
- * marker continue to work; probes that want fine-grained debugging
116
- * can grep for the phase states.
117
- */
118
- export type SessionState =
119
- | 'cold' | 'hydrated' | 'active' | 'drained'
120
- | 'rehydrate' | 'build' | 'wire' | 'online';
121
-
122
- export interface DiagRecoveryEvent {
123
- /** ms epoch */
124
- at: number;
125
- /** Source state. */
126
- fromState: SessionState;
127
- /** Destination state. */
128
- toState: SessionState;
129
- /** Human-readable trigger ('ws-close', 'isolate-evicted', etc.). */
130
- trigger: string;
131
- /** _isolateGen at the time of the event. Lets a probe detect the
132
- * difference between an in-isolate transition and a cross-isolate one
133
- * (the latter implies workerd recycled the DO). */
134
- isolateGen: number;
135
- /** True when the transition could not preserve state that should
136
- * have survived (e.g. SQL persist threw or a snapshot was missing). */
137
- dataLoss: boolean;
138
- /** Number of SQL keys/rows rehydrated on a hydrated transition.
139
- * Zero when the transition is one that doesn't read SQL. */
140
- snapshotKeysRehydrated: number;
141
- /** Optional free-form notes (200 char cap). */
142
- notes?: string;
143
- }
144
-
145
- interface RingState {
146
- failures: DiagFailure[];
147
- recoveryEvents: DiagRecoveryEvent[];
148
- lastRpcFrame: RpcFrame | null;
149
- lastFacetId: FacetId | null;
150
- }
151
-
152
- const state: RingState = {
153
- failures: [],
154
- recoveryEvents: [],
155
- lastRpcFrame: null,
156
- lastFacetId: null,
157
- };
158
-
159
- function getState(): RingState {
160
- return state;
161
- }
162
-
163
- /** Append a failure to the ring. Newest first. Capped at RING_SIZE. */
164
- export function recordFailure(f: DiagFailure): void {
165
- const s = getState();
166
- // Defensive copy + message cap.
167
- const entry: DiagFailure = {
168
- at: Number(f.at) || Date.now(),
169
- phase: String(f.phase ?? 'unknown'),
170
- cause: f.cause,
171
- rssEstimateBytes: Number(f.rssEstimateBytes) || 0,
172
- heapUsedBytes: Number(f.heapUsedBytes) || 0,
173
- lruBytes: Number(f.lruBytes) || 0,
174
- inFlightBytes: Number(f.inFlightBytes) || 0,
175
- lastRpcFrame: f.lastRpcFrame ?? s.lastRpcFrame,
176
- lastFacetId: f.lastFacetId ?? s.lastFacetId,
177
- };
178
- if (typeof f.exitCode === 'number') entry.exitCode = f.exitCode;
179
- if (typeof f.pid === 'number') entry.pid = f.pid;
180
- if (f.message) {
181
- const m = String(f.message);
182
- entry.message = m.length > MESSAGE_CAP ? m.slice(0, MESSAGE_CAP) : m;
183
- }
184
- s.failures.unshift(entry);
185
- if (s.failures.length > RING_SIZE) s.failures.length = RING_SIZE;
186
- }
187
-
188
- /** Read a snapshot of the ring. Newest first. Caller-side mutations
189
- * do not affect the singleton. */
190
- export function getFailures(): DiagFailure[] {
191
- return getState().failures.slice(0);
192
- }
193
-
194
- /** Record the current RPC frame (called at every RPC entry). Bounded
195
- * to a single slot — the LATEST frame wins. */
196
- export function setLastRpcFrame(method: string, payloadBytes: number): void {
197
- const s = getState();
198
- s.lastRpcFrame = {
199
- method: String(method),
200
- payloadBytes: Number(payloadBytes) || 0,
201
- atMs: Date.now(),
202
- };
203
- }
204
-
205
- export function getLastRpcFrame(): RpcFrame | null {
206
- return getState().lastRpcFrame;
207
- }
208
-
209
- /** Record the most recent facet dispatch. */
210
- export function setLastFacetId(codeId: string, slotIndex: number): void {
211
- const s = getState();
212
- s.lastFacetId = {
213
- codeId: String(codeId),
214
- slotIndex: Number(slotIndex) || 0,
215
- atMs: Date.now(),
216
- };
217
- }
218
-
219
- export function getLastFacetId(): FacetId | null {
220
- return getState().lastFacetId;
221
- }
222
-
223
- // ── C'.2 recovery_event ring ────────────────────────────────────────────
224
- //
225
- // The ring is bounded at RECOVERY_RING_SIZE; the diag endpoint reads via
226
- // getRecoveryEvents() (newest first).
227
-
228
- /** Append a recovery event to the ring. Newest first. Capped at
229
- * RECOVERY_RING_SIZE. */
230
- export function recordRecoveryEvent(e: DiagRecoveryEvent): void {
231
- const s = getState();
232
- // Defensive copy + notes cap. We intentionally do NOT validate the
233
- // state-machine direction here (e.g. that 'drained' only follows
234
- // 'active') — the state machine lives elsewhere; this ring is a
235
- // recorder, not an enforcer. If a probe sees an impossible
236
- // transition, that's a real bug in the state machine and the
237
- // probe must fail.
238
- const entry: DiagRecoveryEvent = {
239
- at: Number(e.at) || Date.now(),
240
- fromState: e.fromState,
241
- toState: e.toState,
242
- trigger: String(e.trigger ?? 'unknown'),
243
- isolateGen: Number(e.isolateGen) || 0,
244
- dataLoss: !!e.dataLoss,
245
- snapshotKeysRehydrated: Number(e.snapshotKeysRehydrated) || 0,
246
- };
247
- if (e.notes) {
248
- const n = String(e.notes);
249
- entry.notes = n.length > MESSAGE_CAP ? n.slice(0, MESSAGE_CAP) : n;
250
- }
251
- s.recoveryEvents.unshift(entry);
252
- if (s.recoveryEvents.length > RECOVERY_RING_SIZE) {
253
- s.recoveryEvents.length = RECOVERY_RING_SIZE;
254
- }
255
- }
256
-
257
- /** Read a snapshot of the recovery ring. Newest first. */
258
- export function getRecoveryEvents(): DiagRecoveryEvent[] {
259
- return getState().recoveryEvents.slice(0);
260
- }
261
-
262
- /** Reset the recovery ring. Tests + manual operator use only. */
263
- export function resetRecoveryEvents(): void {
264
- getState().recoveryEvents.length = 0;
265
- }
266
-
267
- /**
268
- * Build a JSON-serializable snapshot suitable for ctx.storage.put.
269
- * Bounded ≤ 40 KB even with both rings full of MESSAGE_CAP-truncated
270
- * messages. Schema version embedded so a future shape change can
271
- * cleanly reject old snapshots.
272
- *
273
- * Schema version 2 includes both failure and recovery-event rings.
274
- */
275
- export interface DiagSnapshot {
276
- /** Schema version. Bump when shape changes. */
277
- v: 2;
278
- failures: DiagFailure[];
279
- recoveryEvents: DiagRecoveryEvent[];
280
- lastRpcFrame: RpcFrame | null;
281
- lastFacetId: FacetId | null;
282
- }
283
-
284
- export function snapshotForStorage(): DiagSnapshot {
285
- const s = getState();
286
- return {
287
- v: 2,
288
- failures: s.failures.slice(0),
289
- recoveryEvents: s.recoveryEvents.slice(0),
290
- lastRpcFrame: s.lastRpcFrame,
291
- lastFacetId: s.lastFacetId,
292
- };
293
- }
294
-
295
- /**
296
- * Restore from a snapshot. Garbage / null / wrong-shape input is
297
- * silently ignored. Does NOT throw — constructor-time rehydration must
298
- * never block DO startup.
299
- *
300
- * Only the current v2 schema is accepted.
301
- */
302
- export function rehydrateFromStorage(blob: unknown): void {
303
- if (!isRecord(blob) || blob.v !== 2) return;
304
- const b = blob;
305
- if (!Array.isArray(b.failures)) return;
306
-
307
- const s = getState();
308
- s.failures.length = 0;
309
- for (const f of b.failures) {
310
- const entry = parseFailure(f);
311
- if (!entry) continue;
312
- s.failures.push(entry);
313
- if (s.failures.length >= RING_SIZE) break;
314
- }
315
- s.recoveryEvents.length = 0;
316
- if (Array.isArray(b.recoveryEvents)) {
317
- for (const e of b.recoveryEvents) {
318
- const entry = parseRecoveryEvent(e);
319
- if (!entry) continue;
320
- s.recoveryEvents.push(entry);
321
- if (s.recoveryEvents.length >= RECOVERY_RING_SIZE) break;
322
- }
323
- }
324
- const lastRpcFrame = parseRpcFrame(b.lastRpcFrame);
325
- if (lastRpcFrame) s.lastRpcFrame = lastRpcFrame;
326
- const lastFacetId = parseFacetId(b.lastFacetId);
327
- if (lastFacetId) s.lastFacetId = lastFacetId;
328
- }
329
-
330
- function isRecord(value: unknown): value is Record<string, unknown> {
331
- return typeof value === 'object' && value !== null;
332
- }
333
-
334
- function parseRpcFrame(value: unknown): RpcFrame | null {
335
- if (!isRecord(value)
336
- || typeof value.method !== 'string'
337
- || typeof value.payloadBytes !== 'number'
338
- || typeof value.atMs !== 'number') return null;
339
- return { method: value.method, payloadBytes: value.payloadBytes, atMs: value.atMs };
340
- }
341
-
342
- function parseFacetId(value: unknown): FacetId | null {
343
- if (!isRecord(value)
344
- || typeof value.codeId !== 'string'
345
- || typeof value.slotIndex !== 'number'
346
- || typeof value.atMs !== 'number') return null;
347
- return { codeId: value.codeId, slotIndex: value.slotIndex, atMs: value.atMs };
348
- }
349
-
350
- function parseFailure(value: unknown): DiagFailure | null {
351
- if (!isRecord(value)
352
- || typeof value.at !== 'number'
353
- || typeof value.phase !== 'string'
354
- || !isOomCause(value.cause)
355
- || typeof value.rssEstimateBytes !== 'number'
356
- || typeof value.heapUsedBytes !== 'number'
357
- || typeof value.lruBytes !== 'number'
358
- || typeof value.inFlightBytes !== 'number') return null;
359
- const entry: DiagFailure = {
360
- at: value.at,
361
- phase: value.phase,
362
- cause: value.cause,
363
- rssEstimateBytes: value.rssEstimateBytes,
364
- heapUsedBytes: value.heapUsedBytes,
365
- lruBytes: value.lruBytes,
366
- inFlightBytes: value.inFlightBytes,
367
- lastRpcFrame: parseRpcFrame(value.lastRpcFrame),
368
- lastFacetId: parseFacetId(value.lastFacetId),
369
- };
370
- if (typeof value.exitCode === 'number') entry.exitCode = value.exitCode;
371
- if (typeof value.pid === 'number') entry.pid = value.pid;
372
- if (typeof value.message === 'string') entry.message = value.message.slice(0, MESSAGE_CAP);
373
- return entry;
374
- }
375
-
376
- const SESSION_STATES: readonly SessionState[] = [
377
- 'cold', 'hydrated', 'active', 'drained',
378
- 'rehydrate', 'build', 'wire', 'online',
379
- ];
380
-
381
- function isSessionState(value: unknown): value is SessionState {
382
- return SESSION_STATES.some((state) => state === value);
383
- }
384
-
385
- function parseRecoveryEvent(value: unknown): DiagRecoveryEvent | null {
386
- if (!isRecord(value)
387
- || typeof value.at !== 'number'
388
- || !isSessionState(value.fromState)
389
- || !isSessionState(value.toState)
390
- || typeof value.trigger !== 'string'
391
- || typeof value.isolateGen !== 'number'
392
- || typeof value.dataLoss !== 'boolean'
393
- || typeof value.snapshotKeysRehydrated !== 'number') return null;
394
- const entry: DiagRecoveryEvent = {
395
- at: value.at,
396
- fromState: value.fromState,
397
- toState: value.toState,
398
- trigger: value.trigger,
399
- isolateGen: value.isolateGen,
400
- dataLoss: value.dataLoss,
401
- snapshotKeysRehydrated: value.snapshotKeysRehydrated,
402
- };
403
- if (typeof value.notes === 'string') entry.notes = value.notes.slice(0, MESSAGE_CAP);
404
- return entry;
405
- }