@cogcoin/client 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +11 -2
  2. package/dist/bitcoind/bootstrap/getblock-archive.d.ts +39 -0
  3. package/dist/bitcoind/bootstrap/getblock-archive.js +548 -0
  4. package/dist/bitcoind/bootstrap.d.ts +1 -0
  5. package/dist/bitcoind/bootstrap.js +1 -0
  6. package/dist/bitcoind/client/factory.js +84 -30
  7. package/dist/bitcoind/client/managed-client.js +2 -1
  8. package/dist/bitcoind/client/sync-engine.js +7 -0
  9. package/dist/bitcoind/errors.js +18 -0
  10. package/dist/bitcoind/indexer-daemon-main.js +78 -0
  11. package/dist/bitcoind/indexer-daemon.d.ts +3 -1
  12. package/dist/bitcoind/indexer-daemon.js +13 -6
  13. package/dist/bitcoind/node.js +2 -0
  14. package/dist/bitcoind/progress/constants.d.ts +1 -0
  15. package/dist/bitcoind/progress/constants.js +1 -0
  16. package/dist/bitcoind/progress/controller.d.ts +22 -0
  17. package/dist/bitcoind/progress/controller.js +48 -23
  18. package/dist/bitcoind/progress/formatting.js +25 -0
  19. package/dist/bitcoind/progress/render-policy.d.ts +35 -0
  20. package/dist/bitcoind/progress/render-policy.js +81 -0
  21. package/dist/bitcoind/service-paths.js +2 -6
  22. package/dist/bitcoind/service.d.ts +5 -1
  23. package/dist/bitcoind/service.js +93 -54
  24. package/dist/bitcoind/testing.d.ts +1 -1
  25. package/dist/bitcoind/testing.js +1 -1
  26. package/dist/bitcoind/types.d.ts +35 -1
  27. package/dist/cli/commands/follow.js +2 -0
  28. package/dist/cli/commands/getblock-archive-restart.d.ts +5 -0
  29. package/dist/cli/commands/getblock-archive-restart.js +15 -0
  30. package/dist/cli/commands/mining-admin.js +4 -0
  31. package/dist/cli/commands/mining-read.js +8 -5
  32. package/dist/cli/commands/mining-runtime.js +4 -0
  33. package/dist/cli/commands/status.js +2 -0
  34. package/dist/cli/commands/sync.js +2 -0
  35. package/dist/cli/commands/wallet-admin.js +29 -3
  36. package/dist/cli/commands/wallet-mutation.js +57 -4
  37. package/dist/cli/commands/wallet-read.js +2 -0
  38. package/dist/cli/context.js +5 -3
  39. package/dist/cli/mutation-command-groups.d.ts +2 -1
  40. package/dist/cli/mutation-command-groups.js +5 -0
  41. package/dist/cli/mutation-json.d.ts +18 -2
  42. package/dist/cli/mutation-json.js +47 -0
  43. package/dist/cli/mutation-success.d.ts +1 -0
  44. package/dist/cli/mutation-success.js +2 -2
  45. package/dist/cli/output.js +84 -1
  46. package/dist/cli/parse.d.ts +1 -1
  47. package/dist/cli/parse.js +127 -3
  48. package/dist/cli/preview-json.d.ts +10 -1
  49. package/dist/cli/preview-json.js +30 -0
  50. package/dist/cli/prompt.js +1 -1
  51. package/dist/cli/runner.js +3 -0
  52. package/dist/cli/types.d.ts +11 -4
  53. package/dist/cli/wallet-format.js +6 -0
  54. package/dist/wallet/lifecycle.d.ts +15 -1
  55. package/dist/wallet/lifecycle.js +147 -83
  56. package/dist/wallet/mining/visualizer.d.ts +11 -6
  57. package/dist/wallet/mining/visualizer.js +32 -15
  58. package/dist/wallet/reset.js +39 -27
  59. package/dist/wallet/runtime.d.ts +12 -1
  60. package/dist/wallet/runtime.js +53 -11
  61. package/dist/wallet/state/provider.d.ts +1 -0
  62. package/dist/wallet/state/provider.js +119 -3
  63. package/dist/wallet/state/seed-index.d.ts +43 -0
  64. package/dist/wallet/state/seed-index.js +151 -0
  65. package/dist/wallet/tx/anchor.d.ts +22 -0
  66. package/dist/wallet/tx/anchor.js +215 -8
  67. package/dist/wallet/tx/index.d.ts +1 -1
  68. package/dist/wallet/tx/index.js +1 -1
  69. package/package.json +1 -1
@@ -1,12 +1,12 @@
1
1
  import { loadBundledGenesisParameters } from "@cogcoin/indexer";
2
2
  import { resolveDefaultBitcoindDataDirForTesting } from "../../app-paths.js";
3
3
  import { openClient } from "../../client.js";
