@fangorn-network/westmarch 2026.10.27 → 2026.10.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fangorn-network/westmarch",
3
- "version": "2026.10.27",
3
+ "version": "2026.10.29",
4
4
  "description": "all-in-one builder for fangorn apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,7 +45,7 @@
45
45
  "@fangorn-network/markov": "^0.1.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@fangorn-network/sdk": "^2026.10.26-dev",
48
+ "@fangorn-network/sdk": "^2026.10.28",
49
49
  "@huggingface/transformers": "^4.2.0",
50
50
  "@noble/curves": "^1.9.1",
51
51
  "viem": "^2.55.1"
@@ -56,7 +56,7 @@
56
56
  }
57
57
  },
58
58
  "devDependencies": {
59
- "@fangorn-network/sdk": "^2026.10.26-dev",
59
+ "@fangorn-network/sdk": "^2026.10.28",
60
60
  "@huggingface/transformers": "^4.2.0",
61
61
  "@noble/curves": "^1.9.1",
62
62
  "@privy-io/react-auth": "^3.47.0",
package/site/worker.js CHANGED
@@ -35,7 +35,7 @@ import { createPublicClient, http } from "viem";
35
35
  import { generatePrivateKey } from "viem/accounts";
36
36
  import { EMBED_MODEL, DOCUMENT, QUERY, matryoshka } from "../src/core/embed.js";
37
37
  import { configure, resetShard } from "../src/core/shard.js";
38
- import { publishView, r2Store } from "../src/publish/view.js";
38
+ import { ORIGIN, publishView, r2Store } from "../src/publish/view.js";
39
39
  import { viewTools } from "../src/agent/view-tools.js";
40
40
 
41
41
  let CONFIG = { paid: null };
@@ -57,8 +57,18 @@ const encode = (env, prompt) => async (texts) =>
57
57
  // ── the indexer ───────────────────────────────────────────────────────────────
58
58
 
59
59
  const STATE = "state.json", LOCK = "index.lock";
60
- // A run past this is presumed dead and its lock taken over. Cron runs get 15 minutes.
61
- const LOCK_MS = 14 * 60_000;
60
+ // A live run beats its lock every BEAT_MS; one silent past LOCK_MS is presumed dead (killed for CPU
61
+ // or wall time, which skips `finally`) and its lock taken over. Lock: "<last beat ms> <run id>".
62
+ // ponytail: get-then-put, so two runs starting in the same instant can both take it; R2's onlyIf etag when that shows.
63
+ const LOCK_MS = 2 * 60_000, BEAT_MS = 30_000;
64
+ export async function takeLock(bucket, now = Date.now()) {
65
+ const held = await (await bucket.get(LOCK))?.text();
66
+ if (held && now - Number(held.split(" ")[0]) < LOCK_MS) return null;
67
+ const id = crypto.randomUUID();
68
+ const beat = () => bucket.put(LOCK, `${Date.now()} ${id}`);
69
+ await beat();
70
+ return { id, beat, release: async () => { if ((await (await bucket.get(LOCK))?.text())?.endsWith(id)) await bucket.delete(LOCK); } };
71
+ }
62
72
 
63
73
  // CONFIG.network: the SDK's network name ("baseSepolia"); absent, its default.
64
74
  const network = () => SupportedNetworks[CONFIG.network] ?? FangornConfig;
@@ -96,9 +106,9 @@ export async function presented({ fangorn, chain }, state) {
96
106
  }
97
107
 
98
108
  export async function index(env, { log = console.log } = {}) {
99
- const lock = await env.VIEW.get(LOCK);
100
- if (lock && Date.now() - Number(await lock.text()) < LOCK_MS) return log("index: a run is in progress; skipped");
101
- await env.VIEW.put(LOCK, String(Date.now()));
109
+ const lock = await takeLock(env.VIEW);
110
+ if (!lock) return log("index: a run is in progress; skipped");
111
+ const beating = setInterval(() => lock.beat().catch(() => {}), BEAT_MS);
102
112
  try {
103
113
  const state = (await (await env.VIEW.get(STATE))?.json()) ?? { block: null, repos: {} };
104
114
  const r = reader(env);
@@ -113,7 +123,8 @@ export async function index(env, { log = console.log } = {}) {
113
123
  log(`index: through block ${state.block}, ${Object.keys(report).length} domain(s) changed`);
114
124
  return report;
115
125
  } finally {
116
- await env.VIEW.delete(LOCK);
126
+ clearInterval(beating);
127
+ await lock.release();
117
128
  }
118
129
  }
119
130
 
@@ -455,7 +466,6 @@ async function paidCall(origin, tool, payment, run) {
455
466
  return { ...result, _meta: { ...result._meta, "x402/payment-response": settled } };
456
467
  }
457
468
 
458
- export const ORIGIN = "https://origin.westmarch.invalid";
459
469
  const BY_ORIGIN = new Set(["/.well-known/agent-card.json", "/openapi.json", "/.well-known/x402"]);
460
470
 
461
471
  const worker = {
@@ -517,6 +527,15 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
517
527
  delimitedPrefixes: [...new Set(ks.filter((k) => rest(k).includes("/")).map((k) => `${prefix + rest(k).split("/")[0]}/`))] };
518
528
  },
519
529
  };
530
+ const a = await takeLock(VIEW);
531
+ eq([!!a, await takeLock(VIEW)], [true, null], "a held lock skips the next run");
532
+ objs.set(LOCK, { bytes: new TextEncoder().encode(`${Date.now() - LOCK_MS - 1} dead`), at: new Date() });
533
+ const b = await takeLock(VIEW);
534
+ await a.release();
535
+ eq([!!b, objs.has(LOCK)], [true, true], "a silent lock is taken over, and its dead owner can't release the new one");
536
+ await b.release();
537
+ eq(objs.has(LOCK), false, "the owner releases its own lock");
538
+
520
539
  // Words → a 768-d vector, so a query shares dimensions with the rows that share its words.
521
540
  const calls = [];
