Skip to content

Commit ce804af

Browse files
authored
Merge pull request #2510 from nanocoai/fix/2465-approval-destinations-inbound-sync
fix(cli): hydrate receiver inbound.db on approval-path destinations add
2 parents 2ab6926 + 898f4b5 commit ce804af

3 files changed

Lines changed: 178 additions & 1 deletion

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Regression test for #2465 — approval-path `ncl destinations add/remove`
3+
* must hydrate every active session's `inbound.db` `destinations` table,
4+
* not just the central `agent_destinations` row.
5+
*
6+
* The approval handler in `dispatch.ts` re-enters `dispatch()` with
7+
* `caller: 'host'` after admin approval, so this test invokes dispatch
8+
* with the host caller — same code path as a real approval payload.
9+
*/
10+
import Database from 'better-sqlite3';
11+
import fs from 'fs';
12+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
13+
14+
vi.mock('../../container-runner.js', () => ({
15+
wakeContainer: vi.fn().mockResolvedValue(undefined),
16+
isContainerRunning: vi.fn().mockReturnValue(false),
17+
getActiveContainerCount: vi.fn().mockReturnValue(0),
18+
killContainer: vi.fn(),
19+
}));
20+
21+
vi.mock('../../config.js', async () => {
22+
const actual = await vi.importActual('../../config.js');
23+
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-destinations' };
24+
});
25+
26+
const TEST_DIR = '/tmp/nanoclaw-test-cli-destinations';
27+
28+
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
29+
import { createSession } from '../../db/sessions.js';
30+
import { initSessionFolder, inboundDbPath } from '../../session-manager.js';
31+
import { dispatch } from '../dispatch.js';
32+
// Side-effect import: registers the `destinations-add` / `destinations-remove` commands.
33+
import './destinations.js';
34+
35+
function now(): string {
36+
return new Date().toISOString();
37+
}
38+
39+
function readSessionDestinations(agentGroupId: string, sessionId: string) {
40+
const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true });
41+
const rows = db.prepare('SELECT name, type, agent_group_id FROM destinations ORDER BY name').all() as Array<{
42+
name: string;
43+
type: string;
44+
agent_group_id: string | null;
45+
}>;
46+
db.close();
47+
return rows;
48+
}
49+
50+
describe('destinations CLI custom ops project to inbound.db (#2465)', () => {
51+
const SOURCE = 'ag-source';
52+
const TARGET = 'ag-target';
53+
const SESSION_A = 'sess-source-1';
54+
const SESSION_B = 'sess-source-2';
55+
56+
beforeEach(() => {
57+
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
58+
fs.mkdirSync(TEST_DIR, { recursive: true });
59+
60+
const db = initTestDb();
61+
runMigrations(db);
62+
63+
createAgentGroup({ id: SOURCE, name: 'source', folder: 'source', agent_provider: null, created_at: now() });
64+
createAgentGroup({ id: TARGET, name: 'target', folder: 'target', agent_provider: null, created_at: now() });
65+
66+
// Two active sessions for the source agent — both must receive the
67+
// projected destination row. Fixing only the "newest" session is a
68+
// common regression shape, so the second session catches that.
69+
for (const sid of [SESSION_A, SESSION_B]) {
70+
createSession({
71+
id: sid,
72+
agent_group_id: SOURCE,
73+
messaging_group_id: null,
74+
thread_id: null,
75+
agent_provider: null,
76+
status: 'active',
77+
container_status: 'stopped',
78+
last_active: null,
79+
created_at: now(),
80+
});
81+
initSessionFolder(SOURCE, sid);
82+
}
83+
});
84+
85+
afterEach(() => {
86+
closeDb();
87+
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
88+
});
89+
90+
it('add: projects the new destination into every active session inbound.db', async () => {
91+
// Sanity: inbound.db starts with no destinations.
92+
expect(readSessionDestinations(SOURCE, SESSION_A)).toEqual([]);
93+
expect(readSessionDestinations(SOURCE, SESSION_B)).toEqual([]);
94+
95+
// caller: 'host' is what the cli_command approval handler in dispatch.ts
96+
// uses when it re-enters dispatch after admin approval.
97+
const resp = await dispatch(
98+
{
99+
id: 'req-1',
100+
command: 'destinations-add',
101+
args: {
102+
agent_group_id: SOURCE,
103+
local_name: 'helper',
104+
target_type: 'agent',
105+
target_id: TARGET,
106+
},
107+
},
108+
{ caller: 'host' },
109+
);
110+
111+
expect(resp.ok).toBe(true);
112+
113+
for (const sid of [SESSION_A, SESSION_B]) {
114+
const rows = readSessionDestinations(SOURCE, sid);
115+
expect(rows).toHaveLength(1);
116+
expect(rows[0]).toMatchObject({ name: 'helper', type: 'agent', agent_group_id: TARGET });
117+
}
118+
});
119+
120+
it('remove: clears the destination from every active session inbound.db', async () => {
121+
await dispatch(
122+
{
123+
id: 'req-add',
124+
command: 'destinations-add',
125+
args: { agent_group_id: SOURCE, local_name: 'helper', target_type: 'agent', target_id: TARGET },
126+
},
127+
{ caller: 'host' },
128+
);
129+
130+
// Precondition: add succeeded and projected to both sessions.
131+
expect(readSessionDestinations(SOURCE, SESSION_A)).toHaveLength(1);
132+
expect(readSessionDestinations(SOURCE, SESSION_B)).toHaveLength(1);
133+
134+
const resp = await dispatch(
135+
{
136+
id: 'req-remove',
137+
command: 'destinations-remove',
138+
args: { agent_group_id: SOURCE, local_name: 'helper' },
139+
},
140+
{ caller: 'host' },
141+
);
142+
143+
expect(resp.ok).toBe(true);
144+
expect(readSessionDestinations(SOURCE, SESSION_A)).toEqual([]);
145+
expect(readSessionDestinations(SOURCE, SESSION_B)).toEqual([]);
146+
});
147+
});