4
- import { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, resolveBootstrapPathsForTesting } from "../bootstrap.js";
4
+ import { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, prepareLatestGetblockArchiveForTesting, resolveBootstrapPathsForTesting, } from "../bootstrap.js";
5
5
  import { attachOrStartIndexerDaemon } from "../indexer-daemon.js";
6
6
  import { createRpcClient } from "../node.js";
7
7
  import { assertCogcoinProcessingStartHeight, resolveCogcoinProcessingStartHeight, } from "../processing-start-height.js";
8
8
  import { ManagedProgressController } from "../progress.js";
9
- import { attachOrStartManagedBitcoindService } from "../service.js";
9
+ import { attachOrStartManagedBitcoindService, probeManagedBitcoindService, stopManagedBitcoindService, } from "../service.js";
10
10
  import { DefaultManagedBitcoindClient } from "./managed-client.js";
11
11
  const DEFAULT_SYNC_DEBOUNCE_MS = 250;
12
12
  async function createManagedBitcoindClient(options) {
@@ -17,40 +17,94 @@ async function createManagedBitcoindClient(options) {
17
17
  genesisParameters,
18
18
  });
19
19
  const dataDir = options.dataDir ?? resolveDefaultBitcoindDataDirForTesting();
20
- const node = await attachOrStartManagedBitcoindService({
21
- ...options,
22
- dataDir,
23
- });
24
- const rpc = createRpcClient(node.rpc);
25
20
  const progress = new ManagedProgressController({
26
21
  onProgress: options.onProgress,
27
22
  progressOutput: options.progressOutput,
28
- quoteStatePath: resolveBootstrapPathsForTesting(node.dataDir, DEFAULT_SNAPSHOT_METADATA).quoteStatePath,
29
- snapshot: DEFAULT_SNAPSHOT_METADATA,
30
- });
31
- const bootstrap = new AssumeUtxoBootstrapController({
32
- rpc,
33
- dataDir: node.dataDir,
34
- progress,
23
+ quoteStatePath: resolveBootstrapPathsForTesting(dataDir, DEFAULT_SNAPSHOT_METADATA).quoteStatePath,
35
24
  snapshot: DEFAULT_SNAPSHOT_METADATA,
36
25
  });
37
- const client = await openClient({
38
- store: options.store,
39
- genesisParameters,
40
- snapshotInterval: options.snapshotInterval,
41
- });
42
- const indexerDaemon = options.databasePath
43
- ? await attachOrStartIndexerDaemon({
26
+ let progressStarted = false;
27
+ try {
28
+ await progress.start();
29
+ progressStarted = true;
30
+ let getblockArchive = options.chain === "main"
31
+ ? await prepareLatestGetblockArchiveForTesting({
32
+ dataDir,
33
+ progress,
34
+ fetchImpl: options.fetchImpl,
35
+ })
36
+ : null;
37
+ if (options.chain === "main" && getblockArchive !== null) {
38
+ const existingProbe = await probeManagedBitcoindService({
39
+ ...options,
40
+ dataDir,
41
+ });
42
+ if (existingProbe.compatibility === "compatible" && existingProbe.status !== null) {
43
+ const currentArchiveEndHeight = existingProbe.status.getblockArchiveEndHeight ?? null;
44
+ const currentArchiveSha256 = existingProbe.status.getblockArchiveSha256 ?? null;
45
+ const nextArchiveEndHeight = getblockArchive.manifest.endHeight;
46
+ const nextArchiveSha256 = getblockArchive.manifest.artifactSha256;
47
+ const needsRestart = currentArchiveEndHeight !== nextArchiveEndHeight
48
+ || currentArchiveSha256 !== nextArchiveSha256;
49
+ if (needsRestart) {
50
+ const restartApproved = options.confirmGetblockArchiveRestart === undefined
51
+ ? false
52
+ : await options.confirmGetblockArchiveRestart({
53
+ currentArchiveEndHeight,
54
+ nextArchiveEndHeight,
55
+ });
56
+ if (restartApproved) {
57
+ await stopManagedBitcoindService({
58
+ dataDir,
59
+ walletRootId: options.walletRootId,
60
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
61
+ });
62
+ }
63
+ else {
64
+ getblockArchive = null;
65
+ }
66
+ }
67
+ }
68
+ }
69
+ const node = await attachOrStartManagedBitcoindService({
70
+ ...options,
44
71
  dataDir,
45
- databasePath: options.databasePath,
46
- walletRootId: options.walletRootId,
47
- startupTimeoutMs: options.startupTimeoutMs,
48
- })
49
- : null;
50
- // The persistent service may already exist from a non-processing attach path
51
- // that used startHeight 0. Cogcoin replay still begins at the requested
52
- // processing boundary for this managed client.
53
- return new DefaultManagedBitcoindClient(client, options.store, node, rpc, progress, bootstrap, indexerDaemon, options.startHeight, options.syncDebounceMs ?? DEFAULT_SYNC_DEBOUNCE_MS);
72
+ getblockArchivePath: getblockArchive?.artifactPath ?? null,
73
+ getblockArchiveEndHeight: getblockArchive?.manifest.endHeight ?? null,
74
+ getblockArchiveSha256: getblockArchive?.manifest.artifactSha256 ?? null,
75
+ });
76
+ const rpc = createRpcClient(node.rpc);
77
+ const bootstrap = new AssumeUtxoBootstrapController({
78
+ rpc,
79
+ dataDir: node.dataDir,
80
+ progress,
81
+ snapshot: DEFAULT_SNAPSHOT_METADATA,
82
+ });
83
+ const client = await openClient({
84
+ store: options.store,
85
+ genesisParameters,
86
+ snapshotInterval: options.snapshotInterval,
87
+ });
88
+ const indexerDaemon = options.databasePath
89
+ ? await attachOrStartIndexerDaemon({
90
+ dataDir,
91
+ databasePath: options.databasePath,
92
+ walletRootId: options.walletRootId,
93
+ startupTimeoutMs: options.startupTimeoutMs,
94
+ })
95
+ : null;
96
+ await indexerDaemon?.pauseBackgroundFollow();
97
+ // The persistent service may already exist from a non-processing attach path
98
+ // that used startHeight 0. Cogcoin replay still begins at the requested
99
+ // processing boundary for this managed client.
100
+ return new DefaultManagedBitcoindClient(client, options.store, node, rpc, progress, bootstrap, indexerDaemon, options.startHeight, options.syncDebounceMs ?? DEFAULT_SYNC_DEBOUNCE_MS);
101
+ }
102
+ catch (error) {
103
+ if (progressStarted) {
104
+ await progress.close().catch(() => undefined);
105
+ }
106
+ throw error;
107
+ }
54
108
  }
