@cogcoin/client 1.0.1 → 1.1.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.
Files changed (79) hide show
  1. package/README.md +4 -2
  2. package/dist/bitcoind/client/factory.d.ts +0 -8
  3. package/dist/bitcoind/client/factory.js +1 -59
  4. package/dist/bitcoind/client/managed-client.d.ts +1 -3
  5. package/dist/bitcoind/client/managed-client.js +3 -47
  6. package/dist/bitcoind/indexer-daemon-main.js +173 -28
  7. package/dist/bitcoind/indexer-daemon.d.ts +14 -3
  8. package/dist/bitcoind/indexer-daemon.js +145 -29
  9. package/dist/bitcoind/indexer-monitor.d.ts +12 -0
  10. package/dist/bitcoind/indexer-monitor.js +89 -0
  11. package/dist/bitcoind/progress/follow-scene.d.ts +7 -1
  12. package/dist/bitcoind/progress/follow-scene.js +87 -4
  13. package/dist/bitcoind/progress/tty-renderer.d.ts +2 -0
  14. package/dist/bitcoind/progress/tty-renderer.js +2 -0
  15. package/dist/bitcoind/retryable-rpc.js +3 -0
  16. package/dist/bitcoind/service.d.ts +1 -0
  17. package/dist/bitcoind/service.js +31 -9
  18. package/dist/bitcoind/testing.d.ts +0 -1
  19. package/dist/bitcoind/testing.js +0 -1
  20. package/dist/bitcoind/types.d.ts +5 -2
  21. package/dist/cli/commands/follow.js +44 -49
  22. package/dist/cli/commands/mining-admin.js +65 -2
  23. package/dist/cli/commands/mining-read.js +43 -3
  24. package/dist/cli/commands/mining-runtime.js +91 -73
  25. package/dist/cli/commands/service-runtime.js +42 -2
  26. package/dist/cli/commands/status.js +3 -1
  27. package/dist/cli/commands/sync.js +50 -90
  28. package/dist/cli/commands/update.d.ts +2 -0
  29. package/dist/cli/commands/update.js +101 -0
  30. package/dist/cli/commands/wallet-admin.js +21 -3
  31. package/dist/cli/commands/wallet-read.js +2 -0
  32. package/dist/cli/context.js +36 -1
  33. package/dist/cli/managed-indexer-observer.d.ts +33 -0
  34. package/dist/cli/managed-indexer-observer.js +163 -0
  35. package/dist/cli/mining-format.d.ts +3 -1
  36. package/dist/cli/mining-format.js +63 -0
  37. package/dist/cli/mining-json.d.ts +11 -1
  38. package/dist/cli/mining-json.js +15 -0
  39. package/dist/cli/output.js +74 -2
  40. package/dist/cli/parse.d.ts +1 -1
  41. package/dist/cli/parse.js +28 -0
  42. package/dist/cli/prompt.js +109 -0
  43. package/dist/cli/read-json.d.ts +26 -1
  44. package/dist/cli/read-json.js +48 -0
  45. package/dist/cli/runner.js +8 -2
  46. package/dist/cli/signals.d.ts +12 -0
  47. package/dist/cli/signals.js +31 -13
  48. package/dist/cli/types.d.ts +13 -4
  49. package/dist/cli/update-notifier.js +7 -222
  50. package/dist/cli/update-service.d.ts +34 -0
  51. package/dist/cli/update-service.js +152 -0
  52. package/dist/client/initialization.js +5 -0
  53. package/dist/semver.d.ts +12 -0
  54. package/dist/semver.js +68 -0
  55. package/dist/wallet/lifecycle.d.ts +10 -0
  56. package/dist/wallet/mining/config.js +64 -3
  57. package/dist/wallet/mining/control.d.ts +5 -1
  58. package/dist/wallet/mining/control.js +269 -26
  59. package/dist/wallet/mining/domain-prompts.d.ts +17 -0
  60. package/dist/wallet/mining/domain-prompts.js +130 -0
  61. package/dist/wallet/mining/index.d.ts +2 -1
  62. package/dist/wallet/mining/index.js +1 -0
  63. package/dist/wallet/mining/provider-model.d.ts +30 -0
  64. package/dist/wallet/mining/provider-model.js +134 -0
  65. package/dist/wallet/mining/runner.d.ts +156 -5
  66. package/dist/wallet/mining/runner.js +1019 -399
  67. package/dist/wallet/mining/runtime-artifacts.js +1 -0
  68. package/dist/wallet/mining/sentence-protocol.d.ts +1 -0
  69. package/dist/wallet/mining/sentences.d.ts +2 -2
  70. package/dist/wallet/mining/sentences.js +32 -6
  71. package/dist/wallet/mining/types.d.ts +35 -1
  72. package/dist/wallet/mining/visualizer.d.ts +3 -0
  73. package/dist/wallet/mining/visualizer.js +132 -15
  74. package/dist/wallet/read/context.d.ts +1 -0
  75. package/dist/wallet/read/context.js +15 -7
  76. package/dist/wallet/state/client-password-agent.js +4 -1
  77. package/dist/wallet/state/client-password.js +15 -8
  78. package/dist/wallet/tx/common.js +1 -1
  79. package/package.json +3 -2
