@ours.network/notifications 0.1.1

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.
Files changed (107) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +231 -0
  3. package/dist/cli.js +446 -0
  4. package/dist/config.js +104 -0
  5. package/dist/daemon.js +353 -0
  6. package/dist/http.js +31 -0
  7. package/dist/log-store.js +48 -0
  8. package/dist/presence.js +157 -0
  9. package/dist/provision.js +29 -0
  10. package/dist/push.js +6 -0
  11. package/dist/web-push-sender.js +41 -0
  12. package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
  13. package/mufl_code/config.mufl +21 -0
  14. package/mufl_code/core/.github/FUNDING.yml +3 -0
  15. package/mufl_code/core/.github/workflows/docs.yml +39 -0
  16. package/mufl_code/core/.github/workflows/tests.yml +66 -0
  17. package/mufl_code/core/COMMERCIAL-LICENCE.md +25 -0
  18. package/mufl_code/core/COMPATIBILITY.md +221 -0
  19. package/mufl_code/core/CONTRIBUTING.md +37 -0
  20. package/mufl_code/core/LICENSE +98 -0
  21. package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +290 -0
  22. package/mufl_code/core/README.md +91 -0
  23. package/mufl_code/core/SECURITY.md +40 -0
  24. package/mufl_code/core/TRADEMARKS.md +24 -0
  25. package/mufl_code/core/a2a_capabilities.mm +449 -0
  26. package/mufl_code/core/a2a_cluster.mm +601 -0
  27. package/mufl_code/core/a2a_control.mm +90 -0
  28. package/mufl_code/core/a2a_messaging.mm +4817 -0
  29. package/mufl_code/core/a2a_monitoring.mm +94 -0
  30. package/mufl_code/core/a2a_notification_integration.mm +144 -0
  31. package/mufl_code/core/a2a_notifications.mm +1127 -0
  32. package/mufl_code/core/a2a_protocol.mm +246 -0
  33. package/mufl_code/core/a2a_versions.mm +801 -0
  34. package/mufl_code/core/config.mufl +29 -0
  35. package/mufl_code/core/docs/.vitepress/config.mts +58 -0
  36. package/mufl_code/core/docs/citations.json +688 -0
  37. package/mufl_code/core/docs/guide/01-vendor-the-core.md +50 -0
  38. package/mufl_code/core/docs/guide/02-configure-and-compile.md +100 -0
  39. package/mufl_code/core/docs/guide/03-wire-the-host.md +154 -0
  40. package/mufl_code/core/docs/guide/04-connect-and-message.md +156 -0
  41. package/mufl_code/core/docs/guide/05-test-your-app.md +75 -0
  42. package/mufl_code/core/docs/guide/index.md +63 -0
  43. package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +77 -0
  44. package/mufl_code/core/docs/how-it-works/cluster.md +65 -0
  45. package/mufl_code/core/docs/how-it-works/identity.md +56 -0
  46. package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +76 -0
  47. package/mufl_code/core/docs/how-it-works/messaging.md +55 -0
  48. package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +72 -0
  49. package/mufl_code/core/docs/how-it-works/overview.md +45 -0
  50. package/mufl_code/core/docs/how-it-works/versioning.md +116 -0
  51. package/mufl_code/core/docs/index.md +27 -0
  52. package/mufl_code/core/docs/package-lock.json +3467 -0
  53. package/mufl_code/core/docs/package.json +15 -0
  54. package/mufl_code/core/docs/reference/contributing.md +26 -0
  55. package/mufl_code/core/docs/reference/glossary.md +108 -0
  56. package/mufl_code/core/docs/reference/implementations.md +23 -0
  57. package/mufl_code/core/docs/reference/modules.md +19 -0
  58. package/mufl_code/core/docs/scripts/check-citations.mjs +36 -0
  59. package/mufl_code/core/docs/scripts/check-denylist.mjs +30 -0
  60. package/mufl_code/core/docs/scripts/gen-llms.mjs +32 -0
  61. package/mufl_code/core/docs/workflows/cluster.md +89 -0
  62. package/mufl_code/core/docs/workflows/contact-exchange.md +60 -0
  63. package/mufl_code/core/docs/workflows/contact-restore.md +60 -0
  64. package/mufl_code/core/docs/workflows/control-verbs.md +70 -0
  65. package/mufl_code/core/docs/workflows/index.md +57 -0
  66. package/mufl_code/core/docs/workflows/introductions.md +60 -0
  67. package/mufl_code/core/docs/workflows/messaging.md +75 -0
  68. package/mufl_code/core/docs/workflows/monitoring.md +84 -0
  69. package/mufl_code/core/docs/workflows/notifications.md +109 -0
  70. package/mufl_code/core/release-notes/0.1.md +60 -0
  71. package/mufl_code/core/release-notes/0.3.md +22 -0
  72. package/mufl_code/core/release-notes/0.4.md +97 -0
  73. package/mufl_code/core/tests/README.md +83 -0
  74. package/mufl_code/core/tests/compat/README.md +82 -0
  75. package/mufl_code/core/tests/compat/compat.mjs +245 -0
  76. package/mufl_code/core/tests/compat/compat_peer.mjs +146 -0
  77. package/mufl_code/core/tests/compat/run_compat.sh +96 -0
  78. package/mufl_code/core/tests/corpus.mjs +139 -0
  79. package/mufl_code/core/tests/corpus_actor.mu +242 -0
  80. package/mufl_code/core/tests/fatcompile_actor.mu +52 -0
  81. package/mufl_code/core/tests/mig.mjs +805 -0
  82. package/mufl_code/core/tests/mig_actor.mu +518 -0
  83. package/mufl_code/core/tests/migapp.mjs +431 -0
  84. package/mufl_code/core/tests/migapp_actor.mu +294 -0
  85. package/mufl_code/core/tests/mufl_semantics/run.sh +69 -0
  86. package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +47 -0
  87. package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +11 -0
  88. package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +30 -0
  89. package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +19 -0
  90. package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +19 -0
  91. package/mufl_code/core/tests/notif.mjs +755 -0
  92. package/mufl_code/core/tests/notif_actor.mu +450 -0
  93. package/mufl_code/core/tests/protocol_container.mm +18 -0
  94. package/mufl_code/core/tests/run.sh +65 -0
  95. package/mufl_code/core/tests/run_corpus.sh +51 -0
  96. package/mufl_code/core/tests/run_fatcompile.sh +40 -0
  97. package/mufl_code/core/tests/run_mig.sh +51 -0
  98. package/mufl_code/core/tests/run_migapp.sh +51 -0
  99. package/mufl_code/core/tests/run_notif.sh +57 -0
  100. package/mufl_code/core/tests/test.mjs +750 -0
  101. package/mufl_code/core/tests/test_actor.mu +564 -0
  102. package/mufl_code/core/tests/test_common.mjs +93 -0
  103. package/mufl_code/core/version.mm +24 -0
  104. package/mufl_code/packet.mu +170 -0
  105. package/mufl_code/protocol_container.mm +18 -0
  106. package/package.json +43 -0
  107. package/scripts/gen-vapid.mjs +16 -0
