@botcord/daemon 0.2.78 → 0.2.80
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/attention-policy-fetcher.d.ts +14 -0
- package/dist/attention-policy-fetcher.js +59 -0
- package/dist/cloud-daemon.js +8 -0
- package/dist/cloud-gateway-runtime.d.ts +29 -0
- package/dist/cloud-gateway-runtime.js +122 -0
- package/dist/daemon-singleton.d.ts +13 -0
- package/dist/daemon-singleton.js +68 -0
- package/dist/daemon.js +21 -6
- package/dist/gateway/channels/botcord.d.ts +1 -0
- package/dist/gateway/channels/botcord.js +62 -17
- package/dist/gateway/channels/login-session.d.ts +12 -0
- package/dist/gateway/channels/login-session.js +20 -2
- package/dist/gateway/channels/sanitize.d.ts +5 -18
- package/dist/gateway/channels/sanitize.js +5 -54
- package/dist/gateway/channels/text-split.d.ts +5 -11
- package/dist/gateway/channels/text-split.js +5 -31
- package/dist/gateway/dispatcher.d.ts +7 -1
- package/dist/gateway/dispatcher.js +88 -8
- package/dist/gateway/gateway.d.ts +16 -1
- package/dist/gateway/gateway.js +21 -0
- package/dist/gateway/policy-resolver.js +17 -9
- package/dist/gateway/runtimes/deepseek-tui.js +56 -13
- package/dist/gateway/types.d.ts +12 -57
- package/dist/gateway-control.js +18 -9
- package/dist/index.js +8 -3
- package/dist/provision.d.ts +7 -3
- package/dist/provision.js +115 -8
- package/dist/room-recovery-context.d.ts +11 -0
- package/dist/room-recovery-context.js +97 -0
- package/dist/status-render.d.ts +4 -0
- package/dist/status-render.js +14 -1
- package/package.json +2 -2
- package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
- package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
- package/src/__tests__/daemon-singleton.test.ts +32 -0
- package/src/__tests__/gateway-control.test.ts +136 -0
- package/src/__tests__/policy-resolver.test.ts +20 -0
- package/src/__tests__/provision.test.ts +65 -0
- package/src/__tests__/status-render.test.ts +23 -0
- package/src/attention-policy-fetcher.ts +87 -0
- package/src/cloud-daemon.ts +8 -0
- package/src/cloud-gateway-runtime.ts +171 -0
- package/src/daemon-singleton.ts +85 -0
- package/src/daemon.ts +23 -6
- package/src/gateway/__tests__/botcord-channel.test.ts +211 -5
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +263 -0
- package/src/gateway/__tests__/dispatcher.test.ts +56 -0
- package/src/gateway/channels/botcord.ts +69 -17
- package/src/gateway/channels/login-session.ts +20 -2
- package/src/gateway/channels/sanitize.ts +8 -66
- package/src/gateway/channels/text-split.ts +5 -27
- package/src/gateway/dispatcher.ts +123 -27
- package/src/gateway/gateway.ts +29 -0
- package/src/gateway/policy-resolver.ts +20 -9
- package/src/gateway/runtimes/deepseek-tui.ts +63 -13
- package/src/gateway/types.ts +31 -59
- package/src/gateway-control.ts +21 -9
- package/src/index.ts +9 -2
- package/src/provision.ts +133 -7
- package/src/room-recovery-context.ts +131 -0
- package/src/status-render.ts +14 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { RUNTIME_FRAME_TYPES, type GatewayInboundFrame } from "@botcord/protocol-core";
|
|
6
|
+
|
|
7
|
+
import { handleCloudGatewayRuntimeInbound } from "../cloud-gateway-runtime.js";
|
|
8
|
+
import { Gateway, type ChannelAdapter } from "../gateway/index.js";
|
|
9
|
+
|
|
10
|
+
describe("cloud gateway runtime inbound", () => {
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "cloud-gateway-runtime-"));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("injects a gateway_inbound frame and captures the runtime reply", async () => {
|
|
22
|
+
const gateway = new Gateway({
|
|
23
|
+
config: {
|
|
24
|
+
channels: [],
|
|
25
|
+
defaultRoute: { runtime: "fake", cwd: tmpDir },
|
|
26
|
+
},
|
|
27
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
28
|
+
createChannel: (cfg) => stubChannel(cfg.id, cfg.type, cfg.accountId),
|
|
29
|
+
createRuntime: () => ({
|
|
30
|
+
id: "fake",
|
|
31
|
+
async run() {
|
|
32
|
+
return { text: "hello from runtime", newSessionId: "sess_1" };
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
transcriptEnabled: false,
|
|
36
|
+
});
|
|
37
|
+
await gateway.start();
|
|
38
|
+
|
|
39
|
+
const frame: GatewayInboundFrame = {
|
|
40
|
+
type: RUNTIME_FRAME_TYPES.GATEWAY_INBOUND,
|
|
41
|
+
event_id: "evt_1",
|
|
42
|
+
gateway_id: "gw_tg_1",
|
|
43
|
+
agent_id: "ag_1",
|
|
44
|
+
provider: "telegram",
|
|
45
|
+
message: {
|
|
46
|
+
id: "telegram:1:2",
|
|
47
|
+
channel: "gw_tg_1",
|
|
48
|
+
accountId: "ag_1",
|
|
49
|
+
conversation: { id: "telegram:user:1", kind: "direct" },
|
|
50
|
+
sender: { id: "telegram:user:1", kind: "user" },
|
|
51
|
+
text: "hi",
|
|
52
|
+
replyTo: null,
|
|
53
|
+
mentioned: true,
|
|
54
|
+
receivedAt: Date.now(),
|
|
55
|
+
trace: { id: "telegram:1:2", streamable: false },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const result = await handleCloudGatewayRuntimeInbound(gateway, frame);
|
|
60
|
+
await gateway.stop("test");
|
|
61
|
+
|
|
62
|
+
expect(result.accepted).toBe(true);
|
|
63
|
+
expect(result.eventId).toBe("evt_1");
|
|
64
|
+
expect(result.gatewayId).toBe("gw_tg_1");
|
|
65
|
+
expect(result.conversationId).toBe("telegram:user:1");
|
|
66
|
+
expect(result.outbound?.finalText).toBe("hello from runtime");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects frames outside the token scope", async () => {
|
|
70
|
+
const gateway = new Gateway({
|
|
71
|
+
config: {
|
|
72
|
+
channels: [],
|
|
73
|
+
defaultRoute: { runtime: "fake", cwd: tmpDir },
|
|
74
|
+
},
|
|
75
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
76
|
+
createChannel: (cfg) => stubChannel(cfg.id, cfg.type, cfg.accountId),
|
|
77
|
+
createRuntime: () => ({
|
|
78
|
+
id: "fake",
|
|
79
|
+
async run() {
|
|
80
|
+
return { text: "unused", newSessionId: "sess_1" };
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
transcriptEnabled: false,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = await handleCloudGatewayRuntimeInbound(gateway, {
|
|
87
|
+
type: RUNTIME_FRAME_TYPES.GATEWAY_INBOUND,
|
|
88
|
+
event_id: "evt_bad",
|
|
89
|
+
gateway_id: "gw_tg_1",
|
|
90
|
+
agent_id: "ag_1",
|
|
91
|
+
provider: "telegram",
|
|
92
|
+
message: {
|
|
93
|
+
id: "telegram:1:2",
|
|
94
|
+
channel: "gw_other",
|
|
95
|
+
accountId: "ag_1",
|
|
96
|
+
conversation: { id: "telegram:user:1", kind: "direct" },
|
|
97
|
+
sender: { id: "telegram:user:1", kind: "user" },
|
|
98
|
+
text: "hi",
|
|
99
|
+
replyTo: null,
|
|
100
|
+
mentioned: true,
|
|
101
|
+
receivedAt: Date.now(),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.accepted).toBe(false);
|
|
106
|
+
expect(result.error?.code).toBe("channel_mismatch");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function stubChannel(id: string, type: string, accountId: string): ChannelAdapter {
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
type,
|
|
114
|
+
async start() {
|
|
115
|
+
return undefined;
|
|
116
|
+
},
|
|
117
|
+
async stop() {
|
|
118
|
+
return undefined;
|
|
119
|
+
},
|
|
120
|
+
async send() {
|
|
121
|
+
return {};
|
|
122
|
+
},
|
|
123
|
+
status() {
|
|
124
|
+
return { channel: id, accountId, running: true };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -5,8 +5,10 @@ import path from "node:path";
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
6
|
import {
|
|
7
7
|
ensureNoOtherDaemonFromPidFile,
|
|
8
|
+
parseDaemonProcesses,
|
|
8
9
|
readPid,
|
|
9
10
|
removePidFile,
|
|
11
|
+
stopOtherDaemonProcessesForRestart,
|
|
10
12
|
stopDaemonFromPidFileForRestart,
|
|
11
13
|
writeCurrentPid,
|
|
12
14
|
} from "../daemon-singleton.js";
|
|
@@ -73,6 +75,36 @@ describe("daemon singleton pid helpers", () => {
|
|
|
73
75
|
|
|
74
76
|
expect(readPid(pidPath)).toBeNull();
|
|
75
77
|
});
|
|
78
|
+
|
|
79
|
+
it("finds botcord daemon start commands in ps output", () => {
|
|
80
|
+
const out = parseDaemonProcesses(
|
|
81
|
+
[
|
|
82
|
+
" 111 node /Users/me/.botcord/daemon/node_modules/.bin/botcord-daemon start --foreground",
|
|
83
|
+
" 222 node /opt/botcord/daemon/dist/index.js start --foreground",
|
|
84
|
+
" 333 node /tmp/other.js",
|
|
85
|
+
` ${process.pid} node /Users/me/.botcord/daemon/node_modules/.bin/botcord-daemon start --foreground`,
|
|
86
|
+
].join("\n"),
|
|
87
|
+
process.pid,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(out.map((p) => p.pid)).toEqual([111, 222]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("terminates extra daemon processes discovered outside the pid file", async () => {
|
|
94
|
+
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
|
95
|
+
stdio: "ignore",
|
|
96
|
+
});
|
|
97
|
+
children.push(child);
|
|
98
|
+
await waitForPid(child);
|
|
99
|
+
|
|
100
|
+
await stopOtherDaemonProcessesForRestart({
|
|
101
|
+
currentPid: process.pid,
|
|
102
|
+
processes: [{ pid: child.pid!, command: "node /opt/botcord/daemon/dist/index.js start --foreground" }],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await waitForExit(child);
|
|
106
|
+
expect(child.exitCode === null && child.signalCode === null).toBe(false);
|
|
107
|
+
});
|
|
76
108
|
});
|
|
77
109
|
|
|
78
110
|
async function waitForPid(child: ChildProcess): Promise<void> {
|
|
@@ -733,3 +733,139 @@ describe("W4: handleLoginStatus accountId ownership check", () => {
|
|
|
733
733
|
expect(ack.error?.code).toBe("bad_params");
|
|
734
734
|
});
|
|
735
735
|
});
|
|
736
|
+
|
|
737
|
+
describe("login_missing vs login_expired", () => {
|
|
738
|
+
it("wechat upsert with an unknown loginId returns login_missing", async () => {
|
|
739
|
+
const gw = makeFakeGateway();
|
|
740
|
+
const { io } = makeConfigIO(baseCfg());
|
|
741
|
+
const sessions = new LoginSessionStore();
|
|
742
|
+
const ctrl = createGatewayControl({
|
|
743
|
+
gateway: gw as any,
|
|
744
|
+
configIO: io,
|
|
745
|
+
loginSessions: sessions,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const ack = await ctrl.handleUpsert({
|
|
749
|
+
id: uniqId("wx_missing"),
|
|
750
|
+
type: "wechat",
|
|
751
|
+
accountId: "ag_alice",
|
|
752
|
+
enabled: true,
|
|
753
|
+
loginId: "wxl_never_created",
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
expect(ack.ok).toBe(false);
|
|
757
|
+
expect(ack.error?.code).toBe("login_missing");
|
|
758
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("feishu upsert with an unknown loginId returns login_missing", async () => {
|
|
762
|
+
const gw = makeFakeGateway();
|
|
763
|
+
const { io } = makeConfigIO(baseCfg());
|
|
764
|
+
const sessions = new LoginSessionStore();
|
|
765
|
+
const ctrl = createGatewayControl({
|
|
766
|
+
gateway: gw as any,
|
|
767
|
+
configIO: io,
|
|
768
|
+
loginSessions: sessions,
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
const ack = await ctrl.handleUpsert({
|
|
772
|
+
id: uniqId("fs_missing"),
|
|
773
|
+
type: "feishu",
|
|
774
|
+
accountId: "ag_alice",
|
|
775
|
+
enabled: true,
|
|
776
|
+
loginId: "fsl_never_created",
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
expect(ack.ok).toBe(false);
|
|
780
|
+
expect(ack.error?.code).toBe("login_missing");
|
|
781
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("recent_senders with an unknown loginId returns login_missing", async () => {
|
|
785
|
+
const gw = makeFakeGateway();
|
|
786
|
+
const { io } = makeConfigIO(baseCfg());
|
|
787
|
+
const sessions = new LoginSessionStore();
|
|
788
|
+
const ctrl = createGatewayControl({
|
|
789
|
+
gateway: gw as any,
|
|
790
|
+
configIO: io,
|
|
791
|
+
loginSessions: sessions,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const ack = await ctrl.handleRecentSenders({
|
|
795
|
+
provider: "wechat",
|
|
796
|
+
loginId: "wxl_never_created",
|
|
797
|
+
accountId: "ag_alice",
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
expect(ack.ok).toBe(false);
|
|
801
|
+
expect(ack.error?.code).toBe("login_missing");
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("wechat upsert with a TTL-expired loginId returns login_expired", async () => {
|
|
805
|
+
const gw = makeFakeGateway();
|
|
806
|
+
const { io } = makeConfigIO(baseCfg());
|
|
807
|
+
let nowMs = 1_000_000;
|
|
808
|
+
const sessions = new LoginSessionStore({ now: () => nowMs, ttlMs: 60_000 });
|
|
809
|
+
sessions.create({
|
|
810
|
+
loginId: "wxl_aged",
|
|
811
|
+
accountId: "ag_alice",
|
|
812
|
+
provider: "wechat",
|
|
813
|
+
qrcode: "QR",
|
|
814
|
+
baseUrl: "https://ilinkai.weixin.qq.com",
|
|
815
|
+
botToken: "wechat-bot-token-aged",
|
|
816
|
+
});
|
|
817
|
+
nowMs += 120_000;
|
|
818
|
+
|
|
819
|
+
const ctrl = createGatewayControl({
|
|
820
|
+
gateway: gw as any,
|
|
821
|
+
configIO: io,
|
|
822
|
+
loginSessions: sessions,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
const ack = await ctrl.handleUpsert({
|
|
826
|
+
id: uniqId("wx_expired"),
|
|
827
|
+
type: "wechat",
|
|
828
|
+
accountId: "ag_alice",
|
|
829
|
+
enabled: true,
|
|
830
|
+
loginId: "wxl_aged",
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
expect(ack.ok).toBe(false);
|
|
834
|
+
expect(ack.error?.code).toBe("login_expired");
|
|
835
|
+
// resolve() also evicts — a follow-up call should now report missing.
|
|
836
|
+
expect(sessions.resolve("wxl_aged").state).toBe("missing");
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("feishu upsert with a TTL-expired loginId returns login_expired", async () => {
|
|
840
|
+
const gw = makeFakeGateway();
|
|
841
|
+
const { io } = makeConfigIO(baseCfg());
|
|
842
|
+
let nowMs = 2_000_000;
|
|
843
|
+
const sessions = new LoginSessionStore({ now: () => nowMs, ttlMs: 60_000 });
|
|
844
|
+
sessions.create({
|
|
845
|
+
loginId: "fsl_aged",
|
|
846
|
+
accountId: "ag_alice",
|
|
847
|
+
provider: "feishu",
|
|
848
|
+
appId: "cli_xxx",
|
|
849
|
+
appSecret: "feishu-secret-aged",
|
|
850
|
+
domain: "feishu",
|
|
851
|
+
});
|
|
852
|
+
nowMs += 120_000;
|
|
853
|
+
|
|
854
|
+
const ctrl = createGatewayControl({
|
|
855
|
+
gateway: gw as any,
|
|
856
|
+
configIO: io,
|
|
857
|
+
loginSessions: sessions,
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const ack = await ctrl.handleUpsert({
|
|
861
|
+
id: uniqId("fs_expired"),
|
|
862
|
+
type: "feishu",
|
|
863
|
+
accountId: "ag_alice",
|
|
864
|
+
enabled: true,
|
|
865
|
+
loginId: "fsl_aged",
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
expect(ack.ok).toBe(false);
|
|
869
|
+
expect(ack.error?.code).toBe("login_expired");
|
|
870
|
+
});
|
|
871
|
+
});
|
|
@@ -65,6 +65,26 @@ describe("PolicyResolver", () => {
|
|
|
65
65
|
expect(p.mode).toBe("always");
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it.each(["telegram:user:42", "wechat:user:alice", "feishu:user:ou_alice"])(
|
|
69
|
+
"forces third-party direct chat %s to mode=always",
|
|
70
|
+
async (conversationId) => {
|
|
71
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
72
|
+
resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
|
|
73
|
+
const p = await resolver.resolve("ag_a", conversationId);
|
|
74
|
+
expect(p.mode).toBe("always");
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
it.each(["telegram:group:-1001", "feishu:chat:oc_team"])(
|
|
79
|
+
"does not force third-party group chat %s to mode=always",
|
|
80
|
+
async (conversationId) => {
|
|
81
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
82
|
+
resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
|
|
83
|
+
const p = await resolver.resolve("ag_a", conversationId);
|
|
84
|
+
expect(p.mode).toBe("mention_only");
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
68
88
|
it("falls back to defaults when fetch throws", async () => {
|
|
69
89
|
const resolver = new PolicyResolver({
|
|
70
90
|
fetchGlobal: async () => {
|
|
@@ -1736,6 +1736,71 @@ describe("update_agent handler", () => {
|
|
|
1736
1736
|
});
|
|
1737
1737
|
});
|
|
1738
1738
|
|
|
1739
|
+
it("updates runtime selectors in credentials and managed route", async () => {
|
|
1740
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
1741
|
+
mockState.cfg = {
|
|
1742
|
+
defaultRoute: {
|
|
1743
|
+
adapter: "codex",
|
|
1744
|
+
cwd: tmp,
|
|
1745
|
+
extraArgs: ["--skip-git-repo-check"],
|
|
1746
|
+
},
|
|
1747
|
+
routes: [],
|
|
1748
|
+
streamBlocks: true,
|
|
1749
|
+
};
|
|
1750
|
+
const credDir = nodePath.join(tmp, ".botcord", "credentials");
|
|
1751
|
+
fs.mkdirSync(credDir, { recursive: true });
|
|
1752
|
+
fs.writeFileSync(
|
|
1753
|
+
nodePath.join(credDir, "ag_runtime.json"),
|
|
1754
|
+
JSON.stringify({
|
|
1755
|
+
version: 1,
|
|
1756
|
+
hubUrl: "https://hub.example",
|
|
1757
|
+
agentId: "ag_runtime",
|
|
1758
|
+
keyId: "k_runtime",
|
|
1759
|
+
privateKey: Buffer.alloc(32, 24).toString("base64"),
|
|
1760
|
+
savedAt: new Date().toISOString(),
|
|
1761
|
+
}),
|
|
1762
|
+
);
|
|
1763
|
+
|
|
1764
|
+
const gw = makeFakeGateway();
|
|
1765
|
+
const provisioner = createProvisioner({
|
|
1766
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1767
|
+
});
|
|
1768
|
+
const ack = await provisioner({
|
|
1769
|
+
id: "req_update_runtime",
|
|
1770
|
+
type: CONTROL_FRAME_TYPES.UPDATE_AGENT,
|
|
1771
|
+
params: {
|
|
1772
|
+
agentId: "ag_runtime",
|
|
1773
|
+
runtime: "codex",
|
|
1774
|
+
runtimeModel: "gpt-5.2",
|
|
1775
|
+
reasoningEffort: "high",
|
|
1776
|
+
thinking: true,
|
|
1777
|
+
},
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
expect(ack.ok).toBe(true);
|
|
1781
|
+
expect((ack.result as { changed: boolean }).changed).toBe(true);
|
|
1782
|
+
|
|
1783
|
+
const saved = JSON.parse(
|
|
1784
|
+
fs.readFileSync(nodePath.join(credDir, "ag_runtime.json"), "utf8"),
|
|
1785
|
+
) as Record<string, unknown>;
|
|
1786
|
+
expect(saved.runtime).toBe("codex");
|
|
1787
|
+
expect(saved.runtimeModel).toBe("gpt-5.2");
|
|
1788
|
+
expect(saved.reasoningEffort).toBe("high");
|
|
1789
|
+
expect(saved.thinking).toBe(true);
|
|
1790
|
+
expect(gw.listManagedRoutes()[0]).toMatchObject({
|
|
1791
|
+
match: { accountId: "ag_runtime" },
|
|
1792
|
+
runtime: "codex",
|
|
1793
|
+
extraArgs: [
|
|
1794
|
+
"--skip-git-repo-check",
|
|
1795
|
+
"--model",
|
|
1796
|
+
"gpt-5.2",
|
|
1797
|
+
"-c",
|
|
1798
|
+
'model_reasoning_effort="high"',
|
|
1799
|
+
],
|
|
1800
|
+
});
|
|
1801
|
+
});
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1739
1804
|
it("rejects update_agent without agentId", async () => {
|
|
1740
1805
|
const gw = makeFakeGateway();
|
|
1741
1806
|
const provisioner = createProvisioner({
|
|
@@ -18,6 +18,18 @@ describe("renderStatus", () => {
|
|
|
18
18
|
expect(out).toContain("stopped");
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
it("reports daemon processes even when the pid file is missing", () => {
|
|
22
|
+
const out = renderStatus({
|
|
23
|
+
pid: null,
|
|
24
|
+
alive: false,
|
|
25
|
+
daemonProcesses: [{ pid: 1342 }, { pid: 1527 }],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(out).toContain("daemon: no pid file");
|
|
29
|
+
expect(out).toContain("warning: 2 daemon processes detected without pid file");
|
|
30
|
+
expect(out).toContain("pids: 1342, 1527");
|
|
31
|
+
});
|
|
32
|
+
|
|
21
33
|
it("prints pid + agent + config when only PID state is known (no snapshot)", () => {
|
|
22
34
|
const out = renderStatus(
|
|
23
35
|
{
|
|
@@ -95,6 +107,17 @@ describe("renderStatus", () => {
|
|
|
95
107
|
expect(out).toContain("agent: ag_solo");
|
|
96
108
|
});
|
|
97
109
|
|
|
110
|
+
it("warns when extra daemon processes are detected", () => {
|
|
111
|
+
const out = renderStatus({
|
|
112
|
+
pid: 42,
|
|
113
|
+
alive: true,
|
|
114
|
+
daemonProcesses: [{ pid: 1001 }, { pid: 1002 }],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(out).toContain("warning: 2 additional daemon processes detected");
|
|
118
|
+
expect(out).toContain("extra pids: 1001, 1002");
|
|
119
|
+
});
|
|
120
|
+
|
|
98
121
|
it("surfaces ⚠ stale when snapshotAgeMs exceeds the threshold", () => {
|
|
99
122
|
const out = renderStatus(
|
|
100
123
|
{
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BotCordClient,
|
|
3
|
+
defaultCredentialsFile,
|
|
4
|
+
loadStoredCredentials,
|
|
5
|
+
updateCredentialsToken,
|
|
6
|
+
} from "@botcord/protocol-core";
|
|
7
|
+
import type { DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
|
|
8
|
+
|
|
9
|
+
interface CachedClient {
|
|
10
|
+
client: BotCordClient;
|
|
11
|
+
credentialsPath: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AttentionPolicyFetcherOptions {
|
|
15
|
+
credentialPathByAgentId: Map<string, string>;
|
|
16
|
+
defaultCredentialsPath?: string;
|
|
17
|
+
hubBaseUrl?: string;
|
|
18
|
+
log?: {
|
|
19
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type AttentionPolicyFetcher = (args: {
|
|
24
|
+
agentId: string;
|
|
25
|
+
roomId?: string | null;
|
|
26
|
+
}) => Promise<DaemonAttentionPolicy | undefined>;
|
|
27
|
+
|
|
28
|
+
export function createAttentionPolicyFetcher(
|
|
29
|
+
opts: AttentionPolicyFetcherOptions,
|
|
30
|
+
): AttentionPolicyFetcher {
|
|
31
|
+
const clients = new Map<string, CachedClient>();
|
|
32
|
+
|
|
33
|
+
function getClient(agentId: string): BotCordClient | null {
|
|
34
|
+
const existing = clients.get(agentId);
|
|
35
|
+
if (existing) return existing.client;
|
|
36
|
+
|
|
37
|
+
const credentialsPath =
|
|
38
|
+
opts.credentialPathByAgentId.get(agentId) ??
|
|
39
|
+
opts.defaultCredentialsPath ??
|
|
40
|
+
defaultCredentialsFile(agentId);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const creds = loadStoredCredentials(credentialsPath);
|
|
44
|
+
const client = new BotCordClient({
|
|
45
|
+
hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
|
|
46
|
+
agentId: creds.agentId,
|
|
47
|
+
keyId: creds.keyId,
|
|
48
|
+
privateKey: creds.privateKey,
|
|
49
|
+
...(creds.token ? { token: creds.token } : {}),
|
|
50
|
+
...(creds.tokenExpiresAt !== undefined
|
|
51
|
+
? { tokenExpiresAt: creds.tokenExpiresAt }
|
|
52
|
+
: {}),
|
|
53
|
+
});
|
|
54
|
+
client.onTokenRefresh = (token, expiresAt) => {
|
|
55
|
+
try {
|
|
56
|
+
updateCredentialsToken(credentialsPath, token, expiresAt);
|
|
57
|
+
} catch {
|
|
58
|
+
// Persistence failures are non-fatal; the next refresh retries.
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
clients.set(agentId, { client, credentialsPath });
|
|
62
|
+
return client;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
opts.log?.warn("daemon.attention-policy.client-init-failed", {
|
|
65
|
+
agentId,
|
|
66
|
+
credentialsPath,
|
|
67
|
+
error: err instanceof Error ? err.message : String(err),
|
|
68
|
+
});
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return async ({ agentId, roomId }) => {
|
|
74
|
+
const client = getClient(agentId);
|
|
75
|
+
if (!client) return undefined;
|
|
76
|
+
try {
|
|
77
|
+
return await client.getAttentionPolicy({ roomId });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
opts.log?.warn("daemon.attention-policy.fetch-failed", {
|
|
80
|
+
agentId,
|
|
81
|
+
roomId: roomId ?? null,
|
|
82
|
+
error: err instanceof Error ? err.message : String(err),
|
|
83
|
+
});
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
package/src/cloud-daemon.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { createDaemonSystemContextBuilder } from "./system-context.js";
|
|
|
32
32
|
import { readWorkingMemorySnapshot } from "./working-memory.js";
|
|
33
33
|
import { createRoomStaticContextBuilder } from "./room-context.js";
|
|
34
34
|
import { createRoomContextFetcher } from "./room-context-fetcher.js";
|
|
35
|
+
import { createRecentRoomMessagesRecoveryBuilder } from "./room-recovery-context.js";
|
|
35
36
|
import { composeBotCordUserTurn } from "./turn-text.js";
|
|
36
37
|
import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
|
|
37
38
|
import { scanMention } from "./mention-scan.js";
|
|
@@ -143,6 +144,12 @@ export async function startCloudDaemon(
|
|
|
143
144
|
fetchRoomInfo: roomContextFetcher,
|
|
144
145
|
log: logger,
|
|
145
146
|
});
|
|
147
|
+
const buildRuntimeRecoveryContext = createRecentRoomMessagesRecoveryBuilder({
|
|
148
|
+
credentialPathByAgentId,
|
|
149
|
+
hubBaseUrl: cloudCfg.hubUrl,
|
|
150
|
+
limit: 20,
|
|
151
|
+
log: logger,
|
|
152
|
+
});
|
|
146
153
|
|
|
147
154
|
type PerAgentBuilder = (
|
|
148
155
|
msg: GatewayInboundMessage,
|
|
@@ -258,6 +265,7 @@ export async function startCloudDaemon(
|
|
|
258
265
|
turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
|
|
259
266
|
buildSystemContext,
|
|
260
267
|
buildMemoryContext,
|
|
268
|
+
buildRuntimeRecoveryContext,
|
|
261
269
|
onInbound,
|
|
262
270
|
onTurnComplete,
|
|
263
271
|
composeUserTurn: composeBotCordUserTurn,
|