@mikeargento/bitgraph-mcp 0.3.0 → 0.4.0
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 +265 -83
- 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 +56 -26
- 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 +45 -14
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +286 -122
- 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 +280 -96
- package/src/api.ts +36 -77
- package/src/format.ts +96 -36
- package/src/scan.ts +173 -0
- package/src/server.ts +355 -134
- 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 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,55 @@ 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
|
+
};
|
|
152
205
|
async function connectedClient(): Promise<Client> {
|
|
153
|
-
const server = buildServer({
|
|
206
|
+
const server = buildServer({ fuseSet: fakeFuseSet });
|
|
154
207
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
155
208
|
const client = new Client({ name: "test-client", version: "0.0.0" });
|
|
156
209
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
157
210
|
return client;
|
|
158
211
|
}
|
|
159
212
|
|
|
213
|
+
interface RecordStructured {
|
|
214
|
+
set: { set: string; count: number; counter: string | null; proof_url: string; index: { written: number; pending: number } | null } | null;
|
|
215
|
+
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 }>;
|
|
216
|
+
omitted: number;
|
|
217
|
+
summary: { files: number; directories: number; fused: number; on_record: number; not_fused: number };
|
|
218
|
+
}
|
|
219
|
+
const textOf = (result: unknown): string => (((result as { content?: unknown }).content ?? []) as Array<{ text: string }>)[0]?.text ?? "";
|
|
220
|
+
|
|
160
221
|
test("lists the three tools", async () => {
|
|
161
222
|
const client = await connectedClient();
|
|
162
223
|
const tools = await client.listTools();
|
|
@@ -164,53 +225,194 @@ test("lists the three tools", async () => {
|
|
|
164
225
|
assert.deepEqual(names, ["bitgraph_check", "bitgraph_get_proof", "bitgraph_record"]);
|
|
165
226
|
});
|
|
166
227
|
|
|
167
|
-
test("record
|
|
228
|
+
test("record makes ONE set of the fresh files and leaves on-record ones alone", async () => {
|
|
168
229
|
const client = await connectedClient();
|
|
169
230
|
requests.length = 0;
|
|
170
|
-
|
|
231
|
+
setCalls.length = 0;
|
|
171
232
|
const result = await client.callTool({
|
|
172
233
|
name: "bitgraph_record",
|
|
173
|
-
arguments: { paths: [fileA, fileB] },
|
|
234
|
+
arguments: { paths: [fileA, fileB, fileC] },
|
|
174
235
|
});
|
|
175
236
|
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.
|
|
237
|
+
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");
|
|
238
|
+
assert.equal(setCalls.length, 1, "one set for the whole call");
|
|
239
|
+
assert.deepEqual(setCalls[0]?.digests, [digestB, digestC], "only the fresh files are members; fileA was on record");
|
|
240
|
+
assert.deepEqual(setCalls[0]?.names, ["b.txt", "c.txt"], "members are named after the files");
|
|
241
|
+
assert.equal(setCalls[0]?.set, "set/1");
|
|
179
242
|
const batch = requests.find((r) => r.path === "/api/proofs/batch");
|
|
180
|
-
if (!batch) throw new Error("no batch check preceded the
|
|
243
|
+
if (!batch) throw new Error("no batch check preceded the set");
|
|
181
244
|
const batchBody = batch.body as { digests: string[] };
|
|
182
245
|
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);
|
|
246
|
+
const structured = result.structuredContent as RecordStructured;
|
|
247
|
+
assert.deepEqual(structured.summary, { files: 3, directories: 0, fused: 2, on_record: 1, not_fused: 0 });
|
|
248
|
+
assert.ok(structured.set, "the set is reported");
|
|
249
|
+
assert.equal(structured.set?.count, 2);
|
|
250
|
+
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
251
|
const a = structured.results.find((r) => r.path === fileA);
|
|
192
252
|
const b = structured.results.find((r) => r.path === fileB);
|
|
253
|
+
const c = structured.results.find((r) => r.path === fileC);
|
|
193
254
|
assert.equal(a?.outcome, "on record");
|
|
194
255
|
assert.equal(a?.counter, "10", "on-record outcome reports the EARLIEST position");
|
|
256
|
+
assert.equal(a?.member, 3, "an on-record set member reports its row, 1-based");
|
|
257
|
+
assert.equal(a?.member_count, 10);
|
|
195
258
|
assert.equal(b?.outcome, "fused");
|
|
196
|
-
assert.equal(b?.
|
|
197
|
-
assert.
|
|
259
|
+
assert.equal(b?.counter, structured.set?.counter, "a fused file's position is the set's");
|
|
260
|
+
assert.equal(b?.placement, "container/2", "text goes in a container");
|
|
261
|
+
assert.equal(b?.member_count, 2);
|
|
262
|
+
assert.ok(b?.member === 1 || b?.member === 2);
|
|
263
|
+
assert.notEqual(b?.member, c?.member, "two members, two rows");
|
|
264
|
+
assert.ok(b?.artifact_digest && !b.artifact_digest.includes("+"), "the member's fused digest, url-safe");
|
|
265
|
+
assert.ok(b?.proof_url?.includes(encodeURIComponent(toUrlSafeB64(digestB))), "the file's own digest finds the set");
|
|
266
|
+
const text = textOf(result);
|
|
267
|
+
assert.ok(text.startsWith("2 files BitGraphed as one set at #"), text);
|
|
268
|
+
assert.ok(text.includes("set of 2"), text);
|
|
198
269
|
});
|
|
199
270
|
|
|
200
|
-
test("record with again=true
|
|
271
|
+
test("record with again=true puts on-record files in the set", async () => {
|
|
201
272
|
const client = await connectedClient();
|
|
202
|
-
|
|
273
|
+
setCalls.length = 0;
|
|
203
274
|
const result = await client.callTool({
|
|
204
275
|
name: "bitgraph_record",
|
|
205
276
|
arguments: { paths: [fileA], again: true },
|
|
206
277
|
});
|
|
207
278
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
208
|
-
assert.deepEqual(
|
|
209
|
-
const structured = result.structuredContent as
|
|
210
|
-
results: Array<{ outcome: string; total_positions: number }>;
|
|
211
|
-
};
|
|
279
|
+
assert.deepEqual(setCalls.map((c) => c.digests), [[digestA]]);
|
|
280
|
+
const structured = result.structuredContent as RecordStructured;
|
|
212
281
|
assert.equal(structured.results[0]?.outcome, "fused");
|
|
213
|
-
assert.equal(structured.results[0]?.total_positions, 3, "two prior positions plus the new
|
|
282
|
+
assert.equal(structured.results[0]?.total_positions, 3, "two prior positions plus the new set");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a directory is its regular files, hidden entries and links left out", async () => {
|
|
286
|
+
const client = await connectedClient();
|
|
287
|
+
setCalls.length = 0;
|
|
288
|
+
const result = await client.callTool({
|
|
289
|
+
name: "bitgraph_record",
|
|
290
|
+
arguments: { paths: [dir] },
|
|
291
|
+
});
|
|
292
|
+
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
293
|
+
assert.deepEqual(setCalls[0]?.names, ["a1.txt", "a2.txt"]);
|
|
294
|
+
const structured = result.structuredContent as RecordStructured;
|
|
295
|
+
assert.deepEqual(structured.summary, { files: 2, directories: 1, fused: 2, on_record: 0, not_fused: 0 });
|
|
296
|
+
assert.equal(structured.set?.count, 2);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("the same bytes under two paths are one member, reported twice", async () => {
|
|
300
|
+
const client = await connectedClient();
|
|
301
|
+
setCalls.length = 0;
|
|
302
|
+
const result = await client.callTool({
|
|
303
|
+
name: "bitgraph_record",
|
|
304
|
+
arguments: { paths: [fileB, copyB] },
|
|
305
|
+
});
|
|
306
|
+
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
307
|
+
assert.deepEqual(setCalls[0]?.digests, [digestB]);
|
|
308
|
+
const structured = result.structuredContent as RecordStructured;
|
|
309
|
+
assert.equal(structured.set?.count, 1);
|
|
310
|
+
assert.equal(structured.summary.fused, 2);
|
|
311
|
+
assert.equal(structured.results[0]?.artifact_digest, structured.results[1]?.artifact_digest);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("above the set/1 cap the set is a set/2 and its evidence is indexed in chunks", async () => {
|
|
315
|
+
const client = await connectedClient();
|
|
316
|
+
requests.length = 0;
|
|
317
|
+
setCalls.length = 0;
|
|
318
|
+
indexMode = "ok";
|
|
319
|
+
const progress: string[] = [];
|
|
320
|
+
const result = await client.callTool({ name: "bitgraph_record", arguments: { paths: [bigDir] } }, undefined, {
|
|
321
|
+
onprogress: (p) => {
|
|
322
|
+
if (p.message !== undefined) progress.push(p.message);
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
assert.ok(!result.isError, JSON.stringify(result.content).slice(0, 500));
|
|
326
|
+
assert.equal(setCalls[0]?.set, "set/2");
|
|
327
|
+
assert.equal(setCalls[0]?.digests.length, BIG);
|
|
328
|
+
const index = requests.filter((r) => r.path === "/api/fuse/set-index");
|
|
329
|
+
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");
|
|
330
|
+
const body = index[0]?.body as { setDigest: string; epoch: string; counter: string };
|
|
331
|
+
assert.ok(!body.setDigest.includes("+") && !body.setDigest.includes("="), "the set digest travels url-safe");
|
|
332
|
+
assert.ok(!body.epoch.includes("+") && !body.epoch.includes("="), "so does the epoch");
|
|
333
|
+
const structured = result.structuredContent as RecordStructured;
|
|
334
|
+
assert.equal(body.counter, structured.set?.counter);
|
|
335
|
+
assert.equal(structured.set?.set, "set/2");
|
|
336
|
+
assert.deepEqual(structured.set?.index, { written: BIG, pending: 0 });
|
|
337
|
+
assert.equal(structured.summary.fused, BIG);
|
|
338
|
+
assert.equal(structured.results.length, ROW_CAP, "the structured rows are capped");
|
|
339
|
+
assert.equal(structured.omitted, BIG - ROW_CAP);
|
|
340
|
+
assert.equal(pendingIndexCount(), 0);
|
|
341
|
+
const text = textOf(result);
|
|
342
|
+
assert.ok(text.includes(`set of ${BIG.toLocaleString("en-US")}`), text.slice(0, 300));
|
|
343
|
+
assert.ok(text.includes("more files in the same set"), "the markdown lists a few rows and counts the rest");
|
|
344
|
+
assert.ok(progress.some((m) => /^hashed \d+ of \d+$/.test(m)), `progress notifications arrived: ${progress.slice(0, 3).join(" | ")}`);
|
|
345
|
+
assert.ok(progress.some((m) => /^indexing \d+ of \d+$/.test(m)), "indexing reports progress too");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("evidence the site cannot index waits, blocks a new set, and is sent first once the site is back", async () => {
|
|
349
|
+
const client = await connectedClient();
|
|
350
|
+
indexMode = "fail";
|
|
351
|
+
const first = await client.callTool({ name: "bitgraph_record", arguments: { paths: [bigDir] } });
|
|
352
|
+
assert.ok(!first.isError, "the set was made; a failed index is reported, not an error");
|
|
353
|
+
const s1 = first.structuredContent as RecordStructured;
|
|
354
|
+
assert.deepEqual(s1.set?.index, { written: 0, pending: BIG });
|
|
355
|
+
assert.equal(pendingIndexCount(), BIG);
|
|
356
|
+
assert.ok(textOf(first).includes("not findable by hash"), textOf(first).slice(0, 400));
|
|
357
|
+
|
|
358
|
+
// Still down: nothing new is made, so the members cannot be made again by mistake.
|
|
359
|
+
requests.length = 0;
|
|
360
|
+
setCalls.length = 0;
|
|
361
|
+
const blocked = await client.callTool({ name: "bitgraph_record", arguments: { paths: [fileC] } });
|
|
362
|
+
assert.ok(blocked.isError);
|
|
363
|
+
assert.ok(textOf(blocked).includes("waiting to be indexed"), textOf(blocked));
|
|
364
|
+
assert.equal(setCalls.length, 0, "no set was made");
|
|
365
|
+
assert.ok(!requests.some((r) => r.path === "/api/proofs/batch"), "the ledger was not even checked");
|
|
366
|
+
assert.equal(pendingIndexCount(), BIG);
|
|
367
|
+
|
|
368
|
+
// Back: the waiting evidence goes first, then the call proceeds.
|
|
369
|
+
indexMode = "ok";
|
|
370
|
+
requests.length = 0;
|
|
371
|
+
const next = await client.callTool({ name: "bitgraph_record", arguments: { paths: [fileC] } });
|
|
372
|
+
assert.ok(!next.isError, JSON.stringify(next.content).slice(0, 400));
|
|
373
|
+
const firstBatch = requests.findIndex((r) => r.path === "/api/proofs/batch");
|
|
374
|
+
const indexCalls = requests.map((r, i) => ({ i, path: r.path })).filter((r) => r.path === "/api/fuse/set-index");
|
|
375
|
+
assert.equal(indexCalls.length, 2);
|
|
376
|
+
assert.ok(indexCalls.every((r) => r.i < firstBatch), "pending evidence is sent before the ledger check");
|
|
377
|
+
assert.equal(pendingIndexCount(), 0);
|
|
378
|
+
assert.equal((next.structuredContent as RecordStructured).summary.fused, 1);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("a set failure labels every attempted file 'not fused', never 'on record'", async () => {
|
|
382
|
+
const client = await connectedClient();
|
|
383
|
+
fuseMode = "fail";
|
|
384
|
+
try {
|
|
385
|
+
const result = await client.callTool({
|
|
386
|
+
name: "bitgraph_record",
|
|
387
|
+
arguments: { paths: [fileA, fileC], again: true },
|
|
388
|
+
});
|
|
389
|
+
assert.ok(result.isError, "a failure must be an error result");
|
|
390
|
+
const text = textOf(result);
|
|
391
|
+
assert.ok(text.includes("Nothing was BitGraphed"), text);
|
|
392
|
+
assert.ok(text.includes("the boundary is restarting"), text);
|
|
393
|
+
const structured = result.structuredContent as RecordStructured;
|
|
394
|
+
assert.equal(structured.set, null);
|
|
395
|
+
assert.ok(structured.results.every((r) => r.outcome === "not fused" && r.proof_url === null));
|
|
396
|
+
assert.deepEqual(structured.summary, { files: 2, directories: 0, fused: 0, on_record: 0, not_fused: 2 });
|
|
397
|
+
} finally {
|
|
398
|
+
fuseMode = "ok";
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("record surfaces unreadable paths before any network call", async () => {
|
|
403
|
+
const client = await connectedClient();
|
|
404
|
+
requests.length = 0;
|
|
405
|
+
setCalls.length = 0;
|
|
406
|
+
const result = await client.callTool({
|
|
407
|
+
name: "bitgraph_record",
|
|
408
|
+
arguments: { paths: [fileA, "/definitely/missing/file.bin"] },
|
|
409
|
+
});
|
|
410
|
+
assert.ok(result.isError);
|
|
411
|
+
assert.equal(requests.length, 0, "no API call happened; nothing was minted");
|
|
412
|
+
assert.equal(setCalls.length, 0);
|
|
413
|
+
const text = textOf(result);
|
|
414
|
+
assert.ok(text.includes("nothing was BitGraphed"), text);
|
|
415
|
+
assert.ok(text.includes("/definitely/missing/file.bin"), text);
|
|
214
416
|
});
|
|
215
417
|
|
|
216
418
|
test("check reports positions without ever committing", async () => {
|
|
@@ -221,15 +423,26 @@ test("check reports positions without ever committing", async () => {
|
|
|
221
423
|
arguments: { paths: [fileB], digests: [toUrlSafeB64(digestA)] },
|
|
222
424
|
});
|
|
223
425
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
224
|
-
assert.ok(!requests.some((r) => r.path === "/api/commit"), "check never commits");
|
|
426
|
+
assert.ok(!requests.some((r) => r.path === "/api/commit" || r.path.startsWith("/api/fuse/")), "check never commits");
|
|
225
427
|
const structured = result.structuredContent as {
|
|
226
|
-
results: Array<{ input: string; on_record: boolean; positions:
|
|
428
|
+
results: Array<{ input: string; on_record: boolean; positions: Array<{ member?: { index: number; count: number } }> }>;
|
|
227
429
|
};
|
|
228
430
|
const a = structured.results.find((r) => r.input === toUrlSafeB64(digestA));
|
|
229
431
|
const b = structured.results.find((r) => r.input === fileB);
|
|
230
432
|
assert.equal(a?.on_record, true);
|
|
231
433
|
assert.equal(a?.positions.length, 2);
|
|
434
|
+
assert.deepEqual(a?.positions[0]?.member, { index: 2, count: 10, role: "origin" });
|
|
232
435
|
assert.equal(b?.on_record, false);
|
|
436
|
+
assert.ok(textOf(result).includes("member 3 of 10"), textOf(result));
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("check accepts a directory", async () => {
|
|
440
|
+
const client = await connectedClient();
|
|
441
|
+
const result = await client.callTool({ name: "bitgraph_check", arguments: { paths: [dir] } });
|
|
442
|
+
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
443
|
+
const structured = result.structuredContent as { results: Array<{ input: string }>; summary: { not_on_record: number } };
|
|
444
|
+
assert.deepEqual(structured.results.map((r) => r.input), [join(dir, "a1.txt"), join(dir, "sub", "a2.txt")]);
|
|
445
|
+
assert.equal(structured.summary.not_on_record, 2);
|
|
233
446
|
});
|
|
234
447
|
|
|
235
448
|
test("check rejects malformed digests with an actionable error", async () => {
|
|
@@ -239,8 +452,7 @@ test("check rejects malformed digests with an actionable error", async () => {
|
|
|
239
452
|
arguments: { digests: ["zzz"] },
|
|
240
453
|
});
|
|
241
454
|
assert.ok(result.isError);
|
|
242
|
-
|
|
243
|
-
assert.ok(text.includes("not a base64 SHA-256 digest"));
|
|
455
|
+
assert.ok(textOf(result).includes("not a base64 SHA-256 digest"));
|
|
244
456
|
});
|
|
245
457
|
|
|
246
458
|
test("get_proof by number resolves through search and renders the window", async () => {
|
|
@@ -250,68 +462,40 @@ test("get_proof by number resolves through search and renders the window", async
|
|
|
250
462
|
arguments: { number: "#10" },
|
|
251
463
|
});
|
|
252
464
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
253
|
-
const text = (result
|
|
465
|
+
const text = textOf(result);
|
|
254
466
|
assert.ok(text.includes("# BitGraph #10"));
|
|
255
467
|
assert.ok(text.includes("BitGraphed between 2026-07-01T00:00:00.000Z"));
|
|
256
468
|
assert.ok(text.includes("Causal positions (2)"));
|
|
257
469
|
assert.ok(text.includes("/proof/"), "includes the proof page url");
|
|
258
470
|
});
|
|
259
471
|
|
|
260
|
-
test("get_proof
|
|
472
|
+
test("get_proof renders a set member's row", async () => {
|
|
261
473
|
const client = await connectedClient();
|
|
262
474
|
const result = await client.callTool({
|
|
263
475
|
name: "bitgraph_get_proof",
|
|
264
|
-
arguments: {
|
|
476
|
+
arguments: { path: fileB },
|
|
265
477
|
});
|
|
266
|
-
assert.ok(result.isError);
|
|
267
|
-
const text = (result
|
|
268
|
-
assert.ok(text.includes("
|
|
478
|
+
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
479
|
+
const text = textOf(result);
|
|
480
|
+
assert.ok(text.includes("# BitGraph #1386"), text);
|
|
481
|
+
assert.ok(text.includes("Set: member 5 of 50, as the original (container/2)"), text);
|
|
269
482
|
});
|
|
270
483
|
|
|
271
|
-
test("get_proof
|
|
484
|
+
test("get_proof for an unknown digest fails with guidance", async () => {
|
|
272
485
|
const client = await connectedClient();
|
|
273
486
|
const result = await client.callTool({
|
|
274
487
|
name: "bitgraph_get_proof",
|
|
275
|
-
arguments: { digest: toUrlSafeB64(
|
|
488
|
+
arguments: { digest: toUrlSafeB64(digestC) },
|
|
276
489
|
});
|
|
277
490
|
assert.ok(result.isError);
|
|
491
|
+
assert.ok(textOf(result).includes("Not on record"));
|
|
278
492
|
});
|
|
279
493
|
|
|
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 () => {
|
|
494
|
+
test("get_proof requires exactly one selector", async () => {
|
|
307
495
|
const client = await connectedClient();
|
|
308
|
-
requests.length = 0;
|
|
309
496
|
const result = await client.callTool({
|
|
310
|
-
name: "
|
|
311
|
-
arguments: {
|
|
497
|
+
name: "bitgraph_get_proof",
|
|
498
|
+
arguments: { digest: toUrlSafeB64(digestA), number: "10" },
|
|
312
499
|
});
|
|
313
500
|
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
501
|
});
|