@botcord/daemon 0.2.75 → 0.2.77
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/dist/cloud-auth.d.ts +47 -0
- package/dist/cloud-auth.js +51 -0
- package/dist/cloud-daemon.d.ts +43 -0
- package/dist/cloud-daemon.js +252 -0
- package/dist/cloud-mode.d.ts +45 -0
- package/dist/cloud-mode.js +55 -0
- package/dist/cloud-settle.d.ts +81 -0
- package/dist/cloud-settle.js +100 -0
- package/dist/daemon-singleton.d.ts +26 -0
- package/dist/daemon-singleton.js +91 -0
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.js +15 -6
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +15 -4
- package/dist/gateway/channels/botcord.d.ts +1 -1
- package/dist/gateway/channels/botcord.js +280 -52
- package/dist/gateway/dispatcher.d.ts +34 -1
- package/dist/gateway/dispatcher.js +277 -20
- package/dist/gateway/gateway.d.ts +9 -1
- package/dist/gateway/gateway.js +4 -1
- package/dist/gateway/runtime-errors.d.ts +6 -0
- package/dist/gateway/runtime-errors.js +14 -0
- package/dist/gateway/runtimes/claude-code.d.ts +8 -0
- package/dist/gateway/runtimes/claude-code.js +92 -4
- package/dist/gateway/runtimes/deepseek-tui.js +19 -5
- package/dist/gateway/transcript.d.ts +1 -1
- package/dist/gateway/types.d.ts +33 -0
- package/dist/index.js +71 -80
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +39 -1
- package/dist/status-render.js +17 -0
- package/package.json +2 -2
- package/src/__tests__/cloud-auth.test.ts +42 -0
- package/src/__tests__/cloud-daemon.test.ts +237 -0
- package/src/__tests__/cloud-mode.test.ts +65 -0
- package/src/__tests__/cloud-settle.test.ts +287 -0
- package/src/__tests__/daemon-singleton.test.ts +89 -0
- package/src/__tests__/doctor.test.ts +34 -0
- package/src/__tests__/runtime-discovery.test.ts +90 -0
- package/src/__tests__/status-render.test.ts +34 -0
- package/src/cloud-auth.ts +78 -0
- package/src/cloud-daemon.ts +338 -0
- package/src/cloud-mode.ts +70 -0
- package/src/cloud-settle.ts +182 -0
- package/src/daemon-singleton.ts +122 -0
- package/src/daemon.ts +18 -5
- package/src/doctor.ts +18 -5
- package/src/gateway/__tests__/botcord-channel.test.ts +98 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +101 -1
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +19 -0
- package/src/gateway/__tests__/dispatcher.test.ts +120 -0
- package/src/gateway/channels/botcord.ts +299 -43
- package/src/gateway/dispatcher.ts +354 -21
- package/src/gateway/gateway.ts +16 -1
- package/src/gateway/runtime-errors.ts +15 -0
- package/src/gateway/runtimes/claude-code.ts +98 -2
- package/src/gateway/runtimes/deepseek-tui.ts +23 -5
- package/src/gateway/transcript.ts +1 -1
- package/src/gateway/types.ts +34 -0
- package/src/index.ts +83 -74
- package/src/provision.ts +45 -1
- package/src/status-render.ts +24 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for {@link startCloudDaemon} — verifies wiring:
|
|
3
|
+
*
|
|
4
|
+
* - the control channel uses the `/cloud/daemon/ws` path
|
|
5
|
+
* - it authenticates with the env-injected JWT (not on-disk user-auth)
|
|
6
|
+
* - the provisioner is constructed and reachable via the channel's handler
|
|
7
|
+
* - shutdown is idempotent
|
|
8
|
+
*
|
|
9
|
+
* Per-frame `provision_agent` / `revoke_agent` semantics live in
|
|
10
|
+
* `provision.test.ts` — the cloud daemon reuses the same provisioner.
|
|
11
|
+
*/
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
import type WebSocket from "ws";
|
|
18
|
+
import { startCloudDaemon } from "../cloud-daemon.js";
|
|
19
|
+
import type { CloudModeConfig } from "../cloud-mode.js";
|
|
20
|
+
import { ControlChannel } from "../control-channel.js";
|
|
21
|
+
import type { DaemonConfig } from "../config.js";
|
|
22
|
+
import type { Gateway, GatewayChannelConfig } from "../gateway/index.js";
|
|
23
|
+
|
|
24
|
+
class FakeWebSocket extends EventEmitter {
|
|
25
|
+
public readyState = 0;
|
|
26
|
+
public sent: string[] = [];
|
|
27
|
+
public closed = false;
|
|
28
|
+
static OPEN = 1;
|
|
29
|
+
constructor(public url: string, public opts: { headers?: Record<string, string> } = {}) {
|
|
30
|
+
super();
|
|
31
|
+
setImmediate(() => {
|
|
32
|
+
this.readyState = FakeWebSocket.OPEN;
|
|
33
|
+
this.emit("open");
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
send(data: string): void {
|
|
37
|
+
this.sent.push(data);
|
|
38
|
+
}
|
|
39
|
+
ping(): void {
|
|
40
|
+
/* noop */
|
|
41
|
+
}
|
|
42
|
+
close(): void {
|
|
43
|
+
this.closed = true;
|
|
44
|
+
this.emit("close", 1000, Buffer.from("test"));
|
|
45
|
+
}
|
|
46
|
+
static readonly instances: FakeWebSocket[] = [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeFakeCtor() {
|
|
50
|
+
function Ctor(url: string, opts: { headers?: Record<string, string> } = {}) {
|
|
51
|
+
const ws = new FakeWebSocket(url, opts);
|
|
52
|
+
FakeWebSocket.instances.push(ws);
|
|
53
|
+
return ws;
|
|
54
|
+
}
|
|
55
|
+
(Ctor as unknown as { OPEN: number }).OPEN = FakeWebSocket.OPEN;
|
|
56
|
+
return Ctor as unknown as typeof WebSocket;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeCfg(): CloudModeConfig {
|
|
60
|
+
return {
|
|
61
|
+
// Loopback host is required by `normalizeAndValidateHubUrl` for http://.
|
|
62
|
+
// Production cloud daemons use https://api.botcord.chat.
|
|
63
|
+
hubUrl: "http://localhost:9000",
|
|
64
|
+
cloudDaemonInstanceId: "cloud_dm_abc123",
|
|
65
|
+
daemonInstanceId: "dm_abc123",
|
|
66
|
+
accessToken: "tok_jwt_42",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makeDaemonCfg(): DaemonConfig {
|
|
71
|
+
return {
|
|
72
|
+
defaultRoute: { adapter: "deepseek-tui", cwd: os.homedir() },
|
|
73
|
+
routes: [],
|
|
74
|
+
streamBlocks: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("startCloudDaemon", () => {
|
|
79
|
+
let tmpDir: string;
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
FakeWebSocket.instances.length = 0;
|
|
83
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "cloud-daemon-test-"));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("dials /cloud/daemon/ws with the injected access token", async () => {
|
|
91
|
+
const ctor = makeFakeCtor();
|
|
92
|
+
// Inject a ControlChannel subclass that passes our fake WS through.
|
|
93
|
+
class TestControlChannel extends ControlChannel {
|
|
94
|
+
constructor(opts: ConstructorParameters<typeof ControlChannel>[0]) {
|
|
95
|
+
super({
|
|
96
|
+
...opts,
|
|
97
|
+
webSocketCtor: ctor,
|
|
98
|
+
hubPublicKey: null,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const handle = await startCloudDaemon({
|
|
104
|
+
cloudConfig: makeCfg(),
|
|
105
|
+
config: makeDaemonCfg(),
|
|
106
|
+
configPath: "(cloud-mode)",
|
|
107
|
+
controlChannelFactory: TestControlChannel as unknown as typeof ControlChannel,
|
|
108
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
109
|
+
snapshotPath: path.join(tmpDir, "snapshot.json"),
|
|
110
|
+
snapshotIntervalMs: 60_000,
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
// The control channel connects asynchronously; let microtasks flush.
|
|
114
|
+
await new Promise((r) => setImmediate(r));
|
|
115
|
+
await new Promise((r) => setImmediate(r));
|
|
116
|
+
expect(FakeWebSocket.instances.length).toBeGreaterThanOrEqual(1);
|
|
117
|
+
const ws = FakeWebSocket.instances[0]!;
|
|
118
|
+
expect(ws.url).toBe("ws://localhost:9000/cloud/daemon/ws?label=cloud%3Acloud_dm_abc123");
|
|
119
|
+
expect(ws.opts.headers?.Authorization).toBe("Bearer tok_jwt_42");
|
|
120
|
+
} finally {
|
|
121
|
+
await handle.stop("test");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("skips the control channel when disableControlChannel=true", async () => {
|
|
126
|
+
const handle = await startCloudDaemon({
|
|
127
|
+
cloudConfig: makeCfg(),
|
|
128
|
+
config: makeDaemonCfg(),
|
|
129
|
+
configPath: "(cloud-mode)",
|
|
130
|
+
disableControlChannel: true,
|
|
131
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
132
|
+
snapshotPath: path.join(tmpDir, "snapshot.json"),
|
|
133
|
+
snapshotIntervalMs: 60_000,
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
await new Promise((r) => setImmediate(r));
|
|
137
|
+
expect(FakeWebSocket.instances).toHaveLength(0);
|
|
138
|
+
// Gateway should still be up — snapshot returns a sane object.
|
|
139
|
+
const snap = handle.snapshot();
|
|
140
|
+
expect(snap.channels).toEqual({});
|
|
141
|
+
} finally {
|
|
142
|
+
await handle.stop("test");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("uses the provided provisioner factory", async () => {
|
|
147
|
+
const provisionerSpy = vi.fn();
|
|
148
|
+
const factorySpy = vi.fn(() => provisionerSpy);
|
|
149
|
+
const ctor = makeFakeCtor();
|
|
150
|
+
class TestControlChannel extends ControlChannel {
|
|
151
|
+
constructor(opts: ConstructorParameters<typeof ControlChannel>[0]) {
|
|
152
|
+
super({ ...opts, webSocketCtor: ctor, hubPublicKey: null });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const handle = await startCloudDaemon({
|
|
156
|
+
cloudConfig: makeCfg(),
|
|
157
|
+
config: makeDaemonCfg(),
|
|
158
|
+
configPath: "(cloud-mode)",
|
|
159
|
+
controlChannelFactory: TestControlChannel as unknown as typeof ControlChannel,
|
|
160
|
+
provisionerFactory: factorySpy as unknown as typeof import("../provision.js").createProvisioner,
|
|
161
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
162
|
+
snapshotPath: path.join(tmpDir, "snapshot.json"),
|
|
163
|
+
snapshotIntervalMs: 60_000,
|
|
164
|
+
});
|
|
165
|
+
try {
|
|
166
|
+
expect(factorySpy).toHaveBeenCalledOnce();
|
|
167
|
+
const callArgs = factorySpy.mock.calls[0]![0] as Record<string, unknown>;
|
|
168
|
+
expect(callArgs.gateway).toBeDefined();
|
|
169
|
+
expect(callArgs.onAgentInstalled).toBeInstanceOf(Function);
|
|
170
|
+
expect(callArgs.policyResolver).toBeDefined();
|
|
171
|
+
} finally {
|
|
172
|
+
await handle.stop("test");
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it.each(["telegram", "wechat", "feishu"] as const)(
|
|
177
|
+
"allows %s gateway channels to be hot-plugged in cloud mode",
|
|
178
|
+
async (type) => {
|
|
179
|
+
let gateway: Pick<Gateway, "addChannel"> | undefined;
|
|
180
|
+
const ctor = makeFakeCtor();
|
|
181
|
+
class TestControlChannel extends ControlChannel {
|
|
182
|
+
constructor(opts: ConstructorParameters<typeof ControlChannel>[0]) {
|
|
183
|
+
super({ ...opts, webSocketCtor: ctor, hubPublicKey: null });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const handle = await startCloudDaemon({
|
|
187
|
+
cloudConfig: makeCfg(),
|
|
188
|
+
config: makeDaemonCfg(),
|
|
189
|
+
configPath: "(cloud-mode)",
|
|
190
|
+
controlChannelFactory: TestControlChannel as unknown as typeof ControlChannel,
|
|
191
|
+
provisionerFactory: ((args: { gateway: Gateway }) => {
|
|
192
|
+
gateway = args.gateway;
|
|
193
|
+
return vi.fn();
|
|
194
|
+
}) as unknown as typeof import("../provision.js").createProvisioner,
|
|
195
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
196
|
+
snapshotPath: path.join(tmpDir, "snapshot.json"),
|
|
197
|
+
snapshotIntervalMs: 60_000,
|
|
198
|
+
});
|
|
199
|
+
try {
|
|
200
|
+
expect(gateway).toBeDefined();
|
|
201
|
+
const cfg: GatewayChannelConfig = {
|
|
202
|
+
id: `gw_${type}_cloud`,
|
|
203
|
+
type,
|
|
204
|
+
accountId: "ag_cloud",
|
|
205
|
+
allowedSenderIds: [type === "telegram" ? "42" : "alice"],
|
|
206
|
+
secretFile: path.join(tmpDir, `missing-${type}-secret.json`),
|
|
207
|
+
};
|
|
208
|
+
if (type !== "wechat") {
|
|
209
|
+
cfg.allowedChatIds = ["111"];
|
|
210
|
+
}
|
|
211
|
+
if (type === "feishu") {
|
|
212
|
+
cfg.appId = "cli_test";
|
|
213
|
+
}
|
|
214
|
+
await expect(
|
|
215
|
+
gateway!.addChannel(cfg),
|
|
216
|
+
).resolves.toBeUndefined();
|
|
217
|
+
} finally {
|
|
218
|
+
await handle.stop("test");
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
it("stop() is idempotent", async () => {
|
|
224
|
+
const handle = await startCloudDaemon({
|
|
225
|
+
cloudConfig: makeCfg(),
|
|
226
|
+
config: makeDaemonCfg(),
|
|
227
|
+
configPath: "(cloud-mode)",
|
|
228
|
+
disableControlChannel: true,
|
|
229
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
230
|
+
snapshotPath: path.join(tmpDir, "snapshot.json"),
|
|
231
|
+
snapshotIntervalMs: 60_000,
|
|
232
|
+
});
|
|
233
|
+
await handle.stop("first");
|
|
234
|
+
await handle.stop("second");
|
|
235
|
+
// No throw is the assertion.
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
CLOUD_ENV_VARS,
|
|
4
|
+
isCloudMode,
|
|
5
|
+
loadCloudModeConfig,
|
|
6
|
+
} from "../cloud-mode.js";
|
|
7
|
+
|
|
8
|
+
function makeEnv(overrides: Record<string, string | undefined> = {}): NodeJS.ProcessEnv {
|
|
9
|
+
return {
|
|
10
|
+
[CLOUD_ENV_VARS.HUB_URL]: "https://api.botcord.chat",
|
|
11
|
+
[CLOUD_ENV_VARS.CLOUD_DAEMON_INSTANCE_ID]: "cloud_dm_abc123def456",
|
|
12
|
+
[CLOUD_ENV_VARS.DAEMON_INSTANCE_ID]: "dm_abc123def456",
|
|
13
|
+
[CLOUD_ENV_VARS.ACCESS_TOKEN]: "tok_jwt_xxx",
|
|
14
|
+
...overrides,
|
|
15
|
+
} as NodeJS.ProcessEnv;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("cloud-mode detection", () => {
|
|
19
|
+
it("returns true when BOTCORD_CLOUD_DAEMON_ACCESS_TOKEN is set", () => {
|
|
20
|
+
expect(isCloudMode(makeEnv())).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns false when the access token is missing", () => {
|
|
24
|
+
expect(isCloudMode(makeEnv({ [CLOUD_ENV_VARS.ACCESS_TOKEN]: undefined }))).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns false when the access token is empty", () => {
|
|
28
|
+
expect(isCloudMode(makeEnv({ [CLOUD_ENV_VARS.ACCESS_TOKEN]: "" }))).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("treats missing other env vars as still cloud-mode (will fail at load)", () => {
|
|
32
|
+
// We only flip on the token; loadCloudModeConfig is what hard-fails. This
|
|
33
|
+
// keeps the detection check cheap and the failure message specific.
|
|
34
|
+
expect(
|
|
35
|
+
isCloudMode(makeEnv({ [CLOUD_ENV_VARS.HUB_URL]: undefined })),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("loadCloudModeConfig", () => {
|
|
41
|
+
it("parses all four env vars into the resolved shape", () => {
|
|
42
|
+
const cfg = loadCloudModeConfig(makeEnv());
|
|
43
|
+
expect(cfg).toEqual({
|
|
44
|
+
hubUrl: "https://api.botcord.chat",
|
|
45
|
+
cloudDaemonInstanceId: "cloud_dm_abc123def456",
|
|
46
|
+
daemonInstanceId: "dm_abc123def456",
|
|
47
|
+
accessToken: "tok_jwt_xxx",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it.each(Object.values(CLOUD_ENV_VARS))(
|
|
52
|
+
"throws when %s is missing",
|
|
53
|
+
(name) => {
|
|
54
|
+
expect(() => loadCloudModeConfig(makeEnv({ [name]: undefined }))).toThrow(
|
|
55
|
+
new RegExp(`required env var "${name}"`),
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
it("throws when an env var is set to an empty string", () => {
|
|
61
|
+
expect(() =>
|
|
62
|
+
loadCloudModeConfig(makeEnv({ [CLOUD_ENV_VARS.HUB_URL]: "" })),
|
|
63
|
+
).toThrow(/required env var "BOTCORD_HUB_URL"/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildCloudRunSettleHook, postCloudRunSettle } from "../cloud-settle.js";
|
|
3
|
+
|
|
4
|
+
interface CapturedRequest {
|
|
5
|
+
url: string;
|
|
6
|
+
method: string;
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
body: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeFakeFetch(
|
|
12
|
+
response: { status: number; body?: unknown } = { status: 200, body: { ok: true } },
|
|
13
|
+
): { fetchFn: typeof fetch; calls: CapturedRequest[] } {
|
|
14
|
+
const calls: CapturedRequest[] = [];
|
|
15
|
+
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
16
|
+
const body = init?.body ? JSON.parse(init.body as string) : undefined;
|
|
17
|
+
calls.push({
|
|
18
|
+
url: String(url),
|
|
19
|
+
method: init?.method ?? "GET",
|
|
20
|
+
headers: Object.fromEntries(
|
|
21
|
+
Object.entries(init?.headers ?? {}).map(([k, v]) => [k, String(v)]),
|
|
22
|
+
),
|
|
23
|
+
body,
|
|
24
|
+
});
|
|
25
|
+
return new Response(
|
|
26
|
+
response.body !== undefined ? JSON.stringify(response.body) : "",
|
|
27
|
+
{
|
|
28
|
+
status: response.status,
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
}) as unknown as typeof fetch;
|
|
33
|
+
return { fetchFn, calls };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("postCloudRunSettle", () => {
|
|
37
|
+
it("POSTs the expected body to the settle endpoint", async () => {
|
|
38
|
+
const { fetchFn, calls } = makeFakeFetch();
|
|
39
|
+
const result = await postCloudRunSettle({
|
|
40
|
+
hubUrl: "https://api.botcord.chat",
|
|
41
|
+
accessToken: "tok_jwt_xxx",
|
|
42
|
+
runId: "crun_abc123",
|
|
43
|
+
provider: "deepseek",
|
|
44
|
+
model: "deepseek-chat",
|
|
45
|
+
inputCacheHitTokens: 100,
|
|
46
|
+
inputCacheMissTokens: 50,
|
|
47
|
+
outputTokens: 200,
|
|
48
|
+
sandboxSeconds: 42,
|
|
49
|
+
fetchFn,
|
|
50
|
+
});
|
|
51
|
+
expect(result.ok).toBe(true);
|
|
52
|
+
expect(result.status).toBe(200);
|
|
53
|
+
expect(calls).toHaveLength(1);
|
|
54
|
+
const call = calls[0]!;
|
|
55
|
+
expect(call.url).toBe(
|
|
56
|
+
"https://api.botcord.chat/internal/cloud-agents/runs/crun_abc123/settle",
|
|
57
|
+
);
|
|
58
|
+
expect(call.method).toBe("POST");
|
|
59
|
+
expect(call.headers.Authorization).toBe("Bearer tok_jwt_xxx");
|
|
60
|
+
expect(call.headers["Content-Type"]).toBe("application/json");
|
|
61
|
+
expect(call.body).toEqual({
|
|
62
|
+
provider: "deepseek",
|
|
63
|
+
model: "deepseek-chat",
|
|
64
|
+
input_cache_hit_tokens: 100,
|
|
65
|
+
input_cache_miss_tokens: 50,
|
|
66
|
+
output_tokens: 200,
|
|
67
|
+
sandbox_seconds: 42,
|
|
68
|
+
idempotency_key: "crun_abc123:settle",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("uses the supplied idempotency_key when provided", async () => {
|
|
73
|
+
const { fetchFn, calls } = makeFakeFetch();
|
|
74
|
+
await postCloudRunSettle({
|
|
75
|
+
hubUrl: "https://api.botcord.chat",
|
|
76
|
+
accessToken: "tok_x",
|
|
77
|
+
runId: "crun_x",
|
|
78
|
+
provider: "deepseek",
|
|
79
|
+
model: "deepseek-chat",
|
|
80
|
+
inputCacheHitTokens: 0,
|
|
81
|
+
inputCacheMissTokens: 0,
|
|
82
|
+
outputTokens: 0,
|
|
83
|
+
sandboxSeconds: 0,
|
|
84
|
+
idempotencyKey: "custom-key-1",
|
|
85
|
+
fetchFn,
|
|
86
|
+
});
|
|
87
|
+
expect((calls[0]!.body as { idempotency_key: string }).idempotency_key).toBe(
|
|
88
|
+
"custom-key-1",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("floors negative or fractional usage numbers", async () => {
|
|
93
|
+
const { fetchFn, calls } = makeFakeFetch();
|
|
94
|
+
await postCloudRunSettle({
|
|
95
|
+
hubUrl: "https://api.botcord.chat",
|
|
96
|
+
accessToken: "tok_x",
|
|
97
|
+
runId: "crun_x",
|
|
98
|
+
provider: "deepseek",
|
|
99
|
+
model: "deepseek-chat",
|
|
100
|
+
inputCacheHitTokens: -5,
|
|
101
|
+
inputCacheMissTokens: 1.9,
|
|
102
|
+
outputTokens: 100.5,
|
|
103
|
+
sandboxSeconds: 10.4,
|
|
104
|
+
fetchFn,
|
|
105
|
+
});
|
|
106
|
+
const body = calls[0]!.body as Record<string, number>;
|
|
107
|
+
expect(body.input_cache_hit_tokens).toBe(0);
|
|
108
|
+
expect(body.input_cache_miss_tokens).toBe(1);
|
|
109
|
+
expect(body.output_tokens).toBe(100);
|
|
110
|
+
expect(body.sandbox_seconds).toBe(10);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns ok=false on non-2xx without throwing", async () => {
|
|
114
|
+
const { fetchFn } = makeFakeFetch({
|
|
115
|
+
status: 401,
|
|
116
|
+
body: { detail: "unauthorized" },
|
|
117
|
+
});
|
|
118
|
+
const result = await postCloudRunSettle({
|
|
119
|
+
hubUrl: "https://api.botcord.chat",
|
|
120
|
+
accessToken: "bad",
|
|
121
|
+
runId: "crun_x",
|
|
122
|
+
provider: "deepseek",
|
|
123
|
+
model: "deepseek-chat",
|
|
124
|
+
inputCacheHitTokens: 0,
|
|
125
|
+
inputCacheMissTokens: 0,
|
|
126
|
+
outputTokens: 0,
|
|
127
|
+
sandboxSeconds: 0,
|
|
128
|
+
fetchFn,
|
|
129
|
+
});
|
|
130
|
+
expect(result.ok).toBe(false);
|
|
131
|
+
expect(result.status).toBe(401);
|
|
132
|
+
expect(result.body).toEqual({ detail: "unauthorized" });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("URL-encodes the run id (defensive — Hub uses crun_ prefix today)", async () => {
|
|
136
|
+
const { fetchFn, calls } = makeFakeFetch();
|
|
137
|
+
await postCloudRunSettle({
|
|
138
|
+
hubUrl: "https://api.botcord.chat",
|
|
139
|
+
accessToken: "t",
|
|
140
|
+
runId: "crun_abc/../etc",
|
|
141
|
+
provider: "deepseek",
|
|
142
|
+
model: "deepseek-chat",
|
|
143
|
+
inputCacheHitTokens: 0,
|
|
144
|
+
inputCacheMissTokens: 0,
|
|
145
|
+
outputTokens: 0,
|
|
146
|
+
sandboxSeconds: 0,
|
|
147
|
+
fetchFn,
|
|
148
|
+
});
|
|
149
|
+
expect(calls[0]!.url).toContain("crun_abc%2F..%2Fetc");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
describe("buildCloudRunSettleHook", () => {
|
|
155
|
+
function makeLogger() {
|
|
156
|
+
return {
|
|
157
|
+
info: vi.fn(),
|
|
158
|
+
warn: vi.fn(),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
it("skips non-cloud_run envelopes", async () => {
|
|
163
|
+
const { fetchFn, calls } = makeFakeFetch();
|
|
164
|
+
const hook = buildCloudRunSettleHook({
|
|
165
|
+
hubUrl: "http://localhost:9000",
|
|
166
|
+
accessToken: "tok",
|
|
167
|
+
fetchFn,
|
|
168
|
+
});
|
|
169
|
+
await hook({
|
|
170
|
+
envelopeType: "message",
|
|
171
|
+
runId: "crun_skipme",
|
|
172
|
+
wallTimeMs: 1000,
|
|
173
|
+
});
|
|
174
|
+
expect(calls).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("warns and skips when run_id is missing", async () => {
|
|
178
|
+
const { fetchFn, calls } = makeFakeFetch();
|
|
179
|
+
const log = makeLogger();
|
|
180
|
+
const hook = buildCloudRunSettleHook({
|
|
181
|
+
hubUrl: "http://localhost:9000",
|
|
182
|
+
accessToken: "tok",
|
|
183
|
+
fetchFn,
|
|
184
|
+
log,
|
|
185
|
+
});
|
|
186
|
+
await hook({
|
|
187
|
+
envelopeType: "cloud_run",
|
|
188
|
+
wallTimeMs: 1000,
|
|
189
|
+
messageId: "h_xyz",
|
|
190
|
+
});
|
|
191
|
+
expect(calls).toEqual([]);
|
|
192
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
193
|
+
"cloud_run envelope missing run_id; skipping settle",
|
|
194
|
+
expect.objectContaining({ messageId: "h_xyz" }),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("POSTs settle with sandbox_seconds rounded from wallTimeMs", async () => {
|
|
199
|
+
const { fetchFn, calls } = makeFakeFetch();
|
|
200
|
+
const log = makeLogger();
|
|
201
|
+
const hook = buildCloudRunSettleHook({
|
|
202
|
+
hubUrl: "http://localhost:9000",
|
|
203
|
+
accessToken: "tok_xyz",
|
|
204
|
+
fetchFn,
|
|
205
|
+
log,
|
|
206
|
+
});
|
|
207
|
+
await hook({
|
|
208
|
+
envelopeType: "cloud_run",
|
|
209
|
+
runId: "crun_happy",
|
|
210
|
+
wallTimeMs: 45_321,
|
|
211
|
+
tokens: { outputTokens: 800 },
|
|
212
|
+
});
|
|
213
|
+
expect(calls).toHaveLength(1);
|
|
214
|
+
expect(calls[0]!.url).toContain("/internal/cloud-agents/runs/crun_happy/settle");
|
|
215
|
+
expect(calls[0]!.headers.Authorization).toBe("Bearer tok_xyz");
|
|
216
|
+
expect(calls[0]!.body).toMatchObject({
|
|
217
|
+
provider: "deepseek",
|
|
218
|
+
model: "deepseek-v4-flash",
|
|
219
|
+
output_tokens: 800,
|
|
220
|
+
sandbox_seconds: 45,
|
|
221
|
+
idempotency_key: "crun_happy:settle",
|
|
222
|
+
});
|
|
223
|
+
expect(log.info).toHaveBeenCalledWith(
|
|
224
|
+
"cloud_run settled",
|
|
225
|
+
expect.objectContaining({ runId: "crun_happy", sandboxSeconds: 45 }),
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("floors sandbox_seconds at 1 even for sub-second runs", async () => {
|
|
230
|
+
const { fetchFn, calls } = makeFakeFetch();
|
|
231
|
+
const hook = buildCloudRunSettleHook({
|
|
232
|
+
hubUrl: "http://localhost:9000",
|
|
233
|
+
accessToken: "tok",
|
|
234
|
+
fetchFn,
|
|
235
|
+
});
|
|
236
|
+
await hook({
|
|
237
|
+
envelopeType: "cloud_run",
|
|
238
|
+
runId: "crun_quick",
|
|
239
|
+
wallTimeMs: 12,
|
|
240
|
+
});
|
|
241
|
+
expect(calls[0]!.body).toMatchObject({ sandbox_seconds: 1 });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("warns on non-2xx settle responses without throwing", async () => {
|
|
245
|
+
const { fetchFn } = makeFakeFetch({ status: 409, body: { code: "x" } });
|
|
246
|
+
const log = makeLogger();
|
|
247
|
+
const hook = buildCloudRunSettleHook({
|
|
248
|
+
hubUrl: "http://localhost:9000",
|
|
249
|
+
accessToken: "tok",
|
|
250
|
+
fetchFn,
|
|
251
|
+
log,
|
|
252
|
+
});
|
|
253
|
+
await hook({
|
|
254
|
+
envelopeType: "cloud_run",
|
|
255
|
+
runId: "crun_409",
|
|
256
|
+
wallTimeMs: 1000,
|
|
257
|
+
});
|
|
258
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
259
|
+
"cloud_run settle returned non-2xx",
|
|
260
|
+
expect.objectContaining({ runId: "crun_409", status: 409 }),
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("warns on transport errors without throwing", async () => {
|
|
265
|
+
const fetchFn = (async () => {
|
|
266
|
+
throw new TypeError("connect ECONNREFUSED");
|
|
267
|
+
}) as unknown as typeof fetch;
|
|
268
|
+
const log = makeLogger();
|
|
269
|
+
const hook = buildCloudRunSettleHook({
|
|
270
|
+
hubUrl: "http://localhost:9000",
|
|
271
|
+
accessToken: "tok",
|
|
272
|
+
fetchFn,
|
|
273
|
+
log,
|
|
274
|
+
});
|
|
275
|
+
await expect(
|
|
276
|
+
hook({
|
|
277
|
+
envelopeType: "cloud_run",
|
|
278
|
+
runId: "crun_dead",
|
|
279
|
+
wallTimeMs: 1000,
|
|
280
|
+
}),
|
|
281
|
+
).resolves.toBeUndefined();
|
|
282
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
283
|
+
"cloud_run settle threw — continuing",
|
|
284
|
+
expect.objectContaining({ runId: "crun_dead" }),
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
ensureNoOtherDaemonFromPidFile,
|
|
8
|
+
readPid,
|
|
9
|
+
removePidFile,
|
|
10
|
+
stopDaemonFromPidFileForRestart,
|
|
11
|
+
writeCurrentPid,
|
|
12
|
+
} from "../daemon-singleton.js";
|
|
13
|
+
|
|
14
|
+
describe("daemon singleton pid helpers", () => {
|
|
15
|
+
let tmpDir: string;
|
|
16
|
+
let children: ChildProcess[];
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "botcord-singleton-test-"));
|
|
20
|
+
children = [];
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const child of children) {
|
|
25
|
+
if (child.pid) {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(child.pid, "SIGKILL");
|
|
28
|
+
} catch {
|
|
29
|
+
// ignore
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("writes and reads the current pid", () => {
|
|
37
|
+
const pidPath = path.join(tmpDir, "daemon.pid");
|
|
38
|
+
|
|
39
|
+
writeCurrentPid({ pidPath, currentPid: 12345 });
|
|
40
|
+
|
|
41
|
+
expect(readPid(pidPath)).toBe(12345);
|
|
42
|
+
expect(readFileSync(pidPath, "utf8")).toBe("12345");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not report the current process as another daemon", () => {
|
|
46
|
+
const pidPath = path.join(tmpDir, "daemon.pid");
|
|
47
|
+
writeCurrentPid({ pidPath, currentPid: process.pid });
|
|
48
|
+
|
|
49
|
+
expect(ensureNoOtherDaemonFromPidFile({ pidPath, currentPid: process.pid })).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("terminates the daemon recorded in the pid file before restart", async () => {
|
|
53
|
+
const pidPath = path.join(tmpDir, "daemon.pid");
|
|
54
|
+
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
|
55
|
+
stdio: "ignore",
|
|
56
|
+
});
|
|
57
|
+
children.push(child);
|
|
58
|
+
await waitForPid(child);
|
|
59
|
+
writeCurrentPid({ pidPath, currentPid: child.pid! });
|
|
60
|
+
|
|
61
|
+
await stopDaemonFromPidFileForRestart({ pidPath, currentPid: process.pid });
|
|
62
|
+
|
|
63
|
+
expect(existsSync(pidPath)).toBe(false);
|
|
64
|
+
await waitForExit(child);
|
|
65
|
+
expect(child.exitCode === null && child.signalCode === null).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("removes stale pid files", () => {
|
|
69
|
+
const pidPath = path.join(tmpDir, "daemon.pid");
|
|
70
|
+
writeCurrentPid({ pidPath, currentPid: 99999999 });
|
|
71
|
+
|
|
72
|
+
removePidFile(pidPath);
|
|
73
|
+
|
|
74
|
+
expect(readPid(pidPath)).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
async function waitForPid(child: ChildProcess): Promise<void> {
|
|
79
|
+
const deadline = Date.now() + 2_000;
|
|
80
|
+
while (!child.pid && Date.now() < deadline) {
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
82
|
+
}
|
|
83
|
+
if (!child.pid) throw new Error("child pid was not assigned");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function waitForExit(child: ChildProcess): Promise<void> {
|
|
87
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
88
|
+
await new Promise<void>((resolve) => child.once("exit", () => resolve()));
|
|
89
|
+
}
|