mnemonad-cli 0.2.0 → 0.3.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.
@@ -16,7 +16,10 @@ import {
16
16
  resolveOffloadParams,
17
17
  formatStreamId,
18
18
  discoverStreamId,
19
+ userError,
19
20
  } from './shared.js';
21
+ import { settleStreamJobs } from '../jobs.js';
22
+ import { makeIndexer, updateIndex } from './buildIndex.js';
20
23
 
21
24
  function timestamp() {
22
25
  return new Date().toTimeString().slice(0, 8);
@@ -35,14 +38,26 @@ export async function watch(args) {
35
38
  // no need to require it again.
36
39
  await discoverStreamId(args, cwd);
37
40
  if (!args.streamId) throw new Error('stream-id is required for watch');
41
+ await settleStreamJobs(args, { reason: 'before watching' });
38
42
  const debounceMs = args.debounce ?? 5000;
39
43
  const pollIntervalMs = (args.pollInterval ?? 2) * 1000;
40
44
  const pushEnabled = !args.pullOnly;
41
45
  const pullEnabled = !args.pushOnly;
42
46
 
47
+ if (args.index && !pushEnabled) {
48
+ throw userError('--index only applies when watch pushes — drop --pull-only to use it');
49
+ }
50
+ if (args.rebuild && !args.index) {
51
+ throw userError('--rebuild only applies with --index (it starts the search index over)');
52
+ }
53
+ // One indexer for the whole session: the embedding model loads on the first change and
54
+ // then stays in memory, so every later re-index costs only the files that changed.
55
+ // Built now so bad chunking flags or a missing extension fail before watching starts.
56
+ const indexer = args.index ? await makeIndexer(cwd, args) : null;
57
+
43
58
  const excludes = makeExcludes(args);
44
59
 
45
- const client = makeChainClient(args);
60
+ const client = await makeChainClient(args);
46
61
  if (pushEnabled) requireWalletClient(client);
47
62
 
48
63
  // Only a push-enabled session needs write-side offload capability (pinata-jwt or
@@ -79,6 +94,7 @@ export async function watch(args) {
79
94
  console.log(` remote version: ${remoteVersion}`);
80
95
  console.log(` push: ${pushEnabled ? `enabled (debounce ${debounceMs}ms)` : 'disabled'}`);
81
96
  console.log(` pull: ${pullEnabled ? `enabled (poll every ${pollIntervalMs / 1000}s)` : 'disabled'}`);
97
+ if (indexer) console.log(' index: updated before every push');
82
98
  console.log('press Ctrl+C to stop\n');
83
99
 
84
100
  async function runPush() {
@@ -88,13 +104,34 @@ export async function watch(args) {
88
104
 
89
105
  pushInFlight = true;
90
106
  try {
91
- // Best-effort race guard, not an atomic one: Mnemonad's own contract has no
92
- // "expected prior length" check — no atomic on-chain compare-and-abort.
93
- // Re-checking here narrows the window where a concurrent push (another `watch`
94
- // session, or a manual push) could make MonadSync build a diff against a base
95
- // the chain has since moved past — the same failure mode monadsync's own
96
- // snapshot-recovery tests exercise — but doesn't close it entirely; only an
97
- // on-chain guard could.
107
+ // Before the push, so the push carries it. The index file's own write then fires
108
+ // the watcher again, but by then lastSyncedHash (taken after indexing, below)
109
+ // already includes it, so that second pass finds nothing new and doesn't push.
110
+ // A failed index skips the push rather than publishing a stale index; the next
111
+ // change retries both.
112
+ let hashToSync = newHash;
113
+ if (indexer) {
114
+ try {
115
+ // Never a rebuild here — `--rebuild` applies once, to the startup pass below.
116
+ await updateIndex(cwd, args, { indexer, quiet: true, rebuild: false });
117
+ } catch (err) {
118
+ console.error(`[${timestamp()}] index update failed, not pushing:`, err.message);
119
+ return;
120
+ }
121
+ hashToSync = await localTreeHash(cwd);
122
+ }
123
+
124
+ // Best-effort race guard, not an atomic one. Mnemonad's contract *does* check an
125
+ // `expectedLength` atomically (Mnemonad.sol's `_checkLength`/`LengthMismatch`) —
126
+ // that closes the index race outright: two writers can never land at the same
127
+ // position, one just reverts. What it can't check is content: it stores opaque
128
+ // bytes, with no way to verify a diff patch is actually consistent with the real
129
+ // previous item. Re-checking here narrows the separate window where a concurrent
130
+ // push (another `watch` session, or a manual push) lands at the *correct* index
131
+ // but MonadSync still builds its diff against a snapshot the chain has since
132
+ // moved past — the same failure mode monadsync's own snapshot-recovery tests
133
+ // exercise. Confirmed live as a real corruption, not just theoretical — see
134
+ // monadsync/README.md's "Repairing a corrupt chain".
98
135
  mn.reInitialize();
99
136
  await mn.initialize();
100
137
  if (mn.length !== remoteVersion) {
@@ -107,7 +144,7 @@ export async function watch(args) {
107
144
  const fsFolder = new FSFolder(cwd, excludes);
108
145
  console.log(`[${timestamp()}] change detected — pushing...`);
109
146
  const result = await monadSync.push(fsFolder);
110
- lastSyncedHash = newHash;
147
+ lastSyncedHash = hashToSync;
111
148
  remoteVersion = result.version;
112
149
  console.log(`[${timestamp()}] pushed version ${result.version}`);
113
150
  } catch (err) {
@@ -169,6 +206,20 @@ export async function watch(args) {
169
206
 
170
207
  const poller = setInterval(runPoll, pollIntervalMs);
171
208
 
209
+ // An index that's already out of date when the session starts (files edited, or indexed
210
+ // for the first time) would otherwise only catch up on the next local change.
211
+ if (indexer) {
212
+ try {
213
+ const result = await updateIndex(cwd, args, { indexer, quiet: true, rebuild: !!args.rebuild });
214
+ if (result.filesChanged || result.filesRemoved) {
215
+ console.log(`[${timestamp()}] index was out of date — pushing the update`);
216
+ debounceTimer = setTimeout(runPush, 0);
217
+ }
218
+ } catch (err) {
219
+ console.error(`[${timestamp()}] index update failed:`, err.message);
220
+ }
221
+ }
222
+
172
223
  let shutdownResolve;
173
224
  const shutdownPromise = new Promise((resolve) => {
174
225
  shutdownResolve = resolve;
package/lib/jobs.js ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Background push jobs — what `mnemonad push --detach` leaves behind.
3
+ *
4
+ * A detached push builds its patch in the foreground (every check that can fail for the
5
+ * user's own reasons runs there: ownership, unlocking, indexing, "nothing to push", the
6
+ * balance), then hands only the sending to a detached child process and exits. Each job is a
7
+ * folder under `~/.cache/mnemonad/jobs/<id>/`:
8
+ *
9
+ * job.json — what to send where, and the job's status (see JOB_STATUS)
10
+ * patch.bin — the prepared patch, frozen when the command ran; deleted once sent
11
+ * push.log — the child's own output
12
+ *
13
+ * Secrets (a key, a phrase, a password, a Pinata JWT) never touch this folder or the child's
14
+ * argv: the parent writes them to the child's stdin and closes it.
15
+ *
16
+ * Correctness never depends on the locking below. The child refuses to send a patch whose
17
+ * base version is gone (`MonadSync.commitPush`), and the contract rejects any write that
18
+ * doesn't land at the index it names. The waiting here only keeps the next command from
19
+ * racing a job it would lose to anyway — or, for a pull, from overwriting the folder with the
20
+ * version before the one still being sent.
21
+ */
22
+ import { spawn } from 'node:child_process';
23
+ import { openSync, closeSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
24
+ import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
25
+ import { randomBytes } from 'node:crypto';
26
+ import { homedir } from 'node:os';
27
+ import { dirname, join } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import Mnemonad from 'mnemonad';
30
+ import { MonadSync } from 'monadsync';
31
+ import { makeChainClient, requireWalletClient } from './chainClient.js';
32
+ import {
33
+ formatStreamId,
34
+ formatPath,
35
+ unlockStream,
36
+ resolveOffloadParams,
37
+ writeManifest,
38
+ userError,
39
+ } from './commands/shared.js';
40
+
41
+ const BIN_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'mnemonad.js');
42
+
43
+ /** pending: written, child not started yet · waiting: queued behind an earlier job from the
44
+ * same signer · sending: transactions in flight · done / failed: finished. */
45
+ export const JOB_STATUS = { PENDING: 'pending', WAITING: 'waiting', SENDING: 'sending', DONE: 'done', FAILED: 'failed' };
46
+
47
+ const ACTIVE = new Set([JOB_STATUS.PENDING, JOB_STATUS.WAITING, JOB_STATUS.SENDING]);
48
+
49
+ /** A job still `pending` with no pid after this long never got its child started. */
50
+ const SPAWN_GRACE_MS = 60_000;
51
+
52
+ /** Finished jobs older than this are removed the next time a job is created. */
53
+ const KEEP_FINISHED_MS = 14 * 24 * 60 * 60 * 1000;
54
+
55
+ const POLL_MS = 1000;
56
+
57
+ /** `MNEMONAD_JOBS_DIR` overrides the location (the tests use a temp dir). */
58
+ export function jobsRoot() {
59
+ return process.env.MNEMONAD_JOBS_DIR || join(homedir(), '.cache', 'mnemonad', 'jobs');
60
+ }
61
+
62
+ /**
63
+ * Identifies a stream across commands: the same token id is a different stream on another
64
+ * chain or registry.
65
+ *
66
+ * @param {{chain: string, contractAddress: ?string, streamId: bigint|string}} where
67
+ */
68
+ export function streamKey({ chain, contractAddress, streamId }) {
69
+ return `${chain}|${String(contractAddress || '').toLowerCase()}|${formatStreamId(streamId)}`;
70
+ }
71
+
72
+ function signerKey({ chain, signer }) {
73
+ return `${chain}|${String(signer || '').toLowerCase()}`;
74
+ }
75
+
76
+ function isPidAlive(pid) {
77
+ if (!pid) return false;
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ } catch (err) {
82
+ // EPERM: the process exists, it's just not ours to signal.
83
+ return err.code === 'EPERM';
84
+ }
85
+ }
86
+
87
+ function writeJobSync(dir, job) {
88
+ // Synchronous on purpose: callers read-modify-write job.json from two processes, and a
89
+ // torn async write would leave a half-written file for the other side to parse.
90
+ const tmp = join(dir, `job.json.${process.pid}.tmp`);
91
+ writeFileSync(tmp, JSON.stringify(job, null, 2));
92
+ renameSync(tmp, join(dir, 'job.json'));
93
+ }
94
+
95
+ function readJobSync(dir) {
96
+ try {
97
+ return JSON.parse(readFileSync(join(dir, 'job.json'), 'utf8'));
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Re-reads a job and settles it as failed when it claims to be running but nothing is: the
105
+ * child was killed, the machine slept through a crash, or the spawn never happened.
106
+ */
107
+ function refreshJob(dir) {
108
+ const job = readJobSync(dir);
109
+ if (!job || !ACTIVE.has(job.status)) return job;
110
+
111
+ const neverStarted = !job.pid && Date.now() - Date.parse(job.createdAt) > SPAWN_GRACE_MS;
112
+ if (neverStarted || (job.pid && !isPidAlive(job.pid))) {
113
+ const settled = {
114
+ ...job,
115
+ status: JOB_STATUS.FAILED,
116
+ error: neverStarted
117
+ ? 'the background process never started'
118
+ : 'the background process exited before finishing',
119
+ finishedAt: new Date().toISOString(),
120
+ reported: false,
121
+ };
122
+ writeJobSync(dir, settled);
123
+ return settled;
124
+ }
125
+ return job;
126
+ }
127
+
128
+ /** Every job on disk, oldest first, with dead ones settled as failed. */
129
+ export function listJobs() {
130
+ let names;
131
+ try {
132
+ names = readdirSync(jobsRoot());
133
+ } catch {
134
+ return [];
135
+ }
136
+ const jobs = [];
137
+ for (const name of names) {
138
+ const dir = join(jobsRoot(), name);
139
+ const job = refreshJob(dir);
140
+ if (job) jobs.push({ ...job, dir });
141
+ }
142
+ return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
143
+ }
144
+
145
+ export function isActive(job) {
146
+ return ACTIVE.has(job.status);
147
+ }
148
+
149
+ /** Jobs still running for a stream (`{streamKey}`) or a signer (`{signer, chain}`). */
150
+ export function activeJobs(selector) {
151
+ return listJobs().filter((job) => isActive(job) && matches(job, selector));
152
+ }
153
+
154
+ function matches(job, selector) {
155
+ if (selector.streamKey) return job.streamKey === selector.streamKey;
156
+ return signerKey(job) === signerKey(selector);
157
+ }
158
+
159
+ /** The most recent job for a stream, active or not. */
160
+ export function latestJob(key) {
161
+ const jobs = listJobs().filter((job) => job.streamKey === key);
162
+ return jobs.length ? jobs[jobs.length - 1] : null;
163
+ }
164
+
165
+ /**
166
+ * Blocks until no job matching `selector` is still running, printing one line while it
167
+ * waits. Returns how many it waited for.
168
+ *
169
+ * @param {{streamKey: string}|{signer: string, chain: string}} selector
170
+ * @param {Object} [opts]
171
+ * @param {string} [opts.reason] - why this command has to wait, for the printed line
172
+ */
173
+ export async function waitForJobs(selector, { reason = 'before continuing' } = {}) {
174
+ let first = activeJobs(selector);
175
+ if (!first.length) return 0;
176
+
177
+ const job = first[first.length - 1];
178
+ console.log(
179
+ ` waiting for background push ${job.id} (version ${job.version}) to finish ${reason}...\n` +
180
+ ` log: ${formatPath(join(job.dir, 'push.log'))}`
181
+ );
182
+ while (first.length) {
183
+ await new Promise((r) => setTimeout(r, POLL_MS));
184
+ first = activeJobs(selector);
185
+ }
186
+ return 1;
187
+ }
188
+
189
+ /**
190
+ * Prints a failed job's error once — the next command on that stream after the failure —
191
+ * then marks it reported, so a failure is never silent past one run but doesn't nag forever.
192
+ * `mnemonad info` keeps showing it regardless (see describeJob).
193
+ */
194
+ export function reportFailedJob(key) {
195
+ const job = latestJob(key);
196
+ if (!job || job.status !== JOB_STATUS.FAILED || job.reported) return;
197
+ console.log(
198
+ ` warning: background push ${job.id} (version ${job.version}) failed: ${job.error}\n` +
199
+ ` nothing from it reached the stream. Log: ${formatPath(join(job.dir, 'push.log'))}`
200
+ );
201
+ writeJobSync(job.dir, { ...readJobSync(job.dir), reported: true });
202
+ }
203
+
204
+ /**
205
+ * What every command that touches a stream calls once its id is known: reports a failed
206
+ * background push (once), then either waits for a running one (`wait: true` — push, pull,
207
+ * compact, watch: anything that would build on, or overwrite with, the version before it) or
208
+ * just says one is running (diff, which only reads).
209
+ *
210
+ * @param {Object} args - parsed CLI args; needs `chain`, `contractAddress`, `streamId`
211
+ * @param {Object} [opts]
212
+ * @param {boolean} [opts.wait=true]
213
+ * @param {string} [opts.reason] - see waitForJobs
214
+ */
215
+ export async function settleStreamJobs(args, { wait = true, reason } = {}) {
216
+ if (!args.streamId) return;
217
+ const key = streamKey({ chain: args.chain, contractAddress: args.contractAddress, streamId: args.streamId });
218
+ reportFailedJob(key);
219
+ if (wait) {
220
+ await waitForJobs({ streamKey: key }, { reason });
221
+ return;
222
+ }
223
+ const running = activeJobs({ streamKey: key });
224
+ if (running.length) {
225
+ const job = running[running.length - 1];
226
+ console.log(
227
+ ` note: background push ${job.id} (version ${job.version}) is still sending —\n` +
228
+ ' this compares against the chain as it is before that version lands'
229
+ );
230
+ }
231
+ }
232
+
233
+ /** One line for `mnemonad info`. */
234
+ export function describeJob(job) {
235
+ const log = formatPath(join(job.dir, 'push.log'));
236
+ switch (job.status) {
237
+ case JOB_STATUS.DONE:
238
+ return `version ${job.version} sent ${job.finishedAt} (job ${job.id})`;
239
+ case JOB_STATUS.FAILED:
240
+ return `version ${job.version} FAILED: ${job.error} (job ${job.id}, log: ${log})`;
241
+ case JOB_STATUS.WAITING:
242
+ return `version ${job.version} queued behind another push from the same wallet (job ${job.id}, log: ${log})`;
243
+ default:
244
+ return `version ${job.version} still sending (job ${job.id}, pid ${job.pid ?? '?'}, log: ${log})`;
245
+ }
246
+ }
247
+
248
+ function pruneFinishedJobs() {
249
+ for (const job of listJobs()) {
250
+ if (isActive(job) || !job.finishedAt) continue;
251
+ if (Date.now() - Date.parse(job.finishedAt) > KEEP_FINISHED_MS) {
252
+ rmSync(job.dir, { recursive: true, force: true });
253
+ }
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Writes a job and starts its detached child.
259
+ *
260
+ * @param {Object} job - everything the child needs except secrets; see push.js's detachPush
261
+ * @param {Uint8Array} patchBytes
262
+ * @param {{key?: ?string, phrase?: ?string, password?: ?string, pinataJwt?: ?string}} secrets
263
+ * @returns {Promise<{id: string, dir: string, pid: number}>}
264
+ */
265
+ export async function startPushJob(job, patchBytes, secrets) {
266
+ pruneFinishedJobs();
267
+
268
+ const id = randomBytes(4).toString('hex');
269
+ const dir = join(jobsRoot(), id);
270
+ await mkdir(dir, { recursive: true });
271
+ await writeFile(join(dir, 'patch.bin'), patchBytes);
272
+ writeJobSync(dir, { ...job, id, status: JOB_STATUS.PENDING, pid: null, createdAt: new Date().toISOString() });
273
+
274
+ const logFd = openSync(join(dir, 'push.log'), 'a');
275
+ let child;
276
+ try {
277
+ child = spawn(process.execPath, [BIN_PATH, '__push-job', dir], {
278
+ detached: true,
279
+ stdio: ['pipe', logFd, logFd],
280
+ windowsHide: true,
281
+ });
282
+ } finally {
283
+ closeSync(logFd);
284
+ }
285
+
286
+ writeJobSync(dir, { ...readJobSync(dir), pid: child.pid });
287
+ child.stdin.end(JSON.stringify(secrets));
288
+ child.unref();
289
+ return { id, dir, pid: child.pid };
290
+ }
291
+
292
+ async function readStdin() {
293
+ const parts = [];
294
+ for await (const part of process.stdin) parts.push(part);
295
+ const raw = Buffer.concat(parts).toString('utf8');
296
+ return raw ? JSON.parse(raw) : {};
297
+ }
298
+
299
+ function log(...parts) {
300
+ console.log(new Date().toISOString(), ...parts);
301
+ }
302
+
303
+ /**
304
+ * The detached child: `mnemonad __push-job <dir>`. Never typed by a person — not in --help.
305
+ *
306
+ * Waits its turn behind earlier jobs from the same signer (two processes sending with one key
307
+ * would race for the same nonce), then sends the prepared patch from a fresh, never-replayed
308
+ * Mnemonad instance, and on success writes the folder's .mnemonad exactly like a foreground
309
+ * `push --manifest` would have.
310
+ *
311
+ * @param {string} dir - the job folder
312
+ */
313
+ export async function runPushJob(dir) {
314
+ if (!dir) throw userError('__push-job needs a job folder');
315
+ const secrets = await readStdin();
316
+
317
+ let job = readJobSync(dir);
318
+ if (!job) throw userError(`no job at ${dir}`);
319
+ const update = (fields) => {
320
+ job = { ...readJobSync(dir), ...fields };
321
+ writeJobSync(dir, job);
322
+ };
323
+ update({ pid: process.pid });
324
+
325
+ try {
326
+ const earlier = () => activeJobs({ signer: job.signer, chain: job.chain })
327
+ .filter((other) => other.createdAt < job.createdAt || (other.createdAt === job.createdAt && other.id < job.id));
328
+ if (earlier().length) {
329
+ update({ status: JOB_STATUS.WAITING });
330
+ log(`waiting for earlier background push(es) from ${job.signer}...`);
331
+ while (earlier().length) await new Promise((r) => setTimeout(r, POLL_MS));
332
+ }
333
+
334
+ update({ status: JOB_STATUS.SENDING, startedAt: new Date().toISOString() });
335
+ log(`sending version ${job.version} of ${job.streamId} on ${job.chain}...`);
336
+
337
+ const args = { ...job.args, key: secrets.key ?? null, phrase: secrets.phrase ?? null, password: secrets.password ?? null, pinataJwt: secrets.pinataJwt ?? null, passkey: false };
338
+ const client = await makeChainClient(args);
339
+ requireWalletClient(client);
340
+
341
+ const mn = new Mnemonad({
342
+ publicClient: client.publicClient,
343
+ walletClient: client.walletClient,
344
+ contractAddress: client.contractAddress,
345
+ id: job.streamId,
346
+ ...resolveOffloadParams(args, client.account),
347
+ });
348
+ await unlockStream(mn, { password: args.password, signer: client.walletClient });
349
+
350
+ const patchBytes = new Uint8Array(await readFile(join(dir, 'patch.bin')));
351
+ const result = await new MonadSync({ mnemonad: mn }).commitPush({ patchBytes, baseLength: job.baseLength });
352
+
353
+ if (job.manifest) {
354
+ await writeManifest(job.folder, { streamId: job.streamId, version: result.version, files: job.manifest.files });
355
+ }
356
+ update({ status: JOB_STATUS.DONE, version: result.version, finishedAt: new Date().toISOString() });
357
+ log(`version ${result.version} pushed`);
358
+ } catch (err) {
359
+ const error = err.code === 'STREAM_MOVED'
360
+ ? `the stream changed after this push was prepared (it was built on version ${job.baseLength}) — push again`
361
+ : err.message;
362
+ update({ status: JOB_STATUS.FAILED, error, finishedAt: new Date().toISOString(), reported: false });
363
+ log('failed:', err.stack || err.message);
364
+ process.exitCode = 1;
365
+ } finally {
366
+ await rm(join(dir, 'patch.bin'), { force: true });
367
+ }
368
+ }
369
+
370
+ /** For tests: blocks until a job has finished, and returns it. */
371
+ export async function waitForJob(dir, { timeoutMs = 60_000 } = {}) {
372
+ const deadline = Date.now() + timeoutMs;
373
+ for (;;) {
374
+ const job = refreshJob(dir);
375
+ if (job && !isActive(job)) return job;
376
+ if (Date.now() > deadline) throw new Error(`job ${dir} still ${job?.status} after ${timeoutMs} ms`);
377
+ await new Promise((r) => setTimeout(r, 200));
378
+ }
379
+ }
380
+