skalpel 4.0.0 → 4.0.1
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/package.json +1 -1
- package/skalpel-setup.mjs +93 -91
package/package.json
CHANGED
package/skalpel-setup.mjs
CHANGED
|
@@ -491,18 +491,24 @@ async function buildGraph(sp, files, id) {
|
|
|
491
491
|
await sleep(600);
|
|
492
492
|
return n;
|
|
493
493
|
}
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
// the
|
|
498
|
-
//
|
|
494
|
+
// CHUNKED UPLOAD. The server folds sessions in idempotently (n+1 aggregate, dedup by session_id),
|
|
495
|
+
// so instead of one giant body we upload small BYTE-BOUNDED batches. A 150MB one-shot decompressed
|
|
496
|
+
// in-memory and OOM-killed the pod (the "async" build never even started — the blowup is in reading
|
|
497
|
+
// the body, before the job). Batches keep the server's per-request memory tiny and the ingest is
|
|
498
|
+
// naturally incremental. Batches run SEQUENTIALLY: a fold reads-then-writes the corpus, so two
|
|
499
|
+
// concurrent batches would race and lose sessions. All caps overridable for tuning.
|
|
500
|
+
const { gzipSync } = await import("node:zlib");
|
|
501
|
+
const MB = 1024 * 1024;
|
|
499
502
|
const MAX_SESSIONS = Math.max(
|
|
500
503
|
1,
|
|
501
|
-
Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_SESSIONS || "
|
|
504
|
+
Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_SESSIONS || "40", 10),
|
|
505
|
+
);
|
|
506
|
+
const CHUNK_BYTES = Math.max(1, Number.parseInt(process.env.SKALPEL_CHUNK_MB || "12", 10)) * MB;
|
|
507
|
+
const CHUNK_SESSIONS = Math.max(
|
|
508
|
+
1,
|
|
509
|
+
Number.parseInt(process.env.SKALPEL_CHUNK_SESSIONS || "6", 10),
|
|
502
510
|
);
|
|
503
|
-
|
|
504
|
-
Math.max(1, Number.parseInt(process.env.SKALPEL_MAX_UPLOAD_MB || "90", 10)) * 1024 * 1024;
|
|
505
|
-
// newest first, so when a cap is hit we keep the freshest history
|
|
511
|
+
// newest-first, so a capped history keeps the freshest sessions
|
|
506
512
|
const ranked = files
|
|
507
513
|
.map((f) => {
|
|
508
514
|
try {
|
|
@@ -513,105 +519,101 @@ async function buildGraph(sp, files, id) {
|
|
|
513
519
|
}
|
|
514
520
|
})
|
|
515
521
|
.filter(Boolean)
|
|
516
|
-
.sort((a, b) => b.m - a.m)
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
522
|
+
.sort((a, b) => b.m - a.m)
|
|
523
|
+
.slice(0, MAX_SESSIONS);
|
|
524
|
+
// pack into byte-bounded batches; a lone session bigger than the cap gets its own batch (never split)
|
|
525
|
+
const batches = [];
|
|
526
|
+
let cur = {};
|
|
527
|
+
let curBytes = 0;
|
|
520
528
|
for (const { f, size } of ranked) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
529
|
+
if (
|
|
530
|
+
Object.keys(cur).length &&
|
|
531
|
+
(curBytes + size > CHUNK_BYTES || Object.keys(cur).length >= CHUNK_SESSIONS)
|
|
532
|
+
) {
|
|
533
|
+
batches.push(cur);
|
|
534
|
+
cur = {};
|
|
535
|
+
curBytes = 0;
|
|
527
536
|
}
|
|
528
537
|
try {
|
|
529
|
-
|
|
530
|
-
|
|
538
|
+
cur[f.split("/").pop()] = readFileSync(f, "utf8");
|
|
539
|
+
curBytes += size;
|
|
531
540
|
} catch {
|
|
532
541
|
/* skip unreadable */
|
|
533
542
|
}
|
|
534
543
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const
|
|
540
|
-
let body = gzipSync(JSON.stringify({ user_id: id.uid, raw_transcripts: raw }));
|
|
541
|
-
const HARD_CAP = 90 * 1024 * 1024; // matches the server body cap; compressed size
|
|
542
|
-
while (body.length > HARD_CAP && Object.keys(raw).length > 1) {
|
|
543
|
-
const keys = Object.keys(raw); // insertion order = newest first → drop the oldest kept
|
|
544
|
-
delete raw[keys[keys.length - 1]];
|
|
545
|
-
dropped++;
|
|
546
|
-
body = gzipSync(JSON.stringify({ user_id: id.uid, raw_transcripts: raw }));
|
|
547
|
-
}
|
|
548
|
-
const kept = Object.keys(raw).length;
|
|
549
|
-
const plain = JSON.stringify({ user_id: id.uid, raw_transcripts: raw });
|
|
550
|
-
sp.text(`Building your graph from ${kept} session${kept === 1 ? "" : "s"}…`);
|
|
551
|
-
const post = (token, gzip) =>
|
|
544
|
+
if (Object.keys(cur).length) batches.push(cur);
|
|
545
|
+
if (!batches.length) return 0;
|
|
546
|
+
|
|
547
|
+
let token = id.access_token;
|
|
548
|
+
const postBatch = (raw, tok) =>
|
|
552
549
|
fetch(`${API}/ingest`, {
|
|
553
550
|
method: "POST",
|
|
554
551
|
headers: {
|
|
555
552
|
"content-type": "application/json",
|
|
556
|
-
|
|
557
|
-
...(
|
|
553
|
+
"content-encoding": "gzip",
|
|
554
|
+
...(tok ? { authorization: `Bearer ${tok}` } : {}),
|
|
558
555
|
},
|
|
559
|
-
body:
|
|
556
|
+
body: gzipSync(JSON.stringify({ user_id: id.uid, raw_transcripts: raw })),
|
|
560
557
|
});
|
|
561
558
|
|
|
562
|
-
let
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
// ASYNC: /ingest now returns 202 {job_id} and judges in the BACKGROUND (so a monster 50MB session
|
|
586
|
-
// can take minutes without any request timeout). Poll /ingest/status and drive the spinner with the
|
|
587
|
-
// server's REAL progress ("Judging 8/15…"); resolve only when the job reaches state=done. A server
|
|
588
|
-
// that predates async returns 200 with n_sessions inline — handle that too (back-compat).
|
|
589
|
-
const res = await r.json().catch(() => ({}));
|
|
590
|
-
if (res && typeof res.n_sessions === "number") return res.n_sessions; // legacy synchronous server
|
|
591
|
-
const jobId = res && res.job_id;
|
|
592
|
-
if (!jobId) return res && typeof res.n_sessions === "number" ? res.n_sessions : 0;
|
|
593
|
-
|
|
594
|
-
const statusUrl = `${API}/ingest/status?job_id=${encodeURIComponent(jobId)}`;
|
|
595
|
-
const POLL_MS = 1500;
|
|
596
|
-
const MAX_POLLS = 800; // ~20 min ceiling — a monster history, never hit by a normal user
|
|
597
|
-
for (let i = 0; i < MAX_POLLS; i++) {
|
|
598
|
-
await sleep(POLL_MS);
|
|
599
|
-
let s;
|
|
600
|
-
try {
|
|
601
|
-
const sr = await fetch(statusUrl, {
|
|
602
|
-
headers: { authorization: `Bearer ${id.access_token}` },
|
|
603
|
-
});
|
|
604
|
-
s = await sr.json();
|
|
605
|
-
} catch {
|
|
606
|
-
continue; // transient network blip — keep polling, the job runs server-side regardless
|
|
559
|
+
let total = 0;
|
|
560
|
+
for (let bi = 0; bi < batches.length; bi++) {
|
|
561
|
+
const label = batches.length > 1 ? ` (batch ${bi + 1}/${batches.length})` : "";
|
|
562
|
+
sp.text(`Building your graph…${label}`);
|
|
563
|
+
let r = await postBatch(batches[bi], token);
|
|
564
|
+
// SELF-HEAL on 401: refresh the Google session once, then retry this batch.
|
|
565
|
+
if (r.status === 401 && !process.env.SKALPEL_USER) {
|
|
566
|
+
sp.text("Session expired — re-opening sign-in…");
|
|
567
|
+
spawnSync("node", [join(__dir, "login.mjs")], { stdio: "inherit" });
|
|
568
|
+
const fresh = await identity();
|
|
569
|
+
if (fresh?.access_token) {
|
|
570
|
+
token = fresh.access_token;
|
|
571
|
+
r = await postBatch(batches[bi], token);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (!r.ok) {
|
|
575
|
+
const hint =
|
|
576
|
+
r.status === 401
|
|
577
|
+
? " — sign-in failed; run `skalpel login` and retry"
|
|
578
|
+
: r.status === 413
|
|
579
|
+
? " — a single session is too large"
|
|
580
|
+
: "";
|
|
581
|
+
throw new Error(`ingest ${r.status}${hint}`);
|
|
607
582
|
}
|
|
608
|
-
|
|
609
|
-
if (
|
|
610
|
-
|
|
611
|
-
|
|
583
|
+
const res = await r.json().catch(() => ({}));
|
|
584
|
+
if (typeof res.n_sessions === "number") {
|
|
585
|
+
total = res.n_sessions; // legacy synchronous server
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const jobId = res.job_id;
|
|
589
|
+
if (!jobId) continue;
|
|
590
|
+
// Poll THIS batch's job to completion before the next batch (folds must be sequential).
|
|
591
|
+
const statusUrl = `${API}/ingest/status?job_id=${encodeURIComponent(jobId)}`;
|
|
592
|
+
const POLL_MS = 1500;
|
|
593
|
+
const MAX_POLLS = 800; // ~20 min/batch ceiling — never hit by a normal batch
|
|
594
|
+
let done = false;
|
|
595
|
+
for (let i = 0; i < MAX_POLLS && !done; i++) {
|
|
596
|
+
await sleep(POLL_MS);
|
|
597
|
+
let s;
|
|
598
|
+
try {
|
|
599
|
+
s = await (
|
|
600
|
+
await fetch(statusUrl, { headers: { authorization: `Bearer ${token}` } })
|
|
601
|
+
).json();
|
|
602
|
+
} catch {
|
|
603
|
+
continue; // transient blip — the job runs server-side regardless
|
|
604
|
+
}
|
|
605
|
+
if (s.state === "done") {
|
|
606
|
+
total = s.n_sessions ?? total;
|
|
607
|
+
done = true;
|
|
608
|
+
} else if (s.state === "error") {
|
|
609
|
+
throw new Error("build failed");
|
|
610
|
+
} else if (s.state === "judging" && s.total) {
|
|
611
|
+
sp.text(`Judging your sessions…${label} ${s.done}/${s.total}`);
|
|
612
|
+
}
|
|
612
613
|
}
|
|
614
|
+
if (!done) throw new Error("build timed out");
|
|
613
615
|
}
|
|
614
|
-
|
|
616
|
+
return total;
|
|
615
617
|
}
|
|
616
618
|
|
|
617
619
|
function startBackgroundBuild() {
|