522
541
  const AI = { run: async (model, { text }) => { calls.push({ model, text });
@@ -17,6 +17,7 @@
17
17
  // point back at it, so writing someone else's appId here gets you nothing.
18
18
 
19
19
  import { FANGORN_APP_EXTENSION, BaseSepoliaConfig, appId as hashAppId } from "@fangorn-network/sdk";
20
+ import { PLUGIN_MARKETPLACE, PLUGIN_NAME } from "../discover/apps.js";
20
21
 
21
22
  /** Run `register` against a recording `document.modelContext`; return what it
22
23
  * registered. `register` is whatever the page calls at load. */
@@ -79,7 +80,7 @@ export function agentCard({ name, description, url, version = "0.0.0", tools = [
79
80
 
80
81
  /** The params `discoverApp` reads, validated the same way it validates them.
81
82
  * A malformed card should fail the build, not the first reader. */
82
- export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp, paid, access, skill, config = BaseSepoliaConfig }) {
83
+ export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp, paid, access, skill, plugin, config = BaseSepoliaConfig }) {
83
84
  if (!app) throw new Error("fangorn.app is required");
84
85
  const id = HEX32.test(app) ? app : hashAppId(app);
85
86
  const from = String(fromBlock ?? "");
@@ -102,6 +103,8 @@ export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp,
102
103
  // `days`) or one x402 payment of `call` per tool call; describe and the catalog are free.
103
104
  if (skill && !(/^https?:\/\//.test(skill.url ?? "") && /^[0-9a-f]{64}$/.test(skill.sha256 ?? "")))
104
105
  throw new Error(`fangorn.skill needs an http(s) url and the sha256 of the file, in hex: ${JSON.stringify(skill)}`);
106
+ if (plugin && !(PLUGIN_MARKETPLACE.test(plugin.marketplace ?? "") && PLUGIN_NAME.test(plugin.name ?? "")))
107
+ throw new Error(`fangorn.plugin needs a marketplace (GitHub owner/repo or https URL) and a plugin name: ${JSON.stringify(plugin)}`);
105
108
  if (access && !(/^https?:\/\//.test(access.pass_url ?? "") && /^\d+$/.test(String(access.pass)) && /^\d+$/.test(String(access.call))))
106
109
  throw new Error(`fangorn.access needs pass_url, and pass and call in base units: ${JSON.stringify(access)}`);
107
110
  return {
@@ -117,6 +120,7 @@ export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp,
117
120
  symbol: paid.symbol ?? "USDC", decimals: paid.decimals ?? 6, description: paid.description ?? "" } } : {}),
118
121
  ...(access ? { access } : {}),
119
122
  ...(skill ? { skill: { url: skill.url, sha256: skill.sha256 } } : {}),
123
+ ...(plugin ? { plugin: { marketplace: plugin.marketplace, name: plugin.name } } : {}),
120
124
  },
121
125
  };
122
126
  }
@@ -159,6 +163,8 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
159
163
  eq(fangornExtension({ app: "a", fromBlock: 0, skill: { url: "https://x.test/skill/a/SKILL.md", sha256: sha } }).params.skill, { url: "https://x.test/skill/a/SKILL.md", sha256: sha }, "a skill rides in the extension");
160
164
  throws(() => fangornExtension({ app: "a", fromBlock: 0, skill: { url: "https://x.test/s.md" } }), /skill/, "a skill without its hash");
161
165
  throws(() => fangornExtension({ app: "a", fromBlock: 0, skill: { url: "file:///etc/passwd", sha256: sha } }), /skill/, "a non-http skill");
166
+ eq(fangornExtension({ app: "a", fromBlock: 0, plugin: { marketplace: "o/r", name: "a" } }).params.plugin, { marketplace: "o/r", name: "a" }, "a plugin rides in the extension");
167
+ throws(() => fangornExtension({ app: "a", fromBlock: 0, plugin: { marketplace: "o/r; rm -rf ~", name: "a" } }), /plugin/, "a plugin that would inject into a shell");
162
168
 
163
169
  console.log("agent-card.js self-check ok — tools captured without executing, extension matches the SDK's, bad params fail the build");
164
170
  }
package/src/agent/mcp.js CHANGED
@@ -24,7 +24,7 @@ import net from "node:net";
24
24
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
26
  import { createInterface } from "node:readline";
27
- import { APP_EXTENSION, listApps, skillOf, toApp } from "../discover/apps.js";
27
+ import { APP_EXTENSION, listApps, pluginOf, skillOf, toApp } from "../discover/apps.js";
28
28
 
29
29
  const log = (...a) => console.error("[fangorn-mcp]", ...a); // stdout is the protocol
30
30
  // FANGORN_MCP_TRACE=1: step timings on stderr, for finding where an answer waits.
@@ -216,7 +216,7 @@ export function handlers(deps) {
216
216
  while (open.has(s)) s += "-";
217
217
  const ext = card.capabilities?.extensions?.find((e) => e.uri === APP_EXTENSION);
218
218
  const access = ext?.params?.access ?? null;
219
- const o = { slug: s, name: card.name, card: cardUrl, appId, skill: skillOf(ext), ...await deps.remoteTools(mcp, { access }) };
219
+ const o = { slug: s, name: card.name, card: cardUrl, appId, skill: skillOf(ext), plugin: pluginOf(ext), ...await deps.remoteTools(mcp, { access }) };
220
220
  trace(`open ${app}: ready`);
221
221
  open.set(o.slug, o);
222
222
  deps.notify?.("notifications/tools/list_changed");
@@ -253,7 +253,10 @@ export function handlers(deps) {
253
253
  call_as: `${o.slug}__<tool>, or call-app-tool`, tools: o.tools,
254
254
  // An app's own skill: standing instructions for the reader's agent, so never loaded
255
255
  // silently. The fangorn-index skill offers it to the user and pins the hash on a yes.
256
- ...(o.skill ? { skill: { ...o.skill, adopt: "The app ships an agent skill. Offer it to the user (what it would make you do, from its description); adopt it only on their yes, checking sha256, and ask again if the hash changes." } } : {}) });
256
+ ...(o.skill ? { skill: { ...o.skill, adopt: "The app ships an agent skill. Offer it to the user (what it would make you do, from its description); adopt it only on their yes, checking sha256, and ask again if the hash changes." } } : {}),
257
+ // Its Claude Code plugin: hooks run code on every matching event, so the same rule, louder.
258
+ ...(o.plugin ? { plugin: { ...o.plugin, install: `claude plugin install ${o.plugin.name} --marketplace ${o.plugin.marketplace}`,
259
+ adopt: "The app ships a Claude Code plugin, whose hooks run code on this machine without being called. Offer it to the user once, saying what its hooks trigger on; install only on their yes. It takes effect next session." } } : {}) });
257
260
  },
258
261
  "call-app-tool": async ({ app, tool, arguments: args }) => call(app, tool, args),
259
262
  };
@@ -303,7 +306,7 @@ if (process.argv[2] === "--selfcheck") {
303
306
  { name: "Bare", desc: "", card: "https://b.test/card", appId: "0xb" }], rejected: [{}] }),