@@ -0,0 +1,82 @@
1
+ # tests/compat — cross-version back-compat matrix (M-legs) + upgrade-UX legs (U-legs)
2
+
3
+ The DR-rollout ship gate (DR-ROLLOUT-PLAN.md §5/§5.1): prove that an OLD-core peer and a
4
+ NEW-core peer interoperate (DoD D1) and that upgrades migrate seamlessly (D2/D3) — with the
5
+ two peers running in **separate processes, each under its own SDK runtime and its own
6
+ compiled unit**. That two-runtime split is what the rest of `tests/` deliberately avoids
7
+ (single node_modules, both peers in-process) and what production actually is mid-rollout.
8
+
9
+ ## Peers
10
+
11
+ - **OLD** — core `v0.2.0` (`faa2b52`, the OSP: the deployed ours-mcp/tg-connector repo pin;
12
+ no `$pv`, no DR, legacy `encrypted_channel`), compiled by and run under an 0.9.x-era
13
+ toolkit (`OLD_ADAPT_TOOLKIT` / `OLD_SDK_NM`).
14
+ - **NEW** — THIS working tree's core, compiled by and run under the current pinned toolkit
15
+ (`ADAPT_TOOLKIT` / `OURS_SDK_NODE_MODULES`, same knobs as `run.sh`).
16
+
17
+ Each peer's build dir gets its own `core/` sources, its own compiled `.muflo`, and its own
18
+ `node_modules` symlink — a packet is only ever loaded by the runtime whose compiler built
19
+ it (the eval_unit.h hash-check contract).
20
+
21
+ ## Env (run_compat.sh)
22
+
23
+ | Var | Meaning | Default |
24
+ |---|---|---|
25
+ | `OLD_CORE_REF` | git ref for the OLD peer's core sources | `v0.2.0` |
26
+ | `OLD_ADAPT_TOOLKIT` | toolkit checkout whose `mufl-compile` + stdlib build the OLD unit | required |
27
+ | `OLD_SDK_NM` | node_modules with the OLD-era `@adapt-toolkit` sdk | required |
28
+ | `ADAPT_TOOLKIT` / `OURS_SDK_NODE_MODULES` / `DEV_BROKER` / `PORT` | as in `run.sh` | required |
29
+
30
+ ## Leg inventory (status is explicit — an unimplemented leg FAILS, never skips silently)
31
+
32
+ | Leg | Gate | DoD | Status |
33
+ |---|---|---|---|
34
+ | M1 invite gen→redeem, both directions | MP | D1 | implemented |
35
+ | M2 first-contact message after redeem | MP | D1 | implemented |
36
+ | M3 steady-state send/receive both ways | MP | D1 | implemented |
37
+ | M4 file transfer both ways | MP | D1 | implemented |
38
+ | M5 OLD restarts: v0-blob export→reimport, channel resumes | MP | D1,D2 | implemented |
39
+ | M6 NEW restarts: stamped blob + DR sessions restore | MP | D1,D2 | implemented |
40
+ | M7 NEW↔NEW DR handshake + ratchet + dual restart | MP | D1 | not-implemented (needs 2× NEW peers — trivial config once M1–M6 green) |
41
+ | M8 stale-snapshot restore → self-heal | NTH | D2 | not-implemented |
42
+ | M9 corrupt blob import reject-to-empty | MP(NEW) | D2 | not-implemented |
43
+ | M10 version_error_t as data | NTH | D1 | not-implemented (assertion pattern exists in tests/test.mjs V-series) |
44
+ | M11 pv re-learning on peer downgrade | NTH | D1 | not-implemented |
45
+ | M12 cross-app tg-connector↔mcp | MP | D1,D4 | lives in the consumer repos (drives their packets, not this actor) |
46
+ | U1/U2/U3 npm-bump upgrade legs | MP | D2,D3 | live in the consumer repos' CI (install old → state → bump → verify) |
47
+
48
+ Related prior art this seeds from: control-plane `tests/blob-import.mjs` (old-unit→new-unit
49
+ same-seed gate) and `tests/mixed-unit.mjs` (4-cell pairing matrix) — single-runtime versions
50
+ of M5/M1; this harness generalizes them across runtimes.
51
+
52
+ ## Run
53
+
54
+ ```
55
+ OLD_ADAPT_TOOLKIT=... OLD_SDK_NM=... ADAPT_TOOLKIT=... OURS_SDK_NODE_MODULES=... \
56
+ DEV_BROKER=... bash tests/compat/run_compat.sh
57
+ ```
58
+
59
+ ## M12 — control-plane NEW ↔ mcp OLD (owner's named leg) — GREEN 2026-07-21
60
+
61
+ Reuses this orchestrator with per-side flavors (`NEW_PEER_FLAVOR=messenger` switches the
62
+ READ verbs to `::actor::get_conversation`; the whole `::a2a_messaging::` write surface is
63
+ shared). Peers: NEW = the control-plane DR unit (dev5/dr-e2e-integration, sdk 0.10.10,
64
+ core v0.8.0) · OLD = the REAL mcp actor (`packages/core/mufl_code/actor.mu`) at its
65
+ deployed pins (core `faa2b52` = v0.2.0/OSP) compiled+run under published sdk/mufl 0.9.1.
66
+ Result: M1–M6 all green (invites both ways, first-contact, steady both ways, files both
67
+ ways, both restart-restore legs).
68
+
69
+ ```
70
+ # build dirs: old = mcp actor + 0.9.1 stage; new = cp unit + its node_modules
71
+ OLD_BUILD_DIR=<mcp-old> NEW_BUILD_DIR=<cp-new> NEW_PEER_FLAVOR=messenger \
72
+ LEG_FILTER=M1,M2,M3,M4,M5,M6 node tests/compat/compat.mjs
73
+ ```
74
+
75
+ ## U3 — control-plane upgrade path — GREEN 2026-07-21
76
+
77
+ Under the fresh-address ruling (unpublished app, zero clients) the npm-bump leg's
78
+ meaningful nucleus is the control-plane's own blob-import gate: main-era unit sources
79
+ (messenger + core 0.7.1) compiled with the CURRENT toolchain → real state → import under
80
+ the DR unit → same seed ⇒ same cid, history/contacts preserved, zero rejects, re-export
81
+ round-trips. Run via the cp repo's tests/run-blob-import.sh with OLD_UNIT pointed at the
82
+ main-era build.
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ // Cross-version matrix orchestrator: spawns OLD-runtime and NEW-runtime peers
3
+ // (compat_peer.mjs each, separate processes, own node_modules + own unit — see README),
4
+ // then drives the M-legs against the pair. Scorecard semantics: an MP leg failing (or not
5
+ // implemented) exits non-zero; NTH legs report but never gate. No leg ever silently skips.
6
+ import { spawn } from 'node:child_process';
7
+ import { openSync as fsOpenSync, appendFileSync as fsAppendFileSync } from 'node:fs';
8
+ import * as readline from 'node:readline';
9
+ import { resolve } from 'node:path';
10
+
11
+ const OLD_DIR = process.env.OLD_BUILD_DIR;
12
+ const NEW_DIR = process.env.NEW_BUILD_DIR;
13
+ const BROKER_URL = process.env.BROKER_URL || 'ws://127.0.0.1:9797';
14
+ if (!OLD_DIR || !NEW_DIR) { console.error('OLD_BUILD_DIR / NEW_BUILD_DIR required'); process.exit(2); }
15
+
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+ let nextId = 1;
18
+
19
+ function spawnPeer(dir, name, extraEnv = {}) {
20
+ // stderr goes to a per-peer log — handshake rejects surface there, and
21
+ // discarding it turned real compat failures into blind timeouts.
22
+ const errLog = fsOpenSync(resolve(dir, `${name}.stderr.log`), 'a');
23
+ const child = spawn(process.execPath, [resolve(dir, 'compat_peer.mjs')], {
24
+ cwd: dir, stdio: ['pipe', 'pipe', errLog],
25
+ env: { ...process.env, BROKER_URL, PEER_NAME: name, PEER_SEED: `compat ${name} seed`,
26
+ PEER_FLAVOR: dir === NEW_DIR ? (process.env.NEW_PEER_FLAVOR || 'actor') : (process.env.OLD_PEER_FLAVOR || 'actor'),
27
+ ...extraEnv },
28
+ });
29
+ const peer = { name, dir, child, cid: '', events: [], waiters: new Map(), readyP: null, exited: null };
30
+ const rl = readline.createInterface({ input: child.stdout });
31
+ let readyResolve; peer.readyP = new Promise((r) => { readyResolve = r; });
32
+ child.on('exit', (code, signal) => {
33
+ peer.exited = { code, signal };
34
+ console.log(` [peer ${name}] exited code=${code} signal=${signal} (${peer.waiters.size} call(s) in flight)`);
35
+ for (const [, w] of peer.waiters) w.reject(new Error(`peer ${name} exited (code=${code} signal=${signal})`));
36
+ peer.waiters.clear();
37
+ readyResolve(); // a dead peer resolves readyP; callers check peer.cid
38
+ });
39
+ rl.on('line', (line) => {
40
+ let msg; try { msg = JSON.parse(line); } catch {
41
+ // Non-JSON stdout (e.g. the sdk leak-detector dump on exit) must not
42
+ // vanish — append it to the peer's log so leak totals are auditable.
43
+ try { fsAppendFileSync(resolve(dir, `${name}.stderr.log`), `[stdout] ${line}\n`); } catch {}
44
+ return;
45
+ }
46
+ if (msg.ready) { peer.cid = msg.cid; return readyResolve(); }
47
+ if (msg.event) return void peer.events.push(msg.event);
48
+ if (msg.fatal) { console.error(`[${name}] FATAL ${msg.fatal}`); return readyResolve(); }
49
+ const w = peer.waiters.get(msg.id);
50
+ if (w) { peer.waiters.delete(msg.id); msg.ok ? w.resolve(msg.value) : w.reject(new Error(msg.error)); }
51
+ });
52
+ return peer;
53
+ }
54
+
55
+ function call(peer, op, fields = {}, timeoutMs = 90000) {
56
+ const id = nextId++;
57
+ return new Promise((resolveP, rejectP) => {
58
+ if (peer.exited && op !== 'exit') return rejectP(new Error(`peer ${peer.name} already exited`));
59
+ const timer = setTimeout(() => {
60
+ peer.waiters.delete(id);
61
+ rejectP(new Error(`call ${op} on ${peer.name} timed out after ${timeoutMs}ms`));
62
+ }, timeoutMs);
63
+ peer.waiters.set(id, {
64
+ resolve: (v) => { clearTimeout(timer); resolveP(v); },
65
+ reject: (e) => { clearTimeout(timer); rejectP(e); },
66
+ });
67
+ peer.child.stdin.write(JSON.stringify({ id, op, ...fields }) + '\n');
68
+ });
69
+ }
70
+
71
+ // ---- shared drive helpers --------------------------------------------------
72
+ async function contactsInclude(peer, cid, timeoutMs = 30000) {
73
+ const deadline = Date.now() + timeoutMs;
74
+ for (;;) {
75
+ const contacts = String(await call(peer, 'contacts').catch(() => ''));
76
+ if (contacts.includes(cid)) return true;
77
+ if (Date.now() > deadline) return false;
78
+ await sleep(1500);
79
+ }
80
+ }
81
+ async function pair(inviter, redeemer) {
82
+ const inviteB64 = await call(inviter, 'invite', { name: redeemer.name });
83
+ await call(redeemer, 'redeem', { invite_b64: inviteB64, name: inviter.name });
84
+ // Handshake is multi-leg and cross-runtime — poll until BOTH sides list the
85
+ // peer instead of guessing a sleep. A timeout here is a real M1 verdict.
86
+ const a = await contactsInclude(inviter, redeemer.cid);
87
+ const b = await contactsInclude(redeemer, inviter.cid);
88
+ if (!a || !b) throw new Error(`pairing incomplete after 30s (inviter-sees=${a} redeemer-sees=${b})`);
89
+ }
90
+ async function roundTrip(from, to, text, expectRoute) {
91
+ const sent = await call(from, 'send', { cid: to.cid, text });
92
+ if (expectRoute && sent?.route !== expectRoute) {
93
+ throw new Error(`route mismatch: expected ${expectRoute}, got ${sent?.route} for "${text}"`);
94
+ }
95
+ await sleep(2500);
96
+ const inbox = await call(to, 'inbox', { cid: from.cid });
97
+ if (!String(inbox).includes(text)) throw new Error(`message not delivered: "${text}"`);
98
+ return sent;
99
+ }
100
+
101
+ // ---- legs ------------------------------------------------------------------
102
+ const LEGS = [
103
+ { id: 'M1', gate: 'MP', dod: 'D1', name: 'invite gen→redeem, both directions',
104
+ run: async (ctx) => {
105
+ await pair(ctx.oldPeer, ctx.newPeer); // OLD invites, NEW redeems (primary pair, reused by later legs)
106
+ // Reverse direction on a fresh throwaway pair: NEW invites, OLD redeems.
107
+ const old2 = spawnPeer(OLD_DIR, 'old2', { PEER_SEED: 'compat old2 seed' });
108
+ const new2 = spawnPeer(NEW_DIR, 'new2', { PEER_SEED: 'compat new2 seed' });
109
+ try {
110
+ await old2.readyP; await new2.readyP;
111
+ if (!old2.cid || !new2.cid) throw new Error('reverse-pair peers failed to boot');
112
+ await pair(new2, old2);
113
+ await roundTrip(new2, old2, 'reverse-pair first-contact new2→old2');
114
+ } finally {
115
+ await call(old2, 'exit').catch(() => {}); await call(new2, 'exit').catch(() => {});
116
+ } } },
117
+ { id: 'M2', gate: 'MP', dod: 'D1', name: 'first-contact message after redeem (new→old)',
118
+ run: async (ctx) => { await roundTrip(ctx.newPeer, ctx.oldPeer, 'first-contact new->old'); } },
119
+ { id: 'M3', gate: 'MP', dod: 'D1', name: 'steady-state send/receive both ways',
120
+ run: async (ctx) => {
121
+ await roundTrip(ctx.oldPeer, ctx.newPeer, 'steady old->new');
122
+ await roundTrip(ctx.newPeer, ctx.oldPeer, 'steady new->old'); } },
123
+ { id: 'M4', gate: 'MP', dod: 'D1', name: 'file transfer both ways',
124
+ run: async (ctx) => {
125
+ const data_b64 = Buffer.from('compat file payload').toString('base64');
126
+ await call(ctx.oldPeer, 'send_file', { cid: ctx.newPeer.cid, filename: 'from-old.txt', data_b64 });
127
+ await call(ctx.newPeer, 'send_file', { cid: ctx.oldPeer.cid, filename: 'from-new.txt', data_b64 });
128
+ await sleep(3000);
129
+ const fNew = String(await call(ctx.newPeer, 'files', { cid: ctx.oldPeer.cid }));
130
+ const fOld = String(await call(ctx.oldPeer, 'files', { cid: ctx.newPeer.cid }));
131
+ if (!fNew.includes('from-old.txt')) throw new Error('old→new file not received');
132
+ if (!fOld.includes('from-new.txt')) throw new Error('new→old file not received'); } },
133
+ { id: 'M5', gate: 'MP', dod: 'D1,D2', name: 'OLD restart: v0-blob export→reimport, channel resumes',
134
+ run: async (ctx) => {
135
+ await call(ctx.oldPeer, 'export_state', { file: resolve(OLD_DIR, 'state.bin') });
136
+ await ctx.respawn('oldPeer', true);
137
+ await roundTrip(ctx.newPeer, ctx.oldPeer, 'post-old-restart new->old'); } },
138
+ { id: 'M6', gate: 'MP', dod: 'D1,D2', name: 'NEW restart: stamped blob + DR-session restore',
139
+ run: async (ctx) => {
140
+ await call(ctx.newPeer, 'export_state', { file: resolve(NEW_DIR, 'state.bin') });
141
+ await ctx.respawn('newPeer', true);
142
+ await roundTrip(ctx.oldPeer, ctx.newPeer, 'post-new-restart old->new'); } },
143
+ { id: 'M7', gate: 'MP', dod: 'D1', name: 'NEW↔NEW DR handshake + ratchet + dual restart',
144
+ run: async () => {
145
+ const A = spawnPeer(NEW_DIR, 'newA', { PEER_SEED: 'compat newA seed' });
146
+ const B = spawnPeer(NEW_DIR, 'newB', { PEER_SEED: 'compat newB seed' });
147
+ try {
148
+ await A.readyP; await B.readyP;
149
+ if (!A.cid || !B.cid) throw new Error('M7 peers failed to boot');
150
+ const CAPS = { advertise: ['core.e2e', 'core.e2e.migrate'] };
151
+ await call(A, 'tx', { verb: '::actor::qa_init_caps', targ: CAPS });
152
+ await call(B, 'tx', { verb: '::actor::qa_init_caps', targ: CAPS });
153
+ await pair(A, B);
154
+ // Drive migration sweeps until the route flips to the double ratchet.
155
+ let route = '';
156
+ const deadline = Date.now() + 45000;
157
+ for (let i = 1; Date.now() < deadline && route !== 'e2e'; i++) {
158
+ await call(A, 'tx', { verb: '::a2a_messaging::sweep_e2e_migrations', targ: {} }).catch(() => {});
159
+ await call(B, 'tx', { verb: '::a2a_messaging::sweep_e2e_migrations', targ: {} }).catch(() => {});
160
+ await sleep(2000);
161
+ route = (await roundTrip(A, B, `dr-probe-${i}`))?.route ?? '';
162
+ }
163
+ if (route !== 'e2e') throw new Error(`route never reached e2e (last: ${route || 'legacy'})`);
164
+ // Ratchet advance both directions on the live session.
165
+ await roundTrip(A, B, 'dr-ratchet-a1', 'e2e');
166
+ await roundTrip(B, A, 'dr-ratchet-b1', 'e2e');
167
+ await roundTrip(A, B, 'dr-ratchet-a2', 'e2e');
168
+ // Dual restart with state — DR sessions must survive on BOTH ends.
169
+ await call(A, 'export_state', { file: resolve(NEW_DIR, 'stateA.bin') });
170
+ await call(B, 'export_state', { file: resolve(NEW_DIR, 'stateB.bin') });
171
+ await call(A, 'exit').catch(() => {}); await call(B, 'exit').catch(() => {});
172
+ await sleep(1000);
173
+ const A2 = spawnPeer(NEW_DIR, 'newA', { PEER_SEED: 'compat newA seed', PEER_STATE_FILE: resolve(NEW_DIR, 'stateA.bin') });
174
+ const B2 = spawnPeer(NEW_DIR, 'newB', { PEER_SEED: 'compat newB seed', PEER_STATE_FILE: resolve(NEW_DIR, 'stateB.bin') });
175
+ try {
176
+ await A2.readyP; await B2.readyP;
177
+ if (!A2.cid || !B2.cid) throw new Error('M7 respawn failed');
178
+ await sleep(2000);
179
+ await roundTrip(A2, B2, 'dr-post-restart-a');
180
+ await roundTrip(B2, A2, 'dr-post-restart-b');
181
+ } finally {
182
+ await call(A2, 'exit').catch(() => {}); await call(B2, 'exit').catch(() => {});
183
+ }
184
+ } finally {
185
+ await call(A, 'exit').catch(() => {}); await call(B, 'exit').catch(() => {});
186
+ } } },
187
+ { id: 'M8', gate: 'NTH', dod: 'D2', name: 'stale-snapshot restore → self-heal',
188
+ run: async () => { throw new Error('NOT IMPLEMENTED'); } },
189
+ { id: 'M9', gate: 'MP', dod: 'D2', name: 'corrupt blob import reject-to-functional (NEW)',
190
+ run: async (ctx) => {
191
+ await call(ctx.newPeer, 'export_state', { file: resolve(NEW_DIR, 'state.bin') });
192
+ const { readFileSync, writeFileSync } = await import('node:fs');
193
+ const good = readFileSync(resolve(NEW_DIR, 'state.bin'));
194
+ writeFileSync(resolve(NEW_DIR, 'state.bin'), good.subarray(0, Math.floor(good.length / 2)));
195
+ await ctx.respawn('newPeer', true);
196
+ const failed = ctx.newPeer.events.some((e) => String(e).startsWith('import_failed'));
197
+ if (!failed) throw new Error('corrupt blob did not surface import_failed');
198
+ // Reject-to-FUNCTIONAL: the fresh identity must still pair + message.
199
+ const inviteB64 = await call(ctx.oldPeer, 'invite', { name: 'renew' });
200
+ await call(ctx.newPeer, 'redeem', { invite_b64: inviteB64, name: 'oldpeer-again' });
201
+ const seen = await contactsInclude(ctx.newPeer, ctx.oldPeer.cid);
202
+ if (!seen) throw new Error('post-corruption re-pairing failed');
203
+ // Restore the good blob so later runs/legs are unaffected.
204
+ writeFileSync(resolve(NEW_DIR, 'state.bin'), good);
205
+ } },
206
+ { id: 'M10', gate: 'NTH', dod: 'D1', name: 'version_error_t as data',
207
+ run: async () => { throw new Error('NOT IMPLEMENTED — assertion pattern in tests/test.mjs V-series'); } },
208
+ { id: 'M11', gate: 'NTH', dod: 'D1', name: 'pv re-learning on peer downgrade',
209
+ run: async () => { throw new Error('NOT IMPLEMENTED'); } },
210
+ ];
211
+
212
+ // ---- main ------------------------------------------------------------------
213
+ const ctx = {
214
+ oldPeer: spawnPeer(OLD_DIR, 'oldpeer'),
215
+ newPeer: spawnPeer(NEW_DIR, 'newpeer'),
216
+ async respawn(ref, withState) {
217
+ const prev = ctx[ref];
218
+ await call(prev, 'exit').catch(() => {});
219
+ await sleep(1000);
220
+ const dir = ref === 'oldPeer' ? OLD_DIR : NEW_DIR;
221
+ ctx[ref] = spawnPeer(dir, prev.name,
222
+ withState ? { PEER_STATE_FILE: resolve(dir, 'state.bin') } : {});
223
+ await ctx[ref].readyP;
224
+ if (!ctx[ref].cid) throw new Error(`${prev.name} failed to respawn`);
225
+ await sleep(2000);
226
+ },
227
+ };
228
+
229
+ const results = [];
230
+ await ctx.oldPeer.readyP; await ctx.newPeer.readyP;
231
+ if (!ctx.oldPeer.cid || !ctx.newPeer.cid) { console.error('peer boot failed'); process.exit(1); }
232
+ console.log(`peers up: old=${ctx.oldPeer.cid.slice(0, 10)} new=${ctx.newPeer.cid.slice(0, 10)}`);
233
+ // LEG_FILTER=M7,M9 runs a subset (unlisted legs report as skipped-by-filter, never silently)
234
+ const filter = (process.env.LEG_FILTER || '').split(',').filter(Boolean);
235
+ for (const leg of LEGS) {
236
+ if (filter.length && !filter.includes(leg.id)) {
237
+ console.log(` - ${leg.id} skipped by LEG_FILTER (not a verdict)`); continue;
238
+ }
239
+ try { await leg.run(ctx); results.push({ leg, pass: true }); console.log(` ✓ ${leg.id} [${leg.gate}/${leg.dod}] ${leg.name}`); }
240
+ catch (err) { results.push({ leg, pass: false, err }); console.log(` ✗ ${leg.id} [${leg.gate}/${leg.dod}] ${leg.name}: ${err.message}`); }
241
+ }
242
+ await call(ctx.oldPeer, 'exit').catch(() => {}); await call(ctx.newPeer, 'exit').catch(() => {});
243
+ const mpFails = results.filter((r) => !r.pass && r.leg.gate === 'MP');
244
+ console.log(`\n${results.filter((r) => r.pass).length}/${LEGS.length} legs passed; MP failures: ${mpFails.length} (${mpFails.map((r) => r.leg.id).join(', ') || 'none'})`);
245
+ process.exit(mpFails.length ? 1 : 0);
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ // Per-process peer driver for the compat matrix. One process = one runtime = one packet.
3
+ //
4
+ // Loaded from a build dir whose node_modules symlink points at THIS peer's sdk era and
5
+ // whose cwd holds THIS peer's compiled unit — the orchestrator (compat.mjs) never mixes
6
+ // runtimes in-process. Commands arrive as JSON lines on stdin; every command produces
7
+ // exactly one JSON line on stdout: {id, ok, value?, error?}. Async inbound traffic is
8
+ // surfaced via unsolicited {event} lines. Boot/mutate/ro shapes mirror tests/test_common.mjs
9
+ // (the era-stable driver conventions: add_client_message + on_return_data pending queue).
10
+ import { resolve } from 'node:path';
11
+ import * as fs from 'node:fs';
12
+ import * as readline from 'node:readline';
13
+ import { adapt_wrapper } from '@adapt-toolkit/sdk/executables';
14
+ import { PacketWrapperConfigurator } from '@adapt-toolkit/sdk/wrappers';
15
+ import { object_to_adapt_value } from '@adapt-toolkit/sdk/wrapper';
16
+
17
+ const BROKER_URL = process.env.BROKER_URL || 'ws://127.0.0.1:9797';
18
+ const SEED = process.env.PEER_SEED || `compat seed ${process.env.PEER_NAME || 'peer'}`;
19
+ const STATE_FILE = process.env.PEER_STATE_FILE || ''; // set ⇒ import on boot if present
20
+ // PEER_FLAVOR=messenger switches the READ verbs to the control-plane messenger
21
+ // surface (history keyed per contact via ::actor::get_conversation). The whole
22
+ // ::a2a_messaging:: WRITE surface (invite/redeem/send/send_file) is shared.
23
+ const FLAVOR = process.env.PEER_FLAVOR || 'actor';
24
+ const UNIT_DIR = resolve('.');
25
+ const unitHash = fs.readdirSync(UNIT_DIR).find((f) => f.endsWith('.muflo')).slice(0, -'.muflo'.length);
26
+ const UNIT = new Uint8Array(fs.readFileSync(resolve(UNIT_DIR, `${unitHash}.muflo`)));
27
+
28
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+ const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
30
+ const pending = [];
31
+ let pw = null;
32
+
33
+ function wire() {
34
+ pw.on_return_data = (d) => {
35
+ const kind = d.Reduce('kind').Visualize();
36
+ if (kind === 'notify_agent') {
37
+ try { out({ event: d.Reduce('payload').Reduce('event').Visualize() }); } catch { out({ event: 'unparsed' }); }
38
+ return;
39
+ }
40
+ if (kind === 'save_state') return;
41
+ const w = pending.shift(); if (!w) return;
42
+ clearTimeout(w.timer); w.resolve(d.Reduce('payload'));
43
+ };
44
+ pw.on_transaction_failure = (msg) => {
45
+ const w = pending.shift();
46
+ if (w) { clearTimeout(w.timer); w.reject(new Error(msg)); }
47
+ else out({ event: `inbound-reject: ${String(msg).split('\n')[0]}` });
48
+ };
49
+ }
50
+
51
+ function mutate(name, targ) {
52
+ return new Promise((res, rej) => {
53
+ const timer = setTimeout(() => { rej(new Error(`timeout: ${name}`)); }, 20000);
54
+ pending.push({ resolve: res, reject: rej, timer });
55
+ pw.add_client_message(object_to_adapt_value({ name, targ }));
56
+ });
57
+ }
58
+ const ro = (name, targ) => pw.packet.ExecuteTransaction(object_to_adapt_value({ name, targ }));
59
+ const binv = (buf) => pw.packet.NewBinaryFromBuffer(Buffer.from(buf));
60
+
61
+ async function boot() {
62
+ const wrapper = await adapt_wrapper.start(['--broker_address', BROKER_URL, '--test_mode',
63
+ '--logger_config', '--level', 'WARNING', '--stdout', 'stderr', '--logger_config_end']);
64
+ wrapper.start();
65
+ await sleep(1500);
66
+ const cfg = new PacketWrapperConfigurator();
67
+ cfg.process_arguments(['--unit_hash', unitHash, '--seed_phrase', SEED, '--unit_dir_path', UNIT_DIR]);
68
+ await new Promise((res, rej) => {
69
+ const t = setTimeout(() => rej(new Error('create timeout')), 30000);
70
+ wrapper.packet_manager.create_packet(cfg, (p) => { clearTimeout(t); pw = p; wire(); res(); }, UNIT);
71
+ });
72
+ if (STATE_FILE && fs.existsSync(STATE_FILE)) {
73
+ // Import failure must not kill the peer — mirror the consumer posture
74
+ // (tg-connector: continue with the fresh identity, blob preserved): the
75
+ // M9 leg asserts exactly this reject-to-functional behavior.
76
+ try {
77
+ const bytes = fs.readFileSync(STATE_FILE);
78
+ await mutate('::actor::import_state', pw.packet.ParseValue(new Uint8Array(bytes)));
79
+ out({ event: 'state_imported' });
80
+ } catch (err) {
81
+ out({ event: `import_failed: ${String(err).split('\n')[0]}` });
82
+ }
83
+ }
84
+ out({ ready: true, cid: pw.packet.GetContainerID().Visualize() });
85
+ }
86
+
87
+ const rl = readline.createInterface({ input: process.stdin });
88
+ rl.on('line', async (line) => {
89
+ let cmd;
90
+ try { cmd = JSON.parse(line); } catch { return out({ id: null, ok: false, error: 'bad json' }); }
91
+ const { id, op } = cmd;
92
+ try {
93
+ switch (op) {
94
+ case 'invite': {
95
+ const m = await mutate('::a2a_messaging::generate_invite', { name: cmd.name });
96
+ return out({ id, ok: true, value: Buffer.from(m.Reduce('invite').GetBinary()).toString('base64') });
97
+ }
98
+ case 'redeem': {
99
+ await mutate('::a2a_messaging::add_contact',
100
+ { invite: binv(Buffer.from(cmd.invite_b64, 'base64')), name: cmd.name });
101
+ return out({ id, ok: true });
102
+ }
103
+ case 'send': {
104
+ const m = await mutate('::a2a_messaging::send_message', { contact: cmd.cid, text: cmd.text });
105
+ // route: 'e2e' (double ratchet) | 'migrating' | absent/NIL = legacy box
106
+ return out({ id, ok: true, value: { wire_id: m.Reduce('wire_id').Visualize(), route: m.Reduce('route').Visualize() } });
107
+ }
108
+ case 'tx': {
109
+ const m = await mutate(cmd.verb, cmd.targ);
110
+ return out({ id, ok: true, value: m ? m.Visualize() : null });
111
+ }
112
+ case 'send_file': {
113
+ const m = await mutate('::a2a_messaging::send_file', {
114
+ contact: cmd.cid, filename: cmd.filename, mime: cmd.mime || 'application/octet-stream',
115
+ data: binv(Buffer.from(cmd.data_b64, 'base64')),
116
+ });
117
+ return out({ id, ok: true, value: m.Reduce('wire_id').Visualize() });
118
+ }
119
+ case 'contacts': return out({ id, ok: true, value: ro('::a2a_messaging::list_contacts', undefined).Visualize() });
120
+ case 'inbox': {
121
+ if (FLAVOR === 'messenger') {
122
+ return out({ id, ok: true, value: ro('::actor::get_conversation', { contact: cmd.cid }).Visualize() });
123
+ }
124
+ return out({ id, ok: true, value: ro('::actor::list_incoming_messages', undefined).Visualize() });
125
+ }
126
+ case 'files': {
127
+ if (FLAVOR === 'messenger') {
128
+ // messenger file receipts land as history entries carrying $filename
129
+ return out({ id, ok: true, value: ro('::actor::get_conversation', { contact: cmd.cid }).Visualize() });
130
+ }
131
+ return out({ id, ok: true, value: ro('::actor::list_incoming_files', undefined).Visualize() });
132
+ }
133
+ case 'export_state': {
134
+ const exported = ro('::actor::export_state', undefined);
135
+ fs.writeFileSync(cmd.file, Buffer.from(exported.Serialize()));
136
+ return out({ id, ok: true });
137
+ }
138
+ case 'exit': out({ id, ok: true }); return process.exit(0);
139
+ default: return out({ id, ok: false, error: `unknown op ${op}` });
140
+ }
141
+ } catch (err) {
142
+ return out({ id, ok: false, error: String(err) });
143
+ }
144
+ });
145
+
146
+ boot().catch((e) => { out({ fatal: String(e) }); process.exit(1); });
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ # Cross-version compat matrix: build TWO units — OLD core (git ref, OLD-era toolchain) and
3
+ # NEW core (this working tree, current toolchain) — each in its own build dir with its own
4
+ # node_modules symlink, boot ONE broker, run tests/compat/compat.mjs against the pair.
5
+ # See tests/compat/README.md for the env contract and leg inventory.
6
+ #
7
+ # Toolchain dirs auto-detect flavor (owner ruling: the OLD side should be the PUBLISHED
8
+ # @adapt-toolkit packages production actually pinned, not a from-scratch build):
9
+ # - npm @adapt-toolkit/mufl package root → prebuilds/linux-x64/mufl-compile
10
+ # - adapt toolkit checkout → build.<platform>.release/mufl-compile
11
+ set -u
12
+ HERE=$(cd "$(dirname "$0")" && pwd)
13
+ CORE_DIR=$(cd "$HERE/../.." && pwd)
14
+
15
+ OLD_CORE_REF=${OLD_CORE_REF:-v0.2.0}
16
+ OLD_TOOLCHAIN=${OLD_TOOLCHAIN:?set OLD_TOOLCHAIN (npm @adapt-toolkit/mufl pkg root of the OLD era, e.g. <stage>/node_modules/@adapt-toolkit/mufl — or an old toolkit checkout)}
17
+ OLD_SDK_NM=${OLD_SDK_NM:?set OLD_SDK_NM (node_modules with the OLD-era @adapt-toolkit sdk, e.g. sdk@0.9.1)}
18
+ NEW_TOOLCHAIN=${NEW_TOOLCHAIN:-${ADAPT_TOOLKIT:-}}
19
+ [ -n "$NEW_TOOLCHAIN" ] || { echo "set NEW_TOOLCHAIN (current @adapt-toolkit/mufl pkg root or toolkit checkout)"; exit 2; }
20
+ SDK_NM=${OURS_SDK_NODE_MODULES:?set OURS_SDK_NODE_MODULES (current sdk node_modules)}
21
+ DEV_BROKER=${DEV_BROKER:?set DEV_BROKER (dev-broker.mjs launcher)}
22
+ PORT=${PORT:-9797}
23
+
24
+ # resolve_toolchain <dir> → sets RESOLVED_COMPILE / RESOLVED_ROOT
25
+ resolve_toolchain() {
26
+ local dir=$1 platform
27
+ platform="$(uname | tr '[:upper:]' '[:lower:]')"
28
+ for cand in "$dir/prebuilds/linux-x64/mufl-compile" \
29
+ "$dir/build/mufl-compile" \
30
+ "$dir/build.$platform.release/mufl-compile"; do
31
+ if [ -x "$cand" ]; then RESOLVED_COMPILE="$cand"; RESOLVED_ROOT="$dir"; return 0; fi
32
+ done
33
+ echo "MISSING: no mufl-compile under $dir"; exit 2
34
+ }
35
+
36
+ for p in "$OLD_SDK_NM/@adapt-toolkit" "$SDK_NM/@adapt-toolkit" "$DEV_BROKER"; do
37
+ [ -e "$p" ] || { echo "MISSING: $p"; exit 2; }
38
+ done
39
+
40
+ WORK=$(mktemp -d); echo "WORK=$WORK (kept for post-mortem when KEEP_WORK=1)"; if [ "${KEEP_WORK:-0}" = "1" ]; then trap '[ -n "${BROKER_PID:-}" ] && kill "$BROKER_PID" 2>/dev/null' EXIT; else trap 'rm -rf "$WORK"; [ -n "${BROKER_PID:-}" ] && kill "$BROKER_PID" 2>/dev/null' EXIT; fi
41
+
42
+ # build_unit <outdir> <core_src_dir> <toolchain_dir> <sdk_nm>
43
+ # Uses the era's OWN tests/test_actor.mu + protocol_container.mm (the actor surface
44
+ # matches its core), same compile shape as tests/run.sh / consumer compile-mufl.sh.
45
+ build_unit() {
46
+ local out=$1 src=$2 tk=$3 nm=$4
47
+ resolve_toolchain "$tk"
48
+ mkdir -p "$out/core"
49
+ cp "$src"/*.mm "$out/core/"; cp "$src/config.mufl" "$out/core/" 2>/dev/null || true
50
+ cp "$src/tests/test_actor.mu" "$out/actor.mu"
51
+ # Cross-process peers must REGISTER with the broker (single-wrapper suites
52
+ # deliver locally and never need this): inject registration_proof, which the
53
+ # real consumer actors (messenger.mu, tg actor.mu) load explicitly and the
54
+ # core test actors deliberately omit. Without it, registration-message
55
+ # creation fails client-side and no traffic ever crosses the broker.
56
+ sed -i '0,/loads libraries/s//loads libraries\n registration_proof,/' "$out/actor.mu"
57
+ if [ -f "$src/tests/protocol_container.mm" ]; then cp "$src/tests/protocol_container.mm" "$out/"
58
+ else cp "$CORE_DIR/tests/protocol_container.mm" "$out/"; fi
59
+ ln -sfn "$nm" "$out/node_modules"
60
+ cp "$HERE/compat_peer.mjs" "$out/"
61
+ cat > "$out/config.mufl" <<'CFG'
62
+ config script
63
+ {
64
+ stdlib_config = (config_load #$MUFL_STDLIB_PATH).
65
+ core_config = (config_load #"core").
66
+ (
67
+ $imports ->
68
+ (
69
+ $libraries -> (stdlib_config $exports $libraries)'(core_config $exports $libraries)'($protocol_container -> #"protocol_container.mm"),
70
+ ),
71
+ $exports -> ( $libraries -> (,), $applications -> (,) )
72
+ ).
73
+ }
74
+ CFG
75
+ ( cd "$out" && MUFL_STDLIB_PATH="$RESOLVED_ROOT/mufl_stdlib" "$RESOLVED_COMPILE" \
76
+ -mp "$RESOLVED_ROOT/meta" -mp "$RESOLVED_ROOT/transactions" -d-c actor.mu >/dev/null ) \
77
+ || { echo "COMPILE FAILED in $out"; exit 3; }
78
+ }
79
+
80
+ echo "== building OLD unit ($OLD_CORE_REF, $(basename "$OLD_TOOLCHAIN")) =="
81
+ OLD_SRC="$WORK/old-src"; mkdir -p "$OLD_SRC"
82
+ git -C "$CORE_DIR" archive "$OLD_CORE_REF" | tar -x -C "$OLD_SRC"
83
+ build_unit "$WORK/old" "$OLD_SRC" "$OLD_TOOLCHAIN" "$OLD_SDK_NM"
84
+
85
+ echo "== building NEW unit (working tree, $(basename "$NEW_TOOLCHAIN")) =="
86
+ build_unit "$WORK/new" "$CORE_DIR" "$NEW_TOOLCHAIN" "$SDK_NM"
87
+
88
+ echo "== broker on :$PORT =="
89
+ # dev-broker contract: --host/--port REQUIRED, --test_mode skips attestation. Runs under
90
+ # the NEW sdk (cwd with the current node_modules) — the broker relays opaque frames, so
91
+ # one broker serves both eras.
92
+ ( cd "$WORK/new" && node "$DEV_BROKER" --host 127.0.0.1 --port "$PORT" --test_mode ) & BROKER_PID=$!
93
+ sleep 2
94
+
95
+ BROKER_URL="ws://127.0.0.1:$PORT" OLD_BUILD_DIR="$WORK/old" NEW_BUILD_DIR="$WORK/new" \
96
+ node "$HERE/compat.mjs"