55
109
  export async function openManagedBitcoindClient(options) {
56
110
  const genesisParameters = options.genesisParameters ?? await loadBundledGenesisParameters();
@@ -176,8 +176,9 @@ export class DefaultManagedBitcoindClient {
176
176
  await this.#syncPromise.catch(() => undefined);
177
177
  await this.#progress.close();
178
178
  await this.#node.stop();
179
- await this.#indexerDaemon?.close();
180
179
  await this.#client.close();
180
+ await this.#indexerDaemon?.resumeBackgroundFollow().catch(() => undefined);
181
+ await this.#indexerDaemon?.close();
181
182
  }
182
183
  async playSyncCompletionScene() {
183
184
  this.#assertOpen();
@@ -1,3 +1,4 @@
1
+ import { waitForGetblockArchiveImport } from "../bootstrap.js";
1
2
  import { formatManagedSyncErrorMessage } from "../errors.js";
2
3
  import { normalizeRpcBlock } from "../normalize.js";
3
4
  import { MANAGED_RPC_RETRY_MESSAGE, consumeManagedRpcRetryDelayMs, createManagedRpcRetryState, describeManagedRpcRetryError, isRetryableManagedRpcError, resetManagedRpcRetryState, } from "../retryable-rpc.js";
@@ -151,6 +152,12 @@ export async function syncToTip(dependencies) {
151
152
  signal: dependencies.abortSignal,
152
153
  retryState,
153
154
  }));
155
+ if (dependencies.node.expectedChain === "main"
156
+ && dependencies.node.getblockArchiveEndHeight !== null) {
157
+ await waitForGetblockArchiveImport({
158
+ getBlockchainInfo: () => runRpc(() => dependencies.rpc.getBlockchainInfo()),
159
+ }, dependencies.progress, dependencies.node.getblockArchiveEndHeight, dependencies.abortSignal);
160
+ }
154
161
  const startTip = await dependencies.client.getTip();