304
307
  exploreApps: async (q) => ({ ranked_by: q ? "meaning" : null, apps: [{ app: "kings-foil", name: "Kings Foil", relevant: true }, { app: "b", name: "B", relevant: false }] }),
305
308
  resolveCard: async (url) => (url.includes("b.test") ? { card: { name: "Bare" }, mcp: null } : { card: { name: "Kings Foil", capabilities: { extensions: [{ uri: APP_EXTENSION,
306
- params: { skill: { url: "https://k.test/skill/kings-foil/SKILL.md", sha256: "b".repeat(64) } } }] } }, mcp: "https://k.test/mcp" }),
309
+ params: { skill: { url: "https://k.test/skill/kings-foil/SKILL.md", sha256: "b".repeat(64) }, plugin: { marketplace: "k/kf", name: "kings-foil" } } }] } }, mcp: "https://k.test/mcp" }),
307
310
  remoteTools: async () => (opened++, remote),
308
311
  };
309
312
  deps.notify = serve(handlers(deps), { input, output });
@@ -327,6 +330,7 @@ if (process.argv[2] === "--selfcheck") {
327
330
  call(6, "open-app", { app: "https://k.test/card" }); await wait();
328
331
  assert(body(5).tools[0].name === "search" && opened === 1, "open once, by name or by card URL");
329
332
  assert(body(5).skill?.url === "https://k.test/skill/kings-foil/SKILL.md" && /user/.test(body(5).skill.adopt), "open-app offers the app's skill, for the user to adopt");
333
+ assert(body(5).plugin?.marketplace === "k/kf" && /user/.test(body(5).plugin.adopt), "open-app offers the app's plugin, for the user to install");
330
334
  assert(replies.some((r) => r.method === "notifications/tools/list_changed"), "opening notifies");
331
335
  rpc(7, "tools/list"); call(8, "kings-foil__search", { query: "x" }); call(9, "call-app-tool", { app: "kings-foil", tool: "search" });
332
336
  call(10, "call-app-tool", { app: "kings-foil", tool: "boom" }); call(11, "open-app", { app: "nope" }); call(13, "open-app", { app: "Bare" }); rpc(12, "nope"); await wait();
package/src/core/embed.js CHANGED
@@ -31,13 +31,17 @@ export const EMBED_DIM = DIM;
31
31
  /** A short name for the space a taste or a reaction lives in. */
32
32
  export const SPACE = `embeddinggemma-${DIM}`;
33
33
 
34
- let _model = null;
34
+ let _model = null, _progress = null;
35
+ /** Hear the model download: transformers.js's own events ({status, file, loaded, total}), on
36
+ * the thread that loads it (the page's own worker, when it calls the *Direct functions). */
37
+ export const onModelProgress = (f) => { _progress = f; };
35
38
  const model = () => (_model ??= (async () => {
36
39
  const { AutoModel, AutoTokenizer } = await import("@huggingface/transformers");
37
40
  // WESTMARCH_EMBED_DEVICE=cuda puts a Node publisher's document side on a GPU.
38
41
  const device = globalThis.process?.env?.WESTMARCH_EMBED_DEVICE;
39
- const [tokenizer, m] = await Promise.all([AutoTokenizer.from_pretrained(ONNX),
40
- AutoModel.from_pretrained(ONNX, { dtype: DTYPE, ...(device && { device }) })]);
42
+ const progress_callback = (p) => _progress?.(p);
43
+ const [tokenizer, m] = await Promise.all([AutoTokenizer.from_pretrained(ONNX, { progress_callback }),
44
+ AutoModel.from_pretrained(ONNX, { dtype: DTYPE, progress_callback, ...(device && { device }) })]);
41
45
  // `sentence_embedding`, not mean pooling: the model's dense head runs after the pool.
42
46
  return async (texts) => {
43
47
  const { sentence_embedding: e } = await m(await tokenizer(texts, { padding: true, truncation: true }));
@@ -39,6 +39,14 @@ export const skillOf = (ext) => {
39
39
  return k && safeView(k.url) && /^[0-9a-f]{64}$/.test(k.sha256 ?? "") ? { url: k.url, sha256: k.sha256 } : null;
40
40
  };
41
41
 
42
+ // The app's Claude Code plugin, for what a skill can't do (hooks). Both fields reach a shell command
43
+ // in the reader's agent, so they are held to a GitHub owner/repo (or https URL) and a plain name.
44
+ export const PLUGIN_MARKETPLACE = /^([\w.-]+\/[\w.-]+|https:\/\/[\w.\/-]+)$/, PLUGIN_NAME = /^[a-z0-9][a-z0-9-]*$/;
45
+ export const pluginOf = (ext) => {
46
+ const p = ext?.params?.plugin;
47
+ return p && PLUGIN_MARKETPLACE.test(p.marketplace ?? "") && PLUGIN_NAME.test(p.name ?? "") ? { marketplace: p.marketplace, name: p.name } : null;
48
+ };
49
+
42
50
  /** A verified card → an app entry. `views` is westmarch's param; the SDK does not read it. */
43
51
  export function toApp({ card, appId, fromBlock, repos }, cardUrl) {
44
52
  const ext = card.capabilities.extensions.find((e) => e.uri === APP_EXTENSION);
@@ -46,7 +54,7 @@ export function toApp({ card, appId, fromBlock, repos }, cardUrl) {
46
54
  const p = ext?.params?.paid;
47
55
  const paid = p && /^https?:\/\//.test(p.url ?? "") && p.url.includes("{id}") && /^\d+$/.test(String(p.price)) ? p : null;
48
56
  return {
49
- appId, card: cardUrl, fromBlock, repos, views, paid, skill: skillOf(ext),
57
+ appId, card: cardUrl, fromBlock, repos, views, paid, skill: skillOf(ext), plugin: pluginOf(ext),
50
58
  mcp: safeView(ext?.params?.mcp),
51
59
  name: card.name ?? appId,
52
60
  desc: card.description ?? "",
@@ -83,6 +83,7 @@ const log = (s) => console.error(`[ship] ${s}`);
83
83
  // // (pages/intro.html, a fragment, replaces the description on the front page)
84
84
  // "nav": [{ "href": "coverage.html", "label": "Coverage" }], // optional: header links to them
85
85
  // "examples": ["…"], // optional: questions to start from, on the front page (and a private app's gate)
86
+ // "plugin": {"marketplace": "owner/repo", "name": "…"}, // optional: its Claude Code plugin (hooks), offered by open-app
86
87
  // "skill": "skill/SKILL.md", // optional: the app's agent skill, served at skill/<app>/SKILL.md and named
87
88
  // // in the card with its sha256; fangorn-index offers it to whoever opens the app
88
89
  // "agent": "app/agent.js" }, // optional: the module whose registerAgent the card lists, when
@@ -165,10 +166,13 @@ export function presentedRepos(cfg) {
165
166
 
166
167
  /** The Worker's wrangler config: the stock worker, the site as its assets, its bucket, the AI
167
168
  * binding, the cron and CONFIG. `worker` is site/worker.js unless a test points elsewhere. */
168
- export function wranglerConfig({ name, site, account, cron = "*/5 * * * *", config, url, ipfsGateway, worker = join(HERE, "../../site/worker.js") }) {
169
+ export function wranglerConfig({ name, site, account, cron = "*/5 * * * *", cpuMs, config, url, ipfsGateway, worker = join(HERE, "../../site/worker.js") }) {
169
170
  return {
170
171
  name, main: worker, compatibility_date: "2025-09-15", compatibility_flags: ["nodejs_compat"],
171
172
  ...(account ? { account_id: account } : {}),
173
+ // site.cpu_ms: the indexer's CPU per run. Reading one big commit (50k records) outlasts the
174
+ // 30 s Workers Paid default; up to 300000. The Free plan refuses the deploy with it set.
175
+ ...(cpuMs ? { limits: { cpu_ms: cpuMs } } : {}),
172
176
  assets: { directory: site, binding: "ASSETS" },
173
177
  r2_buckets: [{ binding: "VIEW", bucket_name: `${name}-view` }],
174
178
  ai: { binding: "AI" },
@@ -272,7 +276,7 @@ export async function buildForDeploy(configPath, wranglerPath = join(dirname(res
272
276
  log(`the R2 bucket ${w.r2_buckets?.[0]?.bucket_name} doesn't name the Worker; two apps sharing one overwrite each other's view`);
273
277
 
274
278
  process.env.FANGORN_NETWORK = process.env.CHAIN_NAME = cfg.network;
275
- const [{ createPublicClient, http }, view, { ORIGIN }] = await Promise.all([import("viem"), import("./view.js"), import("../../site/worker.js")]);
279
+ const [{ createPublicClient, http }, view] = await Promise.all([import("viem"), import("./view.js")]);
276
280
  const FangornConfig = await view.network(cfg.network);
277
281
  const fangorn = await view.readOnlyFangorn();
278
282
  fangorn.setAppId(cfg.app);
@@ -283,7 +287,7 @@ export async function buildForDeploy(configPath, wranglerPath = join(dirname(res
283
287
 
284
288
  const base = dirname(resolve(configPath));
285
289
  const { site, writeCard } = await buildSite(cfg, base);
286
- writeCard(ORIGIN, { fromBlock, repos: [] });
290
+ writeCard(view.ORIGIN, { fromBlock, repos: [] });
287
291
  const gateway = process.env.FANGORN_IPFS_GATEWAY;
288
292
  w.assets = { ...w.assets, directory: relative(dirname(resolve(wranglerPath)), site) };
289
293
  w.vars = { ...w.vars, CONFIG: { app: cfg.app, name: cfg.name, description: cfg.description, fromBlock, network: cfg.network,
@@ -400,7 +404,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
400
404
  const workerConfig = (u) => {
401
405
  const path = join(dir, "wrangler.json");
402
406
  writeFileSync(path, JSON.stringify(wranglerConfig({
403
- name: cfg.site.project, site, account: cfg.site.account, cron: cfg.site.cron, url: cfg.site.url, ipfsGateway: view.ipfsGateway(),
407
+ name: cfg.site.project, site, account: cfg.site.account, cron: cfg.site.cron, cpuMs: cfg.site.cpu_ms, url: cfg.site.url, ipfsGateway: view.ipfsGateway(),
404
408
  config: { app: cfg.app, name: cfg.name, description: cfg.description, fromBlock: state.fromBlock, network: cfg.network,
405
409
  schema: { types: cfg.types, relations: cfg.relations }, repos: register ? [] : repos, paid: sale(u).sale, private: access(u).config },
406
410
  }), null, 1));
@@ -576,7 +580,8 @@ export async function buildSite(cfg, base) {
576
580
  description: `The keys to sealed records (POST ${u}/paid), per record.` }] : []),
577
581
  ],
578
582
  fangorn: { app: cfg.app, fromBlock, repos, views: [`${u}/view`], mcp: `${u}/mcp`, config, ...(paid ? { paid } : {}), ...(access ? { access } : {}),
579
- ...(skill ? { skill: { url: `${u}/${skill.path}`, sha256: skill.sha256 } } : {}) },
583
+ ...(skill ? { skill: { url: `${u}/${skill.path}`, sha256: skill.sha256 } } : {}),
584
+ ...(cfg.site.plugin ? { plugin: cfg.site.plugin } : {}) },
580
585
  });
581
586
  mkdirSync(join(site, ".well-known"), { recursive: true });
582
587
  writeFileSync(join(site, ".well-known/agent-card.json"), JSON.stringify(agentCard, null, 1));
@@ -715,8 +720,11 @@ export async function indexHere(configPath, { port = 8791 } = {}) {
715
720
  if (secret) writeFileSync(vars, `PAID_SECRET=${secret}\n`, { mode: 0o600 });
716
721
  const url = `http://127.0.0.1:${port}`, start = Math.floor(Date.now() / 1000);
717
722
  const wrangler = spawn("npx", ["wrangler", "dev", "--config", wpath, "--port", String(port), "--ip", "127.0.0.1", "--test-scheduled"],
718
- { cwd: join(HERE, "../.."), stdio: ["ignore", "inherit", "inherit"],
723
+ { cwd: join(HERE, "../.."), stdio: ["ignore", "pipe", "inherit"],
719
724
  env: { ...process.env, ...(cfg.site.account ? { CLOUDFLARE_ACCOUNT_ID: cfg.site.account } : {}) } });
725
+ // A run already holds the lock (the deployed cron's): this one skips, and the catalog would never move.
726
+ let skipped = false;
727
+ wrangler.stdout.on("data", (b) => { process.stdout.write(b); if (String(b).includes("index: a run is in progress; skipped")) skipped = true; });
720
728
  try {
721
729
  for (;;) {
722
730
  if (wrangler.exitCode !== null) throw new Error("wrangler dev exited");
@@ -728,6 +736,7 @@ export async function indexHere(configPath, { port = 8791 } = {}) {
728
736
  let catalog = null;
729
737
  while (!(catalog?.generated_at >= start)) {
730
738
  if (wrangler.exitCode !== null) throw new Error("wrangler dev exited");
739
+ if (skipped) throw new Error("another indexer run holds the lock (the deployed cron's, most likely); it frees within 2 minutes of that run dying. Try again then.");
731
740
  await new Promise((ok) => setTimeout(ok, 5000));
732
741
  catalog = await fetch(`${url}/view/cdn/catalog`).then((r) => (r.ok ? r.json() : null), () => null);
733
742
  }
@@ -40,6 +40,9 @@ import { dirname } from "node:path";
40
40
  import { EMBED_DIM, EMBED_DTYPE, EMBED_MODEL, embedDocumentDirect, packVec, unpackVec } from "../core/embed.js";
41
41
  import { coverage as fitCoverage } from "../market/reactions.js";
42
42
 
43
+ // The origin the agent card is built with; the Worker swaps in wherever it is served from.
44
+ // Here, not in site/worker.js: workerd refuses a Worker module exporting anything but handlers.
45
+ export const ORIGIN = "https://origin.westmarch.invalid";
43
46
  export const EMBEDDER = { runtime: "transformers.js", model: EMBED_MODEL, dtype: EMBED_DTYPE };
44
47
  const SINGULAR = ["identity", "title", "subtitle", "temporal", "spatial", "media", "thread"];
45
48
  const MULTI = ["tags", "measures", "relations", "text"];
@@ -168,12 +171,21 @@ export async function readOnlyFangorn() {
168
171
  * A build that must not touch the chain — a candidate recipe graded before it ships —
169
172
  * runs the exact code a real one does. */
170
173
  export function localFangorn(chain, { appId, owner }) {
171
- const root = (k) => `0x${createHash("sha256").update(JSON.stringify(chain[k])).digest("hex")}`;
174
+ // Every version handed out is kept, so repoDiff can diff any two, as the SDK diffs two roots.
175
+ const versions = new Map();
176
+ const root = (k) => { const h = `0x${createHash("sha256").update(JSON.stringify(chain[k])).digest("hex")}`; versions.set(h, structuredClone(chain[k]?.records ?? [])); return h; };
177
+ const repoDiff = async (from, to) => {
178
+ if (!versions.has(from) || !versions.has(to)) throw new Error("unknown root");
179
+ const [a, b] = [versions.get(from), versions.get(to)], cidOf = new Map(a.map((r) => [r.id, r.cid])), ids = new Set(b.map((r) => r.id));
180
+ return { puts: b.filter((r) => cidOf.get(r.id) !== r.cid), deletes: a.filter((r) => !ids.has(r.id)).map((r) => r.id),
181
+ superseded: a.filter((r) => !ids.has(r.id) || b.some((x) => x.id === r.id && x.cid !== r.cid)).map((r) => r.cid) };
182
+ };
172
183
  return {
184
+ engine: { repoDiff }, reads: 0,
173
185
  setAppId() {}, getAppId: () => appId,
174
186
  getAppRegistry: () => ({ getAppOwner: async () => owner }),
175
187
  appRepos: async () => Object.keys(chain).map((k) => ({ owner: k.split("/")[0], repo: k.split("/").slice(1).join("/"), root: root(k) })),
176
- readRepo: async (o, repo) => { const c = chain[`${o}/${repo}`]; return { tip: c ? root(`${o}/${repo}`) : null, contents: { records: c?.records ?? [] } }; },
188
+ readRepo: async function (o, repo) { this.reads++; const c = chain[`${o}/${repo}`]; return { tip: c ? root(`${o}/${repo}`) : null, contents: { records: c?.records ?? [] } }; },
177
189
  };
178
190
  }
179
191
 
@@ -243,11 +255,12 @@ export function r2Store(bucket, prefix = "view/") {
243
255
  const readJson = async (store, p, dflt) => { const b = await store.get(p); return b ? JSON.parse(new TextDecoder().decode(b)) : dflt; };
244
256
  const writeJson = (store, p, o) => store.put(p, JSON.stringify(o, null, 1));
245
257
 
246
- /** What a domain already serves: its manifest and every row (for tombstones and coverage). */
247
- async function loadDomain(store, dir) {
258
+ /** What a domain already serves: its manifest and every row (for tombstones and coverage), or
259
+ * with `only`, the rows of those shard files alone. */
260
+ async function loadDomain(store, dir, only = null) {
248
261
  const manifest = await readJson(store, `${dir}/manifest`, null);
249
262
  const rows = new Map();
250
- for (const s of manifest?.shards ?? []) {
263
+ for (const s of (manifest?.shards ?? []).filter((x) => !only || only.includes(x.file))) {
251
264
  const b = await store.get(`${dir}/shards/${s.file}`);
252
265
  if (!b) throw new Error(`${dir}: shard ${s.file} is named by its manifest and missing`);
253
266
  for (const line of gunzipSync(b).toString("utf8").split("\n")) if (line.trim()) { const r = JSON.parse(line); rows.set(r.track_id, r); }
@@ -339,21 +352,38 @@ async function retry(f, tries = 6) {
339
352
  }
340
353
  }
341
354
 
355
+ const CHECKPOINT = 2048; // rows embedded between saves; a killed run redoes at most this many
342
356
  async function publishDomain({ fangorn, store, schema, schemaHash, publisher, repo, head, domain, dir, rebake, embedBatch, embedder, log, shardRows, open }) {
343
357
  // The head the domain was built from, and the schema it was built under: neither moved,
344
358
  // nothing to read. This is what keeps a cron run with nothing new to one read per repo.
345
359
  const was = rebake ? null : await readJson(store, `${dir}/manifest`, null);
346
360
  if (was && was.head === head && was.schema_hash === schemaHash) return null;
347
361
 
348
- const { contents } = await retry(() => fangorn.readRepo(publisher, repo));
362
+ // The head moved from one this domain was built from: read only what changed between them (the
363
+ // SDK's repoDiff opens only the changed paths of the record tree), not every record again. A
364
+ // schema with relations links each row against all the others, so it still reads the whole repo;
365
+ // so does an SDK whose diff can't name the replaced versions, and a diff that fails (a force-push
366
+ // to a head whose blocks are gone).
367
+ // ponytail: relations read whole; diff their targets too when a big corpus declares one.
368
+ const diff = was?.head && !schema.relations?.length && fangorn.engine?.repoDiff
369
+ ? await retry(() => fangorn.engine.repoDiff(was.head, head)).catch((e) => { log(`${domain}: diff from ${was.head} failed (${e.message}); reading it whole`); return null; })
370
+ : null;
371
+ const got = diff?.superseded ? diff.puts : (await retry(() => fangorn.readRepo(publisher, repo))).contents.records;
349
372
  // `open`: a private app's rows are sealed on chain (x402.js's sealRow); the Worker opens them here.
350
- const records = new Map(await Promise.all(contents.records.filter((r) => r.payload && typeof r.payload === "object")
373
+ const records = new Map(await Promise.all(got.filter((r) => r.payload && typeof r.payload === "object")
351
374
  .map(async (r) => [r.cid, { id: r.id, fields: { ...(await open(r.payload)), entityType: r.type } }])));
352
375
  linkRelations(records, schema);
353
376
  const fieldsOf = (cid) => records.get(cid).fields;
354
377
 
355
378
  if (rebake) await store.del(dir); // old shards would stay reachable
356
- const had = await loadDomain(store, dir);
379
+ // Append only: the diff replaced nothing, so no row already served changes, and the 50k of them
380
+ // are left unread. Only the shards saved since the last head (`ahead`, a killed run's checkpoints)
381
+ // are read, to skip what they hold. A removal, an edit, a rebake or a coverage refit reads all.
382
+ // Rows in `ahead` are already counted, and come back among the puts of a diff from the old head.
383
+ const aheadRows = (was?.shards ?? []).filter((x) => was.ahead?.includes(x.file)).reduce((n, x) => n + x.count, 0);
384
+ const append = !!was && !!diff?.superseded && !diff.superseded.length && !was.tombstones?.length
385
+ && (was.count ?? 0) + diff.puts.length - aheadRows < COVERAGE_REFIT * (was.coverage?.fit_at ?? 0);
386
+ const had = await loadDomain(store, dir, append ? was.ahead ?? [] : null);
357
387
  const made = had.manifest?.embedder ?? (had.manifest ? { runtime: "fastembed", dtype: "fp32" } : null);
358
388
  // Same model and dtype is the same encoder, whatever ran it: q8 on CPU, CUDA and WASM agree to
359
389
  // ~0.98-0.99 cosine, fp32 against q8 noticeably less.
@@ -364,10 +394,11 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
364
394
  const dead = new Set(had.manifest?.tombstones ?? []);
365
395
  const live = (id) => had.rows.has(id) && !dead.has(id);
366
396
  const fresh = [...records.keys()].filter((id) => !live(id));
367
- const gone = [...had.rows.keys()].filter((id) => live(id) && !records.has(id));
397
+ // From a diff, `records` holds only what changed: what is gone is what it superseded.
398
+ const gone = diff?.superseded ? diff.superseded.filter(live) : [...had.rows.keys()].filter((id) => live(id) && !records.has(id));
368
399
  // A row already served whose links changed (its matter came later, or moved): new fields,
369
- // the same vector, so no embedding, only a rewrite.
370
- const relinked = [...had.rows.keys()].filter((id) => live(id) && records.has(id)
400
+ // the same vector, so no embedding, only a rewrite. Only relations relink, and they read whole.
401
+ const relinked = diff?.superseded ? [] : [...had.rows.keys()].filter((id) => live(id) && records.has(id)
371
402
  && JSON.stringify(had.rows.get(id).fields) !== JSON.stringify(fieldsOf(id)));
372
403
  for (const id of relinked) had.rows.get(id).fields = fieldsOf(id);
373
404
 
@@ -383,6 +414,34 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
383
414
  }
384
415
  }
385
416
 
417
+ const manifest = had.manifest ?? { name: domain, description: "", count: 0, dim: EMBED_DIM, model: EMBED_MODEL,
418
+ distance: "Cosine", embedder, filter: { owner: [publisher], repo: [repo] }, shards: [], tombstones: [] };
419
+ // Pages and R2 serve files of any size, but a reader holds a shard whole; 52k Steam games
420
+ // gzipped to 28 MiB in one. ponytail: split by row count (~550 B a row gzipped).
421
+ const writeShard = async (all) => {
422
+ const files = [];
423
+ for (let i = 0; i < all.length; i += shardRows) {
424
+ const ls = all.slice(i, i + shardRows);
425
+ const gz = gzipSync(`${ls.join("\n")}\n`, { level: 9 });
426
+ const digest = sha256(gz);
427
+ const file = `shard-${String(manifest.shards.length).padStart(4, "0")}-${digest.slice(0, 12)}.ndjson.gz`;
428
+ await store.put(`${dir}/shards/${file}`, gz);
429
+ manifest.shards.push({ file, count: ls.length, bytes: gz.length, sha256: digest });
430
+ files.push(file);
431
+ }
432
+ return files;
433
+ };
434
+ // What is embedded so far, served now under the old head: a run killed later (CPU, wall time:
435
+ // no exception, no `finally`) loses at most CHECKPOINT rows, and the next run embeds only the rest.
436
+ const save = async () => {
437
+ manifest.ahead = [...(manifest.ahead ?? []), ...await writeShard(lines)];
438
+ const rows = lines.map((l) => JSON.parse(l));
439
+ for (const r of rows) { had.rows.set(r.track_id, r); dead.delete(r.track_id); }
440
+ lines.length = 0;
441
+ manifest.head = had.manifest?.head ?? null;
442
+ manifest.schema_hash = schemaHash;
443
+ await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf, append ? rows : null);
444
+ };
386
445
  let n = 0, stopped = null;
387
446
  const lines = [];
388
447
  // 32 at a time: what an encoder batches (embed.js, a GPU, the Worker's AI binding).
@@ -400,39 +459,26 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
400
459
  }
401
460
  if (Math.floor((n + ids.length) / 1000) > Math.floor(n / 1000)) log(`${domain}: embedded ${n + ids.length}/${fresh.length}`);
402
461
  n += ids.length;
462
+ if (lines.length >= CHECKPOINT && n < fresh.length) await save();
403
463
  }
404
- const manifest = had.manifest ?? { name: domain, description: "", count: 0, dim: EMBED_DIM, model: EMBED_MODEL,
405
- distance: "Cosine", embedder, filter: { owner: [publisher], repo: [repo] }, shards: [], tombstones: [] };
406
464
  // Unfinished: the old head stays, so the next run sees a head to catch up to and embeds only what is missing.
407
465
  manifest.head = stopped ? (had.manifest?.head ?? null) : head;
408
466
  manifest.schema_hash = schemaHash;
467
+ if (!stopped) delete manifest.ahead; // the head is taken: everything is behind it
409
468
  if (!fresh.length && !gone.length && !relinked.length && had.manifest && !rebake) {
410
- await applySpec(store, dir, manifest, schema, had.rows, dead); // a schema change alone still lands
469
+ await applySpec(store, dir, manifest, schema, had.rows, dead, new Map(), append ? [] : null); // a schema change alone still lands
411
470
  return { added: 0, removed: 0 };
412
471
  }
413
472
 
414
- // Pages and R2 serve files of any size, but a reader holds a shard whole; 52k Steam games
415
- // gzipped to 28 MiB in one. ponytail: split by row count (~550 B a row gzipped).
416
- const writeShard = async (all) => {
417
- for (let i = 0; i < all.length; i += shardRows) {
418
- const ls = all.slice(i, i + shardRows);
419
- const gz = gzipSync(`${ls.join("\n")}\n`, { level: 9 });
420
- const digest = sha256(gz);
421
- const file = `shard-${String(manifest.shards.length).padStart(4, "0")}-${digest.slice(0, 12)}.ndjson.gz`;
422
- await store.put(`${dir}/shards/${file}`, gz);
423
- manifest.shards.push({ file, count: ls.length, bytes: gz.length, sha256: digest });
424
- }
425
- };
426
473
  if (stopped) {
427
- await writeShard(lines);
428
- for (const l of lines) { const r = JSON.parse(l); had.rows.set(r.track_id, r); dead.delete(r.track_id); }
429
- await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf);
474
+ await save();
430
475
  log(`${domain}: stopped at ${n}/${fresh.length} (${stopped.message}); the rest on the next run`);
431
476
  return { added: n, removed: 0, partial: true };
432
477
  }
478
+ const last = lines.map((l) => JSON.parse(l));
433
479
  if (lines.length) {
434
480
  await writeShard(lines);
435
- for (const l of lines) { const r = JSON.parse(l); had.rows.set(r.track_id, r); dead.delete(r.track_id); }
481
+ for (const r of last) { had.rows.set(r.track_id, r); dead.delete(r.track_id); }
436
482
  }
437
483
  for (const id of gone) dead.add(id);
438
484
  // A tombstone hides a row from readers, but its shard still serves the bytes. A record
@@ -441,6 +487,8 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
441
487
  // files are deleted. No re-embedding: the vectors are already in the rows.
442
488
  // ponytail: rewrites the whole domain on every removal; compact past a threshold if domains get big.
443
489
  if (dead.size || relinked.length) {
490
+ // A compaction rewrites `had.rows` as the whole domain, which append mode never loaded.
491
+ if (append) throw new Error(`${domain}: append mode reached a compaction; nothing written past the new shard`);
444
492
  for (const s of manifest.shards) await store.del(`${dir}/shards/${s.file}`);
445
493
  manifest.shards = [];
446
494
  for (const id of dead) had.rows.delete(id);
@@ -449,16 +497,19 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
449
497
  }
450
498
  manifest.tombstones = [...dead].sort();
451
499
  manifest.created_at = Math.floor(Date.now() / 1000);
452
- await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf);
500
+ await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf, append ? last : null);
453
501
  log(`${domain}: +${fresh.length} embedded, -${gone.length} removed, ${relinked.length} relinked, ${manifest.count} live`);
454
502
  return { added: fresh.length, removed: gone.length, ...(relinked.length ? { relinked: relinked.length } : {}) };
455
503
  }
456
504
 
457
505
  /** Counts, types, roles, description, presentation, coverage and the hosts its media role
458
- * links to (what a Worker may show inline, see site/worker.js) from what the domain holds. */
459
- async function applySpec(store, dir, manifest, schema, rows, dead, guessed = new Map()) {
460
- const liveRows = [...rows.values()].filter((r) => !dead.has(r.track_id));
461
- const types = {};
506
+ * links to (what a Worker may show inline, see site/worker.js) from what the domain holds.
507
+ * `added`: append mode, where `rows` is not the whole domain: fold these new rows into the
508
+ * manifest's totals instead of recounting. Coverage is left as fitted (publishDomain appends only
509
+ * while no refit is due). ponytail: hosts only grow on append; a removal recounts them. */
510
+ async function applySpec(store, dir, manifest, schema, rows, dead, guessed = new Map(), added = null) {
511
+ const liveRows = added ?? [...rows.values()].filter((r) => !dead.has(r.track_id));
512
+ const types = added ? Object.fromEntries((manifest.entity_types ?? []).map((e) => [e.type, e.count])) : {};
462
513
  for (const r of liveRows) types[r.fields?.entityType] = (types[r.fields?.entityType] ?? 0) + 1;
463
514
  const tags = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([t]) => t);
464
515
  // Compare with what is stored, not with the manifest as it arrived: publishDomain has already
@@ -466,20 +517,20 @@ async function applySpec(store, dir, manifest, schema, rows, dead, guessed = new
466
517
  // the old manifest naming deleted shards.
467
518
  const before = JSON.stringify(await readJson(store, `${dir}/manifest`, null));
468
519
 
469
- manifest.count = liveRows.length;
520
+ manifest.count = added ? (manifest.count ?? 0) + added.length : liveRows.length;
470
521
  manifest.entity_types = tags.map((t) => ({ type: t, count: types[t] }));
471
522
  const spec = domainSpec(schema, tags);
472
- const fields = [...new Set(liveRows.slice(0, 500).flatMap((r) => Object.keys(r.fields ?? {})))].sort();
523
+ const fields = [...new Set([...(added ? manifest.role_map?.fields ?? [] : []), ...liveRows.slice(0, 500).flatMap((r) => Object.keys(r.fields ?? {}))])].sort();
473
524
  manifest.role_map = { ...(spec?.role_map ?? manifest.role_map ?? guessed.values().next().value ?? completeRoles()), fields };
474
525
  if (spec?.description) manifest.description = spec.description;
475
526
  if (spec && Object.keys(spec.presentation).length) manifest.presentation = spec.presentation;
476
527
  const media = [manifest.role_map.media ?? []].flat();
477
- manifest.link_hosts = [...new Set(liveRows.flatMap((r) => media.map((m) => r.fields?.[m]))
528
+ manifest.link_hosts = [...new Set([...(added ? manifest.link_hosts ?? [] : []), ...liveRows.flatMap((r) => media.map((m) => r.fields?.[m]))
478
529
  .filter((u) => typeof u === "string" && u.startsWith("https://"))
479
- .map((u) => { try { return new URL(u).hostname; } catch { return null; } }).filter(Boolean))].sort();
530
+ .map((u) => { try { return new URL(u).hostname; } catch { return null; } })].filter(Boolean))].sort();
480
531
 
481
532
  const cov = manifest.coverage;
482
- if (liveRows.length && (!cov || liveRows.length >= COVERAGE_REFIT * (cov.fit_at ?? 0))) {
533
+ if (!added && liveRows.length && (!cov || liveRows.length >= COVERAGE_REFIT * (cov.fit_at ?? 0))) {
483
534
  manifest.coverage = { ...fitCoverage(liveRows.map((r) => unpackVec(r.v))), fit_at: liveRows.length };
484
535
  }
485
536
  if (JSON.stringify(manifest) !== before) await writeJson(store, `${dir}/manifest`, manifest);
@@ -617,6 +668,49 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
617
668
  eq((await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
618
669
  { added: 48, removed: 0 }, "the next run embeds only the rest");
619
670
  eq(manifestOf(big, bd).count, 80, "and serves all of it");
671
+ // Killed partway (a Worker over its CPU or wall time: no exception, no return): the last
672
+ // checkpoint is served, and the next run embeds only what came after it.
673
+ chain[BIG] = { records: Array.from({ length: CHECKPOINT + 100 }, (_, i) => ({ id: `k${i}`, cid: `kill${i}`, type: "shop.item.v1", payload: { sku: `k${i}`, name: `Kept ${i}` } })) };
674
+ let left = CHECKPOINT + 64;
675
+ const dies = (ts) => (left -= ts.length) < 0 ? new Promise(() => {}) : Promise.all(ts.map(embed));
676
+ publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: dies, log: () => {} });
677
+ for (let i = 0; i < 200 && manifestOf(big, bd).count < CHECKPOINT; i++) await new Promise((ok) => setTimeout(ok, 10));
678
+ eq((await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
679
+ { added: 100, removed: 80 }, "a killed run resumes from its last checkpoint");
680
+ // A built domain whose head moves reads only the diff: 10 new tracks on top of thousands
681
+ // never read the thousands again, and a removal or an edit drops the version it replaced.
682
+ const whole = fangorn.reads;
683
+ chain[BIG].records.push(...Array.from({ length: 10 }, (_, i) => ({ id: `n${i}`, cid: `next${i}`, type: "shop.item.v1", payload: { sku: `n${i}`, name: `New ${i}` } })));
684
+ chain[BIG].records.splice(0, 1, { ...chain[BIG].records[0], cid: "kill0-v2", payload: { sku: "k0", name: "Kept 0, renamed" } });
685
+ chain[BIG].records.splice(1, 1);
686
+ eq([(await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd], fangorn.reads - whole],
687
+ [{ added: 11, removed: 2 }, 0], "a moved head reads only what changed");
688
+ eq(manifestOf(big, bd).count, CHECKPOINT + 100 + 10 - 1, "and serves the repo as it now stands");
689
+ // Only additions: the rows already served are not read at all, only a delta shard is written.
690
+ const disk = fsStore(big), opened = [];
691
+ const watched = { ...disk, get: (p) => { opened.push(p); return disk.get(p); } };
692
+ const n0 = manifestOf(big, bd).count, shards0 = manifestOf(big, bd).shards.length;
693
+ chain[BIG].records.push(...Array.from({ length: 10 }, (_, i) => ({ id: `m${i}`, cid: `more${i}`, type: "shop.item.v1", payload: { sku: `m${i}`, name: `More ${i}` } })));
694
+ eq((await publishView({ fangorn, app: APP, schema, store: watched, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
695
+ { added: 10, removed: 0 }, "an append embeds only the new records");
696
+ eq(opened.filter((p) => p.includes("/shards/")), [], "and reads no shard it already served");
697
+ const after = manifestOf(big, bd);
698
+ eq([after.count, after.shards.length, after.entity_types[0].count, after.ahead], [n0 + 10, shards0 + 1, n0 + 10, undefined], "counts, types and one delta shard, with the head taken");
699
+ eq((await liveFields(big)).length, n0 + 10, "and every row is still served");
700
+ // An append killed after a checkpoint: the next run reads only that checkpoint's shard, and
701
+ // embeds only what came after it, never the checkpointed rows a second time.
702
+ const n1 = manifestOf(big, bd).count, fit = manifestOf(big, bd).coverage.fit_at, extra = CHECKPOINT + 2;
703
+ if (n1 + extra >= COVERAGE_REFIT * fit) throw new Error(`test setup: ${n1} + ${extra} rows would refit coverage (fit at ${fit}), not append`);
704
+ chain[BIG].records.push(...Array.from({ length: extra }, (_, i) => ({ id: `a${i}`, cid: `app${i}`, type: "shop.item.v1", payload: { sku: `a${i}`, name: `Appended ${i}` } })));
705
+ left = CHECKPOINT + 1;
706
+ publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: dies, log: () => {} });
707
+ for (let i = 0; i < 300 && !manifestOf(big, bd).ahead; i++) await new Promise((ok) => setTimeout(ok, 10));
708
+ eq([manifestOf(big, bd).count, manifestOf(big, bd).ahead?.length], [n1 + CHECKPOINT, 1], "a killed append serves its checkpoint, named as ahead of the head");
709
+ opened.length = 0;
710
+ eq((await publishView({ fangorn, app: APP, schema, store: watched, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
711
+ { added: 2, removed: 0 }, "and the next run embeds only what came after it");
712
+ eq([opened.filter((p) => p.includes("/shards/")).length, manifestOf(big, bd).count, (await liveFields(big)).length],
713
+ [1, n1 + extra, n1 + extra], "reading only the checkpoint's shard, and serving each row once");
620
714
  delete chain[BIG];
621
715
  rmSync(big, { recursive: true, force: true });
622
716
  eq((await publishView({ fangorn, app: APP, schema, out, embed, rebake: true, log: () => {} }))[d],
@@ -6,7 +6,7 @@
6
6
  "deploy": "wrangler deploy"
7
7
  },
8
8
  "dependencies": {
9
- "@fangorn-network/westmarch": "2026.10.27",
9
+ "@fangorn-network/westmarch": "2026.10.29",
10
10
  "@huggingface/transformers": "^4.3.1",
11
11
  "wrangler": "^4.83.0"
12
12
  }