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.
- package/README.md +156 -9
- package/bin/mnemonad.js +122 -5
- package/lib/bridgeCodec.js +12 -0
- package/lib/chainClient.js +21 -7
- package/lib/commands/buildIndex.js +171 -0
- package/lib/commands/burn.js +1 -1
- package/lib/commands/compact.js +3 -1
- package/lib/commands/diff.js +3 -1
- package/lib/commands/index.js +2 -0
- package/lib/commands/info.js +10 -1
- package/lib/commands/pull.js +6 -1
- package/lib/commands/push.js +152 -1
- package/lib/commands/search.js +91 -0
- package/lib/commands/shared.js +3 -2
- package/lib/commands/watch.js +60 -9
- package/lib/jobs.js +380 -0
- package/lib/passkeyBridge.js +203 -0
- package/lib/search/Indexer.js +204 -0
- package/lib/search/Searcher.js +68 -0
- package/lib/search/VectorIndex.js +225 -0
- package/lib/search/browser/BrowserSearcher.js +131 -0
- package/lib/search/browser/WasmIndexReader.js +93 -0
- package/lib/search/browser.js +13 -0
- package/lib/search/chunking/ChunkingStrategy.js +23 -0
- package/lib/search/chunking/TextWindowChunkingStrategy.js +69 -0
- package/lib/search/embeddings/EmbeddingProvider.js +40 -0
- package/lib/search/embeddings/StaticEmbeddingProvider.js +180 -0
- package/lib/search/embeddings/modelFiles.browser.js +28 -0
- package/lib/search/embeddings/modelFiles.node.js +41 -0
- package/lib/search/embeddings/modelFiles.shared.js +44 -0
- package/lib/search/index.js +18 -0
- package/lib/search/providers.js +153 -0
- package/lib/search/schema.js +103 -0
- package/mnemonad.config.js +19 -5
- package/package.json +14 -4
package/lib/commands/watch.js
CHANGED
|
@@ -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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
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 =
|
|
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
|
+
|