@githolon/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,36 @@
1
+ # Nomos Pre-Release License (v1)
2
+
3
+ Copyright © 2026 Captain App Ltd. All rights reserved.
4
+
5
+ This is a pre-release of Nomos. This license gives you what you need to BUILD
6
+ with it; we keep the rest for now.
7
+
8
+ ## You may
9
+
10
+ - install and use these packages to author Nomos domains, compile them, and
11
+ deploy them to Nomos Cloud or any Nomos instance Captain App operates or
12
+ authorizes;
13
+ - build, run, and ship applications on top of them — including commercial ones;
14
+ - keep everything that's yours: code you write, and everything these tools
15
+ generate FOR you (scaffolds from `create-holon` / `holon generate`, generated
16
+ clients, compiled domain packages) carries NO restriction from us — it is
17
+ yours outright.
18
+
19
+ ## You may not
20
+
21
+ - redistribute these packages or their source, in whole or in part, outside
22
+ your team;
23
+ - modify them or build derivative tools, SDKs, or runtimes from their source;
24
+ - offer the Nomos runtime, or anything materially similar, as a hosted service;
25
+ - reverse-engineer the holon wasm runtime.
26
+
27
+ ## The rest
28
+
29
+ Provided **as is**, with no warranty of any kind; to the maximum extent
30
+ permitted by law, Captain App Ltd accepts no liability arising from your use.
31
+ This license terminates automatically if you breach it.
32
+
33
+ Want the kernel source, broader rights, or to do something this doesn't cover?
34
+ **Ask: jack@captainapp.co.uk.** The plan is to open Nomos up gradually, with
35
+ the people actually using it — telling us what you're building is how that
36
+ happens faster.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@githolon/client",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Nomos Cloud web client — a LOCAL holon for offline-first apps: pulls the workspace ledger (git) from Nomos Cloud, replays it into the in-memory SQLite projection inside the wasm32-wasip1 GitHolon, and exposes dispatch / watch / sync. The browser runs the SAME byte-identical holon artifact the edge runs.",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Captain-App/nomos2.git",
10
+ "directory": "cloud/web-client"
11
+ },
12
+ "exports": { ".": "./src/index.mjs" },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "e2e": "node test/e2e.mjs",
21
+ "e2e:convergence": "node test/convergence.e2e.mjs"
22
+ },
23
+ "dependencies": {
24
+ "@bjorn3/browser_wasi_shim": "0.4.2",
25
+ "isomorphic-git": "^1.38.4"
26
+ }
27
+ }
package/src/git-fs.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // An isomorphic-git fs adapter over a @bjorn3/browser_wasi_shim PreopenDirectory tree.
2
+ //
3
+ // This is what lets the DO push/fetch the EXACT same in-memory git repo that the holon's gix
4
+ // writes (loose objects under /work/workspaces/<ws>/nomos.git) — no copy, no second store. The
5
+ // holon commits via gix; isomorphic-git reads those loose objects off this same tree and pushes
6
+ // them to the Artifacts remote (and writes fetched objects back for the holon to read).
7
+ //
8
+ // isomorphic-git uses the Node-style fs.promises surface and is sensitive to error `.code`
9
+ // (ENOENT/EEXIST/ENOTDIR/EISDIR), so we set those exactly.
10
+
11
+ function fsErr(code, p) { const e = new Error(`${code}: ${p}`); e.code = code; return e; }
12
+
13
+ // Build an fs ({promises:{...}}) rooted at `mount` (e.g. "/work") over `preopen.dir`.
14
+ export function makeGitFs(preopen, mount, { File, Directory }) {
15
+ const root = preopen.dir; // Directory: { contents: Map }
16
+ const isDir = (n) => n && n.contents instanceof Map;
17
+
18
+ function parts(p) {
19
+ if (p === mount) return [];
20
+ if (!p.startsWith(mount + "/")) throw fsErr("ENOENT", p);
21
+ const rel = p.slice(mount.length + 1).replace(/\/+$/, "");
22
+ return rel === "" ? [] : rel.split("/");
23
+ }
24
+ function resolve(ps) {
25
+ let cur = root;
26
+ for (const part of ps) {
27
+ if (!isDir(cur)) return null;
28
+ const next = cur.contents.get(part);
29
+ if (next === undefined) return null;
30
+ cur = next;
31
+ }
32
+ return cur;
33
+ }
34
+ const parent = (ps) => resolve(ps.slice(0, -1));
35
+ const leaf = (ps) => ps[ps.length - 1];
36
+
37
+ const promises = {
38
+ async readFile(p, opts) {
39
+ const n = resolve(parts(p));
40
+ if (n === null) throw fsErr("ENOENT", p);
41
+ if (isDir(n)) throw fsErr("EISDIR", p);
42
+ const data = n.data ?? new Uint8Array(0);
43
+ const encoding = typeof opts === "string" ? opts : opts && opts.encoding;
44
+ return encoding ? new TextDecoder().decode(data) : new Uint8Array(data);
45
+ },
46
+ async writeFile(p, data) {
47
+ const ps = parts(p);
48
+ const par = parent(ps);
49
+ if (!isDir(par)) throw fsErr("ENOENT", p);
50
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
51
+ par.contents.set(leaf(ps), new File(bytes));
52
+ },
53
+ async unlink(p) {
54
+ const ps = parts(p), par = parent(ps);
55
+ if (!isDir(par) || !par.contents.delete(leaf(ps))) throw fsErr("ENOENT", p);
56
+ },
57
+ async readdir(p) {
58
+ const n = resolve(parts(p));
59
+ if (n === null) throw fsErr("ENOENT", p);
60
+ if (!isDir(n)) throw fsErr("ENOTDIR", p);
61
+ return [...n.contents.keys()];
62
+ },
63
+ async mkdir(p) {
64
+ const ps = parts(p), par = parent(ps);
65
+ if (!isDir(par)) throw fsErr("ENOENT", p);
66
+ if (par.contents.has(leaf(ps))) throw fsErr("EEXIST", p);
67
+ par.contents.set(leaf(ps), new Directory(new Map()));
68
+ },
69
+ async rmdir(p) {
70
+ const ps = parts(p), par = parent(ps);
71
+ if (!isDir(par)) throw fsErr("ENOENT", p);
72
+ par.contents.delete(leaf(ps));
73
+ },
74
+ async stat(p) { return promises.lstat(p); },
75
+ async lstat(p) {
76
+ const n = resolve(parts(p));
77
+ if (n === null) throw fsErr("ENOENT", p);
78
+ const dir = isDir(n);
79
+ return {
80
+ type: dir ? "dir" : "file",
81
+ mode: dir ? 0o040000 : 0o100644,
82
+ size: dir ? 0 : (n.data ? n.data.length : 0),
83
+ ino: 0, mtimeMs: 0, ctimeMs: 0, uid: 1, gid: 1, dev: 1,
84
+ isFile: () => !dir, isDirectory: () => dir, isSymbolicLink: () => false,
85
+ };
86
+ },
87
+ async readlink(p) { throw fsErr("EINVAL", p); },
88
+ async symlink() { throw fsErr("EPERM", "symlink unsupported"); },
89
+ async chmod() {},
90
+ };
91
+ return { promises };
92
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,464 @@
1
+ // ─── @githolon/client — a LOCAL holon for offline-first apps ───────────────────────────────────
2
+ //
3
+ // Sovereignty in the browser: this client does NOT ask the cloud what the state is. It
4
+ // 1. fetches the holon runtime from Nomos Cloud (`/v1/runtime/holon.wasm` — the byte-identical
5
+ // wasm32-wasip1 GitHolon the edge itself runs),
6
+ // 2. git-PULLS the workspace ledger `main` from the branded remote (`/v1/workspaces/:ws/git`),
7
+ // 3. REPLAYS it locally — the holon folds the commits into its in-memory SQLite projection,
8
+ // verifying as it goes (content-addressed law, signatures; custody is never trusted),
9
+ // 4. exposes `dispatch` / `watch` / `query` / `sync` for the app.
10
+ //
11
+ // Writes are LOCAL-FIRST: `dispatch` commits to the local ledger under the workspace's installed
12
+ // law (resolved from the pulled history, offline). `sync()` pushes the local commits to an
13
+ // UNTRUSTED SESSION BRANCH (`session/<clientId>`) on the cloud — the edge holon validates and
14
+ // merges to `main` (the admission gate), and `sync()` pulls an advanced `main` back down, folding
15
+ // only the delta (the projection cursor).
16
+ import { WASI, File, Directory, OpenFile, ConsoleStdout, PreopenDirectory } from "@bjorn3/browser_wasi_shim";
17
+ import git from "isomorphic-git";
18
+ import http from "isomorphic-git/http/web";
19
+ import { makeGitFs } from "./git-fs.mjs";
20
+ import { serializeTree, deserializeTree } from "./tree.mjs";
21
+
22
+ const enc = new TextEncoder(), dec = new TextDecoder();
23
+ const BRANCH = "main";
24
+
25
+ const unpack = (p) => { const v = typeof p === "bigint" ? p : BigInt(p); return { ptr: Number(v >> 32n), len: Number(v & 0xffffffffn) }; };
26
+ async function sha256hex(t) { const b = await crypto.subtle.digest("SHA-256", enc.encode(t)); return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join(""); }
27
+ function osEntropyBuffer(n) { const bytes = new Uint8Array(n * 8); crypto.getRandomValues(bytes); const out = new Array(n), T = 2 ** 53; for (let i = 0; i < n; i++) { let z = 0n; for (let b = 7; b >= 0; b--) z = (z << 8n) | BigInt(bytes[i * 8 + b]); out[i] = Number(z >> 11n) / T; } return out; }
28
+ function stringifyBig(o) { return JSON.stringify(o, (_k, v) => (typeof v === "bigint" ? `@@B:${v}@@` : v)).replace(/"@@B:(\d+)@@"/g, "$1"); }
29
+ async function replicaIdFromValue(str) {
30
+ const b = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(str)));
31
+ let v = 0n; for (let i = 0; i < 8; i++) v = (v << 8n) | BigInt(b[i]);
32
+ return v & ((1n << 63n) - 1n);
33
+ }
34
+ function holonCall(ex, mode, fields, STDERR) {
35
+ const req = enc.encode(JSON.stringify({ mode, ...fields }));
36
+ const reqPtr = ex.git_holon_alloc(req.length);
37
+ try {
38
+ new Uint8Array(ex.memory.buffer, reqPtr, req.length).set(req);
39
+ const { ptr, len } = unpack(ex.git_holon_call(reqPtr, req.length));
40
+ try {
41
+ const e = JSON.parse(dec.decode(new Uint8Array(ex.memory.buffer, ptr, len).slice()));
42
+ if (!e.ok) throw new Error((e.error || "holon error") + (STDERR.length ? ` | ${STDERR.slice(-4).join(" / ")}` : ""));
43
+ return e.result;
44
+ } finally { ex.git_holon_dealloc(ptr, len); }
45
+ } finally { ex.git_holon_dealloc(reqPtr, req.length); }
46
+ }
47
+
48
+ /**
49
+ * Open a LOCAL holon for `workspace`, hydrated from Nomos Cloud.
50
+ *
51
+ * @param {object} opts
52
+ * @param {string} opts.cloud Nomos Cloud base URL (e.g. https://nomos.captainapp.co.uk)
53
+ * @param {string} opts.workspace workspace name (must already exist — POST /v1/workspaces/:ws)
54
+ * @param {string} [opts.clientId] stable client identity (drives the session branch + HLC replica);
55
+ * defaults to a random id per connect
56
+ * @param {string} [opts.authToken] bearer for keyed clouds (needed for sync() push when the cloud
57
+ * sets NOMOS_CLOUD_KEY)
58
+ * @param {Uint8Array} [opts.restoreFrom] a holon.export() snapshot — hydrate the workspace
59
+ * directory from these bytes INSTEAD of cloning, then pull()
60
+ * to catch up; pending un-synced local commits survive and
61
+ * remain syncable
62
+ */
63
+ export async function connect({ cloud, workspace, clientId, authToken, restoreFrom }) {
64
+ if (!cloud || !workspace) throw new Error("connect({ cloud, workspace }) required");
65
+ const base = cloud.replace(/\/+$/, "");
66
+ const remote = `${base}/v1/workspaces/${workspace}/git`;
67
+ const cid = clientId || [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, "0")).join("");
68
+ const replica = await replicaIdFromValue(`client:${cid}`);
69
+ const authHeaders = authToken ? { Authorization: `Bearer ${authToken}` } : {};
70
+
71
+ // ── 1. the runtime: the SAME holon artifact the edge runs, plus the read-routing manifests.
72
+ // Manifests are WORKSPACE-SCOPED (baked ∪ the workspace's deploy overlays) so a domain
73
+ // deployed with nomos-compile routes its queries + gates its admission locally too;
74
+ // older clouds without /manifests fall back to the baked /v1/runtime/manifests. ──
75
+ const [wasmBytes, manifests] = await Promise.all([
76
+ fetch(`${base}/v1/runtime/holon.wasm`).then((r) => { if (!r.ok) throw new Error(`runtime fetch ${r.status}`); return r.arrayBuffer(); }),
77
+ fetch(`${base}/v1/workspaces/${workspace}/manifests`)
78
+ .then((r) => { if (!r.ok) throw new Error(`ws manifests ${r.status}`); return r.json(); })
79
+ .catch(() => fetch(`${base}/v1/runtime/manifests`).then((r) => { if (!r.ok) throw new Error(`manifests fetch ${r.status}`); return r.json(); })),
80
+ ]);
81
+ const module = await WebAssembly.compile(wasmBytes);
82
+
83
+ // ── 2. the /work tree + the ledger PULL (custody → local; verified on replay, never trusted) ──
84
+ const seed = (m) => {
85
+ m.set("domain_manifests.json", new File(enc.encode(manifests.domainManifests)));
86
+ m.set("identity-manifests.json", new File(enc.encode(manifests.identityManifests)));
87
+ };
88
+ const root = new Map(); seed(root);
89
+ // restoreFrom: hydrate the workspace directory from an export() snapshot instead of cloning
90
+ // (the bare nomos.git — including any pending un-synced local commits — rides the bytes);
91
+ // re-seed afterwards so the workspace-scoped manifests are the FRESH ones from the cloud.
92
+ const wsContents = restoreFrom ? deserializeTree(restoreFrom, { File, Directory }) : new Map();
93
+ seed(wsContents);
94
+ const wsDirNode = new Directory(wsContents);
95
+ root.set("ws", new Directory(new Map([[workspace, wsDirNode]])));
96
+ const preopen = new PreopenDirectory("/work", root);
97
+ const fs = makeGitFs(preopen, "/work", { File, Directory });
98
+ const repoArg = `/work/ws/${workspace}`;
99
+ const gitdir = `${repoArg}/nomos.git`;
100
+
101
+ const info = await git.getRemoteInfo({ http, url: remote });
102
+ const remoteMain = info.refs && info.refs.heads && info.refs.heads.main;
103
+ if (!remoteMain) throw new Error(`workspace '${workspace}' has no ledger main — create it first (POST /v1/workspaces/${workspace})`);
104
+ if (restoreFrom) {
105
+ // restored gitdir already carries the ledger — just (re)point origin at THIS cloud;
106
+ // the catch-up happens via pull() right after the holon boots (it needs apply_intent).
107
+ await git.addRemote({ fs, gitdir, remote: "origin", url: remote, force: true });
108
+ } else {
109
+ await git.init({ fs, gitdir, bare: true, defaultBranch: BRANCH });
110
+ await git.addRemote({ fs, gitdir, remote: "origin", url: remote, force: true });
111
+ await git.fetch({ fs, http, gitdir, remote: "origin", ref: BRANCH, singleBranch: true });
112
+ await git.writeRef({ fs, gitdir, ref: `refs/heads/${BRANCH}`, value: remoteMain, force: true });
113
+ await git.writeRef({ fs, gitdir, ref: "HEAD", value: `ref: refs/heads/${BRANCH}`, force: true, symbolic: true });
114
+ }
115
+
116
+ // ── 3. the resident holon (entropy gate runs at boot — refuses a degenerate battery) ──
117
+ const STDERR = [];
118
+ const fds = [new OpenFile(new File([])), ConsoleStdout.lineBuffered(() => {}), ConsoleStdout.lineBuffered((l) => STDERR.push(l)), preopen];
119
+ const wasi = new WASI(["wasm_git_holon", "reactor"], [], fds, { debug: false });
120
+ const inst = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: wasi.wasiImport });
121
+ const code = wasi.start(inst);
122
+ if (code !== 0) throw new Error(`holon refused to start (exit ${code}): ${STDERR.join("\n")}`);
123
+ const ex = inst.exports;
124
+
125
+ // serialize all holon ops (one wasm instance + one /work tree; never re-enter concurrently)
126
+ let chain = Promise.resolve();
127
+ const serialize = (fn) => { const r = chain.then(fn, fn); chain = r.then(() => {}, () => {}); return r; };
128
+ let seq = 0;
129
+ let syncedBase = remoteMain; // the last remote main we replayed (the fork point of local work)
130
+
131
+ const writeWork = (name, bytes) => preopen.dir.contents.set(name, new File(bytes));
132
+ // ERROR CLARITY: a wasm TRAP ("RuntimeError: unreachable") is a Rust panic — the
133
+ // panic MESSAGE is sitting in the holon's stderr buffer. Surface it; a bare
134
+ // "unreachable" tells an app developer nothing.
135
+ const call = (mode, fields) => {
136
+ try {
137
+ return holonCall(ex, mode, { repoArg, workspace, branch: BRANCH, ...fields }, STDERR);
138
+ } catch (e) {
139
+ if (e instanceof WebAssembly.RuntimeError && STDERR.length) {
140
+ throw new Error(`the holon panicked during '${mode}': ${STDERR.slice(-6).join(" | ")}`);
141
+ }
142
+ throw e;
143
+ }
144
+ };
145
+
146
+ // ── INCREMENTAL-PULL CONVERGENCE (the client leg of edge admission) ──
147
+ // After admission, remote main carries EDGE-sealed copies of our intents (same intent.json
148
+ // bytes — same content-derived intent.id — different commit shas). Convergence is therefore
149
+ // id-based: adopt canonical main, then REBASE-REPLAY only the local intents whose ids the
150
+ // canon does not already carry (apply_intent — the same primitive the edge /admit runs).
151
+ let replaySeq = 0;
152
+ let remoteScan = { head: null, ids: null, oids: null }; // cached per remote head sha
153
+ async function scanRemote(remoteHead) {
154
+ if (remoteScan.head === remoteHead) return remoteScan;
155
+ const ids = new Set(), oids = new Set();
156
+ for (const c of await git.log({ fs, gitdir, ref: remoteHead, depth: 500 })) {
157
+ oids.add(c.oid);
158
+ try {
159
+ const { blob } = await git.readBlob({ fs, gitdir, oid: c.oid, filepath: "intent.json" });
160
+ const id = JSON.parse(dec.decode(blob)).id;
161
+ if (id) ids.add(id);
162
+ } catch { /* genesis/no-intent commits — custody only */ }
163
+ }
164
+ remoteScan = { head: remoteHead, ids, oids };
165
+ return remoteScan;
166
+ }
167
+ // ── DEAD-LETTER QUEUE — refused-but-LEGITIMATE work is never lost (Jack's law) ──
168
+ // A DOMAIN rejection (the law couldn't merge it: uncertified domain, referential
169
+ // violation, invariant) parks the FULL intent here, durably (the DLQ file lives in
170
+ // the workspace tree, so export()/restore carry it). The app/domain dev explicitly
171
+ // handles it: inspect via deadLetters(), push a law fix, retryDeadLetter() — the
172
+ // system unjams and the user's work lands. Obvious ATTACKS (session-lane law
173
+ // intents the edge structurally refuses) are DROPPED, not dead-lettered.
174
+ const DLQ_FILE = "dead-letters.json";
175
+ const b64 = (bytes) => { let s = ""; for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(s); };
176
+ const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
177
+ const readDlq = () => { const f = wsContents.get(DLQ_FILE); if (!f) return []; try { return JSON.parse(dec.decode(f.data)); } catch { return []; } };
178
+ const writeDlq = (list) => wsContents.set(DLQ_FILE, new File(enc.encode(JSON.stringify(list))));
179
+ function deadLetter(e, source, error) {
180
+ const list = readDlq();
181
+ if (e.id && list.some((x) => x.id === e.id)) return;
182
+ let domain = null, directiveId = null;
183
+ try { const doc = JSON.parse(dec.decode(e.blob)); domain = (doc.payload && doc.payload.domain) || null; directiveId = (doc.payload && doc.payload.directiveId) || null; } catch {}
184
+ list.push({ id: e.id, oid: e.oid.slice(0, 10), source, error: String(error || "").slice(0, 300), domain, directiveId, at: new Date().toISOString(), intentB64: b64(e.blob) });
185
+ writeDlq(list);
186
+ }
187
+
188
+ // Intent ids the EDGE has judged and REFUSED — never re-replayed (dead-lettered or
189
+ // dropped per the routing above; re-replaying would resurrect them every sync).
190
+ const edgeRefused = new Set();
191
+ // NOT serialized — sync() calls this from inside its own serialized turn; api.pull wraps it.
192
+ async function pullInner(edgeRejections = null) {
193
+ await git.addRemote({ fs, gitdir, remote: "origin", url: remote, force: true });
194
+ const inf = await git.getRemoteInfo({ http, url: remote });
195
+ const remoteHead = inf.refs && inf.refs.heads && inf.refs.heads.main;
196
+ if (!remoteHead) throw new Error(`pull: workspace '${workspace}' has no remote main`);
197
+ const localHead = await git.resolveRef({ fs, gitdir, ref: BRANCH });
198
+ if (remoteHead === localHead) { syncedBase = remoteHead; return { upToDate: true, remoteHead }; }
199
+ // fetch only if the remote head's objects aren't already in custody (e.g. local-ahead-only)
200
+ const have = await git.readCommit({ fs, gitdir, oid: remoteHead }).then(() => true, () => false);
201
+ if (!have) await git.fetch({ fs, http, gitdir, remote: "origin", ref: BRANCH, singleBranch: true });
202
+ const { ids: remoteIds, oids: remoteOids } = await scanRemote(remoteHead);
203
+ // local-only commits = local main lineage not in remote history; oldest → newest
204
+ const localOnly = [];
205
+ for (const c of await git.log({ fs, gitdir, ref: localHead, depth: 500 })) {
206
+ if (remoteOids.has(c.oid)) break;
207
+ let blob = null, id = null;
208
+ try {
209
+ blob = (await git.readBlob({ fs, gitdir, oid: c.oid, filepath: "intent.json" })).blob;
210
+ try { id = JSON.parse(dec.decode(blob)).id ?? null; } catch {}
211
+ } catch {}
212
+ localOnly.push({ oid: c.oid, blob, id });
213
+ }
214
+ localOnly.reverse();
215
+ // RESET onto the canonical remote main (the projection re-folds from it at next query)
216
+ await git.writeRef({ fs, gitdir, ref: `refs/heads/${BRANCH}`, value: remoteHead, force: true });
217
+ syncedBase = remoteHead;
218
+ // REBASE-REPLAY: re-admit (locally) every carried intent the canon doesn't already hold
219
+ const replayed = [], skipped = [], rejected = [], dropped = [], deadLettered = [];
220
+ for (const e of localOnly) {
221
+ if (e.id && remoteIds.has(e.id)) { skipped.push({ oid: e.oid.slice(0, 10), id: e.id }); continue; }
222
+ // EDGE-REFUSED: the admitting peer judged this intent and said no. Routing:
223
+ // a DOMAIN rejection (deadLettered flag from the admission report) parks the
224
+ // work in the DLQ — never lost, app-visible, retryable after a law fix; a
225
+ // structural ATTACK-LANE refusal is dropped. Either way it leaves the lineage.
226
+ const edgeRej = edgeRejections && edgeRejections.get(e.oid.slice(0, 10));
227
+ if ((e.id && edgeRefused.has(e.id)) || edgeRej) {
228
+ if (e.id) edgeRefused.add(e.id);
229
+ if (edgeRej && edgeRej.deadLettered && e.blob) {
230
+ deadLetter(e, "edge", edgeRej.error);
231
+ deadLettered.push({ oid: e.oid.slice(0, 10), id: e.id, error: edgeRej.error });
232
+ } else {
233
+ dropped.push({ oid: e.oid.slice(0, 10), id: e.id, edgeRejected: true });
234
+ }
235
+ continue;
236
+ }
237
+ if (!e.blob) { rejected.push({ oid: e.oid.slice(0, 10), error: "commit carries no intent.json" }); continue; }
238
+ const name = `replay-${replaySeq++}.json`;
239
+ writeWork(name, e.blob);
240
+ try {
241
+ const res = JSON.parse(call("apply_intent", { intentFile: `/work/${name}` }));
242
+ if (res.ok) replayed.push({ oid: e.oid.slice(0, 10), id: e.id, head: res.head });
243
+ else {
244
+ // The LOCAL law refused the replay (e.g. it changed underneath) — the work
245
+ // is still the user's: dead-letter it, never silently lose it.
246
+ deadLetter(e, "local", res.error);
247
+ deadLettered.push({ oid: e.oid.slice(0, 10), id: e.id, error: res.error });
248
+ rejected.push({ oid: e.oid.slice(0, 10), id: e.id, error: res.error });
249
+ }
250
+ } catch (err) {
251
+ rejected.push({ oid: e.oid.slice(0, 10), id: e.id, error: String(err && err.message || err).slice(0, 200) });
252
+ }
253
+ }
254
+ return { upToDate: false, remoteHead, replayed, skipped, rejected, ...(dropped.length ? { dropped } : {}), ...(deadLettered.length ? { deadLettered } : {}) };
255
+ }
256
+
257
+ // restore catch-up: adopt whatever canonical main became while we were away; pending
258
+ // un-synced local commits are rebase-replayed on top and stay syncable.
259
+ if (restoreFrom) await pullInner();
260
+
261
+ const api = {
262
+ clientId: cid,
263
+ replica: replica.toString(),
264
+
265
+ /** Local head (the client's own ledger tip). */
266
+ head: () => serialize(async () => git.resolveRef({ fs, gitdir, ref: BRANCH })),
267
+
268
+ /** Read an aggregate row from the LOCAL projection (folds any un-folded delta first). */
269
+ queryById: (aggregateId) => serialize(async () => JSON.parse(call("query_by_id", { aggregateId }))),
270
+
271
+ /** Run a domain query against the LOCAL projection. */
272
+ query: (queryId, params = {}) => serialize(async () => JSON.parse(call("query", { queryId, paramsJson: JSON.stringify(params) }))),
273
+
274
+ /**
275
+ * The DEAD-LETTER QUEUE: refused-but-legitimate intents, with full provenance
276
+ * (id, source edge|local, error, domain/directiveId, when). The intent BYTES stay
277
+ * in the queue (and ride export()/restore) — work is never lost; the app decides.
278
+ */
279
+ deadLetters: () => serialize(async () => readDlq().map(({ intentB64, ...meta }) => meta)),
280
+
281
+ /**
282
+ * Retry one dead-lettered intent — after the domain dev shipped a law fix, this is
283
+ * the UNJAM: pull canon first (it may already be admitted), else re-apply locally
284
+ * (it rides the next sync). Resolves the entry on success.
285
+ */
286
+ retryDeadLetter: (id) => serialize(async () => {
287
+ const list = readDlq();
288
+ const entry = list.find((x) => x.id === id || x.oid === id);
289
+ if (!entry) return { ok: false, error: `no dead letter '${id}'` };
290
+ await pullInner();
291
+ if (entry.id && remoteScan && remoteScan.ids.has(entry.id)) {
292
+ writeDlq(list.filter((x) => x !== entry));
293
+ return { ok: true, alreadyOnMain: true };
294
+ }
295
+ const name = `retry-${replaySeq++}.json`;
296
+ writeWork(name, unb64(entry.intentB64));
297
+ const res = JSON.parse(call("apply_intent", { intentFile: `/work/${name}` }));
298
+ if (res.ok) { writeDlq(list.filter((x) => x !== entry)); return { ok: true, head: res.head }; }
299
+ entry.error = String(res.error || "").slice(0, 300); entry.at = new Date().toISOString();
300
+ writeDlq(list);
301
+ return { ok: false, error: res.error };
302
+ }),
303
+
304
+ /** Discard one dead letter — the APP's explicit choice, never the runtime's. */
305
+ discardDeadLetter: (id) => serialize(async () => {
306
+ const list = readDlq();
307
+ const next = list.filter((x) => x.id !== id && x.oid !== id);
308
+ writeDlq(next);
309
+ return { ok: true, removed: list.length - next.length };
310
+ }),
311
+
312
+ /** A declared count's maintained O(1) value from the LOCAL projection. */
313
+ count: (countId, groupKey = "") => serialize(async () => JSON.parse(call("count", { countId, groupKey })).count),
314
+
315
+ /** A declared sum's maintained O(1) total from the LOCAL projection. */
316
+ sum: (sumId, groupKey = "") => serialize(async () => JSON.parse(call("sum", { sumId, groupKey })).sum),
317
+
318
+ /**
319
+ * LOCAL-FIRST dispatch: author an intent under the workspace's INSTALLED law (resolved from
320
+ * the pulled ledger — works fully offline). Returns the new local head. Durable on sync().
321
+ */
322
+ dispatch: (domain, directiveId, payload, { domainHash }) => serialize(async () => {
323
+ if (!domainHash) throw new Error("dispatch requires { domainHash } — the installed law to author under");
324
+ const s = seq++;
325
+ const envelope = {
326
+ payload: { domain, directiveId, payload },
327
+ captured_ports: { clock: { physical: Date.now(), logical: 0, replica }, rng: osEntropyBuffer(64) },
328
+ policy_version: 1, policy_domain: "Nomos", policy_gas: 0, policy_memory: 0,
329
+ };
330
+ writeWork(`payload-${s}.json`, enc.encode(JSON.stringify(payload)));
331
+ writeWork(`envelope-${s}.json`, enc.encode(stringifyBig(envelope)));
332
+ const raw = call("author", {
333
+ domain, directiveId, payloadFile: `/work/payload-${s}.json`, envelopeFile: `/work/envelope-${s}.json`,
334
+ seq: s, actor: "", domainFile: "", domainHash,
335
+ });
336
+ if (process.env.NOMOS_DEBUG_RAW) console.error("RAW AUTHOR:", String(raw).slice(0, 300));
337
+ const res = JSON.parse(raw);
338
+ // The author RPC is two-layered: the transport envelope (holonCall checks it) and
339
+ // THIS result. A law refusal arrives here as ok:false — it MUST throw, loudly and
340
+ // verbatim (it carries the human-first message). Swallowing it resolves undefined
341
+ // and the user's write silently never happens — the worst possible failure mode.
342
+ if (!res.ok) throw new Error(res.error || "the law refused this intent");
343
+ return res.head;
344
+ }),
345
+
346
+ /**
347
+ * Watch a query: polls the local projection and fires `cb(rows)` whenever the result changes.
348
+ * Returns a stop() function. (Sync separately — watch is purely local.)
349
+ */
350
+ watch(queryId, params, cb, { intervalMs = 1000 } = {}) {
351
+ let last, stopped = false;
352
+ const tick = async () => {
353
+ if (stopped) return;
354
+ try {
355
+ const rows = await api.query(queryId, params);
356
+ const key = JSON.stringify(rows);
357
+ if (key !== last) { last = key; cb(rows); }
358
+ } catch (e) { /* surface via onError later; keep polling */ }
359
+ if (!stopped) setTimeout(tick, intervalMs);
360
+ };
361
+ tick();
362
+ return () => { stopped = true; };
363
+ },
364
+
365
+ /** Watch a single aggregate id. */
366
+ watchById(aggregateId, cb, opts) {
367
+ let last, stopped = false;
368
+ const intervalMs = (opts && opts.intervalMs) || 1000;
369
+ const tick = async () => {
370
+ if (stopped) return;
371
+ try {
372
+ const rows = await api.queryById(aggregateId);
373
+ const key = JSON.stringify(rows);
374
+ if (key !== last) { last = key; cb(rows); }
375
+ } catch (e) { /* keep polling */ }
376
+ if (!stopped) setTimeout(tick, intervalMs);
377
+ };
378
+ tick();
379
+ return () => { stopped = true; };
380
+ },
381
+
382
+ /**
383
+ * PULL — incremental convergence onto canonical main:
384
+ * fetch origin main; if unchanged → { upToDate: true }. Otherwise adopt the remote head
385
+ * (reset local main — the projection re-folds from it at next query) and REBASE-REPLAY
386
+ * the local-only intents whose content-derived ids the canon doesn't already carry
387
+ * (apply_intent — the same re-admission primitive the edge runs). Returns
388
+ * { upToDate, remoteHead, replayed, skipped, rejected }.
389
+ */
390
+ pull: () => serialize(pullInner),
391
+
392
+ /**
393
+ * EXPORT — serialize the workspace directory (/work/ws/<ws>: the bare nomos.git ledger
394
+ * + manifests) to bytes. Persist them anywhere (IndexedDB, a file); hand them back via
395
+ * connect({ restoreFrom }) to resume WITHOUT a clone — pending un-synced commits included.
396
+ */
397
+ export: () => serialize(async () => serializeTree(wsDirNode)),
398
+
399
+ /**
400
+ * SYNC — the offline-first exchange:
401
+ * up: push local commits to the untrusted session branch `session/<clientId>` (the edge
402
+ * holon validates + merges to main — admission, not trust),
403
+ * down: after an admission round, CONVERGE in the same call (pull(): adopt canonical main
404
+ * + rebase-replay — result in `.converged`); otherwise fetch remote main and
405
+ * fast-forward if we hold no un-merged local work.
406
+ */
407
+ sync: ({ admit = true } = {}) => serialize(async () => {
408
+ const localHead = await git.resolveRef({ fs, gitdir, ref: BRANCH });
409
+ const localAhead = localHead !== syncedBase;
410
+ let pushed = null, admission = null, converged = null;
411
+ if (localAhead) {
412
+ // The session branch is THIS client's own offer lane. After a converge (pull
413
+ // resets local main onto canon) the new lineage legitimately diverges from a
414
+ // not-yet-consumed previous offer — REPLACING it is the correct semantics
415
+ // (server-side idempotence skips anything already sealed), so retry with force.
416
+ try {
417
+ await git.push({ fs, http, gitdir, url: remote, ref: BRANCH, remoteRef: `refs/heads/session/${cid}`, force: false, headers: authHeaders });
418
+ } catch (e) {
419
+ if (!/fast-forward/i.test(String(e))) throw e;
420
+ await git.push({ fs, http, gitdir, url: remote, ref: BRANCH, remoteRef: `refs/heads/session/${cid}`, force: true, headers: authHeaders });
421
+ }
422
+ pushed = `session/${cid}`;
423
+ if (admit) {
424
+ // Ask the edge holon to JUDGE the session branch now (re-admit each carried intent under
425
+ // its own law and merge to main). Event/queue-driven admission can replace this trigger.
426
+ const r = await fetch(`${base}/v1/workspaces/${workspace}/admit`, { method: "POST", headers: authHeaders });
427
+ admission = await r.json();
428
+ }
429
+ }
430
+ if (admission && admission.ok) {
431
+ // Our work was judged: remote main now carries the EDGE-sealed versions of the admitted
432
+ // intents (same intent bytes, different shas). Converge NOW — no reconnect: adopt
433
+ // canonical main and rebase-replay whatever the canon didn't take. Refused commits
434
+ // route by kind (the admission report carries `deadLettered` on domain rejections):
435
+ // DLQ the legitimate work, drop the attack-lane refusals.
436
+ const refusals = new Map();
437
+ for (const s of admission.sessions || []) for (const r of s.rejected || []) if (r.oid) refusals.set(r.oid, { error: r.error, deadLettered: !!r.deadLettered });
438
+ converged = await pullInner(refusals.size ? refusals : null);
439
+ return {
440
+ pushed, admission, converged,
441
+ pulled: !converged.upToDate, diverged: false, reconnectRequired: false,
442
+ localHead, remoteMain: converged.remoteHead,
443
+ };
444
+ }
445
+ const inf = await git.getRemoteInfo({ http, url: remote });
446
+ const newMain = inf.refs && inf.refs.heads && inf.refs.heads.main;
447
+ let pulled = false, diverged = false, reconnectRequired = false;
448
+ if (newMain && newMain !== syncedBase) {
449
+ if (!localAhead) {
450
+ await git.fetch({ fs, http, gitdir, remote: "origin", ref: BRANCH, singleBranch: true });
451
+ await git.writeRef({ fs, gitdir, ref: `refs/heads/${BRANCH}`, value: newMain, force: true });
452
+ syncedBase = newMain;
453
+ pulled = true; // next query folds the delta from the projection cursor
454
+ } else {
455
+ diverged = true; // local work + remote advance: wait for edge admission
456
+ }
457
+ }
458
+ return { pushed, admission, converged, pulled, diverged, reconnectRequired, localHead, remoteMain: newMain };
459
+ }),
460
+
461
+ stats: () => ({ wasmLinearMemMB: +(ex.memory.buffer.byteLength / 1048576).toFixed(1) }),
462
+ };
463
+ return api;
464
+ }
package/src/tree.mjs ADDED
@@ -0,0 +1,75 @@
1
+ // /work tree ⇄ flat manifest bytes — the client twin of cloud/holon-host/src/tree.mjs.
2
+ // Used by holon.export() / connect({ restoreFrom }) to persist the workspace directory
3
+ // (the bare nomos.git + manifests) across page loads / devices. Byte format is IDENTICAL
4
+ // to the edge serializer, so a snapshot is portable between the browser holon and the
5
+ // edge holon:
6
+ // [u32 count] then per entry: [u8 isDir][u32 pathLen][path][u32 byteLen][bytes]
7
+ const enc = new TextEncoder();
8
+ const dec = new TextDecoder();
9
+
10
+ function flatten(dir, prefix, out) {
11
+ for (const [name, inode] of dir.contents) {
12
+ const path = prefix ? prefix + "/" + name : name;
13
+ if (inode.contents instanceof Map) {
14
+ out.push({ path, dir: true });
15
+ flatten(inode, path, out);
16
+ } else {
17
+ out.push({ path, dir: false, bytes: inode.data ?? new Uint8Array(0) });
18
+ }
19
+ }
20
+ }
21
+
22
+ export function serializeTree(workTree) {
23
+ const entries = [];
24
+ flatten(workTree, "", entries);
25
+ const chunks = [];
26
+ const header = new Uint8Array(4);
27
+ new DataView(header.buffer).setUint32(0, entries.length, true);
28
+ chunks.push(header);
29
+ for (const e of entries) {
30
+ const pathBytes = enc.encode(e.path);
31
+ const body = e.dir ? new Uint8Array(0) : (e.bytes ?? new Uint8Array(0));
32
+ const meta = new Uint8Array(9);
33
+ const dv = new DataView(meta.buffer);
34
+ dv.setUint8(0, e.dir ? 1 : 0);
35
+ dv.setUint32(1, pathBytes.length, true);
36
+ dv.setUint32(5, body.length, true);
37
+ chunks.push(meta, pathBytes, body);
38
+ }
39
+ let total = 0;
40
+ for (const c of chunks) total += c.length;
41
+ const out = new Uint8Array(total);
42
+ let off = 0;
43
+ for (const c of chunks) { out.set(c, off); off += c.length; }
44
+ return out;
45
+ }
46
+
47
+ /** Returns a `Map<name, File|Directory>` — the `contents` of the deserialized root. */
48
+ export function deserializeTree(bytes, { File, Directory }) {
49
+ const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
50
+ const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
51
+ let off = 0;
52
+ const count = dv.getUint32(off, true); off += 4;
53
+ const root = new Directory(new Map());
54
+ for (let i = 0; i < count; i++) {
55
+ const isDir = dv.getUint8(off); off += 1;
56
+ const pathLen = dv.getUint32(off, true); off += 4;
57
+ const byteLen = dv.getUint32(off, true); off += 4;
58
+ const path = dec.decode(u8.subarray(off, off + pathLen)); off += pathLen;
59
+ const body = u8.subarray(off, off + byteLen); off += byteLen;
60
+ const parts = path.split("/");
61
+ let cur = root;
62
+ for (let p = 0; p < parts.length - 1; p++) {
63
+ let next = cur.contents.get(parts[p]);
64
+ if (!next) { next = new Directory(new Map()); cur.contents.set(parts[p], next); }
65
+ cur = next;
66
+ }
67
+ const leaf = parts[parts.length - 1];
68
+ if (isDir) {
69
+ if (!cur.contents.get(leaf)) cur.contents.set(leaf, new Directory(new Map()));
70
+ } else {
71
+ cur.contents.set(leaf, new File(body.slice()));
72
+ }
73
+ }
74
+ return root.contents;
75
+ }