@ddtcorex/dsh-maestro-supervisor 0.7.2 → 0.7.4

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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Session-log health scan.
3
+ *
4
+ * Deeper resilience for dsh-safe-restart pre-flight: after (or before) a
5
+ * restart, individual session logs under a project session dir can end up in
6
+ * three distinct shapes:
7
+ *
8
+ * 1. healthy multi-frame — one frame per line (a header-only first frame,
9
+ * then per-event frames). The DSH reader depends on
10
+ * this shape to fast-seek the session header.
11
+ * 2. single-frame whole-log — the entire log was written as ONE zstd frame.
12
+ * Data is fully intact (whole-file decode works) but
13
+ * the header cannot be located by a frame walk.
14
+ * 3. corrupt first frame — the first frame cannot be decoded at all.
15
+ *
16
+ * Classification uses fzstd's whole-file decode + streaming Decompress and
17
+ * avoids a hand-rolled zstd frame-header walker (empirically wrong on both
18
+ * real file shapes; fzstd whole-file decode already handles single and
19
+ * concatenated frames). The first-frame probe only ever feeds the streaming
20
+ * decoder a bounded 64 KiB head (log2 scans of ≤256-byte-prefix steps), so it
21
+ * is stack-safe even on long-running many-frame logs — feeding a whole file
22
+ * into the streaming decoder recurses per frame and overflows the V8 stack.
23
+ *
24
+ * Repair re-encodes a single-frame whole-log into the canonical multi-frame
25
+ * shape (frame #1 = exactly the header line, trailing line-batched frames),
26
+ * byte-preserving every event; the original file is kept as a sidecar backup
27
+ * and the replace is atomic (write temp + rename).
28
+ *
29
+ * NOTE: fzstd is a decode-only pure-JS zstd library; re-encoding uses Node's
30
+ * native zlib zstd encoder, whose frames fzstd (and the zstd CLI / DSH's
31
+ * reader) decode as standard concatenated frames.
32
+ */
33
+ export type SessionLogClass = 'ok' | 'single-frame-whole-log' | 'corrupt-first-frame' | 'not-a-session-log';
34
+ export interface SessionHealthEntry {
35
+ path: string;
36
+ klass: SessionLogClass;
37
+ remark?: string;
38
+ }
39
+ /**
40
+ * Classify a single session log file per the validated algorithm.
41
+ *
42
+ * Fast path (dominant case): classify a healthy multi-frame log from its first
43
+ * frame alone — the bounded 64 KiB head probe finds the first-frame boundary,
44
+ * decodes just that frame, and a header-only first frame means `ok` WITHOUT
45
+ * ever decoding the whole file.
46
+ *
47
+ * Whole-file decode is ONLY the fallback corrupt gate:
48
+ * - first frame decodes but is multi-line (or the probe found no boundary
49
+ * within 64 KiB — a first frame bigger than the cap is never a canonical
50
+ * healthy log): whole decode throws → 'corrupt-first-frame', succeeds →
51
+ * 'single-frame-whole-log' (whole decodes to exactly one line → 'ok').
52
+ *
53
+ * Empty files are not session logs.
54
+ */
55
+ export declare function classifySessionLog(path: string): Promise<SessionHealthEntry>;
56
+ /**
57
+ * Re-encode a single-frame-whole-log into canonical multi-frame form:
58
+ * frame #1 = exactly the header line, then line-batched trailing frames.
59
+ * The original file is preserved as `path + backupSuffix` (first time only)
60
+ * and the replace is atomic (temp file + rename).
61
+ */
62
+ export declare function repairSingleFrameLog(path: string, backupSuffix?: string): Promise<void>;
63
+ /**
64
+ * Walk `root` recursively and classify every `session.jsonl.zstd` found.
65
+ * The process-write layout is <dshHome>/sessions/<project>/<session>/, but we
66
+ * recurse generically so tests and future layouts work unchanged.
67
+ */
68
+ export declare function scanSessionLogs(root: string): Promise<SessionHealthEntry[]>;
69
+ /**
70
+ * Health check over a session-log root: repair single-frame logs (atomic
71
+ * re-encode), optionally quarantine corrupt-first-frame logs aside, and
72
+ * report counts. `remaining` counts unhealthy entries left untouched
73
+ * (not-a-session-log, corrupt frames when quarantine is off, and
74
+ * single-frame logs when repair is off).
75
+ */
76
+ export declare function runSessionHealthCheck(root: string, opts?: {
77
+ repair?: boolean;
78
+ quarantine?: boolean;
79
+ }): Promise<{
80
+ fixed: number;
81
+ quarantined: number;
82
+ remaining: number;
83
+ }>;
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Session-log health scan.
3
+ *
4
+ * Deeper resilience for dsh-safe-restart pre-flight: after (or before) a
5
+ * restart, individual session logs under a project session dir can end up in
6
+ * three distinct shapes:
7
+ *
8
+ * 1. healthy multi-frame — one frame per line (a header-only first frame,
9
+ * then per-event frames). The DSH reader depends on
10
+ * this shape to fast-seek the session header.
11
+ * 2. single-frame whole-log — the entire log was written as ONE zstd frame.
12
+ * Data is fully intact (whole-file decode works) but
13
+ * the header cannot be located by a frame walk.
14
+ * 3. corrupt first frame — the first frame cannot be decoded at all.
15
+ *
16
+ * Classification uses fzstd's whole-file decode + streaming Decompress and
17
+ * avoids a hand-rolled zstd frame-header walker (empirically wrong on both
18
+ * real file shapes; fzstd whole-file decode already handles single and
19
+ * concatenated frames). The first-frame probe only ever feeds the streaming
20
+ * decoder a bounded 64 KiB head (log2 scans of ≤256-byte-prefix steps), so it
21
+ * is stack-safe even on long-running many-frame logs — feeding a whole file
22
+ * into the streaming decoder recurses per frame and overflows the V8 stack.
23
+ *
24
+ * Repair re-encodes a single-frame whole-log into the canonical multi-frame
25
+ * shape (frame #1 = exactly the header line, trailing line-batched frames),
26
+ * byte-preserving every event; the original file is kept as a sidecar backup
27
+ * and the replace is atomic (write temp + rename).
28
+ *
29
+ * NOTE: fzstd is a decode-only pure-JS zstd library; re-encoding uses Node's
30
+ * native zlib zstd encoder, whose frames fzstd (and the zstd CLI / DSH's
31
+ * reader) decode as standard concatenated frames.
32
+ */
33
+ import { decompress, Decompress } from 'fzstd';
34
+ import { copyFileSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
35
+ import { zstdCompressSync } from 'node:zlib';
36
+ import { join, basename } from 'node:path';
37
+ /**
38
+ * Cap on the file head probed through the streaming decoder. Feeding a whole
39
+ * long-running log (thousands of concatenated zstd frames) into fzstd's
40
+ * streaming `Decompress` in one push recurses once per frame and overflows the
41
+ * V8 stack (RangeError: Maximum call stack size exceeded, seen on a real
42
+ * ~1.8 MB session log). Only the first frame matters for classification, so we
43
+ * never probe past 64 KiB — a canonical log's first frame is a few hundred
44
+ * bytes, and a frame can never legitimately straddle this cap.
45
+ */
46
+ const FIRST_FRAME_CAP = 64 * 1024;
47
+ /** Standard zstd frame encoder (node:zlib) — decode-compatible with fzstd. */
48
+ function compress(data) {
49
+ return zstdCompressSync(data);
50
+ }
51
+ /**
52
+ * Smallest head prefix at which the streaming decoder first emits output
53
+ * (≈ first-frame completion), searched only within FIRST_FRAME_CAP. Never
54
+ * pushes more than the cap into the decoder, so the probe is stack-safe on
55
+ * arbitrarily large many-frame logs. A push that throws on invalid bytes past
56
+ * the first decoded frame is harmless to the boundary search — once the first
57
+ * frame has emitted (`fired`), the boundary is already known, so the throw is
58
+ * swallowed. Only garbage that decodes nothing yields `{ found: false }`.
59
+ */
60
+ function firstFrameHead(buf) {
61
+ const emits = (len) => {
62
+ let fired = false;
63
+ const d = new Decompress((chunk) => { if (chunk.length > 0)
64
+ fired = true; });
65
+ try {
66
+ d.push(buf.subarray(0, len));
67
+ }
68
+ catch {
69
+ // Invalid bytes past the first frame (corrupt tail): the decoder had
70
+ // already emitted the first frame's output → keep `fired` as-is.
71
+ }
72
+ return fired;
73
+ };
74
+ const cap = Math.min(buf.length, FIRST_FRAME_CAP);
75
+ let hi = cap;
76
+ for (let l = 256; l <= cap; l += 256)
77
+ if (emits(l)) {
78
+ hi = l;
79
+ break;
80
+ }
81
+ if (!emits(cap))
82
+ return { found: false }; // no frame boundary within the cap
83
+ let lo = 0;
84
+ while (lo + 1 < hi) {
85
+ const mid = (lo + hi) >> 1;
86
+ emits(mid) ? hi = mid : lo = mid;
87
+ }
88
+ return { found: true, end: hi };
89
+ }
90
+ function isSingleHeaderLine(plain) {
91
+ if (plain.length === 0)
92
+ return false;
93
+ const nl = plain.indexOf(0x0a);
94
+ return nl === plain.length - 1; // exactly one line, terminated by '\n'
95
+ }
96
+ /**
97
+ * Classify a single session log file per the validated algorithm.
98
+ *
99
+ * Fast path (dominant case): classify a healthy multi-frame log from its first
100
+ * frame alone — the bounded 64 KiB head probe finds the first-frame boundary,
101
+ * decodes just that frame, and a header-only first frame means `ok` WITHOUT
102
+ * ever decoding the whole file.
103
+ *
104
+ * Whole-file decode is ONLY the fallback corrupt gate:
105
+ * - first frame decodes but is multi-line (or the probe found no boundary
106
+ * within 64 KiB — a first frame bigger than the cap is never a canonical
107
+ * healthy log): whole decode throws → 'corrupt-first-frame', succeeds →
108
+ * 'single-frame-whole-log' (whole decodes to exactly one line → 'ok').
109
+ *
110
+ * Empty files are not session logs.
111
+ */
112
+ export async function classifySessionLog(path) {
113
+ let buf;
114
+ try {
115
+ buf = readFileSync(path);
116
+ }
117
+ catch {
118
+ return { path, klass: 'not-a-session-log' };
119
+ }
120
+ if (buf.length === 0)
121
+ return { path, klass: 'not-a-session-log' };
122
+ const head = firstFrameHead(buf);
123
+ if (head.found) {
124
+ let first;
125
+ try {
126
+ first = Buffer.from(decompress(buf.subarray(0, head.end)));
127
+ }
128
+ catch { /* prefix not a complete frame */ }
129
+ if (first?.byteLength !== undefined && isSingleHeaderLine(first))
130
+ return { path, klass: 'ok' };
131
+ // First frame decodes but is multi-line (or the prefix is not a complete
132
+ // frame): the whole file must still decode for this to be recoverable.
133
+ try {
134
+ decompress(buf);
135
+ }
136
+ catch {
137
+ return { path, klass: 'corrupt-first-frame' };
138
+ }
139
+ return { path, klass: 'single-frame-whole-log' };
140
+ }
141
+ // No frame boundary within the cap — first frame is bigger than 64 KiB, never
142
+ // a canonical healthy log. The whole-file decode is authoritative.
143
+ let whole;
144
+ try {
145
+ whole = Buffer.from(decompress(buf));
146
+ }
147
+ catch {
148
+ return { path, klass: 'corrupt-first-frame' };
149
+ }
150
+ if (isSingleHeaderLine(whole))
151
+ return { path, klass: 'ok' };
152
+ return { path, klass: 'single-frame-whole-log' }; // whole-file decodes; first frame isn't header-only → recoverable
153
+ }
154
+ /**
155
+ * Re-encode a single-frame-whole-log into canonical multi-frame form:
156
+ * frame #1 = exactly the header line, then line-batched trailing frames.
157
+ * The original file is preserved as `path + backupSuffix` (first time only)
158
+ * and the replace is atomic (temp file + rename).
159
+ */
160
+ export async function repairSingleFrameLog(path, backupSuffix = '.corrupt-singleframe.bak') {
161
+ const buf = readFileSync(path);
162
+ const whole = Buffer.from(decompress(buf));
163
+ const nl = whole.indexOf(0x0a);
164
+ const header = whole.subarray(0, nl + 1);
165
+ const rest = whole.subarray(nl + 1);
166
+ if (!existsSync(path + backupSuffix))
167
+ copyFileSync(path, path + backupSuffix);
168
+ const frames = [compress(header)];
169
+ let start = 0;
170
+ for (let i = 0; i < rest.length; i++) {
171
+ if (rest[i] === 0x0a && i - start + 1 >= 64 * 1024) {
172
+ frames.push(compress(rest.subarray(start, i + 1)));
173
+ start = i + 1;
174
+ }
175
+ }
176
+ if (start < rest.length)
177
+ frames.push(compress(rest.subarray(start)));
178
+ const tmp = path + '.repair.tmp';
179
+ writeFileSync(tmp, Buffer.concat(frames));
180
+ renameSync(tmp, path);
181
+ }
182
+ /**
183
+ * Walk `root` recursively and classify every `session.jsonl.zstd` found.
184
+ * The process-write layout is <dshHome>/sessions/<project>/<session>/, but we
185
+ * recurse generically so tests and future layouts work unchanged.
186
+ */
187
+ export async function scanSessionLogs(root) {
188
+ const found = [];
189
+ const walk = (dir) => {
190
+ let entries;
191
+ try {
192
+ entries = readdirSync(dir, { withFileTypes: true });
193
+ }
194
+ catch {
195
+ return;
196
+ }
197
+ for (const e of entries) {
198
+ const fp = join(dir, e.name);
199
+ if (e.isDirectory())
200
+ walk(fp);
201
+ else if (e.isFile() && basename(e.name) === 'session.jsonl.zstd')
202
+ found.push(fp);
203
+ }
204
+ };
205
+ walk(root);
206
+ const out = [];
207
+ for (const p of found) {
208
+ try {
209
+ out.push(await classifySessionLog(p));
210
+ }
211
+ catch (err) {
212
+ // One unreadable / unclassifiable file must never abort the whole scan.
213
+ out.push({ path: p, klass: 'corrupt-first-frame', remark: err instanceof Error ? err.message : String(err) });
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+ /**
219
+ * Health check over a session-log root: repair single-frame logs (atomic
220
+ * re-encode), optionally quarantine corrupt-first-frame logs aside, and
221
+ * report counts. `remaining` counts unhealthy entries left untouched
222
+ * (not-a-session-log, corrupt frames when quarantine is off, and
223
+ * single-frame logs when repair is off).
224
+ */
225
+ export async function runSessionHealthCheck(root, opts = {}) {
226
+ const repair = opts.repair ?? true;
227
+ const quarantine = opts.quarantine ?? false;
228
+ let fixed = 0;
229
+ let quarantined = 0;
230
+ let remaining = 0;
231
+ for (const entry of await scanSessionLogs(root)) {
232
+ try {
233
+ switch (entry.klass) {
234
+ case 'single-frame-whole-log':
235
+ if (repair) {
236
+ await repairSingleFrameLog(entry.path);
237
+ fixed++;
238
+ }
239
+ else
240
+ remaining++;
241
+ break;
242
+ case 'corrupt-first-frame':
243
+ if (quarantine) {
244
+ let aside = entry.path + '.corrupt-' + Date.now() + '.bak';
245
+ let n = 0;
246
+ while (existsSync(aside))
247
+ aside = entry.path + '.corrupt-' + Date.now() + '-' + (++n) + '.bak';
248
+ renameSync(entry.path, aside);
249
+ quarantined++;
250
+ }
251
+ else
252
+ remaining++;
253
+ break;
254
+ case 'not-a-session-log':
255
+ remaining++;
256
+ break;
257
+ default:
258
+ break;
259
+ }
260
+ }
261
+ catch {
262
+ // Repair/quarantine of a single entry failed — it stays unhealthy and
263
+ // can never abort the whole pass. Counts always reconcile.
264
+ remaining++;
265
+ }
266
+ }
267
+ return { fixed, quarantined, remaining };
268
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddtcorex/dsh-maestro-supervisor",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Supervisor daemon for DSH Web resilience — auto-detect crashes, rollback to LKG, report",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -38,7 +38,8 @@
38
38
  "cordis.patch.yml"
39
39
  ],
40
40
  "dependencies": {
41
- "@ddtcorex/dsh-maestro-config-lib": "^0.1.2"
41
+ "@ddtcorex/dsh-maestro-config-lib": "^0.1.2",
42
+ "fzstd": "0.1.1"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/node": "^26.3.0",
@@ -9,6 +9,13 @@ log="${DSH_RESTART_LOG:-/tmp/dsh-web-restart.log}"
9
9
  # down poll as a crash unless this marker is fresh, so it never races this
10
10
  # script's own kill -> dry-boot -> relaunch sequence with its own rollback.
11
11
  marker="${DSH_SUPERVISOR_MARKER:-$HOME/.dsh/.supervisor/planned-restart}"
12
+ # Session-log pre-flight env (see lib/session-health.ts): SESSIONS_ROOT is the
13
+ # operator DSH store's sessions dir (default <dsh home>/sessions) and may be
14
+ # overridden per invocation. PLUGIN_DIR is this package's root, derived from
15
+ # the script's own path — never a hard-coded location.
16
+ dsh_home="${DSH_HOME:-$HOME/.dsh}"
17
+ export SESSIONS_ROOT="${SESSIONS_ROOT:-$dsh_home/sessions}"
18
+ PLUGIN_DIR="${DSH_SUPERVISOR_PLUGIN_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
12
19
  confirmed=false
13
20
  dry_run=false
14
21
  auto_mode=false
@@ -105,8 +112,8 @@ for command in ss ps grep sort; do
105
112
  command -v "$command" >/dev/null 2>&1 || fail "required command is unavailable: $command"
106
113
  done
107
114
 
108
- listener_pids="$({ ss -tlnp 2>/dev/null || true; } | grep -E ':(3000|3080)([[:space:]]|$)' | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u || true)"
109
- [[ -n "$listener_pids" ]] || fail 'no listeners found on ports 3000 or 3080'
115
+ listener_pids="$({ ss -tlnp 2>/dev/null || true; } | grep -E ':(3000|3080|3081|3082)([[:space:]]|$)' | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u || true)"
116
+ [[ -n "$listener_pids" ]] || fail 'no listeners found on ports 3000/3080 (proxy) or 3081/3082 (local-pin-gate topology)'
110
117
 
111
118
  resolve_tree() {
112
119
  local current="$1"
@@ -243,12 +250,24 @@ else
243
250
  fi
244
251
  fi
245
252
 
246
- for port in 3000 3080; do
253
+ for port in 3000 3080 3081 3082; do
247
254
  if ss -tln 2>/dev/null | grep -q ":$port "; then
248
255
  fail "port $port remains held; refusing to double-boot"
249
256
  fi
250
257
  done
251
258
 
259
+ # Pre-flight: never let a corrupt session log brick the boot
260
+ # Runs only AFTER the old tree is stopped and the ports are free (above) and
261
+ # BEFORE the fresh boot below, so a live writer can never race the repair.
262
+ # Deliberately NON-FATAL (the block's exit status is dropped): a transient
263
+ # scan error must never block a legitimate restart.
264
+ if [ -n "$SESSIONS_ROOT" ] && [ -d "$SESSIONS_ROOT" ]; then
265
+ node --input-type=module -e "import('${PLUGIN_DIR}/lib/session-health.js').then(async m => {
266
+ const r = await m.runSessionHealthCheck(process.env.SESSIONS_ROOT, { repair: true, quarantine: false });
267
+ console.log('[session-health] fixed=' + r.fixed + ' quarantined=' + r.quarantined + ' remaining=' + r.remaining);
268
+ })" || true
269
+ fi
270
+
252
271
  command -v curl >/dev/null 2>&1 || fail 'required command is unavailable: curl'
253
272
 
254
273
  if [[ "$systemd_managed" == true ]]; then
@@ -268,12 +287,20 @@ fi
268
287
  for _ in $(seq 1 90); do
269
288
  # 401 is healthy: dsh-web is up but requires the browser token (matches
270
289
  # dsh-web-supervisor's own health-poller convention since DSH 0.1.2).
271
- code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/ || true)"
272
- if [[ "$code" == 200 || "$code" == 401 ]]; then
273
- printf '[restart] DSH Web is serving HTTP %s on port 3080\n' "$code"
290
+ # Under the local-pin-gate topology (2026-09-02) :3080 is the maestro PIN
291
+ # proxy (303 -> login / 200 authed) and the raw webserver lives on :3082 —
292
+ # accept any dsh-web surface that answers 200/401/303.
293
+ code_3080="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/ || true)"
294
+ if [[ "$code_3080" == 200 || "$code_3080" == 401 || "$code_3080" == 303 ]]; then
295
+ printf '[restart] DSH Web is serving HTTP %s on port 3080\n' "$code_3080"
296
+ exit 0
297
+ fi
298
+ code_3082="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3082/ || true)"
299
+ if [[ "$code_3082" == 200 || "$code_3082" == 401 ]]; then
300
+ printf '[restart] DSH Web raw webserver is serving HTTP %s on port 3082\n' "$code_3082"
274
301
  exit 0
275
302
  fi
276
303
  sleep 1
277
304
  done
278
305
 
279
- fail 'DSH Web did not serve HTTP 200/401 on port 3080 before timeout'
306
+ fail 'DSH Web did not serve HTTP 200/401/303 on :3080 (PIN proxy) or :3082 (raw webserver) before timeout'