155
162
  const aggregate = {
156
163
  appliedBlocks: 0,
@@ -5,6 +5,24 @@ function appendNextStep(message, nextStep) {
5
5
  return `${message} Next: ${nextStep}`;
6
6
  }
7
7
  export function formatManagedSyncErrorMessage(message) {
8
+ if (message.startsWith("managed_getblock_archive_manifest_http_")) {
9
+ return appendNextStep(`Getblock archive manifest request failed (${message.replace("managed_getblock_archive_manifest_http_", "HTTP ")}).`, "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
10
+ }
11
+ if (message.startsWith("managed_getblock_archive_http_")) {
12
+ return appendNextStep(`Getblock archive request failed (${message.replace("managed_getblock_archive_http_", "HTTP ")}).`, "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
13
+ }
14
+ if (message === "managed_getblock_archive_response_body_missing") {
15
+ return appendNextStep("Getblock archive server returned an empty response body.", "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
16
+ }
17
+ if (message === "managed_getblock_archive_resume_requires_partial_content") {
18
+ return appendNextStep("Getblock archive server ignored the resume request for a partial download.", "Wait a moment and rerun sync. If this keeps happening, confirm the snapshot host supports HTTP range requests.");
19
+ }
20
+ if (message.startsWith("managed_getblock_archive_chunk_sha256_mismatch_")) {
21
+ return appendNextStep("A downloaded getblock archive chunk was corrupted and was rolled back to the last verified checkpoint.", "Wait a moment and rerun sync. If this keeps happening, check local disk health and the stability of the archive download.");
22
+ }
23
+ if (message === "managed_getblock_archive_sha256_mismatch" || message === "managed_getblock_archive_truncated") {
24
+ return appendNextStep("The downloaded getblock archive did not match the published manifest.", "Rerun sync so the archive can be downloaded again. If this keeps happening, check local disk health and the snapshot host.");
25
+ }
8
26
  if (message === "bitcoind_no_peers_for_header_sync_check_internet_or_firewall") {
9
27
  return appendNextStep("No Bitcoin peers were available for header sync.", "Check your internet access and firewall rules for outbound Bitcoin connections, then rerun sync.");
10
28
  }
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import net from "node:net";
3
3
  import { access, constants, mkdir, readFile, rm } from "node:fs/promises";
4
4
  import { serializeIndexerState } from "@cogcoin/indexer";
5
+ import { openManagedBitcoindClientInternal } from "./client.js";
5
6
  import { openClient } from "../client.js";
6
7
  import { openSqliteStore } from "../sqlite/index.js";
7
8
  import { writeRuntimeStatusFile } from "../wallet/fs/status-file.js";
@@ -29,6 +30,9 @@ async function readJsonFile(filePath) {
29
30
  throw error;
30
31
  }
31
32
  }
33
+ async function readManagedBitcoindStatus(paths) {
34
+ return readJsonFile(paths.bitcoindStatusPath);
35
+ }
32
36
  async function readPackageVersionFromDisk() {
33
37
  try {
34
38
  const raw = await readFile(new URL("../../package.json", import.meta.url), "utf8");
@@ -168,6 +172,9 @@ async function main() {
168
172
  let lastAppliedAtUnixMs = null;
169
173
  let lastError = null;
170
174
  let hasSuccessfulCoreTipRefresh = false;
175
+ let backgroundStore = null;
176
+ let backgroundClient = null;
177
+ let backgroundResumePromise = null;
171
178
  await mkdir(paths.indexerServiceRoot, { recursive: true });
172
179
  await rm(paths.indexerDaemonSocketPath, { force: true }).catch(() => undefined);
173
180
  const observeAppliedTip = (appliedTip, now) => {
@@ -264,6 +271,58 @@ async function main() {
264
271
  lastError = leaseState.lastError;
265
272
  return writeStatus();
266
273
  };
274
+ const pauseBackgroundFollow = async () => {
275
+ const pendingResume = backgroundResumePromise;
276
+ backgroundResumePromise = null;
277
+ await pendingResume?.catch(() => undefined);
278
+ const client = backgroundClient;
279
+ const store = backgroundStore;
280
+ backgroundClient = null;
281
+ backgroundStore = null;
282
+ await client?.close().catch(() => undefined);
283
+ await store?.close().catch(() => undefined);
284
+ };
285
+ const resumeBackgroundFollow = async () => {
286
+ if (backgroundClient !== null) {
287
+ return;
288
+ }
289
+ if (backgroundResumePromise !== null) {
290
+ return backgroundResumePromise;
291
+ }
292
+ backgroundResumePromise = (async () => {
293
+ const bitcoindStatus = await readManagedBitcoindStatus(paths);
294
+ const store = await openSqliteStore({ filename: databasePath });
295
+ try {
296
+ const client = await openManagedBitcoindClientInternal({
297
+ store,
298
+ dataDir,
299
+ chain: bitcoindStatus?.chain ?? "main",
300
+ startHeight: bitcoindStatus?.startHeight ?? 0,
301
+ walletRootId,
302
+ progressOutput: "none",
303
+ });
304
+ try {
305
+ await client.startFollowingTip();
306
+ backgroundStore = store;
307
+ backgroundClient = client;
308
+ }
309
+ catch (error) {
310
+ await client.close().catch(() => undefined);
311
+ throw error;
312
+ }
313
+ }
314
+ catch (error) {
315
+ await store.close().catch(() => undefined);
316
+ throw error;
317
+ }
318
+ })();
319
+ try {
320
+ await backgroundResumePromise;
321
+ }
322
+ finally {
323
+ backgroundResumePromise = null;
324
+ }
325
+ };
267
326
  const heartbeat = setInterval(() => {
268
327
  void refreshStatus().catch(() => undefined);
269
328
  const now = Date.now();
@@ -427,6 +486,24 @@ async function main() {
427
486
  });
428
487
  return;
429
488
  }
489
+ if (request.method === "PauseBackgroundFollow") {
490
+ await pauseBackgroundFollow();
491
+ writeResponse({
492
+ id: request.id,
493
+ ok: true,
494
+ result: null,
495
+ });
496
+ return;
497
+ }
498
+ if (request.method === "ResumeBackgroundFollow") {
499
+ await resumeBackgroundFollow();
500
+ writeResponse({
501
+ id: request.id,
502
+ ok: true,
503
+ result: null,
504
+ });
505
+ return;
506
+ }
430
507
  throw new Error(`indexer_daemon_unknown_method_${request.method}`);
431
508
  }
432
509
  catch (error) {
@@ -443,6 +520,7 @@ async function main() {
443
520
  });
444
521
  const shutdown = async () => {
445
522
  clearInterval(heartbeat);
523
+ await pauseBackgroundFollow().catch(() => undefined);
446
524
  state = "stopping";
447
525
  heartbeatAtUnixMs = Date.now();
448
526
  updatedAtUnixMs = heartbeatAtUnixMs;
@@ -2,7 +2,7 @@ import { type ManagedIndexerDaemonObservedStatus, type ManagedIndexerDaemonStatu
2
2
  import { resolveManagedServicePaths } from "./service-paths.js";
3
3
  interface DaemonRequest {
4
4
  id: string;
5
- method: "GetStatus" | "OpenSnapshot" | "ReadSnapshot" | "CloseSnapshot";
5
+ method: "GetStatus" | "OpenSnapshot" | "ReadSnapshot" | "CloseSnapshot" | "PauseBackgroundFollow" | "ResumeBackgroundFollow";
6
6
  token?: string;
7
7
  }
8
8
  interface DaemonResponse {
@@ -65,6 +65,8 @@ export interface IndexerDaemonClient {
65
65
  openSnapshot(): Promise<IndexerSnapshotHandle>;
66
66
  readSnapshot(token: string): Promise<IndexerSnapshotPayload>;
67
67
  closeSnapshot(token: string): Promise<void>;
68
+ pauseBackgroundFollow(): Promise<void>;
69
+ resumeBackgroundFollow(): Promise<void>;
68
70
  close(): Promise<void>;
69
71
  }
70
72
  export type IndexerDaemonCompatibility = "compatible" | "service-version-mismatch" | "wallet-root-mismatch" | "schema-mismatch" | "unreachable" | "protocol-error";
@@ -168,6 +168,18 @@ function createIndexerDaemonClient(socketPath) {
168
168
  token,
169
169
  });
170
170
  },
171
+ async pauseBackgroundFollow() {
172
+ await sendRequest({
173
+ id: randomUUID(),
174
+ method: "PauseBackgroundFollow",
175
+ });
176
+ },
177
+ async resumeBackgroundFollow() {
178
+ await sendRequest({
179
+ id: randomUUID(),
180
+ method: "ResumeBackgroundFollow",
181
+ });
182
+ },
171
183
  async close() {
172
184
  return;
173
185
  },
@@ -177,9 +189,6 @@ function validateIndexerRuntimeIdentity(identity, expectedWalletRootId) {
177
189
  if (identity.serviceApiVersion !== INDEXER_DAEMON_SERVICE_API_VERSION) {
178
190
  throw new Error("indexer_daemon_service_version_mismatch");
179
191
  }
180
- if (identity.walletRootId !== expectedWalletRootId) {
181
- throw new Error("indexer_daemon_wallet_root_mismatch");
182
- }
183
192
  if (identity.schemaVersion !== INDEXER_DAEMON_SCHEMA_VERSION || identity.state === "schema-mismatch") {
184
193
  throw new Error("indexer_daemon_schema_mismatch");
185
194
  }
@@ -271,9 +280,7 @@ async function probeIndexerDaemonAtSocket(socketPath, expectedWalletRootId) {
271
280
  compatibility: error instanceof Error
272
281
  ? error.message === "indexer_daemon_service_version_mismatch"
273
282
  ? "service-version-mismatch"
274
- : error.message === "indexer_daemon_wallet_root_mismatch"
275
- ? "wallet-root-mismatch"
276
- : "schema-mismatch"
283
+ : "schema-mismatch"
277
284
  : "protocol-error",
278
285
  status,
279
286
  client: null,
@@ -182,6 +182,8 @@ export async function launchManagedBitcoindNode(options) {
182
182
  expectedChain: resolvedOptions.chain,
183
183
  startHeight: resolvedOptions.startHeight,
184
184
  dataDir: resolvedOptions.dataDir,
185
+ getblockArchiveEndHeight: null,
186
+ getblockArchiveSha256: null,
185
187
  async validate() {
186
188
  await validateNodeConfigForTesting(rpcClient, resolvedOptions.chain, zmqEndpoint);
187
189
  },
@@ -1,4 +1,5 @@
1
1
  export declare const PROGRESS_TICK_MS = 250;
2
+ export declare const HEADLESS_PROGRESS_TICK_MS = 1000;
2
3
  export declare const PREPARING_SYNC_LINE = "Preparing managed Cogcoin sync...";
3
4
  export declare const ART_WIDTH = 80;
4
5
  export declare const ART_HEIGHT = 13;
@@ -1,4 +1,5 @@
1
1
  export const PROGRESS_TICK_MS = 250;
2
+ export const HEADLESS_PROGRESS_TICK_MS = 1_000;
2
3
  export const PREPARING_SYNC_LINE = "Preparing managed Cogcoin sync...";
3
4
  export const ART_WIDTH = 80;
4
5
  export const ART_HEIGHT = 13;
@@ -1,9 +1,31 @@
1
+ import type { QuoteDisplayPhase } from "../quotes.js";
1
2
  import type { BootstrapPhase, BootstrapProgress, ManagedBitcoindProgressEvent, ProgressOutputMode, SnapshotMetadata, WritingQuote } from "../types.js";
3
+ import { type FollowSceneStateForTesting } from "./follow-scene.js";
4
+ import { type RenderClock, type TtyRenderStream } from "./render-policy.js";
5
+ interface QuoteRotatorLike {
6
+ current(now?: number): Promise<{
7
+ displayPhase: QuoteDisplayPhase;
8
+ currentQuote: WritingQuote | null;
9
+ displayStartedAt: number;
10
+ }>;
11
+ }
12
+ interface ProgressRendererLike {
13
+ render(displayPhase: QuoteDisplayPhase, quote: WritingQuote | null, progress: BootstrapProgress, cogcoinSyncHeight: number | null, cogcoinSyncTargetHeight: number | null, introElapsedMs?: number, statusFieldText?: string): void;
14
+ renderTrainScene(kind: "intro" | "completion", progress: BootstrapProgress, cogcoinSyncHeight: number | null, cogcoinSyncTargetHeight: number | null, elapsedMs: number, statusFieldText?: string): void;
15
+ renderFollowScene(progress: BootstrapProgress, cogcoinSyncHeight: number | null, cogcoinSyncTargetHeight: number | null, followScene: FollowSceneStateForTesting, statusFieldText?: string): void;
16
+ close(): void;
17
+ }
2
18
  interface ProgressControllerOptions {
3
19
  onProgress?: (event: ManagedBitcoindProgressEvent) => void;
4
20
  progressOutput?: ProgressOutputMode;
5
21
  quoteStatePath: string;
6
22
  snapshot: SnapshotMetadata;
23
+ quoteRotator?: QuoteRotatorLike;
24
+ rendererFactory?: (stream: TtyRenderStream) => ProgressRendererLike;
25
+ stream?: TtyRenderStream;
26
+ platform?: NodeJS.Platform;
27
+ env?: NodeJS.ProcessEnv;
28
+ clock?: RenderClock;
7
29
  }
8
30
  export declare class ManagedProgressController {
9
31
  #private;
@@ -1,12 +1,17 @@
1
1
  import { WritingQuoteRotator as QuoteRotator } from "../quotes.js";
2
- import { INTRO_TOTAL_MS, PROGRESS_TICK_MS } from "./constants.js";
2
+ import { INTRO_TOTAL_MS } from "./constants.js";
3
3
  import { advanceFollowSceneState, createFollowSceneState, replaceFollowBlockTimes, setFollowBlockTime, syncFollowSceneState, } from "./follow-scene.js";
4
4
  import { createBootstrapProgress, createDefaultMessage, resolveStatusFieldText, } from "./formatting.js";
5
+ import { DEFAULT_RENDER_CLOCK, resolveTtyRenderPolicy, TtyRenderThrottle, } from "./render-policy.js";
5
6
  import { TtyProgressRenderer } from "./tty-renderer.js";
6
7
  export class ManagedProgressController {
7
8
  #options;
8
9
  #snapshot;
9
10
  #outputMode;
11
+ #clock;
12
+ #renderStream;
13
+ #renderThrottle;
14
+ #renderIntervalMs;
10
15
  #quoteRotator = null;
11
16
  #renderer = null;
12
17
  #ticker = null;
@@ -24,26 +29,47 @@ export class ManagedProgressController {
24
29
  this.#snapshot = options.snapshot;
25
30
  this.#outputMode = options.progressOutput ?? "auto";
26
31
  this.#progress = createBootstrapProgress("paused", options.snapshot);
32
+ this.#clock = options.clock ?? DEFAULT_RENDER_CLOCK;
33
+ this.#renderStream = options.stream ?? process.stderr;
34
+ const renderPolicy = resolveTtyRenderPolicy(this.#outputMode, this.#renderStream, {
35
+ platform: options.platform,
36
+ env: options.env,
37
+ });
38
+ this.#renderIntervalMs = renderPolicy.repaintIntervalMs;
39
+ this.#renderThrottle = new TtyRenderThrottle({
40
+ clock: this.#clock,
41
+ intervalMs: this.#renderIntervalMs,
42
+ onRender: () => {
43
+ this.#renderToTty();
44
+ },
45
+ throttled: renderPolicy.linuxHeadlessThrottle,
46
+ });
27
47
  }
28
48
  async start() {
29
49
  if (this.#started) {
30
50
  return;
31
51
  }
32
52
  this.#started = true;
33
- this.#quoteRotator = await QuoteRotator.create(this.#options.quoteStatePath);
34
- if (this.#shouldRenderToTty()) {
35
- this.#renderer = new TtyProgressRenderer();
53
+ this.#quoteRotator = this.#options.quoteRotator
54
+ ?? await QuoteRotator.create(this.#options.quoteStatePath);
55
+ if (resolveTtyRenderPolicy(this.#outputMode, this.#renderStream, {
56
+ platform: this.#options.platform,
57
+ env: this.#options.env,
58
+ }).enabled) {
59
+ this.#renderer = this.#options.rendererFactory?.(this.#renderStream)
60
+ ?? new TtyProgressRenderer(this.#renderStream);
36
61
  }
37
62
  await this.#refresh();
38
- this.#ticker = setInterval(() => {
63
+ this.#ticker = this.#clock.setInterval(() => {
39
64
  void this.#refresh();
40
- }, PROGRESS_TICK_MS);
65
+ }, this.#renderIntervalMs);
41
66
  }
42
67
  async close() {
43
68
  if (this.#ticker !== null) {
44
- clearInterval(this.#ticker);
69
+ this.#clock.clearInterval(this.#ticker);
45
70
  this.#ticker = null;
46
71
  }
72
+ this.#renderThrottle.flush();
47
73
  this.#renderer?.close();
48
74
  this.#renderer = null;
49
75
  this.#started = false;
@@ -72,19 +98,20 @@ export class ManagedProgressController {
72
98
  if (!this.#started || this.#renderer === null) {
73
99
  return;
74
100
  }
101
+ this.#renderThrottle.flush();
75
102
  if (this.#ticker !== null) {
76
- clearInterval(this.#ticker);
103
+ this.#clock.clearInterval(this.#ticker);
77
104
  this.#ticker = null;
78
105
  }
79
- const startedAt = Date.now();
106
+ const startedAt = this.#clock.now();
80
107
  while (true) {
81
- const elapsedMs = Math.min(INTRO_TOTAL_MS, Date.now() - startedAt);
108
+ const elapsedMs = Math.min(INTRO_TOTAL_MS, this.#clock.now() - startedAt);
82
109
  this.#renderer.renderTrainScene("completion", this.#progress, this.#cogcoinSyncHeight, this.#cogcoinSyncTargetHeight, elapsedMs);
83
110
  if (elapsedMs >= INTRO_TOTAL_MS) {
84
111
  break;
85
112
  }
86
113
  await new Promise((resolve) => {
87
- setTimeout(resolve, PROGRESS_TICK_MS);
114
+ this.#clock.setTimeout(resolve, this.#renderIntervalMs);
88
115
  });
89
116
  }
90
117
  }
@@ -143,7 +170,7 @@ export class ManagedProgressController {
143
170
  if (!this.#started || this.#quoteRotator === null) {
144
171
  return;
145
172
  }
146
- const now = Date.now();
173
+ const now = this.#clock.now();
147
174
  if (this.#followVisualMode) {
148
175
  advanceFollowSceneState(this.#followScene, now);
149
176
  this.#currentQuote = null;
@@ -170,20 +197,18 @@ export class ManagedProgressController {
170
197
  catch {
171
198
  // User progress callbacks should never break managed sync.
172
199
  }
200
+ this.#renderThrottle.request();
201
+ }
202
+ #renderToTty() {
203
+ if (!this.#started || this.#renderer === null) {
204
+ return;
205
+ }
206
+ const now = this.#clock.now();
173
207
  const statusFieldText = resolveStatusFieldText(this.#progress, this.#snapshot.height, now);
174
208
  if (this.#followVisualMode) {
175
- this.#renderer?.renderFollowScene(this.#progress, this.#cogcoinSyncHeight, this.#cogcoinSyncTargetHeight, this.#followScene, statusFieldText);
209
+ this.#renderer.renderFollowScene(this.#progress, this.#cogcoinSyncHeight, this.#cogcoinSyncTargetHeight, this.#followScene, statusFieldText);
176
210
  return;
177
211
  }
178
- this.#renderer?.render(this.#currentDisplayPhase, this.#currentQuote, this.#progress, this.#cogcoinSyncHeight, this.#cogcoinSyncTargetHeight, Math.max(0, now - this.#currentDisplayStartedAt), statusFieldText);
179
- }
180
- #shouldRenderToTty() {
181
- if (this.#outputMode === "none") {
182
- return false;
183
- }
184
- if (this.#outputMode === "tty") {
185
- return true;
186
- }
187
- return process.stderr.isTTY === true;
212
+ this.#renderer.render(this.#currentDisplayPhase, this.#currentQuote, this.#progress, this.#cogcoinSyncHeight, this.#cogcoinSyncTargetHeight, Math.max(0, now - this.#currentDisplayStartedAt), statusFieldText);
188
213
  }
189
214
  }
@@ -1,6 +1,10 @@
1
1
  import { FIELD_LEFT, FIELD_WIDTH, PREPARING_SYNC_LINE, PROGRESS_TICK_MS, SCROLL_WINDOW_LEFT, SCROLL_WINDOW_WIDTH, STATUS_ELLIPSIS_TICK_MS, STATUS_ELLIPSIS_WIDTH, } from "./constants.js";
2
2
  export function createDefaultMessage(phase) {
3
3
  switch (phase) {
4
+ case "getblock_archive_download":
5
+ return "Downloading getblock archive.";
6
+ case "getblock_archive_import":
7
+ return "Bitcoin Core is importing getblock archive blocks.";
4
8
  case "snapshot_download":
5
9
  return "Downloading UTXO snapshot.";
6
10
  case "wait_headers_for_snapshot":
@@ -121,6 +125,10 @@ function animateStatusEllipsis(now) {
121
125
  }
122
126
  export function resolveStatusFieldText(progress, snapshotHeight, now = 0) {
123
127
  switch (progress.phase) {
128
+ case "getblock_archive_download":
129
+ return `Downloading getblock archive${animateStatusEllipsis(now)}`;
130
+ case "getblock_archive_import":
131
+ return `Importing getblock archive${animateStatusEllipsis(now)}`;
124
132
  case "paused":
125
133
  case "snapshot_download":
126
134
  return `Downloading snapshot to ${snapshotHeight}${animateStatusEllipsis(now)}`;
@@ -166,6 +174,16 @@ export const formatQuoteLineForTesting = formatQuoteLine;
166
174
  export function formatProgressLine(progress, cogcoinSyncHeight, cogcoinSyncTargetHeight, width = 120, now = Date.now()) {
167
175
  let line;
168
176
  switch (progress.phase) {
177
+ case "getblock_archive_download": {
178
+ const current = progress.downloadedBytes ?? 0;
179
+ const total = progress.totalBytes ?? 0;
180
+ const bar = renderBar(current, total, 20);
181
+ const percent = progress.percent ?? (total > 0 ? (current / total) * 100 : 0);
182
+ const speed = progress.bytesPerSecond === null ? "--" : `${formatBytes(progress.bytesPerSecond)}/s`;
183
+ const resumed = progress.resumed ? " resumed" : "";
184
+ line = `${bar} ${percent.toFixed(2)}% ${formatBytes(current)} / ${formatBytes(total)} ${speed} ETA ${formatDuration(progress.etaSeconds)}${resumed}`;
185
+ break;
186
+ }
169
187
  case "snapshot_download": {
170
188
  const current = progress.downloadedBytes ?? 0;
171
189
  const total = progress.totalBytes ?? 0;
@@ -190,6 +208,13 @@ export function formatProgressLine(progress, cogcoinSyncHeight, cogcoinSyncTarge
190
208
  line = `${bar} Bitcoin ${blocks.toLocaleString()} / ${target.toLocaleString()} ETA ${formatDuration(progress.etaSeconds)} ${progress.message}`;
191
209
  break;
192
210
  }
211
+ case "getblock_archive_import": {
212
+ const blocks = progress.blocks ?? 0;
213
+ const target = progress.targetHeight ?? blocks;
214
+ const bar = renderBar(blocks, target, 20);
215
+ line = `${bar} Bitcoin ${blocks.toLocaleString()} / ${target.toLocaleString()} ${progress.message}`;
216
+ break;
217
+ }
193
218
  case "cogcoin_sync": {
194
219
  const current = cogcoinSyncHeight ?? 0;
195
220
  const target = cogcoinSyncTargetHeight ?? current;
@@ -0,0 +1,35 @@
1
+ import type { ProgressOutputMode } from "../types.js";
2
+ export interface TtyRenderStream {
3
+ isTTY?: boolean;
4
+ columns?: number;
5
+ write(chunk: string): boolean | void;
6
+ }
7
+ export interface RenderClock {
8
+ now(): number;
9
+ setTimeout: typeof setTimeout;
10
+ clearTimeout: typeof clearTimeout;
11
+ setInterval: typeof setInterval;
12
+ clearInterval: typeof clearInterval;
13
+ }
14
+ export interface TtyRenderPolicy {
15
+ enabled: boolean;
16
+ linuxHeadlessThrottle: boolean;
17
+ repaintIntervalMs: number;
18
+ }
19
+ export declare const DEFAULT_RENDER_CLOCK: RenderClock;
20
+ export declare function resolveTtyRenderPolicy(progressOutput: ProgressOutputMode, stream: Pick<TtyRenderStream, "isTTY">, options?: {
21
+ platform?: NodeJS.Platform;
22
+ env?: NodeJS.ProcessEnv;
23
+ }): TtyRenderPolicy;
24
+ export declare class TtyRenderThrottle {
25
+ #private;
26
+ constructor(options: {
27
+ clock?: RenderClock;
28
+ intervalMs: number;
29
+ onRender: () => void;
30
+ throttled: boolean;
31
+ });
32
+ request(): void;
33
+ flush(): void;
34
+ cancel(): void;
35
+ }