@systemdox/mcp-server 0.1.57 → 0.1.58
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 +37 -9
- package/dist/cli.js +454 -69
- package/dist/http.js +155 -38
- package/dist/index.js +155 -38
- package/dist/lib/memory-sync.d.ts +10 -3
- package/dist/lib/memory-sync.js +30 -4
- package/dist/lib/team-setup-files.d.ts +8 -1
- package/dist/lib/team-setup-files.js +8 -0
- package/dist/lib/team-setup-types.d.ts +2 -0
- package/dist/server.js +154 -37
- package/dist/setup-mcp.js +10 -9
- package/dist/setup-team.d.ts +38 -5
- package/dist/setup-team.js +228 -44
- package/dist/sync-memory.d.ts +61 -9
- package/dist/sync-memory.js +343 -34
- package/dist/tools/team-setup.js +147 -30
- package/package.json +1 -1
package/dist/sync-memory.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/sync-memory.ts
|
|
4
4
|
import { existsSync } from "fs";
|
|
5
|
-
import { mkdir as mkdir2, readdir, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
5
|
+
import { appendFile, mkdir as mkdir2, open, readdir, readFile as readFile2, rename as rename2, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
|
|
6
6
|
import { dirname as dirname2, join as join2, resolve } from "path";
|
|
7
7
|
|
|
8
8
|
// src/lib/cache.ts
|
|
@@ -349,15 +349,41 @@ function readFrontmatter(text) {
|
|
|
349
349
|
const m = BLOCK_RE.exec(text);
|
|
350
350
|
if (!m) return void 0;
|
|
351
351
|
const front = { shareFalse: false };
|
|
352
|
-
|
|
353
|
-
|
|
352
|
+
const lines = m[1].split(/\r?\n/);
|
|
353
|
+
if (lines.some((l) => /^[ \t]*\t/.test(l) && l.trim())) return front;
|
|
354
|
+
let topType;
|
|
355
|
+
let topShare;
|
|
356
|
+
let nestedType;
|
|
357
|
+
let nestedShare;
|
|
358
|
+
let inMetadata = false;
|
|
359
|
+
let nestedIndent;
|
|
360
|
+
for (const line of lines) {
|
|
361
|
+
if (!line.trim() || /^[ \t]*#/.test(line)) continue;
|
|
362
|
+
const indent = /^ */.exec(line)[0].length;
|
|
363
|
+
if (indent === 0) {
|
|
364
|
+
inMetadata = /^metadata[ \t]*:[ \t]*(#.*)?$/.test(line);
|
|
365
|
+
nestedIndent = void 0;
|
|
366
|
+
}
|
|
367
|
+
const kv = /^ *([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*?)[ \t]*$/.exec(line);
|
|
354
368
|
if (!kv) continue;
|
|
355
369
|
const raw = kv[2].replace(/[ \t]+#.*$/, "");
|
|
356
370
|
const quoted = /^(["'])(.*)\1$/.exec(raw);
|
|
357
371
|
const value = quoted ? quoted[2] : raw;
|
|
358
|
-
|
|
359
|
-
if (
|
|
360
|
-
|
|
372
|
+
const isFalse = quoted ? value.trim().toLowerCase() === "false" : YAML_FALSE.has(value);
|
|
373
|
+
if (indent === 0) {
|
|
374
|
+
if (kv[1] === "type") topType = value.trim().toLowerCase();
|
|
375
|
+
if (kv[1] === "share") topShare = isFalse;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (!inMetadata) continue;
|
|
379
|
+
nestedIndent ??= indent;
|
|
380
|
+
if (indent !== nestedIndent) continue;
|
|
381
|
+
if (kv[1] === "type") nestedType = value.trim().toLowerCase();
|
|
382
|
+
if (kv[1] === "share") nestedShare = isFalse;
|
|
383
|
+
}
|
|
384
|
+
const type = topType ?? nestedType;
|
|
385
|
+
if (type !== void 0) front.type = type;
|
|
386
|
+
front.shareFalse = (topShare ?? nestedShare) === true;
|
|
361
387
|
return front;
|
|
362
388
|
}
|
|
363
389
|
function isShareable(path, text) {
|
|
@@ -644,6 +670,12 @@ function workspaceDir(workspace) {
|
|
|
644
670
|
function statePath(workspace) {
|
|
645
671
|
return join(workspaceDir(workspace), "state.json");
|
|
646
672
|
}
|
|
673
|
+
function syncLogPath(workspace) {
|
|
674
|
+
return join(workspaceDir(workspace), "sync-memory.log");
|
|
675
|
+
}
|
|
676
|
+
function syncLockPath(workspace) {
|
|
677
|
+
return join(workspaceDir(workspace), "sync.lock");
|
|
678
|
+
}
|
|
647
679
|
async function readJson(path, empty) {
|
|
648
680
|
let text;
|
|
649
681
|
try {
|
|
@@ -668,6 +700,11 @@ async function readCredentials() {
|
|
|
668
700
|
return readJson(credentialsPath(), {});
|
|
669
701
|
}
|
|
670
702
|
var run = promisify(execFile);
|
|
703
|
+
async function apiKeyFor(workspace, env = process.env) {
|
|
704
|
+
if (env.SYSTEMDOX_API_KEY) return env.SYSTEMDOX_API_KEY;
|
|
705
|
+
const entry = (await readCredentials())[workspace];
|
|
706
|
+
return typeof entry?.apiKey === "string" && entry.apiKey ? entry.apiKey : void 0;
|
|
707
|
+
}
|
|
671
708
|
async function localApiKey(env = process.env) {
|
|
672
709
|
if (env.SYSTEMDOX_API_KEY) return env.SYSTEMDOX_API_KEY;
|
|
673
710
|
const keys = Object.entries(await readCredentials()).filter(
|
|
@@ -706,38 +743,79 @@ async function updateState(workspace, change) {
|
|
|
706
743
|
}
|
|
707
744
|
|
|
708
745
|
// src/sync-memory.ts
|
|
709
|
-
var SYNC_MEMORY_USAGE = "Usage: systemdox-mcp-server sync-memory [--project <name>] [--dir <folder>] [--import <folder>] [--dry-run]";
|
|
746
|
+
var SYNC_MEMORY_USAGE = "Usage: systemdox-mcp-server sync-memory [--project <name>] [--dir <folder>] [--import <folder>]... [--dry-run]\n systemdox-mcp-server sync-memory --hook <workspace>";
|
|
747
|
+
var HOOK_BUDGET_MS = 2e4;
|
|
748
|
+
var HOOK_LOCK_STALE_MS = 6e4;
|
|
749
|
+
var HOOK_LOG_MAX_BYTES = 1024 * 1024;
|
|
710
750
|
var SYNC_MEMORY_COMMAND = "npx -y @systemdox/mcp-server sync-memory";
|
|
711
751
|
var SyncFailed = class extends Error {
|
|
712
752
|
};
|
|
753
|
+
var SyncStopped = class extends Error {
|
|
754
|
+
constructor(reason, message) {
|
|
755
|
+
super(message);
|
|
756
|
+
this.reason = reason;
|
|
757
|
+
}
|
|
758
|
+
reason;
|
|
759
|
+
};
|
|
760
|
+
function failureReason(e) {
|
|
761
|
+
if (e instanceof SyncStopped) return e.reason;
|
|
762
|
+
const { status, code } = refusal(e);
|
|
763
|
+
if (status !== void 0) return code ? `SystemDox refused it, ${code}` : "SystemDox refused it";
|
|
764
|
+
const err = e;
|
|
765
|
+
const net = err?.cause?.code ?? err?.code ?? "";
|
|
766
|
+
if (e instanceof TypeError || /^(ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|UND_ERR)/.test(net)) {
|
|
767
|
+
return "SystemDox could not be reached";
|
|
768
|
+
}
|
|
769
|
+
return "it could not finish";
|
|
770
|
+
}
|
|
771
|
+
async function writeWhole(path, text) {
|
|
772
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
773
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
774
|
+
try {
|
|
775
|
+
await writeFile2(tmp, text, "utf8");
|
|
776
|
+
await rename2(tmp, path);
|
|
777
|
+
} catch (e) {
|
|
778
|
+
await rm2(tmp, { force: true });
|
|
779
|
+
throw e;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
713
782
|
function refusal(e) {
|
|
714
783
|
if (!(e instanceof Error)) return {};
|
|
715
784
|
const { status, code } = e;
|
|
716
785
|
return { status: typeof status === "number" ? status : void 0, code: typeof code === "string" ? code : void 0 };
|
|
717
786
|
}
|
|
718
787
|
function parseSyncMemoryArgs(argv) {
|
|
719
|
-
const args = { dryRun: false };
|
|
788
|
+
const args = { dryRun: false, importDirs: [] };
|
|
720
789
|
for (let i = 0; i < argv.length; i++) {
|
|
721
790
|
const a = argv[i];
|
|
722
791
|
if (a === "--dry-run") args.dryRun = true;
|
|
723
|
-
else if (a === "--project" || a === "--dir" || a === "--import") {
|
|
792
|
+
else if (a === "--project" || a === "--dir" || a === "--import" || a === "--hook") {
|
|
724
793
|
const value = argv[++i];
|
|
725
794
|
if (!value || value.startsWith("--")) throw new SyncFailed(`${a} needs a value
|
|
726
795
|
${SYNC_MEMORY_USAGE}`);
|
|
727
796
|
if (a === "--project") {
|
|
728
797
|
if (!isWorkspace(value)) throw new SyncFailed(`${value} is not a project name (lower case letters, digits and -)`);
|
|
729
798
|
args.project = value;
|
|
799
|
+
} else if (a === "--hook") {
|
|
800
|
+
if (!isWorkspace(value)) throw new SyncFailed(`${value} is not a workspace name (lower case letters, digits and -)`);
|
|
801
|
+
args.hook = value;
|
|
730
802
|
} else if (a === "--dir") args.dir = value;
|
|
731
|
-
else args.
|
|
803
|
+
else args.importDirs.push(value);
|
|
732
804
|
} else throw new SyncFailed(`Unexpected argument ${a}
|
|
805
|
+
${SYNC_MEMORY_USAGE}`);
|
|
806
|
+
}
|
|
807
|
+
if (args.hook && (args.project || args.dir || args.importDirs.length || args.dryRun)) {
|
|
808
|
+
throw new SyncFailed(`--hook takes no other option
|
|
733
809
|
${SYNC_MEMORY_USAGE}`);
|
|
734
810
|
}
|
|
735
811
|
return args;
|
|
736
812
|
}
|
|
737
813
|
var toPath = (root, rel) => join2(root, ...rel.split("/"));
|
|
814
|
+
var LEFT_TEMP_RE = /\.\d+\.[a-z0-9]+\.tmp$/;
|
|
738
815
|
async function readFolder(root) {
|
|
739
816
|
const files = {};
|
|
740
817
|
const names = /* @__PURE__ */ new Set();
|
|
818
|
+
const temps = [];
|
|
741
819
|
async function walk(dir, prefix) {
|
|
742
820
|
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
743
821
|
if (e.name.startsWith(".")) continue;
|
|
@@ -745,12 +823,13 @@ async function readFolder(root) {
|
|
|
745
823
|
if (e.isDirectory()) await walk(join2(dir, e.name), rel);
|
|
746
824
|
else if (e.isFile()) {
|
|
747
825
|
names.add(rel.toLowerCase());
|
|
826
|
+
if (LEFT_TEMP_RE.test(e.name)) temps.push(rel);
|
|
748
827
|
if (e.name.endsWith(".md")) files[rel] = await readFile2(join2(dir, e.name), "utf8");
|
|
749
828
|
}
|
|
750
829
|
}
|
|
751
830
|
}
|
|
752
831
|
if (existsSync(root)) await walk(root, "");
|
|
753
|
-
return { files, names };
|
|
832
|
+
return { files, names, temps };
|
|
754
833
|
}
|
|
755
834
|
async function pullTeam(client, ws, project) {
|
|
756
835
|
try {
|
|
@@ -799,7 +878,11 @@ var Report = class {
|
|
|
799
878
|
deletedHere = /* @__PURE__ */ new Set();
|
|
800
879
|
deletedFromTeam = /* @__PURE__ */ new Set();
|
|
801
880
|
conflicts = [];
|
|
881
|
+
/** The conflict copies written this run, by name. */
|
|
882
|
+
copies = /* @__PURE__ */ new Set();
|
|
802
883
|
skipped = /* @__PURE__ */ new Map();
|
|
884
|
+
/** Skipped files a sync in the member's terminal can send: held for a value, or for the vault. */
|
|
885
|
+
waiting = /* @__PURE__ */ new Set();
|
|
803
886
|
people = [];
|
|
804
887
|
toAnswer = /* @__PURE__ */ new Map();
|
|
805
888
|
add(plan) {
|
|
@@ -807,12 +890,32 @@ var Report = class {
|
|
|
807
890
|
plan.pushed.forEach((p) => this.pushed.add(p));
|
|
808
891
|
plan.deletedHere.forEach((p) => this.deletedHere.add(p));
|
|
809
892
|
plan.deletedFromTeam.forEach((p) => this.deletedFromTeam.add(p));
|
|
810
|
-
for (const c of plan.conflicts)
|
|
893
|
+
for (const c of plan.conflicts) {
|
|
894
|
+
this.conflicts.push(`${c.path}: the team's text; yours is beside it as ${c.copy}`);
|
|
895
|
+
this.copies.add(c.copy);
|
|
896
|
+
}
|
|
811
897
|
for (const line of plan.indexTeamLines) this.conflicts.push(`${INDEX_FILE}: your line was kept; the team's was: ${line}`);
|
|
812
898
|
}
|
|
813
899
|
skip(path, reason) {
|
|
814
900
|
if (!this.skipped.has(path)) this.skipped.set(path, reason);
|
|
815
901
|
}
|
|
902
|
+
/** Skipped, and waiting for the member at a terminal. */
|
|
903
|
+
wait(path, reason) {
|
|
904
|
+
this.skip(path, reason);
|
|
905
|
+
this.waiting.add(path);
|
|
906
|
+
}
|
|
907
|
+
summary(outcome, version, reason) {
|
|
908
|
+
return {
|
|
909
|
+
outcome,
|
|
910
|
+
...reason ? { reason } : {},
|
|
911
|
+
version,
|
|
912
|
+
pulled: this.pulled.size,
|
|
913
|
+
pushed: this.pushed.size,
|
|
914
|
+
deleted: this.deletedHere.size + this.deletedFromTeam.size,
|
|
915
|
+
conflictCopies: [...this.copies].sort(),
|
|
916
|
+
waiting: [...this.waiting].sort()
|
|
917
|
+
};
|
|
918
|
+
}
|
|
816
919
|
lines(summary, dryRun) {
|
|
817
920
|
const out = [summary];
|
|
818
921
|
const section = (title, items) => {
|
|
@@ -915,7 +1018,7 @@ async function people(opts, ws, files, report, hint) {
|
|
|
915
1018
|
for (const path of g.files) {
|
|
916
1019
|
if (leftOut.has(path)) continue;
|
|
917
1020
|
current[path] = addShareFalse(current[path]);
|
|
918
|
-
await
|
|
1021
|
+
await writeWhole(toPath(opts.dir, path), current[path]);
|
|
919
1022
|
leftOut.add(path);
|
|
920
1023
|
}
|
|
921
1024
|
report.people.push({ kind: g.kind, answer: "left out (share: false)", files: g.files.length });
|
|
@@ -1018,7 +1121,7 @@ async function people(opts, ws, files, report, hint) {
|
|
|
1018
1121
|
const yes = /^y(es)?$/i.test((await ask(`Rewrite ${paths.length} file${paths.length === 1 ? "" : "s"} as shown? [y/N] `)).trim());
|
|
1019
1122
|
if (yes) {
|
|
1020
1123
|
for (const path of paths) {
|
|
1021
|
-
await
|
|
1124
|
+
await writeWhole(toPath(opts.dir, path), rewritten[path]);
|
|
1022
1125
|
current[path] = rewritten[path];
|
|
1023
1126
|
}
|
|
1024
1127
|
}
|
|
@@ -1033,11 +1136,11 @@ async function people(opts, ws, files, report, hint) {
|
|
|
1033
1136
|
held.add(path);
|
|
1034
1137
|
const keys = pending.filter((f) => f.kind !== HOME_PATH).map((f) => groupKey(f.kind, f.normalised));
|
|
1035
1138
|
if (keys.some((k) => vaultDown.has(k))) {
|
|
1036
|
-
report.
|
|
1139
|
+
report.wait(path, "the vault did not answer (SYD-K019) for a value in it; it syncs once the value is filed");
|
|
1037
1140
|
} else if (keys.some((k) => !answers.has(k))) {
|
|
1038
|
-
report.
|
|
1141
|
+
report.wait(path, `it holds a value nobody has answered for: run \`${hint}\` in a terminal`);
|
|
1039
1142
|
} else {
|
|
1040
|
-
report.
|
|
1143
|
+
report.wait(path, `it holds a value to rewrite as its identifier or ~: run \`${hint}\` in a terminal`);
|
|
1041
1144
|
}
|
|
1042
1145
|
}
|
|
1043
1146
|
for (const g of groups) {
|
|
@@ -1048,12 +1151,11 @@ async function people(opts, ws, files, report, hint) {
|
|
|
1048
1151
|
}
|
|
1049
1152
|
return { files: current, held, leftOut };
|
|
1050
1153
|
}
|
|
1154
|
+
async function removeLeftTemps(dir, temps) {
|
|
1155
|
+
for (const rel of temps) await rm2(toPath(dir, rel), { force: true });
|
|
1156
|
+
}
|
|
1051
1157
|
async function applyPlan(dir, plan) {
|
|
1052
|
-
for (const [path, text] of Object.entries(plan.writes))
|
|
1053
|
-
const full = toPath(dir, path);
|
|
1054
|
-
await mkdir2(dirname2(full), { recursive: true });
|
|
1055
|
-
await writeFile2(full, text, "utf8");
|
|
1056
|
-
}
|
|
1158
|
+
for (const [path, text] of Object.entries(plan.writes)) await writeWhole(toPath(dir, path), text);
|
|
1057
1159
|
for (const path of plan.deletes) await rm2(toPath(dir, path), { force: true });
|
|
1058
1160
|
}
|
|
1059
1161
|
var sameManifest = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([p, h]) => b[p] === h);
|
|
@@ -1098,8 +1200,19 @@ async function syncMemory(opts) {
|
|
|
1098
1200
|
const { client, out } = opts;
|
|
1099
1201
|
const now = opts.now?.() ?? /* @__PURE__ */ new Date();
|
|
1100
1202
|
try {
|
|
1203
|
+
const checkDeadline = () => {
|
|
1204
|
+
if (opts.deadline !== void 0 && Date.now() > opts.deadline) {
|
|
1205
|
+
throw new SyncStopped("took too long", "Stopped: the sync's time ran out before anything more was written or sent.");
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1101
1208
|
const own = await client.getOwnTeamSetup();
|
|
1102
1209
|
const ws = own.workspace;
|
|
1210
|
+
if (opts.workspace && ws !== opts.workspace) {
|
|
1211
|
+
throw new SyncStopped(
|
|
1212
|
+
"the key is for another workspace",
|
|
1213
|
+
`The key is for ${ws}, not ${opts.workspace}: nothing was synced. Set SYSTEMDOX_API_KEY to a key of ${opts.workspace}, or unset it.`
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1103
1216
|
const project = opts.project ?? (own.memory.length === 1 ? own.memory[0].project : void 0);
|
|
1104
1217
|
if (!project) {
|
|
1105
1218
|
throw new SyncFailed(
|
|
@@ -1113,6 +1226,12 @@ async function syncMemory(opts) {
|
|
|
1113
1226
|
const date = localDate(now);
|
|
1114
1227
|
const stored = (await readState(ws)).memory?.[project];
|
|
1115
1228
|
let team = await pullTeam(client, ws, project);
|
|
1229
|
+
if (opts.unattended && stored && !team.exists) {
|
|
1230
|
+
throw new SyncStopped(
|
|
1231
|
+
"the team memory could not be read",
|
|
1232
|
+
`${ws}/${project} could not be read from SystemDox: nothing was changed. Run sync-memory in your terminal to see why.`
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1116
1235
|
let base = stored ? { version: stored.version, manifest: stored.manifest, exists: true } : { version: 0, manifest: {}, exists: false };
|
|
1117
1236
|
const folder = await readFolder(dir);
|
|
1118
1237
|
const shared = {};
|
|
@@ -1125,14 +1244,16 @@ async function syncMemory(opts) {
|
|
|
1125
1244
|
} else shared[path] = text;
|
|
1126
1245
|
}
|
|
1127
1246
|
const importWrites = {};
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1247
|
+
const current = { ...folder.files };
|
|
1248
|
+
const taken = new Set(folder.names);
|
|
1249
|
+
for (const importDir of opts.importDirs ?? []) {
|
|
1250
|
+
const other = await readFolder(resolve(importDir));
|
|
1131
1251
|
for (const [path, text] of Object.entries(other.files).sort(([a], [b]) => a.localeCompare(b))) {
|
|
1132
1252
|
if (!isShareable(path, text) || !isFilePath(path)) continue;
|
|
1133
|
-
const here =
|
|
1253
|
+
const here = current[path];
|
|
1134
1254
|
if (here === void 0) {
|
|
1135
1255
|
shared[path] = text;
|
|
1256
|
+
current[path] = text;
|
|
1136
1257
|
importWrites[path] = text;
|
|
1137
1258
|
taken.add(path.toLowerCase());
|
|
1138
1259
|
} else if (here !== text) {
|
|
@@ -1140,16 +1261,14 @@ async function syncMemory(opts) {
|
|
|
1140
1261
|
taken.add(copy.toLowerCase());
|
|
1141
1262
|
importWrites[copy] = text;
|
|
1142
1263
|
report.conflicts.push(`${path}: this folder's text; the imported one is beside it as ${copy}`);
|
|
1264
|
+
report.copies.add(copy);
|
|
1143
1265
|
}
|
|
1144
1266
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
await writeFile2(full, text, "utf8");
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1267
|
+
}
|
|
1268
|
+
folder.names = taken;
|
|
1269
|
+
if (!opts.dryRun) {
|
|
1270
|
+
checkDeadline();
|
|
1271
|
+
for (const [path, text] of Object.entries(importWrites)) await writeWhole(toPath(dir, path), text);
|
|
1153
1272
|
}
|
|
1154
1273
|
const found = await people(run2, ws, shared, report, hint);
|
|
1155
1274
|
for (const path of found.leftOut) {
|
|
@@ -1174,12 +1293,16 @@ async function syncMemory(opts) {
|
|
|
1174
1293
|
);
|
|
1175
1294
|
return 0;
|
|
1176
1295
|
}
|
|
1296
|
+
checkDeadline();
|
|
1297
|
+
if (attempt === 1) await removeLeftTemps(dir, folder.temps);
|
|
1177
1298
|
await applyPlan(dir, plan);
|
|
1178
1299
|
let version = team.version;
|
|
1179
1300
|
if (!sameManifest(plan.manifest, team.manifest)) {
|
|
1301
|
+
checkDeadline();
|
|
1180
1302
|
await upload(client, ws, project, plan, team.manifest, report);
|
|
1181
1303
|
}
|
|
1182
1304
|
if (!sameManifest(plan.manifest, team.manifest)) {
|
|
1305
|
+
checkDeadline();
|
|
1183
1306
|
try {
|
|
1184
1307
|
version = (await client.pushMemoryVersion(ws, project, { baseVersion: team.version, manifest: plan.manifest })).version;
|
|
1185
1308
|
} catch (e) {
|
|
@@ -1191,6 +1314,7 @@ async function syncMemory(opts) {
|
|
|
1191
1314
|
false
|
|
1192
1315
|
)
|
|
1193
1316
|
);
|
|
1317
|
+
opts.summary?.(report.summary("moved-on", team.version, "moved on twice"));
|
|
1194
1318
|
return 2;
|
|
1195
1319
|
}
|
|
1196
1320
|
base = team;
|
|
@@ -1215,18 +1339,198 @@ async function syncMemory(opts) {
|
|
|
1215
1339
|
false
|
|
1216
1340
|
)
|
|
1217
1341
|
);
|
|
1342
|
+
opts.summary?.(report.summary("synced", version));
|
|
1218
1343
|
return 0;
|
|
1219
1344
|
}
|
|
1220
1345
|
} catch (e) {
|
|
1221
1346
|
out(e.message);
|
|
1347
|
+
opts.summary?.({
|
|
1348
|
+
outcome: "failed",
|
|
1349
|
+
reason: failureReason(e),
|
|
1350
|
+
version: 0,
|
|
1351
|
+
pulled: 0,
|
|
1352
|
+
pushed: 0,
|
|
1353
|
+
deleted: 0,
|
|
1354
|
+
conflictCopies: [],
|
|
1355
|
+
waiting: []
|
|
1356
|
+
});
|
|
1222
1357
|
return 1;
|
|
1223
1358
|
}
|
|
1224
1359
|
}
|
|
1360
|
+
var plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
1361
|
+
function hookLines(ws, project, folder, s) {
|
|
1362
|
+
if (s.outcome !== "synced") {
|
|
1363
|
+
return [`Team memory ${ws}/${project}: not synced (${s.reason ?? "it did not finish"}); your files are unchanged and the next session tries again.`];
|
|
1364
|
+
}
|
|
1365
|
+
const lines = [`Team memory ${ws}/${project}: version ${s.version}, ${s.pulled} pulled, ${s.pushed} pushed, ${s.deleted} deleted.`];
|
|
1366
|
+
if (s.conflictCopies.length) {
|
|
1367
|
+
lines.push(` ${plural(s.conflictCopies.length, "conflict copy", "conflict copies")} to tidy in ${folder}: ${s.conflictCopies.join(", ")}`);
|
|
1368
|
+
}
|
|
1369
|
+
if (s.waiting.length) {
|
|
1370
|
+
lines.push(
|
|
1371
|
+
` ${plural(s.waiting.length, "file waits", "files wait")} for you: run \`${SYNC_MEMORY_COMMAND} --project ${project}\` in your terminal.`
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
return lines;
|
|
1375
|
+
}
|
|
1376
|
+
async function appendLog(ws, lines) {
|
|
1377
|
+
const path = syncLogPath(ws);
|
|
1378
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1379
|
+
try {
|
|
1380
|
+
if ((await stat(path)).size > HOOK_LOG_MAX_BYTES) await writeFile2(path, "", "utf8");
|
|
1381
|
+
} catch {
|
|
1382
|
+
}
|
|
1383
|
+
await appendFile(path, lines.join("\n") + "\n", "utf8");
|
|
1384
|
+
}
|
|
1385
|
+
async function takeLock(lock, staleMs, now) {
|
|
1386
|
+
await mkdir2(dirname2(lock), { recursive: true });
|
|
1387
|
+
const body = JSON.stringify({ pid: process.pid, at: now.toISOString() }) + "\n";
|
|
1388
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1389
|
+
try {
|
|
1390
|
+
const handle = await open(lock, "wx");
|
|
1391
|
+
try {
|
|
1392
|
+
await handle.writeFile(body, "utf8");
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
await handle.close();
|
|
1395
|
+
await rm2(lock, { force: true });
|
|
1396
|
+
throw err;
|
|
1397
|
+
}
|
|
1398
|
+
await handle.close();
|
|
1399
|
+
return true;
|
|
1400
|
+
} catch (e) {
|
|
1401
|
+
if (e.code !== "EEXIST") throw e;
|
|
1402
|
+
let seen;
|
|
1403
|
+
try {
|
|
1404
|
+
seen = await stat(lock);
|
|
1405
|
+
} catch {
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
if (Date.now() - seen.mtimeMs < staleMs || attempt === 1) return false;
|
|
1409
|
+
try {
|
|
1410
|
+
const now2 = await stat(lock);
|
|
1411
|
+
if (now2.ino !== seen.ino || now2.mtimeMs !== seen.mtimeMs) return false;
|
|
1412
|
+
} catch {
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
await rm2(lock, { force: true });
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
async function releaseLock(lock) {
|
|
1421
|
+
try {
|
|
1422
|
+
const held = JSON.parse(await readFile2(lock, "utf8"));
|
|
1423
|
+
if (held.pid === process.pid) await rm2(lock, { force: true });
|
|
1424
|
+
} catch {
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
async function runHookSync(workspace, io, env = process.env) {
|
|
1428
|
+
try {
|
|
1429
|
+
return await hookSync(workspace, io, env);
|
|
1430
|
+
} catch (e) {
|
|
1431
|
+
try {
|
|
1432
|
+
io.err(`sync-memory --hook: ${e.message}`);
|
|
1433
|
+
} catch {
|
|
1434
|
+
}
|
|
1435
|
+
return 0;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
async function hookSync(workspace, io, env) {
|
|
1439
|
+
if (!isWorkspace(workspace)) {
|
|
1440
|
+
io.err(`sync-memory --hook: ${JSON.stringify(workspace)} is not a workspace name`);
|
|
1441
|
+
return 0;
|
|
1442
|
+
}
|
|
1443
|
+
const now = io.now?.() ?? /* @__PURE__ */ new Date();
|
|
1444
|
+
const deadline = (io.startedAt ?? Date.now()) + (io.budgetMs ?? HOOK_BUDGET_MS);
|
|
1445
|
+
let client = io.client;
|
|
1446
|
+
if (!client) {
|
|
1447
|
+
let apiKey;
|
|
1448
|
+
let apiUrl;
|
|
1449
|
+
try {
|
|
1450
|
+
apiKey = await apiKeyFor(workspace, env);
|
|
1451
|
+
apiUrl = apiUrlFrom(env);
|
|
1452
|
+
} catch {
|
|
1453
|
+
return 0;
|
|
1454
|
+
}
|
|
1455
|
+
if (!apiKey) return 0;
|
|
1456
|
+
client = new SystemDoxApiClient({ apiKey, apiUrl, cacheTtlMs: 0, defaultOrg: void 0 });
|
|
1457
|
+
}
|
|
1458
|
+
let projects;
|
|
1459
|
+
try {
|
|
1460
|
+
projects = Object.keys((await readState(workspace)).memory ?? {}).sort();
|
|
1461
|
+
} catch {
|
|
1462
|
+
return 0;
|
|
1463
|
+
}
|
|
1464
|
+
if (!projects.length) return 0;
|
|
1465
|
+
const lock = syncLockPath(workspace);
|
|
1466
|
+
if (!await takeLock(lock, io.lockStaleMs ?? HOOK_LOCK_STALE_MS, now)) {
|
|
1467
|
+
io.out(`Team memory ${workspace}: a sync is already running.`);
|
|
1468
|
+
return 0;
|
|
1469
|
+
}
|
|
1470
|
+
try {
|
|
1471
|
+
for (const project of projects) {
|
|
1472
|
+
const folder = join2(workspaceDir(workspace), "memory", project);
|
|
1473
|
+
const report = [];
|
|
1474
|
+
let summary;
|
|
1475
|
+
const left = deadline - Date.now();
|
|
1476
|
+
if (left > 0) {
|
|
1477
|
+
let timer;
|
|
1478
|
+
const timeout = new Promise((resolveTimeout) => {
|
|
1479
|
+
timer = setTimeout(() => resolveTimeout("timeout"), left);
|
|
1480
|
+
});
|
|
1481
|
+
const run2 = syncMemory({
|
|
1482
|
+
project,
|
|
1483
|
+
dryRun: false,
|
|
1484
|
+
client,
|
|
1485
|
+
workspace,
|
|
1486
|
+
deadline,
|
|
1487
|
+
unattended: true,
|
|
1488
|
+
out: (...lines2) => report.push(...lines2),
|
|
1489
|
+
summary: (s) => summary = s,
|
|
1490
|
+
now: io.now
|
|
1491
|
+
});
|
|
1492
|
+
const won = await Promise.race([run2.then(() => "done"), timeout]);
|
|
1493
|
+
if (timer) clearTimeout(timer);
|
|
1494
|
+
if (won === "timeout") summary = void 0;
|
|
1495
|
+
}
|
|
1496
|
+
const result = summary ?? {
|
|
1497
|
+
outcome: "failed",
|
|
1498
|
+
reason: "took too long",
|
|
1499
|
+
version: 0,
|
|
1500
|
+
pulled: 0,
|
|
1501
|
+
pushed: 0,
|
|
1502
|
+
deleted: 0,
|
|
1503
|
+
conflictCopies: [],
|
|
1504
|
+
waiting: []
|
|
1505
|
+
};
|
|
1506
|
+
const lines = hookLines(workspace, project, folder, result);
|
|
1507
|
+
lines.forEach((l) => io.out(l));
|
|
1508
|
+
try {
|
|
1509
|
+
await appendLog(workspace, [`--- ${now.toISOString()} ${workspace}/${project}`, ...lines, ...report]);
|
|
1510
|
+
} catch (e) {
|
|
1511
|
+
io.err(`sync-memory --hook: the log could not be written: ${e.message}`);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
} finally {
|
|
1515
|
+
await releaseLock(lock);
|
|
1516
|
+
}
|
|
1517
|
+
return 0;
|
|
1518
|
+
}
|
|
1225
1519
|
async function runSyncMemory(argv, io, env = process.env) {
|
|
1226
1520
|
let args;
|
|
1227
1521
|
let client = io.client;
|
|
1228
1522
|
try {
|
|
1229
1523
|
args = parseSyncMemoryArgs(argv);
|
|
1524
|
+
} catch (e) {
|
|
1525
|
+
if (argv.includes("--hook")) {
|
|
1526
|
+
io.err(`sync-memory --hook: ${e.message.split("\n")[0]}`);
|
|
1527
|
+
return 0;
|
|
1528
|
+
}
|
|
1529
|
+
io.err(e.message);
|
|
1530
|
+
return 1;
|
|
1531
|
+
}
|
|
1532
|
+
if (args.hook) return runHookSync(args.hook, io, env);
|
|
1533
|
+
try {
|
|
1230
1534
|
if (!client) {
|
|
1231
1535
|
const apiUrl = apiUrlFrom(env);
|
|
1232
1536
|
const apiKey = await localApiKey(env);
|
|
@@ -1239,9 +1543,14 @@ async function runSyncMemory(argv, io, env = process.env) {
|
|
|
1239
1543
|
return syncMemory({ ...args, client, out: (...lines) => lines.forEach((l) => io.out(l)), ask: io.ask, now: io.now });
|
|
1240
1544
|
}
|
|
1241
1545
|
export {
|
|
1546
|
+
HOOK_BUDGET_MS,
|
|
1547
|
+
HOOK_LOCK_STALE_MS,
|
|
1548
|
+
HOOK_LOG_MAX_BYTES,
|
|
1242
1549
|
SYNC_MEMORY_COMMAND,
|
|
1243
1550
|
SYNC_MEMORY_USAGE,
|
|
1551
|
+
hookLines,
|
|
1244
1552
|
parseSyncMemoryArgs,
|
|
1553
|
+
runHookSync,
|
|
1245
1554
|
runSyncMemory,
|
|
1246
1555
|
syncMemory
|
|
1247
1556
|
};
|