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.
- package/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
- package/IMPLEMENTATION.md +253 -0
- package/README.md +46 -0
- package/SECURITY.md +23 -0
- package/SKILL.md +13 -3
- package/bun.lock +68 -41
- package/dist/daemon/index.js +48837 -11383
- package/dist/index.js +5027 -421
- package/package.json +8 -3
- package/src/cli.test.ts +55 -0
- package/src/cli.ts +601 -217
- package/src/daemon/args.ts +8 -1
- package/src/daemon/fatal-error.test.ts +104 -0
- package/src/daemon/fatal-error.ts +48 -0
- package/src/daemon/models.ts +119 -39
- package/src/daemon/wallet/auto-refill.ts +4 -6
- package/src/daemon/wallet/cleanup.test.ts +168 -0
- package/src/daemon/wallet/cleanup.ts +104 -0
- package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
- package/src/daemon/wallet/coco-client.test.ts +669 -0
- package/src/daemon/wallet/coco-client.ts +1628 -0
- package/src/daemon/wallet/cocod-client.ts +135 -3
- package/src/daemon/wallet/diagnostics.test.ts +378 -0
- package/src/daemon/wallet/diagnostics.ts +471 -0
- package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
- package/src/daemon/wallet/migration.test.ts +171 -0
- package/src/daemon/wallet/migration.ts +154 -0
- package/src/daemon/wallet/paths.ts +33 -0
- package/src/daemon/wallet/receive-dedup.test.ts +238 -0
- package/src/daemon/wallet/receive-dedup.ts +384 -0
- package/src/daemon/wallet/wallet-state.ts +75 -0
- package/src/integrations/claudecode.ts +2 -1
- package/src/integrations/hermes.ts +46 -6
- package/src/integrations/openclaw.ts +3 -2
- package/src/integrations/opencode.ts +3 -2
- package/src/integrations/pi.ts +3 -2
- package/src/integrations/registry.ts +7 -5
- package/src/start-daemon.ts +189 -23
- package/src/utils/clients.ts +21 -0
- package/src/utils/config.ts +11 -0
- package/src/utils/daemon-client.ts +104 -25
- package/src/utils/logger.ts +42 -28
- package/tests/integrations/hermes.test.ts +42 -5
- package/tests/utils/daemon-client.test.ts +61 -0
- package/tests/wallet/short-keyset-token.test.ts +106 -0
package/src/daemon/args.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/daemon/models.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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, ${
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|