amalgm 0.1.158 → 0.1.159

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.
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Live smoke for the shared realtime rail (Wave 3) against RUNNING runtimes.
6
+ *
7
+ * The CI tiers in local-live-runbook.md (unit / loopback / full-route e2e)
8
+ * all run against sandboxed engines. This tool is the fourth tier: it drives
9
+ * the real installed runtime on this machine, through the real local
10
+ * gateway, onto the real cloud rail. Nothing is stubbed.
11
+ *
12
+ * Commands:
13
+ * status shared replicas + runtime health for this machine
14
+ * send [--path <doc>] append a marker line to a shared doc through the
15
+ * normal doc path, then wait for the official cloud
16
+ * ack (applied_version advance + sync_status synced)
17
+ * watch --for <text> wait until <text> arrives IN THE FILE on disk —
18
+ * i.e. a remote machine's edit crossed the rail and
19
+ * materialized here
20
+ *
21
+ * Cross-machine proof = `send` on machine A, `watch --for <its marker>` on
22
+ * machine B, then the reverse. Run from a checkout (node
23
+ * runtime/scripts/amalgm-mcp/tools/shared-live-smoke.js …) or from an
24
+ * installed runtime package (same relative path under the install dir).
25
+ *
26
+ * Options: --user <email> (default: sole entry under ~/amalgm/users),
27
+ * --branch <label> (default: main), --timeout <seconds> (default: 90).
28
+ * Exit code 0 on proof, 1 on failure/timeout — usable from scripts.
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const os = require('os');
33
+ const path = require('path');
34
+
35
+ function fail(msg) {
36
+ console.error(`FAIL: ${msg}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ function parseArgs(argv) {
41
+ const args = { _: [] };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ if (a.startsWith('--')) {
45
+ args[a.slice(2)] = argv[i + 1];
46
+ i++;
47
+ } else {
48
+ args._.push(a);
49
+ }
50
+ }
51
+ return args;
52
+ }
53
+
54
+ function expandHome(p) {
55
+ if (!p) return p;
56
+ return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
57
+ }
58
+
59
+ function resolveUser(args) {
60
+ if (args.user) return args.user;
61
+ const usersDir = path.join(os.homedir(), 'amalgm', 'users');
62
+ const entries = fs.readdirSync(usersDir).filter((e) => e.includes('@'));
63
+ if (entries.length === 1) return entries[0];
64
+ fail(`multiple users under ${usersDir} — pass --user <email> (found: ${entries.join(', ')})`);
65
+ }
66
+
67
+ function loadRuntime(user, branch) {
68
+ const base = path.join(os.homedir(), 'amalgm', 'users', user);
69
+ const runtimeDir = path.join(base, 'runtimes', branch);
70
+ const statePath = path.join(runtimeDir, 'runtime-state.json');
71
+ if (!fs.existsSync(statePath)) {
72
+ fail(`no runtime state at ${statePath} — is the ${branch} runtime installed for ${user}?`);
73
+ }
74
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
75
+ const token = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'runtime-token.json'), 'utf8')).token;
76
+ const dbPath = path.join(base, 'amalgm.db');
77
+ return { base, gatewayUrl: state.gateway_url, version: state.version, token, dbPath };
78
+ }
79
+
80
+ function openDb(dbPath) {
81
+ const Database = require('better-sqlite3');
82
+ return new Database(dbPath, { readonly: true, fileMustExist: true });
83
+ }
84
+
85
+ function readReplicas(db) {
86
+ return db
87
+ .prepare(
88
+ `SELECT resource_id, local_path, applied_version, snapshot_version,
89
+ sync_status, last_error, updated_at
90
+ FROM shared_resource_replicas ORDER BY updated_at DESC`
91
+ )
92
+ .all();
93
+ }
94
+
95
+ function replicaFor(db, docPath) {
96
+ return db
97
+ .prepare(`SELECT * FROM shared_resource_replicas WHERE local_path = ?`)
98
+ .get(docPath);
99
+ }
100
+
101
+ async function api(rt, route, body) {
102
+ const res = await fetch(`${rt.gatewayUrl}${route}`, {
103
+ method: 'POST',
104
+ headers: {
105
+ 'content-type': 'application/json',
106
+ 'x-amalgm-runtime-token': rt.token,
107
+ },
108
+ body: JSON.stringify(body),
109
+ });
110
+ const text = await res.text();
111
+ if (!res.ok) fail(`${route} → HTTP ${res.status}: ${text.slice(0, 300)}`);
112
+ return JSON.parse(text);
113
+ }
114
+
115
+ async function checkHealth(rt) {
116
+ let res;
117
+ try {
118
+ res = await fetch(`${rt.gatewayUrl}/healthz`, { signal: AbortSignal.timeout(5000) });
119
+ } catch (err) {
120
+ fail(`runtime unreachable at ${rt.gatewayUrl} (${err.cause?.code || err.message}) — is it running?`);
121
+ }
122
+ if (!res.ok) fail(`healthz → HTTP ${res.status}`);
123
+ return res.json();
124
+ }
125
+
126
+ function pickDocPath(args, db) {
127
+ if (args.path) return path.resolve(expandHome(args.path));
128
+ const rows = readReplicas(db);
129
+ if (rows.length === 1) return rows[0].local_path;
130
+ if (rows.length === 0) fail('no shared replicas on this machine — share a doc first, or pass --path');
131
+ fail(`multiple shared replicas — pass --path. Known:\n ${rows.map((r) => r.local_path).join('\n ')}`);
132
+ }
133
+
134
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
135
+
136
+ async function cmdStatus(rt) {
137
+ const health = await checkHealth(rt);
138
+ console.log(`runtime ${health.runtime_label} v${rt.version} at ${rt.gatewayUrl} — ${health.status}`);
139
+ const db = openDb(rt.dbPath);
140
+ const rows = readReplicas(db);
141
+ if (rows.length === 0) {
142
+ console.log('no shared replicas.');
143
+ return;
144
+ }
145
+ for (const r of rows) {
146
+ console.log(
147
+ ` ${r.resource_id} v${r.applied_version} (snapshot v${r.snapshot_version}) ${r.sync_status}` +
148
+ `${r.last_error ? ` ERROR: ${r.last_error}` : ''}\n ${r.local_path} (updated ${r.updated_at})`
149
+ );
150
+ }
151
+ }
152
+
153
+ async function cmdSend(rt, args, timeoutMs) {
154
+ const Y = require('yjs');
155
+ await checkHealth(rt);
156
+ const db = openDb(rt.dbPath);
157
+ const docPath = pickDocPath(args, db);
158
+ const before = replicaFor(db, docPath);
159
+ if (!before) fail(`${docPath} is not a shared replica on this machine`);
160
+ if (before.sync_status !== 'synced') {
161
+ console.log(`note: replica starts in '${before.sync_status}' (v${before.applied_version})`);
162
+ }
163
+
164
+ const marker = `[smoke ${new Date().toISOString()} host=${os.hostname()} n=${Math.random().toString(36).slice(2, 8)}]`;
165
+ const opened = await api(rt, '/state/docs/open', { path: docPath });
166
+ const doc = new Y.Doc();
167
+ Y.applyUpdate(doc, Buffer.from(opened.state, 'base64'));
168
+ const beforeVector = Y.encodeStateVector(doc);
169
+ const text = doc.getText('content');
170
+ doc.transact(() => text.insert(text.length, `\n${marker}\n`));
171
+ const update = Buffer.from(Y.encodeStateAsUpdate(doc, beforeVector)).toString('base64');
172
+ await api(rt, '/state/docs/update', { path: docPath, updates: [update] });
173
+ console.log(`sent marker through doc path: ${marker}`);
174
+ console.log(`waiting for cloud ack (baseline v${before.applied_version})…`);
175
+
176
+ const start = Date.now();
177
+ while (Date.now() - start < timeoutMs) {
178
+ const row = replicaFor(db, docPath);
179
+ if (row.sync_status === 'rejected' || row.sync_status === 'conflicted') {
180
+ fail(`replica entered '${row.sync_status}': ${row.last_error || '(no error recorded)'}`);
181
+ }
182
+ if (row.applied_version > before.applied_version && row.sync_status === 'synced') {
183
+ const materialized = fs.readFileSync(docPath, 'utf8').includes(marker);
184
+ console.log(
185
+ `PASS: official v${before.applied_version} → v${row.applied_version} in ${Date.now() - start}ms; ` +
186
+ `file materialized: ${materialized ? 'yes' : 'pending write-back'}`
187
+ );
188
+ console.log(`cross-machine check: on the other machine run\n … watch --for "${marker}"`);
189
+ return;
190
+ }
191
+ await sleep(400);
192
+ }
193
+ fail(`no cloud ack within ${timeoutMs / 1000}s — edit is journaled locally (nothing lost); check tunnel/gateway per local-live-runbook.md`);
194
+ }
195
+
196
+ async function cmdWatch(rt, args, timeoutMs) {
197
+ if (!args.for) fail('watch requires --for <text>');
198
+ const db = openDb(rt.dbPath);
199
+ const docPath = pickDocPath(args, db);
200
+ const start = Date.now();
201
+ console.log(`watching ${docPath} for: ${args.for}`);
202
+ while (Date.now() - start < timeoutMs) {
203
+ if (fs.existsSync(docPath) && fs.readFileSync(docPath, 'utf8').includes(args.for)) {
204
+ const row = replicaFor(db, docPath);
205
+ console.log(`PASS: marker arrived in the file after ${Date.now() - start}ms (replica v${row?.applied_version}, ${row?.sync_status})`);
206
+ return;
207
+ }
208
+ await sleep(400);
209
+ }
210
+ fail(`marker did not arrive within ${timeoutMs / 1000}s`);
211
+ }
212
+
213
+ async function main() {
214
+ const args = parseArgs(process.argv.slice(2));
215
+ const cmd = args._[0];
216
+ if (!['status', 'send', 'watch'].includes(cmd)) {
217
+ console.error('usage: shared-live-smoke.js <status|send|watch> [--path <doc>] [--for <text>] [--user <email>] [--branch <label>] [--timeout <seconds>]');
218
+ process.exit(1);
219
+ }
220
+ const rt = loadRuntime(resolveUser(args), args.branch || 'main');
221
+ const timeoutMs = (Number(args.timeout) || 90) * 1000;
222
+ if (cmd === 'status') await cmdStatus(rt);
223
+ if (cmd === 'send') await cmdSend(rt, args, timeoutMs);
224
+ if (cmd === 'watch') await cmdWatch(rt, args, timeoutMs);
225
+ }
226
+
227
+ main().catch((err) => fail(err.stack || String(err)));
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Repo cards: one small, typed statement of a worktree's exact state.
5
+ *
6
+ * A card carries identifiers and counts, never file contents — a
7
+ * 4,000-file checkout is one card change. Cards are observations; nothing
8
+ * in the system acts on one, and observing never writes to a repo. The
9
+ * contract is docs/workspace-sync.md.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const {
15
+ classifyDirectory,
16
+ headAndDirty,
17
+ headOid,
18
+ listWorktrees,
19
+ operationState,
20
+ originUrl,
21
+ rootCommit,
22
+ } = require('./git');
23
+ const { cacheRootCommit, machineId, replicaForGitDir } = require('./identity');
24
+
25
+ const CARD_CONTRACT = 'repo-card@2';
26
+
27
+ // A transportable identity needs a dotted host: bare names resolve through
28
+ // this machine's own config (ssh aliases, LAN names) and identify nothing
29
+ // anywhere else.
30
+ const HOST = String.raw`([\w-]+(?:\.[\w-]+)+)`;
31
+ const URL_REMOTE = new RegExp(`^(?:ssh|git|https?)://(?:[^@/]+@)?${HOST}(?::\\d+)?/(.+)$`, 'i');
32
+ const SCP_REMOTE = new RegExp(`^(?:[\\w.-]+@)?${HOST}:(.+)$`);
33
+
34
+ /**
35
+ * The transportable identity of a remote, or null when it has none.
36
+ *
37
+ * Every clone spelling of one hosted repo folds onto one https URL:
38
+ * scp-like (git@host:owner/repo.git), ssh:// with a port, https:// with
39
+ * credentials — lowercased host, no userinfo, no port, no query, no
40
+ * fragment, no .git. Credentials and tokens embedded anywhere in a URL
41
+ * must never ride a card off this machine, and machine-local remotes
42
+ * (file://, bare paths, ssh aliases) are null: a name that only resolves
43
+ * on this machine identifies nothing on any other.
44
+ */
45
+ function remoteIdentity(url) {
46
+ const trimmed = url.trim();
47
+ const match = trimmed.match(URL_REMOTE) || trimmed.match(SCP_REMOTE);
48
+ if (!match) return null;
49
+ const repoPath = match[2]
50
+ .replace(/[?#].*$/, '')
51
+ .replace(/^\/+/, '')
52
+ .replace(/\/+$/, '')
53
+ .replace(/\.git$/, '');
54
+ return repoPath ? `https://${match[1].toLowerCase()}/${repoPath}` : null;
55
+ }
56
+
57
+ function cardKind(classified) {
58
+ if (classified.superproject) return 'submodule';
59
+ return classified.linked ? 'linked-worktree' : 'primary';
60
+ }
61
+
62
+ async function observeWorktree(classified) {
63
+ const worktreeRoot = classified.toplevel;
64
+ const gitDir = await fs.promises.realpath(classified.gitDir);
65
+ const replica = replicaForGitDir(gitDir);
66
+ const [{ head, dirty }, operation, remote] = await Promise.all([
67
+ headAndDirty(worktreeRoot),
68
+ operationState(worktreeRoot),
69
+ originUrl(worktreeRoot),
70
+ ]);
71
+ let anchor = replica.rootCommit;
72
+ if (!anchor && head.oid) {
73
+ anchor = await rootCommit(worktreeRoot);
74
+ cacheRootCommit(gitDir, anchor);
75
+ }
76
+
77
+ return {
78
+ card: CARD_CONTRACT,
79
+ machine: machineId(),
80
+ replica: replica.replicaId,
81
+ path: worktreeRoot,
82
+ gitDir,
83
+ repo: {
84
+ remote: remote === null ? null : remoteIdentity(remote),
85
+ rootCommit: anchor,
86
+ },
87
+ kind: cardKind(classified),
88
+ head,
89
+ dirty,
90
+ operation,
91
+ observedAt: new Date().toISOString(),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Build the repo-card@2 for one worktree. `dirPath` may be any directory
97
+ * inside the worktree; the card is always for the worktree root. Throws
98
+ * on anything that is not a worktree — discovery filters first.
99
+ *
100
+ * Observation is not atomic — git can move between the underlying reads —
101
+ * so HEAD is probed again afterwards and the card rebuilt once if it
102
+ * moved. A repo still in motion after that simply cards again on the next
103
+ * doorbell; every card is a true observation of some moment.
104
+ */
105
+ async function buildRepoCard(dirPath) {
106
+ const classified = await classifyDirectory(dirPath);
107
+ if (classified.kind !== 'worktree') {
108
+ throw new Error(`cannot card ${classified.path}: ${classified.kind}`);
109
+ }
110
+ const observed = await observeWorktree(classified);
111
+ if (observed.head.oid === await headOid(classified.toplevel)) return observed;
112
+ return observeWorktree(classified);
113
+ }
114
+
115
+ /**
116
+ * The card's identity-relevant content. Two observations of an untouched
117
+ * repo produce the same fingerprint; any real change produces a new one.
118
+ * The store bumps a card's generation exactly when the fingerprint moves.
119
+ */
120
+ function cardFingerprint(card) {
121
+ const { observedAt, ...content } = card;
122
+ return JSON.stringify(content);
123
+ }
124
+
125
+ async function isDirectory(candidate) {
126
+ const stats = await fs.promises.stat(candidate);
127
+ return stats.isDirectory();
128
+ }
129
+
130
+ /**
131
+ * Resolve a tracked folder to the worktrees it means, using git itself:
132
+ *
133
+ * - a folder inside a repo means that repo,
134
+ * - a plain folder means the repos sitting one level down (a folder of
135
+ * projects; hidden entries are not code folders),
136
+ * - and every discovered repo brings all of its linked worktrees along,
137
+ * wherever on disk they live — same repo, same tracking.
138
+ *
139
+ * Returns { worktrees, skipped } — worktree roots to card, and what was
140
+ * looked at but not carded, each with its reason: 'bare', 'not-a-repo',
141
+ * 'prunable' (registered but gone from disk), or 'broken-worktree'
142
+ * (present on disk but no longer answering as a repo, e.g. after its
143
+ * main repo moved).
144
+ * Discovery never descends into a repo looking for submodules; an
145
+ * explicitly tracked submodule path cards like any other worktree.
146
+ */
147
+ async function discoverWorktrees(rootPath) {
148
+ const classified = await classifyDirectory(rootPath);
149
+ const skipped = [];
150
+ const repos = new Map(); // commonDir → one worktree root; two doors into one repo expand once
151
+
152
+ if (classified.kind === 'worktree') {
153
+ repos.set(classified.commonDir, classified.toplevel);
154
+ } else if (classified.kind === 'bare') {
155
+ skipped.push({ path: classified.path, reason: 'bare' });
156
+ } else {
157
+ const entries = await fs.promises.readdir(classified.path);
158
+ const children = entries
159
+ .filter((entry) => !entry.startsWith('.'))
160
+ .map((entry) => path.join(classified.path, entry));
161
+ for (const child of children) {
162
+ if (!(await isDirectory(child))) continue;
163
+ const childClassified = await classifyDirectory(child);
164
+ if (childClassified.kind === 'worktree') {
165
+ if (!repos.has(childClassified.commonDir)) repos.set(childClassified.commonDir, childClassified.toplevel);
166
+ } else {
167
+ skipped.push({ path: childClassified.path, reason: childClassified.kind });
168
+ }
169
+ }
170
+ }
171
+
172
+ const worktrees = new Set();
173
+ for (const repoRoot of repos.values()) {
174
+ for (const worktree of await listWorktrees(repoRoot)) {
175
+ if (worktree.prunable) {
176
+ skipped.push({ path: worktree.path, reason: 'prunable' });
177
+ continue;
178
+ }
179
+ // Being listed is not enough — after a repo moves, a registered
180
+ // worktree can survive on disk with a dangling pointer. Card only
181
+ // what answers to git as a worktree.
182
+ const answered = await classifyDirectory(worktree.path);
183
+ if (answered.kind === 'worktree') {
184
+ worktrees.add(answered.toplevel);
185
+ } else {
186
+ skipped.push({ path: worktree.path, reason: 'broken-worktree' });
187
+ }
188
+ }
189
+ }
190
+ return { worktrees: [...worktrees].sort(), skipped };
191
+ }
192
+
193
+ module.exports = {
194
+ CARD_CONTRACT,
195
+ buildRepoCard,
196
+ cardFingerprint,
197
+ discoverWorktrees,
198
+ remoteIdentity,
199
+ };