@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/src/api.ts CHANGED
@@ -3,17 +3,15 @@
3
3
  /**
4
4
  * @mikeargento/bitgraph-mcp: HTTP client for the hosted BitGraph API.
5
5
  *
6
- * All recording goes through the website's /api/commit proxy rather than the
7
- * TEE directly: the proxy is what maintains the per-position by-digest index
8
- * that makes every causal position of a file discoverable afterward.
6
+ * The reads: the batch lookup, the proof detail, the number search. Making a
7
+ * BitGraph goes through the core package's own transport against the same
8
+ * site (its /api/fuse routes), which is what maintains the per-position
9
+ * by-digest index that makes every member of a set discoverable afterward;
10
+ * the one write here is a set/2's member evidence, indexed after the commit.
9
11
  */
10
12
 
11
- import type {
12
- BatchCheckResponse,
13
- BitGraphProof,
14
- ProofDetailResponse,
15
- SearchResponse,
16
- } from "./types.js";
13
+ import { mapConcurrent } from "./encoding.js";
14
+ import type { BatchCheckResponse, ProofDetailResponse, SearchResponse, SetIndexResponse } from "./types.js";
17
15
 
18
16
  export interface ApiConfig {
19
17
  baseUrl: string;
@@ -27,13 +25,13 @@ export function configFromEnv(): ApiConfig {
27
25
  return apiKey ? { baseUrl, apiKey } : { baseUrl };
28
26
  }
29
27
 
30
- /** Matches the website client: 50 digests per commit request (~1s of TEE work each). */
31
- export const COMMIT_CHUNK_SIZE = 50;
32
28
  /** Matches the batch endpoint's MAX_DIGESTS. */
33
29
  export const BATCH_CHECK_LIMIT = 500;
30
+ /** Batch lookups in flight at once: a large folder is many pages. */
31
+ const BATCH_CHECK_CONCURRENCY = 4;
34
32
 
35
33
  const CHECK_TIMEOUT_MS = 30_000;
36
- const COMMIT_TIMEOUT_MS = 120_000;
34
+ const SET_INDEX_TIMEOUT_MS = 60_000;
37
35
 
38
36
  export class ApiError extends Error {
39
37
  readonly status: number;
@@ -104,83 +102,44 @@ async function postJson<T>(
104
102
 
105
103
  /**
106
104
  * Look up which digests are on record. Input digests must be URL-safe base64;
107
- * the response is keyed by the exact strings sent. Batches of up to 500.
105
+ * the response is keyed by the exact strings sent. Pages of up to 500, a few
106
+ * in flight at once.
108
107
  */
109
108
  export async function batchCheck(
110
109
  config: ApiConfig,
111
110
  urlSafeDigests: readonly string[]
112
111
  ): Promise<BatchCheckResponse> {
113
- const merged: BatchCheckResponse = { results: {} };
112
+ const chunks: string[][] = [];
114
113
  for (let offset = 0; offset < urlSafeDigests.length; offset += BATCH_CHECK_LIMIT) {
115
- const chunk = urlSafeDigests.slice(offset, offset + BATCH_CHECK_LIMIT);
116
- const page = await postJson<BatchCheckResponse>(
117
- config,
118
- "/api/proofs/batch",
119
- { digests: chunk },
120
- CHECK_TIMEOUT_MS,
121
- false
122
- );
123
- Object.assign(merged.results, page.results);
114
+ chunks.push(urlSafeDigests.slice(offset, offset + BATCH_CHECK_LIMIT));
124
115
  }
116
+ const pages = await mapConcurrent(chunks, BATCH_CHECK_CONCURRENCY, (chunk) =>
117
+ postJson<BatchCheckResponse>(config, "/api/proofs/batch", { digests: chunk }, CHECK_TIMEOUT_MS, false)
118
+ );
119
+ const merged: BatchCheckResponse = { results: {} };
120
+ for (const page of pages) Object.assign(merged.results, page.results);
125
121
  return merged;
126
122
  }
127
123
 
128
- /**
129
- * Record digests at new causal positions. Input digests must be STANDARD
130
- * base64 (as stored in proofs). Commits sequentially in chunks of 50, matching
131
- * the website client; the TEE serializes commits anyway.
132
- *
133
- * On a mid-batch failure the error carries how many proofs were already
134
- * minted (those are permanent); the caller must report partial results
135
- * honestly rather than pretending all-or-nothing.
136
- */
137
- export async function commitDigests(
138
- config: ApiConfig,
139
- standardDigests: readonly string[],
140
- attribution?: { name?: string | undefined; title?: string | undefined; message?: string | undefined }
141
- ): Promise<BitGraphProof[]> {
142
- const proofs: BitGraphProof[] = [];
143
- for (let offset = 0; offset < standardDigests.length; offset += COMMIT_CHUNK_SIZE) {
144
- const chunk = standardDigests.slice(offset, offset + COMMIT_CHUNK_SIZE);
145
- const body: Record<string, unknown> = {
146
- digests: chunk.map((digestB64) => ({ digestB64, hashAlg: "sha256" })),
147
- chainId: "bitgraph:main",
148
- };
149
- if (attribution) body["attribution"] = attribution;
150
- try {
151
- const raw = await postJson<BitGraphProof[] | BitGraphProof>(
152
- config,
153
- "/api/commit",
154
- body,
155
- COMMIT_TIMEOUT_MS,
156
- true
157
- );
158
- proofs.push(...(Array.isArray(raw) ? raw : [raw]));
159
- } catch (err) {
160
- throw new PartialCommitError(proofs, standardDigests.length, err);
161
- }
162
- }
163
- return proofs;
124
+ /** One request's worth of a set/2's member evidence, for the site to index. */
125
+ export interface SetIndexRequest {
126
+ /** The set proof's artifact digest, URL-safe base64. */
127
+ setDigest: string;
128
+ /** The set proof's epoch id, URL-safe base64. */
129
+ epoch: string;
130
+ /** The set proof's commit counter. */
131
+ counter: string;
132
+ members: unknown[];
164
133
  }
165
134
 
166
- /** A commit batch failed partway: `minted` proofs are already permanent. */
167
- export class PartialCommitError extends Error {
168
- readonly minted: BitGraphProof[];
169
- readonly requested: number;
170
- readonly cause2: unknown;
171
-
172
- constructor(minted: BitGraphProof[], requested: number, cause: unknown) {
173
- const reason = cause instanceof Error ? cause.message : String(cause);
174
- super(
175
- `Recording stopped after ${minted.length} of ${requested} digests: ${reason}` +
176
- (cause instanceof ApiError && cause.retryAfterSec !== null
177
- ? ` (retry after ${cause.retryAfterSec}s)`
178
- : "")
179
- );
180
- this.minted = minted;
181
- this.requested = requested;
182
- this.cause2 = cause;
183
- }
135
+ /**
136
+ * Index members of a set/2 from their evidence. The site reads the set proof
137
+ * from its own position, binds the root document, and checks every member's
138
+ * path before writing a key; a row that does not bind is rejected, never
139
+ * written.
140
+ */
141
+ export async function indexSetMembers(config: ApiConfig, body: SetIndexRequest): Promise<SetIndexResponse> {
142
+ return postJson<SetIndexResponse>(config, "/api/fuse/set-index", body, SET_INDEX_TIMEOUT_MS, false);
184
143
  }
185
144
 
186
145
  /** Full detail for one digest: proof, all causal positions, anchor window. */
package/src/format.ts CHANGED
@@ -9,9 +9,11 @@
9
9
  */
10
10
 
11
11
  import { toUrlSafeB64 } from "./encoding.js";
12
- import type { BitGraphProof, PositionView, ProofDetailResponse } from "./types.js";
12
+ import type { BitGraphProof, PositionView, ProofDetailResponse, SetMemberView } from "./types.js";
13
13
 
14
14
  export const CHARACTER_LIMIT = 25_000;
15
+ /** Rows per group a markdown summary lists before "and N more". */
16
+ export const MARKDOWN_ROWS = 50;
15
17
 
16
18
  /** Public proof page URL for a digest, optionally pinned to one causal position. */
17
19
  export function proofUrl(
@@ -31,32 +33,54 @@ export function proofUrl(
31
33
  }
32
34
 
33
35
  /**
34
- * One outcome per path, in the product's own vocabulary. "fused": a new fused
35
- * artifact was built from the file and committed under its own slot. "on
36
- * record": the bytes already had a recording or a fused artifact naming them
37
- * as origin, and nothing was minted. "not fused": the attempt failed; never
38
- * claim "on record" for bytes that have no proof.
36
+ * One outcome per path, in the product's own vocabulary. "fused": the file is
37
+ * a member of the set just made, its new fused bytes listed by digest in the
38
+ * committed artifact. "on record": the bytes already had a recording or a
39
+ * fused artifact naming them as origin, and nothing was made. "not fused":
40
+ * the attempt failed or the file was left out; never claim "on record" for
41
+ * bytes that have no proof.
39
42
  */
40
43
  export interface RecordOutcome {
41
44
  path: string;
42
- /** The file's own digest (URL-safe): the origin of the fused artifact. */
45
+ /** The file's own digest (URL-safe): the origin of its fused bytes. */
43
46
  digest: string;
44
47
  outcome: "fused" | "on record" | "not fused";
45
- /** The fused artifact's digest (URL-safe), present on a "fused" outcome. */
48
+ /** The member's fused digest (URL-safe), present on a "fused" outcome. */
46
49
  artifact_digest: string | null;
47
50
  placement: string | null;
48
51
  counter: string | null;
49
52
  epoch: string | null; // URL-safe
53
+ /** The file's row in the set just made, 1-based, of member_count. */
54
+ member: number | null;
55
+ member_count: number | null;
50
56
  total_positions: number;
51
57
  proof_url: string | null;
52
58
  error?: string;
53
59
  }
54
60
 
61
+ /** The one BitGraph a record call makes: a set, one position for every fused row. */
62
+ export interface SetOutcome {
63
+ /** "set/1": the committed artifact lists every member. "set/2": it is a Merkle root over the rows, and each member's evidence is indexed on the site. */
64
+ set: "set/1" | "set/2";
65
+ count: number;
66
+ counter: string | null;
67
+ epoch: string | null; // URL-safe
68
+ /** The committed artifact's digest (URL-safe): the manifest or the root document. */
69
+ artifact_digest: string;
70
+ proof_url: string;
71
+ /** True when the boundary echoed the committed artifact in the proof's metadata, so the ledger's copy carries it. */
72
+ manifest_echoed: boolean;
73
+ /** True when the commit response was lost and the proof was read back by digest. */
74
+ recovered: boolean;
75
+ /** set/2 only: members whose evidence the site has indexed, and members still waiting. */
76
+ index: { written: number; pending: number } | null;
77
+ }
78
+
55
79
  export interface CheckOutcome {
56
80
  input: string;
57
81
  digest: string; // URL-safe
58
82
  on_record: boolean;
59
- positions: Array<{ counter: string | null; epoch: string | null }>;
83
+ positions: Array<{ counter: string | null; epoch: string | null; member?: SetMemberView }>;
60
84
  proof_url: string | null;
61
85
  }
62
86
 
@@ -66,56 +90,87 @@ export function positionOf(proof: BitGraphProof): { counter: string | null; epoc
66
90
  return { counter, epoch: epochId !== undefined ? toUrlSafeB64(epochId) : null };
67
91
  }
68
92
 
69
- export function renderRecordMarkdown(outcomes: readonly RecordOutcome[]): string {
93
+ const fmt = (n: number): string => n.toLocaleString("en-US");
94
+
95
+ function memberNote(m: SetMemberView | undefined): string {
96
+ return m ? ` (member ${fmt(m.index + 1)} of ${fmt(m.count)})` : "";
97
+ }
98
+
99
+ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: SetOutcome | null = null, omitted = 0): string {
70
100
  const fused = outcomes.filter((o) => o.outcome === "fused");
71
101
  const onRecord = outcomes.filter((o) => o.outcome === "on record");
72
102
  const notFused = outcomes.filter((o) => o.outcome === "not fused");
73
103
  const lines: string[] = [];
74
- let headline = `${fused.length} fused, ${onRecord.length} already on record.`;
75
- if (notFused.length > 0) {
76
- headline = `${fused.length} fused, ${onRecord.length} already on record, ${notFused.length} NOT fused.`;
104
+ const parts: string[] = [];
105
+ if (set !== null && fused.length > 0) {
106
+ parts.push(`${fmt(fused.length)} file${fused.length === 1 ? "" : "s"} BitGraphed as one set at #${set.counter ?? "?"} (set of ${fmt(set.count)})`);
107
+ } else {
108
+ parts.push(`${fmt(fused.length)} fused`);
77
109
  }
78
- lines.push(headline);
79
- for (const o of outcomes) {
80
- if (o.outcome === "not fused") {
81
- lines.push(`- not fused · ${o.path}${o.error ? `: ${o.error}` : ""}`);
82
- continue;
110
+ parts.push(`${fmt(onRecord.length)} already on record`);
111
+ if (notFused.length > 0) parts.push(`${fmt(notFused.length)} NOT fused`);
112
+ lines.push(`${parts.join(", ")}.`);
113
+ if (set !== null && fused.length > 0) {
114
+ lines.push(`- #${set.counter ?? "?"} · set of ${fmt(set.count)} · ${set.proof_url}`);
115
+ if (set.index !== null && set.index.pending > 0) {
116
+ lines.push(` The set is on the ledger, but the evidence for ${fmt(set.index.pending)} of its ${fmt(set.count)} members is not indexed yet, so those files are not findable by hash until it is. It is sent again at the start of the next bitgraph_record call.`);
117
+ }
118
+ if (!set.manifest_echoed) {
119
+ lines.push(` The boundary did not echo the committed artifact; the ledger's copy of this proof carries no member list. Keep the set's proof page.`);
83
120
  }
84
- const note =
85
- o.outcome === "on record"
86
- ? o.total_positions > 1
87
- ? ` (${o.total_positions} positions, earliest shown)`
88
- : ""
89
- : o.placement
90
- ? ` (${o.placement})`
91
- : "";
92
- lines.push(`- ${o.outcome} · #${o.counter ?? "?"} · ${o.path}${note}\n ${o.proof_url}`);
93
121
  }
122
+ const group = (rows: readonly RecordOutcome[], render: (o: RecordOutcome) => string, more: (n: number) => string) => {
123
+ for (const o of rows.slice(0, MARKDOWN_ROWS)) lines.push(render(o));
124
+ if (rows.length > MARKDOWN_ROWS) lines.push(`- ${more(rows.length - MARKDOWN_ROWS)}`);
125
+ };
126
+ group(
127
+ notFused,
128
+ (o) => `- not fused · ${o.path}${o.error ? `: ${o.error}` : ""}`,
129
+ (n) => `and ${fmt(n)} more not fused`
130
+ );
131
+ group(
132
+ onRecord,
133
+ (o) => {
134
+ const note = o.total_positions > 1 ? ` (${fmt(o.total_positions)} positions, earliest shown)` : "";
135
+ const row = o.member !== null && o.member_count !== null ? ` (member ${fmt(o.member)} of ${fmt(o.member_count)})` : "";
136
+ return `- on record · #${o.counter ?? "?"}${row} · ${o.path}${note}\n ${o.proof_url}`;
137
+ },
138
+ (n) => `and ${fmt(n)} more already on record`
139
+ );
140
+ group(
141
+ fused,
142
+ (o) => `- fused · ${o.path} (${fmt(o.member ?? 0)} of ${fmt(o.member_count ?? 0)}, ${o.placement ?? "?"})`,
143
+ (n) => `and ${fmt(n)} more files in the same set`
144
+ );
94
145
  if (fused.length > 0) {
95
146
  lines.push(
96
- "\nEach fused artifact was built in memory from the file, hashed and committed under its own slot; the file itself is unchanged and was not uploaded. The original plus the proof rebuilds the fused bytes; the Frame for each is in the structured result."
147
+ "\nOne BitGraph holds every file made here: one slot, one position, and the committed artifact lists each file's new fused bytes by digest. Those bytes were hashed on this machine and never written or uploaded; the file itself is unchanged, and the original plus the set proof rebuilds them. A lookup by any file's own digest finds the set."
97
148
  );
98
149
  }
99
150
  if (onRecord.length > 0) {
100
151
  lines.push(
101
- "\nFiles already on record were left alone. To make a new fused artifact from one of them deliberately, call bitgraph_record with again=true."
152
+ "\nFiles already on record were left alone. To make a new BitGraph of them deliberately, call bitgraph_record with again=true."
102
153
  );
103
154
  }
155
+ if (omitted > 0) {
156
+ lines.push(`\nThe structured result lists the first rows only (${fmt(omitted)} omitted); every fused file shares the set's position above.`);
157
+ }
104
158
  return lines.join("\n");
105
159
  }
106
160
 
107
161
  export function renderCheckMarkdown(outcomes: readonly CheckOutcome[]): string {
108
162
  const found = outcomes.filter((o) => o.on_record).length;
109
- const lines: string[] = [`${found} of ${outcomes.length} on record.`];
110
- for (const o of outcomes) {
163
+ const lines: string[] = [`${fmt(found)} of ${fmt(outcomes.length)} on record.`];
164
+ for (const o of outcomes.slice(0, MARKDOWN_ROWS)) {
111
165
  if (o.on_record) {
112
166
  const first = o.positions[0];
113
- const extra = o.positions.length > 1 ? ` and ${o.positions.length - 1} more position(s)` : "";
114
- lines.push(`- on record · #${first?.counter ?? "?"}${extra} · ${o.input}\n ${o.proof_url}`);
167
+ const extra = o.positions.length > 1 ? ` and ${fmt(o.positions.length - 1)} more position(s)` : "";
168
+ lines.push(`- on record · #${first?.counter ?? "?"}${memberNote(first?.member)}${extra} · ${o.input}\n ${o.proof_url}`);
115
169
  } else {
116
170
  lines.push(`- not on record · ${o.input}`);
117
171
  }
118
172
  }
173
+ if (outcomes.length > MARKDOWN_ROWS) lines.push(`- and ${fmt(outcomes.length - MARKDOWN_ROWS)} more; the structured result lists every one`);
119
174
  return lines.join("\n");
120
175
  }
121
176
 
@@ -142,11 +197,17 @@ export function renderProofMarkdown(
142
197
  if (!proof) return "No proof found for that digest.";
143
198
  const digest = proof.artifact?.digestB64 ?? "";
144
199
  const { counter, epoch } = positionOf(proof);
200
+ const positions: PositionView[] = detail.positions ?? [];
201
+ const here = positions.find((p) => p.counter === counter) ?? positions[0];
145
202
  const lines: string[] = [];
146
203
  lines.push(`# BitGraph #${counter ?? "?"}`);
147
204
  lines.push("");
148
205
  lines.push(`- Digest (SHA-256): ${toUrlSafeB64(digest)}`);
149
206
  if (epoch) lines.push(`- Position: counter ${counter ?? "?"} in epoch ${epoch.slice(0, 12)}…`);
207
+ if (here?.member) {
208
+ const role = here.member.role === "fused" ? "its new fused bytes" : "the original";
209
+ lines.push(`- Set: member ${fmt(here.member.index + 1)} of ${fmt(here.member.count)}, as ${role}${here.placement ? ` (${here.placement})` : ""}`);
210
+ }
150
211
  const window = renderWindow(detail);
151
212
  if (window) lines.push(`- ${window}`);
152
213
  const etherscan =
@@ -162,15 +223,14 @@ export function renderProofMarkdown(
162
223
  .join(": ");
163
224
  lines.push(`- Submitter's note (self-attributed, not verified): ${note}`);
164
225
  }
165
- const positions: PositionView[] = detail.positions ?? [];
166
226
  if (positions.length > 1) {
167
227
  lines.push("");
168
- lines.push(`## Causal positions (${positions.length})`);
228
+ lines.push(`## Causal positions (${fmt(positions.length)})`);
169
229
  positions.forEach((p, i) => {
170
230
  const label = i === 0 ? " · original" : "";
171
231
  const bracket =
172
232
  p.lowerTime && p.upperTime ? ` · between ${p.lowerTime} and ${p.upperTime}` : "";
173
- lines.push(`- #${p.counter ?? "?"}${label}${bracket}`);
233
+ lines.push(`- #${p.counter ?? "?"}${label}${p.member ? ` · set of ${fmt(p.member.count)}` : ""}${bracket}`);
174
234
  });
175
235
  }
176
236
  lines.push("");
package/src/scan.ts ADDED
@@ -0,0 +1,173 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * @mikeargento/bitgraph-mcp: the scan.
5
+ *
6
+ * One pass over each file yields its SHA-256 (the member's origin) and a
7
+ * hasher left open after the placement's prefix and the file's last byte, so
8
+ * the member's fused digest can be finished later, for whatever slot the set
9
+ * is made under, without reading the file again. A placement is prefix,
10
+ * original, suffix (its frame): trailer/1 has no prefix and container/2's is
11
+ * the original's tar header, which depends on the size alone; the suffix
12
+ * carries the commitment and is hashed at BitGraph time. Node's SHA-256 is
13
+ * the platform's own, and a Hash can be copied mid-stream, which is the whole
14
+ * trick: the copy takes the suffix, so no file is read twice or held in
15
+ * memory, and the original is never touched.
16
+ */
17
+
18
+ import { createHash, type Hash } from "node:crypto";
19
+ import { createReadStream } from "node:fs";
20
+ import { readdir, stat } from "node:fs/promises";
21
+ import { basename, join } from "node:path";
22
+ import { placementForBytes } from "@mikeargento/bitgraph";
23
+ import { getPlacement } from "@mikeargento/bitgraph-verify";
24
+
25
+ /** The placements the scan makes: chosen from the bytes by the core, never from the name. */
26
+ export type ScanPlacement = "trailer/1" | "container/2";
27
+
28
+ export interface ScannedFile {
29
+ path: string;
30
+ name: string;
31
+ size: number;
32
+ /** SHA-256 of the file, standard base64: the member's origin. */
33
+ digestB64: string;
34
+ originDigest: Uint8Array;
35
+ placement: ScanPlacement;
36
+ /**
37
+ * The hasher after the placement's prefix and the file's bytes, still open.
38
+ * Null when the file's length changed while it was read: its fused digest
39
+ * then needs the bytes again.
40
+ */
41
+ state: Hash | null;
42
+ }
43
+
44
+ /** Bytes the placement decision reads: every magic number sits in the first 16. */
45
+ const SNIFF = 64;
46
+
47
+ /** Hash one file in a single pass. Throws, before any network call, when the path is not a regular file. */
48
+ export async function scanFile(path: string): Promise<ScannedFile> {
49
+ const info = await stat(path);
50
+ if (!info.isFile()) throw new Error(`Not a regular file: ${path}`);
51
+ const size = info.size;
52
+ const origin = createHash("sha256");
53
+ // Held in one object: the closures below assign to it.
54
+ const run: { fused: Hash | null; placement: ScanPlacement | null } = { fused: null, placement: null };
55
+ let head: Buffer | null = null;
56
+ let bytes = 0;
57
+ const start = (p: ScanPlacement) => {
58
+ run.placement = p;
59
+ // A second hasher runs over prefix and bytes when the prefix is not
60
+ // empty; with an empty prefix the origin hasher's own state is the fused state.
61
+ const prefix = getPlacement(p)?.scanPrefix?.(size) ?? null;
62
+ if (prefix !== null && prefix.length > 0) run.fused = createHash("sha256").update(prefix);
63
+ };
64
+ const feed = (chunk: Buffer) => {
65
+ origin.update(chunk);
66
+ if (run.fused !== null) run.fused.update(chunk);
67
+ };
68
+ for await (const raw of createReadStream(path)) {
69
+ const chunk = raw as Buffer;
70
+ if (run.placement === null) {
71
+ head = head === null ? chunk : Buffer.concat([head, chunk]);
72
+ if (head.length >= SNIFF) {
73
+ start(placementForBytes(new Uint8Array(head.subarray(0, SNIFF))));
74
+ feed(head);
75
+ head = null;
76
+ }
77
+ } else {
78
+ feed(chunk);
79
+ }
80
+ bytes += chunk.length;
81
+ }
82
+ if (run.placement === null) {
83
+ // A short file: decide from what there is, then hash it.
84
+ start(placementForBytes(new Uint8Array(head ?? Buffer.alloc(0))));
85
+ if (head !== null) feed(head);
86
+ }
87
+ const placement = run.placement as ScanPlacement;
88
+ const state = bytes !== size ? null : (run.fused ?? origin);
89
+ // When the origin hasher is the state its digest is taken from a copy, so it stays open.
90
+ const digest = state === origin ? origin.copy().digest() : origin.digest();
91
+ return {
92
+ path,
93
+ name: basename(path),
94
+ size,
95
+ digestB64: digest.toString("base64"),
96
+ originDigest: Uint8Array.from(digest),
97
+ placement,
98
+ state,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * A member's fused digest for a slot: the open hasher, copied, finished with
104
+ * the placement's suffix for that slot's commitment. The hash of prefix,
105
+ * original, suffix is exactly the hash of the placement's own build, which
106
+ * a test pins.
107
+ */
108
+ export function fusedDigestFor(file: ScannedFile, commitment: Uint8Array): Uint8Array {
109
+ if (file.state === null) throw new Error(`${file.path}: the file changed while it was read`);
110
+ const placement = getPlacement(file.placement);
111
+ if (placement?.frame === undefined) throw new Error(`placement ${file.placement} has no frame`);
112
+ const { suffix } = placement.frame({ originalSize: file.size, originDigest: file.originDigest, commitment });
113
+ return Uint8Array.from(file.state.copy().update(suffix).digest());
114
+ }
115
+
116
+ export interface Expansion {
117
+ /** Regular files in the order given; a directory contributes its files sorted by name, depth first. */
118
+ files: string[];
119
+ /** How many of the inputs were directories. */
120
+ directories: number;
121
+ }
122
+
123
+ /**
124
+ * Paths to files: a regular file is itself; a directory is every regular
125
+ * file under it, recursively, with hidden entries (names starting with a
126
+ * dot) and symbolic links left out. The same file twice is one file. A path
127
+ * that is missing or neither kind, or more than `limit` files in all, throws
128
+ * before any network call; the message names every path that failed.
129
+ */
130
+ export async function expandPaths(paths: readonly string[], limit: number): Promise<Expansion> {
131
+ const files: string[] = [];
132
+ const seen = new Set<string>();
133
+ const failures: string[] = [];
134
+ let directories = 0;
135
+ const push = (p: string) => {
136
+ if (seen.has(p)) return;
137
+ seen.add(p);
138
+ files.push(p);
139
+ if (files.length > limit) throw new Error(`more than ${limit} files; BitGraph fewer at a time`);
140
+ };
141
+ const walk = async (dir: string): Promise<void> => {
142
+ const entries = (await readdir(dir, { withFileTypes: true }))
143
+ .filter((e) => !e.name.startsWith("."))
144
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
145
+ for (const e of entries) {
146
+ const full = join(dir, e.name);
147
+ if (e.isSymbolicLink()) continue;
148
+ if (e.isDirectory()) await walk(full);
149
+ else if (e.isFile()) push(full);
150
+ }
151
+ };
152
+ for (const p of paths) {
153
+ try {
154
+ const info = await stat(p);
155
+ if (info.isDirectory()) {
156
+ directories += 1;
157
+ await walk(p);
158
+ } else if (info.isFile()) {
159
+ push(p);
160
+ } else {
161
+ throw new Error("not a regular file or a directory");
162
+ }
163
+ } catch (err) {
164
+ const message = err instanceof Error ? err.message : String(err);
165
+ if (message.startsWith("more than ")) throw err;
166
+ failures.push(`${p}: ${message}`);
167
+ }
168
+ }
169
+ if (failures.length > 0) {
170
+ throw new Error(`Could not read ${failures.length} path(s); nothing was BitGraphed.\n${failures.join("\n")}\nUse absolute paths to existing regular files or directories.`);
171
+ }
172
+ return { files, directories };
173
+ }