dsh-mnemon 0.5.28 → 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/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";
|
|
@@ -843,6 +843,315 @@ var MnemonMachineStore = class {
|
|
|
843
843
|
}
|
|
844
844
|
};
|
|
845
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
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
//#endregion
|
|
846
1155
|
//#region src/host/pack.ts
|
|
847
1156
|
const MNEMON_PACK_FORMAT = "mnemonpack";
|
|
848
1157
|
const MNEMON_PACK_MIME = "application/zip";
|
|
@@ -970,9 +1279,19 @@ function parseEntryOrigin(value) {
|
|
|
970
1279
|
at: origin.at
|
|
971
1280
|
};
|
|
972
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
|
+
}
|
|
973
1292
|
function decodeArchive(base64) {
|
|
974
1293
|
if (typeof base64 !== "string" || base64 === "" || base64.length > Math.ceil(16777216) * 4 + 4) throw new Error("Mnemon Pack archive is empty or too large");
|
|
975
|
-
if (
|
|
1294
|
+
if (!isBase64(base64)) throw new Error("Mnemon Pack payload is not valid base64");
|
|
976
1295
|
const bytes = Buffer.from(base64, "base64");
|
|
977
1296
|
if (bytes.length === 0 || bytes.length > 50331648) throw new Error("Mnemon Pack archive is empty or too large");
|
|
978
1297
|
return bytes;
|
|
@@ -1630,24 +1949,86 @@ function mergeTombstoneFiles(current, incoming) {
|
|
|
1630
1949
|
tombstones: [...byKey.values()].sort((left, right) => left.deletedAt < right.deletedAt ? -1 : left.deletedAt > right.deletedAt ? 1 : 0)
|
|
1631
1950
|
};
|
|
1632
1951
|
}
|
|
1633
|
-
|
|
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) {
|
|
1634
1993
|
const current = readCurrentDocuments(root);
|
|
1635
1994
|
const incoming = archiveDocuments(pack);
|
|
1636
1995
|
const ids = new Map(current.index.documents.map((document) => [document.id, document]));
|
|
1996
|
+
const add = (document, bytes) => {
|
|
1997
|
+
current.index.documents.push(document);
|
|
1998
|
+
current.files.set(document.id, bytes);
|
|
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
|
+
};
|
|
1637
2005
|
for (const source of incoming.index.documents) {
|
|
1638
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
|
+
}
|
|
1639
2028
|
if (existing !== void 0 && existing.contentHash === source.contentHash) continue;
|
|
1640
2029
|
if (existing !== void 0 && current.index.documents.some((document) => document.contentHash === source.contentHash)) continue;
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
if (existing !== void 0) {
|
|
1644
|
-
const remapped = remapDocument(source, bytes, randomUUID());
|
|
1645
|
-
document = remapped.document;
|
|
1646
|
-
bytes = remapped.bytes;
|
|
1647
|
-
}
|
|
1648
|
-
current.index.documents.push(document);
|
|
1649
|
-
current.files.set(document.id, bytes);
|
|
1650
|
-
ids.set(document.id, document);
|
|
2030
|
+
if (existing !== void 0) copy(source);
|
|
2031
|
+
else add(source, incoming.files.get(source.id));
|
|
1651
2032
|
}
|
|
1652
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`);
|
|
1653
2034
|
return current;
|
|
@@ -1656,26 +2037,43 @@ function mergeRegistry(root, pack) {
|
|
|
1656
2037
|
const current = readCurrentRegistry(root);
|
|
1657
2038
|
const incoming = archiveRegistry(pack);
|
|
1658
2039
|
const ids = new Set(current.registry.bodies.map((body) => body.id));
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
const
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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();
|
|
1677
2076
|
}
|
|
1678
|
-
return current;
|
|
1679
2077
|
}
|
|
1680
2078
|
function persistedStore(root) {
|
|
1681
2079
|
try {
|
|
@@ -1730,7 +2128,7 @@ function stageImport(root, pack, components, mode, runtimeLimits, options = {})
|
|
|
1730
2128
|
keepRuntimeArchive(root, join(staging, "runtime"));
|
|
1731
2129
|
}
|
|
1732
2130
|
if (components.includes("documents")) {
|
|
1733
|
-
const documents = mode === "merge" ? mergeDocuments(root, pack) : archiveDocuments(pack);
|
|
2131
|
+
const documents = mode === "merge" ? mergeDocuments(root, pack, options.documentsBase) : archiveDocuments(pack);
|
|
1734
2132
|
writeDocuments(join(staging, "documents"), documents.index, documents.files);
|
|
1735
2133
|
}
|
|
1736
2134
|
if (components.includes("memory-spaces")) {
|
|
@@ -1967,6 +2365,10 @@ var MnemonPackManager = class {
|
|
|
1967
2365
|
occupied: Object.fromEntries(COMPONENT_ORDER.map((component) => [component, occupied(this.root, component)]))
|
|
1968
2366
|
};
|
|
1969
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
|
+
*/
|
|
1970
2372
|
async importPack(base64, options) {
|
|
1971
2373
|
const pack = parseArchive(base64, this.runtimeLimits);
|
|
1972
2374
|
if (options.mode !== "merge" && options.mode !== "replace") throw new Error("Pack import mode must be merge or replace");
|
|
@@ -2562,6 +2964,17 @@ function headerRecord(headers) {
|
|
|
2562
2964
|
if (typeof headers.entries === "function") return Object.fromEntries(headers.entries());
|
|
2563
2965
|
return { ...headers };
|
|
2564
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
|
+
}
|
|
2565
2978
|
/** The request one tunnel performs, as a `fetch`-shaped answer. */
|
|
2566
2979
|
function tunnelRequest(agent, url, init) {
|
|
2567
2980
|
return new Promise((resolve, reject) => {
|
|
@@ -2579,9 +2992,8 @@ function tunnelRequest(agent, url, init) {
|
|
|
2579
2992
|
});
|
|
2580
2993
|
response.once("error", reject);
|
|
2581
2994
|
response.once("end", () => {
|
|
2582
|
-
const
|
|
2583
|
-
|
|
2584
|
-
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")));
|
|
2585
2997
|
});
|
|
2586
2998
|
});
|
|
2587
2999
|
if (typeof init.body === "string") request.write(init.body);
|
|
@@ -2688,7 +3100,9 @@ var MnemonSyncError = class extends Error {
|
|
|
2688
3100
|
* process-wide dispatcher, then one retry through a proxy discovered from the
|
|
2689
3101
|
* environment or from this machine's own settings, so a network that reaches
|
|
2690
3102
|
* GitHub only through a proxy still signs in. Failures report GitHub's error
|
|
2691
|
-
* 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.
|
|
2692
3106
|
*/
|
|
2693
3107
|
const MNEMON_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
2694
3108
|
const MNEMON_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
@@ -2703,7 +3117,6 @@ const MNEMON_GITHUB_SCOPE = "repo";
|
|
|
2703
3117
|
*/
|
|
2704
3118
|
const MNEMON_SYNC_GITHUB_CREDENTIAL_KEY = "dsh-mnemon/github";
|
|
2705
3119
|
const MNEMON_GITHUB_REQUEST_TIMEOUT_MS = 3e4;
|
|
2706
|
-
const MNEMON_GITHUB_DEFAULT_INTERVAL_MS = 5e3;
|
|
2707
3120
|
const MNEMON_GITHUB_SLOW_DOWN_MS = 5e3;
|
|
2708
3121
|
const MNEMON_GITHUB_MAX_INTERVAL_MS = 6e4;
|
|
2709
3122
|
const MNEMON_GITHUB_FLOW_LIMIT_MS = 36e5;
|
|
@@ -2721,6 +3134,23 @@ function codeOf(body) {
|
|
|
2721
3134
|
const fields = record$5(body);
|
|
2722
3135
|
return text$1(fields?.error) ?? text$1(fields?.message);
|
|
2723
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
|
+
}
|
|
2724
3154
|
function failure$2(action, status, body) {
|
|
2725
3155
|
const code = codeOf(body);
|
|
2726
3156
|
const message = code === void 0 ? action + " failed with HTTP " + String(status) : action + " failed: " + code;
|
|
@@ -2867,16 +3297,16 @@ var MnemonGitHubAuth = class {
|
|
|
2867
3297
|
const deviceCode = text$1(fields?.device_code);
|
|
2868
3298
|
const userCode = text$1(fields?.user_code);
|
|
2869
3299
|
const verificationUri = text$1(fields?.verification_uri);
|
|
2870
|
-
if (deviceCode === void 0 || userCode === void 0 || verificationUri === void 0) throw new Error("the GitHub device code answer was incomplete");
|
|
2871
|
-
const expiresIn =
|
|
2872
|
-
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);
|
|
2873
3303
|
this.flow = {
|
|
2874
3304
|
deviceCode,
|
|
2875
3305
|
userCode,
|
|
2876
3306
|
verificationUri,
|
|
2877
3307
|
expiresAt: this.now() + Math.min(expiresIn * 1e3, MNEMON_GITHUB_FLOW_LIMIT_MS),
|
|
2878
|
-
intervalMs
|
|
2879
|
-
nextPollAt: this.now() +
|
|
3308
|
+
intervalMs,
|
|
3309
|
+
nextPollAt: this.now() + intervalMs
|
|
2880
3310
|
};
|
|
2881
3311
|
return this.status();
|
|
2882
3312
|
}
|
|
@@ -3076,14 +3506,16 @@ var MnemonGitHubAuth = class {
|
|
|
3076
3506
|
signal
|
|
3077
3507
|
});
|
|
3078
3508
|
const raw = await response.text();
|
|
3509
|
+
const type = response.headers.get("content-type") ?? void 0;
|
|
3079
3510
|
let body;
|
|
3080
3511
|
try {
|
|
3081
3512
|
body = raw === "" ? void 0 : JSON.parse(raw);
|
|
3082
3513
|
} catch {
|
|
3083
|
-
body = void 0;
|
|
3514
|
+
body = /^application\/x-www-form-urlencoded\b/iu.test(type ?? "") ? Object.fromEntries(new URLSearchParams(raw)) : void 0;
|
|
3084
3515
|
}
|
|
3085
3516
|
return {
|
|
3086
3517
|
status: response.status,
|
|
3518
|
+
...type === void 0 ? {} : { type },
|
|
3087
3519
|
body
|
|
3088
3520
|
};
|
|
3089
3521
|
} finally {
|
|
@@ -3342,6 +3774,9 @@ const OWNERLESS_LOCK_MS = 9e5;
|
|
|
3342
3774
|
/** The mirror's own Git config names the repository it was filled from. */
|
|
3343
3775
|
const ORIGIN_KEY = "mnemon.origin";
|
|
3344
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;
|
|
3345
3780
|
const GIT_LABEL = "git";
|
|
3346
3781
|
const MANIFEST = "manifest.json";
|
|
3347
3782
|
const CHECKSUMS = "checksums.json";
|
|
@@ -3943,13 +4378,16 @@ var MnemonGitSync = class {
|
|
|
3943
4378
|
const remote = await this.readPrepared(settings, prepared);
|
|
3944
4379
|
let merged;
|
|
3945
4380
|
if (remote !== void 0) {
|
|
4381
|
+
const base = await this.agreedDocuments(settings, remote.commit, signal);
|
|
3946
4382
|
const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
|
|
3947
4383
|
mode: "merge",
|
|
3948
4384
|
tombstones: {
|
|
3949
4385
|
version: 1,
|
|
3950
4386
|
tombstones: remote.tombstones
|
|
3951
|
-
}
|
|
4387
|
+
},
|
|
4388
|
+
...base === void 0 ? {} : { documentsBase: base }
|
|
3952
4389
|
});
|
|
4390
|
+
if (imported.components.includes("documents")) this.agree(settings, remote.files, remote.commit);
|
|
3953
4391
|
merged = {
|
|
3954
4392
|
commit: remote.commit,
|
|
3955
4393
|
...remote.machine === void 0 ? {} : { machine: {
|
|
@@ -4018,6 +4456,7 @@ var MnemonGitSync = class {
|
|
|
4018
4456
|
pushed: false,
|
|
4019
4457
|
reason: prepared.tip === void 0 ? "nothing to publish" : "the branch already holds this payload"
|
|
4020
4458
|
};
|
|
4459
|
+
if (published.pushed || !committed && prepared.tip !== void 0) this.agree(settings, entries, head);
|
|
4021
4460
|
const compaction = await this.compact(signal);
|
|
4022
4461
|
const summary = exported.manifest.summary.map((entry) => ({
|
|
4023
4462
|
...entry,
|
|
@@ -4089,6 +4528,70 @@ var MnemonGitSync = class {
|
|
|
4089
4528
|
lastRunPath() {
|
|
4090
4529
|
return join(this.store.directory(), "sync", LAST_RUN_FILE);
|
|
4091
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
|
+
}
|
|
4092
4595
|
/** Remember one finished run. Bookkeeping never fails the run it describes. */
|
|
4093
4596
|
recordRun(run) {
|
|
4094
4597
|
const path = this.lastRunPath();
|
|
@@ -4260,13 +4763,16 @@ var MnemonGitSync = class {
|
|
|
4260
4763
|
return this.lock(async () => {
|
|
4261
4764
|
const remote = await this.readRemote(settings, input.signal);
|
|
4262
4765
|
if (remote === void 0) throw this.absentPayload(settings);
|
|
4766
|
+
const base = await this.agreedDocuments(settings, remote.commit, input.signal);
|
|
4263
4767
|
const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
|
|
4264
4768
|
...options,
|
|
4265
4769
|
tombstones: {
|
|
4266
4770
|
version: 1,
|
|
4267
4771
|
tombstones: remote.tombstones
|
|
4268
|
-
}
|
|
4772
|
+
},
|
|
4773
|
+
...base === void 0 ? {} : { documentsBase: base }
|
|
4269
4774
|
});
|
|
4775
|
+
if (imported.components.includes("documents")) this.agree(settings, remote.files, remote.commit);
|
|
4270
4776
|
this.recordRun({
|
|
4271
4777
|
kind: "merge",
|
|
4272
4778
|
commit: remote.commit,
|
|
@@ -4551,8 +5057,7 @@ var MnemonGitSync = class {
|
|
|
4551
5057
|
});
|
|
4552
5058
|
if (renamed.exitCode !== 0) throw new Error("git symbolic-ref failed: " + tail(renamed.stderr || renamed.stdout));
|
|
4553
5059
|
}
|
|
4554
|
-
|
|
4555
|
-
if (tip === void 0) {
|
|
5060
|
+
if (await this.lsRemote(settings, credential, signal) === void 0) {
|
|
4556
5061
|
await this.startHistory(settings.branch, signal);
|
|
4557
5062
|
return {};
|
|
4558
5063
|
}
|
|
@@ -4579,7 +5084,7 @@ var MnemonGitSync = class {
|
|
|
4579
5084
|
signal
|
|
4580
5085
|
});
|
|
4581
5086
|
if (reset.exitCode !== 0) throw new Error("git reset failed: " + tail(reset.stderr || reset.stdout));
|
|
4582
|
-
return { tip };
|
|
5087
|
+
return { tip: await this.head(signal) };
|
|
4583
5088
|
}
|
|
4584
5089
|
/**
|
|
4585
5090
|
* Point the mirror at a branch the remote does not hold. The next commit starts a
|
|
@@ -5924,6 +6429,22 @@ function createRuntimeGraph(config, workspaceRoot, extensions) {
|
|
|
5924
6429
|
}
|
|
5925
6430
|
};
|
|
5926
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
|
+
}
|
|
5927
6448
|
/** Resolve every property access against one generation, binding methods to it. */
|
|
5928
6449
|
function liveProxy(resolve) {
|
|
5929
6450
|
return new Proxy({}, {
|
|
@@ -5962,6 +6483,8 @@ var LiveMnemonRuntime = class {
|
|
|
5962
6483
|
workspaceGraphs = /* @__PURE__ */ new Map();
|
|
5963
6484
|
agentGraphs = /* @__PURE__ */ new Map();
|
|
5964
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();
|
|
5965
6488
|
githubAuth;
|
|
5966
6489
|
autoBackupView;
|
|
5967
6490
|
closed = false;
|
|
@@ -5980,6 +6503,7 @@ var LiveMnemonRuntime = class {
|
|
|
5980
6503
|
this.packs = liveProxy(() => this.current.packs);
|
|
5981
6504
|
this.sync = liveProxy(() => this.current.sync);
|
|
5982
6505
|
this.reviews = liveProxy(() => this.current.reviews);
|
|
6506
|
+
this.rediscoverProviders(initial);
|
|
5983
6507
|
}
|
|
5984
6508
|
/**
|
|
5985
6509
|
* Share one GitHub sign-in with every generation this runtime builds. The
|
|
@@ -6014,6 +6538,7 @@ var LiveMnemonRuntime = class {
|
|
|
6014
6538
|
this.current = next;
|
|
6015
6539
|
this.applyGitHubAuth(next);
|
|
6016
6540
|
this.applyAutoBackup(next);
|
|
6541
|
+
this.rediscoverProviders(next);
|
|
6017
6542
|
this.retireGraph(previous);
|
|
6018
6543
|
for (const graph of this.workspaceGraphs.values()) this.retireGraph(graph);
|
|
6019
6544
|
this.workspaceGraphs.clear();
|
|
@@ -6051,6 +6576,8 @@ var LiveMnemonRuntime = class {
|
|
|
6051
6576
|
this.agentGraphs.clear();
|
|
6052
6577
|
this.workspaceGraphs.clear();
|
|
6053
6578
|
this.retiredGraphs.clear();
|
|
6579
|
+
for (const stop of this.rediscoveries.values()) stop();
|
|
6580
|
+
this.rediscoveries.clear();
|
|
6054
6581
|
for (const graph of graphs) graph.dispose();
|
|
6055
6582
|
}
|
|
6056
6583
|
/** Resolve the runtime that must serve one Agent execution. */
|
|
@@ -6099,11 +6626,64 @@ var LiveMnemonRuntime = class {
|
|
|
6099
6626
|
graph = createRuntimeGraph(this.current.config, key, this.extensions);
|
|
6100
6627
|
this.applyGitHubAuth(graph);
|
|
6101
6628
|
this.applyAutoBackup(graph);
|
|
6629
|
+
this.rediscoverProviders(graph);
|
|
6102
6630
|
this.workspaceGraphs.set(key, graph);
|
|
6103
6631
|
}
|
|
6104
6632
|
return graph;
|
|
6105
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
|
+
}
|
|
6106
6684
|
retireGraph(graph) {
|
|
6685
|
+
this.rediscoveries.get(graph)?.();
|
|
6686
|
+
this.rediscoveries.delete(graph);
|
|
6107
6687
|
graph.retire();
|
|
6108
6688
|
if ([...this.agentGraphs.values()].some((binding) => binding.graph === graph)) this.retiredGraphs.add(graph);
|
|
6109
6689
|
else graph.dispose();
|