@attocash/cli 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/application/app.d.ts +11 -0
- package/dist/application/app.js +37 -12
- package/dist/cli/cli.js +13 -2
- package/dist/cli/output.js +1 -1
- package/dist/spending/payments.js +5 -6
- package/dist/storage/state.d.ts +7 -0
- package/dist/storage/state.js +61 -8
- package/dist/wallet/background-receive.d.ts +30 -0
- package/dist/wallet/background-receive.js +117 -0
- package/dist/wallet/receive-daemon.d.ts +1 -0
- package/dist/wallet/receive-daemon.js +63 -0
- package/dist/wallet/work-daemon.d.ts +1 -0
- package/dist/wallet/work-daemon.js +20 -0
- package/dist/wallet/work.d.ts +31 -9
- package/dist/wallet/work.js +307 -78
- package/dist/watches/manager.d.ts +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -689,6 +689,45 @@ mnemonic is lost from the password store, recover it from your private backup
|
|
|
689
689
|
using `atto wallet import` in an uninitialized profile. Preserve the old public
|
|
690
690
|
state and pending-send records for reconciliation before resuming payments.
|
|
691
691
|
|
|
692
|
+
## Background receiving and work preparation
|
|
693
|
+
|
|
694
|
+
`atto wallet receive` keeps automatic receiving in the current terminal. To
|
|
695
|
+
keep receiving after the terminal exits, use:
|
|
696
|
+
|
|
697
|
+
```sh
|
|
698
|
+
atto wallet receive --background
|
|
699
|
+
atto wallet receive status
|
|
700
|
+
atto wallet receive stop
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
The background receiver uses the same profile selected by `--data-dir`, the OS
|
|
704
|
+
password store, active addresses, minimum receive amount, and automatic
|
|
705
|
+
receiving setting. It does not install a service, does not start after reboot,
|
|
706
|
+
and does not grant MCP spending access. `wallet status` reports its separate
|
|
707
|
+
state and latest operational error. Stop it before resetting a profile.
|
|
708
|
+
|
|
709
|
+
All three commands accept `--data-dir <directory>` and `--json`. Starting or
|
|
710
|
+
stopping repeatedly is safe. Start acknowledges local initialization; it does
|
|
711
|
+
not promise that the node or password store is reachable. The detached process
|
|
712
|
+
inherits your login environment and needs access to the OS password store. On
|
|
713
|
+
Linux, keep the Secret Service session available; on macOS and Windows, allow
|
|
714
|
+
the CLI to access its credential. Check `wallet receive status` for retry errors.
|
|
715
|
+
No recovery phrase is stored in the public profile or passed on the command line.
|
|
716
|
+
|
|
717
|
+
Stop prevents new receives and reports `stopping` until the current operation
|
|
718
|
+
finishes. A crashed receiver reports `stopped`; start it explicitly again after
|
|
719
|
+
a crash, reboot, or restoring a backup. Foreground and MCP receivers may coexist,
|
|
720
|
+
but stopping this background receiver does not stop those separate sessions.
|
|
721
|
+
|
|
722
|
+
After a completed send, receive, representative change, or approved pool step,
|
|
723
|
+
the CLI may finish public proof-of-work preparation in a short detached process.
|
|
724
|
+
This contains only public account heads and worker settings, runs for at most a
|
|
725
|
+
minute, and caches usable work for a later transaction. A changed account head
|
|
726
|
+
or an immediate transaction can still require fresh work.
|
|
727
|
+
The worker exits when its queue is drained, keeps at most two speculative
|
|
728
|
+
requests active, and leaves failed or unfinished jobs eligible for a later CLI
|
|
729
|
+
invocation. Reset cancels queued preparation before clearing the profile.
|
|
730
|
+
|
|
692
731
|
## Update notices
|
|
693
732
|
|
|
694
733
|
Interactive CLI commands can show a notice on stderr when a newer stable CLI
|
|
@@ -4,6 +4,7 @@ import type { SendRetry } from '../network/retry.js';
|
|
|
4
4
|
import { type SecretStore } from '../storage/secrets.js';
|
|
5
5
|
import { StateStore } from '../storage/state.js';
|
|
6
6
|
import { SpendLedger, type McpAccess } from '../spending/ledger.js';
|
|
7
|
+
import { type WorkExecution } from '../wallet/work.js';
|
|
7
8
|
import { type ReceiveProgress } from '../wallet/auto-receive.js';
|
|
8
9
|
import type { AccountPool, SpendingPolicy, WalletIdentity } from '../wallet/types.js';
|
|
9
10
|
import { MarketData } from '../pricing/market.js';
|
|
@@ -16,6 +17,10 @@ export declare class AttoApplication {
|
|
|
16
17
|
private readonly labels;
|
|
17
18
|
private readonly auto;
|
|
18
19
|
private readonly work;
|
|
20
|
+
private readonly background;
|
|
21
|
+
private readonly receivingAllowed;
|
|
22
|
+
private preparation?;
|
|
23
|
+
private readonly preparationAbort;
|
|
19
24
|
private readonly payments;
|
|
20
25
|
private watchManager?;
|
|
21
26
|
private watchConfiguration;
|
|
@@ -36,6 +41,8 @@ export declare class AttoApplication {
|
|
|
36
41
|
sendRetry?: SendRetry;
|
|
37
42
|
globalDirectory?: GlobalDirectory;
|
|
38
43
|
onDestination?: (binding: DestinationBinding) => void;
|
|
44
|
+
workExecution?: WorkExecution;
|
|
45
|
+
receivingAllowed?: () => boolean;
|
|
39
46
|
});
|
|
40
47
|
private settings;
|
|
41
48
|
private addresses;
|
|
@@ -133,6 +140,10 @@ export declare class AttoApplication {
|
|
|
133
140
|
private invoke;
|
|
134
141
|
call(name: string, input?: Record<string, unknown>): Promise<unknown>;
|
|
135
142
|
start(): Promise<void>;
|
|
143
|
+
/** Terminal-only detached receiver control; it never changes MCP approval. */
|
|
144
|
+
startBackgroundReceiver(): Promise<import("../wallet/background-receive.js").BackgroundReceiveStatus>;
|
|
145
|
+
stopBackgroundReceiver(): Promise<import("../wallet/background-receive.js").BackgroundReceiveStatus>;
|
|
146
|
+
resumeWork(): void;
|
|
136
147
|
private preparePoolWork;
|
|
137
148
|
close(): Promise<void>;
|
|
138
149
|
}
|
package/dist/application/app.js
CHANGED
|
@@ -12,6 +12,7 @@ import { resolveWalletProfile } from '../storage/profiles.js';
|
|
|
12
12
|
import { SpendLedger } from '../spending/ledger.js';
|
|
13
13
|
import { Payments } from '../spending/payments.js';
|
|
14
14
|
import { WalletWork } from '../wallet/work.js';
|
|
15
|
+
import { BackgroundReceiver } from '../wallet/background-receive.js';
|
|
15
16
|
import { WatchManager } from '../watches/manager.js';
|
|
16
17
|
import { AutoReceiver } from '../wallet/auto-receive.js';
|
|
17
18
|
import { defaultSettings } from '../wallet/defaults.js';
|
|
@@ -30,6 +31,10 @@ export class AttoApplication {
|
|
|
30
31
|
labels;
|
|
31
32
|
auto;
|
|
32
33
|
work;
|
|
34
|
+
background;
|
|
35
|
+
receivingAllowed;
|
|
36
|
+
preparation;
|
|
37
|
+
preparationAbort = new AbortController();
|
|
33
38
|
payments;
|
|
34
39
|
watchManager;
|
|
35
40
|
watchConfiguration = '';
|
|
@@ -55,13 +60,15 @@ export class AttoApplication {
|
|
|
55
60
|
if (!this.store.get('settings'))
|
|
56
61
|
this.store.set('settings', defaultSettings());
|
|
57
62
|
});
|
|
58
|
-
this.work = new WalletWork(this.store, () => this.settings());
|
|
63
|
+
this.work = new WalletWork(this.store, () => this.settings(), options.workExecution);
|
|
64
|
+
this.background = new BackgroundReceiver(this.store);
|
|
65
|
+
this.receivingAllowed = options.receivingAllowed ?? (() => true);
|
|
59
66
|
this.payments = new Payments(this.store, this.ledger, {
|
|
60
67
|
settings: () => this.settings(), addresses: () => this.addresses(), seed: () => this.seed(),
|
|
61
68
|
requireWrite: () => this.requireMcpWrite(), mcp: this.mcp,
|
|
62
69
|
}, this.market, this.work, options.sendRetry, options.onDestination);
|
|
63
70
|
this.auto = new AutoReceiver(() => ({
|
|
64
|
-
settings: this.mcp && this.ledger.mcpAccess() !== 'spend' ? { ...this.settings(), autoReceive: false } : this.settings(),
|
|
71
|
+
settings: !this.receivingAllowed() || (this.mcp && this.ledger.mcpAccess() !== 'spend') ? { ...this.settings(), autoReceive: false } : this.settings(),
|
|
65
72
|
addresses: this.addresses(),
|
|
66
73
|
}), (index, hash) => this.receive({ index, hash }, true), event => options.onReceiveProgress?.(this.labels.decorate(event, this.settings().network)));
|
|
67
74
|
}
|
|
@@ -178,6 +185,8 @@ export class AttoApplication {
|
|
|
178
185
|
this.requireSession();
|
|
179
186
|
if (this.mcp)
|
|
180
187
|
throw new AttoError('LOCAL_APPROVAL_REQUIRED', 'Wallet reset requires approval in a local terminal.');
|
|
188
|
+
if (this.background.status().state !== 'stopped')
|
|
189
|
+
throw new AttoError('WALLET_BUSY', 'Stop the background receiver before resetting this wallet.');
|
|
181
190
|
if (this.started || this.calls.size || this.recoveryReads || this.store.busy
|
|
182
191
|
|| this.watchManager?.list().some(watch => ['running', 'reconnecting'].includes(watch.status))) {
|
|
183
192
|
throw new AttoError('WALLET_BUSY', 'Stop active wallet operations and reset from a new terminal command.');
|
|
@@ -202,7 +211,7 @@ export class AttoApplication {
|
|
|
202
211
|
try {
|
|
203
212
|
// A previous completed send may still have speculative public work queued.
|
|
204
213
|
// Stop it before clearing state; normal calls cannot start during reset.
|
|
205
|
-
await this.work.
|
|
214
|
+
await this.work.cancel();
|
|
206
215
|
await this.store.withExclusiveReset(() => this.store.withWalletLock(async () => {
|
|
207
216
|
if ((this.identity()?.fingerprint ?? null) !== expectedFingerprint) {
|
|
208
217
|
throw new AttoError('WALLET_CHANGED', 'The wallet changed after confirmation. Review it before resetting again.');
|
|
@@ -353,6 +362,8 @@ export class AttoApplication {
|
|
|
353
362
|
return found;
|
|
354
363
|
}
|
|
355
364
|
async receive(request, automatic = false) {
|
|
365
|
+
if (automatic && !this.receivingAllowed())
|
|
366
|
+
throw new AttoError('AUTO_RECEIVE_DISABLED', 'Automatic receiving is stopping.');
|
|
356
367
|
const index = request.index ?? 0;
|
|
357
368
|
const operation = async () => {
|
|
358
369
|
await this.store.withWalletLock(async () => {
|
|
@@ -374,6 +385,7 @@ export class AttoApplication {
|
|
|
374
385
|
if (transaction && transaction.hash.toString() === prior.blockHash && await transaction.isValid()) {
|
|
375
386
|
const result = this.result(transaction, 'received');
|
|
376
387
|
this.store.set(key, { ...prior, result });
|
|
388
|
+
this.work.prepareConfirmed(transaction.block);
|
|
377
389
|
return result;
|
|
378
390
|
}
|
|
379
391
|
// Refresh pending state below: an unconsumed send permits retrying receive
|
|
@@ -403,9 +415,7 @@ export class AttoApplication {
|
|
|
403
415
|
const transaction = await wallet.receive(receivable, parseAddress(request.representative ?? this.settings().representative), null);
|
|
404
416
|
const result = { ...this.result(transaction, 'received'), index, amount: amountOutput(receivable.amount.toString()) };
|
|
405
417
|
this.store.set(key, { hash, index, blockHash: transaction.hash.toString(), result });
|
|
406
|
-
|
|
407
|
-
if (account)
|
|
408
|
-
this.work.prepare([account]);
|
|
418
|
+
this.work.prepareConfirmed(transaction.block);
|
|
409
419
|
return result;
|
|
410
420
|
}
|
|
411
421
|
finally {
|
|
@@ -455,9 +465,7 @@ export class AttoApplication {
|
|
|
455
465
|
}, this.work.worker());
|
|
456
466
|
wallet = execution.wallet;
|
|
457
467
|
const transaction = await wallet.change(toAttoIndex(index), parseAddress(representative), null);
|
|
458
|
-
|
|
459
|
-
if (account)
|
|
460
|
-
this.work.prepare([account]);
|
|
468
|
+
this.work.prepareConfirmed(transaction.block);
|
|
461
469
|
return this.result(transaction, 'representative_changed');
|
|
462
470
|
}
|
|
463
471
|
finally {
|
|
@@ -486,7 +494,7 @@ export class AttoApplication {
|
|
|
486
494
|
const reader = this.reader();
|
|
487
495
|
switch (name) {
|
|
488
496
|
case 'doctor': return runDoctor({ directory: this.store.directory, access: this.mcp ? 'mcp' : undefined, signal: this.doctorAbort.signal, globalDirectory: args.globalDirectory });
|
|
489
|
-
case 'wallet_status': return { directory: this.store.directory, initialized: Boolean(this.identity()), identity: this.identity() ?? null, settings: this.settings(), addresses: this.addresses(), autoReceive: this.auto.status(), mcpAccess: this.ledger.mcpAccess(), pool: this.ledger.pool(), pendingSends: this.ledger.pending().map(({ id, hash, status }) => ({ requestId: id, hash, status })), resetPending: Boolean(this.store.get(RESET_KEY)) };
|
|
497
|
+
case 'wallet_status': return { directory: this.store.directory, initialized: Boolean(this.identity()), identity: this.identity() ?? null, settings: this.settings(), addresses: this.addresses(), autoReceive: this.auto.status(), backgroundReceive: this.background.status(), mcpAccess: this.ledger.mcpAccess(), pool: this.ledger.pool(), pendingSends: this.ledger.pending().map(({ id, hash, status }) => ({ requestId: id, hash, status })), resetPending: Boolean(this.store.get(RESET_KEY)) };
|
|
490
498
|
case 'wallet_configure': return this.configure(args);
|
|
491
499
|
case 'address_list': return { addresses: this.addresses() };
|
|
492
500
|
case 'address_add': return this.store.withWalletLock(async () => {
|
|
@@ -628,14 +636,29 @@ export class AttoApplication {
|
|
|
628
636
|
this.requireReady();
|
|
629
637
|
this.started = true;
|
|
630
638
|
this.auto.start();
|
|
631
|
-
|
|
639
|
+
this.preparation ??= this.preparePoolWork();
|
|
640
|
+
await this.preparation;
|
|
632
641
|
}
|
|
642
|
+
/** Terminal-only detached receiver control; it never changes MCP approval. */
|
|
643
|
+
async startBackgroundReceiver() {
|
|
644
|
+
this.requireReady();
|
|
645
|
+
if (this.mcp)
|
|
646
|
+
throw new AttoError('LOCAL_APPROVAL_REQUIRED', 'Start background receiving through the CLI.');
|
|
647
|
+
return this.background.start();
|
|
648
|
+
}
|
|
649
|
+
async stopBackgroundReceiver() {
|
|
650
|
+
this.requireSession();
|
|
651
|
+
if (this.mcp)
|
|
652
|
+
throw new AttoError('LOCAL_APPROVAL_REQUIRED', 'Stop background receiving through the CLI.');
|
|
653
|
+
return this.background.stop();
|
|
654
|
+
}
|
|
655
|
+
resumeWork() { this.work.resume(); }
|
|
633
656
|
async preparePoolWork() {
|
|
634
657
|
if (this.mcp && this.ledger.mcpAccess() !== 'spend')
|
|
635
658
|
return;
|
|
636
659
|
const indexes = this.ledger.pool().indexes;
|
|
637
660
|
await Promise.allSettled(this.addresses().filter(address => indexes.includes(address.index)).map(async (address) => {
|
|
638
|
-
const account = await this.reader().account(address.address);
|
|
661
|
+
const account = await this.reader().account(address.address, this.preparationAbort.signal);
|
|
639
662
|
if (account && !this.closed)
|
|
640
663
|
this.work.prepare([account]);
|
|
641
664
|
}));
|
|
@@ -645,7 +668,9 @@ export class AttoApplication {
|
|
|
645
668
|
return;
|
|
646
669
|
this.closed = true;
|
|
647
670
|
this.doctorAbort.abort();
|
|
671
|
+
this.preparationAbort.abort();
|
|
648
672
|
await this.auto.close();
|
|
673
|
+
await this.preparation;
|
|
649
674
|
await Promise.allSettled([...this.calls]);
|
|
650
675
|
await this.watchManager?.close();
|
|
651
676
|
await this.work.close();
|
package/dist/cli/cli.js
CHANGED
|
@@ -53,7 +53,7 @@ export async function runCli(argv = process.argv) {
|
|
|
53
53
|
const currentVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
54
54
|
let application;
|
|
55
55
|
let sendRequestId;
|
|
56
|
-
const app = (onReceiveProgress, sendRetry, onDestination) => application ??= new AttoApplication({ directory: program.opts().dataDir, onReceiveProgress, sendRetry, onDestination });
|
|
56
|
+
const app = (onReceiveProgress, sendRetry, onDestination) => application ??= new AttoApplication({ directory: program.opts().dataDir, onReceiveProgress, sendRetry, onDestination, workExecution: onReceiveProgress ? 'in-process' : 'detached' });
|
|
57
57
|
const jsonOutput = (value) => { process.stdout.write(`${JSON.stringify(value, (_, value) => typeof value === 'bigint' ? value.toString() : value)}\n`); };
|
|
58
58
|
const output = (result, operation) => {
|
|
59
59
|
if (program.opts().json)
|
|
@@ -67,6 +67,7 @@ export async function runCli(argv = process.argv) {
|
|
|
67
67
|
return diagnose(input);
|
|
68
68
|
}
|
|
69
69
|
const result = await app().call(name, compact(input));
|
|
70
|
+
app().resumeWork();
|
|
70
71
|
output(result, name);
|
|
71
72
|
};
|
|
72
73
|
const waitForSignal = async (work) => {
|
|
@@ -166,8 +167,14 @@ export async function runCli(argv = process.argv) {
|
|
|
166
167
|
if (program.opts().json)
|
|
167
168
|
output({ displayed: true });
|
|
168
169
|
});
|
|
169
|
-
wallet.command('receive').description('Keep automatic receiving running until Ctrl+C')
|
|
170
|
+
const receiveCommand = wallet.command('receive').description('Keep automatic receiving running until Ctrl+C')
|
|
171
|
+
.option('--background', 'Keep receiving after this terminal exits; restart manually after reboot')
|
|
170
172
|
.action(async () => {
|
|
173
|
+
if (activeCommand.opts().background) {
|
|
174
|
+
const status = await app().startBackgroundReceiver();
|
|
175
|
+
output({ backgroundReceive: status }, 'wallet_receive');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
171
178
|
const receiving = app(event => output(event, 'receive_progress'));
|
|
172
179
|
const status = await receiving.call('wallet_status');
|
|
173
180
|
if (!status.initialized)
|
|
@@ -183,6 +190,8 @@ export async function runCli(argv = process.argv) {
|
|
|
183
190
|
await new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true }));
|
|
184
191
|
});
|
|
185
192
|
});
|
|
193
|
+
receiveCommand.command('status').description('Read detached receiver status').action(() => call('wallet_status'));
|
|
194
|
+
receiveCommand.command('stop').description('Request detached receiver shutdown').action(async () => output({ backgroundReceive: await app().stopBackgroundReceiver() }, 'wallet_receive'));
|
|
186
195
|
const address = program.command('address').description('Manage mnemonic-derived public addresses');
|
|
187
196
|
address.command('list').description('List all saved addresses and their activation state').action(() => call('address_list'));
|
|
188
197
|
address.command('add').description('Add and activate the next account address; receiving funds opens it on the network').action(() => call('address_add'));
|
|
@@ -254,6 +263,7 @@ export async function runCli(argv = process.argv) {
|
|
|
254
263
|
signal,
|
|
255
264
|
onRetry: (error, delayMs) => progress({ requestId: sendRequestId, error: errorResult(error), retryInMs: delayMs }, `${error.message} Retrying in ${delayMs / 1000}s. Press Ctrl+C to stop.\n`),
|
|
256
265
|
}, binding => progress({ requestId: sendRequestId, destination: binding.address, network: binding.network, ...(binding.label ? { personalName: binding.label } : {}) })).call('send', input);
|
|
266
|
+
app().resumeWork();
|
|
257
267
|
output(result, 'send');
|
|
258
268
|
});
|
|
259
269
|
});
|
|
@@ -386,6 +396,7 @@ export async function runCli(argv = process.argv) {
|
|
|
386
396
|
'atto wallet configure': 'atto wallet configure --node-url https://node-public.live.application.atto.cash --auto-receive',
|
|
387
397
|
'atto wallet create': 'atto wallet create', 'atto wallet import': 'atto wallet import',
|
|
388
398
|
'atto wallet reset': 'atto wallet reset', 'atto wallet backup': 'atto wallet backup', 'atto wallet receive': 'atto wallet receive',
|
|
399
|
+
'atto wallet receive status': 'atto wallet receive status', 'atto wallet receive stop': 'atto wallet receive stop',
|
|
389
400
|
'atto address': 'atto address add\n atto address list', 'atto address list': 'atto address list',
|
|
390
401
|
'atto address add': 'atto address add', 'atto address derive': 'atto address derive 3',
|
|
391
402
|
'atto address activate': 'atto address activate 1', 'atto address deactivate': 'atto address deactivate 1',
|
package/dist/cli/output.js
CHANGED
|
@@ -137,7 +137,7 @@ function formatUnlabeledHumanResult(result, operation) {
|
|
|
137
137
|
address: record(result.identity) ? result.identity.address : null,
|
|
138
138
|
network: result.settings.network,
|
|
139
139
|
automaticReceiving: result.settings.autoReceive ? 'Enabled' : 'Disabled',
|
|
140
|
-
...(operation === 'wallet_status' ? { receivingInThisProcess: receiver.running, lastReceiveError: receiver.lastError } : {}),
|
|
140
|
+
...(operation === 'wallet_status' ? { receivingInThisProcess: receiver.running, lastReceiveError: receiver.lastError, backgroundReceiver: result.backgroundReceive } : {}),
|
|
141
141
|
representative: result.settings.representative,
|
|
142
142
|
minReceiveRaw: result.settings.minReceiveRaw,
|
|
143
143
|
nodeUrl: result.settings.nodeUrl,
|
|
@@ -333,15 +333,11 @@ export class Payments {
|
|
|
333
333
|
throw new AttoError('RECEIVABLE_NOT_PENDING', 'The consolidation transfer is not available to receive yet. Resume this payment with the same request ID.');
|
|
334
334
|
}
|
|
335
335
|
const transaction = await execution.wallet.receive(receivable, parseAddress(settings.representative), null);
|
|
336
|
-
|
|
337
|
-
if (account)
|
|
338
|
-
this.work.prepare([account]);
|
|
336
|
+
this.work.prepareConfirmed(transaction.block);
|
|
339
337
|
return transaction;
|
|
340
338
|
}
|
|
341
339
|
const transaction = await execution.wallet.sendByIndex(toAttoIndex(index), parseAddress(step?.destination ?? record.destination), AttoAmount.from(AttoUnit.RAW, step?.raw ?? record.raw), null);
|
|
342
|
-
|
|
343
|
-
if (account)
|
|
344
|
-
this.work.prepare([account]);
|
|
340
|
+
this.work.prepareConfirmed(transaction.block);
|
|
345
341
|
return transaction;
|
|
346
342
|
}
|
|
347
343
|
finally {
|
|
@@ -390,6 +386,7 @@ export class Payments {
|
|
|
390
386
|
});
|
|
391
387
|
if (outcome.status === 'rejected')
|
|
392
388
|
return;
|
|
389
|
+
this.work.prepareConfirmed(outcome.transaction.block);
|
|
393
390
|
}
|
|
394
391
|
if (!record.hash) {
|
|
395
392
|
if (!record.plan)
|
|
@@ -403,6 +400,8 @@ export class Payments {
|
|
|
403
400
|
else if (outcome.status === 'rejected')
|
|
404
401
|
this.ledger.reject(record.id);
|
|
405
402
|
});
|
|
403
|
+
if (outcome.status === 'published')
|
|
404
|
+
this.work.prepareConfirmed(outcome.transaction.block);
|
|
406
405
|
}
|
|
407
406
|
async poolStatus() {
|
|
408
407
|
const pool = this.ledger.pool();
|
package/dist/storage/state.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare class StateStore {
|
|
|
9
9
|
private closed;
|
|
10
10
|
private lockRequests;
|
|
11
11
|
private exclusiveReset;
|
|
12
|
+
private auxiliaryLocks;
|
|
12
13
|
get busy(): boolean;
|
|
13
14
|
constructor(directory: string);
|
|
14
15
|
get<T>(key: string): T | undefined;
|
|
@@ -28,5 +29,11 @@ export declare class StateStore {
|
|
|
28
29
|
/** Acquire account locks first; short wallet reservations may run inside fn.
|
|
29
30
|
* Closing the connection (including process death) releases every lock. */
|
|
30
31
|
withAccountLocks<T>(indexes: readonly number[], fn: () => Promise<T>): Promise<T>;
|
|
32
|
+
/** A process lifetime lock. Unlike the wallet mutation lock this has no
|
|
33
|
+
* bearing on ordinary commands, and SQLite releases it if the owner dies. */
|
|
34
|
+
tryProcessLock(name: string): (() => void) | undefined;
|
|
35
|
+
/** Computation locks never share the wallet/account mutation databases. */
|
|
36
|
+
tryWorkLock(target: string): (() => void) | undefined;
|
|
37
|
+
private tryAuxiliaryLock;
|
|
31
38
|
close(): void;
|
|
32
39
|
}
|
package/dist/storage/state.js
CHANGED
|
@@ -4,6 +4,20 @@ import { join, resolve } from 'node:path';
|
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
6
|
import { AttoError } from '../domain/errors.js';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
function ensureDatabaseFile(path) {
|
|
9
|
+
// Closing any raw descriptor for an existing SQLite file drops this process's
|
|
10
|
+
// POSIX locks, even when a different connection still owns the lease.
|
|
11
|
+
try {
|
|
12
|
+
closeSync(openSync(path, 'ax', 0o600));
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code !== 'EEXIST')
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
if (process.platform !== 'win32')
|
|
19
|
+
chmodSync(path, 0o600);
|
|
20
|
+
}
|
|
7
21
|
export function defaultDataDirectory() {
|
|
8
22
|
if (process.platform === 'win32')
|
|
9
23
|
return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'Atto MCP');
|
|
@@ -21,6 +35,7 @@ export class StateStore {
|
|
|
21
35
|
closed = false;
|
|
22
36
|
lockRequests = 0;
|
|
23
37
|
exclusiveReset = false;
|
|
38
|
+
auxiliaryLocks = 0;
|
|
24
39
|
get busy() { return this.lockRequests !== 0 || this.exclusiveReset; }
|
|
25
40
|
constructor(directory) {
|
|
26
41
|
this.directory = resolve(directory);
|
|
@@ -28,9 +43,7 @@ export class StateStore {
|
|
|
28
43
|
const connections = [];
|
|
29
44
|
const open = (name) => {
|
|
30
45
|
const path = join(this.directory, name);
|
|
31
|
-
|
|
32
|
-
if (process.platform !== 'win32')
|
|
33
|
-
chmodSync(path, 0o600);
|
|
46
|
+
ensureDatabaseFile(path);
|
|
34
47
|
const connection = new DatabaseSync(path);
|
|
35
48
|
connections.push(connection);
|
|
36
49
|
return connection;
|
|
@@ -93,7 +106,7 @@ export class StateStore {
|
|
|
93
106
|
* store closes, even if another session prevents acquiring exclusivity. */
|
|
94
107
|
async withExclusiveReset(fn) {
|
|
95
108
|
this.requireOpen();
|
|
96
|
-
if (this.lockRequests !== 0 || this.exclusiveReset)
|
|
109
|
+
if (this.lockRequests !== 0 || this.auxiliaryLocks !== 0 || this.exclusiveReset)
|
|
97
110
|
throw new AttoError('WALLET_BUSY', 'Wait for wallet operations before resetting this profile.');
|
|
98
111
|
try {
|
|
99
112
|
this.lifecycle.exec('ROLLBACK;');
|
|
@@ -192,9 +205,7 @@ export class StateStore {
|
|
|
192
205
|
// Sorted acquisition prevents cycles when payments consolidate accounts.
|
|
193
206
|
for (const index of [...new Set(indexes)].sort((left, right) => left - right)) {
|
|
194
207
|
const file = join(directory, `${index}.sqlite`);
|
|
195
|
-
|
|
196
|
-
if (process.platform !== 'win32')
|
|
197
|
-
chmodSync(file, 0o600);
|
|
208
|
+
ensureDatabaseFile(file);
|
|
198
209
|
const connection = new DatabaseSync(file);
|
|
199
210
|
connections.push(connection);
|
|
200
211
|
connection.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE;');
|
|
@@ -242,10 +253,52 @@ export class StateStore {
|
|
|
242
253
|
}
|
|
243
254
|
}
|
|
244
255
|
}
|
|
256
|
+
/** A process lifetime lock. Unlike the wallet mutation lock this has no
|
|
257
|
+
* bearing on ordinary commands, and SQLite releases it if the owner dies. */
|
|
258
|
+
tryProcessLock(name) {
|
|
259
|
+
if (!/^[a-z0-9-]{1,64}$/i.test(name))
|
|
260
|
+
throw new AttoError('INVALID_LOCK', 'Invalid process lock name.');
|
|
261
|
+
return this.tryAuxiliaryLock('process-locks', name);
|
|
262
|
+
}
|
|
263
|
+
/** Computation locks never share the wallet/account mutation databases. */
|
|
264
|
+
tryWorkLock(target) {
|
|
265
|
+
return this.tryAuxiliaryLock('work-locks', createHash('sha256').update(target).digest('hex'));
|
|
266
|
+
}
|
|
267
|
+
tryAuxiliaryLock(folder, name) {
|
|
268
|
+
this.requireOpen();
|
|
269
|
+
const directory = join(this.directory, folder);
|
|
270
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
271
|
+
const file = join(directory, `${name}.sqlite`);
|
|
272
|
+
ensureDatabaseFile(file);
|
|
273
|
+
const connection = new DatabaseSync(file);
|
|
274
|
+
try {
|
|
275
|
+
connection.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE;');
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
connection.close();
|
|
279
|
+
const code = error.errcode;
|
|
280
|
+
if (code === 5 || code === 6)
|
|
281
|
+
return undefined;
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
this.auxiliaryLocks++;
|
|
285
|
+
let held = true;
|
|
286
|
+
return () => {
|
|
287
|
+
if (!held)
|
|
288
|
+
return;
|
|
289
|
+
held = false;
|
|
290
|
+
try {
|
|
291
|
+
connection.close();
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
this.auxiliaryLocks--;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
245
298
|
close() {
|
|
246
299
|
if (this.closed)
|
|
247
300
|
return;
|
|
248
|
-
if (this.lockRequests !== 0)
|
|
301
|
+
if (this.lockRequests !== 0 || this.auxiliaryLocks !== 0)
|
|
249
302
|
throw new AttoError('WALLET_BUSY', 'Wait for wallet operations before closing state.');
|
|
250
303
|
this.coordination.close();
|
|
251
304
|
this.state.close();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { errorResult } from '../domain/errors.js';
|
|
2
|
+
import type { StateStore } from '../storage/state.js';
|
|
3
|
+
import type { ReceiveProgress } from './auto-receive.js';
|
|
4
|
+
interface ReceiverRecord {
|
|
5
|
+
token: string;
|
|
6
|
+
desired: boolean;
|
|
7
|
+
state: 'starting' | 'running' | 'stopping' | 'stopped';
|
|
8
|
+
lastError: ReturnType<typeof errorResult> | null;
|
|
9
|
+
}
|
|
10
|
+
export interface BackgroundReceiveStatus {
|
|
11
|
+
state: 'running' | 'stopping' | 'stopped';
|
|
12
|
+
lastError: ReturnType<typeof errorResult> | null;
|
|
13
|
+
}
|
|
14
|
+
export declare function requireReceivingProfile(store: StateStore): void;
|
|
15
|
+
/** Profile control and observable process ownership. Persisted intent alone
|
|
16
|
+
* cannot make a receiver live or restart it after restoring a backup. */
|
|
17
|
+
export declare class BackgroundReceiver {
|
|
18
|
+
private readonly store;
|
|
19
|
+
constructor(store: StateStore);
|
|
20
|
+
status(): BackgroundReceiveStatus;
|
|
21
|
+
start(): Promise<BackgroundReceiveStatus>;
|
|
22
|
+
stop(): Promise<BackgroundReceiveStatus>;
|
|
23
|
+
private launch;
|
|
24
|
+
/** A stale launch token cannot restart a stopped/reset/restored profile. */
|
|
25
|
+
claim(token: string): (() => void) | undefined;
|
|
26
|
+
stopping(token: string): boolean;
|
|
27
|
+
update(token: string, update: Partial<Omit<ReceiverRecord, 'token'>>): void;
|
|
28
|
+
progress(token: string, event: ReceiveProgress): void;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { AttoError, errorResult } from '../domain/errors.js';
|
|
5
|
+
const KEY = 'receive.background';
|
|
6
|
+
export function requireReceivingProfile(store) {
|
|
7
|
+
if (store.get('wallet.reset'))
|
|
8
|
+
throw new AttoError('WALLET_RESET_REQUIRED', 'Finish wallet reset before starting receiving.');
|
|
9
|
+
if (!store.get('identity'))
|
|
10
|
+
throw new AttoError('WALLET_NOT_INITIALIZED', 'Create or import a wallet before receiving: atto wallet create or atto wallet import.');
|
|
11
|
+
if (!store.get('settings')?.autoReceive)
|
|
12
|
+
throw new AttoError('AUTO_RECEIVE_DISABLED', 'Automatic receiving is disabled. Enable it with atto wallet configure --auto-receive.');
|
|
13
|
+
if (!store.get('addresses')?.some(address => address.active))
|
|
14
|
+
throw new AttoError('NO_ACTIVE_ACCOUNTS', 'Add or activate a wallet address before receiving.');
|
|
15
|
+
}
|
|
16
|
+
/** Profile control and observable process ownership. Persisted intent alone
|
|
17
|
+
* cannot make a receiver live or restart it after restoring a backup. */
|
|
18
|
+
export class BackgroundReceiver {
|
|
19
|
+
store;
|
|
20
|
+
constructor(store) {
|
|
21
|
+
this.store = store;
|
|
22
|
+
}
|
|
23
|
+
status() {
|
|
24
|
+
const record = this.store.get(KEY);
|
|
25
|
+
if (!record)
|
|
26
|
+
return { state: 'stopped', lastError: null };
|
|
27
|
+
const release = this.store.tryProcessLock('receive-daemon');
|
|
28
|
+
const live = !release;
|
|
29
|
+
release?.();
|
|
30
|
+
const lastError = record.lastError ?? (!live && record.state !== 'stopped'
|
|
31
|
+
? errorResult(new AttoError('RECEIVER_EXITED', 'The background receiver exited. Start it again with atto wallet receive --background.')) : null);
|
|
32
|
+
return { state: live ? record.desired ? 'running' : 'stopping' : 'stopped', lastError };
|
|
33
|
+
}
|
|
34
|
+
async start() {
|
|
35
|
+
// Serialize launch/stop decisions, including startup acknowledgment. The
|
|
36
|
+
// child performs only local initialization before acknowledging this call.
|
|
37
|
+
return this.store.withWalletLock(async () => {
|
|
38
|
+
const status = this.status();
|
|
39
|
+
if (status.state !== 'stopped')
|
|
40
|
+
return status;
|
|
41
|
+
requireReceivingProfile(this.store);
|
|
42
|
+
const token = randomUUID();
|
|
43
|
+
this.store.set(KEY, { token, desired: true, state: 'starting', lastError: null });
|
|
44
|
+
try {
|
|
45
|
+
await this.launch(token);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
this.update(token, { desired: false, state: 'stopping', lastError: errorResult(error) });
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
return this.status();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async stop() {
|
|
55
|
+
return this.store.withWalletLock(async () => {
|
|
56
|
+
const status = this.status();
|
|
57
|
+
const record = this.store.get(KEY);
|
|
58
|
+
if (record)
|
|
59
|
+
this.store.set(KEY, { ...record, desired: false, state: status.state === 'stopped' ? 'stopped' : 'stopping', lastError: status.lastError });
|
|
60
|
+
return { ...status, state: status.state === 'stopped' ? 'stopped' : 'stopping' };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
launch(token) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./receive-daemon.js', import.meta.url)), this.store.directory, token], {
|
|
66
|
+
detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true,
|
|
67
|
+
});
|
|
68
|
+
let settled = false;
|
|
69
|
+
const finish = (error) => {
|
|
70
|
+
if (settled)
|
|
71
|
+
return;
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
if (child.connected)
|
|
75
|
+
child.disconnect();
|
|
76
|
+
child.unref();
|
|
77
|
+
if (error)
|
|
78
|
+
reject(error);
|
|
79
|
+
else
|
|
80
|
+
resolve();
|
|
81
|
+
};
|
|
82
|
+
const timer = setTimeout(() => finish(new AttoError('RECEIVER_START_TIMEOUT', 'Background receiver initialization timed out. Check wallet receive status before retrying.')), 5000);
|
|
83
|
+
child.on('error', () => finish(new AttoError('RECEIVER_START_FAILED', 'The background receiver could not start. Check the installed CLI and profile permissions.')));
|
|
84
|
+
child.once('exit', () => finish(new AttoError('RECEIVER_START_FAILED', 'The background receiver exited before initialization completed. Check wallet receive status.')));
|
|
85
|
+
child.on('message', (message) => {
|
|
86
|
+
if (message && typeof message === 'object' && 'ready' in message && message.ready === token)
|
|
87
|
+
finish();
|
|
88
|
+
});
|
|
89
|
+
child.unref();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** A stale launch token cannot restart a stopped/reset/restored profile. */
|
|
93
|
+
claim(token) {
|
|
94
|
+
return this.store.transaction(() => {
|
|
95
|
+
if (this.stopping(token))
|
|
96
|
+
return;
|
|
97
|
+
return this.store.tryProcessLock('receive-daemon');
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
stopping(token) {
|
|
101
|
+
const record = this.store.get(KEY);
|
|
102
|
+
return record?.token !== token || !record.desired;
|
|
103
|
+
}
|
|
104
|
+
update(token, update) {
|
|
105
|
+
this.store.transaction(() => {
|
|
106
|
+
const record = this.store.get(KEY);
|
|
107
|
+
if (record?.token === token)
|
|
108
|
+
this.store.set(KEY, { ...record, ...update });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
progress(token, event) {
|
|
112
|
+
if (event.event === 'retry' || event.event === 'reconnecting')
|
|
113
|
+
this.update(token, { lastError: event.error });
|
|
114
|
+
else if (event.event === 'received')
|
|
115
|
+
this.update(token, { lastError: null });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
process.env.KOTLIN_LOGGING_STARTUP_MESSAGE = 'false';
|
|
2
|
+
const [directory, token] = process.argv.slice(2);
|
|
3
|
+
if (directory && token) {
|
|
4
|
+
const { setTimeout: delay } = await import('node:timers/promises');
|
|
5
|
+
const { StateStore } = await import('../storage/state.js');
|
|
6
|
+
const { AttoApplication } = await import('../application/app.js');
|
|
7
|
+
const { BackgroundReceiver, requireReceivingProfile } = await import('./background-receive.js');
|
|
8
|
+
const { errorResult } = await import('../domain/errors.js');
|
|
9
|
+
let store;
|
|
10
|
+
let application;
|
|
11
|
+
let receiver;
|
|
12
|
+
let release;
|
|
13
|
+
const stop = () => receiver?.update(token, { desired: false, state: 'stopping' });
|
|
14
|
+
try {
|
|
15
|
+
store = new StateStore(directory);
|
|
16
|
+
receiver = new BackgroundReceiver(store);
|
|
17
|
+
release = receiver.claim(token);
|
|
18
|
+
if (release) {
|
|
19
|
+
requireReceivingProfile(store);
|
|
20
|
+
const owner = receiver;
|
|
21
|
+
application = new AttoApplication({
|
|
22
|
+
directory, receivingAllowed: () => !owner.stopping(token),
|
|
23
|
+
onReceiveProgress: event => owner.progress(token, event),
|
|
24
|
+
});
|
|
25
|
+
process.once('SIGINT', stop);
|
|
26
|
+
process.once('SIGTERM', stop);
|
|
27
|
+
receiver.update(token, { state: 'running' });
|
|
28
|
+
// Acknowledge local construction/ownership before starting any network or
|
|
29
|
+
// password-store operation. IPC is detached immediately after the reply.
|
|
30
|
+
if (process.connected)
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
process.send({ ready: token }, error => error ? reject(error) : resolve());
|
|
33
|
+
});
|
|
34
|
+
if (process.connected)
|
|
35
|
+
process.disconnect();
|
|
36
|
+
if (!receiver.stopping(token)) {
|
|
37
|
+
void application.start().catch(error => owner.update(token, { lastError: errorResult(error) }));
|
|
38
|
+
while (!receiver.stopping(token))
|
|
39
|
+
await delay(100);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (release)
|
|
45
|
+
receiver?.update(token, { desired: false, state: 'stopping', lastError: errorResult(error) });
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
// Keep ownership and stopping visible until the current receive, startup
|
|
49
|
+
// lookups, and work requests have all finished or been canceled.
|
|
50
|
+
if (release)
|
|
51
|
+
receiver?.update(token, { desired: false, state: 'stopping' });
|
|
52
|
+
await application?.close();
|
|
53
|
+
if (release)
|
|
54
|
+
receiver?.update(token, { desired: false, state: 'stopped' });
|
|
55
|
+
release?.();
|
|
56
|
+
store?.close();
|
|
57
|
+
process.removeListener('SIGINT', stop);
|
|
58
|
+
process.removeListener('SIGTERM', stop);
|
|
59
|
+
if (process.connected)
|
|
60
|
+
process.disconnect();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
process.env.KOTLIN_LOGGING_STARTUP_MESSAGE = 'false';
|
|
2
|
+
const [directory, epoch] = process.argv.slice(2);
|
|
3
|
+
if (directory && epoch) {
|
|
4
|
+
const { StateStore } = await import('../storage/state.js');
|
|
5
|
+
const { WalletWork } = await import('./work.js');
|
|
6
|
+
let store;
|
|
7
|
+
let work;
|
|
8
|
+
try {
|
|
9
|
+
store = new StateStore(directory);
|
|
10
|
+
const state = store;
|
|
11
|
+
work = new WalletWork(state, () => state.get('settings'));
|
|
12
|
+
await work.runDetached(epoch);
|
|
13
|
+
}
|
|
14
|
+
catch { /* Public jobs remain eligible after startup or storage failures. */ }
|
|
15
|
+
finally {
|
|
16
|
+
await work?.close();
|
|
17
|
+
store?.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export {};
|
package/dist/wallet/work.d.ts
CHANGED
|
@@ -5,25 +5,47 @@ export interface BlockWorker {
|
|
|
5
5
|
workBlock(block: AttoBlock, signal?: AbortSignal): Promise<AttoWork>;
|
|
6
6
|
close(): void;
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
export type WorkExecution = 'in-process' | 'detached';
|
|
9
|
+
/** Public preparation, validation and caching. No credential or signer enters
|
|
10
|
+
* this owner. Speculative failures never escape into completed transactions. */
|
|
9
11
|
export declare class WalletWork {
|
|
10
12
|
private readonly store;
|
|
11
13
|
private readonly settings;
|
|
12
|
-
private readonly
|
|
13
|
-
private readonly pending;
|
|
14
|
-
private readonly requests;
|
|
15
|
-
private backgroundCount;
|
|
14
|
+
private readonly execution;
|
|
16
15
|
private closed;
|
|
17
|
-
|
|
16
|
+
private drainTask?;
|
|
17
|
+
private resumeRequested;
|
|
18
|
+
private readonly active;
|
|
19
|
+
private readonly computations;
|
|
20
|
+
private readonly requests;
|
|
21
|
+
constructor(store: StateStore, settings: () => WalletSettings, execution?: WorkExecution);
|
|
22
|
+
private scope;
|
|
23
|
+
private nextBlock;
|
|
24
|
+
private key;
|
|
25
|
+
private head;
|
|
26
|
+
private same;
|
|
18
27
|
isReady(account: AttoAccount): boolean;
|
|
19
28
|
prepare(accounts: readonly AttoAccount[]): void;
|
|
29
|
+
/** A confirmed block is sufficient to prepare its successor, including after
|
|
30
|
+
* publication recovery. This unsigned change template is never published;
|
|
31
|
+
* work depends on the head, height, network and time, not its representative. */
|
|
32
|
+
prepareConfirmed(block: AttoBlock): void;
|
|
33
|
+
private enqueue;
|
|
34
|
+
/** Resume only after successful CLI input validation/operation, never in the constructor. */
|
|
35
|
+
resume(): void;
|
|
20
36
|
worker(): BlockWorker;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
private queue;
|
|
38
|
+
/** Caller owns the short state transaction. Lower observations cannot undo
|
|
39
|
+
* newer heads; an equal-height replacement invalidates the previous fork. */
|
|
40
|
+
private observe;
|
|
24
41
|
private read;
|
|
25
42
|
private save;
|
|
26
43
|
private obtain;
|
|
27
44
|
private compute;
|
|
45
|
+
/** Release the singleton inside the final queue transaction. An enqueue is
|
|
46
|
+
* either seen by this owner or its launch candidate can become the next owner. */
|
|
47
|
+
runDetached(epoch: string): Promise<void>;
|
|
28
48
|
private drain;
|
|
49
|
+
cancel(): Promise<void>;
|
|
50
|
+
close(): Promise<void>;
|
|
29
51
|
}
|
package/dist/wallet/work.js
CHANGED
|
@@ -1,62 +1,153 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
6
|
+
import { AttoBlock, AttoChangeBlock, AttoInstant, AttoWork, attoAccountChange, attoBlockWorkTarget, toAttoHeight } from '@attocash/commons-core';
|
|
2
7
|
import { requestWork } from '../network/work.js';
|
|
3
8
|
import { AttoError } from '../domain/errors.js';
|
|
4
|
-
const
|
|
9
|
+
const QUEUE_KEY = 'work.queue';
|
|
10
|
+
const EPOCH_KEY = 'work.epoch';
|
|
5
11
|
const QUEUE_LIMIT = 100;
|
|
6
|
-
|
|
12
|
+
const CONCURRENCY = 2;
|
|
13
|
+
const SPECULATIVE_MS = 10_000;
|
|
14
|
+
const WORKER_MS = 60_000;
|
|
15
|
+
/** Public preparation, validation and caching. No credential or signer enters
|
|
16
|
+
* this owner. Speculative failures never escape into completed transactions. */
|
|
7
17
|
export class WalletWork {
|
|
8
18
|
store;
|
|
9
19
|
settings;
|
|
10
|
-
|
|
11
|
-
pending = new Map();
|
|
12
|
-
requests = new Set();
|
|
13
|
-
backgroundCount = 0;
|
|
20
|
+
execution;
|
|
14
21
|
closed = false;
|
|
15
|
-
|
|
22
|
+
drainTask;
|
|
23
|
+
resumeRequested = false;
|
|
24
|
+
active = new Set();
|
|
25
|
+
computations = new Map();
|
|
26
|
+
requests = new Set();
|
|
27
|
+
constructor(store, settings, execution = 'in-process') {
|
|
16
28
|
this.store = store;
|
|
17
29
|
this.settings = settings;
|
|
30
|
+
this.execution = execution;
|
|
31
|
+
}
|
|
32
|
+
scope() {
|
|
33
|
+
const { network, nodeUrl, workerUrl } = this.settings();
|
|
34
|
+
return JSON.stringify([this.store.get(EPOCH_KEY) ?? null, this.store.get('identity') ?? null, network, nodeUrl, workerUrl]);
|
|
35
|
+
}
|
|
36
|
+
nextBlock(account) {
|
|
37
|
+
return attoAccountChange(account, account.representativeAddress, AttoInstant.Companion.now().toString()).block;
|
|
18
38
|
}
|
|
39
|
+
key(block) { return `work.${block.network.name}.${block.publicKey}`; }
|
|
40
|
+
head(block) { return { scope: this.scope(), target: attoBlockWorkTarget(block), height: block.height.toString() }; }
|
|
41
|
+
same(left, right) { return left.scope === right.scope && left.target === right.target && left.height === right.height; }
|
|
19
42
|
isReady(account) {
|
|
20
|
-
|
|
43
|
+
try {
|
|
44
|
+
return !this.closed && account.network.name === this.settings().network && Boolean(this.read(this.nextBlock(account)));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
21
47
|
return false;
|
|
22
|
-
|
|
48
|
+
}
|
|
23
49
|
}
|
|
24
50
|
prepare(accounts) {
|
|
51
|
+
try {
|
|
52
|
+
this.enqueue(accounts.map(account => this.nextBlock(account)));
|
|
53
|
+
}
|
|
54
|
+
catch { /* Public preparation must not change a completed payment's result. */ }
|
|
55
|
+
}
|
|
56
|
+
/** A confirmed block is sufficient to prepare its successor, including after
|
|
57
|
+
* publication recovery. This unsigned change template is never published;
|
|
58
|
+
* work depends on the head, height, network and time, not its representative. */
|
|
59
|
+
prepareConfirmed(block) {
|
|
60
|
+
try {
|
|
61
|
+
this.enqueue([new AttoChangeBlock(block.network, block.version, block.algorithm, block.publicKey, toAttoHeight((BigInt(block.height.toString()) + 1n).toString()), block.balance, AttoInstant.Companion.now(), block.hash, block.algorithm, block.publicKey)]);
|
|
62
|
+
}
|
|
63
|
+
catch { /* Even template/storage failures are optional after confirmation. */ }
|
|
64
|
+
}
|
|
65
|
+
enqueue(blocks) {
|
|
25
66
|
if (this.closed)
|
|
26
67
|
return;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
68
|
+
try {
|
|
69
|
+
this.store.transaction(() => {
|
|
70
|
+
if (!this.store.get(EPOCH_KEY))
|
|
71
|
+
this.store.set(EPOCH_KEY, randomUUID());
|
|
72
|
+
let queue = this.queue();
|
|
73
|
+
for (const block of blocks) {
|
|
74
|
+
if (block.network.name !== this.settings().network)
|
|
75
|
+
continue;
|
|
76
|
+
const key = this.key(block);
|
|
77
|
+
const head = this.head(block);
|
|
78
|
+
if (!this.observe(key, head))
|
|
79
|
+
continue;
|
|
80
|
+
queue = queue.filter(job => job.scope === head.scope);
|
|
81
|
+
const position = queue.findIndex(job => job.key === key);
|
|
82
|
+
if (this.read(block)) {
|
|
83
|
+
queue = queue.filter(job => job.key !== key || !this.same(job, head));
|
|
84
|
+
}
|
|
85
|
+
else if (position !== -1) {
|
|
86
|
+
if (!this.same(queue[position], head))
|
|
87
|
+
queue[position] = { ...head, key, id: randomUUID(), block: block.toJson() };
|
|
88
|
+
}
|
|
89
|
+
else if (queue.length < QUEUE_LIMIT)
|
|
90
|
+
queue.push({ ...head, key, id: randomUUID(), block: block.toJson() });
|
|
91
|
+
}
|
|
92
|
+
this.store.set(QUEUE_KEY, queue);
|
|
93
|
+
});
|
|
94
|
+
this.resume();
|
|
95
|
+
}
|
|
96
|
+
catch { /* A completed transaction survives speculative storage/launch failures. */ }
|
|
97
|
+
}
|
|
98
|
+
/** Resume only after successful CLI input validation/operation, never in the constructor. */
|
|
99
|
+
resume() {
|
|
100
|
+
if (this.closed)
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
if (!this.queue().length)
|
|
104
|
+
return;
|
|
105
|
+
if (this.execution === 'detached') {
|
|
106
|
+
// Always launch a candidate after enqueueing. The child checks ownership
|
|
107
|
+
// in the same state transaction used by the draining owner's exit.
|
|
108
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./work-daemon.js', import.meta.url)), this.store.directory, this.store.get(EPOCH_KEY) ?? ''], {
|
|
109
|
+
detached: true, stdio: 'ignore', windowsHide: true,
|
|
110
|
+
});
|
|
111
|
+
child.on('error', () => { });
|
|
112
|
+
child.unref();
|
|
113
|
+
}
|
|
114
|
+
else if (this.drainTask)
|
|
115
|
+
this.resumeRequested = true;
|
|
116
|
+
else {
|
|
117
|
+
this.resumeRequested = false;
|
|
118
|
+
this.drainTask = this.drain().catch(() => { }).finally(() => {
|
|
119
|
+
this.drainTask = undefined;
|
|
120
|
+
if (this.resumeRequested)
|
|
121
|
+
this.resume();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch { /* Pending jobs remain eligible on a later invocation. */ }
|
|
38
126
|
}
|
|
39
127
|
worker() {
|
|
40
|
-
// A signing operation borrows this cache; only the application closes it.
|
|
41
128
|
return { workBlock: (block, signal) => this.obtain(block, false, signal), close() { } };
|
|
42
129
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
130
|
+
queue() {
|
|
131
|
+
const value = this.store.get(QUEUE_KEY);
|
|
132
|
+
if (!Array.isArray(value))
|
|
133
|
+
return [];
|
|
134
|
+
return value.filter((job) => job !== null && typeof job === 'object'
|
|
135
|
+
&& ['id', 'key', 'scope', 'target', 'block'].every(key => typeof job[key] === 'string')
|
|
136
|
+
&& typeof job.height === 'string' && /^\d+$/.test(job.height)).slice(0, QUEUE_LIMIT);
|
|
49
137
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
138
|
+
/** Caller owns the short state transaction. Lower observations cannot undo
|
|
139
|
+
* newer heads; an equal-height replacement invalidates the previous fork. */
|
|
140
|
+
observe(key, head) {
|
|
141
|
+
const prior = this.store.get('head.' + key);
|
|
142
|
+
if (prior?.scope === head.scope && /^\d+$/.test(prior.height) && BigInt(prior.height) > BigInt(head.height))
|
|
143
|
+
return false;
|
|
144
|
+
this.store.set('head.' + key, head);
|
|
145
|
+
return true;
|
|
54
146
|
}
|
|
55
|
-
key(block) { return `work.${block.network.name}.${block.publicKey.toString()}`; }
|
|
56
147
|
read(block) {
|
|
57
148
|
try {
|
|
58
149
|
const record = this.store.get(this.key(block));
|
|
59
|
-
if (!record || record.
|
|
150
|
+
if (!record || !this.same(record, this.head(block)) || !/^[0-9a-f]{16}$/i.test(record.work))
|
|
60
151
|
return;
|
|
61
152
|
const work = AttoWork.Companion.parse(record.work);
|
|
62
153
|
return work.isValid(block) ? work : undefined;
|
|
@@ -65,63 +156,126 @@ export class WalletWork {
|
|
|
65
156
|
return undefined;
|
|
66
157
|
}
|
|
67
158
|
}
|
|
68
|
-
save(block, work) {
|
|
69
|
-
if (this.closed ||
|
|
159
|
+
save(block, head, work) {
|
|
160
|
+
if (this.closed || this.scope() !== head.scope)
|
|
70
161
|
return;
|
|
71
|
-
const key = this.key(block);
|
|
72
162
|
this.store.transaction(() => {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
// including work another CLI/MCP process has already persisted.
|
|
76
|
-
if (prior && /^\d+$/.test(prior.height) && BigInt(prior.height) > BigInt(block.height.toString()))
|
|
163
|
+
const observed = this.store.get('head.' + this.key(block));
|
|
164
|
+
if (observed && !this.same(observed, head))
|
|
77
165
|
return;
|
|
78
|
-
this.store.set(key, { target:
|
|
166
|
+
this.store.set(this.key(block), { scope: head.scope, target: head.target, height: head.height, work: work.toString() });
|
|
167
|
+
this.store.set(QUEUE_KEY, this.queue().filter(job => job.key !== this.key(block) || !this.same(job, head)));
|
|
79
168
|
});
|
|
80
169
|
}
|
|
81
|
-
async obtain(block, speculative
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
170
|
+
async obtain(block, speculative, signal) {
|
|
171
|
+
// Establish the shared generation before any computation takes its lock.
|
|
172
|
+
// Otherwise a first enqueue could change its scope while foreground work
|
|
173
|
+
// was already running under an uninitialized generation.
|
|
174
|
+
if (!this.store.get(EPOCH_KEY)) {
|
|
175
|
+
try {
|
|
176
|
+
this.store.transaction(() => { if (!this.store.get(EPOCH_KEY))
|
|
177
|
+
this.store.set(EPOCH_KEY, randomUUID()); });
|
|
178
|
+
}
|
|
179
|
+
catch { /* Foreground work can still use its ordinary uncached fallback. */ }
|
|
180
|
+
}
|
|
181
|
+
const key = this.scope() + ':' + this.key(block) + ':' + attoBlockWorkTarget(block);
|
|
182
|
+
const existing = this.computations.get(key);
|
|
92
183
|
if (existing) {
|
|
184
|
+
let cancel;
|
|
93
185
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
186
|
+
const cancelled = new Promise((_, reject) => {
|
|
187
|
+
cancel = () => reject(new AttoError('CANCELLED', 'Send cancelled.'));
|
|
188
|
+
if (signal?.aborted)
|
|
189
|
+
cancel();
|
|
190
|
+
else
|
|
191
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
192
|
+
});
|
|
193
|
+
const work = await Promise.race([existing.promise, cancelled]);
|
|
96
194
|
if (work.isValid(block))
|
|
97
195
|
return work;
|
|
98
196
|
}
|
|
99
197
|
catch (error) {
|
|
100
|
-
|
|
101
|
-
// normal worker budget if that earlier background attempt failed.
|
|
102
|
-
if (speculative || !existing.speculative)
|
|
198
|
+
if (signal?.aborted || speculative || !existing.speculative)
|
|
103
199
|
throw error;
|
|
104
200
|
}
|
|
201
|
+
finally {
|
|
202
|
+
if (cancel)
|
|
203
|
+
signal?.removeEventListener('abort', cancel);
|
|
204
|
+
}
|
|
105
205
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
this.
|
|
206
|
+
const operation = this.compute(block, speculative, signal);
|
|
207
|
+
this.active.add(operation);
|
|
208
|
+
const computation = { promise: operation, speculative };
|
|
209
|
+
this.computations.set(key, computation);
|
|
110
210
|
try {
|
|
111
|
-
return await
|
|
211
|
+
return await operation;
|
|
112
212
|
}
|
|
113
213
|
finally {
|
|
114
|
-
|
|
115
|
-
|
|
214
|
+
this.active.delete(operation);
|
|
215
|
+
if (this.computations.get(key) === computation)
|
|
216
|
+
this.computations.delete(key);
|
|
116
217
|
}
|
|
117
218
|
}
|
|
118
|
-
async compute(block,
|
|
219
|
+
async compute(block, speculative, signal) {
|
|
220
|
+
if (this.closed)
|
|
221
|
+
throw new AttoError('WORK_CLOSED', 'The wallet work cache is closed.');
|
|
222
|
+
const settings = this.settings();
|
|
223
|
+
if (block.network.name !== settings.network)
|
|
224
|
+
throw new AttoError('NETWORK_MISMATCH', 'Work must use the configured wallet network.');
|
|
225
|
+
const head = this.head(block);
|
|
119
226
|
const controller = new AbortController();
|
|
120
227
|
this.requests.add(controller);
|
|
121
|
-
const
|
|
228
|
+
const combined = AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]);
|
|
229
|
+
let timer = setTimeout(() => controller.abort(), speculative ? SPECULATIVE_MS : WORKER_MS);
|
|
230
|
+
let release;
|
|
231
|
+
let slot;
|
|
122
232
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
233
|
+
if (!speculative) {
|
|
234
|
+
try {
|
|
235
|
+
this.store.transaction(() => this.observe(this.key(block), head));
|
|
236
|
+
}
|
|
237
|
+
catch { /* Coordination/cache failure falls back to foreground generation. */ }
|
|
238
|
+
}
|
|
239
|
+
for (;;) {
|
|
240
|
+
combined.throwIfAborted();
|
|
241
|
+
if (this.scope() !== head.scope)
|
|
242
|
+
throw new AttoError('WORK_OBSOLETE', 'Wallet configuration changed during work preparation.');
|
|
243
|
+
const ready = this.read(block);
|
|
244
|
+
if (ready)
|
|
245
|
+
return ready;
|
|
246
|
+
try {
|
|
247
|
+
release = this.store.tryWorkLock(head.scope + ':' + this.key(block) + ':' + head.target);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (speculative)
|
|
251
|
+
throw error;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
if (release)
|
|
255
|
+
break;
|
|
256
|
+
await delay(25, undefined, { signal: combined });
|
|
257
|
+
}
|
|
258
|
+
if (speculative) {
|
|
259
|
+
while (!slot) {
|
|
260
|
+
for (let index = 0; index < CONCURRENCY && !slot; index++)
|
|
261
|
+
slot = this.store.tryWorkLock('speculative-slot-' + index);
|
|
262
|
+
if (!slot)
|
|
263
|
+
await delay(25, undefined, { signal: combined });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Waiting for speculation does not consume the normal generation budget.
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
timer = setTimeout(() => controller.abort(), WORKER_MS);
|
|
270
|
+
}
|
|
271
|
+
const ready = this.read(block);
|
|
272
|
+
if (ready)
|
|
273
|
+
return ready;
|
|
274
|
+
const work = await requestWork(block, settings.workerUrl, combined);
|
|
275
|
+
try {
|
|
276
|
+
this.save(block, head, work);
|
|
277
|
+
}
|
|
278
|
+
catch { /* The validated nonce remains usable. */ }
|
|
125
279
|
return work;
|
|
126
280
|
}
|
|
127
281
|
catch (error) {
|
|
@@ -138,17 +292,92 @@ export class WalletWork {
|
|
|
138
292
|
finally {
|
|
139
293
|
clearTimeout(timer);
|
|
140
294
|
this.requests.delete(controller);
|
|
295
|
+
slot?.();
|
|
296
|
+
release?.();
|
|
141
297
|
}
|
|
142
298
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
this.
|
|
150
|
-
|
|
151
|
-
|
|
299
|
+
/** Release the singleton inside the final queue transaction. An enqueue is
|
|
300
|
+
* either seen by this owner or its launch candidate can become the next owner. */
|
|
301
|
+
async runDetached(epoch) {
|
|
302
|
+
let release;
|
|
303
|
+
this.store.transaction(() => {
|
|
304
|
+
if (this.store.get(EPOCH_KEY) === epoch)
|
|
305
|
+
release = this.store.tryProcessLock('work-daemon');
|
|
306
|
+
});
|
|
307
|
+
if (!release)
|
|
308
|
+
return;
|
|
309
|
+
const unlock = () => { release?.(); release = undefined; };
|
|
310
|
+
try {
|
|
311
|
+
await this.drain(epoch, unlock);
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
await this.close();
|
|
315
|
+
unlock();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async drain(epoch = this.store.get(EPOCH_KEY), unlock) {
|
|
319
|
+
const attempted = new Set();
|
|
320
|
+
const pending = new Map();
|
|
321
|
+
const started = performance.now();
|
|
322
|
+
try {
|
|
323
|
+
for (;;) {
|
|
324
|
+
if (this.closed || this.store.get(EPOCH_KEY) !== epoch || (unlock && performance.now() - started >= WORKER_MS))
|
|
325
|
+
break;
|
|
326
|
+
const jobs = this.store.transaction(() => {
|
|
327
|
+
const queue = this.queue().filter(job => job.scope === this.scope());
|
|
328
|
+
this.store.set(QUEUE_KEY, queue);
|
|
329
|
+
const next = queue.filter(job => !attempted.has(job.id)).slice(0, CONCURRENCY - pending.size);
|
|
330
|
+
if (!next.length && !pending.size)
|
|
331
|
+
unlock?.();
|
|
332
|
+
return next;
|
|
333
|
+
});
|
|
334
|
+
if (!jobs.length && !pending.size)
|
|
335
|
+
return;
|
|
336
|
+
for (const job of jobs) {
|
|
337
|
+
attempted.add(job.id);
|
|
338
|
+
const task = (async () => {
|
|
339
|
+
const template = AttoBlock.fromJson(job.block);
|
|
340
|
+
if (!(template instanceof AttoChangeBlock))
|
|
341
|
+
return;
|
|
342
|
+
const block = new AttoChangeBlock(template.network, template.version, template.algorithm, template.publicKey, template.height, template.balance, AttoInstant.Companion.now(), template.previous, template.representativeAlgorithm, template.representativePublicKey);
|
|
343
|
+
const observed = this.store.get('head.' + job.key);
|
|
344
|
+
if (this.key(block) !== job.key || !this.same(this.head(block), job) || (observed && !this.same(observed, job)))
|
|
345
|
+
return;
|
|
346
|
+
const work = await this.obtain(block, true);
|
|
347
|
+
// Cache hits also remove jobs left by interrupted owners.
|
|
348
|
+
this.save(block, job, work);
|
|
349
|
+
})().catch(() => { }).finally(() => pending.delete(job.id));
|
|
350
|
+
pending.set(job.id, task);
|
|
351
|
+
}
|
|
352
|
+
await Promise.race([...pending.values(), delay(25)]);
|
|
353
|
+
}
|
|
152
354
|
}
|
|
355
|
+
finally {
|
|
356
|
+
if (unlock)
|
|
357
|
+
for (const controller of this.requests)
|
|
358
|
+
controller.abort();
|
|
359
|
+
await Promise.allSettled([...pending.values()]);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async cancel() {
|
|
363
|
+
this.store.transaction(() => { this.store.set(EPOCH_KEY, randomUUID()); this.store.set(QUEUE_KEY, []); });
|
|
364
|
+
await this.close();
|
|
365
|
+
const deadline = performance.now() + 5000;
|
|
366
|
+
for (;;) {
|
|
367
|
+
const release = this.store.tryProcessLock('work-daemon');
|
|
368
|
+
if (release) {
|
|
369
|
+
release();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (performance.now() >= deadline)
|
|
373
|
+
throw new AttoError('WALLET_BUSY', 'Public work preparation is still stopping. Retry wallet reset.');
|
|
374
|
+
await delay(25);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async close() {
|
|
378
|
+
this.closed = true;
|
|
379
|
+
for (const controller of this.requests)
|
|
380
|
+
controller.abort();
|
|
381
|
+
await Promise.allSettled([...this.active, ...(this.drainTask ? [this.drainTask] : [])]);
|
|
153
382
|
}
|
|
154
383
|
}
|
|
@@ -28,7 +28,7 @@ export declare class WatchManager {
|
|
|
28
28
|
} | undefined;
|
|
29
29
|
id: string;
|
|
30
30
|
filter: StreamFilter;
|
|
31
|
-
status: "
|
|
31
|
+
status: "reconnecting" | "running" | "stopped" | "completed";
|
|
32
32
|
createdAt: string;
|
|
33
33
|
reconnects: number;
|
|
34
34
|
replayable: boolean;
|
|
@@ -40,7 +40,7 @@ export declare class WatchManager {
|
|
|
40
40
|
} | undefined;
|
|
41
41
|
id: string;
|
|
42
42
|
filter: StreamFilter;
|
|
43
|
-
status: "
|
|
43
|
+
status: "reconnecting" | "running" | "stopped" | "completed";
|
|
44
44
|
createdAt: string;
|
|
45
45
|
reconnects: number;
|
|
46
46
|
replayable: boolean;
|
|
@@ -60,7 +60,7 @@ export declare class WatchManager {
|
|
|
60
60
|
} | undefined;
|
|
61
61
|
id: string;
|
|
62
62
|
filter: StreamFilter;
|
|
63
|
-
status: "
|
|
63
|
+
status: "reconnecting" | "running" | "stopped" | "completed";
|
|
64
64
|
createdAt: string;
|
|
65
65
|
reconnects: number;
|
|
66
66
|
replayable: boolean;
|