routstrd 0.3.11 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
  2. package/IMPLEMENTATION.md +253 -0
  3. package/README.md +46 -0
  4. package/SECURITY.md +23 -0
  5. package/SKILL.md +13 -3
  6. package/bun.lock +68 -41
  7. package/dist/daemon/index.js +48837 -11383
  8. package/dist/index.js +5027 -421
  9. package/package.json +8 -3
  10. package/src/cli.test.ts +55 -0
  11. package/src/cli.ts +601 -217
  12. package/src/daemon/args.ts +8 -1
  13. package/src/daemon/fatal-error.test.ts +104 -0
  14. package/src/daemon/fatal-error.ts +48 -0
  15. package/src/daemon/models.ts +119 -39
  16. package/src/daemon/wallet/auto-refill.ts +4 -6
  17. package/src/daemon/wallet/cleanup.test.ts +168 -0
  18. package/src/daemon/wallet/cleanup.ts +104 -0
  19. package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
  20. package/src/daemon/wallet/coco-client.test.ts +669 -0
  21. package/src/daemon/wallet/coco-client.ts +1628 -0
  22. package/src/daemon/wallet/cocod-client.ts +135 -3
  23. package/src/daemon/wallet/diagnostics.test.ts +378 -0
  24. package/src/daemon/wallet/diagnostics.ts +471 -0
  25. package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
  26. package/src/daemon/wallet/migration.test.ts +171 -0
  27. package/src/daemon/wallet/migration.ts +154 -0
  28. package/src/daemon/wallet/paths.ts +33 -0
  29. package/src/daemon/wallet/receive-dedup.test.ts +238 -0
  30. package/src/daemon/wallet/receive-dedup.ts +384 -0
  31. package/src/daemon/wallet/wallet-state.ts +75 -0
  32. package/src/integrations/claudecode.ts +2 -1
  33. package/src/integrations/hermes.ts +46 -6
  34. package/src/integrations/openclaw.ts +3 -2
  35. package/src/integrations/opencode.ts +3 -2
  36. package/src/integrations/pi.ts +3 -2
  37. package/src/integrations/registry.ts +7 -5
  38. package/src/start-daemon.ts +189 -23
  39. package/src/utils/clients.ts +21 -0
  40. package/src/utils/config.ts +11 -0
  41. package/src/utils/daemon-client.ts +104 -25
  42. package/src/utils/logger.ts +42 -28
  43. package/tests/integrations/hermes.test.ts +42 -5
  44. package/tests/utils/daemon-client.test.ts +61 -0
  45. package/tests/wallet/short-keyset-token.test.ts +106 -0
@@ -1,8 +1,10 @@
1
1
  export function parseArgs(argv: string[]): {
2
2
  port: number;
3
+ host: string | null;
3
4
  provider: string | null;
4
5
  } {
5
6
  const portFlagIndex = argv.findIndex((arg) => arg === "--port");
7
+ const hostFlagIndex = argv.findIndex((arg) => arg === "--host");
6
8
  const providerFlagIndex = argv.findIndex(
7
9
  (arg) => arg === "--provider" || arg === "-p",
8
10
  );
@@ -11,9 +13,14 @@ export function parseArgs(argv: string[]): {
11
13
  portFlagIndex !== -1
12
14
  ? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10)
13
15
  : 8008;
16
+
17
+ const hostValue =
18
+ hostFlagIndex !== -1 ? argv[hostFlagIndex + 1] : undefined;
19
+ const host = hostValue?.trim() || null;
20
+
14
21
  const providerValue =
15
22
  providerFlagIndex !== -1 ? argv[providerFlagIndex + 1] : undefined;
16
23
  const provider = providerValue ? providerValue.trim() : null;
17
24
 
18
- return { port, provider };
25
+ return { port, host, provider };
19
26
  }
