@join-pantheon/cli 0.33.2 → 0.33.4-rc.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/dist/accounts.js +53 -0
- package/dist/board.js +1 -2
- package/dist/cli.js +183 -22
- package/dist/config.js +2 -1
- package/dist/disk-writer.js +404 -0
- package/dist/doctor.js +6 -3
- package/dist/ledger.js +31 -14
- package/dist/notifier.js +1 -1
- package/dist/quickstart.js +18 -6
- package/dist/recover-offer.js +125 -0
- package/dist/relay.js +258 -74
- package/dist/roster-file.js +11 -6
- package/dist/store.js +28 -7
- package/dist/team-name.js +58 -0
- package/dist/tray.js +5 -0
- package/dist/worker.js +5 -3
- package/helper/macos/main.swift +5 -3
- package/helper/macos/prebuilt/manifest.json +29 -0
- package/helper/macos/prebuilt/pantheon-hud +0 -0
- package/helper/macos/prebuilt/pantheon-tray +0 -0
- package/installer/install.ps1 +12 -2
- package/installer/install.sh +5 -0
- package/package.json +1 -1
package/dist/relay.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { createHash, randomBytes, timingSafeEqual as timingSafeEqualBuf } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { copyFileSync, readdirSync, readFileSync, statSync, existsSync, writeFileSync, openSync, readSync, closeSync, fstatSync } from "node:fs";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import {
|
|
5
|
+
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
6
7
|
import { WebSocketServer } from "ws";
|
|
7
8
|
import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, RELAY_FEATURES, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
|
|
8
9
|
import { VERSION } from "./version.js";
|
|
@@ -14,9 +15,11 @@ import { Accounts, normalizeUsername, validEmail, validUsername } from "./accoun
|
|
|
14
15
|
import { emailSenderFromEnv } from "./email.js";
|
|
15
16
|
import { Allowlist, RosterUnreadable } from "./roster-file.js";
|
|
16
17
|
import { StateStore, ensurePrivateDir, setWaitForDisk, takeSlowestDiskWait, waitsForDisk } from "./store.js";
|
|
18
|
+
import { busyWriter, DiskBusy, flushWrites, onWriteError, pendingView, readLatest, recordWrites, whenLanded, writerFor } from "./disk-writer.js";
|
|
17
19
|
import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, spaceHealth, fleetReport, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
|
|
18
20
|
import { fmtLimit, inviteLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
|
|
19
21
|
import { FEEDBACK_ADDRESS } from "./feedback.js";
|
|
22
|
+
import { relayShellArg, teamNameProblem } from "./team-name.js";
|
|
20
23
|
import { routeDecision } from "./profile.js";
|
|
21
24
|
const presentSessions = (m) => [...m.sessions.values()].filter((s) => !s.quiet);
|
|
22
25
|
function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
|
|
@@ -178,6 +181,19 @@ const fmtAgo = (t) => {
|
|
|
178
181
|
return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
|
|
179
182
|
};
|
|
180
183
|
const safeName = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
184
|
+
const quoteTeam = (name) => relayShellArg(name);
|
|
185
|
+
function parseLines(text) {
|
|
186
|
+
const records = [];
|
|
187
|
+
for (const line of text.split("\n")) {
|
|
188
|
+
if (!line.trim())
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
records.push(JSON.parse(line));
|
|
192
|
+
}
|
|
193
|
+
catch { }
|
|
194
|
+
}
|
|
195
|
+
return records;
|
|
196
|
+
}
|
|
181
197
|
const spaceKey = (name) => safeName(name).toLowerCase();
|
|
182
198
|
function firstLedgerName(file) {
|
|
183
199
|
let fd = null;
|
|
@@ -276,6 +292,26 @@ function versionBelow(a, b) {
|
|
|
276
292
|
export const RECOVERY_PROOF_MS = 15 * 60_000;
|
|
277
293
|
const RECOVERY_KEEP_MS = 7 * 86_400_000;
|
|
278
294
|
const LOCKED_MSG = "too many wrong codes from this device or your network in the last hour — wait an hour, then request a new code";
|
|
295
|
+
const LOOP_RESOLUTION_MS = 10;
|
|
296
|
+
const LOOP_WINDOW_MS = 60_000;
|
|
297
|
+
function loopMs(ns) {
|
|
298
|
+
return Math.max(0, Math.round((ns / 1e6 - LOOP_RESOLUTION_MS) * 100) / 100);
|
|
299
|
+
}
|
|
300
|
+
const STORAGE_HELD = "the relay received this but has not saved it yet — its disk is refusing writes. It is kept, and reaches the team when the disk recovers; do not send it again.";
|
|
301
|
+
export function backupExcludes(dir, keep) {
|
|
302
|
+
const always = ["--exclude=*.tmp"];
|
|
303
|
+
if (!keep)
|
|
304
|
+
return always;
|
|
305
|
+
let names = [];
|
|
306
|
+
try {
|
|
307
|
+
names = readdirSync(dir);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return always;
|
|
311
|
+
}
|
|
312
|
+
const kept = basename(keep);
|
|
313
|
+
return [...always, ...names.filter((f) => f !== kept && /^accounts-.*\.db$/.test(f)).map((f) => `--exclude=./backups/${f}`)];
|
|
314
|
+
}
|
|
279
315
|
const STORAGE_UNHEALTHY_MS = 5 * 60_000;
|
|
280
316
|
function isStorageError(e) {
|
|
281
317
|
const code = e?.code;
|
|
@@ -428,6 +464,10 @@ export class Relay {
|
|
|
428
464
|
catch { }
|
|
429
465
|
};
|
|
430
466
|
try {
|
|
467
|
+
if (this.closing && req.method !== "GET" && req.method !== "HEAD") {
|
|
468
|
+
res.writeHead(503, { "content-type": "application/json", "retry-after": "10" });
|
|
469
|
+
return void res.end(JSON.stringify({ code: "SHUTTING_DOWN", error: "the relay is restarting — try again in a few seconds" }));
|
|
470
|
+
}
|
|
431
471
|
if (!URL.canParse(req.url ?? "/", "http://x"))
|
|
432
472
|
return fail(new TypeError("unparseable request target"));
|
|
433
473
|
const r = this.onHttp(req, res);
|
|
@@ -442,6 +482,12 @@ export class Relay {
|
|
|
442
482
|
heartbeat;
|
|
443
483
|
lagTimer;
|
|
444
484
|
lagMax = 0;
|
|
485
|
+
loopDelay = null;
|
|
486
|
+
loopPrev = null;
|
|
487
|
+
loopTimer;
|
|
488
|
+
stopWriteErrors;
|
|
489
|
+
spaceOf = new WeakMap();
|
|
490
|
+
closing = false;
|
|
445
491
|
fsyncOff = false;
|
|
446
492
|
compactedAt = new Map();
|
|
447
493
|
sessionSeq = 0;
|
|
@@ -483,6 +529,7 @@ export class Relay {
|
|
|
483
529
|
this.log("* PANTHEON_RELAY_FSYNC=off: writes go to the operating system at once but the relay does not wait for the disk to confirm them (a relay crash loses nothing; a host power loss can lose the last seconds)");
|
|
484
530
|
}
|
|
485
531
|
ensurePrivateDir(this.dataDir);
|
|
532
|
+
this.stopWriteErrors = onWriteError(this.dataDir, (where, e, stem) => { this.noteStorageFailure(where, e); this.failHeld(stem); });
|
|
486
533
|
this.maxLedgerBytes = opts.spaceMaxBytes
|
|
487
534
|
?? (Number(process.env.PANTHEON_SPACE_MAX_BYTES) > 0 ? Number(process.env.PANTHEON_SPACE_MAX_BYTES) : SPACE_MAX_BYTES_DEFAULT);
|
|
488
535
|
this.scanUnbound();
|
|
@@ -544,6 +591,10 @@ export class Relay {
|
|
|
544
591
|
let last = Date.now();
|
|
545
592
|
this.lagTimer = setInterval(() => { const now = Date.now(); this.lagMax = Math.max(this.lagMax, now - last - 500); last = now; }, 500);
|
|
546
593
|
this.lagTimer.unref?.();
|
|
594
|
+
this.loopDelay = monitorEventLoopDelay({ resolution: LOOP_RESOLUTION_MS });
|
|
595
|
+
this.loopDelay.enable();
|
|
596
|
+
this.loopTimer = setInterval(() => this.rotateLoopWindow(), LOOP_WINDOW_MS);
|
|
597
|
+
this.loopTimer.unref?.();
|
|
547
598
|
this.startBackups();
|
|
548
599
|
const addr = this.http.address();
|
|
549
600
|
const port = typeof addr === "object" && addr ? addr.port : want;
|
|
@@ -552,6 +603,11 @@ export class Relay {
|
|
|
552
603
|
});
|
|
553
604
|
}
|
|
554
605
|
async close() {
|
|
606
|
+
this.closing = true;
|
|
607
|
+
for (const ws of this.wss.clients)
|
|
608
|
+
ws.terminate();
|
|
609
|
+
await new Promise((r) => this.wss.close(() => r()));
|
|
610
|
+
await new Promise((r) => setImmediate(r));
|
|
555
611
|
for (const sp of this.spaces.values()) {
|
|
556
612
|
try {
|
|
557
613
|
sp.store.close(sp.goals, sp.obligations, sp.profiles);
|
|
@@ -560,6 +616,9 @@ export class Relay {
|
|
|
560
616
|
this.noteStorageFailure(`[${sp.name}] final flush`, e);
|
|
561
617
|
}
|
|
562
618
|
}
|
|
619
|
+
if (!(await flushWrites(this.dataDir, 30_000)))
|
|
620
|
+
this.log("! storage: writes were still queued when the relay closed (the disk did not take them in 30 s) — they are lost if the process exits now");
|
|
621
|
+
this.stopWriteErrors();
|
|
563
622
|
if (this.backupTimer)
|
|
564
623
|
clearInterval(this.backupTimer);
|
|
565
624
|
this.installerGate.close();
|
|
@@ -569,15 +628,24 @@ export class Relay {
|
|
|
569
628
|
clearInterval(this.heartbeat);
|
|
570
629
|
if (this.lagTimer)
|
|
571
630
|
clearInterval(this.lagTimer);
|
|
631
|
+
if (this.loopTimer)
|
|
632
|
+
clearInterval(this.loopTimer);
|
|
633
|
+
this.loopDelay?.disable();
|
|
572
634
|
if (this.fsyncOff) {
|
|
573
635
|
setWaitForDisk(true);
|
|
574
636
|
this.fsyncOff = false;
|
|
575
637
|
}
|
|
576
|
-
for (const ws of this.wss.clients)
|
|
577
|
-
ws.terminate();
|
|
578
|
-
await new Promise((r) => this.wss.close(() => r()));
|
|
579
638
|
await new Promise((r) => this.http.close(() => r()));
|
|
580
639
|
}
|
|
640
|
+
replyLanded(res, writes, reply) {
|
|
641
|
+
void whenLanded(writes()).then((ok) => {
|
|
642
|
+
if (res.destroyed)
|
|
643
|
+
return;
|
|
644
|
+
if (ok)
|
|
645
|
+
return reply();
|
|
646
|
+
this.json(res, 503, { code: "STORAGE", error: "the relay made that change but has not saved it yet — its disk is refusing writes. It is kept, and is saved when the disk recovers; do not send it again." });
|
|
647
|
+
});
|
|
648
|
+
}
|
|
581
649
|
noteStorageFailure(where, e) {
|
|
582
650
|
const message = e?.message ?? String(e);
|
|
583
651
|
this.log(`! storage: ${where} failed: ${message}`);
|
|
@@ -608,12 +676,17 @@ export class Relay {
|
|
|
608
676
|
}
|
|
609
677
|
}
|
|
610
678
|
unboundOnDisk(safe) {
|
|
611
|
-
const size = (suf) => {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
679
|
+
const size = (suf) => {
|
|
680
|
+
const v = pendingView(this.dataDir, safe, join(this.dataDir, `${safe}${suf}`));
|
|
681
|
+
if (v !== undefined)
|
|
682
|
+
return v === null ? -1 : v.length;
|
|
683
|
+
try {
|
|
684
|
+
return statSync(join(this.dataDir, `${safe}${suf}`)).size;
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
return -1;
|
|
688
|
+
}
|
|
689
|
+
};
|
|
617
690
|
const ledger = size(".jsonl");
|
|
618
691
|
if (size(".members.json") < 0)
|
|
619
692
|
return ledger > 0;
|
|
@@ -652,6 +725,7 @@ export class Relay {
|
|
|
652
725
|
...(adminView && this.unbound.size ? { unbound_spaces: [...this.unbound].sort() } : {}),
|
|
653
726
|
space_count: this.spaces.size,
|
|
654
727
|
space_capacity: this.limits.maxSpaces,
|
|
728
|
+
...(this.loopDelay ? { loop_ms: this.loopStats() } : {}),
|
|
655
729
|
...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
|
|
656
730
|
: asked ? { spaces: one ? [detail(one)] : [] }
|
|
657
731
|
: {}),
|
|
@@ -765,7 +839,7 @@ export class Relay {
|
|
|
765
839
|
const seen = Number(url.searchParams.get("seen"));
|
|
766
840
|
if (Number.isFinite(seen) && seen > (member.seen_seq ?? 0))
|
|
767
841
|
member.seen_seq = seen;
|
|
768
|
-
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
842
|
+
this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
|
|
769
843
|
return this.json(res, 200, { ok: true });
|
|
770
844
|
}
|
|
771
845
|
this.json(res, 404, { error: "not found" });
|
|
@@ -779,19 +853,19 @@ export class Relay {
|
|
|
779
853
|
const dir = join(this.dataDir, "backups");
|
|
780
854
|
const sessionDays = Number(process.env.PANTHEON_SESSION_RETENTION_DAYS ?? "30");
|
|
781
855
|
const auditDays = Number(process.env.PANTHEON_AUDIT_RETENTION_DAYS ?? "365");
|
|
782
|
-
const run = () => {
|
|
856
|
+
const run = async () => {
|
|
783
857
|
try {
|
|
784
858
|
const pruned = this.accounts.prune({ sessionDays, auditDays });
|
|
785
859
|
if (pruned.sessions || pruned.audit)
|
|
786
860
|
this.log(`accounts pruned: ${pruned.sessions} stale session(s), ${pruned.audit} old audit row(s)`);
|
|
787
|
-
const f = this.accounts.
|
|
861
|
+
const f = await this.accounts.backupAsync(dir);
|
|
788
862
|
this.log(`accounts backup written: ${f}`);
|
|
789
863
|
}
|
|
790
864
|
catch (e) {
|
|
791
865
|
this.log(`! accounts backup/prune failed: ${e.message}`);
|
|
792
866
|
}
|
|
793
867
|
};
|
|
794
|
-
setTimeout(run, 60_000);
|
|
868
|
+
setTimeout(run, 60_000).unref?.();
|
|
795
869
|
this.backupTimer = setInterval(run, hours * 3_600_000);
|
|
796
870
|
this.backupTimer.unref?.();
|
|
797
871
|
}
|
|
@@ -849,7 +923,14 @@ export class Relay {
|
|
|
849
923
|
if (!authed)
|
|
850
924
|
return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
|
|
851
925
|
A0.audit({ event: "admin_backup", ip });
|
|
852
|
-
|
|
926
|
+
void this.adminBackup(res, A0).catch((e) => {
|
|
927
|
+
this.log(`! backup archive failed: ${e.message}`);
|
|
928
|
+
if (!res.headersSent)
|
|
929
|
+
this.json(res, 500, { error: "the backup could not be made" });
|
|
930
|
+
else
|
|
931
|
+
res.end();
|
|
932
|
+
});
|
|
933
|
+
return;
|
|
853
934
|
}
|
|
854
935
|
if (url.pathname === "/admin/action" && req.method === "POST") {
|
|
855
936
|
if (!authed)
|
|
@@ -965,6 +1046,7 @@ export class Relay {
|
|
|
965
1046
|
});
|
|
966
1047
|
}
|
|
967
1048
|
if (url.pathname === "/account/revoke" && req.method === "POST") {
|
|
1049
|
+
const writes = recordWrites();
|
|
968
1050
|
const who = this.verifySignedAccount(url, "POST", "/account/revoke", ip);
|
|
969
1051
|
if (!who)
|
|
970
1052
|
return this.json(res, 403, { error: "this request must be signed by a device on the account" });
|
|
@@ -977,9 +1059,10 @@ export class Relay {
|
|
|
977
1059
|
this.dropDeviceSockets(who.account.id, hit.deviceId);
|
|
978
1060
|
this.noteAccountRemoval(who.account.username);
|
|
979
1061
|
}
|
|
980
|
-
return this.json(res, 200, { ok: true, revoked: hit.sessionIds.length, device_removed: !!hit.deviceId });
|
|
1062
|
+
return this.replyLanded(res, writes, () => this.json(res, 200, { ok: true, revoked: hit.sessionIds.length, device_removed: !!hit.deviceId }));
|
|
981
1063
|
}
|
|
982
1064
|
if (url.pathname === "/account/delete" && req.method === "POST") {
|
|
1065
|
+
const writes = recordWrites();
|
|
983
1066
|
const who = this.verifySignedAccount(url, "POST", "/account/delete", ip);
|
|
984
1067
|
if (!who)
|
|
985
1068
|
return this.json(res, 403, { error: "this request must be signed by a device on the account" });
|
|
@@ -988,7 +1071,7 @@ export class Relay {
|
|
|
988
1071
|
for (const s of sessions)
|
|
989
1072
|
this.dropSessionEverywhere(s.id);
|
|
990
1073
|
this.noteAccountRemoval(who.account.username);
|
|
991
|
-
return this.json(res, 200, { ok: true });
|
|
1074
|
+
return this.replyLanded(res, writes, () => this.json(res, 200, { ok: true }));
|
|
992
1075
|
}
|
|
993
1076
|
if (url.pathname === "/account/recover" || url.pathname === "/account/recover/withdraw") {
|
|
994
1077
|
if (!this.authRateOk(ip))
|
|
@@ -1002,10 +1085,11 @@ export class Relay {
|
|
|
1002
1085
|
if (method === "GET")
|
|
1003
1086
|
return this.json(res, 200, { seats: this.recoverableSeats(who.account, who.device) });
|
|
1004
1087
|
const name = url.searchParams.get("space") ?? "";
|
|
1088
|
+
const writes = recordWrites();
|
|
1005
1089
|
const r = url.pathname === "/account/recover/withdraw"
|
|
1006
1090
|
? this.withdrawRecovery(who.account, who.device, name, ip)
|
|
1007
1091
|
: this.recoverSeat(who.account, who.device, name, ip);
|
|
1008
|
-
return this.json(res, r.status, r.body);
|
|
1092
|
+
return this.replyLanded(res, writes, () => this.json(res, r.status, r.body));
|
|
1009
1093
|
}
|
|
1010
1094
|
if (url.pathname === "/audit" && req.method === "GET") {
|
|
1011
1095
|
const who = this.verifySignedAccount(url, "GET", "/audit", ip);
|
|
@@ -1446,6 +1530,9 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1446
1530
|
sleepingCache = new Map();
|
|
1447
1531
|
sleepingRecords(safe) {
|
|
1448
1532
|
const file = join(this.dataDir, `${safe}.jsonl`);
|
|
1533
|
+
const pending = pendingView(this.dataDir, safe, file);
|
|
1534
|
+
if (pending !== undefined)
|
|
1535
|
+
return parseLines(pending?.toString("utf8") ?? "");
|
|
1449
1536
|
let st;
|
|
1450
1537
|
try {
|
|
1451
1538
|
st = statSync(file);
|
|
@@ -1457,15 +1544,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1457
1544
|
const hit = this.sleepingCache.get(safe);
|
|
1458
1545
|
if (hit && hit.key === key)
|
|
1459
1546
|
return hit.records;
|
|
1460
|
-
const records =
|
|
1461
|
-
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
1462
|
-
if (!line.trim())
|
|
1463
|
-
continue;
|
|
1464
|
-
try {
|
|
1465
|
-
records.push(JSON.parse(line));
|
|
1466
|
-
}
|
|
1467
|
-
catch { }
|
|
1468
|
-
}
|
|
1547
|
+
const records = parseLines(readFileSync(file, "utf8"));
|
|
1469
1548
|
this.sleepingCache.set(safe, { key, records });
|
|
1470
1549
|
if (this.sleepingCache.size > 200)
|
|
1471
1550
|
this.sleepingCache.delete(this.sleepingCache.keys().next().value);
|
|
@@ -1488,7 +1567,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1488
1567
|
setTimeout(() => this.deleteSpaceFiles(name), 600).unref?.();
|
|
1489
1568
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
1490
1569
|
try {
|
|
1491
|
-
const hash = sp?.allow ? JSON.parse(
|
|
1570
|
+
const hash = sp?.allow ? JSON.parse(readLatest(this.dataDir, safe, join(this.dataDir, `${safe}.members.json`))).tokenHash ?? null : null;
|
|
1492
1571
|
const earlier = sp?.allow ? sp.allow.tokenHashes().filter((h) => h !== hash) : [];
|
|
1493
1572
|
writeFileSync(join(this.dataDir, `${safe}.deleted.json`), JSON.stringify({ name, at: Date.now(), tokenHash: hash, ...(earlier.length ? { retiredTokenHashes: earlier } : {}) }));
|
|
1494
1573
|
this.tombstoneFile(name);
|
|
@@ -1508,7 +1587,8 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1508
1587
|
if (body.csrf !== this.adminCsrf())
|
|
1509
1588
|
return this.json(res, 403, { error: "stale form — reload the dashboard and try again" });
|
|
1510
1589
|
const act = body.action ?? "", id = (body.id ?? "").trim();
|
|
1511
|
-
const
|
|
1590
|
+
const writes = recordWrites();
|
|
1591
|
+
const done = (msg) => this.replyLanded(res, writes, () => { res.writeHead(303, { location: `/admin?done=${encodeURIComponent(msg)}` }); res.end(); });
|
|
1512
1592
|
switch (act) {
|
|
1513
1593
|
case "suspend":
|
|
1514
1594
|
case "reinstate": {
|
|
@@ -1531,7 +1611,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1531
1611
|
const allow = sp ? sp.allow : new Allowlist(this.dataDir, id.replace(/[^a-z0-9._-]/gi, "_"));
|
|
1532
1612
|
const token = (body.token ?? "").trim();
|
|
1533
1613
|
const inv = allow.findInvite(token);
|
|
1534
|
-
const back = (msg) => { res.writeHead(303, { location: `/admin/space?name=${encodeURIComponent(id)}&done=${encodeURIComponent(msg)}` }); res.end(); };
|
|
1614
|
+
const back = (msg) => this.replyLanded(res, writes, () => { res.writeHead(303, { location: `/admin/space?name=${encodeURIComponent(id)}&done=${encodeURIComponent(msg)}` }); res.end(); });
|
|
1535
1615
|
if (!inv)
|
|
1536
1616
|
return back(`no open invite …${token.slice(-6)} in ${id}`);
|
|
1537
1617
|
allow.revokeInvite(inv.token);
|
|
@@ -1778,9 +1858,9 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1778
1858
|
if (seen.has(stem))
|
|
1779
1859
|
continue;
|
|
1780
1860
|
try {
|
|
1781
|
-
const m = statSync(join(this.dataDir, f)).mtimeMs;
|
|
1861
|
+
const m = busyWriter(this.dataDir, stem) ? -1 : statSync(join(this.dataDir, f)).mtimeMs;
|
|
1782
1862
|
let c = this.rosterCache.get(stem);
|
|
1783
|
-
if (!c || c.mtimeMs !== m) {
|
|
1863
|
+
if (!c || c.mtimeMs !== m || m < 0) {
|
|
1784
1864
|
c = { mtimeMs: m, allow: new Allowlist(this.dataDir, stem) };
|
|
1785
1865
|
this.rosterCache.set(stem, c);
|
|
1786
1866
|
}
|
|
@@ -1795,7 +1875,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1795
1875
|
const A = this.accounts;
|
|
1796
1876
|
const u = acct.username;
|
|
1797
1877
|
const out = [];
|
|
1798
|
-
for (const { name, allow } of this.allRosters()) {
|
|
1878
|
+
for (const { name, allow, live } of this.allRosters()) {
|
|
1799
1879
|
const e = allow.get(u);
|
|
1800
1880
|
if (!e?.admitted)
|
|
1801
1881
|
continue;
|
|
@@ -1804,15 +1884,18 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1804
1884
|
const fp = fingerprint(e.publicKey);
|
|
1805
1885
|
if (e.publicKey === device.pubkey) {
|
|
1806
1886
|
const inv = this.openRecoveryInvite(allow, u);
|
|
1807
|
-
out.push({ space: name, state: inv ? "ready" : "here", founder, alone, fp, ...(inv ? { invite: inv.token } : {}) });
|
|
1887
|
+
out.push({ space: name, state: inv ? "ready" : "here", founder, alone, fp, in_use: false, ...(inv ? { invite: inv.token } : {}) });
|
|
1808
1888
|
continue;
|
|
1809
1889
|
}
|
|
1890
|
+
const in_use = (live?.members.get(u)?.sessions.size ?? 0) > 0;
|
|
1891
|
+
const dev = in_use ? A.deviceByPubkey(e.publicKey) : null;
|
|
1892
|
+
const use = { in_use, ...(dev && dev.account_id === acct.id && dev.label ? { in_use_label: dev.label } : {}) };
|
|
1810
1893
|
const pend = allow.recoveryOf(u);
|
|
1811
1894
|
if (pend && pend.pubkey === device.pubkey) {
|
|
1812
|
-
out.push({ space: name, state: "waiting", founder, alone, fp, due_at: pend.due_at });
|
|
1895
|
+
out.push({ space: name, state: "waiting", founder, alone, fp, due_at: pend.due_at, ...use });
|
|
1813
1896
|
continue;
|
|
1814
1897
|
}
|
|
1815
|
-
out.push({ space: name, state: A.wasDeviceOf(acct.id, fp, e.moved?.at ?? e.first_seen) ? "movable" : "unproven", founder, alone, fp });
|
|
1898
|
+
out.push({ space: name, state: A.wasDeviceOf(acct.id, fp, e.moved?.at ?? e.first_seen) ? "movable" : "unproven", founder, alone, fp, ...use });
|
|
1816
1899
|
}
|
|
1817
1900
|
return out.sort((a, b) => a.space.localeCompare(b.space));
|
|
1818
1901
|
}
|
|
@@ -1853,7 +1936,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1853
1936
|
if (now >= pend.due_at) {
|
|
1854
1937
|
if (now - pend.due_at > RECOVERY_KEEP_MS) {
|
|
1855
1938
|
sp.allow.cancelRecovery(u);
|
|
1856
|
-
return { status: 410, body: { reason: "lapsed", error: `that request lapsed (nobody finished it within a week of its time) — ask again: pantheon recover ${sp.name}` } };
|
|
1939
|
+
return { status: 410, body: { reason: "lapsed", error: `that request lapsed (nobody finished it within a week of its time) — ask again: pantheon recover ${quoteTeam(sp.name)}` } };
|
|
1857
1940
|
}
|
|
1858
1941
|
const inv = this.completeRecovery(sp, u, device.pubkey, "waited", null, null, ip, acct.id);
|
|
1859
1942
|
return { status: 200, body: { state: "moved", space: sp.name, invite: inv.token, how: "waited" } };
|
|
@@ -1886,8 +1969,8 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1886
1969
|
from: RELAY_MEMBER, act: "NOTE", to: null, goal: null, ref: null,
|
|
1887
1970
|
body: `${u} asked to move their seat in this team to a new device ("${r.label}", key ${r.fp}), after signing in with the code emailed to their account. ` +
|
|
1888
1971
|
`It moves ${hours ? `in ${hours} hour${hours === 1 ? "" : "s"}` : "when they ask again"} unless someone cancels, or sooner when a teammate confirms it. ` +
|
|
1889
|
-
`If you have checked with ${u} (a call, or a channel you trust) that the new key is theirs: pantheon recover confirm ${u} --space ${sp.name}. ` +
|
|
1890
|
-
`If it is not them: pantheon recover cancel ${u} --space ${sp.name}.`,
|
|
1972
|
+
`If you have checked with ${u} (a call, or a channel you trust) that the new key is theirs: pantheon recover confirm ${u} --space ${quoteTeam(sp.name)}. ` +
|
|
1973
|
+
`If it is not them: pantheon recover cancel ${u} --space ${quoteTeam(sp.name)}.`,
|
|
1891
1974
|
meta: { notice: "seat_recovery", recovery: { member: u, fp: r.fp, from_fp: oldFp, state: "waiting", due_at: r.due_at } },
|
|
1892
1975
|
});
|
|
1893
1976
|
this.broadcast(sp, { t: "members", members: this.members(sp) });
|
|
@@ -1896,7 +1979,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1896
1979
|
recoveryProofRefusal(A, acct, device, name, seat, founder) {
|
|
1897
1980
|
const proof = A.lastEmailProof(acct.id, device.fingerprint);
|
|
1898
1981
|
if (!proof || Date.now() - proof > RECOVERY_PROOF_MS) {
|
|
1899
|
-
return { status: 403, body: { reason: "sign_in_again", error: `first prove it is you: run pantheon recover ${name} in a terminal (it emails a code to ${acct.email} and asks for it), or sign in with pantheon signin and within ${RECOVERY_PROOF_MS / 60_000} minutes run pantheon recover ${name} again` } };
|
|
1982
|
+
return { status: 403, body: { reason: "sign_in_again", error: `first prove it is you: run pantheon recover ${quoteTeam(name)} in a terminal (it emails a code to ${acct.email} and asks for it), or sign in with pantheon signin and within ${RECOVERY_PROOF_MS / 60_000} minutes run pantheon recover ${quoteTeam(name)} again` } };
|
|
1900
1983
|
}
|
|
1901
1984
|
const oldFp = fingerprint(seat.publicKey);
|
|
1902
1985
|
if (!A.wasDeviceOf(acct.id, oldFp, seat.moved?.at ?? seat.first_seen)) {
|
|
@@ -1954,7 +2037,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
1954
2037
|
meta: { notice: "seat_recovery", recovery: { member, fp: toFp, from_fp: fromFp, state: "moved", how, ...(by ? { by } : {}) } },
|
|
1955
2038
|
});
|
|
1956
2039
|
this.broadcast(sp, { t: "members", members: this.members(sp) });
|
|
1957
|
-
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
2040
|
+
this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp) }));
|
|
1958
2041
|
return inv;
|
|
1959
2042
|
}
|
|
1960
2043
|
mintRecoveryInvite(sp, member) {
|
|
@@ -2600,15 +2683,19 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
|
|
|
2600
2683
|
res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
|
|
2601
2684
|
res.end(csv);
|
|
2602
2685
|
}
|
|
2603
|
-
adminBackup(res, A) {
|
|
2686
|
+
async adminBackup(res, A) {
|
|
2687
|
+
const dir = join(this.dataDir, "backups");
|
|
2688
|
+
let fresh = null;
|
|
2604
2689
|
try {
|
|
2605
|
-
A.
|
|
2690
|
+
fresh = await A.backupAsync(dir);
|
|
2606
2691
|
}
|
|
2607
2692
|
catch (e) {
|
|
2608
2693
|
this.log(`! backup snapshot before archive failed: ${e.message}`);
|
|
2609
2694
|
}
|
|
2695
|
+
if (res.destroyed)
|
|
2696
|
+
return;
|
|
2610
2697
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2611
|
-
const tar = spawn("tar", ["czf", "-", "-C", this.dataDir, "."], { stdio: ["ignore", "pipe", "pipe"] });
|
|
2698
|
+
const tar = spawn("tar", ["czf", "-", "-C", this.dataDir, ...backupExcludes(dir, fresh), "."], { stdio: ["ignore", "pipe", "pipe"] });
|
|
2612
2699
|
let err = "";
|
|
2613
2700
|
tar.stderr.on("data", (c) => { err += String(c); });
|
|
2614
2701
|
tar.on("error", (e) => {
|
|
@@ -2864,6 +2951,11 @@ pantheon start</code></pre>
|
|
|
2864
2951
|
return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — use its exact name, or pick another`);
|
|
2865
2952
|
}
|
|
2866
2953
|
const isNew = !this.live(m.space) && !this.spaceOnDisk(m.space);
|
|
2954
|
+
const badName = isNew ? teamNameProblem(m.space, { relay: true }) : null;
|
|
2955
|
+
if (badName) {
|
|
2956
|
+
this.log(`! REFUSED ${m.member}: cannot create a team called ${JSON.stringify(m.space.slice(0, 80))} (${badName.split(":")[0]})`);
|
|
2957
|
+
return reject("BAD_NAME", badName);
|
|
2958
|
+
}
|
|
2867
2959
|
if (isNew && this.nameCollides(m.space)) {
|
|
2868
2960
|
return reject("NAME_TAKEN", `a team with a name too close to "${m.space}" already exists here — pick another name`);
|
|
2869
2961
|
}
|
|
@@ -2896,8 +2988,14 @@ pantheon start</code></pre>
|
|
|
2896
2988
|
}
|
|
2897
2989
|
}
|
|
2898
2990
|
sp = this.getOrCreateSpace(m.space, m.token, { claim: { member: m.member, pubkey: m.pubkey }, retired: true });
|
|
2899
|
-
if (!sp)
|
|
2991
|
+
if (!sp) {
|
|
2992
|
+
const own = pre?.ok ? this.rosterOf(m.space)?.get(pre.account.username) : undefined;
|
|
2993
|
+
if (own?.admitted && pre?.ok) {
|
|
2994
|
+
const name = this.live(m.space)?.name ?? m.space;
|
|
2995
|
+
return reject("BAD_TOKEN", `You're already a member of ${name}. On this machine run: pantheon recover ${quoteTeam(name)}`);
|
|
2996
|
+
}
|
|
2900
2997
|
return reject("BAD_TOKEN", "wrong token for this space");
|
|
2998
|
+
}
|
|
2901
2999
|
staleToken = this.tokenState(sp, m.token) === "retired";
|
|
2902
3000
|
if (isNew) {
|
|
2903
3001
|
this.noteSpaceCreate(ip);
|
|
@@ -2964,10 +3062,10 @@ pantheon start</code></pre>
|
|
|
2964
3062
|
if (moved && moved.from_fp === fingerprint(m.pubkey)) {
|
|
2965
3063
|
const how = moved.how === "confirmed" ? `, confirmed by ${moved.by}` : moved.how === "alone" ? "" : ", after nobody cancelled it";
|
|
2966
3064
|
return reject("KEY_MISMATCH", `"${m.member}"'s seat in "${sp.name}" moved to another device on ${new Date(moved.at).toISOString().slice(0, 16).replace("T", " ")} UTC — signed in with the code emailed to ${m.member}'s account${how}. This machine's key no longer opens it. ` +
|
|
2967
|
-
`If that was you, use the new machine (or run pantheon recover ${sp.name} here to move it back). If it was not you, your email account may be compromised: secure it, then tell your team.`);
|
|
3065
|
+
`If that was you, use the new machine (or run pantheon recover ${quoteTeam(sp.name)} here to move it back). If it was not you, your email account may be compromised: secure it, then tell your team.`);
|
|
2968
3066
|
}
|
|
2969
3067
|
const recover = this.requireAccounts && this.accounts
|
|
2970
|
-
? ` If this is really you on a new machine and the old one is gone: run pantheon recover ${sp.name} here, in a terminal — it signs you in with your email and moves your seat to this machine.`
|
|
3068
|
+
? ` If this is really you on a new machine and the old one is gone: run pantheon recover ${quoteTeam(sp.name)} here, in a terminal — it signs you in with your email and moves your seat to this machine.`
|
|
2971
3069
|
: "";
|
|
2972
3070
|
if (sp.allow.founder() === m.member) {
|
|
2973
3071
|
return reject("KEY_MISMATCH", `"${m.member}" founded "${sp.name}" and is registered here with a different key.` + (recover ||
|
|
@@ -3013,6 +3111,7 @@ pantheon start</code></pre>
|
|
|
3013
3111
|
delegated = { pub: m.delegate.pub, run: m.delegate.run, expires: m.delegate.expires };
|
|
3014
3112
|
}
|
|
3015
3113
|
space = sp;
|
|
3114
|
+
this.spaceOf.set(ws, sp);
|
|
3016
3115
|
myPubkey = m.pubkey;
|
|
3017
3116
|
clearHelloTimer();
|
|
3018
3117
|
sessionId = acctSession ?? this.mintSession();
|
|
@@ -3082,7 +3181,7 @@ pantheon start</code></pre>
|
|
|
3082
3181
|
if (firstSession && m.role !== "cli") {
|
|
3083
3182
|
this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
|
|
3084
3183
|
}
|
|
3085
|
-
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
3184
|
+
this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp) }));
|
|
3086
3185
|
this.log(`[${sp.name}] + ${me.id}/${sessionId} (${m.role}, ${me.agent})${verdict === "founder" ? " [founder, auto-admitted]" : ""}`);
|
|
3087
3186
|
return;
|
|
3088
3187
|
}
|
|
@@ -3170,7 +3269,7 @@ pantheon start</code></pre>
|
|
|
3170
3269
|
this.log(`[${space.name}] * ADMITTED ${m.member} by ${me.id}${m.may_wake ? " (may wake workers)" : ""}`);
|
|
3171
3270
|
}
|
|
3172
3271
|
this.broadcast(space, { t: "members", members: this.members(space) });
|
|
3173
|
-
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
3272
|
+
this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
|
|
3174
3273
|
this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
|
|
3175
3274
|
return;
|
|
3176
3275
|
}
|
|
@@ -3178,7 +3277,7 @@ pantheon start</code></pre>
|
|
|
3178
3277
|
if (role !== "worker")
|
|
3179
3278
|
return;
|
|
3180
3279
|
me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
|
|
3181
|
-
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
3280
|
+
this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
|
|
3182
3281
|
return;
|
|
3183
3282
|
}
|
|
3184
3283
|
if (m.t === "delete_space") {
|
|
@@ -3384,6 +3483,7 @@ pantheon start</code></pre>
|
|
|
3384
3483
|
if (m.deadline !== undefined && m.deadline <= Date.now()) {
|
|
3385
3484
|
return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
|
|
3386
3485
|
}
|
|
3486
|
+
space.ledger.checkBacklog();
|
|
3387
3487
|
const goal = {
|
|
3388
3488
|
id: m.id && !space.goals.has(m.id) ? m.id : newId("g"),
|
|
3389
3489
|
title: m.title,
|
|
@@ -3489,6 +3589,7 @@ pantheon start</code></pre>
|
|
|
3489
3589
|
const wasFull = this.spaceFull(space);
|
|
3490
3590
|
if (wasFull && !recoveryStep && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
|
|
3491
3591
|
return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
|
|
3592
|
+
space.ledger.checkBacklog();
|
|
3492
3593
|
const rec = this.commit(space, {
|
|
3493
3594
|
...(m.sig && m.id ? { id: m.id } : {}),
|
|
3494
3595
|
from: me.id, session: sessionId, role, act: m.act, body: m.body,
|
|
@@ -3540,6 +3641,11 @@ pantheon start</code></pre>
|
|
|
3540
3641
|
send("SPACE_FULL", `${e.message}. Nothing new can be recorded until finished goals are closed (the relay then compacts their old acts), or the relay operator raises PANTHEON_SPACE_MAX_BYTES.`);
|
|
3541
3642
|
return;
|
|
3542
3643
|
}
|
|
3644
|
+
if (e instanceof DiskBusy) {
|
|
3645
|
+
this.log(`! [${space?.name ?? "?"}] ${e.message} — refusing new acts until it catches up`);
|
|
3646
|
+
send("STORAGE_BUSY", `${e.message}, so this was not recorded. Wait a few seconds, then send it again.`);
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3543
3649
|
if (e instanceof RosterUnreadable) {
|
|
3544
3650
|
this.log(`!! ${e.message} — every member of this space is refused until it is repaired (it was left in place)`);
|
|
3545
3651
|
if (!space)
|
|
@@ -3548,7 +3654,7 @@ pantheon start</code></pre>
|
|
|
3548
3654
|
return;
|
|
3549
3655
|
}
|
|
3550
3656
|
this.noteStorageFailure(`[${space?.name ?? "?"}] ${lastMsg || "message"}`, e);
|
|
3551
|
-
send("STORAGE", "the relay
|
|
3657
|
+
send("STORAGE", "the relay's disk is refusing writes, so this was not recorded — try again in a moment");
|
|
3552
3658
|
}
|
|
3553
3659
|
});
|
|
3554
3660
|
const closeWork = (code) => {
|
|
@@ -3587,7 +3693,7 @@ pantheon start</code></pre>
|
|
|
3587
3693
|
this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
|
|
3588
3694
|
}
|
|
3589
3695
|
this.pushObligations(space);
|
|
3590
|
-
this.broadcast(space, { t: "roster", roster: this.roster(space) });
|
|
3696
|
+
this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
|
|
3591
3697
|
this.log(`[${space.name}] - ${me.id}/${sessionId}`);
|
|
3592
3698
|
};
|
|
3593
3699
|
ws.on("close", (code) => { try {
|
|
@@ -3598,6 +3704,20 @@ pantheon start</code></pre>
|
|
|
3598
3704
|
} });
|
|
3599
3705
|
ws.on("error", () => { });
|
|
3600
3706
|
}
|
|
3707
|
+
rotateLoopWindow() {
|
|
3708
|
+
const h = this.loopDelay;
|
|
3709
|
+
if (!h)
|
|
3710
|
+
return;
|
|
3711
|
+
if (h.count > 0)
|
|
3712
|
+
this.loopPrev = { p50: loopMs(h.percentile(50)), p99: loopMs(h.percentile(99)), max: loopMs(h.max) };
|
|
3713
|
+
h.reset();
|
|
3714
|
+
}
|
|
3715
|
+
loopStats() {
|
|
3716
|
+
const h = this.loopDelay;
|
|
3717
|
+
const cur = h.count > 0 ? { p50: loopMs(h.percentile(50)), p99: loopMs(h.percentile(99)), max: loopMs(h.max) } : null;
|
|
3718
|
+
const at = cur ?? this.loopPrev ?? { p50: 0, p99: 0, max: 0 };
|
|
3719
|
+
return { p50: at.p50, p99: at.p99, max_1m: Math.max(cur?.max ?? 0, this.loopPrev?.max ?? 0) };
|
|
3720
|
+
}
|
|
3601
3721
|
noteStalls() {
|
|
3602
3722
|
const worst = this.lagMax;
|
|
3603
3723
|
this.lagMax = 0;
|
|
@@ -3896,7 +4016,7 @@ pantheon start</code></pre>
|
|
|
3896
4016
|
sp.store.rebuilt = null;
|
|
3897
4017
|
this.pushObligations(sp);
|
|
3898
4018
|
this.broadcast(sp, { t: "act", act: rec });
|
|
3899
|
-
this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
|
|
4019
|
+
this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp) }));
|
|
3900
4020
|
return rec;
|
|
3901
4021
|
}
|
|
3902
4022
|
effects(sp, rec, ctx) {
|
|
@@ -4158,6 +4278,8 @@ pantheon start</code></pre>
|
|
|
4158
4278
|
continue;
|
|
4159
4279
|
if (this.liveSockets(sp))
|
|
4160
4280
|
continue;
|
|
4281
|
+
if (busyWriter(this.dataDir, safeName(sp.name)))
|
|
4282
|
+
continue;
|
|
4161
4283
|
const idle = now - sp.lastTouch;
|
|
4162
4284
|
const junk = this.isJunkSpace(sp);
|
|
4163
4285
|
try {
|
|
@@ -4182,12 +4304,8 @@ pantheon start</code></pre>
|
|
|
4182
4304
|
}
|
|
4183
4305
|
deleteSpaceFiles(name) {
|
|
4184
4306
|
const safe = name.replace(/[^a-z0-9._-]/gi, "_");
|
|
4185
|
-
for (const suffix of [".jsonl", ".state.json", ".members.json"])
|
|
4186
|
-
|
|
4187
|
-
rmSync(join(this.dataDir, `${safe}${suffix}`), { force: true });
|
|
4188
|
-
}
|
|
4189
|
-
catch { }
|
|
4190
|
-
}
|
|
4307
|
+
for (const suffix of [".jsonl", ".state.json", ".members.json"])
|
|
4308
|
+
writerFor(this.dataDir, safe).remove(join(this.dataDir, `${safe}${suffix}`));
|
|
4191
4309
|
}
|
|
4192
4310
|
getOrCreateSpace(name, token, opts = {}) {
|
|
4193
4311
|
const claim = opts.claim;
|
|
@@ -4229,6 +4347,17 @@ pantheon start</code></pre>
|
|
|
4229
4347
|
store.headOf = () => ledger.head;
|
|
4230
4348
|
store.onError = (e) => this.noteStorageFailure(`[${name}] snapshot save`, e);
|
|
4231
4349
|
const restored = store.load({ quarantine: true, expectHistory: ledger.size > 0 });
|
|
4350
|
+
if (!restored.unclean && restored.ledgerHead !== undefined && restored.ledgerHead > ledger.head) {
|
|
4351
|
+
if (ledger.size > 0) {
|
|
4352
|
+
try {
|
|
4353
|
+
copyFileSync(store.file, `${store.file}.ahead-${Date.now()}`);
|
|
4354
|
+
}
|
|
4355
|
+
catch { }
|
|
4356
|
+
restored.unclean = `the state snapshot counts acts up to seq ${restored.ledgerHead}, but the ledger ends at seq ${ledger.head}`;
|
|
4357
|
+
}
|
|
4358
|
+
else
|
|
4359
|
+
this.log(`!! [${name}] the state snapshot counts acts up to seq ${restored.ledgerHead}, but the ledger is empty — kept the snapshot; the ledger file may have been lost`);
|
|
4360
|
+
}
|
|
4232
4361
|
const sp = {
|
|
4233
4362
|
name, token: raw,
|
|
4234
4363
|
ledger,
|
|
@@ -4483,23 +4612,78 @@ pantheon start</code></pre>
|
|
|
4483
4612
|
}));
|
|
4484
4613
|
}
|
|
4485
4614
|
send(ws, msg) {
|
|
4486
|
-
|
|
4487
|
-
|
|
4615
|
+
const sp = this.spaceOf.get(ws);
|
|
4616
|
+
const payload = JSON.stringify(msg);
|
|
4617
|
+
const run = () => { if (ws.readyState === 1)
|
|
4618
|
+
ws.send(payload); };
|
|
4619
|
+
if (!sp)
|
|
4620
|
+
return run();
|
|
4621
|
+
const instead = msg.t === "ack" && msg.cid ? { cid: msg.cid } : msg.t === "welcome" ? {} : null;
|
|
4622
|
+
const message = msg.t === "welcome" ? "the relay's disk is refusing writes, so it cannot let you in right now — reconnect in a moment" : STORAGE_HELD;
|
|
4623
|
+
this.out(sp, run, instead ? { ws, payload: JSON.stringify({ t: "error", code: "STORAGE", message, ...instead }), hangUp: msg.t === "welcome" } : undefined);
|
|
4488
4624
|
}
|
|
4489
4625
|
broadcast(sp, msg) {
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
for (const
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
if (
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4626
|
+
this.out(sp, () => {
|
|
4627
|
+
const m = typeof msg === "function" ? msg() : msg;
|
|
4628
|
+
const payload = JSON.stringify(m);
|
|
4629
|
+
const seq = m.t === "act" ? m.act.seq : null;
|
|
4630
|
+
for (const mem of sp.members.values()) {
|
|
4631
|
+
let reached = false;
|
|
4632
|
+
for (const s of mem.sessions.values())
|
|
4633
|
+
if (s.ws.readyState === 1) {
|
|
4634
|
+
s.ws.send(payload);
|
|
4635
|
+
if (!s.quiet)
|
|
4636
|
+
reached = true;
|
|
4637
|
+
}
|
|
4638
|
+
if (reached && seq !== null && seq > mem.delivered_seq)
|
|
4639
|
+
mem.delivered_seq = seq;
|
|
4640
|
+
}
|
|
4641
|
+
});
|
|
4642
|
+
}
|
|
4643
|
+
out(sp, run, failed) {
|
|
4644
|
+
const w = busyWriter(this.dataDir, safeName(sp.name));
|
|
4645
|
+
if (!sp.outbox?.length && !w)
|
|
4646
|
+
return run();
|
|
4647
|
+
if (failed && w?.failing)
|
|
4648
|
+
return this.sendFailed(failed);
|
|
4649
|
+
(sp.outbox ??= []).push({ run, w, ticket: w ? w.ticket : 0, ...(failed ? { failed } : {}) });
|
|
4650
|
+
if (w)
|
|
4651
|
+
void w.whenWritten(w.ticket).then(() => this.release(sp));
|
|
4652
|
+
}
|
|
4653
|
+
release(sp) {
|
|
4654
|
+
const q = sp.outbox;
|
|
4655
|
+
while (q?.length) {
|
|
4656
|
+
const h = q[0];
|
|
4657
|
+
if (h.w && h.w.doneThrough < h.ticket)
|
|
4658
|
+
break;
|
|
4659
|
+
q.shift();
|
|
4660
|
+
try {
|
|
4661
|
+
h.run();
|
|
4662
|
+
}
|
|
4663
|
+
catch { }
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4666
|
+
failHeld(stem) {
|
|
4667
|
+
const sp = [...this.spaces.values()].find((s) => safeName(s.name) === stem);
|
|
4668
|
+
if (!sp?.outbox?.length)
|
|
4669
|
+
return;
|
|
4670
|
+
const failing = sp.outbox.filter((h) => h.failed && h.w && h.w.doneThrough < h.ticket);
|
|
4671
|
+
if (!failing.length)
|
|
4672
|
+
return;
|
|
4673
|
+
sp.outbox = sp.outbox.filter((h) => !failing.includes(h));
|
|
4674
|
+
for (const h of failing)
|
|
4675
|
+
this.sendFailed(h.failed);
|
|
4676
|
+
this.release(sp);
|
|
4677
|
+
}
|
|
4678
|
+
sendFailed(failed) {
|
|
4679
|
+
if (failed.ws.readyState === 1) {
|
|
4680
|
+
try {
|
|
4681
|
+
failed.ws.send(failed.payload);
|
|
4682
|
+
}
|
|
4683
|
+
catch { }
|
|
4502
4684
|
}
|
|
4685
|
+
if (failed.hangUp)
|
|
4686
|
+
this.hangUp(failed.ws, "STORAGE");
|
|
4503
4687
|
}
|
|
4504
4688
|
}
|
|
4505
4689
|
function first(s, n = 120) {
|