@@ -3,11 +3,16 @@ import { spawn } from "node:child_process";
3
3
  import { mkdir, readFile, rm } from "node:fs/promises";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import net from "node:net";
6
+ import { compareSemver } from "../semver.js";
6
7
  import { acquireFileLock, FileLockBusyError } from "../wallet/fs/lock.js";
7
8
  import { writeRuntimeStatusFile } from "../wallet/fs/status-file.js";
8
9
  import { INDEXER_DAEMON_SCHEMA_VERSION, INDEXER_DAEMON_SERVICE_API_VERSION, } from "./types.js";
9
10
  import { resolveManagedServicePaths, UNINITIALIZED_WALLET_ROOT_ID } from "./service-paths.js";
10
11
  const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
12
+ const INDEXER_DAEMON_REQUEST_TIMEOUT_MS = 15_000;
13
+ const INDEXER_DAEMON_RESUME_BACKGROUND_FOLLOW_REQUEST_TIMEOUT_MS = 35_000;
14
+ const INDEXER_DAEMON_BACKGROUND_FOLLOW_NOT_ACTIVE = "indexer_daemon_background_follow_not_active";
15
+ export const INDEXER_DAEMON_BACKGROUND_FOLLOW_RECOVERY_FAILED = "indexer_daemon_background_follow_recovery_failed";
11
16
  async function readJsonFile(filePath) {
12
17
  try {
13
18
  return JSON.parse(await readFile(filePath, "utf8"));
@@ -57,7 +62,7 @@ export async function stopIndexerDaemonServiceWithLockHeld(options) {
57
62
  const walletRootId = options.walletRootId ?? UNINITIALIZED_WALLET_ROOT_ID;
58
63
  const paths = options.paths ?? resolveManagedServicePaths(options.dataDir, walletRootId);
59
64
  const status = await readJsonFile(paths.indexerDaemonStatusPath);
60
- const processId = status?.processId ?? null;
65
+ const processId = options.processId ?? status?.processId ?? null;
61
66
  if (status === null || processId === null || !await isProcessAlive(processId)) {
62
67
  await clearIndexerDaemonRuntimeArtifacts(paths);
63
68
  return {
@@ -80,7 +85,8 @@ export async function stopIndexerDaemonServiceWithLockHeld(options) {
80
85
  walletRootId,
81
86
  };
82
87
  }
83
- function createIndexerDaemonClient(socketPath) {
88
+ function createIndexerDaemonClient(socketPath, closeOptions = null) {
89
+ let closed = false;
84
90
  async function sendRequest(request) {
85
91
  return new Promise((resolve, reject) => {
86
92
  const socket = net.createConnection(socketPath);
@@ -94,7 +100,9 @@ function createIndexerDaemonClient(socketPath) {
94
100
  socket.destroy();
95
101
  handler();
96
102
  };
97
- socket.setTimeout(15_000);
103
+ socket.setTimeout(request.method === "ResumeBackgroundFollow"
104
+ ? INDEXER_DAEMON_RESUME_BACKGROUND_FOLLOW_REQUEST_TIMEOUT_MS
105
+ : INDEXER_DAEMON_REQUEST_TIMEOUT_MS);
98
106
  socket.on("connect", () => {
99
107
  socket.write(`${JSON.stringify(request)}\n`);
100
108
  });
@@ -168,12 +176,6 @@ function createIndexerDaemonClient(socketPath) {
168
176
  token,
169
177
  });
170
178
  },
171
- async pauseBackgroundFollow() {
172
- await sendRequest({
173
- id: randomUUID(),
174
- method: "PauseBackgroundFollow",
175
- });
176
- },
177
179
  async resumeBackgroundFollow() {
178
180
  await sendRequest({
179
181
  id: randomUUID(),
@@ -181,7 +183,18 @@ function createIndexerDaemonClient(socketPath) {
181
183
  });
182
184
  },
183
185
  async close() {
184
- return;
186
+ if (closed) {
187
+ return;
188
+ }
189
+ closed = true;
190
+ if (closeOptions === null || closeOptions.serviceLifetime !== "ephemeral" || closeOptions.ownership === "attached") {
191
+ return;
192
+ }
193
+ await stopIndexerDaemonService({
194
+ dataDir: closeOptions.dataDir,
195
+ walletRootId: closeOptions.walletRootId,
196
+ shutdownTimeoutMs: closeOptions.shutdownTimeoutMs,
197
+ });
185
198
  },
186
199
  };
187
200
  }
@@ -259,8 +272,20 @@ function buildStatusFromSnapshotHandle(handle) {
259
272
  lastAppliedAtUnixMs: handle.lastAppliedAtUnixMs,
260
273
  activeSnapshotCount: handle.activeSnapshotCount,
261
274
  lastError: handle.lastError,
275
+ backgroundFollowActive: handle.backgroundFollowActive,
276
+ bootstrapPhase: handle.bootstrapPhase,
277
+ bootstrapProgress: handle.bootstrapProgress,
278
+ cogcoinSyncHeight: handle.cogcoinSyncHeight,
279
+ cogcoinSyncTargetHeight: handle.cogcoinSyncTargetHeight,
262
280
  };
263
281
  }
282
+ function isStaleIndexerDaemonVersion(status, expectedBinaryVersion) {
283
+ if (status === null || expectedBinaryVersion === null || expectedBinaryVersion === undefined) {
284
+ return false;
285
+ }
286
+ const comparison = compareSemver(status.binaryVersion, expectedBinaryVersion);
287
+ return comparison !== null && comparison < 0;
288
+ }
264
289
  async function probeIndexerDaemonAtSocket(socketPath, expectedWalletRootId) {
265
290
  const client = createIndexerDaemonClient(socketPath);
266
291
  try {
@@ -308,7 +333,8 @@ async function waitForIndexerDaemon(dataDir, walletRootId, timeoutMs) {
308
333
  while (Date.now() < deadline) {
309
334
  const probe = await probeIndexerDaemonAtSocket(paths.indexerDaemonSocketPath, walletRootId);
310
335
  if (probe.compatibility === "compatible" && probe.client !== null) {
311
- return probe.client;
336
+ await probe.client.close().catch(() => undefined);
337
+ return;
312
338
  }
313
339
  if (probe.compatibility !== "unreachable") {
314
340
  throw new Error(probe.error ?? "indexer_daemon_protocol_error");
@@ -355,14 +381,84 @@ export async function readObservedIndexerDaemonStatus(options) {
355
381
  return readJsonFile(paths.indexerDaemonStatusPath);
356
382
  }
357
383
  export async function attachOrStartIndexerDaemon(options) {
384
+ const requestBackgroundFollow = async (client, observedStatus = null) => {
385
+ if (options.ensureBackgroundFollow !== true) {
386
+ return client;
387
+ }
388
+ if (observedStatus?.backgroundFollowActive === true) {
389
+ return client;
390
+ }
391
+ await client.resumeBackgroundFollow();
392
+ const status = await client.getStatus();
393
+ if (status.backgroundFollowActive !== true) {
394
+ throw new Error(INDEXER_DAEMON_BACKGROUND_FOLLOW_NOT_ACTIVE);
395
+ }
396
+ return client;
397
+ };
358
398
  const walletRootId = options.walletRootId ?? UNINITIALIZED_WALLET_ROOT_ID;
359
399
  const paths = resolveManagedServicePaths(options.dataDir, walletRootId);
360
400
  const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
401
+ const serviceLifetime = options.serviceLifetime ?? "persistent";
402
+ const expectedBinaryVersion = options.expectedBinaryVersion ?? null;
403
+ const startDaemon = async () => {
404
+ await mkdir(paths.indexerServiceRoot, { recursive: true });
405
+ const daemonEntryPath = fileURLToPath(new URL("./indexer-daemon-main.js", import.meta.url));
406
+ const spawnOptions = serviceLifetime === "ephemeral"
407
+ ? {
408
+ stdio: "ignore",
409
+ }
410
+ : {
411
+ detached: true,
412
+ stdio: "ignore",
413
+ };
414
+ const child = spawn(process.execPath, [
415
+ daemonEntryPath,
416
+ `--data-dir=${options.dataDir}`,
417
+ `--database-path=${options.databasePath}`,
418
+ `--wallet-root-id=${walletRootId}`,
419
+ ], {
420
+ ...spawnOptions,
421
+ });
422
+ if (serviceLifetime !== "ephemeral") {
423
+ child.unref();
424
+ }
425
+ try {
426
+ await waitForIndexerDaemon(options.dataDir, walletRootId, startupTimeoutMs);
427
+ }
428
+ catch (error) {
429
+ if (child.pid !== undefined) {
430
+ try {
431
+ process.kill(child.pid, "SIGTERM");
432
+ }
433
+ catch {
434
+ // ignore shutdown failures while unwinding startup errors
435
+ }
436
+ }
437
+ throw error;
438
+ }
439
+ return createIndexerDaemonClient(paths.indexerDaemonSocketPath, {
440
+ dataDir: options.dataDir,
441
+ walletRootId,
442
+ serviceLifetime,
443
+ ownership: "started",
444
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
445
+ });
446
+ };
361
447
  const existingProbe = await probeIndexerDaemonAtSocket(paths.indexerDaemonSocketPath, walletRootId);
362
448
  if (existingProbe.compatibility === "compatible" && existingProbe.client !== null) {
363
- return existingProbe.client;
449
+ if (!isStaleIndexerDaemonVersion(existingProbe.status, expectedBinaryVersion)) {
450
+ try {
451
+ return await requestBackgroundFollow(existingProbe.client, existingProbe.status);
452
+ }
453
+ catch {
454
+ await existingProbe.client.close().catch(() => undefined);
455
+ }
456
+ }
457
+ else {
458
+ await existingProbe.client.close().catch(() => undefined);
459
+ }
364
460
  }
365
- if (existingProbe.compatibility !== "unreachable") {
461
+ if (existingProbe.compatibility !== "unreachable" && existingProbe.compatibility !== "compatible") {
366
462
  throw new Error(existingProbe.error ?? "indexer_daemon_protocol_error");
367
463
  }
368
464
  try {
@@ -375,24 +471,43 @@ export async function attachOrStartIndexerDaemon(options) {
375
471
  try {
376
472
  const liveProbe = await probeIndexerDaemonAtSocket(paths.indexerDaemonSocketPath, walletRootId);
377
473
  if (liveProbe.compatibility === "compatible" && liveProbe.client !== null) {
378
- return liveProbe.client;
474
+ if (!isStaleIndexerDaemonVersion(liveProbe.status, expectedBinaryVersion)) {
475
+ try {
476
+ return await requestBackgroundFollow(liveProbe.client, liveProbe.status);
477
+ }
478
+ catch {
479
+ await liveProbe.client.close().catch(() => undefined);
480
+ await stopIndexerDaemonServiceWithLockHeld({
481
+ dataDir: options.dataDir,
482
+ walletRootId,
483
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
484
+ paths,
485
+ processId: liveProbe.status?.processId ?? null,
486
+ });
487
+ }
488
+ }
489
+ else {
490
+ await liveProbe.client.close().catch(() => undefined);
491
+ await stopIndexerDaemonServiceWithLockHeld({
492
+ dataDir: options.dataDir,
493
+ walletRootId,
494
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
495
+ paths,
496
+ processId: liveProbe.status?.processId ?? null,
497
+ });
498
+ }
379
499
  }
380
- if (liveProbe.compatibility !== "unreachable") {
500
+ else if (liveProbe.compatibility !== "unreachable") {
381
501
  throw new Error(liveProbe.error ?? "indexer_daemon_protocol_error");
382
502
  }
383
- await mkdir(paths.indexerServiceRoot, { recursive: true });
384
- const daemonEntryPath = fileURLToPath(new URL("./indexer-daemon-main.js", import.meta.url));
385
- const child = spawn(process.execPath, [
386
- daemonEntryPath,
387
- `--data-dir=${options.dataDir}`,
388
- `--database-path=${options.databasePath}`,
389
- `--wallet-root-id=${walletRootId}`,
390
- ], {
391
- detached: true,
392
- stdio: "ignore",
393
- });
394
- child.unref();
395
- return await waitForIndexerDaemon(options.dataDir, walletRootId, startupTimeoutMs);
503
+ const daemon = await startDaemon();
504
+ try {
505
+ return await requestBackgroundFollow(daemon);
506
+ }
507
+ catch (error) {
508
+ await daemon.close().catch(() => undefined);
509
+ throw new Error(INDEXER_DAEMON_BACKGROUND_FOLLOW_RECOVERY_FAILED, { cause: error });
510
+ }
396
511
  }
397
512
  finally {
398
513
  await lock.release();
@@ -400,7 +515,8 @@ export async function attachOrStartIndexerDaemon(options) {
400
515
  }
401
516
  catch (error) {
402
517
  if (error instanceof FileLockBusyError) {
403
- return waitForIndexerDaemon(options.dataDir, walletRootId, startupTimeoutMs);
518
+ await waitForIndexerDaemon(options.dataDir, walletRootId, startupTimeoutMs);
519
+ return attachOrStartIndexerDaemon(options);
404
520
  }
405
521
  throw error;
406
522
  }
@@ -0,0 +1,12 @@
1
+ import type { ManagedIndexerDaemonObservedStatus } from "./types.js";
2
+ export interface ManagedIndexerMonitor {
3
+ getStatus(): Promise<ManagedIndexerDaemonObservedStatus>;
4
+ close(): Promise<void>;
5
+ }
6
+ export declare function openManagedIndexerMonitor(options: {
7
+ dataDir: string;
8
+ databasePath: string;
9
+ walletRootId?: string;
10
+ startupTimeoutMs?: number;
11
+ expectedBinaryVersion?: string | null;
12
+ }): Promise<ManagedIndexerMonitor>;
@@ -0,0 +1,89 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { loadBundledGenesisParameters } from "@cogcoin/indexer";
3
+ import { attachOrStartIndexerDaemon, readObservedIndexerDaemonStatus, } from "./indexer-daemon.js";
4
+ import { resolveCogcoinProcessingStartHeight } from "./processing-start-height.js";
5
+ import { resolveManagedServicePaths, UNINITIALIZED_WALLET_ROOT_ID } from "./service-paths.js";
6
+ import { attachOrStartManagedBitcoindService } from "./service.js";
7
+ async function readJsonFile(filePath) {
8
+ try {
9
+ return JSON.parse(await readFile(filePath, "utf8"));
10
+ }
11
+ catch (error) {
12
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
13
+ return null;
14
+ }
15
+ throw error;
16
+ }
17
+ }
18
+ async function resolveStartOptions(options) {
19
+ const paths = resolveManagedServicePaths(options.dataDir, options.walletRootId);
20
+ const observedStatus = await readJsonFile(paths.bitcoindStatusPath).catch(() => null);
21
+ if (observedStatus !== null) {
22
+ return {
23
+ chain: observedStatus.chain,
24
+ startHeight: observedStatus.startHeight,
25
+ };
26
+ }
27
+ const genesisParameters = await loadBundledGenesisParameters();
28
+ return {
29
+ chain: "main",
30
+ startHeight: resolveCogcoinProcessingStartHeight(genesisParameters),
31
+ };
32
+ }
33
+ export async function openManagedIndexerMonitor(options) {
34
+ const walletRootId = options.walletRootId ?? UNINITIALIZED_WALLET_ROOT_ID;
35
+ const startOptions = await resolveStartOptions({
36
+ dataDir: options.dataDir,
37
+ walletRootId,
38
+ });
39
+ await attachOrStartManagedBitcoindService({
40
+ dataDir: options.dataDir,
41
+ chain: startOptions.chain,
42
+ startHeight: startOptions.startHeight,
43
+ walletRootId,
44
+ startupTimeoutMs: options.startupTimeoutMs,
45
+ });
46
+ const daemon = await attachOrStartIndexerDaemon({
47
+ dataDir: options.dataDir,
48
+ databasePath: options.databasePath,
49
+ walletRootId,
50
+ startupTimeoutMs: options.startupTimeoutMs,
51
+ ensureBackgroundFollow: true,
52
+ expectedBinaryVersion: options.expectedBinaryVersion,
53
+ });
54
+ return createManagedIndexerMonitor({
55
+ daemon,
56
+ dataDir: options.dataDir,
57
+ walletRootId,
58
+ });
59
+ }
60
+ function createManagedIndexerMonitor(options) {
61
+ let closed = false;
62
+ return {
63
+ async getStatus() {
64
+ if (closed) {
65
+ throw new Error("managed_indexer_monitor_closed");
66
+ }
67
+ try {
68
+ return await options.daemon.getStatus();
69
+ }
70
+ catch (error) {
71
+ const observed = await readObservedIndexerDaemonStatus({
72
+ dataDir: options.dataDir,
73
+ walletRootId: options.walletRootId,
74
+ }).catch(() => null);
75
+ if (observed !== null) {
76
+ return observed;
77
+ }
78
+ throw error;
79
+ }
80
+ },
81
+ async close() {
82
+ if (closed) {
83
+ return;
84
+ }
85
+ closed = true;
86
+ await options.daemon.close().catch(() => undefined);
87
+ },
88
+ };
89
+ }
@@ -19,16 +19,20 @@ export interface FollowSceneStateForTesting {
19
19
  export interface FollowFrameRenderOptions {
20
20
  artworkCogText?: string | null;
21
21
  artworkSatText?: string | null;
22
+ artworkStatusLeftText?: string | null;
23
+ artworkStatusRightText?: string | null;
22
24
  }
23
25
  export declare function createFollowSceneState(indexedHeight?: number | null, blockTimesByHeight?: Record<number, number>): FollowSceneStateForTesting;
24
26
  export declare function setFollowBlockTime(state: FollowSceneStateForTesting, height: number, blockTime: number): void;
25
27
  export declare function replaceFollowBlockTimes(state: FollowSceneStateForTesting, blockTimesByHeight: Record<number, number>): void;
26
28
  export declare function formatCompactFollowAgeLabel(blockTime: number, now: number): string;
27
29
  export declare const formatCompactFollowAgeLabelForTesting: typeof formatCompactFollowAgeLabel;
28
- export declare function syncFollowSceneState(state: FollowSceneStateForTesting, { indexedHeight, nodeHeight, liveActivated, }: {
30
+ export declare function syncFollowSceneState(state: FollowSceneStateForTesting, { indexedHeight, nodeHeight, liveActivated, authoritativeTip, settleLatest, }: {
29
31
  indexedHeight?: number | null;
30
32
  nodeHeight?: number | null;
31
33
  liveActivated?: boolean;
34
+ authoritativeTip?: boolean;
35
+ settleLatest?: boolean;
32
36
  }): void;
33
37
  export declare function advanceFollowSceneState(state: FollowSceneStateForTesting, now: number): void;
34
38
  export declare function renderFollowFrame(state: FollowSceneStateForTesting, statusFieldText: string, now: number, options?: FollowFrameRenderOptions): string[];
@@ -39,6 +43,8 @@ export declare function syncFollowSceneStateForTesting(state: FollowSceneStateFo
39
43
  indexedHeight?: number | null;
40
44
  nodeHeight?: number | null;
41
45
  liveActivated?: boolean;
46
+ authoritativeTip?: boolean;
47
+ settleLatest?: boolean;
42
48
  }): FollowSceneStateForTesting;
43
49
  export declare function advanceFollowSceneStateForTesting(state: FollowSceneStateForTesting, now: number): FollowSceneStateForTesting;
44
50
  export declare function renderFollowFrameForTesting(state: FollowSceneStateForTesting, statusFieldText?: string, now?: number, options?: FollowFrameRenderOptions): string[];
@@ -1,12 +1,13 @@
1
1
  import { loadArtTemplate, loadFollowCarTemplate } from "./assets.js";
2
2
  import { FIELD_LEFT, FIELD_WIDTH, FOLLOW_AGE_ROW, FOLLOW_APPROACH_MS, FOLLOW_CAR_HEIGHT, FOLLOW_CAR_PITCH, FOLLOW_CAR_TOP, FOLLOW_CAR_WIDTH, FOLLOW_CENTER_SLOT_X, FOLLOW_CLIP_MAX_COLUMN, FOLLOW_CLIP_MIN_COLUMN, FOLLOW_CONNECTION_SLOT_X, FOLLOW_FAST_APPROACH_MS, FOLLOW_FAST_SHIFT_MS, FOLLOW_PENDING_ENTER_MS, FOLLOW_PENDING_LABEL, FOLLOW_PENDING_OFFSCREEN_LEFT_X, FOLLOW_PENDING_SLOT_X, FOLLOW_RIGHT_SLOT_XS, FOLLOW_SHIFT_MS, FOLLOW_WINDOW_LEFT, MESSAGE_FIELD_ROW, NEUTRAL_MESSAGE_TITLE, STATUS_FIELD_ROW, } from "./constants.js";
3
- import { centerLine, computeCenteredLeftPadding, overlayCenteredField, replaceSegment, rightAlignLine, truncateLine, } from "./formatting.js";
3
+ import { centerLine, computeCenteredLeftPadding, replaceSegment, rightAlignLine, truncateLine, } from "./formatting.js";
4
4
  const FOLLOW_TITLE_LEFT = computeCenteredLeftPadding(NEUTRAL_MESSAGE_TITLE, FIELD_WIDTH);
5
5
  const FOLLOW_TITLE_WIDTH = NEUTRAL_MESSAGE_TITLE.length;
6
6
  const FOLLOW_COG_LEFT = 0;
7
7
  const FOLLOW_COG_WIDTH = FOLLOW_TITLE_LEFT;
8
8
  const FOLLOW_SAT_LEFT = FOLLOW_TITLE_LEFT + FOLLOW_TITLE_WIDTH;
9
9
  const FOLLOW_SAT_WIDTH = FIELD_WIDTH - FOLLOW_SAT_LEFT;
10
+ const FOLLOW_STATUS_VERSION_GAP = 2;
10
11
  export function createFollowSceneState(indexedHeight = null, blockTimesByHeight = {}) {
11
12
  return {
12
13
  liveActivated: false,
@@ -79,9 +80,50 @@ function renderFollowHeaderField(options) {
79
80
  }
80
81
  return field;
81
82
  }
83
+ function renderFollowStatusField(statusFieldText, options) {
84
+ const leftText = options.artworkStatusLeftText === null || options.artworkStatusLeftText === undefined
85
+ ? ""
86
+ : truncateLine(options.artworkStatusLeftText, FIELD_WIDTH);
87
+ const rightText = options.artworkStatusRightText === null || options.artworkStatusRightText === undefined
88
+ ? ""
89
+ : truncateLine(options.artworkStatusRightText, FIELD_WIDTH);
90
+ const leftWidth = Math.min(FIELD_WIDTH, leftText.length);
91
+ const rightWidth = Math.min(FIELD_WIDTH, rightText.length);
92
+ if (leftWidth === 0 && rightWidth === 0) {
93
+ return centerLine(statusFieldText, FIELD_WIDTH);
94
+ }
95
+ let field = centerLine(statusFieldText, FIELD_WIDTH);
96
+ if (leftWidth > 0) {
97
+ field = replaceSegment(field, 0, leftWidth, leftAlignLane(leftText, leftWidth));
98
+ if (leftWidth < FIELD_WIDTH) {
99
+ field = replaceSegment(field, leftWidth, Math.min(FOLLOW_STATUS_VERSION_GAP, FIELD_WIDTH - leftWidth), "".padEnd(Math.min(FOLLOW_STATUS_VERSION_GAP, FIELD_WIDTH - leftWidth), " "));
100
+ }
101
+ }
102
+ if (rightWidth > 0) {
103
+ if (rightWidth < FIELD_WIDTH) {
104
+ const gapStart = Math.max(0, FIELD_WIDTH - rightWidth - FOLLOW_STATUS_VERSION_GAP);
105
+ const gapWidth = Math.min(FOLLOW_STATUS_VERSION_GAP, FIELD_WIDTH - rightWidth);
106
+ field = replaceSegment(field, gapStart, gapWidth, "".padEnd(gapWidth, " "));
107
+ }
108
+ field = replaceSegment(field, FIELD_WIDTH - rightWidth, rightWidth, rightAlignLine(rightText, rightWidth));
109
+ }
110
+ return field;
111
+ }
82
112
  function highestTrackedFollowHeight(state) {
83
113
  return Math.max(state.indexedHeight ?? Number.NEGATIVE_INFINITY, state.displayedCenterHeight ?? Number.NEGATIVE_INFINITY, state.animation?.height ?? Number.NEGATIVE_INFINITY, ...state.queuedHeights);
84
114
  }
115
+ function resolveLatestAuthoritativeFollowHeight(indexedHeight, nodeHeight) {
116
+ if (indexedHeight === null) {
117
+ return nodeHeight;
118
+ }
119
+ if (nodeHeight === null) {
120
+ return indexedHeight;
121
+ }
122
+ return Math.max(indexedHeight, nodeHeight);
123
+ }
124
+ function resolveDisplayedFollowHeight(state) {
125
+ return state.displayedCenterHeight ?? state.indexedHeight;
126
+ }
85
127
  function resetFollowSceneState(state) {
86
128
  state.displayedCenterHeight = state.indexedHeight;
87
129
  state.blockTimesByHeight = {};
@@ -90,13 +132,33 @@ function resetFollowSceneState(state) {
90
132
  state.pendingStaticX = null;
91
133
  state.animation = null;
92
134
  }
135
+ function resyncFollowSceneToLatestHeight(state, latestHeight) {
136
+ state.displayedCenterHeight = latestHeight;
137
+ state.queuedHeights = [];
138
+ state.pendingLabel = null;
139
+ state.pendingStaticX = null;
140
+ state.animation = null;
141
+ }
142
+ function shouldResyncAuthoritativeFollowScene(options) {
143
+ if (options.latestHeight === null) {
144
+ return false;
145
+ }
146
+ if (options.settleLatest) {
147
+ return true;
148
+ }
149
+ const displayedHeight = resolveDisplayedFollowHeight(options.state);
150
+ if (displayedHeight === null) {
151
+ return false;
152
+ }
153
+ return options.latestHeight - displayedHeight > 1;
154
+ }
93
155
  function enqueueFollowHeights(state, nodeHeight) {
94
156
  const startHeight = Math.max(state.indexedHeight ?? -1, highestTrackedFollowHeight(state));
95
157
  for (let height = startHeight + 1; height <= nodeHeight; height += 1) {
96
158
  state.queuedHeights.push(height);
97
159
  }
98
160
  }
99
- export function syncFollowSceneState(state, { indexedHeight, nodeHeight, liveActivated, }) {
161
+ export function syncFollowSceneState(state, { indexedHeight, nodeHeight, liveActivated, authoritativeTip, settleLatest, }) {
100
162
  const wasLive = state.liveActivated;
101
163
  const nextLive = liveActivated ?? state.liveActivated;
102
164
  let shouldReset = false;
@@ -119,7 +181,17 @@ export function syncFollowSceneState(state, { indexedHeight, nodeHeight, liveAct
119
181
  if (shouldReset) {
120
182
  resetFollowSceneState(state);
121
183
  }
122
- if (state.liveActivated && state.observedNodeHeight !== null) {
184
+ const latestAuthoritativeHeight = resolveLatestAuthoritativeFollowHeight(state.indexedHeight, state.observedNodeHeight);
185
+ if (state.liveActivated
186
+ && authoritativeTip === true
187
+ && shouldResyncAuthoritativeFollowScene({
188
+ state,
189
+ latestHeight: latestAuthoritativeHeight,
190
+ settleLatest: settleLatest === true,
191
+ })) {
192
+ resyncFollowSceneToLatestHeight(state, latestAuthoritativeHeight);
193
+ }
194
+ else if (state.liveActivated && state.observedNodeHeight !== null) {
123
195
  enqueueFollowHeights(state, state.observedNodeHeight);
124
196
  }
125
197
  if (!wasLive && state.liveActivated && state.displayedCenterHeight === null) {
@@ -363,7 +435,18 @@ export function renderFollowFrame(state, statusFieldText, now, options = {}) {
363
435
  if (headerRow !== undefined) {
364
436
  frame[MESSAGE_FIELD_ROW] = replaceSegment(headerRow, FIELD_LEFT, FIELD_WIDTH, renderFollowHeaderField(options));
365
437
  }
366
- overlayCenteredField(frame, STATUS_FIELD_ROW, statusFieldText);
438
+ if (statusFieldText.length > 0
439
+ || (options.artworkStatusLeftText !== null
440
+ && options.artworkStatusLeftText !== undefined
441
+ && options.artworkStatusLeftText.length > 0)
442
+ || (options.artworkStatusRightText !== null
443
+ && options.artworkStatusRightText !== undefined
444
+ && options.artworkStatusRightText.length > 0)) {
445
+ const statusRow = frame[STATUS_FIELD_ROW];
446
+ if (statusRow !== undefined) {
447
+ frame[STATUS_FIELD_ROW] = replaceSegment(statusRow, FIELD_LEFT, FIELD_WIDTH, renderFollowStatusField(statusFieldText, options));
448
+ }
449
+ }
367
450
  return frame;
368
451
  }
369
452
  export function createFollowSceneStateForTesting(indexedHeight = null, blockTimesByHeight = {}) {
@@ -10,6 +10,8 @@ interface RenderStream {
10
10
  export interface FollowSceneRenderOptions {
11
11
  artworkCogText?: string | null;
12
12
  artworkSatText?: string | null;
13
+ artworkStatusLeftText?: string | null;
14
+ artworkStatusRightText?: string | null;
13
15
  extraLines?: string[];
14
16
  }
15
17
  export declare class TtyProgressRenderer {
@@ -94,6 +94,8 @@ export class TtyProgressRenderer {
94
94
  ? [...renderFollowFrame(followScene, statusFieldText, now, {
95
95
  artworkCogText: renderOptions.artworkCogText ?? null,
96
96
  artworkSatText: renderOptions.artworkSatText ?? null,
97
+ artworkStatusLeftText: renderOptions.artworkStatusLeftText ?? null,
98
+ artworkStatusRightText: renderOptions.artworkStatusRightText ?? null,
97
99
  }), "", progressLine, "", ...extraLines]
98
100
  : [truncateLine(NEUTRAL_MESSAGE_TITLE, width), progressLine, "", ...extraLines];
99
101
  const frame = lines.join("\n");
@@ -19,6 +19,9 @@ export function isRetryableManagedRpcError(error) {
19
19
  if (message === "bitcoind_rpc_timeout") {
20
20
  return true;
21
21
  }
22
+ if (/^bitcoind_rpc_[^_]+_-28(?:_|$)/.test(message)) {
23
+ return true;
24
+ }
22
25
  if (message.startsWith("The managed Bitcoin RPC request to ")) {
23
26
  return message.includes(" failed");
24
27
  }
@@ -24,6 +24,7 @@ type ManagedBitcoindServiceOptions = Pick<InternalManagedBitcoindOptions, "dataD
24
24
  getblockArchivePath?: string | null;
25
25
  getblockArchiveEndHeight?: number | null;
26
26
  getblockArchiveSha256?: string | null;
27
+ serviceLifetime?: "persistent" | "ephemeral";
27
28
  };
28
29
  export type ManagedBitcoindServiceCompatibility = "compatible" | "service-version-mismatch" | "wallet-root-mismatch" | "runtime-mismatch" | "unreachable" | "protocol-error";
29
30
  export interface ManagedBitcoindServiceProbeResult {
@@ -681,9 +681,10 @@ async function refreshManagedBitcoindStatus(status, paths, options) {
681
681
  return nextStatus;
682
682
  }
683
683
  }
684
- function createNodeHandle(status, paths, options) {
684
+ function createNodeHandle(status, paths, options, ownership) {
685
685
  let currentStatus = status;
686
686
  const rpc = createRpcClient(currentStatus.rpc);
687
+ let stopped = false;
687
688
  return {
688
689
  rpc: currentStatus.rpc,
689
690
  zmq: currentStatus.zmq,
@@ -706,9 +707,20 @@ function createNodeHandle(status, paths, options) {
706
707
  return currentStatus;
707
708
  },
708
709
  async stop() {
709
- // Public managed clients detach from the persistent service instead of
710
- // shutting it down on ordinary command exit.
711
- return;
710
+ if (stopped) {
711
+ return;
712
+ }
713
+ stopped = true;
714
+ if (options.serviceLifetime !== "ephemeral" || ownership === "attached") {
715
+ // Public managed clients detach from persistent services, and ephemeral
716
+ // attach callers must not shut down services they did not launch.
717
+ return;
718
+ }
719
+ await stopManagedBitcoindService({
720
+ dataDir: currentStatus.dataDir,
721
+ walletRootId: currentStatus.walletRootId,
722
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
723
+ });
712
724
  },
713
725
  };
714
726
  }
@@ -720,7 +732,7 @@ async function tryAttachExistingManagedBitcoindService(options) {
720
732
  return null;
721
733
  }
722
734
  const refreshed = await refreshManagedBitcoindStatus(probe.status, paths, options);
723
- return createNodeHandle(refreshed, paths, options);
735
+ return createNodeHandle(refreshed, paths, options, "attached");
724
736
  }
725
737
  async function waitForManagedBitcoindService(options, timeoutMs) {
726
738
  const deadline = Date.now() + timeoutMs;
@@ -765,6 +777,7 @@ export async function attachOrStartManagedBitcoindService(options) {
765
777
  const resolvedOptions = {
766
778
  ...options,
767
779
  dataDir: options.dataDir,
780
+ serviceLifetime: options.serviceLifetime ?? "persistent",
768
781
  walletRootId: options.walletRootId ?? UNINITIALIZED_WALLET_ROOT_ID,
769
782
  };
770
783
  const startupTimeoutMs = resolvedOptions.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
@@ -815,11 +828,20 @@ export async function attachOrStartManagedBitcoindService(options) {
815
828
  port: runtimeConfig.zmqPort,
816
829
  pollIntervalMs: startOptions.pollIntervalMs ?? 15_000,
817
830
  };
831
+ const spawnOptions = startOptions.serviceLifetime === "ephemeral"
832
+ ? {
833
+ stdio: "ignore",
834
+ }
835
+ : {
836
+ detached: true,
837
+ stdio: "ignore",
838
+ };
818
839
  const child = spawn(bitcoindPath, buildManagedServiceArgs(startOptions, runtimeConfig), {
819
- detached: true,
820
- stdio: "ignore",
840
+ ...spawnOptions,
821
841
  });
822
- child.unref();
842
+ if (startOptions.serviceLifetime !== "ephemeral") {
843
+ child.unref();
844
+ }
823
845
  const rpc = createRpcClient(rpcConfig);
824
846
  try {
825
847
  await waitForRpcReady(rpc, rpcConfig.cookieFile, startOptions.chain, startupTimeoutMs);
@@ -876,7 +898,7 @@ export async function attachOrStartManagedBitcoindService(options) {
876
898
  });
877
899
  }
878
900
  await writeBitcoindStatus(paths, status);
879
- return createNodeHandle(status, paths, resolvedOptions);
901
+ return createNodeHandle(status, paths, resolvedOptions, "started");
880
902
  }
881
903
  finally {
882
904
  await lock.release();
@@ -1,6 +1,5 @@
1
1
  export { openManagedBitcoindClientInternal } from "./client.js";
2
2
  export { DefaultManagedBitcoindClient } from "./client/managed-client.js";
3
- export { pauseIndexerDaemonForForegroundClientForTesting } from "./client/factory.js";
4
3
  export { attachOrStartIndexerDaemon, readIndexerDaemonStatusForTesting, stopIndexerDaemonService, shutdownIndexerDaemonForTesting, } from "./indexer-daemon.js";
5
4
  export { normalizeRpcBlock } from "./normalize.js";
6
5
  export { BitcoinRpcClient } from "./rpc.js";