src/cli/resources/destinations.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
1-
import { getDb } from '../../db/connection.js';
1+
import { getDb, hasTable } from '../../db/connection.js';
2+
import { getSessionsByAgentGroup } from '../../db/sessions.js';
3+
import { log } from '../../log.js';
24
import { registerResource } from '../crud.js';
35

6+
/**
7+
* Project the agent's central `agent_destinations` rows into every active
8+
* session's `inbound.db`. The agent-to-agent module is optional, so we guard
9+
* on `hasTable('agent_destinations')` and load `writeDestinations` lazily —
10+
* same pattern as container-runner.ts on container wake.
11+
*
12+
* Called from both `add` and `remove` so the live container picks up the
13+
* change without waiting for the next spawn. Without this, send_message to
14+
* the new local_name silently drops with "unknown destination" until restart.
15+
* See the destination-projection invariant in
16+
* src/modules/agent-to-agent/db/agent-destinations.ts.
17+
*/
18+
async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
19+
if (!hasTable(getDb(), 'agent_destinations')) return;
20+
const { writeDestinations } = await import('../../modules/agent-to-agent/write-destinations.js');
21+
for (const session of getSessionsByAgentGroup(agentGroupId)) {
22+
try {
23+
writeDestinations(agentGroupId, session.id);
24+
} catch (err) {
25+
log.warn('Failed to project destinations to session inbound.db', { agentGroupId, sessionId: session.id, err });
26+
}
27+
}
28+
}
29+
430
registerResource({
531
name: 'destination',
632
plural: 'destinations',
@@ -56,6 +82,7 @@ registerResource({
5682
VALUES (?, ?, ?, ?, datetime('now'))`,
5783
)
5884
.run(agentGroupId, localName, targetType, targetId);
85+
await projectDestinationsToSessions(agentGroupId);
5986
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
6087
},
6188
},
@@ -71,6 +98,7 @@ registerResource({
7198
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
7299
.run(agentGroupId, localName);
73100
if (result.changes === 0) throw new Error('destination not found');
101+
await projectDestinationsToSessions(agentGroupId);
74102
return { removed: { agent_group_id: agentGroupId, local_name: localName } };
75103
},
76104
},

src/modules/agent-to-agent/db/agent-destinations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
* Affected call sites today (keep this list honest if you add more):
3232
* - src/delivery.ts::handleSystemAction case 'create_agent'
3333
* - src/db/messaging-groups.ts::createMessagingGroupAgent
34+
* - src/cli/resources/destinations.ts::add / remove (admin-time `ncl destinations`
35+
* — iterates over `getSessionsByAgentGroup(agentGroupId)`)
3436
*/
3537
import type { AgentDestination } from '../../../types.js';
3638
import { getDb } from '../../../db/connection.js';

0 commit comments

Comments
 (0)