@@ -0,0 +1,104 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { unlinkSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { exitOnUncaughtException } from "./fatal-error";
6
+
7
+ const fatalErrorModule = JSON.stringify(join(import.meta.dir, "fatal-error.ts"));
8
+
9
+ const fixturePaths: string[] = [];
10
+
11
+ afterEach(() => {
12
+ while (fixturePaths.length > 0) {
13
+ try {
14
+ unlinkSync(fixturePaths.pop()!);
15
+ } catch {
16
+ // Best effort cleanup of temp fixtures.
17
+ }
18
+ }
19
+ });
20
+
21
+ /** Run a fixture script in a real subprocess so process.exit is for real. */
22
+ async function runFixture(source: string): Promise<{
23
+ exitCode: number;
24
+ stdout: string;
25
+ stderr: string;
26
+ }> {
27
+ const scriptPath = join(
28
+ tmpdir(),
29
+ `routstrd-fatal-error-fixture-${crypto.randomUUID()}.ts`,
30
+ );
31
+ fixturePaths.push(scriptPath);
32
+ await Bun.write(scriptPath, source);
33
+ const proc = Bun.spawn(["bun", scriptPath], {
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ });
37
+ const [stdout, stderr] = await Promise.all([
38
+ new Response(proc.stdout).text(),
39
+ new Response(proc.stderr).text(),
40
+ ]);
41
+ const exitCode = await proc.exited;
42
+ return { exitCode, stdout, stderr };
43
+ }
44
+
45
+ describe("exitOnUncaughtException", () => {
46
+ test("reports the error and exits with code 1", () => {
47
+ let exitCode: number | undefined;
48
+ const stderrLines: string[] = [];
49
+ const originalConsoleError = console.error;
50
+ console.error = (...args: unknown[]) => {
51
+ stderrLines.push(args.map(String).join(" "));
52
+ };
53
+ try {
54
+ exitOnUncaughtException(new Error("boom"), (code) => {
55
+ exitCode = code;
56
+ });
57
+ } finally {
58
+ console.error = originalConsoleError;
59
+ }
60
+ expect(exitCode).toBe(1);
61
+ expect(stderrLines.join("\n")).toContain("boom");
62
+ });
63
+
64
+ test("handles non-Error thrown values", () => {
65
+ let exitCode: number | undefined;
66
+ const originalConsoleError = console.error;
67
+ console.error = () => {};
68
+ try {
69
+ exitOnUncaughtException("string failure", (code) => {
70
+ exitCode = code;
71
+ });
72
+ } finally {
73
+ console.error = originalConsoleError;
74
+ }
75
+ expect(exitCode).toBe(1);
76
+ });
77
+ });
78
+
79
+ describe("installGlobalErrorHandlers (subprocess)", () => {
80
+ test("an uncaught exception exits the process with code 1", async () => {
81
+ const { exitCode, stderr } = await runFixture(`
82
+ import { installGlobalErrorHandlers } from ${fatalErrorModule};
83
+ installGlobalErrorHandlers();
84
+ setTimeout(() => {
85
+ throw new Error("boom-uncaught");
86
+ }, 10);
87
+ `);
88
+ expect(exitCode).toBe(1);
89
+ expect(stderr).toContain("boom-uncaught");
90
+ });
91
+
92
+ test("an unhandled rejection is logged without exiting", async () => {
93
+ const { exitCode, stdout } = await runFixture(`
94
+ import { installGlobalErrorHandlers } from ${fatalErrorModule};
95
+ installGlobalErrorHandlers();
96
+ Promise.reject(new Error("boom-rejection"));
97
+ setTimeout(() => {
98
+ console.log("STILL-ALIVE");
99
+ }, 50);
100
+ `);
101
+ expect(exitCode).toBe(0);
102
+ expect(stdout).toContain("STILL-ALIVE");
103
+ });
104
+ });
@@ -0,0 +1,48 @@
1
+ import { logger } from "../utils/logger";
2
+
3
+ /**
4
+ * Report an unrecoverable error, then exit.
5
+ *
6
+ * The daemon runs detached under a supervisor (pm2 via `routstrd service
7
+ * install`, or the spawning CLI). An uncaught exception means the call stack
8
+ * unwound unexpectedly mid-operation and process state can no longer be
9
+ * trusted. Swallowing it once left a daemon alive with nothing listening
10
+ * while it held both wallet PID locks, so every later start failed on the
11
+ * lock and liveness-only supervisors never restarted it. Exiting lets the
12
+ * supervisor bring up a healthy process instead.
13
+ *
14
+ * The file logger writes synchronously, so the record survives the exit.
15
+ * The error is mirrored to stderr because the daemon's stdout/stderr are
16
+ * redirected to debug.log, which is what the CLI surfaces when the daemon
17
+ * exits early and what pm2 captures. process.exit() runs the synchronous
18
+ * PID-lock exit hooks registered by claimPidFile, releasing the wallet
19
+ * locks on the way out.
20
+ *
21
+ * `exit` is injectable for tests.
22
+ */
23
+ export function exitOnUncaughtException(
24
+ error: unknown,
25
+ exit: (code: number) => void = (code) => process.exit(code),
26
+ ): void {
27
+ logger.error("UNCAUGHT EXCEPTION:", error);
28
+ // Full error object (not just .message): this path means a bug, so the
29
+ // stack is the most useful thing to have in debug.log.
30
+ console.error("UNCAUGHT EXCEPTION:", error);
31
+ exit(1);
32
+ }
33
+
34
+ /**
35
+ * Install the process-level error handlers. Called once at daemon module
36
+ * load, before main() runs.
37
+ *
38
+ * Only uncaught exceptions are fatal. An unhandled rejection means a
39
+ * promise's failure went unobserved; the stack was not unwound and the
40
+ * process is still in a consistent state, so it is logged and the daemon
41
+ * keeps serving.
42
+ */
43
+ export function installGlobalErrorHandlers(): void {
44
+ process.on("uncaughtException", (error) => exitOnUncaughtException(error));
45
+ process.on("unhandledRejection", (reason) => {
46
+ logger.error("UNHANDLED REJECTION:", reason);
47
+ });
48
+ }
@@ -1,9 +1,10 @@
1
- import { ModelManager, type SdkStore } from "@routstr/sdk";
1
+ import { ModelManager, ProviderManager, type SdkStore } from "@routstr/sdk";
2
2
  import type { ExposedModel } from "./types";
3
3
  import { logger } from "../utils/logger";
4
4
 
5
5
  export type ModelProviderInfo = {
6
6
  baseUrl: string;
7
+ disabled: boolean;
7
8
  pricing: {
8
9
  prompt: number;
9
10
  completion: number;
@@ -16,16 +17,75 @@ export type ModelWithProviders = ExposedModel & {
16
17
  providers: ModelProviderInfo[];
17
18
  };
18
19
 
19
- export function createModelService(modelManager: ModelManager, store: SdkStore) {
20
+ export function createModelService(
21
+ modelManager: ModelManager,
22
+ providerManager: ProviderManager,
23
+ store: SdkStore,
24
+ ) {
20
25
  let providerBootstrapPromise: Promise<void> | null = null;
21
26
 
27
+ const normalizeBaseUrl = (url: string): string =>
28
+ url.endsWith("/") ? url : `${url}/`;
29
+
30
+ /**
31
+ * Build the same cheapest-per-model view as ModelManager.fetchModels, but
32
+ * exclusively from the persisted cache. Model-list reads must not wait for
33
+ * unavailable providers; explicit and scheduled refreshes own network I/O.
34
+ */
35
+ const getCachedModels = (): ExposedModel[] => {
36
+ type PricedModel = ExposedModel & {
37
+ sats_pricing?: { completion?: number };
38
+ };
39
+
40
+ const cachedByProvider = modelManager.getAllCachedModels();
41
+ const currentProviders = new Set(
42
+ modelManager.getBaseUrls().map(normalizeBaseUrl),
43
+ );
44
+ const s = store.getState();
45
+ const manuallyEnabled = new Set(
46
+ (s.manuallyEnabledProviders || []).map(normalizeBaseUrl),
47
+ );
48
+ const disabledProviders = new Set(
49
+ [
50
+ ...(s.disabledProviders || []),
51
+ ...(s.manuallyDisabledProviders || []),
52
+ ]
53
+ .map(normalizeBaseUrl)
54
+ .filter((url) => !manuallyEnabled.has(url)),
55
+ );
56
+ const bestById = new Map<string, PricedModel>();
57
+
58
+ for (const [baseUrl, models] of Object.entries(cachedByProvider)) {
59
+ const normalized = normalizeBaseUrl(baseUrl);
60
+ if (
61
+ disabledProviders.has(normalized) ||
62
+ (currentProviders.size > 0 && !currentProviders.has(normalized))
63
+ ) {
64
+ continue;
65
+ }
66
+
67
+ for (const model of models as PricedModel[]) {
68
+ if (!model.sats_pricing) continue;
69
+ const existing = bestById.get(model.id);
70
+ if (
71
+ !existing ||
72
+ (model.sats_pricing.completion ?? 0) <
73
+ (existing.sats_pricing?.completion ?? 0)
74
+ ) {
75
+ bestById.set(model.id, model);
76
+ }
77
+ }
78
+ }
79
+
80
+ return [...bestById.values()];
81
+ };
82
+
22
83
  const ensureProvidersBootstrapped = (): Promise<void> => {
23
84
  if (!providerBootstrapPromise) {
24
85
  providerBootstrapPromise = (async () => {
25
86
  logger.log("Bootstrapping providers...");
26
87
  const providers = await modelManager.bootstrapProviders(false);
27
88
  logger.log(`Bootstrapped ${providers.length} providers`);
28
- await modelManager.fetchModels(providers);
29
89
 
30
90
  // Sync discovered providers into the store so `providers list` reflects
31
91
  // the same set that the model manager knows about.
@@ -44,6 +104,7 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
44
104
 
45
105
  logger.log("Provider bootstrap complete.");
46
106
  })().catch((error) => {
107
+ providerBootstrapPromise = null;
47
108
  logger.error("Provider bootstrap failed:", error);
48
109
  throw error;
49
110
  });
@@ -54,16 +115,27 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
54
115
  const getRoutstr21Models = async (
55
116
  forceRefresh = false,
56
117
  ): Promise<ExposedModel[]> => {
57
- await ensureProvidersBootstrapped();
58
-
59
118
  const routstr21ModelIds = Array.from(
60
119
  new Set(await modelManager.fetchRoutstr21Models(forceRefresh)),
61
120
  ).slice(0, 21);
62
- const baseUrls = modelManager.getBaseUrls();
63
- const discoveredModels = await modelManager.fetchModels(
64
- baseUrls,
65
- forceRefresh,
66
- );
121
+
122
+ let discoveredModels: ExposedModel[];
123
+ if (!forceRefresh) {
124
+ discoveredModels = getCachedModels();
125
+ } else {
126
+ discoveredModels = [];
127
+ }
128
+
129
+ // Warm reads are cache-only. A cold start and explicit refresh still
130
+ // populate models from the provider network.
131
+ if (forceRefresh || discoveredModels.length === 0) {
132
+ await ensureProvidersBootstrapped();
133
+ discoveredModels = await modelManager.fetchModels(
134
+ modelManager.getBaseUrls(),
135
+ forceRefresh,
136
+ );
137
+ }
138
+
67
139
  const modelsById = new Map(discoveredModels.map((model) => [model.id, model]));
68
140
 
69
141
  return routstr21ModelIds.map((modelId) => {
@@ -77,33 +149,42 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
77
149
  ): Promise<ModelWithProviders | null> => {
78
150
  await ensureProvidersBootstrapped();
79
151
 
80
- const allModels = modelManager.getAllCachedModels();
81
- const providers: ModelProviderInfo[] = [];
82
-
83
- for (const [baseUrl, models] of Object.entries(allModels)) {
84
- const model = models.find((m) => m.id === modelId);
85
- if (model && model.sats_pricing) {
86
- providers.push({
87
- baseUrl,
88
- pricing: {
89
- prompt: model.sats_pricing.prompt,
90
- completion: model.sats_pricing.completion,
91
- request: model.sats_pricing.request,
92
- max_cost: model.sats_pricing.max_cost,
93
- },
94
- });
95
- }
96
- }
152
+ const s = store.getState();
153
+ const manuallyEnabled = new Set<string>(
154
+ (s.manuallyEnabledProviders || []).map(normalizeBaseUrl),
155
+ );
156
+ const disabledSet = new Set<string>(
157
+ [
158
+ ...(s.disabledProviders || []),
159
+ ...(s.manuallyDisabledProviders || []),
160
+ ].filter((url) => !manuallyEnabled.has(normalizeBaseUrl(url))),
161
+ );
162
+
163
+ // Use the SDK ranking (sorted by prompt+completion per million tokens)
164
+ // so the display order matches real routing. includeDisabled keeps
165
+ // disabled providers visible so we can annotate them.
166
+ const ranking = providerManager.getProviderPriceRankingForModel(modelId, {
167
+ includeDisabled: true,
168
+ });
97
169
 
98
- // Sort by max_cost (cheapest first)
99
- providers.sort((a, b) => a.pricing.max_cost - b.pricing.max_cost);
170
+ const providers: ModelProviderInfo[] = ranking.map((entry: any) => ({
171
+ baseUrl: entry.baseUrl,
172
+ disabled: disabledSet.has(entry.baseUrl),
173
+ pricing: {
174
+ prompt: entry.promptPerMillion / 1_000_000,
175
+ completion: entry.completionPerMillion / 1_000_000,
176
+ request: entry.model.sats_pricing?.request ?? 0,
177
+ max_cost: entry.model.sats_pricing?.max_cost ?? 0,
178
+ },
179
+ }));
100
180
 
101
181
  if (providers.length === 0) {
102
182
  return null;
103
183
  }
104
184
 
105
- // Get model metadata from first provider that has it
185
+ // Get model metadata from first (cheapest) provider
106
186
  const cheapest = providers[0]!;
187
+ const allModels = modelManager.getAllCachedModels();
107
188
  const firstProvider = allModels[cheapest.baseUrl];
108
189
  const modelInfo = firstProvider?.find((m: { id: string }) => m.id === modelId);
109
190
 
@@ -149,28 +230,27 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
149
230
  undefined,
150
231
  true,
151
232
  );
152
- if (reviewedDisabled.length > 0) {
233
+ if (reviewedDisabled && reviewedDisabled.length > 0) {
153
234
  console.log(
154
235
  `Review sync disabled ${reviewedDisabled.length} provider(s): ${reviewedDisabled.join(", ")}`,
155
236
  );
156
237
  }
157
238
 
158
239
  // Sync discovered providers into the store
159
- const { baseUrlsList, setBaseUrlsList, disabledProviders, setDisabledProviders } =
160
- store.getState() as any;
240
+ const { setBaseUrlsList, setDisabledProviders } = store.getState() as any;
161
241
 
162
242
  // Replace baseUrlsList with the fresh provider list
163
243
  setBaseUrlsList(providers);
164
244
 
165
- // Merge review-disabled providers into the store's disabled list
166
- const existingDisabled = new Set(disabledProviders || []);
167
- for (const url of reviewedDisabled) {
168
- existingDisabled.add(url);
245
+ // Mirror the review-disabled set into the store's auto-disabled list.
246
+ // `null` means the review sync left the adapter unchanged (e.g. no lgtm
247
+ // reviews found), so we must not clobber the store list with an empty array.
248
+ if (reviewedDisabled !== null) {
249
+ setDisabledProviders(reviewedDisabled);
169
250
  }
170
- setDisabledProviders([...existingDisabled]);
171
251
 
172
252
  console.log(
173
- `Provider refresh complete: ${providers.length} total, ${existingDisabled.size} disabled`,
253
+ `Provider refresh complete: ${providers.length} total, ${reviewedDisabled?.length ?? store.getState().disabledProviders?.length ?? 0} review-disabled`,
174
254
  );
175
255
  };
176
256
 
@@ -66,8 +66,6 @@ export function startAutoRefillLoop(
66
66
  return;
67
67
  }
68
68
 
69
- checkInProgress = true;
70
-
71
69
  // If we've been failing too much, back off rather than retrying at full speed.
72
70
  // This prevents tight loops on transient errors.
73
71
  const backoffInterval = Math.min(
@@ -78,6 +76,8 @@ export function startAutoRefillLoop(
78
76
  return;
79
77
  }
80
78
 
79
+ checkInProgress = true;
80
+
81
81
  try {
82
82
  const balances = await cocod.getBalances();
83
83
  const totalBalance = Object.values(balances).reduce<number>(
@@ -94,11 +94,9 @@ export function startAutoRefillLoop(
94
94
  `[auto-refill] Balance ${totalBalance} sats < threshold ${config.threshold}. Refilling ${config.amount} sats...`,
95
95
  );
96
96
 
97
- // Get active mint
98
- const mints = await cocod.listMints();
99
- const mintUrl = mints[0];
97
+ const mintUrl = await cocod.getDefaultMint();
100
98
  if (!mintUrl) {
101
- logger.error("[auto-refill] No active mint configured");
99
+ logger.error("[auto-refill] No default mint configured");
102
100
  return;
103
101
  }
104
102
 
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { selectCleanupOperations } from "./cleanup";
3
+
4
+ const NOW_MS = 1_800_000_000_000;
5
+ const DAY_MS = 24 * 60 * 60 * 1000;
6
+
7
+ function mint(overrides: Record<string, unknown>) {
8
+ return {
9
+ id: "mint-1",
10
+ state: "pending",
11
+ expiry: NOW_MS / 1000 - 1000, // expired
12
+ updatedAt: NOW_MS - 2 * DAY_MS,
13
+ lastObservedRemoteState: undefined,
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function send(overrides: Record<string, unknown>) {
19
+ return {
20
+ id: "send-1",
21
+ state: "pending",
22
+ updatedAt: NOW_MS - 2 * DAY_MS,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function melt(overrides: Record<string, unknown>) {
28
+ return {
29
+ id: "melt-1",
30
+ state: "prepared",
31
+ updatedAt: NOW_MS - 2 * DAY_MS,
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ describe("selectCleanupOperations", () => {
37
+ it("selects expired pending mint quotes that are old enough", () => {
38
+ const result = selectCleanupOperations({
39
+ mints: [mint({ id: "a" })],
40
+ sends: [],
41
+ melts: [],
42
+ nowMs: NOW_MS,
43
+ minAgeMs: DAY_MS,
44
+ });
45
+
46
+ expect(result.mintsToFail.map((op) => op.id)).toEqual(["a"]);
47
+ });
48
+
49
+ it("ignores mint quotes that have not expired", () => {
50
+ const result = selectCleanupOperations({
51
+ mints: [mint({ id: "a", expiry: NOW_MS / 1000 + 1000 })],
52
+ sends: [],
53
+ melts: [],
54
+ nowMs: NOW_MS,
55
+ minAgeMs: DAY_MS,
56
+ });
57
+
58
+ expect(result.mintsToFail).toEqual([]);
59
+ });
60
+
61
+ it("ignores expired mint quotes already observed as PAID", () => {
62
+ const result = selectCleanupOperations({
63
+ mints: [mint({ id: "a", lastObservedRemoteState: "PAID" })],
64
+ sends: [],
65
+ melts: [],
66
+ nowMs: NOW_MS,
67
+ minAgeMs: DAY_MS,
68
+ });
69
+
70
+ expect(result.mintsToFail).toEqual([]);
71
+ });
72
+
73
+ it("ignores expired mint quotes already observed as ISSUED", () => {
74
+ const result = selectCleanupOperations({
75
+ mints: [mint({ id: "a", lastObservedRemoteState: "ISSUED" })],
76
+ sends: [],
77
+ melts: [],
78
+ nowMs: NOW_MS,
79
+ minAgeMs: DAY_MS,
80
+ });
81
+
82
+ expect(result.mintsToFail).toEqual([]);
83
+ });
84
+
85
+ it("ignores mint quotes without an expiry", () => {
86
+ const result = selectCleanupOperations({
87
+ mints: [mint({ id: "a", expiry: 0 })],
88
+ sends: [],
89
+ melts: [],
90
+ nowMs: NOW_MS,
91
+ minAgeMs: DAY_MS,
92
+ });
93
+
94
+ expect(result.mintsToFail).toEqual([]);
95
+ });
96
+
97
+ it("selects expired mint quotes even when the watcher recently touched them", () => {
98
+ const result = selectCleanupOperations({
99
+ mints: [mint({ id: "a", updatedAt: NOW_MS - 60_000 })],
100
+ sends: [],
101
+ melts: [],
102
+ nowMs: NOW_MS,
103
+ minAgeMs: DAY_MS,
104
+ });
105
+
106
+ expect(result.mintsToFail.map((op) => op.id)).toEqual(["a"]);
107
+ });
108
+
109
+ it("selects stale pending sends for reclaim", () => {
110
+ const result = selectCleanupOperations({
111
+ mints: [],
112
+ sends: [send({ id: "s" })],
113
+ melts: [],
114
+ nowMs: NOW_MS,
115
+ minAgeMs: DAY_MS,
116
+ });
117
+
118
+ expect(result.sendsToReclaim.map((op) => op.id)).toEqual(["s"]);
119
+ });
120
+
121
+ it("ignores recent pending sends", () => {
122
+ const result = selectCleanupOperations({
123
+ mints: [],
124
+ sends: [send({ id: "s", updatedAt: NOW_MS - 60_000 })],
125
+ melts: [],
126
+ nowMs: NOW_MS,
127
+ minAgeMs: DAY_MS,
128
+ });
129
+
130
+ expect(result.sendsToReclaim).toEqual([]);
131
+ });
132
+
133
+ it("ignores non-pending sends even when old", () => {
134
+ const result = selectCleanupOperations({
135
+ mints: [],
136
+ sends: [send({ id: "s", state: "executing" })],
137
+ melts: [],
138
+ nowMs: NOW_MS,
139
+ minAgeMs: DAY_MS,
140
+ });
141
+
142
+ expect(result.sendsToReclaim).toEqual([]);
143
+ });
144
+
145
+ it("selects stale prepared melts for cancellation", () => {
146
+ const result = selectCleanupOperations({
147
+ mints: [],
148
+ sends: [],
149
+ melts: [melt({ id: "m" })],
150
+ nowMs: NOW_MS,
151
+ minAgeMs: DAY_MS,
152
+ });
153
+
154
+ expect(result.meltsToCancel.map((op) => op.id)).toEqual(["m"]);
155
+ });
156
+
157
+ it("ignores non-prepared melts", () => {
158
+ const result = selectCleanupOperations({
159
+ mints: [],
160
+ sends: [],
161
+ melts: [melt({ id: "m", state: "pending" })],
162
+ nowMs: NOW_MS,
163
+ minAgeMs: DAY_MS,
164
+ });
165
+
166
+ expect(result.meltsToCancel).toEqual([]);
167
+ });
168
+ });