routstrd 0.2.8 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -20
- package/SKILL.md +46 -13
- package/dist/daemon/index.js +137 -28
- package/dist/index.js +300 -181
- package/package.json +1 -1
- package/src/daemon/wallet/cocod-client.ts +22 -6
- package/src/start-daemon.ts +52 -30
- package/src/utils/clients.ts +8 -9
- package/src/utils/daemon-client.ts +6 -26
- package/src/utils/process-lock.ts +136 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
3
|
import { logger } from "../../utils/logger";
|
|
4
|
+
import { withCrossProcessLock } from "../../utils/process-lock";
|
|
4
5
|
|
|
5
6
|
const DEFAULT_CONFIG_DIR =
|
|
6
7
|
process.env.COCOD_DIR || `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`;
|
|
@@ -139,6 +140,7 @@ export function createCocodClient(
|
|
|
139
140
|
): CocodClient {
|
|
140
141
|
const executable = resolveCocodExecutable(options.cocodPath);
|
|
141
142
|
const socketPath = options.socketPath || DEFAULT_SOCKET_PATH;
|
|
143
|
+
const startupLockPath = `${socketPath}.startup.lock`;
|
|
142
144
|
const fetchImpl = options.fetchImpl || (fetch as CocodFetch);
|
|
143
145
|
const pollIntervalMs = options.pollIntervalMs ?? 100;
|
|
144
146
|
const startupTimeoutMs = options.startupTimeoutMs ?? 5000;
|
|
@@ -228,7 +230,7 @@ export function createCocodClient(
|
|
|
228
230
|
|
|
229
231
|
async function startDaemon(): Promise<void> {
|
|
230
232
|
const env = { ...process.env, COCOD_SOCKET: socketPath };
|
|
231
|
-
const proc = spawnDaemon([executable, "
|
|
233
|
+
const proc = spawnDaemon([executable, "init"], env);
|
|
232
234
|
const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs);
|
|
233
235
|
let exitCode: number | null = null;
|
|
234
236
|
|
|
@@ -239,8 +241,8 @@ export function createCocodClient(
|
|
|
239
241
|
for (let i = 0; i < maxPolls; i++) {
|
|
240
242
|
await delay(pollIntervalMs);
|
|
241
243
|
|
|
242
|
-
if (exitCode !== null) {
|
|
243
|
-
throw new Error(`cocod
|
|
244
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
245
|
+
throw new Error(`cocod init exited early with code ${exitCode}`);
|
|
244
246
|
}
|
|
245
247
|
|
|
246
248
|
if (await pingInternal()) {
|
|
@@ -250,7 +252,7 @@ export function createCocodClient(
|
|
|
250
252
|
}
|
|
251
253
|
|
|
252
254
|
throw new Error(
|
|
253
|
-
`cocod
|
|
255
|
+
`cocod failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds`,
|
|
254
256
|
);
|
|
255
257
|
}
|
|
256
258
|
|
|
@@ -260,8 +262,22 @@ export function createCocodClient(
|
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
if (!startPromise) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
+
startPromise = withCrossProcessLock(
|
|
266
|
+
startupLockPath,
|
|
267
|
+
async () => {
|
|
268
|
+
if (await pingInternal()) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
logger.debug(`Starting cocod daemon via ${executable} init...`);
|
|
273
|
+
await startDaemon();
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
acquireTimeoutMs: startupTimeoutMs + 30_000,
|
|
277
|
+
staleAfterMs: startupTimeoutMs + 30_000,
|
|
278
|
+
log: (message) => logger.debug(message),
|
|
279
|
+
},
|
|
280
|
+
).finally(() => {
|
|
265
281
|
startPromise = null;
|
|
266
282
|
});
|
|
267
283
|
}
|
package/src/start-daemon.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { logger } from "./utils/logger";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
|
-
import { LOGS_DIR } from "./utils/config";
|
|
3
|
+
import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
|
|
4
|
+
import { withCrossProcessLock } from "./utils/process-lock";
|
|
5
|
+
|
|
6
|
+
const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
|
|
4
7
|
|
|
5
8
|
function getTodayLogFile(): string {
|
|
6
9
|
const now = new Date();
|
|
@@ -10,27 +13,32 @@ function getTodayLogFile(): string {
|
|
|
10
13
|
return `${LOGS_DIR}/${year}-${month}-${day}.log`;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
async function isDaemonHealthy(port: string): Promise<boolean> {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
19
|
+
try {
|
|
20
|
+
const existing = await fetch(`http://localhost:${port}/health`, {
|
|
21
|
+
signal: controller.signal,
|
|
22
|
+
});
|
|
23
|
+
return existing.ok;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
} finally {
|
|
27
|
+
clearTimeout(timeoutId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function startDaemonUnlocked(
|
|
32
|
+
options: { port?: string; provider?: string },
|
|
15
33
|
): Promise<void> {
|
|
16
34
|
const args: string[] = [];
|
|
17
35
|
const port = options.port || "8008";
|
|
18
36
|
const pollIntervalMs = 250;
|
|
19
37
|
const startupTimeoutMs = 10 * 60 * 1000;
|
|
20
38
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const existing = await fetch(`http://localhost:${port}/health`, {
|
|
25
|
-
signal: controller.signal,
|
|
26
|
-
});
|
|
27
|
-
clearTimeout(timeoutId);
|
|
28
|
-
if (existing.ok) {
|
|
29
|
-
logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
} catch {
|
|
33
|
-
// Daemon is not running yet; continue with startup.
|
|
39
|
+
if (await isDaemonHealthy(port)) {
|
|
40
|
+
logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
|
|
41
|
+
return;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
if (options.port) {
|
|
@@ -47,7 +55,7 @@ export async function startDaemon(
|
|
|
47
55
|
|
|
48
56
|
const daemonScript = new URL("./daemon/index.js", import.meta.url).pathname;
|
|
49
57
|
const todayLogFile = getTodayLogFile();
|
|
50
|
-
const shellCmd = `bun run "${daemonScript}" ${args.map(a => `'${a}'`).join(" ")} >> "${todayLogFile}" 2>&1`;
|
|
58
|
+
const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")} >> "${todayLogFile}" 2>&1`;
|
|
51
59
|
|
|
52
60
|
const proc = Bun.spawn(["sh", "-c", shellCmd], {
|
|
53
61
|
stdout: "inherit",
|
|
@@ -73,19 +81,9 @@ export async function startDaemon(
|
|
|
73
81
|
);
|
|
74
82
|
}
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const res = await fetch(`http://localhost:${port}/health`, {
|
|
80
|
-
signal: controller.signal,
|
|
81
|
-
});
|
|
82
|
-
clearTimeout(timeoutId);
|
|
83
|
-
if (res.ok) {
|
|
84
|
-
logger.log(`Routstr daemon started (PID: ${proc.pid}).`);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
} catch {
|
|
88
|
-
// Not ready yet
|
|
84
|
+
if (await isDaemonHealthy(port)) {
|
|
85
|
+
logger.log(`Routstr daemon started (PID: ${proc.pid}).`);
|
|
86
|
+
return;
|
|
89
87
|
}
|
|
90
88
|
}
|
|
91
89
|
|
|
@@ -93,3 +91,27 @@ export async function startDaemon(
|
|
|
93
91
|
`Daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds. Check logs in ${LOGS_DIR}`,
|
|
94
92
|
);
|
|
95
93
|
}
|
|
94
|
+
|
|
95
|
+
export async function startDaemon(
|
|
96
|
+
options: { port?: string; provider?: string } = {},
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const port = options.port || "8008";
|
|
99
|
+
const startupTimeoutMs = 10 * 60 * 1000;
|
|
100
|
+
|
|
101
|
+
if (await isDaemonHealthy(port)) {
|
|
102
|
+
logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await withCrossProcessLock(
|
|
107
|
+
DAEMON_STARTUP_LOCK_PATH,
|
|
108
|
+
async () => {
|
|
109
|
+
await startDaemonUnlocked(options);
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
acquireTimeoutMs: startupTimeoutMs + 30_000,
|
|
113
|
+
staleAfterMs: startupTimeoutMs + 30_000,
|
|
114
|
+
log: (message) => logger.debug(message),
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
}
|
package/src/utils/clients.ts
CHANGED
|
@@ -121,12 +121,15 @@ export async function getClientsList(): Promise<ClientEntry[]> {
|
|
|
121
121
|
|
|
122
122
|
export async function addDaemonClient(
|
|
123
123
|
name: string,
|
|
124
|
-
clientId?: string,
|
|
125
124
|
): Promise<{ message?: string; client: DaemonClient; created: boolean }> {
|
|
126
125
|
const existingClients = await getClientsList();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
// Derive id from name by replacing spaces with hyphens
|
|
127
|
+
const derivedId = name.replace(/\s+/g, "-").toLowerCase();
|
|
128
|
+
const config = await loadConfig();
|
|
129
|
+
const suffix = getNpubSuffix(config);
|
|
130
|
+
const clientId = suffix ? addSuffixToId(derivedId, suffix) : derivedId;
|
|
131
|
+
|
|
132
|
+
const existing = existingClients.find((c) => c.clientId === clientId);
|
|
130
133
|
|
|
131
134
|
if (existing) {
|
|
132
135
|
const client: DaemonClient = {
|
|
@@ -139,9 +142,6 @@ export async function addDaemonClient(
|
|
|
139
142
|
return { client, created: false };
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
// Derive id from name by replacing spaces with hyphens
|
|
143
|
-
const derivedId = name.replace(/\s+/g, "-").toLowerCase();
|
|
144
|
-
|
|
145
145
|
const result = await callDaemon("/clients/add", {
|
|
146
146
|
method: "POST",
|
|
147
147
|
body: { name, id: derivedId },
|
|
@@ -246,8 +246,7 @@ export async function addClientAction(options: AddClientOptions): Promise<void>
|
|
|
246
246
|
|
|
247
247
|
try {
|
|
248
248
|
const { client, created } = await addDaemonClient(
|
|
249
|
-
integrationConfig.name
|
|
250
|
-
integrationConfig.clientId,
|
|
249
|
+
integrationConfig.name
|
|
251
250
|
);
|
|
252
251
|
if (created) {
|
|
253
252
|
logger.log(`Created new API key for ${integrationConfig.name}`);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
+
import { startDaemon } from "../start-daemon";
|
|
2
3
|
import {
|
|
3
4
|
CONFIG_FILE,
|
|
4
5
|
DEFAULT_CONFIG,
|
|
5
|
-
LOGS_DIR,
|
|
6
6
|
type RoutstrdConfig,
|
|
7
7
|
} from "./config";
|
|
8
8
|
import {
|
|
@@ -115,31 +115,11 @@ export function getNpubSuffix(config: RoutstrdConfig): string | null {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
export async function startDaemonProcess(): Promise<void> {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const proc = Bun.spawn(
|
|
124
|
-
["bun", "run", `${import.meta.dir}/../daemon/index.ts`],
|
|
125
|
-
{
|
|
126
|
-
stdout: "inherit",
|
|
127
|
-
stderr: "inherit",
|
|
128
|
-
stdin: "ignore",
|
|
129
|
-
detached: true,
|
|
130
|
-
},
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
proc.unref();
|
|
134
|
-
|
|
135
|
-
for (let i = 0; i < 50; i++) {
|
|
136
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
137
|
-
if (await isDaemonRunning()) {
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
throw new Error("Daemon failed to start within 5 seconds");
|
|
118
|
+
const config = await loadConfig();
|
|
119
|
+
await startDaemon({
|
|
120
|
+
port: String(config.port || 8008),
|
|
121
|
+
provider: config.provider || undefined,
|
|
122
|
+
});
|
|
143
123
|
}
|
|
144
124
|
|
|
145
125
|
export async function ensureDaemonRunning(): Promise<void> {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
|
|
5
|
+
export interface CrossProcessLockOptions {
|
|
6
|
+
/** How long to wait while another process holds the lock. */
|
|
7
|
+
acquireTimeoutMs?: number;
|
|
8
|
+
/** How often to retry acquiring the lock. */
|
|
9
|
+
retryIntervalMs?: number;
|
|
10
|
+
/** Treat locks older than this as stale even if their PID cannot be checked. */
|
|
11
|
+
staleAfterMs?: number;
|
|
12
|
+
/** Optional logger used when removing stale locks. */
|
|
13
|
+
log?: (message: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface LockOwner {
|
|
17
|
+
pid: number;
|
|
18
|
+
createdAt: number;
|
|
19
|
+
token?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function delay(ms: number): Promise<void> {
|
|
23
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isProcessRunning(pid: number): boolean {
|
|
27
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
36
|
+
return code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readLockOwner(lockDir: string): Promise<LockOwner | null> {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await readFile(`${lockDir}/owner.json`, "utf8");
|
|
43
|
+
const parsed = JSON.parse(raw) as Partial<LockOwner>;
|
|
44
|
+
if (
|
|
45
|
+
typeof parsed.pid === "number" &&
|
|
46
|
+
typeof parsed.createdAt === "number"
|
|
47
|
+
) {
|
|
48
|
+
return {
|
|
49
|
+
pid: parsed.pid,
|
|
50
|
+
createdAt: parsed.createdAt,
|
|
51
|
+
token: typeof parsed.token === "string" ? parsed.token : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// The lock may have been created but not fully written yet.
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function isLockStale(
|
|
61
|
+
lockDir: string,
|
|
62
|
+
staleAfterMs: number,
|
|
63
|
+
): Promise<boolean> {
|
|
64
|
+
const owner = await readLockOwner(lockDir);
|
|
65
|
+
if (owner) {
|
|
66
|
+
return !isProcessRunning(owner.pid) || Date.now() - owner.createdAt > staleAfterMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const info = await stat(lockDir);
|
|
71
|
+
return Date.now() - info.mtimeMs > staleAfterMs;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function acquireCrossProcessLock(
|
|
78
|
+
lockDir: string,
|
|
79
|
+
options: CrossProcessLockOptions = {},
|
|
80
|
+
): Promise<() => Promise<void>> {
|
|
81
|
+
const acquireTimeoutMs = options.acquireTimeoutMs ?? 120_000;
|
|
82
|
+
const retryIntervalMs = options.retryIntervalMs ?? 100;
|
|
83
|
+
const staleAfterMs = options.staleAfterMs ?? 120_000;
|
|
84
|
+
const deadline = Date.now() + acquireTimeoutMs;
|
|
85
|
+
|
|
86
|
+
await mkdir(dirname(lockDir), { recursive: true });
|
|
87
|
+
|
|
88
|
+
while (true) {
|
|
89
|
+
try {
|
|
90
|
+
await mkdir(lockDir);
|
|
91
|
+
const token = randomUUID();
|
|
92
|
+
const owner: LockOwner = { pid: process.pid, createdAt: Date.now(), token };
|
|
93
|
+
await writeFile(`${lockDir}/owner.json`, JSON.stringify(owner), "utf8");
|
|
94
|
+
let released = false;
|
|
95
|
+
return async () => {
|
|
96
|
+
if (released) return;
|
|
97
|
+
released = true;
|
|
98
|
+
|
|
99
|
+
const currentOwner = await readLockOwner(lockDir);
|
|
100
|
+
if (currentOwner?.token === token) {
|
|
101
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
106
|
+
if (code !== "EEXIST") {
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (await isLockStale(lockDir, staleAfterMs)) {
|
|
111
|
+
options.log?.(`Removing stale lock at ${lockDir}`);
|
|
112
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Date.now() >= deadline) {
|
|
117
|
+
throw new Error(`Timed out waiting to acquire lock ${lockDir}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await delay(retryIntervalMs);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function withCrossProcessLock<T>(
|
|
126
|
+
lockDir: string,
|
|
127
|
+
fn: () => Promise<T>,
|
|
128
|
+
options: CrossProcessLockOptions = {},
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
const release = await acquireCrossProcessLock(lockDir, options);
|
|
131
|
+
try {
|
|
132
|
+
return await fn();
|
|
133
|
+
} finally {
|
|
134
|
+
await release();
|
|
135
|
+
}
|
|
136
|
+
}
|