@mikeargento/bitgraph-mcp 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/__tests__/format.test.js +24 -3
- package/dist/__tests__/format.test.js.map +1 -1
- package/dist/__tests__/scan.test.d.ts +2 -0
- package/dist/__tests__/scan.test.d.ts.map +1 -0
- package/dist/__tests__/scan.test.js +73 -0
- package/dist/__tests__/scan.test.js.map +1 -0
- package/dist/__tests__/tools.test.js +287 -84
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/api.d.ts +18 -30
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +27 -50
- package/dist/api.js.map +1 -1
- package/dist/format.d.ts +36 -9
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +64 -29
- package/dist/format.js.map +1 -1
- package/dist/scan.d.ts +56 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +158 -0
- package/dist/scan.js.map +1 -0
- package/dist/server.d.ts +51 -9
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +336 -113
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/__tests__/format.test.ts +25 -3
- package/src/__tests__/scan.test.ts +80 -0
- package/src/__tests__/tools.test.ts +302 -97
- package/src/api.ts +36 -77
- package/src/format.ts +104 -37
- package/src/scan.ts +173 -0
- package/src/server.ts +410 -120
- package/src/types.ts +23 -1
|
@@ -3,19 +3,21 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* End-to-end tool tests: a real MCP client drives the real server over an
|
|
5
5
|
* in-memory transport, against a mock bitgraph.ing that asserts the exact
|
|
6
|
-
* wire shapes
|
|
6
|
+
* wire shapes, with the set pipeline replaced by a stand-in. No real ledger
|
|
7
|
+
* writes ever happen here.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { test, before, after } from "node:test";
|
|
10
11
|
import { strict as assert } from "node:assert";
|
|
11
12
|
import { createHash } from "node:crypto";
|
|
12
13
|
import { createServer, type Server } from "node:http";
|
|
13
|
-
import { writeFile, mkdtemp } from "node:fs/promises";
|
|
14
|
+
import { writeFile, mkdtemp, mkdir, symlink } from "node:fs/promises";
|
|
14
15
|
import { tmpdir } from "node:os";
|
|
15
16
|
import { join } from "node:path";
|
|
16
17
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
18
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
18
|
-
import {
|
|
19
|
+
import { FuseError } from "@mikeargento/bitgraph";
|
|
20
|
+
import { buildServer, pendingIndexCount, ROW_CAP, SET_INDEX_CHUNK, type FuseFileFn, type FuseSetFn } from "../server.js";
|
|
19
21
|
import { toUrlSafeB64 } from "../encoding.js";
|
|
20
22
|
|
|
21
23
|
interface Recorded {
|
|
@@ -30,10 +32,16 @@ let mock: Server;
|
|
|
30
32
|
let fileA = "";
|
|
31
33
|
let fileB = "";
|
|
32
34
|
let fileC = "";
|
|
35
|
+
let copyB = "";
|
|
36
|
+
let dir = "";
|
|
37
|
+
let bigDir = "";
|
|
38
|
+
const BIG = SET_INDEX_CHUNK + 100;
|
|
33
39
|
let digestA = ""; // standard b64 of fileA bytes
|
|
34
40
|
let digestB = "";
|
|
41
|
+
let digestC = "";
|
|
35
42
|
const EPOCH = createHash("sha256").update("test-epoch").digest("base64");
|
|
36
43
|
let mintCounter = 100;
|
|
44
|
+
let indexMode: "ok" | "fail" = "ok";
|
|
37
45
|
|
|
38
46
|
function proofFor(digestB64: string): Record<string, unknown> {
|
|
39
47
|
mintCounter += 2; // slot consumes one position, commit lands on the next
|
|
@@ -43,19 +51,34 @@ function proofFor(digestB64: string): Record<string, unknown> {
|
|
|
43
51
|
commit: { counter: String(mintCounter), epochId: EPOCH },
|
|
44
52
|
signer: { publicKeyB64: "pk", signatureB64: "sig" },
|
|
45
53
|
environment: { enforcement: "measured-tee", measurement: "m" },
|
|
54
|
+
attribution: { name: "bitgraph-fuse/1", title: "set/1" },
|
|
46
55
|
};
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
before(async () => {
|
|
50
|
-
const
|
|
51
|
-
fileA = join(
|
|
52
|
-
fileB = join(
|
|
53
|
-
fileC = join(
|
|
59
|
+
const root = await mkdtemp(join(tmpdir(), "bitgraph-mcp-e2e-"));
|
|
60
|
+
fileA = join(root, "a.txt");
|
|
61
|
+
fileB = join(root, "b.txt");
|
|
62
|
+
fileC = join(root, "c.txt");
|
|
63
|
+
copyB = join(root, "b-copy.txt");
|
|
54
64
|
await writeFile(fileA, "alpha bytes");
|
|
55
65
|
await writeFile(fileB, "beta bytes");
|
|
56
66
|
await writeFile(fileC, "gamma bytes");
|
|
67
|
+
await writeFile(copyB, "beta bytes");
|
|
57
68
|
digestA = createHash("sha256").update("alpha bytes").digest("base64");
|
|
58
69
|
digestB = createHash("sha256").update("beta bytes").digest("base64");
|
|
70
|
+
digestC = createHash("sha256").update("gamma bytes").digest("base64");
|
|
71
|
+
// A folder: two files, a hidden one, a nested one, a symbolic link.
|
|
72
|
+
dir = join(root, "folder");
|
|
73
|
+
await mkdir(join(dir, "sub"), { recursive: true });
|
|
74
|
+
await writeFile(join(dir, "a1.txt"), "folder one");
|
|
75
|
+
await writeFile(join(dir, ".hidden.txt"), "hidden");
|
|
76
|
+
await writeFile(join(dir, "sub", "a2.txt"), "folder two");
|
|
77
|
+
await symlink(join(dir, "a1.txt"), join(dir, "link.txt"));
|
|
78
|
+
// A folder above the set/1 cap.
|
|
79
|
+
bigDir = join(root, "big");
|
|
80
|
+
await mkdir(bigDir);
|
|
81
|
+
await Promise.all(Array.from({ length: BIG }, (_, i) => writeFile(join(bigDir, `m${String(i).padStart(5, "0")}.txt`), `member ${i}\n`)));
|
|
59
82
|
|
|
60
83
|
mock = createServer((req, res) => {
|
|
61
84
|
const chunks: Buffer[] = [];
|
|
@@ -72,14 +95,14 @@ before(async () => {
|
|
|
72
95
|
};
|
|
73
96
|
|
|
74
97
|
if (url.pathname === "/api/proofs/batch") {
|
|
75
|
-
// fileA is already on record (two positions);
|
|
98
|
+
// fileA is already on record (two positions, the first as a set member); nothing else is.
|
|
76
99
|
const digests = (body as { digests: string[] }).digests;
|
|
77
|
-
const results: Record<string, { proofs: Array<{ proof: unknown }> }> = {};
|
|
100
|
+
const results: Record<string, { proofs: Array<{ proof: unknown; member?: unknown }> }> = {};
|
|
78
101
|
for (const d of digests) {
|
|
79
102
|
if (d === toUrlSafeB64(digestA)) {
|
|
80
103
|
results[d] = {
|
|
81
104
|
proofs: [
|
|
82
|
-
{ proof: { artifact: { digestB64: digestA }, commit: { counter: "10", epochId: EPOCH } } },
|
|
105
|
+
{ proof: { artifact: { digestB64: digestA }, commit: { counter: "10", epochId: EPOCH } }, member: { index: 2, count: 10, role: "origin" } },
|
|
83
106
|
{ proof: { artifact: { digestB64: digestA }, commit: { counter: "55", epochId: EPOCH } } },
|
|
84
107
|
],
|
|
85
108
|
};
|
|
@@ -88,9 +111,16 @@ before(async () => {
|
|
|
88
111
|
}
|
|
89
112
|
}
|
|
90
113
|
send(200, { results });
|
|
91
|
-
} else if (url.pathname === "/api/
|
|
92
|
-
|
|
93
|
-
|
|
114
|
+
} else if (url.pathname === "/api/fuse/set-index") {
|
|
115
|
+
if (indexMode === "fail") {
|
|
116
|
+
send(500, { error: "indexing failed" });
|
|
117
|
+
} else {
|
|
118
|
+
const members = (body as { members: unknown[] }).members;
|
|
119
|
+
send(200, { count: BIG, written: members.length, failed: 0, rejected: 0 });
|
|
120
|
+
}
|
|
121
|
+
} else if (url.pathname === "/api/commit" || url.pathname.startsWith("/api/fuse/allocate") || url.pathname.startsWith("/api/fuse/commit")) {
|
|
122
|
+
// The stand-in pipeline never reaches the boundary; nothing here may.
|
|
123
|
+
send(500, { error: `unexpected ${url.pathname}` });
|
|
94
124
|
} else if (url.pathname.startsWith("/api/proofs/digest/")) {
|
|
95
125
|
const digest = decodeURIComponent(url.pathname.split("/").pop() ?? "");
|
|
96
126
|
if (digest === toUrlSafeB64(digestA)) {
|
|
@@ -105,6 +135,12 @@ before(async () => {
|
|
|
105
135
|
anchorAfter: { blockTime: "2026-07-01T00:00:12.000Z", blockNumber: 2 },
|
|
106
136
|
},
|
|
107
137
|
});
|
|
138
|
+
} else if (digest === toUrlSafeB64(digestB)) {
|
|
139
|
+
send(200, {
|
|
140
|
+
proofs: [{ proof: { artifact: { digestB64: "c2V0" }, commit: { counter: "1386", epochId: EPOCH } } }],
|
|
141
|
+
positions: [{ counter: "1386", epoch: toUrlSafeB64(EPOCH), lowerTime: null, upperTime: null, kind: "fused", placement: "container/2", member: { index: 4, count: 50, role: "origin" } }],
|
|
142
|
+
causalWindow: null,
|
|
143
|
+
});
|
|
108
144
|
} else {
|
|
109
145
|
send(200, { proofs: [] });
|
|
110
146
|
}
|
|
@@ -133,30 +169,64 @@ after(() => {
|
|
|
133
169
|
delete process.env["BITGRAPH_API_KEY"];
|
|
134
170
|
});
|
|
135
171
|
|
|
136
|
-
/** A stand-in for the
|
|
137
|
-
const
|
|
172
|
+
/** A stand-in for the set pipeline: no slot, no boundary; records what it was asked to fuse and answers like a set. */
|
|
173
|
+
const setCalls: Array<{ names: string[]; digests: string[]; set: string }> = [];
|
|
138
174
|
let fuseMode: "ok" | "fail" = "ok";
|
|
139
|
-
async
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
175
|
+
const fakeFuseSet: FuseSetFn = async (files, _config, opts) => {
|
|
176
|
+
setCalls.push({ names: files.map((f) => f.name), digests: files.map((f) => f.digestB64), set: opts.set });
|
|
177
|
+
if (fuseMode === "fail") throw new FuseError("tee-restarting", "the boundary is restarting", 503);
|
|
178
|
+
opts.onProgress?.({ phase: "commit", done: 1, total: 1 });
|
|
179
|
+
const rows = files.map((f, index) => ({ index, artifact: createHash("sha256").update("fused:" + f.digestB64).digest("base64"), origin: f.digestB64, placement: f.placement }));
|
|
180
|
+
const sorted = [...rows].sort((a, b) => Buffer.from(a.artifact, "base64").compare(Buffer.from(b.artifact, "base64")));
|
|
181
|
+
const manifestIndex = new Map(sorted.map((r, k) => [r.index, k]));
|
|
182
|
+
const setDigest = createHash("sha256").update("set:" + sorted.map((r) => r.artifact).join(",")).digest("base64");
|
|
144
183
|
return {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
184
|
+
set: opts.set,
|
|
185
|
+
proof: proofFor(setDigest),
|
|
186
|
+
artifactDigestB64: setDigest,
|
|
187
|
+
count: files.length,
|
|
188
|
+
manifestEchoed: true,
|
|
189
|
+
recovered: false,
|
|
190
|
+
members: rows.map((r) => {
|
|
191
|
+
const k = manifestIndex.get(r.index) as number;
|
|
192
|
+
return {
|
|
193
|
+
index: r.index,
|
|
194
|
+
manifestIndex: k,
|
|
195
|
+
placement: r.placement,
|
|
196
|
+
originDigestB64: r.origin,
|
|
197
|
+
artifactDigestB64: r.artifact,
|
|
198
|
+
...(opts.set === "set/2"
|
|
199
|
+
? { memberProof: { count: files.length, index: k, member: { artifact: { algorithm: "sha256", digest: "" }, origin: { algorithm: "sha256", digest: "" }, placement: r.placement }, path: [], placement: "set/2", type: "bitgraph-fuse/1" } }
|
|
200
|
+
: {}),
|
|
201
|
+
};
|
|
202
|
+
}),
|
|
150
203
|
};
|
|
151
|
-
}
|
|
204
|
+
};
|
|
205
|
+
/** A stand-in for the single-file pipeline: records what it was asked to fuse. */
|
|
206
|
+
const soloCalls: Array<{ name: string; digestB64: string }> = [];
|
|
207
|
+
const fakeFuseFile: FuseFileFn = async (file) => {
|
|
208
|
+
soloCalls.push({ name: file.name, digestB64: file.digestB64 });
|
|
209
|
+
if (fuseMode === "fail") throw new FuseError("tee-restarting", "the boundary is restarting", 503);
|
|
210
|
+
const artifactDigestB64 = createHash("sha256").update("fused:" + file.digestB64).digest("base64");
|
|
211
|
+
return { proof: proofFor(artifactDigestB64), frame: { type: "bitgraph-fuse/1" }, placement: file.placement, artifactDigestB64, originDigestB64: file.digestB64 };
|
|
212
|
+
};
|
|
152
213
|
async function connectedClient(): Promise<Client> {
|
|
153
|
-
const server = buildServer({ fuseFile:
|
|
214
|
+
const server = buildServer({ fuseSet: fakeFuseSet, fuseFile: fakeFuseFile });
|
|
154
215
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
155
216
|
const client = new Client({ name: "test-client", version: "0.0.0" });
|
|
156
217
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
157
218
|
return client;
|
|
158
219
|
}
|
|
159
220
|
|
|
221
|
+
interface RecordStructured {
|
|
222
|
+
set: { set: string; count: number; counter: string | null; proof_url: string; index: { written: number; pending: number } | null } | null;
|
|
223
|
+
frames: Record<string, unknown>;
|
|
224
|
+
results: Array<{ path: string; outcome: string; counter: string | null; placement: string | null; artifact_digest: string | null; member: number | null; member_count: number | null; total_positions: number; proof_url: string | null; error?: string }>;
|
|
225
|
+
omitted: number;
|
|
226
|
+
summary: { files: number; directories: number; fused: number; on_record: number; not_fused: number };
|
|
227
|
+
}
|
|
228
|
+
const textOf = (result: unknown): string => (((result as { content?: unknown }).content ?? []) as Array<{ text: string }>)[0]?.text ?? "";
|
|
229
|
+
|
|
160
230
|
test("lists the three tools", async () => {
|
|
161
231
|
const client = await connectedClient();
|
|
162
232
|
const tools = await client.listTools();
|
|
@@ -164,53 +234,206 @@ test("lists the three tools", async () => {
|
|
|
164
234
|
assert.deepEqual(names, ["bitgraph_check", "bitgraph_get_proof", "bitgraph_record"]);
|
|
165
235
|
});
|
|
166
236
|
|
|
167
|
-
test("record
|
|
237
|
+
test("record makes ONE set of the fresh files and leaves on-record ones alone", async () => {
|
|
168
238
|
const client = await connectedClient();
|
|
169
239
|
requests.length = 0;
|
|
170
|
-
|
|
240
|
+
setCalls.length = 0;
|
|
171
241
|
const result = await client.callTool({
|
|
172
242
|
name: "bitgraph_record",
|
|
173
|
-
arguments: { paths: [fileA, fileB] },
|
|
243
|
+
arguments: { paths: [fileA, fileB, fileC] },
|
|
174
244
|
});
|
|
175
245
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
176
|
-
assert.equal(requests.filter((r) => r.path === "/api/commit").length, 0, "nothing is committed as bytes-only");
|
|
177
|
-
assert.
|
|
178
|
-
assert.
|
|
246
|
+
assert.equal(requests.filter((r) => r.path === "/api/commit" || r.path.startsWith("/api/fuse/")).length, 0, "the stand-in never reaches the boundary; nothing is committed as bytes-only");
|
|
247
|
+
assert.equal(setCalls.length, 1, "one set for the whole call");
|
|
248
|
+
assert.deepEqual(setCalls[0]?.digests, [digestB, digestC], "only the fresh files are members; fileA was on record");
|
|
249
|
+
assert.deepEqual(setCalls[0]?.names, ["b.txt", "c.txt"], "members are named after the files");
|
|
250
|
+
assert.equal(setCalls[0]?.set, "set/1");
|
|
179
251
|
const batch = requests.find((r) => r.path === "/api/proofs/batch");
|
|
180
|
-
if (!batch) throw new Error("no batch check preceded the
|
|
252
|
+
if (!batch) throw new Error("no batch check preceded the set");
|
|
181
253
|
const batchBody = batch.body as { digests: string[] };
|
|
182
254
|
assert.ok(batchBody.digests.every((d) => !d.includes("+") && !d.includes("=")), "check uses url-safe digests");
|
|
183
|
-
const structured = result.structuredContent as
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
assert.equal(structured.summary.fused, 1);
|
|
189
|
-
assert.equal(structured.summary.on_record, 1);
|
|
190
|
-
assert.equal(structured.summary.not_fused, 0);
|
|
255
|
+
const structured = result.structuredContent as RecordStructured;
|
|
256
|
+
assert.deepEqual(structured.summary, { files: 3, directories: 0, fused: 2, on_record: 1, not_fused: 0 });
|
|
257
|
+
assert.ok(structured.set, "the set is reported");
|
|
258
|
+
assert.equal(structured.set?.count, 2);
|
|
259
|
+
assert.ok(structured.set?.proof_url.includes("/proof/") && structured.set?.proof_url.includes("counter="), "the set's proof page is pinned to its position");
|
|
191
260
|
const a = structured.results.find((r) => r.path === fileA);
|
|
192
261
|
const b = structured.results.find((r) => r.path === fileB);
|
|
262
|
+
const c = structured.results.find((r) => r.path === fileC);
|
|
193
263
|
assert.equal(a?.outcome, "on record");
|
|
194
264
|
assert.equal(a?.counter, "10", "on-record outcome reports the EARLIEST position");
|
|
265
|
+
assert.equal(a?.member, 3, "an on-record set member reports its row, 1-based");
|
|
266
|
+
assert.equal(a?.member_count, 10);
|
|
195
267
|
assert.equal(b?.outcome, "fused");
|
|
196
|
-
assert.equal(b?.
|
|
197
|
-
assert.
|
|
268
|
+
assert.equal(b?.counter, structured.set?.counter, "a fused file's position is the set's");
|
|
269
|
+
assert.equal(b?.placement, "container/2", "text goes in a container");
|
|
270
|
+
assert.equal(b?.member_count, 2);
|
|
271
|
+
assert.ok(b?.member === 1 || b?.member === 2);
|
|
272
|
+
assert.notEqual(b?.member, c?.member, "two members, two rows");
|
|
273
|
+
assert.ok(b?.artifact_digest && !b.artifact_digest.includes("+"), "the member's fused digest, url-safe");
|
|
274
|
+
assert.ok(b?.proof_url?.includes(encodeURIComponent(toUrlSafeB64(digestB))), "the file's own digest finds the set");
|
|
275
|
+
const text = textOf(result);
|
|
276
|
+
assert.ok(text.startsWith("2 files BitGraphed as one set at #"), text);
|
|
277
|
+
assert.ok(text.includes("set of 2"), text);
|
|
198
278
|
});
|
|
199
279
|
|
|
200
|
-
test("
|
|
280
|
+
test("a single file is fused on its own, with its Frame; again=true fuses an on-record one", async () => {
|
|
201
281
|
const client = await connectedClient();
|
|
202
|
-
|
|
282
|
+
setCalls.length = 0;
|
|
283
|
+
soloCalls.length = 0;
|
|
203
284
|
const result = await client.callTool({
|
|
204
285
|
name: "bitgraph_record",
|
|
205
286
|
arguments: { paths: [fileA], again: true },
|
|
206
287
|
});
|
|
207
288
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
208
|
-
assert.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
assert.equal(
|
|
289
|
+
assert.equal(setCalls.length, 0, "one file is not a set");
|
|
290
|
+
assert.deepEqual(soloCalls.map((c) => c.digestB64), [digestA]);
|
|
291
|
+
const structured = result.structuredContent as RecordStructured;
|
|
292
|
+
assert.equal(structured.set, null);
|
|
293
|
+
const row = structured.results[0];
|
|
294
|
+
assert.equal(row?.outcome, "fused");
|
|
295
|
+
assert.equal(row?.member, null);
|
|
296
|
+
assert.equal(row?.total_positions, 3, "two prior positions plus the new BitGraph");
|
|
297
|
+
assert.ok(row?.artifact_digest && structured.frames[row.artifact_digest], "the Frame rides in the structured result under the fused digest");
|
|
298
|
+
assert.ok(row?.proof_url?.includes(encodeURIComponent(row.artifact_digest ?? "")), "a single file's proof page is its own");
|
|
299
|
+
const text = textOf(result);
|
|
300
|
+
assert.ok(text.startsWith("1 fused, 0 already on record."), text);
|
|
301
|
+
assert.ok(text.includes("its Frame is in the structured result"), text);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("a directory is its regular files, hidden entries and links left out", async () => {
|
|
305
|
+
const client = await connectedClient();
|
|
306
|
+
setCalls.length = 0;
|
|
307
|
+
const result = await client.callTool({
|
|
308
|
+
name: "bitgraph_record",
|
|
309
|
+
arguments: { paths: [dir] },
|
|
310
|
+
});
|
|
311
|
+
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
312
|
+
assert.deepEqual(setCalls[0]?.names, ["a1.txt", "a2.txt"]);
|
|
313
|
+
const structured = result.structuredContent as RecordStructured;
|
|
314
|
+
assert.deepEqual(structured.summary, { files: 2, directories: 1, fused: 2, on_record: 0, not_fused: 0 });
|
|
315
|
+
assert.equal(structured.set?.count, 2);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("the same bytes under two paths are one file, fused once and reported twice", async () => {
|
|
319
|
+
const client = await connectedClient();
|
|
320
|
+
setCalls.length = 0;
|
|
321
|
+
soloCalls.length = 0;
|
|
322
|
+
const result = await client.callTool({
|
|
323
|
+
name: "bitgraph_record",
|
|
324
|
+
arguments: { paths: [fileB, copyB] },
|
|
325
|
+
});
|
|
326
|
+
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
327
|
+
assert.equal(setCalls.length, 0, "one distinct file is fused on its own");
|
|
328
|
+
assert.deepEqual(soloCalls.map((c) => c.digestB64), [digestB]);
|
|
329
|
+
const structured = result.structuredContent as RecordStructured;
|
|
330
|
+
assert.equal(structured.set, null);
|
|
331
|
+
assert.equal(structured.summary.fused, 2);
|
|
332
|
+
assert.equal(structured.results[0]?.artifact_digest, structured.results[1]?.artifact_digest);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("above the set/1 cap the set is a set/2 and its evidence is indexed in chunks", async () => {
|
|
336
|
+
const client = await connectedClient();
|
|
337
|
+
requests.length = 0;
|
|
338
|
+
setCalls.length = 0;
|
|
339
|
+
indexMode = "ok";
|
|
340
|
+
const progress: string[] = [];
|
|
341
|
+
const result = await client.callTool({ name: "bitgraph_record", arguments: { paths: [bigDir] } }, undefined, {
|
|
342
|
+
onprogress: (p) => {
|
|
343
|
+
if (p.message !== undefined) progress.push(p.message);
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
assert.ok(!result.isError, JSON.stringify(result.content).slice(0, 500));
|
|
347
|
+
assert.equal(setCalls[0]?.set, "set/2");
|
|
348
|
+
assert.equal(setCalls[0]?.digests.length, BIG);
|
|
349
|
+
const index = requests.filter((r) => r.path === "/api/fuse/set-index");
|
|
350
|
+
assert.deepEqual(index.map((r) => (r.body as { members: unknown[] }).members.length), [SET_INDEX_CHUNK, BIG - SET_INDEX_CHUNK], "evidence goes in the site's chunks");
|
|
351
|
+
const body = index[0]?.body as { setDigest: string; epoch: string; counter: string };
|
|
352
|
+
assert.ok(!body.setDigest.includes("+") && !body.setDigest.includes("="), "the set digest travels url-safe");
|
|
353
|
+
assert.ok(!body.epoch.includes("+") && !body.epoch.includes("="), "so does the epoch");
|
|
354
|
+
const structured = result.structuredContent as RecordStructured;
|
|
355
|
+
assert.equal(body.counter, structured.set?.counter);
|
|
356
|
+
assert.equal(structured.set?.set, "set/2");
|
|
357
|
+
assert.deepEqual(structured.set?.index, { written: BIG, pending: 0 });
|
|
358
|
+
assert.equal(structured.summary.fused, BIG);
|
|
359
|
+
assert.equal(structured.results.length, ROW_CAP, "the structured rows are capped");
|
|
360
|
+
assert.equal(structured.omitted, BIG - ROW_CAP);
|
|
361
|
+
assert.equal(pendingIndexCount(), 0);
|
|
362
|
+
const text = textOf(result);
|
|
363
|
+
assert.ok(text.includes(`set of ${BIG.toLocaleString("en-US")}`), text.slice(0, 300));
|
|
364
|
+
assert.ok(text.includes("more files in the same set"), "the markdown lists a few rows and counts the rest");
|
|
365
|
+
assert.ok(progress.some((m) => /^hashed \d+ of \d+$/.test(m)), `progress notifications arrived: ${progress.slice(0, 3).join(" | ")}`);
|
|
366
|
+
assert.ok(progress.some((m) => /^indexing \d+ of \d+$/.test(m)), "indexing reports progress too");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("evidence the site cannot index waits, blocks a new set, and is sent first once the site is back", async () => {
|
|
370
|
+
const client = await connectedClient();
|
|
371
|
+
indexMode = "fail";
|
|
372
|
+
const first = await client.callTool({ name: "bitgraph_record", arguments: { paths: [bigDir] } });
|
|
373
|
+
assert.ok(!first.isError, "the set was made; a failed index is reported, not an error");
|
|
374
|
+
const s1 = first.structuredContent as RecordStructured;
|
|
375
|
+
assert.deepEqual(s1.set?.index, { written: 0, pending: BIG });
|
|
376
|
+
assert.equal(pendingIndexCount(), BIG);
|
|
377
|
+
assert.ok(textOf(first).includes("not findable by hash"), textOf(first).slice(0, 400));
|
|
378
|
+
|
|
379
|
+
// Still down: nothing new is made, so the members cannot be made again by mistake.
|
|
380
|
+
requests.length = 0;
|
|
381
|
+
setCalls.length = 0;
|
|
382
|
+
const blocked = await client.callTool({ name: "bitgraph_record", arguments: { paths: [fileC] } });
|
|
383
|
+
assert.ok(blocked.isError);
|
|
384
|
+
assert.ok(textOf(blocked).includes("waiting to be indexed"), textOf(blocked));
|
|
385
|
+
assert.equal(setCalls.length, 0, "no set was made");
|
|
386
|
+
assert.ok(!requests.some((r) => r.path === "/api/proofs/batch"), "the ledger was not even checked");
|
|
387
|
+
assert.equal(pendingIndexCount(), BIG);
|
|
388
|
+
|
|
389
|
+
// Back: the waiting evidence goes first, then the call proceeds.
|
|
390
|
+
indexMode = "ok";
|
|
391
|
+
requests.length = 0;
|
|
392
|
+
const next = await client.callTool({ name: "bitgraph_record", arguments: { paths: [fileC] } });
|
|
393
|
+
assert.ok(!next.isError, JSON.stringify(next.content).slice(0, 400));
|
|
394
|
+
const firstBatch = requests.findIndex((r) => r.path === "/api/proofs/batch");
|
|
395
|
+
const indexCalls = requests.map((r, i) => ({ i, path: r.path })).filter((r) => r.path === "/api/fuse/set-index");
|
|
396
|
+
assert.equal(indexCalls.length, 2);
|
|
397
|
+
assert.ok(indexCalls.every((r) => r.i < firstBatch), "pending evidence is sent before the ledger check");
|
|
398
|
+
assert.equal(pendingIndexCount(), 0);
|
|
399
|
+
assert.equal((next.structuredContent as RecordStructured).summary.fused, 1);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("a set failure labels every attempted file 'not fused', never 'on record'", async () => {
|
|
403
|
+
const client = await connectedClient();
|
|
404
|
+
fuseMode = "fail";
|
|
405
|
+
try {
|
|
406
|
+
const result = await client.callTool({
|
|
407
|
+
name: "bitgraph_record",
|
|
408
|
+
arguments: { paths: [fileA, fileC], again: true },
|
|
409
|
+
});
|
|
410
|
+
assert.ok(result.isError, "a failure must be an error result");
|
|
411
|
+
const text = textOf(result);
|
|
412
|
+
assert.ok(text.includes("Nothing was BitGraphed"), text);
|
|
413
|
+
assert.ok(text.includes("the boundary is restarting"), text);
|
|
414
|
+
const structured = result.structuredContent as RecordStructured;
|
|
415
|
+
assert.equal(structured.set, null);
|
|
416
|
+
assert.ok(structured.results.every((r) => r.outcome === "not fused" && r.proof_url === null));
|
|
417
|
+
assert.deepEqual(structured.summary, { files: 2, directories: 0, fused: 0, on_record: 0, not_fused: 2 });
|
|
418
|
+
} finally {
|
|
419
|
+
fuseMode = "ok";
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("record surfaces unreadable paths before any network call", async () => {
|
|
424
|
+
const client = await connectedClient();
|
|
425
|
+
requests.length = 0;
|
|
426
|
+
setCalls.length = 0;
|
|
427
|
+
const result = await client.callTool({
|
|
428
|
+
name: "bitgraph_record",
|
|
429
|
+
arguments: { paths: [fileA, "/definitely/missing/file.bin"] },
|
|
430
|
+
});
|
|
431
|
+
assert.ok(result.isError);
|
|
432
|
+
assert.equal(requests.length, 0, "no API call happened; nothing was minted");
|
|
433
|
+
assert.equal(setCalls.length, 0);
|
|
434
|
+
const text = textOf(result);
|
|
435
|
+
assert.ok(text.includes("nothing was BitGraphed"), text);
|
|
436
|
+
assert.ok(text.includes("/definitely/missing/file.bin"), text);
|
|
214
437
|
});
|
|
215
438
|
|
|
216
439
|
test("check reports positions without ever committing", async () => {
|
|
@@ -221,15 +444,26 @@ test("check reports positions without ever committing", async () => {
|
|
|
221
444
|
arguments: { paths: [fileB], digests: [toUrlSafeB64(digestA)] },
|
|
222
445
|
});
|
|
223
446
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
224
|
-
assert.ok(!requests.some((r) => r.path === "/api/commit"), "check never commits");
|
|
447
|
+
assert.ok(!requests.some((r) => r.path === "/api/commit" || r.path.startsWith("/api/fuse/")), "check never commits");
|
|
225
448
|
const structured = result.structuredContent as {
|
|
226
|
-
results: Array<{ input: string; on_record: boolean; positions:
|
|
449
|
+
results: Array<{ input: string; on_record: boolean; positions: Array<{ member?: { index: number; count: number } }> }>;
|
|
227
450
|
};
|
|
228
451
|
const a = structured.results.find((r) => r.input === toUrlSafeB64(digestA));
|
|
229
452
|
const b = structured.results.find((r) => r.input === fileB);
|
|
230
453
|
assert.equal(a?.on_record, true);
|
|
231
454
|
assert.equal(a?.positions.length, 2);
|
|
455
|
+
assert.deepEqual(a?.positions[0]?.member, { index: 2, count: 10, role: "origin" });
|
|
232
456
|
assert.equal(b?.on_record, false);
|
|
457
|
+
assert.ok(textOf(result).includes("member 3 of 10"), textOf(result));
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("check accepts a directory", async () => {
|
|
461
|
+
const client = await connectedClient();
|
|
462
|
+
const result = await client.callTool({ name: "bitgraph_check", arguments: { paths: [dir] } });
|
|
463
|
+
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
464
|
+
const structured = result.structuredContent as { results: Array<{ input: string }>; summary: { not_on_record: number } };
|
|
465
|
+
assert.deepEqual(structured.results.map((r) => r.input), [join(dir, "a1.txt"), join(dir, "sub", "a2.txt")]);
|
|
466
|
+
assert.equal(structured.summary.not_on_record, 2);
|
|
233
467
|
});
|
|
234
468
|
|
|
235
469
|
test("check rejects malformed digests with an actionable error", async () => {
|
|
@@ -239,8 +473,7 @@ test("check rejects malformed digests with an actionable error", async () => {
|
|
|
239
473
|
arguments: { digests: ["zzz"] },
|
|
240
474
|
});
|
|
241
475
|
assert.ok(result.isError);
|
|
242
|
-
|
|
243
|
-
assert.ok(text.includes("not a base64 SHA-256 digest"));
|
|
476
|
+
assert.ok(textOf(result).includes("not a base64 SHA-256 digest"));
|
|
244
477
|
});
|
|
245
478
|
|
|
246
479
|
test("get_proof by number resolves through search and renders the window", async () => {
|
|
@@ -250,68 +483,40 @@ test("get_proof by number resolves through search and renders the window", async
|
|
|
250
483
|
arguments: { number: "#10" },
|
|
251
484
|
});
|
|
252
485
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
253
|
-
const text = (result
|
|
486
|
+
const text = textOf(result);
|
|
254
487
|
assert.ok(text.includes("# BitGraph #10"));
|
|
255
488
|
assert.ok(text.includes("BitGraphed between 2026-07-01T00:00:00.000Z"));
|
|
256
489
|
assert.ok(text.includes("Causal positions (2)"));
|
|
257
490
|
assert.ok(text.includes("/proof/"), "includes the proof page url");
|
|
258
491
|
});
|
|
259
492
|
|
|
260
|
-
test("get_proof
|
|
493
|
+
test("get_proof renders a set member's row", async () => {
|
|
261
494
|
const client = await connectedClient();
|
|
262
495
|
const result = await client.callTool({
|
|
263
496
|
name: "bitgraph_get_proof",
|
|
264
|
-
arguments: {
|
|
497
|
+
arguments: { path: fileB },
|
|
265
498
|
});
|
|
266
|
-
assert.ok(result.isError);
|
|
267
|
-
const text = (result
|
|
268
|
-
assert.ok(text.includes("
|
|
499
|
+
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
500
|
+
const text = textOf(result);
|
|
501
|
+
assert.ok(text.includes("# BitGraph #1386"), text);
|
|
502
|
+
assert.ok(text.includes("Set: member 5 of 50, as the original (container/2)"), text);
|
|
269
503
|
});
|
|
270
504
|
|
|
271
|
-
test("get_proof
|
|
505
|
+
test("get_proof for an unknown digest fails with guidance", async () => {
|
|
272
506
|
const client = await connectedClient();
|
|
273
507
|
const result = await client.callTool({
|
|
274
508
|
name: "bitgraph_get_proof",
|
|
275
|
-
arguments: { digest: toUrlSafeB64(
|
|
509
|
+
arguments: { digest: toUrlSafeB64(digestC) },
|
|
276
510
|
});
|
|
277
511
|
assert.ok(result.isError);
|
|
512
|
+
assert.ok(textOf(result).includes("Not on record"));
|
|
278
513
|
});
|
|
279
514
|
|
|
280
|
-
test("
|
|
281
|
-
const client = await connectedClient();
|
|
282
|
-
fuseMode = "fail";
|
|
283
|
-
try {
|
|
284
|
-
const result = await client.callTool({
|
|
285
|
-
name: "bitgraph_record",
|
|
286
|
-
arguments: { paths: [fileC] },
|
|
287
|
-
});
|
|
288
|
-
assert.ok(result.isError, "a failure must be an error result");
|
|
289
|
-
const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
|
|
290
|
-
assert.ok(text.includes("0 of 1"), text);
|
|
291
|
-
assert.ok(text.includes("the boundary is restarting"), text);
|
|
292
|
-
const structured = result.structuredContent as {
|
|
293
|
-
results: Array<{ outcome: string; proof_url: string | null; error?: string }>;
|
|
294
|
-
summary: { fused: number; on_record: number; not_fused: number };
|
|
295
|
-
};
|
|
296
|
-
assert.equal(structured.results[0]?.outcome, "not fused");
|
|
297
|
-
assert.equal(structured.results[0]?.proof_url, null);
|
|
298
|
-
assert.equal(structured.summary.not_fused, 1);
|
|
299
|
-
assert.equal(structured.summary.fused, 0);
|
|
300
|
-
assert.equal(structured.summary.on_record, 0);
|
|
301
|
-
} finally {
|
|
302
|
-
fuseMode = "ok";
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
test("record surfaces unreadable paths before any network call", async () => {
|
|
515
|
+
test("get_proof requires exactly one selector", async () => {
|
|
307
516
|
const client = await connectedClient();
|
|
308
|
-
requests.length = 0;
|
|
309
517
|
const result = await client.callTool({
|
|
310
|
-
name: "
|
|
311
|
-
arguments: {
|
|
518
|
+
name: "bitgraph_get_proof",
|
|
519
|
+
arguments: { digest: toUrlSafeB64(digestA), number: "10" },
|
|
312
520
|
});
|
|
313
521
|
assert.ok(result.isError);
|
|
314
|
-
assert.equal(requests.length, 0, "no API call happened; nothing was minted");
|
|
315
|
-
const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
|
|
316
|
-
assert.ok(text.includes("nothing was BitGraphed"));
|
|
317
522
|
});
|