dsh-mnemon 0.5.27 → 0.5.29
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/bundle.js +1 -1
- package/lib/client.js +2041 -1264
- package/lib/index.js +1229 -223
- package/lib/{starter-resolution-C2z8b9AZ.js → starter-resolution-CiUfOO4b.js} +4 -4
- package/lib/starter.js +1 -1
- package/lib/types/src/client/locales.d.ts +163 -100
- package/lib/types/src/host/protocol.d.ts +57 -2
- package/package.json +5 -5
package/lib/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import z from "@deepseek-ai/schemastery";
|
|
|
7
7
|
import { basename, delimiter, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { resolveEmbedding, resolvePersistenceStrategy, resolveRecallQuality } from "dsh-mnemon-source-memory-spaces";
|
|
9
9
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
-
import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { homedir, hostname } from "node:os";
|
|
12
12
|
import { strToU8, unzipSync, zipSync } from "fflate";
|
|
13
13
|
import { DOCUMENTS_ACTIVE_LIMIT_BYTES, DOCUMENTS_VERSION } from "dsh-mnemon-source-documents/contracts";
|
|
@@ -541,6 +541,7 @@ var MnemonAutoBackupScheduler = class {
|
|
|
541
541
|
lastAt;
|
|
542
542
|
lastAttemptAt;
|
|
543
543
|
lastError;
|
|
544
|
+
lastFailure;
|
|
544
545
|
lastCommit;
|
|
545
546
|
lastPushed;
|
|
546
547
|
constructor(runtime, now = () => /* @__PURE__ */ new Date()) {
|
|
@@ -574,6 +575,7 @@ var MnemonAutoBackupScheduler = class {
|
|
|
574
575
|
...this.lastAt === void 0 ? {} : { lastAt: this.lastAt },
|
|
575
576
|
...this.lastAttemptAt === void 0 || !available ? {} : { nextAt: new Date(this.lastAttemptAt + minutes * MINUTE_MS).toISOString() },
|
|
576
577
|
...this.lastError === void 0 ? {} : { lastError: this.lastError },
|
|
578
|
+
...this.lastError === void 0 || this.lastFailure === void 0 ? {} : { lastFailure: this.lastFailure },
|
|
577
579
|
...this.lastCommit === void 0 ? {} : { lastCommit: this.lastCommit },
|
|
578
580
|
...this.lastPushed === void 0 ? {} : { lastPushed: this.lastPushed }
|
|
579
581
|
};
|
|
@@ -619,19 +621,26 @@ var MnemonAutoBackupScheduler = class {
|
|
|
619
621
|
const controller = new AbortController();
|
|
620
622
|
this.controller = controller;
|
|
621
623
|
try {
|
|
622
|
-
if (sync.settings().read().repoUrl === void 0)
|
|
623
|
-
|
|
624
|
-
|
|
624
|
+
if (sync.settings().read().repoUrl === void 0) {
|
|
625
|
+
this.lastError = void 0;
|
|
626
|
+
this.lastFailure = void 0;
|
|
627
|
+
} else if (!this.runtime.config.writeEnabled) {
|
|
628
|
+
this.lastError = "dsh-mnemon is configured read-only";
|
|
629
|
+
this.lastFailure = void 0;
|
|
630
|
+
} else {
|
|
625
631
|
const result = await sync.push({ signal: controller.signal });
|
|
626
632
|
this.lastAt = this.now().toISOString();
|
|
627
633
|
this.lastCommit = result.commit;
|
|
628
634
|
this.lastPushed = result.pushed;
|
|
629
635
|
this.lastError = result.pushed ? void 0 : result.committed ? result.reason : void 0;
|
|
636
|
+
this.lastFailure = this.lastError === void 0 ? void 0 : result.failure;
|
|
630
637
|
}
|
|
631
638
|
} catch (error) {
|
|
632
639
|
const message = error instanceof Error ? error.message : String(error);
|
|
633
|
-
|
|
640
|
+
const reason = error instanceof Error ? error.reason : void 0;
|
|
641
|
+
if (!this.stopped && reason !== "busy" && !message.includes(BUSY_MESSAGE)) {
|
|
634
642
|
this.lastError = message;
|
|
643
|
+
this.lastFailure = typeof reason === "string" ? reason : void 0;
|
|
635
644
|
console.warn("dsh-mnemon: automatic Git backup failed", error);
|
|
636
645
|
}
|
|
637
646
|
} finally {
|
|
@@ -707,7 +716,7 @@ function createStorageRoot(config, workspaceRoot) {
|
|
|
707
716
|
const MNEMON_MACHINE_DIRECTORY = "state";
|
|
708
717
|
const MNEMON_MACHINE_FILE = "machine.json";
|
|
709
718
|
const MNEMON_MACHINE_VERSION = 1;
|
|
710
|
-
const
|
|
719
|
+
const CONTROL = /[\p{Cc}\u2028\u2029]/u;
|
|
711
720
|
function record$8(value) {
|
|
712
721
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
713
722
|
}
|
|
@@ -715,16 +724,26 @@ function record$8(value) {
|
|
|
715
724
|
function defaultMachineLabel() {
|
|
716
725
|
const name = hostname().trim();
|
|
717
726
|
const label = name === "" ? "this machine" : name;
|
|
718
|
-
return label.length >
|
|
727
|
+
return label.length > 120 ? label.slice(0, 120) : label;
|
|
728
|
+
}
|
|
729
|
+
/** A name a person gives this machine, as it is stored and shown. */
|
|
730
|
+
function machineLabel(value) {
|
|
731
|
+
const label = typeof value === "string" ? value.trim() : "";
|
|
732
|
+
if (label === "" || label.length > 120 || CONTROL.test(label)) throw new Error("machine name must be 1 to " + String(120) + " characters on one line");
|
|
733
|
+
return label;
|
|
719
734
|
}
|
|
720
735
|
/**
|
|
721
736
|
* The machine identity store. One file, one identity, created on first read and
|
|
722
737
|
* then stable for the life of the data directory: an id that changed would
|
|
723
738
|
* make every earlier entry look like it came from another machine.
|
|
739
|
+
*
|
|
740
|
+
* The name is not part of that promise. A host-name label follows the computer's
|
|
741
|
+
* host name, and a person can name the machine instead. Every read goes to the
|
|
742
|
+
* file, because the pack manager and the sync channel each hold a store and a
|
|
743
|
+
* rename through one has to reach what the other stamps.
|
|
724
744
|
*/
|
|
725
745
|
var MnemonMachineStore = class {
|
|
726
746
|
root;
|
|
727
|
-
cached;
|
|
728
747
|
constructor(runner) {
|
|
729
748
|
this.root = resolve(runner.effectiveDataDir());
|
|
730
749
|
}
|
|
@@ -736,10 +755,42 @@ var MnemonMachineStore = class {
|
|
|
736
755
|
}
|
|
737
756
|
/** This installation's identity, creating and persisting one when none exists yet. */
|
|
738
757
|
read() {
|
|
739
|
-
|
|
758
|
+
return this.current().identity;
|
|
759
|
+
}
|
|
760
|
+
/** The identity with the source of its name, bringing the stored record up to date. */
|
|
761
|
+
current() {
|
|
740
762
|
const stored = this.stored();
|
|
741
|
-
|
|
742
|
-
|
|
763
|
+
if (stored === void 0) return this.write({
|
|
764
|
+
id: randomUUID(),
|
|
765
|
+
label: defaultMachineLabel(),
|
|
766
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
767
|
+
}, "hostname");
|
|
768
|
+
if (stored.source === "hostname") {
|
|
769
|
+
const label = defaultMachineLabel();
|
|
770
|
+
if (stored.identity.label !== label || !stored.marked) return this.write({
|
|
771
|
+
...stored.identity,
|
|
772
|
+
label
|
|
773
|
+
}, "hostname");
|
|
774
|
+
} else if (!stored.marked) return this.write(stored.identity, "custom");
|
|
775
|
+
return {
|
|
776
|
+
identity: stored.identity,
|
|
777
|
+
source: stored.source
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Name this machine. A typed name stays as given; null or an empty name goes back
|
|
782
|
+
* to the host name. The id never changes, so earlier entries keep pointing here.
|
|
783
|
+
*/
|
|
784
|
+
rename(value) {
|
|
785
|
+
const label = value === null || value === "" ? void 0 : machineLabel(value);
|
|
786
|
+
const identity = this.current().identity;
|
|
787
|
+
return label === void 0 ? this.write({
|
|
788
|
+
...identity,
|
|
789
|
+
label: defaultMachineLabel()
|
|
790
|
+
}, "hostname") : this.write({
|
|
791
|
+
...identity,
|
|
792
|
+
label
|
|
793
|
+
}, "custom");
|
|
743
794
|
}
|
|
744
795
|
stored() {
|
|
745
796
|
let value;
|
|
@@ -753,29 +804,351 @@ var MnemonMachineStore = class {
|
|
|
753
804
|
if (typeof parsed.id !== "string" || parsed.id.trim() === "") return void 0;
|
|
754
805
|
if (typeof parsed.label !== "string" || parsed.label.trim() === "") return void 0;
|
|
755
806
|
if (typeof parsed.createdAt !== "string") return void 0;
|
|
756
|
-
|
|
807
|
+
const identity = {
|
|
757
808
|
id: parsed.id,
|
|
758
809
|
label: parsed.label,
|
|
759
810
|
createdAt: parsed.createdAt
|
|
760
811
|
};
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
label: defaultMachineLabel(),
|
|
766
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
812
|
+
if (parsed.labelSource === "hostname" || parsed.labelSource === "custom") return {
|
|
813
|
+
identity,
|
|
814
|
+
source: parsed.labelSource,
|
|
815
|
+
marked: true
|
|
767
816
|
};
|
|
817
|
+
return {
|
|
818
|
+
identity,
|
|
819
|
+
source: parsed.label === defaultMachineLabel() ? "hostname" : "custom",
|
|
820
|
+
marked: false
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
write(identity, source) {
|
|
768
824
|
mkdirSync(this.directory(), {
|
|
769
825
|
recursive: true,
|
|
770
826
|
mode: 448
|
|
771
827
|
});
|
|
772
828
|
const temporary = `${this.path()}.${randomUUID()}.tmp`;
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
829
|
+
try {
|
|
830
|
+
writeFileSync(temporary, `${JSON.stringify({
|
|
831
|
+
version: MNEMON_MACHINE_VERSION,
|
|
832
|
+
...identity,
|
|
833
|
+
labelSource: source
|
|
834
|
+
}, null, 2)}\n`, { mode: 384 });
|
|
835
|
+
renameSync(temporary, this.path());
|
|
836
|
+
} finally {
|
|
837
|
+
rmSync(temporary, { force: true });
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
identity,
|
|
841
|
+
source
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
//#endregion
|
|
846
|
+
//#region src/host/space-identity.ts
|
|
847
|
+
/**
|
|
848
|
+
* What a Memory Space holds, as opposed to how it was used, and the merge of two versions of one.
|
|
849
|
+
*
|
|
850
|
+
* Mnemon writes to a space whenever it is read: a recall, search or show raises
|
|
851
|
+
* `access_count`, stamps `last_accessed_at` and logs the read in `oplog`. Two machines
|
|
852
|
+
* that only read the same space therefore never hold the same `mnemon.db` bytes, and a
|
|
853
|
+
* merge that compares bytes kept the other machine's space as a new one on every sync,
|
|
854
|
+
* doubling the spaces each round (#370). Spaces are compared by their content instead:
|
|
855
|
+
* every table, column and row, the edges included, except the usage columns, the read log
|
|
856
|
+
* and the derived embedding and effective importance.
|
|
857
|
+
*
|
|
858
|
+
* Rows are compared by their columns in name order, so the order a table declares them in
|
|
859
|
+
* does not matter. One version contains another when it holds every row of it. Two versions
|
|
860
|
+
* of one space merge by adding to this machine's database the rows only the other holds:
|
|
861
|
+
* this machine's rows, read counts, read log and schema stay as they are, and the result holds
|
|
862
|
+
* the memories of both. A row both sides hold differently, a memory edited or forgotten on
|
|
863
|
+
* one of them, refuses the merge, and the caller keeps a copy instead.
|
|
864
|
+
*
|
|
865
|
+
* It needs `node:sqlite` (Node 22.13, 23.4 and later). Without it, for bytes that are not a
|
|
866
|
+
* readable database, for a database with a virtual table, or past the row limits, the identity
|
|
867
|
+
* is the bytes' digest, which is what merges compared before, and such a space contains and
|
|
868
|
+
* merges with no other: a copy is kept, never a memory lost.
|
|
869
|
+
*/
|
|
870
|
+
/** Written by reading a space: the read log. */
|
|
871
|
+
const USAGE_TABLES = /* @__PURE__ */ new Set(["oplog"]);
|
|
872
|
+
/** Written by reading a space, or derived from what it holds. */
|
|
873
|
+
const USAGE_COLUMNS = /* @__PURE__ */ new Set([
|
|
874
|
+
"access_count",
|
|
875
|
+
"last_accessed_at",
|
|
876
|
+
"effective_importance",
|
|
877
|
+
"embedding"
|
|
878
|
+
]);
|
|
879
|
+
const SCRATCH_PREFIX = ".dsh-space-identity-";
|
|
880
|
+
/**
|
|
881
|
+
* Past this many rows a space is compared by its bytes, and past the second limit every space
|
|
882
|
+
* a merge reads after it. A merge runs under the Pack lock, so a crafted database must not hold
|
|
883
|
+
* the Host or the lock for long: 120,000 real rows read in about 0.4 s.
|
|
884
|
+
*/
|
|
885
|
+
const MEMORY_SPACE_IDENTITY_MAX_ROWS = 5e5;
|
|
886
|
+
const MEMORY_SPACE_MERGE_MAX_ROWS = 2e6;
|
|
887
|
+
function sqlite() {
|
|
888
|
+
try {
|
|
889
|
+
return process.getBuiltinModule?.("node:sqlite");
|
|
890
|
+
} catch {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
function quote(name) {
|
|
895
|
+
return "\"" + name.replaceAll("\"", "\"\"") + "\"";
|
|
896
|
+
}
|
|
897
|
+
/** A cell as JSON that keeps SQLite's types apart: 1, 1.0, '1', x'01' and NULL all differ. */
|
|
898
|
+
function cell(value) {
|
|
899
|
+
if (value instanceof Uint8Array) return { blob: Buffer.from(value).toString("base64") };
|
|
900
|
+
if (typeof value === "bigint") return { integer: String(value) };
|
|
901
|
+
if (typeof value === "number") return { real: Object.is(value, -0) ? "-0" : String(value) };
|
|
902
|
+
return value;
|
|
903
|
+
}
|
|
904
|
+
/** 128 bits of a row's digest: enough to tell rows apart, half the memory of the whole digest. */
|
|
905
|
+
function rowDigest(columns, row) {
|
|
906
|
+
return createHash("sha256").update(JSON.stringify(columns.map((column) => cell(row[column])))).digest("base64").slice(0, 22);
|
|
907
|
+
}
|
|
908
|
+
function digest(bytes) {
|
|
909
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
910
|
+
}
|
|
911
|
+
function sameBytes(left, right) {
|
|
912
|
+
return left === right || Buffer.from(left.buffer, left.byteOffset, left.byteLength).equals(right);
|
|
913
|
+
}
|
|
914
|
+
/** Cleanup never fails a merge: a copy another process holds open is swept by the next one. */
|
|
915
|
+
function remove(path) {
|
|
916
|
+
try {
|
|
917
|
+
rmSync(path, {
|
|
918
|
+
recursive: true,
|
|
919
|
+
force: true,
|
|
920
|
+
maxRetries: 3
|
|
921
|
+
});
|
|
922
|
+
} catch {}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Reads what each database holds once, and merges two of them. A merge holds one under the
|
|
926
|
+
* Memory Spaces lock and disposes it at the end, so the copies a Host that stopped mid-merge
|
|
927
|
+
* left behind are swept first.
|
|
928
|
+
*/
|
|
929
|
+
var MemorySpaceIdentity = class {
|
|
930
|
+
parent;
|
|
931
|
+
module;
|
|
932
|
+
maxRows;
|
|
933
|
+
scratch;
|
|
934
|
+
known = /* @__PURE__ */ new WeakMap();
|
|
935
|
+
projections = /* @__PURE__ */ new WeakMap();
|
|
936
|
+
remaining;
|
|
937
|
+
/** `parent` holds the short-lived copies a database is read from: the storage root. */
|
|
938
|
+
constructor(parent, module = sqlite, maxRows = MEMORY_SPACE_IDENTITY_MAX_ROWS, maxMergeRows = MEMORY_SPACE_MERGE_MAX_ROWS) {
|
|
939
|
+
this.parent = parent;
|
|
940
|
+
this.module = module;
|
|
941
|
+
this.maxRows = maxRows;
|
|
942
|
+
this.remaining = maxMergeRows;
|
|
943
|
+
}
|
|
944
|
+
of(bytes) {
|
|
945
|
+
return this.contents(bytes)?.identity ?? "bytes:" + digest(bytes);
|
|
946
|
+
}
|
|
947
|
+
/** Whether `outer` holds every row `inner` holds; `outer` may have columns `inner` lacks. */
|
|
948
|
+
contains(outer, inner) {
|
|
949
|
+
if (sameBytes(outer, inner)) return true;
|
|
950
|
+
const whole = this.contents(outer);
|
|
951
|
+
const part = this.contents(inner);
|
|
952
|
+
if (whole === void 0 || part === void 0) return false;
|
|
953
|
+
for (const [name, table] of part.tables) {
|
|
954
|
+
const present = this.rowsOn(outer, whole, name, table.columns);
|
|
955
|
+
if (present === void 0) return false;
|
|
956
|
+
for (const row of table.rows) if (!present.has(row)) return false;
|
|
957
|
+
}
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* `local` with the rows only `incoming` holds added to it, or undefined when the two cannot
|
|
962
|
+
* merge: a row both hold differently, a table or column `local` lacks, or a database that
|
|
963
|
+
* cannot be read. `local` itself comes back when it already holds everything.
|
|
964
|
+
*/
|
|
965
|
+
merge(local, incoming) {
|
|
966
|
+
if (sameBytes(local, incoming)) return local;
|
|
967
|
+
const mine = this.contents(local);
|
|
968
|
+
const theirs = this.contents(incoming);
|
|
969
|
+
if (mine === void 0 || theirs === void 0) return void 0;
|
|
970
|
+
const missing = /* @__PURE__ */ new Map();
|
|
971
|
+
for (const [name, table] of theirs.tables) {
|
|
972
|
+
const present = this.rowsOn(local, mine, name, table.columns);
|
|
973
|
+
if (present === void 0) return void 0;
|
|
974
|
+
const rows = new Set([...table.rows].filter((row) => !present.has(row)));
|
|
975
|
+
if (rows.size > 0) missing.set(name, rows);
|
|
976
|
+
}
|
|
977
|
+
if (missing.size === 0) return local;
|
|
978
|
+
const module = this.module();
|
|
979
|
+
const directory = this.directory();
|
|
980
|
+
if (module === void 0 || directory === void 0) return void 0;
|
|
981
|
+
const target = join(directory, randomUUID() + ".db");
|
|
982
|
+
const source = join(directory, randomUUID() + ".db");
|
|
983
|
+
let writer;
|
|
984
|
+
let reader;
|
|
985
|
+
try {
|
|
986
|
+
writeFileSync(target, local, { mode: 384 });
|
|
987
|
+
writeFileSync(source, incoming, { mode: 384 });
|
|
988
|
+
writer = this.open(module, target, false);
|
|
989
|
+
reader = this.open(module, source, true);
|
|
990
|
+
if (writer === void 0 || reader === void 0) return void 0;
|
|
991
|
+
writer.exec("BEGIN IMMEDIATE");
|
|
992
|
+
writer.exec("PRAGMA defer_foreign_keys = ON");
|
|
993
|
+
for (const [name, rows] of missing) {
|
|
994
|
+
const table = theirs.tables.get(name);
|
|
995
|
+
const own = mine.tables.get(name);
|
|
996
|
+
const written = table.all.filter((column) => own.all.includes(column));
|
|
997
|
+
const insert = writer.prepare(`INSERT INTO ${quote(name)} (${written.map(quote).join(", ")}) VALUES (${written.map(() => "?").join(", ")})`);
|
|
998
|
+
const statement = reader.prepare(`SELECT ${written.map(quote).join(", ")} FROM ${quote(name)}`);
|
|
999
|
+
statement.setReadBigInts(true);
|
|
1000
|
+
for (const row of statement.iterate()) {
|
|
1001
|
+
if (!this.spend()) throw new Error("the merge read limit was reached");
|
|
1002
|
+
if (!rows.has(rowDigest(table.columns, row))) continue;
|
|
1003
|
+
insert.run(...written.map((column) => row[column]));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
writer.exec("COMMIT");
|
|
1007
|
+
writer.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
1008
|
+
writer.close();
|
|
1009
|
+
writer = void 0;
|
|
1010
|
+
return readFileSync(target);
|
|
1011
|
+
} catch {
|
|
1012
|
+
return;
|
|
1013
|
+
} finally {
|
|
1014
|
+
for (const database of [writer, reader]) try {
|
|
1015
|
+
database?.close();
|
|
1016
|
+
} catch {}
|
|
1017
|
+
for (const path of [target, source]) for (const suffix of [
|
|
1018
|
+
"",
|
|
1019
|
+
"-wal",
|
|
1020
|
+
"-shm",
|
|
1021
|
+
"-journal"
|
|
1022
|
+
]) remove(path + suffix);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
dispose() {
|
|
1026
|
+
if (this.scratch !== void 0) remove(this.scratch);
|
|
1027
|
+
this.scratch = void 0;
|
|
1028
|
+
}
|
|
1029
|
+
spend() {
|
|
1030
|
+
this.remaining -= 1;
|
|
1031
|
+
return this.remaining >= 0;
|
|
1032
|
+
}
|
|
1033
|
+
contents(bytes) {
|
|
1034
|
+
const known = this.known.get(bytes);
|
|
1035
|
+
if (known !== void 0) return known ?? void 0;
|
|
1036
|
+
const contents = this.read(bytes);
|
|
1037
|
+
this.known.set(bytes, contents ?? null);
|
|
1038
|
+
return contents;
|
|
1039
|
+
}
|
|
1040
|
+
/** The digests of a table's rows over `columns`, which may leave out columns the table has. */
|
|
1041
|
+
rowsOn(bytes, contents, name, columns) {
|
|
1042
|
+
const table = contents.tables.get(name);
|
|
1043
|
+
if (table === void 0 || !columns.every((column) => table.columns.includes(column))) return void 0;
|
|
1044
|
+
if (columns.length === table.columns.length) return table.rows;
|
|
1045
|
+
const key = JSON.stringify([name, columns]);
|
|
1046
|
+
const cached = this.projections.get(bytes) ?? /* @__PURE__ */ new Map();
|
|
1047
|
+
this.projections.set(bytes, cached);
|
|
1048
|
+
if (!cached.has(key)) cached.set(key, this.withCopy(bytes, true, (database) => this.digests(database, name, columns) ?? null) ?? null);
|
|
1049
|
+
return cached.get(key) ?? void 0;
|
|
1050
|
+
}
|
|
1051
|
+
digests(database, name, columns) {
|
|
1052
|
+
const rows = /* @__PURE__ */ new Set();
|
|
1053
|
+
if (columns.length === 0) return rows;
|
|
1054
|
+
const statement = database.prepare(`SELECT ${columns.map(quote).join(", ")} FROM ${quote(name)}`);
|
|
1055
|
+
statement.setReadBigInts(true);
|
|
1056
|
+
let budget = this.maxRows;
|
|
1057
|
+
for (const row of statement.iterate()) {
|
|
1058
|
+
budget -= 1;
|
|
1059
|
+
if (budget < 0 || !this.spend()) return void 0;
|
|
1060
|
+
rows.add(rowDigest(columns, row));
|
|
1061
|
+
}
|
|
1062
|
+
return rows;
|
|
1063
|
+
}
|
|
1064
|
+
directory() {
|
|
1065
|
+
try {
|
|
1066
|
+
if (this.scratch === void 0) {
|
|
1067
|
+
for (const name of readdirSync(this.parent)) if (name.startsWith(SCRATCH_PREFIX)) remove(join(this.parent, name));
|
|
1068
|
+
}
|
|
1069
|
+
if (this.scratch === void 0 || !existsSync(this.scratch)) this.scratch = mkdtempSync(join(this.parent, SCRATCH_PREFIX));
|
|
1070
|
+
} catch {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
return this.scratch;
|
|
1074
|
+
}
|
|
1075
|
+
/** A connection to a private copy, refused when the copy is missing or empty rather than created. */
|
|
1076
|
+
open(module, path, readOnly) {
|
|
1077
|
+
if (!existsSync(path)) return void 0;
|
|
1078
|
+
const database = new module.DatabaseSync(path, { readOnly });
|
|
1079
|
+
database.exec("PRAGMA trusted_schema = OFF");
|
|
1080
|
+
const pages = database.prepare("PRAGMA page_count").get();
|
|
1081
|
+
if (Number(pages?.page_count ?? 0) <= 0) {
|
|
1082
|
+
database.close();
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
return database;
|
|
1086
|
+
}
|
|
1087
|
+
withCopy(bytes, readOnly, use) {
|
|
1088
|
+
const module = this.module();
|
|
1089
|
+
if (module === void 0) return void 0;
|
|
1090
|
+
const directory = this.directory();
|
|
1091
|
+
if (directory === void 0) return void 0;
|
|
1092
|
+
const path = join(directory, randomUUID() + ".db");
|
|
1093
|
+
let database;
|
|
1094
|
+
try {
|
|
1095
|
+
writeFileSync(path, bytes, { mode: 384 });
|
|
1096
|
+
database = this.open(module, path, readOnly);
|
|
1097
|
+
return database === void 0 ? void 0 : use(database);
|
|
1098
|
+
} catch {
|
|
1099
|
+
return;
|
|
1100
|
+
} finally {
|
|
1101
|
+
try {
|
|
1102
|
+
database?.close();
|
|
1103
|
+
} catch {}
|
|
1104
|
+
for (const suffix of [
|
|
1105
|
+
"",
|
|
1106
|
+
"-wal",
|
|
1107
|
+
"-shm",
|
|
1108
|
+
"-journal"
|
|
1109
|
+
]) remove(path + suffix);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
read(bytes) {
|
|
1113
|
+
return this.withCopy(bytes, true, (database) => {
|
|
1114
|
+
const listed = database.prepare("SELECT name, type FROM pragma_table_list WHERE schema = 'main'").all();
|
|
1115
|
+
if (listed.some((entry) => entry.type !== "table" && entry.type !== "view")) return void 0;
|
|
1116
|
+
const names = listed.filter((entry) => entry.type === "table" && !entry.name.startsWith("sqlite_") && !USAGE_TABLES.has(entry.name)).map((entry) => entry.name).sort();
|
|
1117
|
+
const hash = createHash("sha256");
|
|
1118
|
+
const tables = /* @__PURE__ */ new Map();
|
|
1119
|
+
let budget = this.maxRows;
|
|
1120
|
+
for (const name of names) {
|
|
1121
|
+
const all = database.prepare(`PRAGMA table_info(${quote(name)})`).all().map((column) => column.name);
|
|
1122
|
+
const columns = all.filter((column) => !USAGE_COLUMNS.has(column)).sort();
|
|
1123
|
+
const rows = [];
|
|
1124
|
+
if (columns.length > 0) {
|
|
1125
|
+
const statement = database.prepare(`SELECT ${columns.map(quote).join(", ")} FROM ${quote(name)}`);
|
|
1126
|
+
statement.setReadBigInts(true);
|
|
1127
|
+
for (const row of statement.iterate()) {
|
|
1128
|
+
budget -= 1;
|
|
1129
|
+
if (budget < 0 || !this.spend()) return void 0;
|
|
1130
|
+
rows.push(rowDigest(columns, row));
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
rows.sort();
|
|
1134
|
+
hash.update(JSON.stringify([
|
|
1135
|
+
"table",
|
|
1136
|
+
name,
|
|
1137
|
+
columns,
|
|
1138
|
+
rows.length
|
|
1139
|
+
]));
|
|
1140
|
+
for (const row of rows) hash.update(row);
|
|
1141
|
+
tables.set(name, {
|
|
1142
|
+
columns,
|
|
1143
|
+
all,
|
|
1144
|
+
rows: new Set(rows)
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
return {
|
|
1148
|
+
identity: "content:" + hash.digest("hex"),
|
|
1149
|
+
tables
|
|
1150
|
+
};
|
|
1151
|
+
});
|
|
779
1152
|
}
|
|
780
1153
|
};
|
|
781
1154
|
//#endregion
|
|
@@ -906,9 +1279,19 @@ function parseEntryOrigin(value) {
|
|
|
906
1279
|
at: origin.at
|
|
907
1280
|
};
|
|
908
1281
|
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Whole groups of four from the base64 alphabet, padded with at most two `=` at the end. The
|
|
1284
|
+
* length check and one character class keep this a flat loop at any size. A pattern that repeats
|
|
1285
|
+
* a four-character group keeps a backtracking entry per group, and V8 threw "Maximum call stack
|
|
1286
|
+
* size exceeded" once a payload passed about 4.5 MB (#368). The `u` flag stays off: under it,
|
|
1287
|
+
* V8 13 (Node 24) does the same for a class over a string holding a character above U+00FF.
|
|
1288
|
+
*/
|
|
1289
|
+
function isBase64(value) {
|
|
1290
|
+
return value.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(value);
|
|
1291
|
+
}
|
|
909
1292
|
function decodeArchive(base64) {
|
|
910
1293
|
if (typeof base64 !== "string" || base64 === "" || base64.length > Math.ceil(16777216) * 4 + 4) throw new Error("Mnemon Pack archive is empty or too large");
|
|
911
|
-
if (
|
|
1294
|
+
if (!isBase64(base64)) throw new Error("Mnemon Pack payload is not valid base64");
|
|
912
1295
|
const bytes = Buffer.from(base64, "base64");
|
|
913
1296
|
if (bytes.length === 0 || bytes.length > 50331648) throw new Error("Mnemon Pack archive is empty or too large");
|
|
914
1297
|
return bytes;
|
|
@@ -1566,23 +1949,86 @@ function mergeTombstoneFiles(current, incoming) {
|
|
|
1566
1949
|
tombstones: [...byKey.values()].sort((left, right) => left.deletedAt < right.deletedAt ? -1 : left.deletedAt > right.deletedAt ? 1 : 0)
|
|
1567
1950
|
};
|
|
1568
1951
|
}
|
|
1569
|
-
|
|
1952
|
+
/**
|
|
1953
|
+
* One document's version as a sync agrees on it: every field but the time it was last read,
|
|
1954
|
+
* which a read moves without changing the document. A title, an archive or a link is as much
|
|
1955
|
+
* a part of it as the text.
|
|
1956
|
+
*/
|
|
1957
|
+
function documentVersion(document) {
|
|
1958
|
+
const { lastAccessedAt: _read, ...fields } = document;
|
|
1959
|
+
return sha256$1(canonicalJson(fields));
|
|
1960
|
+
}
|
|
1961
|
+
/** JSON with every object's keys in order and unset ones left out, so the same record always hashes the same. */
|
|
1962
|
+
function canonicalJson(value) {
|
|
1963
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
1964
|
+
if (value !== null && typeof value === "object") {
|
|
1965
|
+
const fields = value;
|
|
1966
|
+
return `{${Object.keys(fields).filter((key) => fields[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(fields[key])}`).join(",")}}`;
|
|
1967
|
+
}
|
|
1968
|
+
return JSON.stringify(value) ?? "null";
|
|
1969
|
+
}
|
|
1970
|
+
/** Whether two records say the same thing to a reader: the text, the title, the description and the archive. */
|
|
1971
|
+
function sameDocument(left, right) {
|
|
1972
|
+
return left.contentHash === right.contentHash && left.title === right.title && left.description === right.description && left.status === right.status && (left.archiveSummary ?? "") === (right.archiveSummary ?? "");
|
|
1973
|
+
}
|
|
1974
|
+
/** Each document's version in one payload, by id, or nothing when the payload holds no valid Documents index. */
|
|
1975
|
+
function documentVersions(files) {
|
|
1976
|
+
const bytes = files["payload/documents/index.json"];
|
|
1977
|
+
if (bytes === void 0) return void 0;
|
|
1978
|
+
try {
|
|
1979
|
+
return Object.fromEntries(parseDocumentIndex(json$1(bytes, "payload/documents/index.json")).documents.map((document) => [document.id, documentVersion(document)]));
|
|
1980
|
+
} catch {
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* `base` is each document's version as this machine and the branch last agreed on it, which
|
|
1986
|
+
* only a sync channel knows, and it decides only for a document both hold. One the branch did
|
|
1987
|
+
* not change since then keeps this machine's version, edited or not. One only the branch
|
|
1988
|
+
* changed is the newer version of the same document and replaces the one here, unless its
|
|
1989
|
+
* revision is lower than this machine's, as an older dsh-mnemon can publish; that one is kept
|
|
1990
|
+
* beside it, as a merge without `base` keeps every document whose text differs.
|
|
1991
|
+
*/
|
|
1992
|
+
function mergeDocuments(root, pack, base) {
|
|
1570
1993
|
const current = readCurrentDocuments(root);
|
|
1571
1994
|
const incoming = archiveDocuments(pack);
|
|
1572
1995
|
const ids = new Map(current.index.documents.map((document) => [document.id, document]));
|
|
1573
|
-
|
|
1574
|
-
const existing = ids.get(source.id);
|
|
1575
|
-
if (existing !== void 0 && existing.contentHash === source.contentHash) continue;
|
|
1576
|
-
let document = source;
|
|
1577
|
-
let bytes = incoming.files.get(source.id);
|
|
1578
|
-
if (existing !== void 0) {
|
|
1579
|
-
const remapped = remapDocument(source, bytes, randomUUID());
|
|
1580
|
-
document = remapped.document;
|
|
1581
|
-
bytes = remapped.bytes;
|
|
1582
|
-
}
|
|
1996
|
+
const add = (document, bytes) => {
|
|
1583
1997
|
current.index.documents.push(document);
|
|
1584
1998
|
current.files.set(document.id, bytes);
|
|
1585
1999
|
ids.set(document.id, document);
|
|
2000
|
+
};
|
|
2001
|
+
const copy = (source) => {
|
|
2002
|
+
const remapped = remapDocument(source, incoming.files.get(source.id), randomUUID());
|
|
2003
|
+
add(remapped.document, remapped.bytes);
|
|
2004
|
+
};
|
|
2005
|
+
for (const source of incoming.index.documents) {
|
|
2006
|
+
const existing = ids.get(source.id);
|
|
2007
|
+
const agreed = existing !== void 0 && base !== void 0 && Object.hasOwn(base, source.id) ? base[source.id] : void 0;
|
|
2008
|
+
if (existing !== void 0 && agreed !== void 0) {
|
|
2009
|
+
if (documentVersion(source) === agreed) continue;
|
|
2010
|
+
const at = current.index.documents.indexOf(existing);
|
|
2011
|
+
if (documentVersion(existing) !== agreed) {
|
|
2012
|
+
const kept = {
|
|
2013
|
+
...existing,
|
|
2014
|
+
revision: Math.max(existing.revision, source.revision) + 1
|
|
2015
|
+
};
|
|
2016
|
+
current.index.documents[at] = kept;
|
|
2017
|
+
ids.set(source.id, kept);
|
|
2018
|
+
if (!current.index.documents.some((document) => sameDocument(document, source))) copy(source);
|
|
2019
|
+
continue;
|
|
2020
|
+
}
|
|
2021
|
+
if (source.revision >= existing.revision) {
|
|
2022
|
+
current.index.documents[at] = source;
|
|
2023
|
+
current.files.set(source.id, incoming.files.get(source.id));
|
|
2024
|
+
ids.set(source.id, source);
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
if (existing !== void 0 && existing.contentHash === source.contentHash) continue;
|
|
2029
|
+
if (existing !== void 0 && current.index.documents.some((document) => document.contentHash === source.contentHash)) continue;
|
|
2030
|
+
if (existing !== void 0) copy(source);
|
|
2031
|
+
else add(source, incoming.files.get(source.id));
|
|
1586
2032
|
}
|
|
1587
2033
|
if (current.index.documents.filter((document) => document.status === "active").reduce((sum, document) => sum + document.sizeBytes, 0) > DOCUMENTS_ACTIVE_LIMIT_BYTES) throw new Error(`merged Documents exceed the ${DOCUMENTS_ACTIVE_LIMIT_BYTES} byte active limit`);
|
|
1588
2034
|
return current;
|
|
@@ -1591,23 +2037,43 @@ function mergeRegistry(root, pack) {
|
|
|
1591
2037
|
const current = readCurrentRegistry(root);
|
|
1592
2038
|
const incoming = archiveRegistry(pack);
|
|
1593
2039
|
const ids = new Set(current.registry.bodies.map((body) => body.id));
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
const
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
body
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
2040
|
+
const identity = new MemorySpaceIdentity(root);
|
|
2041
|
+
try {
|
|
2042
|
+
const covers = (outer, inner) => identity.of(outer) === identity.of(inner) || identity.contains(outer, inner);
|
|
2043
|
+
const held = (database, accept) => current.registry.bodies.some((body) => accept(body) && covers(current.databases.get(body.id), database));
|
|
2044
|
+
const add = (body, database) => {
|
|
2045
|
+
ids.add(body.id);
|
|
2046
|
+
current.registry.bodies.push(body);
|
|
2047
|
+
current.databases.set(body.id, database);
|
|
2048
|
+
};
|
|
2049
|
+
for (const source of incoming.registry.bodies) {
|
|
2050
|
+
const sourceDb = incoming.databases.get(source.id);
|
|
2051
|
+
if (ids.has(source.id)) {
|
|
2052
|
+
const localDb = current.databases.get(source.id);
|
|
2053
|
+
if (covers(localDb, sourceDb)) continue;
|
|
2054
|
+
const merged = identity.merge(localDb, sourceDb);
|
|
2055
|
+
if (merged !== void 0) {
|
|
2056
|
+
current.databases.set(source.id, merged);
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
2059
|
+
if (held(sourceDb, (body) => body.id !== source.id)) continue;
|
|
2060
|
+
const copy = current.registry.bodies.find((body) => body.id !== source.id && body.name === source.name && identity.contains(sourceDb, current.databases.get(body.id)));
|
|
2061
|
+
const advanced = copy === void 0 ? void 0 : identity.merge(current.databases.get(copy.id), sourceDb);
|
|
2062
|
+
if (copy !== void 0 && advanced !== void 0) {
|
|
2063
|
+
current.databases.set(copy.id, advanced);
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
add({
|
|
2067
|
+
...source,
|
|
2068
|
+
id: randomUUID(),
|
|
2069
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2070
|
+
}, sourceDb);
|
|
2071
|
+
} else if (!held(sourceDb, (body) => body.name === source.name)) add(source, sourceDb);
|
|
2072
|
+
}
|
|
2073
|
+
return current;
|
|
2074
|
+
} finally {
|
|
2075
|
+
identity.dispose();
|
|
1609
2076
|
}
|
|
1610
|
-
return current;
|
|
1611
2077
|
}
|
|
1612
2078
|
function persistedStore(root) {
|
|
1613
2079
|
try {
|
|
@@ -1662,7 +2128,7 @@ function stageImport(root, pack, components, mode, runtimeLimits, options = {})
|
|
|
1662
2128
|
keepRuntimeArchive(root, join(staging, "runtime"));
|
|
1663
2129
|
}
|
|
1664
2130
|
if (components.includes("documents")) {
|
|
1665
|
-
const documents = mode === "merge" ? mergeDocuments(root, pack) : archiveDocuments(pack);
|
|
2131
|
+
const documents = mode === "merge" ? mergeDocuments(root, pack, options.documentsBase) : archiveDocuments(pack);
|
|
1666
2132
|
writeDocuments(join(staging, "documents"), documents.index, documents.files);
|
|
1667
2133
|
}
|
|
1668
2134
|
if (components.includes("memory-spaces")) {
|
|
@@ -1899,6 +2365,10 @@ var MnemonPackManager = class {
|
|
|
1899
2365
|
occupied: Object.fromEntries(COMPONENT_ORDER.map((component) => [component, occupied(this.root, component)]))
|
|
1900
2366
|
};
|
|
1901
2367
|
}
|
|
2368
|
+
/**
|
|
2369
|
+
* `documentsBase` is each document's version, by id, as a sync channel last agreed on it with
|
|
2370
|
+
* the branch; a merge uses it to tell an older version from a divergent one. A ZIP import has none.
|
|
2371
|
+
*/
|
|
1902
2372
|
async importPack(base64, options) {
|
|
1903
2373
|
const pack = parseArchive(base64, this.runtimeLimits);
|
|
1904
2374
|
if (options.mode !== "merge" && options.mode !== "replace") throw new Error("Pack import mode must be merge or replace");
|
|
@@ -2494,6 +2964,17 @@ function headerRecord(headers) {
|
|
|
2494
2964
|
if (typeof headers.entries === "function") return Object.fromEntries(headers.entries());
|
|
2495
2965
|
return { ...headers };
|
|
2496
2966
|
}
|
|
2967
|
+
/**
|
|
2968
|
+
* What a tunnelled request read back, as a `fetch` answer: the status, the text and its
|
|
2969
|
+
* content type, which is all this channel uses. The type says how to read a body that
|
|
2970
|
+
* is not JSON, such as the form GitHub's sign-in answers with when no JSON was asked for.
|
|
2971
|
+
*/
|
|
2972
|
+
function tunnelAnswer(status, type, text) {
|
|
2973
|
+
return new Response(status === 204 || status === 205 || status === 304 ? null : text, {
|
|
2974
|
+
status,
|
|
2975
|
+
...type === void 0 ? {} : { headers: { "content-type": type } }
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2497
2978
|
/** The request one tunnel performs, as a `fetch`-shaped answer. */
|
|
2498
2979
|
function tunnelRequest(agent, url, init) {
|
|
2499
2980
|
return new Promise((resolve, reject) => {
|
|
@@ -2511,9 +2992,8 @@ function tunnelRequest(agent, url, init) {
|
|
|
2511
2992
|
});
|
|
2512
2993
|
response.once("error", reject);
|
|
2513
2994
|
response.once("end", () => {
|
|
2514
|
-
const
|
|
2515
|
-
|
|
2516
|
-
resolve(new Response(status === 204 || status === 205 || status === 304 ? null : text, { status }));
|
|
2995
|
+
const type = response.headers["content-type"];
|
|
2996
|
+
resolve(tunnelAnswer(response.statusCode ?? 502, type, Buffer.concat(chunks).toString("utf8")));
|
|
2517
2997
|
});
|
|
2518
2998
|
});
|
|
2519
2999
|
if (typeof init.body === "string") request.write(init.body);
|
|
@@ -2591,6 +3071,20 @@ function createGitHubTransport(options = {}) {
|
|
|
2591
3071
|
});
|
|
2592
3072
|
}
|
|
2593
3073
|
//#endregion
|
|
3074
|
+
//#region src/host/sync-error.ts
|
|
3075
|
+
/**
|
|
3076
|
+
* A sync failure the settings page can name in its own language. The message
|
|
3077
|
+
* stays the Host's own words for logs and for anything the page cannot name.
|
|
3078
|
+
*/
|
|
3079
|
+
var MnemonSyncError = class extends Error {
|
|
3080
|
+
reason;
|
|
3081
|
+
constructor(reason, message) {
|
|
3082
|
+
super(message);
|
|
3083
|
+
this.reason = reason;
|
|
3084
|
+
this.name = "MnemonSyncError";
|
|
3085
|
+
}
|
|
3086
|
+
};
|
|
3087
|
+
//#endregion
|
|
2594
3088
|
//#region src/host/github-auth.ts
|
|
2595
3089
|
/**
|
|
2596
3090
|
* GitHub sign-in for the repository sync channel: the OAuth device flow, the
|
|
@@ -2606,7 +3100,9 @@ function createGitHubTransport(options = {}) {
|
|
|
2606
3100
|
* process-wide dispatcher, then one retry through a proxy discovered from the
|
|
2607
3101
|
* environment or from this machine's own settings, so a network that reaches
|
|
2608
3102
|
* GitHub only through a proxy still signs in. Failures report GitHub's error
|
|
2609
|
-
* code, the HTTP status and the transport reason
|
|
3103
|
+
* code, the HTTP status and the transport reason, and a device code answer that
|
|
3104
|
+
* cannot be used adds its content type and field names; never a value from a
|
|
3105
|
+
* response body.
|
|
2610
3106
|
*/
|
|
2611
3107
|
const MNEMON_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
2612
3108
|
const MNEMON_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
@@ -2621,7 +3117,6 @@ const MNEMON_GITHUB_SCOPE = "repo";
|
|
|
2621
3117
|
*/
|
|
2622
3118
|
const MNEMON_SYNC_GITHUB_CREDENTIAL_KEY = "dsh-mnemon/github";
|
|
2623
3119
|
const MNEMON_GITHUB_REQUEST_TIMEOUT_MS = 3e4;
|
|
2624
|
-
const MNEMON_GITHUB_DEFAULT_INTERVAL_MS = 5e3;
|
|
2625
3120
|
const MNEMON_GITHUB_SLOW_DOWN_MS = 5e3;
|
|
2626
3121
|
const MNEMON_GITHUB_MAX_INTERVAL_MS = 6e4;
|
|
2627
3122
|
const MNEMON_GITHUB_FLOW_LIMIT_MS = 36e5;
|
|
@@ -2639,9 +3134,27 @@ function codeOf(body) {
|
|
|
2639
3134
|
const fields = record$5(body);
|
|
2640
3135
|
return text$1(fields?.error) ?? text$1(fields?.message);
|
|
2641
3136
|
}
|
|
3137
|
+
/** A positive count GitHub sends as a JSON number, or as a string in a form-encoded answer. */
|
|
3138
|
+
function count(value) {
|
|
3139
|
+
const number = typeof value === "string" && /^\d{1,9}$/u.test(value) ? Number(value) : value;
|
|
3140
|
+
return typeof number === "number" && Number.isFinite(number) && number > 0 ? number : void 0;
|
|
3141
|
+
}
|
|
3142
|
+
/** A key that reads as a field name; anything else in a body that only claimed to be a form could hold a value. */
|
|
3143
|
+
const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/u;
|
|
3144
|
+
/**
|
|
3145
|
+
* What an answer GitHub sent looked like, without any of its values: those can be a
|
|
3146
|
+
* device code or a token. Enough to tell a proxy's HTML page from a reply that lost a field.
|
|
3147
|
+
*/
|
|
3148
|
+
function shapeOf(answer) {
|
|
3149
|
+
const keys = Object.keys(record$5(answer.body) ?? {});
|
|
3150
|
+
const names = keys.filter((key) => FIELD_NAME.test(key)).slice(0, 8);
|
|
3151
|
+
const listed = keys.length > names.length ? [...names, String(keys.length - names.length) + " other"] : names;
|
|
3152
|
+
return "HTTP " + String(answer.status) + ", content-type " + (answer.type ?? "none") + ", fields: " + (listed.length === 0 ? "none" : listed.join(", "));
|
|
3153
|
+
}
|
|
2642
3154
|
function failure$2(action, status, body) {
|
|
2643
3155
|
const code = codeOf(body);
|
|
2644
|
-
|
|
3156
|
+
const message = code === void 0 ? action + " failed with HTTP " + String(status) : action + " failed: " + code;
|
|
3157
|
+
return status === 401 ? new MnemonSyncError("github-unauthorized", message) : new Error(message);
|
|
2645
3158
|
}
|
|
2646
3159
|
/** Read one grant out of a stored record, refusing anything else. */
|
|
2647
3160
|
function githubGrantFromRecord(record_) {
|
|
@@ -2784,16 +3297,16 @@ var MnemonGitHubAuth = class {
|
|
|
2784
3297
|
const deviceCode = text$1(fields?.device_code);
|
|
2785
3298
|
const userCode = text$1(fields?.user_code);
|
|
2786
3299
|
const verificationUri = text$1(fields?.verification_uri);
|
|
2787
|
-
if (deviceCode === void 0 || userCode === void 0 || verificationUri === void 0) throw new Error("the GitHub device code answer was incomplete");
|
|
2788
|
-
const expiresIn =
|
|
2789
|
-
const
|
|
3300
|
+
if (deviceCode === void 0 || userCode === void 0 || verificationUri === void 0) throw new Error("the GitHub device code answer was incomplete (" + shapeOf(answer) + ")");
|
|
3301
|
+
const expiresIn = count(fields?.expires_in) ?? 900;
|
|
3302
|
+
const intervalMs = Math.min(Math.max((count(fields?.interval) ?? 5) * 1e3, 1e3), MNEMON_GITHUB_MAX_INTERVAL_MS);
|
|
2790
3303
|
this.flow = {
|
|
2791
3304
|
deviceCode,
|
|
2792
3305
|
userCode,
|
|
2793
3306
|
verificationUri,
|
|
2794
3307
|
expiresAt: this.now() + Math.min(expiresIn * 1e3, MNEMON_GITHUB_FLOW_LIMIT_MS),
|
|
2795
|
-
intervalMs
|
|
2796
|
-
nextPollAt: this.now() +
|
|
3308
|
+
intervalMs,
|
|
3309
|
+
nextPollAt: this.now() + intervalMs
|
|
2797
3310
|
};
|
|
2798
3311
|
return this.status();
|
|
2799
3312
|
}
|
|
@@ -2920,17 +3433,20 @@ var MnemonGitHubAuth = class {
|
|
|
2920
3433
|
/** The repositories the account may write to, most recently pushed first. */
|
|
2921
3434
|
async repositories(signal) {
|
|
2922
3435
|
const token = await this.requireToken();
|
|
2923
|
-
const url = "https://api.github.com/user/repos?per_page=" + String(100) + "&sort=updated&affiliation=owner%2Ccollaborator%2Corganization_member";
|
|
2924
|
-
const { status, body } = await this.ask(url, {
|
|
2925
|
-
headers: this.authorization(token),
|
|
2926
|
-
...signal === void 0 ? {} : { signal }
|
|
2927
|
-
}, "listing GitHub repositories");
|
|
2928
|
-
if (status !== 200) throw failure$2("listing GitHub repositories", status, body);
|
|
2929
|
-
if (!Array.isArray(body)) throw new Error("the GitHub repository answer was not a list");
|
|
2930
3436
|
const repositories = [];
|
|
2931
|
-
for (
|
|
2932
|
-
const
|
|
2933
|
-
|
|
3437
|
+
for (let page = 1; page <= 5; page += 1) {
|
|
3438
|
+
const url = "https://api.github.com/user/repos?per_page=" + String(100) + "&page=" + String(page) + "&sort=updated&affiliation=owner%2Ccollaborator%2Corganization_member";
|
|
3439
|
+
const { status, body } = await this.ask(url, {
|
|
3440
|
+
headers: this.authorization(token),
|
|
3441
|
+
...signal === void 0 ? {} : { signal }
|
|
3442
|
+
}, "listing GitHub repositories");
|
|
3443
|
+
if (status !== 200) throw failure$2("listing GitHub repositories", status, body);
|
|
3444
|
+
if (!Array.isArray(body)) throw new Error("the GitHub repository answer was not a list");
|
|
3445
|
+
for (const entry of body) {
|
|
3446
|
+
const repository = repositoryOf(entry);
|
|
3447
|
+
if (repository !== void 0 && !repositories.some((known) => known.fullName === repository.fullName)) repositories.push(repository);
|
|
3448
|
+
}
|
|
3449
|
+
if (body.length < 100) break;
|
|
2934
3450
|
}
|
|
2935
3451
|
return {
|
|
2936
3452
|
login: await this.account(token, signal) ?? "",
|
|
@@ -2990,14 +3506,16 @@ var MnemonGitHubAuth = class {
|
|
|
2990
3506
|
signal
|
|
2991
3507
|
});
|
|
2992
3508
|
const raw = await response.text();
|
|
3509
|
+
const type = response.headers.get("content-type") ?? void 0;
|
|
2993
3510
|
let body;
|
|
2994
3511
|
try {
|
|
2995
3512
|
body = raw === "" ? void 0 : JSON.parse(raw);
|
|
2996
3513
|
} catch {
|
|
2997
|
-
body = void 0;
|
|
3514
|
+
body = /^application\/x-www-form-urlencoded\b/iu.test(type ?? "") ? Object.fromEntries(new URLSearchParams(raw)) : void 0;
|
|
2998
3515
|
}
|
|
2999
3516
|
return {
|
|
3000
3517
|
status: response.status,
|
|
3518
|
+
...type === void 0 ? {} : { type },
|
|
3001
3519
|
body
|
|
3002
3520
|
};
|
|
3003
3521
|
} finally {
|
|
@@ -3234,7 +3752,31 @@ var MnemonSyncSettingsStore = class {
|
|
|
3234
3752
|
//#region src/host/git-sync.ts
|
|
3235
3753
|
/** The oldest Git the channel drives: `archive`, `ls-remote`, `-C`, and `credential` all predate it. */
|
|
3236
3754
|
const MNEMON_SYNC_MINIMUM_GIT = "2.20";
|
|
3237
|
-
|
|
3755
|
+
/**
|
|
3756
|
+
* How long the settings page waits to learn whether the remote answers. A page
|
|
3757
|
+
* that opens should not hang on a host that drops packets, and anything slower
|
|
3758
|
+
* than this is reported as a timeout rather than awaited.
|
|
3759
|
+
*/
|
|
3760
|
+
const MNEMON_SYNC_STATUS_TIMEOUT_MS = 2e4;
|
|
3761
|
+
/** A push Git refused because the branch moved is folded in and tried once more. */
|
|
3762
|
+
const PUSH_ATTEMPTS = 2;
|
|
3763
|
+
/**
|
|
3764
|
+
* No sync runs this long, so a lock older than this is abandoned whoever holds it.
|
|
3765
|
+
* It also covers a process id that a restart handed to an unrelated process.
|
|
3766
|
+
*/
|
|
3767
|
+
const STALE_LOCK_MS = 36e5;
|
|
3768
|
+
const LOCK_OWNER = "owner.json";
|
|
3769
|
+
/**
|
|
3770
|
+
* A lock without an owner record was taken by a version that kept none, and may
|
|
3771
|
+
* still be in use by it; it is taken over only once no such run could still last.
|
|
3772
|
+
*/
|
|
3773
|
+
const OWNERLESS_LOCK_MS = 9e5;
|
|
3774
|
+
/** The mirror's own Git config names the repository it was filled from. */
|
|
3775
|
+
const ORIGIN_KEY = "mnemon.origin";
|
|
3776
|
+
const LAST_RUN_FILE = "last-run.json";
|
|
3777
|
+
/** What this machine and the branch last agreed on: each document's version, by id, and the commit it was agreed at. */
|
|
3778
|
+
const BASE_FILE = "base.json";
|
|
3779
|
+
const AGREED_COMMIT = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u;
|
|
3238
3780
|
const GIT_LABEL = "git";
|
|
3239
3781
|
const MANIFEST = "manifest.json";
|
|
3240
3782
|
const CHECKSUMS = "checksums.json";
|
|
@@ -3295,6 +3837,16 @@ function gitEnvironment(environment = process.env) {
|
|
|
3295
3837
|
sanitized.GCM_INTERACTIVE = "never";
|
|
3296
3838
|
return sanitized;
|
|
3297
3839
|
}
|
|
3840
|
+
/** The host an HTTPS address hands a token to; any other address takes none. */
|
|
3841
|
+
function tokenHost(repoUrl) {
|
|
3842
|
+
if (repoUrl === void 0) return void 0;
|
|
3843
|
+
try {
|
|
3844
|
+
const url = new URL(repoUrl);
|
|
3845
|
+
return url.protocol === "https:" ? url.host.toLowerCase() : void 0;
|
|
3846
|
+
} catch {
|
|
3847
|
+
return;
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3298
3850
|
/** Whether a repository address is HTTPS on github.com, the only host a GitHub grant is for. */
|
|
3299
3851
|
function isGitHubHttps(repoUrl) {
|
|
3300
3852
|
try {
|
|
@@ -3304,6 +3856,37 @@ function isGitHubHttps(repoUrl) {
|
|
|
3304
3856
|
return false;
|
|
3305
3857
|
}
|
|
3306
3858
|
}
|
|
3859
|
+
/**
|
|
3860
|
+
* What kind of failure one Git error describes. Git words these differently per
|
|
3861
|
+
* transport and per host, so the patterns are the phrases each transport uses:
|
|
3862
|
+
* a refused or missing credential, an address that leads nowhere (GitHub answers
|
|
3863
|
+
* "not found" for a private repository it will not show without credentials), a
|
|
3864
|
+
* network that does not reach the host, and a host that never answered.
|
|
3865
|
+
*/
|
|
3866
|
+
function remoteIssueOf(message) {
|
|
3867
|
+
const text = message.toLowerCase();
|
|
3868
|
+
if (/did not respond within|timed out|timeout was reached/u.test(text)) return "timeout";
|
|
3869
|
+
if (/authentication failed|could not read (username|password)|terminal prompts disabled|permission denied|invalid username or password|bad credentials|returned error: 40[13]|host key verification failed|access denied|denied to /u.test(text)) return "auth";
|
|
3870
|
+
if (/repository not found|not found|does not appear to be a git repository|no such file or directory|does not exist|returned error: 404/u.test(text)) return "not-found";
|
|
3871
|
+
if (/could not resolve|failed to connect|couldn't connect|connection (refused|reset|closed|timed out)|network is unreachable|no route to host|unable to access|ssl|tls|proxy|empty reply/u.test(text)) return "network";
|
|
3872
|
+
return "unknown";
|
|
3873
|
+
}
|
|
3874
|
+
const REMOTE_FAILURE = {
|
|
3875
|
+
auth: "remote-auth",
|
|
3876
|
+
"not-found": "remote-not-found",
|
|
3877
|
+
network: "remote-network",
|
|
3878
|
+
timeout: "remote-timeout",
|
|
3879
|
+
unknown: void 0
|
|
3880
|
+
};
|
|
3881
|
+
/** One failed remote command as the error a caller reports: named when it can be. */
|
|
3882
|
+
function remoteFailure(message) {
|
|
3883
|
+
const reason = REMOTE_FAILURE[remoteIssueOf(message)];
|
|
3884
|
+
return reason === void 0 ? new Error(message) : new MnemonSyncError(reason, message);
|
|
3885
|
+
}
|
|
3886
|
+
/** Whether Git refused a push because the branch moved on since this run read it. */
|
|
3887
|
+
function rejectedPush(output) {
|
|
3888
|
+
return /\[rejected\]|fetch first|non-fast-forward/iu.test(output);
|
|
3889
|
+
}
|
|
3307
3890
|
function record$3(value) {
|
|
3308
3891
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
3309
3892
|
}
|
|
@@ -3450,9 +4033,9 @@ function objectCount(output) {
|
|
|
3450
4033
|
if (loose === null || bytes === null || packed === null || packedBytes === null) return void 0;
|
|
3451
4034
|
return {
|
|
3452
4035
|
loose: Number(loose[1]),
|
|
3453
|
-
bytes: Number(bytes[1]),
|
|
4036
|
+
bytes: Number(bytes[1]) * 1024,
|
|
3454
4037
|
packed: Number(packed[1]),
|
|
3455
|
-
packedBytes: Number(packedBytes[1])
|
|
4038
|
+
packedBytes: Number(packedBytes[1]) * 1024
|
|
3456
4039
|
};
|
|
3457
4040
|
}
|
|
3458
4041
|
/** How many commits one page asks for: the caller's window, clamped to what one listing should carry. */
|
|
@@ -3718,6 +4301,8 @@ var MnemonGitSync = class {
|
|
|
3718
4301
|
};
|
|
3719
4302
|
const commit = existsSync(this.store.mirror()) && git.available ? await this.lastCommit(signal) : void 0;
|
|
3720
4303
|
const autoBackup = this.autoBackupView?.();
|
|
4304
|
+
const lastRun = repoUrl === void 0 ? void 0 : this.lastRun();
|
|
4305
|
+
const machine = this.machine.current();
|
|
3721
4306
|
return {
|
|
3722
4307
|
enabled: true,
|
|
3723
4308
|
configured: repoUrl !== void 0,
|
|
@@ -3726,14 +4311,27 @@ var MnemonGitSync = class {
|
|
|
3726
4311
|
mirrorPath: this.store.mirror(),
|
|
3727
4312
|
git,
|
|
3728
4313
|
remote,
|
|
3729
|
-
machine:
|
|
4314
|
+
machine: machine.identity,
|
|
4315
|
+
machineLabel: {
|
|
4316
|
+
source: machine.source,
|
|
4317
|
+
hostname: defaultMachineLabel()
|
|
4318
|
+
},
|
|
3730
4319
|
...commit === void 0 ? {} : { lastCommit: commit },
|
|
4320
|
+
...lastRun === void 0 ? {} : { lastRun },
|
|
3731
4321
|
...autoBackup === void 0 ? {} : { autoBackup }
|
|
3732
4322
|
};
|
|
3733
4323
|
}
|
|
3734
4324
|
async configure(patch) {
|
|
3735
|
-
const
|
|
4325
|
+
const previous = this.store.read();
|
|
4326
|
+
const fields = record$3(patch);
|
|
4327
|
+
const { machineLabel: name, ...settings } = fields ?? {};
|
|
4328
|
+
const naming = fields !== void 0 && "machineLabel" in fields;
|
|
4329
|
+
if (naming && name !== null && name !== "") machineLabel(name);
|
|
4330
|
+
const next = this.store.patch(fields === void 0 ? patch : settings);
|
|
4331
|
+
if (next.token !== void 0 && previous.repoUrl !== void 0 && fields !== void 0 && !("token" in fields) && tokenHost(previous.repoUrl) !== tokenHost(next.repoUrl)) delete next.token;
|
|
3736
4332
|
this.store.write(next);
|
|
4333
|
+
if (naming) this.machine.rename(name);
|
|
4334
|
+
if (previous.repoUrl !== next.repoUrl) rmSync(this.lastRunPath(), { force: true });
|
|
3737
4335
|
return this.credentialView(next);
|
|
3738
4336
|
}
|
|
3739
4337
|
/**
|
|
@@ -3747,106 +4345,270 @@ var MnemonGitSync = class {
|
|
|
3747
4345
|
await this.requireGit(input.signal);
|
|
3748
4346
|
const message = commitMessage(input.message, this.now());
|
|
3749
4347
|
return this.lock(async () => {
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
let
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
tombstones: {
|
|
3758
|
-
version: 1,
|
|
3759
|
-
tombstones: remote.tombstones
|
|
3760
|
-
}
|
|
3761
|
-
});
|
|
3762
|
-
merged = {
|
|
3763
|
-
commit: remote.commit,
|
|
3764
|
-
...remote.machine === void 0 ? {} : { machine: {
|
|
3765
|
-
id: remote.machine.id,
|
|
3766
|
-
label: remote.machine.label
|
|
3767
|
-
} },
|
|
3768
|
-
components: imported.components,
|
|
3769
|
-
summary: imported.summary,
|
|
3770
|
-
tombstones: remote.tombstones.length
|
|
3771
|
-
};
|
|
4348
|
+
let result = await this.pushOnce(settings, message, input.signal);
|
|
4349
|
+
let added = result.merged?.runtime?.added ?? 0;
|
|
4350
|
+
let held = result.merged?.runtime?.held ?? 0;
|
|
4351
|
+
for (let attempt = 1; attempt < PUSH_ATTEMPTS && result.failure === "push-rejected"; attempt += 1) {
|
|
4352
|
+
result = await this.pushOnce(settings, message, input.signal);
|
|
4353
|
+
added += result.merged?.runtime?.added ?? 0;
|
|
4354
|
+
held = result.merged?.runtime?.held ?? held;
|
|
3772
4355
|
}
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
4356
|
+
if (result.merged !== void 0 && (added > 0 || result.merged.runtime !== void 0)) result = {
|
|
4357
|
+
...result,
|
|
4358
|
+
merged: {
|
|
4359
|
+
...result.merged,
|
|
4360
|
+
runtime: {
|
|
4361
|
+
added,
|
|
4362
|
+
held
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
3782
4365
|
};
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
if (prepared.tip === void 0 || !holdsPayload(before, entries)) this.writePayload(settings.subdir, entries);
|
|
3789
|
-
const staged = await this.git([
|
|
3790
|
-
"add",
|
|
3791
|
-
"--all",
|
|
3792
|
-
"--",
|
|
3793
|
-
settings.subdir
|
|
3794
|
-
], {
|
|
3795
|
-
cwd: this.store.mirror(),
|
|
3796
|
-
signal: input.signal
|
|
4366
|
+
if (result.pushed || !result.committed) this.recordRun({
|
|
4367
|
+
kind: "sync",
|
|
4368
|
+
commit: result.commit,
|
|
4369
|
+
pushed: result.pushed,
|
|
4370
|
+
...result.merged?.runtime === void 0 ? {} : { added: result.merged.runtime.added }
|
|
3797
4371
|
});
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
4372
|
+
return result;
|
|
4373
|
+
});
|
|
4374
|
+
}
|
|
4375
|
+
async pushOnce(settings, message, signal) {
|
|
4376
|
+
const prepared = await this.ensureMirror(settings, signal);
|
|
4377
|
+
await this.packs.recordDeletions();
|
|
4378
|
+
const remote = await this.readPrepared(settings, prepared);
|
|
4379
|
+
let merged;
|
|
4380
|
+
if (remote !== void 0) {
|
|
4381
|
+
const base = await this.agreedDocuments(settings, remote.commit, signal);
|
|
4382
|
+
const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
|
|
4383
|
+
mode: "merge",
|
|
4384
|
+
tombstones: {
|
|
4385
|
+
version: 1,
|
|
4386
|
+
tombstones: remote.tombstones
|
|
4387
|
+
},
|
|
4388
|
+
...base === void 0 ? {} : { documentsBase: base }
|
|
4389
|
+
});
|
|
4390
|
+
if (imported.components.includes("documents")) this.agree(settings, remote.files, remote.commit);
|
|
4391
|
+
merged = {
|
|
4392
|
+
commit: remote.commit,
|
|
4393
|
+
...remote.machine === void 0 ? {} : { machine: {
|
|
4394
|
+
id: remote.machine.id,
|
|
4395
|
+
label: remote.machine.label
|
|
4396
|
+
} },
|
|
4397
|
+
components: imported.components,
|
|
4398
|
+
summary: imported.summary,
|
|
4399
|
+
tombstones: remote.tombstones.length,
|
|
4400
|
+
...imported.runtime === void 0 ? {} : { runtime: imported.runtime }
|
|
4401
|
+
};
|
|
4402
|
+
}
|
|
4403
|
+
const exported = await this.packs.exportPack("full", { stamp: true });
|
|
4404
|
+
const entries = unpack(Buffer.from(exported.base64, "base64"));
|
|
4405
|
+
const extension = {
|
|
4406
|
+
channel: "git",
|
|
4407
|
+
branch: settings.branch,
|
|
4408
|
+
subdir: settings.subdir,
|
|
4409
|
+
pushedAt: exported.manifest.exportedAt,
|
|
4410
|
+
machine: this.machine.read(),
|
|
4411
|
+
tombstones: this.packs.tombstones().tombstones
|
|
4412
|
+
};
|
|
4413
|
+
entries[MANIFEST] = new TextEncoder().encode(JSON.stringify({
|
|
4414
|
+
...exported.manifest,
|
|
4415
|
+
[SYNC_EXTENSION]: extension
|
|
4416
|
+
}, null, 2) + "\n");
|
|
4417
|
+
const before = readFiles(this.payloadRoot(settings.subdir));
|
|
4418
|
+
if (prepared.tip === void 0 || !holdsPayload(before, entries)) this.writePayload(settings.subdir, entries);
|
|
4419
|
+
const staged = await this.git([
|
|
4420
|
+
"add",
|
|
4421
|
+
"--all",
|
|
4422
|
+
"--",
|
|
4423
|
+
settings.subdir
|
|
4424
|
+
], {
|
|
4425
|
+
cwd: this.store.mirror(),
|
|
4426
|
+
signal
|
|
4427
|
+
});
|
|
4428
|
+
if (staged.exitCode !== 0) throw new Error("git add failed: " + tail(staged.stderr || staged.stdout));
|
|
4429
|
+
const committed = (await this.git([
|
|
4430
|
+
"status",
|
|
4431
|
+
"--porcelain",
|
|
4432
|
+
"--",
|
|
4433
|
+
settings.subdir
|
|
4434
|
+
], {
|
|
4435
|
+
cwd: this.store.mirror(),
|
|
4436
|
+
signal
|
|
4437
|
+
})).stdout.trim() !== "";
|
|
4438
|
+
if (committed) {
|
|
4439
|
+
const identity = await this.commitIdentity(settings, signal);
|
|
4440
|
+
const result = await this.git([
|
|
4441
|
+
...identity,
|
|
4442
|
+
"commit",
|
|
4443
|
+
"--quiet",
|
|
4444
|
+
"-m",
|
|
4445
|
+
message,
|
|
3802
4446
|
"--",
|
|
3803
4447
|
settings.subdir
|
|
3804
4448
|
], {
|
|
3805
4449
|
cwd: this.store.mirror(),
|
|
3806
|
-
signal
|
|
3807
|
-
})
|
|
3808
|
-
if (
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
}
|
|
4450
|
+
signal
|
|
4451
|
+
});
|
|
4452
|
+
if (result.exitCode !== 0) throw new Error("git commit failed: " + tail(result.stderr || result.stdout));
|
|
4453
|
+
}
|
|
4454
|
+
const head = await this.head(signal);
|
|
4455
|
+
const published = committed ? await this.publish(settings, await this.credential(settings), signal) : {
|
|
4456
|
+
pushed: false,
|
|
4457
|
+
reason: prepared.tip === void 0 ? "nothing to publish" : "the branch already holds this payload"
|
|
4458
|
+
};
|
|
4459
|
+
if (published.pushed || !committed && prepared.tip !== void 0) this.agree(settings, entries, head);
|
|
4460
|
+
const compaction = await this.compact(signal);
|
|
4461
|
+
const summary = exported.manifest.summary.map((entry) => ({
|
|
4462
|
+
...entry,
|
|
4463
|
+
changed: componentChanged(before, entries, entry.component)
|
|
4464
|
+
}));
|
|
4465
|
+
return {
|
|
4466
|
+
repoUrl: settings.repoUrl,
|
|
4467
|
+
branch: settings.branch,
|
|
4468
|
+
subdir: settings.subdir,
|
|
4469
|
+
commit: head,
|
|
4470
|
+
committed,
|
|
4471
|
+
message,
|
|
4472
|
+
files: Object.keys(entries).length,
|
|
4473
|
+
bytes: Object.values(entries).reduce((total, bytes) => total + bytes.byteLength, 0),
|
|
4474
|
+
summary,
|
|
4475
|
+
pushed: published.pushed,
|
|
4476
|
+
...merged === void 0 ? {} : { merged },
|
|
4477
|
+
...published.reason === void 0 ? {} : { reason: published.reason },
|
|
4478
|
+
...published.failure === void 0 ? {} : { failure: published.failure },
|
|
4479
|
+
...compaction === void 0 ? {} : { compaction }
|
|
4480
|
+
};
|
|
4481
|
+
}
|
|
4482
|
+
/**
|
|
4483
|
+
* The identity a sync commit is recorded under: the one configured here, else the
|
|
4484
|
+
* Git identity this machine has. A machine without one gets the channel's own
|
|
4485
|
+
* rather than a failed commit, and rather than the account and host names Git
|
|
4486
|
+
* would otherwise guess and publish.
|
|
4487
|
+
*/
|
|
4488
|
+
async commitIdentity(settings, signal) {
|
|
4489
|
+
const name = settings.authorName !== "" ? settings.authorName : await this.configured("user.name", signal) ?? "dsh-mnemon sync";
|
|
4490
|
+
const email = settings.authorEmail !== "" ? settings.authorEmail : await this.configured("user.email", signal) ?? "mnemon@localhost";
|
|
4491
|
+
return [
|
|
4492
|
+
"-c",
|
|
4493
|
+
"user.name=" + name,
|
|
4494
|
+
"-c",
|
|
4495
|
+
"user.email=" + email
|
|
4496
|
+
];
|
|
4497
|
+
}
|
|
4498
|
+
/** One Git setting as this machine resolves it, or nothing when no config sets it. */
|
|
4499
|
+
async configured(key, signal) {
|
|
4500
|
+
const result = await this.git([
|
|
4501
|
+
"config",
|
|
4502
|
+
"--get",
|
|
4503
|
+
key
|
|
4504
|
+
], {
|
|
4505
|
+
cwd: this.store.mirror(),
|
|
4506
|
+
signal
|
|
4507
|
+
});
|
|
4508
|
+
const value = result.exitCode === 0 ? result.stdout.trim() : "";
|
|
4509
|
+
return value === "" ? void 0 : value;
|
|
4510
|
+
}
|
|
4511
|
+
/** When memory last travelled through this channel, or nothing when it never did. */
|
|
4512
|
+
lastRun() {
|
|
4513
|
+
try {
|
|
4514
|
+
const value = record$3(JSON.parse(readFileSync(this.lastRunPath(), "utf8")));
|
|
4515
|
+
if (value === void 0 || typeof value.at !== "string" || typeof value.commit !== "string") return void 0;
|
|
4516
|
+
if (value.kind !== "sync" && value.kind !== "merge") return void 0;
|
|
3834
4517
|
return {
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
message,
|
|
3841
|
-
files: Object.keys(entries).length,
|
|
3842
|
-
bytes: Object.values(entries).reduce((total, bytes) => total + bytes.byteLength, 0),
|
|
3843
|
-
summary,
|
|
3844
|
-
pushed: published.pushed,
|
|
3845
|
-
...merged === void 0 ? {} : { merged },
|
|
3846
|
-
...published.reason === void 0 ? {} : { reason: published.reason },
|
|
3847
|
-
...compaction === void 0 ? {} : { compaction }
|
|
4518
|
+
at: value.at,
|
|
4519
|
+
kind: value.kind,
|
|
4520
|
+
commit: value.commit,
|
|
4521
|
+
pushed: value.pushed === true,
|
|
4522
|
+
...typeof value.added === "number" && Number.isInteger(value.added) && value.added >= 0 ? { added: value.added } : {}
|
|
3848
4523
|
};
|
|
3849
|
-
}
|
|
4524
|
+
} catch {
|
|
4525
|
+
return;
|
|
4526
|
+
}
|
|
4527
|
+
}
|
|
4528
|
+
lastRunPath() {
|
|
4529
|
+
return join(this.store.directory(), "sync", LAST_RUN_FILE);
|
|
4530
|
+
}
|
|
4531
|
+
basePath() {
|
|
4532
|
+
return join(this.store.directory(), "sync", BASE_FILE);
|
|
4533
|
+
}
|
|
4534
|
+
/** The repository, branch and directory one agreement belongs to. */
|
|
4535
|
+
agreementScope(settings) {
|
|
4536
|
+
return sha256Hex(new TextEncoder().encode(JSON.stringify([
|
|
4537
|
+
settings.repoUrl,
|
|
4538
|
+
settings.branch,
|
|
4539
|
+
settings.subdir
|
|
4540
|
+
])));
|
|
4541
|
+
}
|
|
4542
|
+
/**
|
|
4543
|
+
* Each document's version as this machine and the branch last agreed on it, after the last
|
|
4544
|
+
* merge or publish, when that still describes the branch at `tip`: the same repository,
|
|
4545
|
+
* branch and directory, agreed at a commit `tip` descends from. A branch deleted and published
|
|
4546
|
+
* again from another history has none, nor has the first sync, and a merge then keeps a copy
|
|
4547
|
+
* of every document that differs, as before.
|
|
4548
|
+
*/
|
|
4549
|
+
async agreedDocuments(settings, tip, signal) {
|
|
4550
|
+
let value;
|
|
4551
|
+
try {
|
|
4552
|
+
value = record$3(JSON.parse(readFileSync(this.basePath(), "utf8")));
|
|
4553
|
+
} catch {
|
|
4554
|
+
return;
|
|
4555
|
+
}
|
|
4556
|
+
if (value?.version !== 1 || value.scope !== this.agreementScope(settings)) return void 0;
|
|
4557
|
+
const documents = record$3(value.documents);
|
|
4558
|
+
if (typeof value.commit !== "string" || !AGREED_COMMIT.test(value.commit) || documents === void 0) return void 0;
|
|
4559
|
+
if ((await this.git([
|
|
4560
|
+
"merge-base",
|
|
4561
|
+
"--is-ancestor",
|
|
4562
|
+
value.commit,
|
|
4563
|
+
tip
|
|
4564
|
+
], {
|
|
4565
|
+
cwd: this.store.mirror(),
|
|
4566
|
+
signal
|
|
4567
|
+
})).exitCode !== 0) return void 0;
|
|
4568
|
+
return Object.fromEntries(Object.entries(documents).filter(([, version]) => typeof version === "string" && /^[a-f0-9]{64}$/u.test(version)));
|
|
4569
|
+
}
|
|
4570
|
+
/** Remember what this machine and the branch at `commit` now agree on. Bookkeeping never fails the run it follows. */
|
|
4571
|
+
agree(settings, files, commit) {
|
|
4572
|
+
const path = this.basePath();
|
|
4573
|
+
const temporary = path + "." + String(process.pid) + ".tmp";
|
|
4574
|
+
try {
|
|
4575
|
+
const documents = documentVersions(files);
|
|
4576
|
+
if (documents === void 0 || !AGREED_COMMIT.test(commit)) {
|
|
4577
|
+
rmSync(path, { force: true });
|
|
4578
|
+
return;
|
|
4579
|
+
}
|
|
4580
|
+
mkdirSync(join(path, ".."), {
|
|
4581
|
+
recursive: true,
|
|
4582
|
+
mode: 448
|
|
4583
|
+
});
|
|
4584
|
+
writeFileSync(temporary, JSON.stringify({
|
|
4585
|
+
version: 1,
|
|
4586
|
+
scope: this.agreementScope(settings),
|
|
4587
|
+
commit,
|
|
4588
|
+
documents
|
|
4589
|
+
}, null, 2) + "\n", { mode: 384 });
|
|
4590
|
+
renameSync(temporary, path);
|
|
4591
|
+
} catch {
|
|
4592
|
+
rmSync(temporary, { force: true });
|
|
4593
|
+
}
|
|
4594
|
+
}
|
|
4595
|
+
/** Remember one finished run. Bookkeeping never fails the run it describes. */
|
|
4596
|
+
recordRun(run) {
|
|
4597
|
+
const path = this.lastRunPath();
|
|
4598
|
+
const temporary = path + "." + String(process.pid) + ".tmp";
|
|
4599
|
+
try {
|
|
4600
|
+
mkdirSync(join(path, ".."), {
|
|
4601
|
+
recursive: true,
|
|
4602
|
+
mode: 448
|
|
4603
|
+
});
|
|
4604
|
+
writeFileSync(temporary, JSON.stringify({
|
|
4605
|
+
at: this.now().toISOString(),
|
|
4606
|
+
...run
|
|
4607
|
+
}, null, 2) + "\n", { mode: 384 });
|
|
4608
|
+
renameSync(temporary, path);
|
|
4609
|
+
} catch {
|
|
4610
|
+
rmSync(temporary, { force: true });
|
|
4611
|
+
}
|
|
3850
4612
|
}
|
|
3851
4613
|
/** Read the remote payload and report what it would change, without importing anything. */
|
|
3852
4614
|
async preview(signal) {
|
|
@@ -3855,7 +4617,7 @@ var MnemonGitSync = class {
|
|
|
3855
4617
|
const exported = await this.packs.exportPack("full");
|
|
3856
4618
|
return this.lock(async () => {
|
|
3857
4619
|
const remote = await this.readRemote(settings, signal);
|
|
3858
|
-
if (remote === void 0) throw
|
|
4620
|
+
if (remote === void 0) throw this.absentPayload(settings);
|
|
3859
4621
|
const local = unpack(Buffer.from(exported.base64, "base64"));
|
|
3860
4622
|
const components = MNEMON_PACK_COMPONENTS.map((component) => {
|
|
3861
4623
|
const published = remote.manifest.summary.find((entry) => entry.component === component);
|
|
@@ -3930,7 +4692,7 @@ var MnemonGitSync = class {
|
|
|
3930
4692
|
const exported = await this.packs.exportPack("full");
|
|
3931
4693
|
return this.lock(async () => {
|
|
3932
4694
|
const remote = await this.readRemote(settings, signal);
|
|
3933
|
-
if (remote === void 0) throw
|
|
4695
|
+
if (remote === void 0) throw this.absentPayload(settings);
|
|
3934
4696
|
const local = unpack(Buffer.from(exported.base64, "base64"));
|
|
3935
4697
|
const limits = this.runtimeLimits();
|
|
3936
4698
|
const mine = runtimeOf(local, limits, "the local");
|
|
@@ -4000,13 +4762,22 @@ var MnemonGitSync = class {
|
|
|
4000
4762
|
};
|
|
4001
4763
|
return this.lock(async () => {
|
|
4002
4764
|
const remote = await this.readRemote(settings, input.signal);
|
|
4003
|
-
if (remote === void 0) throw
|
|
4765
|
+
if (remote === void 0) throw this.absentPayload(settings);
|
|
4766
|
+
const base = await this.agreedDocuments(settings, remote.commit, input.signal);
|
|
4004
4767
|
const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
|
|
4005
4768
|
...options,
|
|
4006
4769
|
tombstones: {
|
|
4007
4770
|
version: 1,
|
|
4008
4771
|
tombstones: remote.tombstones
|
|
4009
|
-
}
|
|
4772
|
+
},
|
|
4773
|
+
...base === void 0 ? {} : { documentsBase: base }
|
|
4774
|
+
});
|
|
4775
|
+
if (imported.components.includes("documents")) this.agree(settings, remote.files, remote.commit);
|
|
4776
|
+
this.recordRun({
|
|
4777
|
+
kind: "merge",
|
|
4778
|
+
commit: remote.commit,
|
|
4779
|
+
pushed: false,
|
|
4780
|
+
...imported.runtime === void 0 ? {} : { added: imported.runtime.added }
|
|
4010
4781
|
});
|
|
4011
4782
|
return {
|
|
4012
4783
|
imported: true,
|
|
@@ -4025,11 +4796,11 @@ var MnemonGitSync = class {
|
|
|
4025
4796
|
});
|
|
4026
4797
|
}
|
|
4027
4798
|
absentPayload(settings) {
|
|
4028
|
-
return "the branch " + settings.branch + " holds no Mnemon payload at " + settings.subdir + " yet; push one first";
|
|
4799
|
+
return new MnemonSyncError("remote-empty", "the branch " + settings.branch + " holds no Mnemon payload at " + settings.subdir + " yet; push one first");
|
|
4029
4800
|
}
|
|
4030
4801
|
requireRepository() {
|
|
4031
4802
|
const settings = this.store.read();
|
|
4032
|
-
if (settings.repoUrl === void 0) throw new
|
|
4803
|
+
if (settings.repoUrl === void 0) throw new MnemonSyncError("no-repository", "no sync repository is configured; set repoUrl in " + this.store.path() + " first");
|
|
4033
4804
|
return {
|
|
4034
4805
|
...settings,
|
|
4035
4806
|
repoUrl: settings.repoUrl
|
|
@@ -4067,12 +4838,12 @@ var MnemonGitSync = class {
|
|
|
4067
4838
|
}
|
|
4068
4839
|
async requireGit(signal) {
|
|
4069
4840
|
const status = await this.gitStatus(signal);
|
|
4070
|
-
if (!status.available) throw new
|
|
4841
|
+
if (!status.available) throw new MnemonSyncError("git-missing", "Git is not available on this Host: " + (status.issue ?? "unknown reason"));
|
|
4071
4842
|
}
|
|
4072
4843
|
async remoteStatus(settings, signal) {
|
|
4073
4844
|
const credential = await this.credential(settings);
|
|
4074
4845
|
try {
|
|
4075
|
-
const tip = await this.lsRemote(settings, credential, signal);
|
|
4846
|
+
const tip = await this.lsRemote(settings, credential, signal, MNEMON_SYNC_STATUS_TIMEOUT_MS);
|
|
4076
4847
|
return tip === void 0 ? {
|
|
4077
4848
|
reachable: true,
|
|
4078
4849
|
branchExists: false
|
|
@@ -4082,26 +4853,40 @@ var MnemonGitSync = class {
|
|
|
4082
4853
|
commit: tip
|
|
4083
4854
|
};
|
|
4084
4855
|
} catch (error) {
|
|
4856
|
+
const message = this.mask(error instanceof Error ? error.message : String(error), credential);
|
|
4085
4857
|
return {
|
|
4086
4858
|
reachable: false,
|
|
4087
4859
|
branchExists: false,
|
|
4088
|
-
error:
|
|
4860
|
+
error: message,
|
|
4861
|
+
issue: remoteIssueOf(message)
|
|
4089
4862
|
};
|
|
4090
4863
|
}
|
|
4091
4864
|
}
|
|
4092
|
-
|
|
4865
|
+
/**
|
|
4866
|
+
* The branch tip the remote holds, or nothing when it holds no such branch. A
|
|
4867
|
+
* remote that cannot be read fails with the kind of failure it was, the token
|
|
4868
|
+
* masked, so the page can say "sign in" rather than print Git's trace.
|
|
4869
|
+
*/
|
|
4870
|
+
async lsRemote(settings, credential, signal, timeoutMs) {
|
|
4093
4871
|
const reference = "refs/heads/" + settings.branch;
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4872
|
+
let result;
|
|
4873
|
+
try {
|
|
4874
|
+
result = await this.git([
|
|
4875
|
+
"ls-remote",
|
|
4876
|
+
"--heads",
|
|
4877
|
+
settings.repoUrl,
|
|
4878
|
+
reference
|
|
4879
|
+
], {
|
|
4880
|
+
signal,
|
|
4881
|
+
token: credential.token,
|
|
4882
|
+
authenticated: true,
|
|
4883
|
+
...timeoutMs === void 0 ? {} : { timeoutMs }
|
|
4884
|
+
});
|
|
4885
|
+
} catch (error) {
|
|
4886
|
+
if (signal?.aborted === true) throw error;
|
|
4887
|
+
throw remoteFailure(this.mask(error instanceof Error ? error.message : String(error), credential));
|
|
4888
|
+
}
|
|
4889
|
+
if (result.exitCode !== 0) throw remoteFailure(this.mask(tail(result.stderr || result.stdout) || "git ls-remote failed", credential));
|
|
4105
4890
|
for (const line of result.stdout.split("\n")) {
|
|
4106
4891
|
const [commit, name] = line.trim().split(/\s+/u);
|
|
4107
4892
|
if (name === reference && commit !== void 0) return commit;
|
|
@@ -4218,6 +5003,20 @@ var MnemonGitSync = class {
|
|
|
4218
5003
|
recursive: true,
|
|
4219
5004
|
mode: 448
|
|
4220
5005
|
});
|
|
5006
|
+
if (existsSync(join(mirror, ".git"))) {
|
|
5007
|
+
const origin = await this.git([
|
|
5008
|
+
"config",
|
|
5009
|
+
"--get",
|
|
5010
|
+
ORIGIN_KEY
|
|
5011
|
+
], {
|
|
5012
|
+
cwd: mirror,
|
|
5013
|
+
signal
|
|
5014
|
+
});
|
|
5015
|
+
if (origin.exitCode !== 0 || origin.stdout.trim() !== settings.repoUrl) rmSync(mirror, {
|
|
5016
|
+
recursive: true,
|
|
5017
|
+
force: true
|
|
5018
|
+
});
|
|
5019
|
+
}
|
|
4221
5020
|
if (!existsSync(join(mirror, ".git"))) {
|
|
4222
5021
|
rmSync(mirror, {
|
|
4223
5022
|
recursive: true,
|
|
@@ -4226,10 +5025,18 @@ var MnemonGitSync = class {
|
|
|
4226
5025
|
const initialized = await this.git([
|
|
4227
5026
|
"init",
|
|
4228
5027
|
"--quiet",
|
|
4229
|
-
"--initial-branch=" + settings.branch,
|
|
4230
5028
|
mirror
|
|
4231
5029
|
], { signal });
|
|
4232
5030
|
if (initialized.exitCode !== 0) throw new Error("git init failed: " + tail(initialized.stderr || initialized.stdout));
|
|
5031
|
+
const marked = await this.git([
|
|
5032
|
+
"config",
|
|
5033
|
+
ORIGIN_KEY,
|
|
5034
|
+
settings.repoUrl
|
|
5035
|
+
], {
|
|
5036
|
+
cwd: mirror,
|
|
5037
|
+
signal
|
|
5038
|
+
});
|
|
5039
|
+
if (marked.exitCode !== 0) throw new Error("git config failed: " + tail(marked.stderr || marked.stdout));
|
|
4233
5040
|
}
|
|
4234
5041
|
const current = await this.git([
|
|
4235
5042
|
"symbolic-ref",
|
|
@@ -4250,8 +5057,10 @@ var MnemonGitSync = class {
|
|
|
4250
5057
|
});
|
|
4251
5058
|
if (renamed.exitCode !== 0) throw new Error("git symbolic-ref failed: " + tail(renamed.stderr || renamed.stdout));
|
|
4252
5059
|
}
|
|
4253
|
-
|
|
4254
|
-
|
|
5060
|
+
if (await this.lsRemote(settings, credential, signal) === void 0) {
|
|
5061
|
+
await this.startHistory(settings.branch, signal);
|
|
5062
|
+
return {};
|
|
5063
|
+
}
|
|
4255
5064
|
const fetched = await this.git([
|
|
4256
5065
|
"fetch",
|
|
4257
5066
|
"--no-tags",
|
|
@@ -4264,7 +5073,7 @@ var MnemonGitSync = class {
|
|
|
4264
5073
|
token: credential.token,
|
|
4265
5074
|
authenticated: true
|
|
4266
5075
|
});
|
|
4267
|
-
if (fetched.exitCode !== 0) throw
|
|
5076
|
+
if (fetched.exitCode !== 0) throw remoteFailure("git fetch failed: " + this.mask(tail(fetched.stderr || fetched.stdout), credential));
|
|
4268
5077
|
const reset = await this.git([
|
|
4269
5078
|
"reset",
|
|
4270
5079
|
"--hard",
|
|
@@ -4275,7 +5084,33 @@ var MnemonGitSync = class {
|
|
|
4275
5084
|
signal
|
|
4276
5085
|
});
|
|
4277
5086
|
if (reset.exitCode !== 0) throw new Error("git reset failed: " + tail(reset.stderr || reset.stdout));
|
|
4278
|
-
return { tip };
|
|
5087
|
+
return { tip: await this.head(signal) };
|
|
5088
|
+
}
|
|
5089
|
+
/**
|
|
5090
|
+
* Point the mirror at a branch the remote does not hold. The next commit starts a
|
|
5091
|
+
* history of its own: continuing the one this mirror kept, from a branch the remote
|
|
5092
|
+
* has since deleted, would publish every earlier payload all over again.
|
|
5093
|
+
*/
|
|
5094
|
+
async startHistory(branch, signal) {
|
|
5095
|
+
const mirror = this.store.mirror();
|
|
5096
|
+
await this.git([
|
|
5097
|
+
"update-ref",
|
|
5098
|
+
"-d",
|
|
5099
|
+
"refs/heads/" + branch
|
|
5100
|
+
], {
|
|
5101
|
+
cwd: mirror,
|
|
5102
|
+
signal
|
|
5103
|
+
});
|
|
5104
|
+
const emptied = await this.git(["read-tree", "--empty"], {
|
|
5105
|
+
cwd: mirror,
|
|
5106
|
+
signal
|
|
5107
|
+
});
|
|
5108
|
+
if (emptied.exitCode !== 0) throw new Error("git read-tree failed: " + tail(emptied.stderr || emptied.stdout));
|
|
5109
|
+
const cleaned = await this.git(["clean", "-fdxq"], {
|
|
5110
|
+
cwd: mirror,
|
|
5111
|
+
signal
|
|
5112
|
+
});
|
|
5113
|
+
if (cleaned.exitCode !== 0) throw new Error("git clean failed: " + tail(cleaned.stderr || cleaned.stdout));
|
|
4279
5114
|
}
|
|
4280
5115
|
/**
|
|
4281
5116
|
* The payload directory inside the mirror. A link where that directory or one of
|
|
@@ -4318,26 +5153,37 @@ var MnemonGitSync = class {
|
|
|
4318
5153
|
}
|
|
4319
5154
|
async publish(settings, credential, signal) {
|
|
4320
5155
|
const reference = "refs/heads/" + settings.branch + ":refs/heads/" + settings.branch;
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
5156
|
+
let result;
|
|
5157
|
+
try {
|
|
5158
|
+
result = await this.git([
|
|
5159
|
+
"push",
|
|
5160
|
+
"--porcelain",
|
|
5161
|
+
settings.repoUrl,
|
|
5162
|
+
reference
|
|
5163
|
+
], {
|
|
5164
|
+
cwd: this.store.mirror(),
|
|
5165
|
+
signal,
|
|
5166
|
+
token: credential.token,
|
|
5167
|
+
authenticated: true
|
|
5168
|
+
});
|
|
5169
|
+
} catch (error) {
|
|
5170
|
+
if (signal?.aborted === true) throw error;
|
|
5171
|
+
const reason = this.mask(error instanceof Error ? error.message : String(error), credential);
|
|
5172
|
+
const failure = REMOTE_FAILURE[remoteIssueOf(reason)];
|
|
5173
|
+
return {
|
|
5174
|
+
pushed: false,
|
|
5175
|
+
reason,
|
|
5176
|
+
...failure === void 0 ? {} : { failure }
|
|
5177
|
+
};
|
|
5178
|
+
}
|
|
4332
5179
|
if (result.exitCode === 0) return { pushed: true };
|
|
4333
|
-
const
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
reason: "the commit stays local because no token is available: " + (reason || "git push failed")
|
|
4337
|
-
};
|
|
5180
|
+
const output = this.mask(result.stdout + "\n" + result.stderr, credential);
|
|
5181
|
+
const reason = this.mask(tail(result.stderr || result.stdout), credential) || "git push failed";
|
|
5182
|
+
const failure = rejectedPush(output) ? "push-rejected" : REMOTE_FAILURE[remoteIssueOf(output)];
|
|
4338
5183
|
return {
|
|
4339
5184
|
pushed: false,
|
|
4340
|
-
reason:
|
|
5185
|
+
reason: credential.token === void 0 && failure !== "push-rejected" ? "the commit stays local because no token is available: " + reason : reason,
|
|
5186
|
+
...failure === void 0 ? {} : { failure }
|
|
4341
5187
|
};
|
|
4342
5188
|
}
|
|
4343
5189
|
/**
|
|
@@ -4410,9 +5256,12 @@ var MnemonGitSync = class {
|
|
|
4410
5256
|
};
|
|
4411
5257
|
}
|
|
4412
5258
|
}
|
|
4413
|
-
/**
|
|
5259
|
+
/** Refresh the mirror, then read the payload the branch holds. */
|
|
4414
5260
|
async readRemote(settings, signal) {
|
|
4415
|
-
|
|
5261
|
+
return this.readPrepared(settings, await this.ensureMirror(settings, signal));
|
|
5262
|
+
}
|
|
5263
|
+
/** Validate the payload a refreshed mirror holds and rebuild the exact pack archive the ZIP path would have produced. */
|
|
5264
|
+
async readPrepared(settings, prepared) {
|
|
4416
5265
|
if (prepared.tip === void 0) return void 0;
|
|
4417
5266
|
const files = readFiles(this.payloadRoot(settings.subdir));
|
|
4418
5267
|
const manifestBytes = files[MANIFEST];
|
|
@@ -4465,13 +5314,36 @@ var MnemonGitSync = class {
|
|
|
4465
5314
|
recursive: true,
|
|
4466
5315
|
mode: 448
|
|
4467
5316
|
});
|
|
4468
|
-
return withDirectoryLock(join(this.store.directory(), ".sync.lock"),
|
|
5317
|
+
return withDirectoryLock(join(this.store.directory(), ".sync.lock"), async () => {
|
|
5318
|
+
this.sweepCredentials();
|
|
5319
|
+
return operation();
|
|
5320
|
+
});
|
|
5321
|
+
}
|
|
5322
|
+
/**
|
|
5323
|
+
* Remove the one-command credential files a Host that stopped mid-command left
|
|
5324
|
+
* behind. Each holds a token, so one whose process is gone is deleted rather
|
|
5325
|
+
* than kept until the next restart of that process id.
|
|
5326
|
+
*/
|
|
5327
|
+
sweepCredentials() {
|
|
5328
|
+
let names;
|
|
5329
|
+
try {
|
|
5330
|
+
names = readdirSync(this.store.directory());
|
|
5331
|
+
} catch {
|
|
5332
|
+
return;
|
|
5333
|
+
}
|
|
5334
|
+
for (const name of names) {
|
|
5335
|
+
const match = /^\.credentials\.(\d+)\.\d+\.tmp$/u.exec(name);
|
|
5336
|
+
if (match === null) continue;
|
|
5337
|
+
const pid = Number(match[1]);
|
|
5338
|
+
if (pid === process.pid || processAlive(pid)) continue;
|
|
5339
|
+
rmSync(join(this.store.directory(), name), { force: true });
|
|
5340
|
+
}
|
|
4469
5341
|
}
|
|
4470
5342
|
async git(args, options) {
|
|
4471
5343
|
const prepared = this.credentials(args, options);
|
|
4472
5344
|
try {
|
|
4473
5345
|
return await this.run(GIT_LABEL, prepared.args, {
|
|
4474
|
-
timeoutMs:
|
|
5346
|
+
timeoutMs: options.timeoutMs ?? 12e4,
|
|
4475
5347
|
env: gitEnvironment(),
|
|
4476
5348
|
...options.cwd === void 0 ? {} : { cwd: options.cwd },
|
|
4477
5349
|
...options.signal === void 0 ? {} : { signal: options.signal },
|
|
@@ -4520,11 +5392,17 @@ var MnemonGitSync = class {
|
|
|
4520
5392
|
};
|
|
4521
5393
|
}
|
|
4522
5394
|
};
|
|
5395
|
+
/**
|
|
5396
|
+
* How the payload files differ. The manifest and the checksum inventory describe a
|
|
5397
|
+
* payload rather than hold memory, and the manifest's export time differs on every
|
|
5398
|
+
* read, so counting them would report a difference where every component agrees.
|
|
5399
|
+
*/
|
|
4523
5400
|
function compareFiles(published, local) {
|
|
4524
5401
|
let changed = 0;
|
|
4525
5402
|
let added = 0;
|
|
4526
5403
|
let removed = 0;
|
|
4527
|
-
|
|
5404
|
+
const paths = new Set([...Object.keys(published), ...Object.keys(local)].filter((path) => path !== MANIFEST && path !== CHECKSUMS));
|
|
5405
|
+
for (const path of paths) {
|
|
4528
5406
|
const remote = published[path];
|
|
4529
5407
|
const mine = local[path];
|
|
4530
5408
|
if (remote === void 0) added += 1;
|
|
@@ -4532,13 +5410,46 @@ function compareFiles(published, local) {
|
|
|
4532
5410
|
else if (sha256Hex(remote) !== sha256Hex(mine)) changed += 1;
|
|
4533
5411
|
}
|
|
4534
5412
|
return {
|
|
4535
|
-
total:
|
|
5413
|
+
total: paths.size,
|
|
4536
5414
|
changed,
|
|
4537
5415
|
added,
|
|
4538
5416
|
removed
|
|
4539
5417
|
};
|
|
4540
5418
|
}
|
|
4541
|
-
/**
|
|
5419
|
+
/** Whether a process of this machine still runs; one the Host may not signal is assumed alive. */
|
|
5420
|
+
function processAlive(pid) {
|
|
5421
|
+
try {
|
|
5422
|
+
process.kill(pid, 0);
|
|
5423
|
+
return true;
|
|
5424
|
+
} catch (error) {
|
|
5425
|
+
return error.code === "EPERM";
|
|
5426
|
+
}
|
|
5427
|
+
}
|
|
5428
|
+
/**
|
|
5429
|
+
* Whether a lock was left by an operation that can no longer release it: its owner
|
|
5430
|
+
* process is gone, or it is older than any sync takes. The age also covers a
|
|
5431
|
+
* process id that a restart handed to an unrelated process.
|
|
5432
|
+
*/
|
|
5433
|
+
function abandoned(path, now) {
|
|
5434
|
+
let age;
|
|
5435
|
+
try {
|
|
5436
|
+
age = now - statSync(path).mtimeMs;
|
|
5437
|
+
} catch {
|
|
5438
|
+
return false;
|
|
5439
|
+
}
|
|
5440
|
+
if (age > STALE_LOCK_MS) return true;
|
|
5441
|
+
try {
|
|
5442
|
+
const pid = record$3(JSON.parse(readFileSync(join(path, LOCK_OWNER), "utf8")))?.pid;
|
|
5443
|
+
return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && pid !== process.pid && !processAlive(pid);
|
|
5444
|
+
} catch {
|
|
5445
|
+
return age > OWNERLESS_LOCK_MS;
|
|
5446
|
+
}
|
|
5447
|
+
}
|
|
5448
|
+
/**
|
|
5449
|
+
* A single-writer lock for one storage root's sync work; the pack locks cover the
|
|
5450
|
+
* shared data. The lock records its owner, so a Host that stopped mid-operation
|
|
5451
|
+
* does not leave every later sync refused until someone deletes the directory.
|
|
5452
|
+
*/
|
|
4542
5453
|
async function withDirectoryLock(path, operation) {
|
|
4543
5454
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
4544
5455
|
let held = false;
|
|
@@ -4549,9 +5460,28 @@ async function withDirectoryLock(path, operation) {
|
|
|
4549
5460
|
});
|
|
4550
5461
|
held = true;
|
|
4551
5462
|
} catch {
|
|
5463
|
+
if (abandoned(path, Date.now())) {
|
|
5464
|
+
const aside = path + ".abandoned." + String(process.pid) + "." + String(Date.now());
|
|
5465
|
+
try {
|
|
5466
|
+
if (abandoned(path, Date.now())) renameSync(path, aside);
|
|
5467
|
+
} catch {
|
|
5468
|
+
continue;
|
|
5469
|
+
}
|
|
5470
|
+
rmSync(aside, {
|
|
5471
|
+
recursive: true,
|
|
5472
|
+
force: true
|
|
5473
|
+
});
|
|
5474
|
+
continue;
|
|
5475
|
+
}
|
|
4552
5476
|
await new Promise((resolveReady) => setTimeout(resolveReady, 25));
|
|
4553
5477
|
}
|
|
4554
|
-
if (!held) throw new
|
|
5478
|
+
if (!held) throw new MnemonSyncError("busy", "another Mnemon sync operation is still running");
|
|
5479
|
+
try {
|
|
5480
|
+
writeFileSync(join(path, LOCK_OWNER), JSON.stringify({
|
|
5481
|
+
pid: process.pid,
|
|
5482
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5483
|
+
}) + "\n", { mode: 384 });
|
|
5484
|
+
} catch {}
|
|
4555
5485
|
try {
|
|
4556
5486
|
return await operation();
|
|
4557
5487
|
} finally {
|
|
@@ -5499,6 +6429,22 @@ function createRuntimeGraph(config, workspaceRoot, extensions) {
|
|
|
5499
6429
|
}
|
|
5500
6430
|
};
|
|
5501
6431
|
}
|
|
6432
|
+
/**
|
|
6433
|
+
* When the Host asks a data directory's Providers again for namespaces they gained (#373): a
|
|
6434
|
+
* little after a runtime starts serving it, then, while a try cannot reach the Source, after
|
|
6435
|
+
* `retryDelayMs`, `attempts` tries in all. Tests shorten these.
|
|
6436
|
+
*/
|
|
6437
|
+
const PROVIDER_REDISCOVERY_TIMING = {
|
|
6438
|
+
delayMs: 1e4,
|
|
6439
|
+
retryDelayMs: 3e4,
|
|
6440
|
+
attempts: 3
|
|
6441
|
+
};
|
|
6442
|
+
/** Data directories whose Provider namespaces this process has asked for again, or is asking now. */
|
|
6443
|
+
const rediscoveredDirectories = /* @__PURE__ */ new Set();
|
|
6444
|
+
/** An answer asking again cannot change: no Memory Spaces Source, or one older than this request. */
|
|
6445
|
+
function finalRediscoveryAnswer(error) {
|
|
6446
|
+
return /unsupported Memory Spaces management mutation operation|is not installed/u.test(error instanceof Error ? error.message : String(error));
|
|
6447
|
+
}
|
|
5502
6448
|
/** Resolve every property access against one generation, binding methods to it. */
|
|
5503
6449
|
function liveProxy(resolve) {
|
|
5504
6450
|
return new Proxy({}, {
|
|
@@ -5537,6 +6483,8 @@ var LiveMnemonRuntime = class {
|
|
|
5537
6483
|
workspaceGraphs = /* @__PURE__ */ new Map();
|
|
5538
6484
|
agentGraphs = /* @__PURE__ */ new Map();
|
|
5539
6485
|
retiredGraphs = /* @__PURE__ */ new Set();
|
|
6486
|
+
/** The start-up rediscovery each graph has waiting or running, to stop when the graph goes. */
|
|
6487
|
+
rediscoveries = /* @__PURE__ */ new Map();
|
|
5540
6488
|
githubAuth;
|
|
5541
6489
|
autoBackupView;
|
|
5542
6490
|
closed = false;
|
|
@@ -5555,6 +6503,7 @@ var LiveMnemonRuntime = class {
|
|
|
5555
6503
|
this.packs = liveProxy(() => this.current.packs);
|
|
5556
6504
|
this.sync = liveProxy(() => this.current.sync);
|
|
5557
6505
|
this.reviews = liveProxy(() => this.current.reviews);
|
|
6506
|
+
this.rediscoverProviders(initial);
|
|
5558
6507
|
}
|
|
5559
6508
|
/**
|
|
5560
6509
|
* Share one GitHub sign-in with every generation this runtime builds. The
|
|
@@ -5589,6 +6538,7 @@ var LiveMnemonRuntime = class {
|
|
|
5589
6538
|
this.current = next;
|
|
5590
6539
|
this.applyGitHubAuth(next);
|
|
5591
6540
|
this.applyAutoBackup(next);
|
|
6541
|
+
this.rediscoverProviders(next);
|
|
5592
6542
|
this.retireGraph(previous);
|
|
5593
6543
|
for (const graph of this.workspaceGraphs.values()) this.retireGraph(graph);
|
|
5594
6544
|
this.workspaceGraphs.clear();
|
|
@@ -5626,6 +6576,8 @@ var LiveMnemonRuntime = class {
|
|
|
5626
6576
|
this.agentGraphs.clear();
|
|
5627
6577
|
this.workspaceGraphs.clear();
|
|
5628
6578
|
this.retiredGraphs.clear();
|
|
6579
|
+
for (const stop of this.rediscoveries.values()) stop();
|
|
6580
|
+
this.rediscoveries.clear();
|
|
5629
6581
|
for (const graph of graphs) graph.dispose();
|
|
5630
6582
|
}
|
|
5631
6583
|
/** Resolve the runtime that must serve one Agent execution. */
|
|
@@ -5674,11 +6626,64 @@ var LiveMnemonRuntime = class {
|
|
|
5674
6626
|
graph = createRuntimeGraph(this.current.config, key, this.extensions);
|
|
5675
6627
|
this.applyGitHubAuth(graph);
|
|
5676
6628
|
this.applyAutoBackup(graph);
|
|
6629
|
+
this.rediscoverProviders(graph);
|
|
5677
6630
|
this.workspaceGraphs.set(key, graph);
|
|
5678
6631
|
}
|
|
5679
6632
|
return graph;
|
|
5680
6633
|
}
|
|
6634
|
+
/**
|
|
6635
|
+
* Ask the Providers of one data directory again for namespaces they gained, once per process,
|
|
6636
|
+
* a little after a runtime this Host serves starts (#373); a graph built only to validate
|
|
6637
|
+
* settings never asks. A read-only runtime asks nothing, and neither does one whose Memory
|
|
6638
|
+
* Spaces layer is off or does not maintain itself automatically. A try that cannot reach the
|
|
6639
|
+
* Source, before a generation serves for example, is made again a little later; one cut short
|
|
6640
|
+
* because its runtime was replaced is left to the runtime that replaced it.
|
|
6641
|
+
*/
|
|
6642
|
+
rediscoverProviders(graph) {
|
|
6643
|
+
if (!graph.config.writeEnabled || !allowsParticipation(graph.config, "memory-spaces", "maintain", "automatic")) return;
|
|
6644
|
+
const directory = resolve(graph.directory);
|
|
6645
|
+
const controller = new AbortController();
|
|
6646
|
+
let timer;
|
|
6647
|
+
let running = false;
|
|
6648
|
+
const release = () => {
|
|
6649
|
+
if (this.rediscoveries.get(graph) === stop) this.rediscoveries.delete(graph);
|
|
6650
|
+
};
|
|
6651
|
+
const schedule = (attempt, delayMs) => {
|
|
6652
|
+
timer = setTimeout(() => void ask(attempt), delayMs);
|
|
6653
|
+
timer.unref?.();
|
|
6654
|
+
};
|
|
6655
|
+
const ask = async (attempt) => {
|
|
6656
|
+
timer = void 0;
|
|
6657
|
+
if (rediscoveredDirectories.has(directory)) return release();
|
|
6658
|
+
rediscoveredDirectories.add(directory);
|
|
6659
|
+
running = true;
|
|
6660
|
+
let again = false;
|
|
6661
|
+
try {
|
|
6662
|
+
await graph.source("memory-spaces").mutate("provider-rediscover", { trigger: "startup" }, controller.signal);
|
|
6663
|
+
} catch (error) {
|
|
6664
|
+
again = !finalRediscoveryAnswer(error);
|
|
6665
|
+
} finally {
|
|
6666
|
+
running = false;
|
|
6667
|
+
}
|
|
6668
|
+
if (controller.signal.aborted) return;
|
|
6669
|
+
if (again) {
|
|
6670
|
+
rediscoveredDirectories.delete(directory);
|
|
6671
|
+
if (attempt < PROVIDER_REDISCOVERY_TIMING.attempts) return schedule(attempt + 1, PROVIDER_REDISCOVERY_TIMING.retryDelayMs);
|
|
6672
|
+
}
|
|
6673
|
+
release();
|
|
6674
|
+
};
|
|
6675
|
+
const stop = () => {
|
|
6676
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
6677
|
+
if (running) rediscoveredDirectories.delete(directory);
|
|
6678
|
+
controller.abort(/* @__PURE__ */ new Error("the Mnemon runtime was replaced"));
|
|
6679
|
+
};
|
|
6680
|
+
this.rediscoveries.get(graph)?.();
|
|
6681
|
+
this.rediscoveries.set(graph, stop);
|
|
6682
|
+
schedule(1, PROVIDER_REDISCOVERY_TIMING.delayMs);
|
|
6683
|
+
}
|
|
5681
6684
|
retireGraph(graph) {
|
|
6685
|
+
this.rediscoveries.get(graph)?.();
|
|
6686
|
+
this.rediscoveries.delete(graph);
|
|
5682
6687
|
graph.retire();
|
|
5683
6688
|
if ([...this.agentGraphs.values()].some((binding) => binding.graph === graph)) this.retiredGraphs.add(graph);
|
|
5684
6689
|
else graph.dispose();
|
|
@@ -11056,12 +12061,13 @@ function success$1(value) {
|
|
|
11056
12061
|
};
|
|
11057
12062
|
}
|
|
11058
12063
|
function failure$1(error) {
|
|
12064
|
+
const reason = error instanceof Error ? error.reason : void 0;
|
|
11059
12065
|
return {
|
|
11060
12066
|
ok: false,
|
|
11061
12067
|
error: {
|
|
11062
12068
|
code: "internal",
|
|
11063
12069
|
message: error instanceof Error ? error.message : String(error),
|
|
11064
|
-
details: {}
|
|
12070
|
+
details: typeof reason === "string" ? { reason } : {}
|
|
11065
12071
|
}
|
|
11066
12072
|
};
|
|
11067
12073
|
}
|