@fangorn-network/westmarch 2026.10.2-2.0-dev → 2026.10.2-7.2
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 +3 -3
- package/site/worker.js +26 -6
- package/src/agent/agent-card.js +18 -1
- package/src/agent/mcp.js +15 -5
- package/src/discover/apps.js +15 -1
- package/src/publish/ship.js +63 -8
- package/src/publish/view.js +131 -40
- package/template/README.md +3 -2
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fangorn-network/westmarch",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.27.2",
|
|
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.
|
|
48
|
+
"@fangorn-network/sdk": "^2026.10.27-dev",
|
|
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.
|
|
59
|
+
"@fangorn-network/sdk": "^2026.10.27-dev",
|
|
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
|
@@ -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
|
|
61
|
-
|
|
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
|
|
100
|
-
if (lock
|
|
101
|
-
|
|
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
|
-
|
|
126
|
+
clearInterval(beating);
|
|
127
|
+
await lock.release();
|
|
117
128
|
}
|
|
118
129
|
}
|
|
119
130
|
|
|
@@ -517,6 +528,15 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
517
528
|
delimitedPrefixes: [...new Set(ks.filter((k) => rest(k).includes("/")).map((k) => `${prefix + rest(k).split("/")[0]}/`))] };
|
|
518
529
|
},
|
|
519
530
|
};
|
|
531
|
+
const a = await takeLock(VIEW);
|
|
532
|
+
eq([!!a, await takeLock(VIEW)], [true, null], "a held lock skips the next run");
|
|
533
|
+
objs.set(LOCK, { bytes: new TextEncoder().encode(`${Date.now() - LOCK_MS - 1} dead`), at: new Date() });
|
|
534
|
+
const b = await takeLock(VIEW);
|
|
535
|
+
await a.release();
|
|
536
|
+
eq([!!b, objs.has(LOCK)], [true, true], "a silent lock is taken over, and its dead owner can't release the new one");
|
|
537
|
+
await b.release();
|
|
538
|
+
eq(objs.has(LOCK), false, "the owner releases its own lock");
|
|
539
|
+
|
|
520
540
|
// Words → a 768-d vector, so a query shares dimensions with the rows that share its words.
|
|
521
541
|
const calls = [];
|
|
522
542
|
const AI = { run: async (model, { text }) => { calls.push({ model, text });
|
package/src/agent/agent-card.js
CHANGED
|
@@ -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. */
|
|
@@ -48,6 +49,10 @@ const HEX32 = /^0x[0-9a-fA-F]{64}$/;
|
|
|
48
49
|
* tools, as the free tier's Worker does at /mcp.
|
|
49
50
|
* config the deployment; defaults to the SDK's, so the addresses in the
|
|
50
51
|
* card cannot drift from the ones `discoverApp` checks.
|
|
52
|
+
* skill `{url, sha256}`: the app's own agent skill (a SKILL.md), for agents
|
|
53
|
+
* that adopt it, so its behaviour travels with the app and not with a
|
|
54
|
+
* marketplace. The hash pins what the reader saw: an agent adopts it
|
|
55
|
+
* only on its user's yes, and asks again when the hash changes.
|
|
51
56
|
*/
|
|
52
57
|
export function agentCard({ name, description, url, version = "0.0.0", tools = [], tags = [], skills = [], fangorn, ...rest }) {
|
|
53
58
|
if (!name || !url) throw new Error("agentCard: name and url are required");
|
|
@@ -75,7 +80,7 @@ export function agentCard({ name, description, url, version = "0.0.0", tools = [
|
|
|
75
80
|
|
|
76
81
|
/** The params `discoverApp` reads, validated the same way it validates them.
|
|
77
82
|
* A malformed card should fail the build, not the first reader. */
|
|
78
|
-
export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp, paid, access, config = BaseSepoliaConfig }) {
|
|
83
|
+
export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp, paid, access, skill, plugin, config = BaseSepoliaConfig }) {
|
|
79
84
|
if (!app) throw new Error("fangorn.app is required");
|
|
80
85
|
const id = HEX32.test(app) ? app : hashAppId(app);
|
|
81
86
|
const from = String(fromBlock ?? "");
|
|
@@ -96,6 +101,10 @@ export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp,
|
|
|
96
101
|
}
|
|
97
102
|
// `access`: a private app. Its view and tools need a pass (`pass_url`, `pass` in base units, for
|
|
98
103
|
// `days`) or one x402 payment of `call` per tool call; describe and the catalog are free.
|
|
104
|
+
if (skill && !(/^https?:\/\//.test(skill.url ?? "") && /^[0-9a-f]{64}$/.test(skill.sha256 ?? "")))
|
|
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)}`);
|
|
99
108
|
if (access && !(/^https?:\/\//.test(access.pass_url ?? "") && /^\d+$/.test(String(access.pass)) && /^\d+$/.test(String(access.call))))
|
|
100
109
|
throw new Error(`fangorn.access needs pass_url, and pass and call in base units: ${JSON.stringify(access)}`);
|
|
101
110
|
return {
|
|
@@ -110,6 +119,8 @@ export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp,
|
|
|
110
119
|
...(paid ? { paid: { url: paid.url, price: String(paid.price), asset: paid.asset, network: paid.network,
|
|
111
120
|
symbol: paid.symbol ?? "USDC", decimals: paid.decimals ?? 6, description: paid.description ?? "" } } : {}),
|
|
112
121
|
...(access ? { access } : {}),
|
|
122
|
+
...(skill ? { skill: { url: skill.url, sha256: skill.sha256 } } : {}),
|
|
123
|
+
...(plugin ? { plugin: { marketplace: plugin.marketplace, name: plugin.name } } : {}),
|
|
113
124
|
},
|
|
114
125
|
};
|
|
115
126
|
}
|
|
@@ -148,6 +159,12 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
148
159
|
throws(() => fangornExtension({ app: "a" }), /fromBlock/, "missing fromBlock");
|
|
149
160
|
throws(() => fangornExtension({ app: "a", fromBlock: 0, views: ["javascript:alert(1)"] }), /views/, "non-http view");
|
|
150
161
|
throws(() => agentCard({ name: "X" }), /url/, "missing url");
|
|
162
|
+
const sha = "a".repeat(64);
|
|
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");
|
|
164
|
+
throws(() => fangornExtension({ app: "a", fromBlock: 0, skill: { url: "https://x.test/s.md" } }), /skill/, "a skill without its hash");
|
|
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");
|
|
151
168
|
|
|
152
169
|
console.log("agent-card.js self-check ok — tools captured without executing, extension matches the SDK's, bad params fail the build");
|
|
153
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, 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.
|
|
@@ -214,8 +214,9 @@ export function handlers(deps) {
|
|
|
214
214
|
if (!mcp) throw new Error(`${card.name ?? cardUrl} has no MCP server (its card has no \`mcp\`)`);
|
|
215
215
|
let s = slug(card.name ?? app);
|
|
216
216
|
while (open.has(s)) s += "-";
|
|
217
|
-
const
|
|
218
|
-
const
|
|
217
|
+
const ext = card.capabilities?.extensions?.find((e) => e.uri === APP_EXTENSION);
|
|
218
|
+
const access = ext?.params?.access ?? null;
|
|
219
|
+
const o = { slug: s, name: card.name, card: cardUrl, appId, skill: skillOf(ext), plugin: pluginOf(ext), ...await deps.remoteTools(mcp, { access }) };
|
|
219
220
|
trace(`open ${app}: ready`);
|
|
220
221
|
open.set(o.slug, o);
|
|
221
222
|
deps.notify?.("notifications/tools/list_changed");
|
|
@@ -249,7 +250,13 @@ export function handlers(deps) {
|
|
|
249
250
|
"open-app": async ({ app }) => {
|
|
250
251
|
const o = await openApp(app);
|
|
251
252
|
return text({ app: o.slug, name: o.name, card: o.card,
|
|
252
|
-
call_as: `${o.slug}__<tool>, or call-app-tool`, tools: o.tools
|
|
253
|
+
call_as: `${o.slug}__<tool>, or call-app-tool`, tools: o.tools,
|
|
254
|
+
// An app's own skill: standing instructions for the reader's agent, so never loaded
|
|
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." } } : {}),
|
|
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." } } : {}) });
|
|
253
260
|
},
|
|
254
261
|
"call-app-tool": async ({ app, tool, arguments: args }) => call(app, tool, args),
|
|
255
262
|
};
|
|
@@ -298,7 +305,8 @@ if (process.argv[2] === "--selfcheck") {
|
|
|
298
305
|
findApps: async () => ({ apps: [{ name: "Kings Foil", desc: "trials", card: "https://k.test/card", appId: "0xk" },
|
|
299
306
|
{ name: "Bare", desc: "", card: "https://b.test/card", appId: "0xb" }], rejected: [{}] }),
|
|
300
307
|
exploreApps: async (q) => ({ ranked_by: q ? "meaning" : null, apps: [{ app: "kings-foil", name: "Kings Foil", relevant: true }, { app: "b", name: "B", relevant: false }] }),
|
|
301
|
-
resolveCard: async (url) => (url.includes("b.test") ? { card: { name: "Bare" }, mcp: null } : { card: { name: "Kings Foil"
|
|
308
|
+
resolveCard: async (url) => (url.includes("b.test") ? { card: { name: "Bare" }, mcp: null } : { card: { name: "Kings Foil", capabilities: { extensions: [{ uri: APP_EXTENSION,
|
|
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" }),
|
|
302
310
|
remoteTools: async () => (opened++, remote),
|
|
303
311
|
};
|
|
304
312
|
deps.notify = serve(handlers(deps), { input, output });
|
|
@@ -321,6 +329,8 @@ if (process.argv[2] === "--selfcheck") {
|
|
|
321
329
|
call(5, "open-app", { app: "Kings Foil" }); await wait();
|
|
322
330
|
call(6, "open-app", { app: "https://k.test/card" }); await wait();
|
|
323
331
|
assert(body(5).tools[0].name === "search" && opened === 1, "open once, by name or by card URL");
|
|
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");
|
|
324
334
|
assert(replies.some((r) => r.method === "notifications/tools/list_changed"), "opening notifies");
|
|
325
335
|
rpc(7, "tools/list"); call(8, "kings-foil__search", { query: "x" }); call(9, "call-app-tool", { app: "kings-foil", tool: "search" });
|
|
326
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/discover/apps.js
CHANGED
|
@@ -33,6 +33,20 @@ function safeView(url) {
|
|
|
33
33
|
* src/agent/agent-card.js's self-check fails if the two drift. */
|
|
34
34
|
export const APP_EXTENSION = "https://fangorn.network/a2a/app/v1";
|
|
35
35
|
|
|
36
|
+
/** The app's agent skill, `{url, sha256}`, when its card names one well-formed; else null. */
|
|
37
|
+
export const skillOf = (ext) => {
|
|
38
|
+
const k = ext?.params?.skill;
|
|
39
|
+
return k && safeView(k.url) && /^[0-9a-f]{64}$/.test(k.sha256 ?? "") ? { url: k.url, sha256: k.sha256 } : null;
|
|
40
|
+
};
|
|
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
|
+
|
|
36
50
|
/** A verified card → an app entry. `views` is westmarch's param; the SDK does not read it. */
|
|
37
51
|
export function toApp({ card, appId, fromBlock, repos }, cardUrl) {
|
|
38
52
|
const ext = card.capabilities.extensions.find((e) => e.uri === APP_EXTENSION);
|
|
@@ -40,7 +54,7 @@ export function toApp({ card, appId, fromBlock, repos }, cardUrl) {
|
|
|
40
54
|
const p = ext?.params?.paid;
|
|
41
55
|
const paid = p && /^https?:\/\//.test(p.url ?? "") && p.url.includes("{id}") && /^\d+$/.test(String(p.price)) ? p : null;
|
|
42
56
|
return {
|
|
43
|
-
appId, card: cardUrl, fromBlock, repos, views, paid,
|
|
57
|
+
appId, card: cardUrl, fromBlock, repos, views, paid, skill: skillOf(ext), plugin: pluginOf(ext),
|
|
44
58
|
mcp: safeView(ext?.params?.mcp),
|
|
45
59
|
name: card.name ?? appId,
|
|
46
60
|
desc: card.description ?? "",
|
package/src/publish/ship.js
CHANGED
|
@@ -83,11 +83,17 @@ 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
|
|
87
|
+
// "skill": "skill/SKILL.md", // optional: the app's agent skill, served at skill/<app>/SKILL.md and named
|
|
88
|
+
// // in the card with its sha256; fangorn-index offers it to whoever opens the app
|
|
86
89
|
// "agent": "app/agent.js" }, // optional: the module whose registerAgent the card lists, when
|
|
87
90
|
// // `pages` replaces the stock index.html with a page of its own
|
|
88
91
|
// "types": { "<tag>": { "description": "…", "role_map": {…}, "presentation": {…} } },
|
|
89
92
|
// "relations": [{ "rel": "…", "from": "<tag>", "to": "<tag>" }],
|
|
90
93
|
// "sources": [{ "namespace": "…", "cwd": "…", "command": ["python", "-m", "…", …] }],
|
|
94
|
+
// "live": ["readings"], // optional: namespaces members push to from their own wallets (devices,
|
|
95
|
+
// // contributors), presented beside the sources'; true = every repo they push.
|
|
96
|
+
// // Without sources the app already presents every member's repo.
|
|
91
97
|
// "network": "baseSepolia", // required: the SDK's network name. baseSepolia for a new app; an app
|
|
92
98
|
// // already claimed on arbitrumSepolia keeps it (a different name re-claims it there)
|
|
93
99
|
// "fangorn": "fangorn", // the CLI that signs; e.g. "node ~/fangorn/fangorn/lib/cli/cli.js",
|
|
@@ -146,15 +152,27 @@ export function loadConfig(path) {
|
|
|
146
152
|
if (!c.site.project) throw new Error(`${path}: site.project names the Cloudflare Worker`);
|
|
147
153
|
for (const s of c.sources) if (!s.namespace || !Array.isArray(s.command))
|
|
148
154
|
throw new Error(`${path}: each source needs a namespace and a command (an argv array)`);
|
|
155
|
+
if (c.live != null && c.live !== true && !(Array.isArray(c.live) && c.live.every((n) => typeof n === "string")))
|
|
156
|
+
throw new Error(`${path}: live is true (every repo members push) or a list of the namespaces they push to`);
|
|
149
157
|
return { relations: [], tags: [], types: {}, ...c };
|
|
150
158
|
}
|
|
151
159
|
|
|
160
|
+
/** The repo names the view presents: the sources' namespaces plus `live`'s, the ones members push
|
|
161
|
+
* to themselves (devices, contributors). [] means every repo the app presents: no sources, or live: true. */
|
|
162
|
+
export function presentedRepos(cfg) {
|
|
163
|
+
if (cfg.live === true) return [];
|
|
164
|
+
return [...new Set([...cfg.sources.map((s) => s.namespace), ...(cfg.live ?? [])])];
|
|
165
|
+
}
|
|
166
|
+
|
|
152
167
|
/** The Worker's wrangler config: the stock worker, the site as its assets, its bucket, the AI
|
|
153
168
|
* binding, the cron and CONFIG. `worker` is site/worker.js unless a test points elsewhere. */
|
|
154
|
-
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") }) {
|
|
155
170
|
return {
|
|
156
171
|
name, main: worker, compatibility_date: "2025-09-15", compatibility_flags: ["nodejs_compat"],
|
|
157
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 } } : {}),
|
|
158
176
|
assets: { directory: site, binding: "ASSETS" },
|
|
159
177
|
r2_buckets: [{ binding: "VIEW", bucket_name: `${name}-view` }],
|
|
160
178
|
ai: { binding: "AI" },
|
|
@@ -359,7 +377,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
359
377
|
// site.url: the app's own domain, routed to the Worker. Otherwise its workers.dev URL,
|
|
360
378
|
// which is only known once it is deployed: the first deploy learns it and the card follows.
|
|
361
379
|
let url = cfg.site.url?.replace(/\/$/, "") ?? state.url ?? null;
|
|
362
|
-
const repos =
|
|
380
|
+
const repos = presentedRepos(cfg);
|
|
363
381
|
// Paid records are in the committed rows already, encrypted (`paid_ct`: AES-256-GCM under
|
|
364
382
|
// HMAC-SHA256(PAID_SECRET, id), see x402.js). The Worker sells keys, never records, so the
|
|
365
383
|
// data a buyer pays for is on IPFS, committed, and in their hands before they pay.
|
|
@@ -386,7 +404,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
386
404
|
const workerConfig = (u) => {
|
|
387
405
|
const path = join(dir, "wrangler.json");
|
|
388
406
|
writeFileSync(path, JSON.stringify(wranglerConfig({
|
|
389
|
-
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(),
|
|
390
408
|
config: { app: cfg.app, name: cfg.name, description: cfg.description, fromBlock: state.fromBlock, network: cfg.network,
|
|
391
409
|
schema: { types: cfg.types, relations: cfg.relations }, repos: register ? [] : repos, paid: sale(u).sale, private: access(u).config },
|
|
392
410
|
}), null, 1));
|
|
@@ -435,7 +453,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
435
453
|
} else if (deploy) log("card already bound");
|
|
436
454
|
|
|
437
455
|
// A new app has no view until the cron runs, which may be hours off: build the first one now.
|
|
438
|
-
if (deploy && !dry && !register && cfg.sources.length && !await fetch(`${url}/view/cdn/catalog`).then((r) => r.ok, () => false)) {
|
|
456
|
+
if (deploy && !dry && !register && (cfg.sources.length || cfg.live) && !await fetch(`${url}/view/cdn/catalog`).then((r) => r.ok, () => false)) {
|
|
439
457
|
log("no view yet: indexing it here rather than waiting for the cron");
|
|
440
458
|
await indexHere(configPath).catch((e) => log(`✗ first index: ${e.message}; the cron will build it`));
|
|
441
459
|
}
|
|
@@ -537,6 +555,15 @@ export async function buildSite(cfg, base) {
|
|
|
537
555
|
if (cfg.site.pages) cpSync(resolve(base, cfg.site.pages), site, { recursive: true });
|
|
538
556
|
writeFileSync(join(site, "nav.json"), JSON.stringify(cfg.site.nav ?? []));
|
|
539
557
|
writeFileSync(join(site, "examples.json"), JSON.stringify(cfg.site.examples ?? []));
|
|
558
|
+
// site.skill: the app's agent skill (a SKILL.md), served at skill/<app>/SKILL.md and named in the
|
|
559
|
+
// card with its hash, so fangorn-index can offer it to whoever opens the app.
|
|
560
|
+
let skill = null;
|
|
561
|
+
if (cfg.site.skill) {
|
|
562
|
+
const bytes = readFileSync(resolve(base, cfg.site.skill));
|
|
563
|
+
mkdirSync(join(site, "skill", cfg.app), { recursive: true });
|
|
564
|
+
writeFileSync(join(site, "skill", cfg.app, "SKILL.md"), bytes);
|
|
565
|
+
skill = { path: `skill/${cfg.app}/SKILL.md`, sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
566
|
+
}
|
|
540
567
|
// The card lists the tools the page at `url` registers: the stock page's, or the app's own.
|
|
541
568
|
const card = await import("../agent/agent-card.js");
|
|
542
569
|
const { registerAgent } = await import(cfg.site.agent ? pathToFileURL(resolve(base, cfg.site.agent)).href : "../../site/agent.js");
|
|
@@ -552,7 +579,9 @@ export async function buildSite(cfg, base) {
|
|
|
552
579
|
...(paid ? [{ id: "keys", name: "Keys", tags: [...(cfg.tags ?? []), "x402"],
|
|
553
580
|
description: `The keys to sealed records (POST ${u}/paid), per record.` }] : []),
|
|
554
581
|
],
|
|
555
|
-
fangorn: { app: cfg.app, fromBlock, repos, views: [`${u}/view`], mcp: `${u}/mcp`, config, ...(paid ? { paid } : {}), ...(access ? { access } : {})
|
|
582
|
+
fangorn: { app: cfg.app, fromBlock, repos, views: [`${u}/view`], mcp: `${u}/mcp`, config, ...(paid ? { paid } : {}), ...(access ? { access } : {}),
|
|
583
|
+
...(skill ? { skill: { url: `${u}/${skill.path}`, sha256: skill.sha256 } } : {}),
|
|
584
|
+
...(cfg.site.plugin ? { plugin: cfg.site.plugin } : {}) },
|
|
556
585
|
});
|
|
557
586
|
mkdirSync(join(site, ".well-known"), { recursive: true });
|
|
558
587
|
writeFileSync(join(site, ".well-known/agent-card.json"), JSON.stringify(agentCard, null, 1));
|
|
@@ -691,8 +720,11 @@ export async function indexHere(configPath, { port = 8791 } = {}) {
|
|
|
691
720
|
if (secret) writeFileSync(vars, `PAID_SECRET=${secret}\n`, { mode: 0o600 });
|
|
692
721
|
const url = `http://127.0.0.1:${port}`, start = Math.floor(Date.now() / 1000);
|
|
693
722
|
const wrangler = spawn("npx", ["wrangler", "dev", "--config", wpath, "--port", String(port), "--ip", "127.0.0.1", "--test-scheduled"],
|
|
694
|
-
{ cwd: join(HERE, "../.."), stdio: ["ignore", "
|
|
723
|
+
{ cwd: join(HERE, "../.."), stdio: ["ignore", "pipe", "inherit"],
|
|
695
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; });
|
|
696
728
|
try {
|
|
697
729
|
for (;;) {
|
|
698
730
|
if (wrangler.exitCode !== null) throw new Error("wrangler dev exited");
|
|
@@ -704,6 +736,7 @@ export async function indexHere(configPath, { port = 8791 } = {}) {
|
|
|
704
736
|
let catalog = null;
|
|
705
737
|
while (!(catalog?.generated_at >= start)) {
|
|
706
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.");
|
|
707
740
|
await new Promise((ok) => setTimeout(ok, 5000));
|
|
708
741
|
catalog = await fetch(`${url}/view/cdn/catalog`).then((r) => (r.ok ? r.json() : null), () => null);
|
|
709
742
|
}
|
|
@@ -732,12 +765,30 @@ export async function buildLocal(configPath, out, { crawl = false, only = null,
|
|
|
732
765
|
const appId = `0x${createHash("sha256").update(cfg.app).digest("hex")}`, owner = `0x${"10ca1".padStart(40, "0")}`;
|
|
733
766
|
const chain = {};
|
|
734
767
|
// Storage addresses a record by its content, and so does this.
|
|
735
|
-
for (const [ns, records] of Object.entries(readStage(cfg, base, { crawl, only, log: say })))
|
|
768
|
+
if (cfg.sources.length) for (const [ns, records] of Object.entries(readStage(cfg, base, { crawl, only, log: say })))
|
|
736
769
|
chain[`${owner}/${ns}`] = { records: records.map((r) => ({ ...r, cid: createHash("sha256").update(JSON.stringify(stable(r.payload))).digest("hex") })) };
|
|
770
|
+
// What members already pushed to the live namespaces, read from the chain (reads only, no gas),
|
|
771
|
+
// so a recipe is graded on contributed data too, not just on what the sources staged.
|
|
772
|
+
if (cfg.live) {
|
|
773
|
+
if (cfg.network) process.env.FANGORN_NETWORK = process.env.CHAIN_NAME = cfg.network;
|
|
774
|
+
const fangorn = await view.readOnlyFangorn();
|
|
775
|
+
fangorn.setAppId(cfg.app);
|
|
776
|
+
const statePath = join(base, ".ship/state.json");
|
|
777
|
+
const fromBlock = (existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")).fromBlock : null) ?? cfg.fromBlock;
|
|
778
|
+
const staged = new Set(cfg.sources.map((s) => s.namespace));
|
|
779
|
+
for (const t of await fangorn.appRepos(fromBlock == null ? {} : { fromBlock: BigInt(fromBlock) })) {
|
|
780
|
+
if (t.repo === "fangorn.schema" || staged.has(t.repo) || (only && !only.includes(t.repo))
|
|
781
|
+
|| (cfg.live !== true && !cfg.live.includes(t.repo))) continue;
|
|
782
|
+
const { contents } = await fangorn.readRepo(t.owner, t.repo);
|
|
783
|
+
chain[`${t.owner.toLowerCase()}/${t.repo}`] = { records: contents.records };
|
|
784
|
+
say(`live ${t.owner}/${t.repo}: ${contents.records.length} records, from the chain`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (!Object.keys(chain).length) throw new Error("nothing to build: no sources staged anything and no member has pushed to a live namespace");
|
|
737
788
|
rmSync(out, { recursive: true, force: true });
|
|
738
789
|
mkdirSync(join(base, ".ship"), { recursive: true });
|
|
739
790
|
const embed = view.cachedEmbed(join(base, ".ship/vectors.ndjson"));
|
|
740
|
-
const repos =
|
|
791
|
+
const repos = presentedRepos(cfg).filter((n) => !only || only.includes(n));
|
|
741
792
|
const report = await view.publishView({ fangorn: view.localFangorn(chain, { appId, owner }), app: cfg.app,
|
|
742
793
|
schema: { types: cfg.types, relations: cfg.relations }, repos, out, embed, log: say });
|
|
743
794
|
const rows = Object.values(report).reduce((n, r) => n + r.added, 0);
|
|
@@ -763,6 +814,10 @@ if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch
|
|
|
763
814
|
const dp = discovery({ name: "Q", description: "q" }, "https://q.example", null, { pass: "5000000", days: 30, call: "10000", decimals: 6 });
|
|
764
815
|
if (dp.openapi.paths["/pass"].post["x-payment-info"].price.amount !== "5" || dp.x402.resources.join() !== "https://q.example/pass" || dp.openapi.paths["/paid"])
|
|
765
816
|
throw new Error(`private discovery: ${JSON.stringify(dp.x402)}`);
|
|
817
|
+
const src = [{ namespace: "towns", command: ["py"] }];
|
|
818
|
+
if (presentedRepos({ sources: src }).join() !== "towns" || presentedRepos({ sources: src, live: ["readings", "towns"] }).join() !== "towns,readings"
|
|
819
|
+
|| presentedRepos({ sources: src, live: true }).length || presentedRepos({ sources: [] }).length)
|
|
820
|
+
throw new Error("presentedRepos: sources plus live namespaces; [] (every repo) for live: true or no sources");
|
|
766
821
|
if (units("0.01") !== "10000" || units("5") !== "5000000" || units("1.5", 2) !== "150") throw new Error("units");
|
|
767
822
|
if (USDC["eip155:84532"].name !== "USDC" || USDC["eip155:8453"].name !== "USD Coin") throw new Error("USDC's EIP-712 name differs on Base Sepolia and Base");
|
|
768
823
|
console.log("ship.js self-check ok — a source template fills per row, the Worker config binds its bucket, AI and cron, and the paid route is priced per id in /openapi.json and named in /.well-known/x402");
|
package/src/publish/view.js
CHANGED
|
@@ -168,12 +168,21 @@ export async function readOnlyFangorn() {
|
|
|
168
168
|
* A build that must not touch the chain — a candidate recipe graded before it ships —
|
|
169
169
|
* runs the exact code a real one does. */
|
|
170
170
|
export function localFangorn(chain, { appId, owner }) {
|
|
171
|
-
|
|
171
|
+
// Every version handed out is kept, so repoDiff can diff any two, as the SDK diffs two roots.
|
|
172
|
+
const versions = new Map();
|
|
173
|
+
const root = (k) => { const h = `0x${createHash("sha256").update(JSON.stringify(chain[k])).digest("hex")}`; versions.set(h, structuredClone(chain[k]?.records ?? [])); return h; };
|
|
174
|
+
const repoDiff = async (from, to) => {
|
|
175
|
+
if (!versions.has(from) || !versions.has(to)) throw new Error("unknown root");
|
|
176
|
+
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));
|
|
177
|
+
return { puts: b.filter((r) => cidOf.get(r.id) !== r.cid), deletes: a.filter((r) => !ids.has(r.id)).map((r) => r.id),
|
|
178
|
+
superseded: a.filter((r) => !ids.has(r.id) || b.some((x) => x.id === r.id && x.cid !== r.cid)).map((r) => r.cid) };
|
|
179
|
+
};
|
|
172
180
|
return {
|
|
181
|
+
engine: { repoDiff }, reads: 0,
|
|
173
182
|
setAppId() {}, getAppId: () => appId,
|
|
174
183
|
getAppRegistry: () => ({ getAppOwner: async () => owner }),
|
|
175
184
|
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)
|
|
185
|
+
readRepo: async function (o, repo) { this.reads++; const c = chain[`${o}/${repo}`]; return { tip: c ? root(`${o}/${repo}`) : null, contents: { records: c?.records ?? [] } }; },
|
|
177
186
|
};
|
|
178
187
|
}
|
|
179
188
|
|
|
@@ -243,11 +252,12 @@ export function r2Store(bucket, prefix = "view/") {
|
|
|
243
252
|
const readJson = async (store, p, dflt) => { const b = await store.get(p); return b ? JSON.parse(new TextDecoder().decode(b)) : dflt; };
|
|
244
253
|
const writeJson = (store, p, o) => store.put(p, JSON.stringify(o, null, 1));
|
|
245
254
|
|
|
246
|
-
/** What a domain already serves: its manifest and every row (for tombstones and coverage)
|
|
247
|
-
|
|
255
|
+
/** What a domain already serves: its manifest and every row (for tombstones and coverage), or
|
|
256
|
+
* with `only`, the rows of those shard files alone. */
|
|
257
|
+
async function loadDomain(store, dir, only = null) {
|
|
248
258
|
const manifest = await readJson(store, `${dir}/manifest`, null);
|
|
249
259
|
const rows = new Map();
|
|
250
|
-
for (const s of manifest?.shards ?? []) {
|
|
260
|
+
for (const s of (manifest?.shards ?? []).filter((x) => !only || only.includes(x.file))) {
|
|
251
261
|
const b = await store.get(`${dir}/shards/${s.file}`);
|
|
252
262
|
if (!b) throw new Error(`${dir}: shard ${s.file} is named by its manifest and missing`);
|
|
253
263
|
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 +349,38 @@ async function retry(f, tries = 6) {
|
|
|
339
349
|
}
|
|
340
350
|
}
|
|
341
351
|
|
|
352
|
+
const CHECKPOINT = 2048; // rows embedded between saves; a killed run redoes at most this many
|
|
342
353
|
async function publishDomain({ fangorn, store, schema, schemaHash, publisher, repo, head, domain, dir, rebake, embedBatch, embedder, log, shardRows, open }) {
|
|
343
354
|
// The head the domain was built from, and the schema it was built under: neither moved,
|
|
344
355
|
// nothing to read. This is what keeps a cron run with nothing new to one read per repo.
|
|
345
356
|
const was = rebake ? null : await readJson(store, `${dir}/manifest`, null);
|
|
346
357
|
if (was && was.head === head && was.schema_hash === schemaHash) return null;
|
|
347
358
|
|
|
348
|
-
|
|
359
|
+
// The head moved from one this domain was built from: read only what changed between them (the
|
|
360
|
+
// SDK's repoDiff opens only the changed paths of the record tree), not every record again. A
|
|
361
|
+
// schema with relations links each row against all the others, so it still reads the whole repo;
|
|
362
|
+
// so does an SDK whose diff can't name the replaced versions, and a diff that fails (a force-push
|
|
363
|
+
// to a head whose blocks are gone).
|
|
364
|
+
// ponytail: relations read whole; diff their targets too when a big corpus declares one.
|
|
365
|
+
const diff = was?.head && !schema.relations?.length && fangorn.engine?.repoDiff
|
|
366
|
+
? await retry(() => fangorn.engine.repoDiff(was.head, head)).catch((e) => { log(`${domain}: diff from ${was.head} failed (${e.message}); reading it whole`); return null; })
|
|
367
|
+
: null;
|
|
368
|
+
const got = diff?.superseded ? diff.puts : (await retry(() => fangorn.readRepo(publisher, repo))).contents.records;
|
|
349
369
|
// `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(
|
|
370
|
+
const records = new Map(await Promise.all(got.filter((r) => r.payload && typeof r.payload === "object")
|
|
351
371
|
.map(async (r) => [r.cid, { id: r.id, fields: { ...(await open(r.payload)), entityType: r.type } }])));
|
|
352
372
|
linkRelations(records, schema);
|
|
353
373
|
const fieldsOf = (cid) => records.get(cid).fields;
|
|
354
374
|
|
|
355
375
|
if (rebake) await store.del(dir); // old shards would stay reachable
|
|
356
|
-
|
|
376
|
+
// Append only: the diff replaced nothing, so no row already served changes, and the 50k of them
|
|
377
|
+
// are left unread. Only the shards saved since the last head (`ahead`, a killed run's checkpoints)
|
|
378
|
+
// are read, to skip what they hold. A removal, an edit, a rebake or a coverage refit reads all.
|
|
379
|
+
// Rows in `ahead` are already counted, and come back among the puts of a diff from the old head.
|
|
380
|
+
const aheadRows = (was?.shards ?? []).filter((x) => was.ahead?.includes(x.file)).reduce((n, x) => n + x.count, 0);
|
|
381
|
+
const append = !!was && !!diff?.superseded && !diff.superseded.length && !was.tombstones?.length
|
|
382
|
+
&& (was.count ?? 0) + diff.puts.length - aheadRows < COVERAGE_REFIT * (was.coverage?.fit_at ?? 0);
|
|
383
|
+
const had = await loadDomain(store, dir, append ? was.ahead ?? [] : null);
|
|
357
384
|
const made = had.manifest?.embedder ?? (had.manifest ? { runtime: "fastembed", dtype: "fp32" } : null);
|
|
358
385
|
// Same model and dtype is the same encoder, whatever ran it: q8 on CPU, CUDA and WASM agree to
|
|
359
386
|
// ~0.98-0.99 cosine, fp32 against q8 noticeably less.
|
|
@@ -364,10 +391,11 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
|
|
|
364
391
|
const dead = new Set(had.manifest?.tombstones ?? []);
|
|
365
392
|
const live = (id) => had.rows.has(id) && !dead.has(id);
|
|
366
393
|
const fresh = [...records.keys()].filter((id) => !live(id));
|
|
367
|
-
|
|
394
|
+
// From a diff, `records` holds only what changed: what is gone is what it superseded.
|
|
395
|
+
const gone = diff?.superseded ? diff.superseded.filter(live) : [...had.rows.keys()].filter((id) => live(id) && !records.has(id));
|
|
368
396
|
// 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)
|
|
397
|
+
// the same vector, so no embedding, only a rewrite. Only relations relink, and they read whole.
|
|
398
|
+
const relinked = diff?.superseded ? [] : [...had.rows.keys()].filter((id) => live(id) && records.has(id)
|
|
371
399
|
&& JSON.stringify(had.rows.get(id).fields) !== JSON.stringify(fieldsOf(id)));
|
|
372
400
|
for (const id of relinked) had.rows.get(id).fields = fieldsOf(id);
|
|
373
401
|
|
|
@@ -383,6 +411,34 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
|
|
|
383
411
|
}
|
|
384
412
|
}
|
|
385
413
|
|
|
414
|
+
const manifest = had.manifest ?? { name: domain, description: "", count: 0, dim: EMBED_DIM, model: EMBED_MODEL,
|
|
415
|
+
distance: "Cosine", embedder, filter: { owner: [publisher], repo: [repo] }, shards: [], tombstones: [] };
|
|
416
|
+
// Pages and R2 serve files of any size, but a reader holds a shard whole; 52k Steam games
|
|
417
|
+
// gzipped to 28 MiB in one. ponytail: split by row count (~550 B a row gzipped).
|
|
418
|
+
const writeShard = async (all) => {
|
|
419
|
+
const files = [];
|
|
420
|
+
for (let i = 0; i < all.length; i += shardRows) {
|
|
421
|
+
const ls = all.slice(i, i + shardRows);
|
|
422
|
+
const gz = gzipSync(`${ls.join("\n")}\n`, { level: 9 });
|
|
423
|
+
const digest = sha256(gz);
|
|
424
|
+
const file = `shard-${String(manifest.shards.length).padStart(4, "0")}-${digest.slice(0, 12)}.ndjson.gz`;
|
|
425
|
+
await store.put(`${dir}/shards/${file}`, gz);
|
|
426
|
+
manifest.shards.push({ file, count: ls.length, bytes: gz.length, sha256: digest });
|
|
427
|
+
files.push(file);
|
|
428
|
+
}
|
|
429
|
+
return files;
|
|
430
|
+
};
|
|
431
|
+
// What is embedded so far, served now under the old head: a run killed later (CPU, wall time:
|
|
432
|
+
// no exception, no `finally`) loses at most CHECKPOINT rows, and the next run embeds only the rest.
|
|
433
|
+
const save = async () => {
|
|
434
|
+
manifest.ahead = [...(manifest.ahead ?? []), ...await writeShard(lines)];
|
|
435
|
+
const rows = lines.map((l) => JSON.parse(l));
|
|
436
|
+
for (const r of rows) { had.rows.set(r.track_id, r); dead.delete(r.track_id); }
|
|
437
|
+
lines.length = 0;
|
|
438
|
+
manifest.head = had.manifest?.head ?? null;
|
|
439
|
+
manifest.schema_hash = schemaHash;
|
|
440
|
+
await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf, append ? rows : null);
|
|
441
|
+
};
|
|
386
442
|
let n = 0, stopped = null;
|
|
387
443
|
const lines = [];
|
|
388
444
|
// 32 at a time: what an encoder batches (embed.js, a GPU, the Worker's AI binding).
|
|
@@ -400,39 +456,26 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
|
|
|
400
456
|
}
|
|
401
457
|
if (Math.floor((n + ids.length) / 1000) > Math.floor(n / 1000)) log(`${domain}: embedded ${n + ids.length}/${fresh.length}`);
|
|
402
458
|
n += ids.length;
|
|
459
|
+
if (lines.length >= CHECKPOINT && n < fresh.length) await save();
|
|
403
460
|
}
|
|
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
461
|
// Unfinished: the old head stays, so the next run sees a head to catch up to and embeds only what is missing.
|
|
407
462
|
manifest.head = stopped ? (had.manifest?.head ?? null) : head;
|
|
408
463
|
manifest.schema_hash = schemaHash;
|
|
464
|
+
if (!stopped) delete manifest.ahead; // the head is taken: everything is behind it
|
|
409
465
|
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
|
|
466
|
+
await applySpec(store, dir, manifest, schema, had.rows, dead, new Map(), append ? [] : null); // a schema change alone still lands
|
|
411
467
|
return { added: 0, removed: 0 };
|
|
412
468
|
}
|
|
413
469
|
|
|
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
470
|
if (stopped) {
|
|
427
|
-
await
|
|
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);
|
|
471
|
+
await save();
|
|
430
472
|
log(`${domain}: stopped at ${n}/${fresh.length} (${stopped.message}); the rest on the next run`);
|
|
431
473
|
return { added: n, removed: 0, partial: true };
|
|
432
474
|
}
|
|
475
|
+
const last = lines.map((l) => JSON.parse(l));
|
|
433
476
|
if (lines.length) {
|
|
434
477
|
await writeShard(lines);
|
|
435
|
-
for (const
|
|
478
|
+
for (const r of last) { had.rows.set(r.track_id, r); dead.delete(r.track_id); }
|
|
436
479
|
}
|
|
437
480
|
for (const id of gone) dead.add(id);
|
|
438
481
|
// A tombstone hides a row from readers, but its shard still serves the bytes. A record
|
|
@@ -441,6 +484,8 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
|
|
|
441
484
|
// files are deleted. No re-embedding: the vectors are already in the rows.
|
|
442
485
|
// ponytail: rewrites the whole domain on every removal; compact past a threshold if domains get big.
|
|
443
486
|
if (dead.size || relinked.length) {
|
|
487
|
+
// A compaction rewrites `had.rows` as the whole domain, which append mode never loaded.
|
|
488
|
+
if (append) throw new Error(`${domain}: append mode reached a compaction; nothing written past the new shard`);
|
|
444
489
|
for (const s of manifest.shards) await store.del(`${dir}/shards/${s.file}`);
|
|
445
490
|
manifest.shards = [];
|
|
446
491
|
for (const id of dead) had.rows.delete(id);
|
|
@@ -449,16 +494,19 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
|
|
|
449
494
|
}
|
|
450
495
|
manifest.tombstones = [...dead].sort();
|
|
451
496
|
manifest.created_at = Math.floor(Date.now() / 1000);
|
|
452
|
-
await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf);
|
|
497
|
+
await applySpec(store, dir, manifest, schema, had.rows, dead, rolesOf, append ? last : null);
|
|
453
498
|
log(`${domain}: +${fresh.length} embedded, -${gone.length} removed, ${relinked.length} relinked, ${manifest.count} live`);
|
|
454
499
|
return { added: fresh.length, removed: gone.length, ...(relinked.length ? { relinked: relinked.length } : {}) };
|
|
455
500
|
}
|
|
456
501
|
|
|
457
502
|
/** 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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
503
|
+
* links to (what a Worker may show inline, see site/worker.js) from what the domain holds.
|
|
504
|
+
* `added`: append mode, where `rows` is not the whole domain: fold these new rows into the
|
|
505
|
+
* manifest's totals instead of recounting. Coverage is left as fitted (publishDomain appends only
|
|
506
|
+
* while no refit is due). ponytail: hosts only grow on append; a removal recounts them. */
|
|
507
|
+
async function applySpec(store, dir, manifest, schema, rows, dead, guessed = new Map(), added = null) {
|
|
508
|
+
const liveRows = added ?? [...rows.values()].filter((r) => !dead.has(r.track_id));
|
|
509
|
+
const types = added ? Object.fromEntries((manifest.entity_types ?? []).map((e) => [e.type, e.count])) : {};
|
|
462
510
|
for (const r of liveRows) types[r.fields?.entityType] = (types[r.fields?.entityType] ?? 0) + 1;
|
|
463
511
|
const tags = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([t]) => t);
|
|
464
512
|
// Compare with what is stored, not with the manifest as it arrived: publishDomain has already
|
|
@@ -466,20 +514,20 @@ async function applySpec(store, dir, manifest, schema, rows, dead, guessed = new
|
|
|
466
514
|
// the old manifest naming deleted shards.
|
|
467
515
|
const before = JSON.stringify(await readJson(store, `${dir}/manifest`, null));
|
|
468
516
|
|
|
469
|
-
manifest.count = liveRows.length;
|
|
517
|
+
manifest.count = added ? (manifest.count ?? 0) + added.length : liveRows.length;
|
|
470
518
|
manifest.entity_types = tags.map((t) => ({ type: t, count: types[t] }));
|
|
471
519
|
const spec = domainSpec(schema, tags);
|
|
472
|
-
const fields = [...new Set(liveRows.slice(0, 500).flatMap((r) => Object.keys(r.fields ?? {})))].sort();
|
|
520
|
+
const fields = [...new Set([...(added ? manifest.role_map?.fields ?? [] : []), ...liveRows.slice(0, 500).flatMap((r) => Object.keys(r.fields ?? {}))])].sort();
|
|
473
521
|
manifest.role_map = { ...(spec?.role_map ?? manifest.role_map ?? guessed.values().next().value ?? completeRoles()), fields };
|
|
474
522
|
if (spec?.description) manifest.description = spec.description;
|
|
475
523
|
if (spec && Object.keys(spec.presentation).length) manifest.presentation = spec.presentation;
|
|
476
524
|
const media = [manifest.role_map.media ?? []].flat();
|
|
477
|
-
manifest.link_hosts = [...new Set(liveRows.flatMap((r) => media.map((m) => r.fields?.[m]))
|
|
525
|
+
manifest.link_hosts = [...new Set([...(added ? manifest.link_hosts ?? [] : []), ...liveRows.flatMap((r) => media.map((m) => r.fields?.[m]))
|
|
478
526
|
.filter((u) => typeof u === "string" && u.startsWith("https://"))
|
|
479
|
-
.map((u) => { try { return new URL(u).hostname; } catch { return null; } }).filter(Boolean))].sort();
|
|
527
|
+
.map((u) => { try { return new URL(u).hostname; } catch { return null; } })].filter(Boolean))].sort();
|
|
480
528
|
|
|
481
529
|
const cov = manifest.coverage;
|
|
482
|
-
if (liveRows.length && (!cov || liveRows.length >= COVERAGE_REFIT * (cov.fit_at ?? 0))) {
|
|
530
|
+
if (!added && liveRows.length && (!cov || liveRows.length >= COVERAGE_REFIT * (cov.fit_at ?? 0))) {
|
|
483
531
|
manifest.coverage = { ...fitCoverage(liveRows.map((r) => unpackVec(r.v))), fit_at: liveRows.length };
|
|
484
532
|
}
|
|
485
533
|
if (JSON.stringify(manifest) !== before) await writeJson(store, `${dir}/manifest`, manifest);
|
|
@@ -617,6 +665,49 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
617
665
|
eq((await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
|
|
618
666
|
{ added: 48, removed: 0 }, "the next run embeds only the rest");
|
|
619
667
|
eq(manifestOf(big, bd).count, 80, "and serves all of it");
|
|
668
|
+
// Killed partway (a Worker over its CPU or wall time: no exception, no return): the last
|
|
669
|
+
// checkpoint is served, and the next run embeds only what came after it.
|
|
670
|
+
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}` } })) };
|
|
671
|
+
let left = CHECKPOINT + 64;
|
|
672
|
+
const dies = (ts) => (left -= ts.length) < 0 ? new Promise(() => {}) : Promise.all(ts.map(embed));
|
|
673
|
+
publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: dies, log: () => {} });
|
|
674
|
+
for (let i = 0; i < 200 && manifestOf(big, bd).count < CHECKPOINT; i++) await new Promise((ok) => setTimeout(ok, 10));
|
|
675
|
+
eq((await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
|
|
676
|
+
{ added: 100, removed: 80 }, "a killed run resumes from its last checkpoint");
|
|
677
|
+
// A built domain whose head moves reads only the diff: 10 new tracks on top of thousands
|
|
678
|
+
// never read the thousands again, and a removal or an edit drops the version it replaced.
|
|
679
|
+
const whole = fangorn.reads;
|
|
680
|
+
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}` } })));
|
|
681
|
+
chain[BIG].records.splice(0, 1, { ...chain[BIG].records[0], cid: "kill0-v2", payload: { sku: "k0", name: "Kept 0, renamed" } });
|
|
682
|
+
chain[BIG].records.splice(1, 1);
|
|
683
|
+
eq([(await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd], fangorn.reads - whole],
|
|
684
|
+
[{ added: 11, removed: 2 }, 0], "a moved head reads only what changed");
|
|
685
|
+
eq(manifestOf(big, bd).count, CHECKPOINT + 100 + 10 - 1, "and serves the repo as it now stands");
|
|
686
|
+
// Only additions: the rows already served are not read at all, only a delta shard is written.
|
|
687
|
+
const disk = fsStore(big), opened = [];
|
|
688
|
+
const watched = { ...disk, get: (p) => { opened.push(p); return disk.get(p); } };
|
|
689
|
+
const n0 = manifestOf(big, bd).count, shards0 = manifestOf(big, bd).shards.length;
|
|
690
|
+
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}` } })));
|
|
691
|
+
eq((await publishView({ fangorn, app: APP, schema, store: watched, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
|
|
692
|
+
{ added: 10, removed: 0 }, "an append embeds only the new records");
|
|
693
|
+
eq(opened.filter((p) => p.includes("/shards/")), [], "and reads no shard it already served");
|
|
694
|
+
const after = manifestOf(big, bd);
|
|
695
|
+
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");
|
|
696
|
+
eq((await liveFields(big)).length, n0 + 10, "and every row is still served");
|
|
697
|
+
// An append killed after a checkpoint: the next run reads only that checkpoint's shard, and
|
|
698
|
+
// embeds only what came after it, never the checkpointed rows a second time.
|
|
699
|
+
const n1 = manifestOf(big, bd).count, fit = manifestOf(big, bd).coverage.fit_at, extra = CHECKPOINT + 2;
|
|
700
|
+
if (n1 + extra >= COVERAGE_REFIT * fit) throw new Error(`test setup: ${n1} + ${extra} rows would refit coverage (fit at ${fit}), not append`);
|
|
701
|
+
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}` } })));
|
|
702
|
+
left = CHECKPOINT + 1;
|
|
703
|
+
publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: dies, log: () => {} });
|
|
704
|
+
for (let i = 0; i < 300 && !manifestOf(big, bd).ahead; i++) await new Promise((ok) => setTimeout(ok, 10));
|
|
705
|
+
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");
|
|
706
|
+
opened.length = 0;
|
|
707
|
+
eq((await publishView({ fangorn, app: APP, schema, store: watched, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
|
|
708
|
+
{ added: 2, removed: 0 }, "and the next run embeds only what came after it");
|
|
709
|
+
eq([opened.filter((p) => p.includes("/shards/")).length, manifestOf(big, bd).count, (await liveFields(big)).length],
|
|
710
|
+
[1, n1 + extra, n1 + extra], "reading only the checkpoint's shard, and serving each row once");
|
|
620
711
|
delete chain[BIG];
|
|
621
712
|
rmSync(big, { recursive: true, force: true });
|
|
622
713
|
eq((await publishView({ fangorn, app: APP, schema, out, embed, rebake: true, log: () => {} }))[d],
|
package/template/README.md
CHANGED
|
@@ -39,8 +39,9 @@ fangorn --app my-app push
|
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
The view picks it up within 5 minutes at `<url>/view`. A source that crawls something
|
|
42
|
-
on a schedule goes in `app.json` `sources`, and
|
|
43
|
-
|
|
42
|
+
on a schedule goes in `app.json` `sources`, and publishes with
|
|
43
|
+
`npx westmarch-ship app.json --no-deploy` (the push to `main` deploys; without the flag it
|
|
44
|
+
would deploy a second copy over this Worker). See westmarch's docs.
|
|
44
45
|
|
|
45
46
|
## Keep it good
|
|
46
47
|
|