mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
c2e5b14bf8
Storage: every JS-side datetime('now') write becomes new Date().toISOString()
(messages_out all writers, processing_ack all stamps, delivered, retry
backoff, roles/members/destinations, skill SQL via strftime ISO-Z). Dormant
deliver_after raw string compares wrapped in datetime().
Display: ncl human output renders ISO instants as local "YYYY-MM-DD HH:mm"
(host + container renderers; --json stays ISO; the stamp shape round-trips
through parseZonedToUtc). Task run logs, conversation archive filenames,
clidash, the dashboard pusher, and the uninstall backup stamp all render
local; instruction texts updated in lockstep. CLAUDE.md documents the
convention.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
138 lines
6.3 KiB
TypeScript
138 lines
6.3 KiB
TypeScript
import { getDb, hasTable } from '../../db/connection.js';
|
|
import { getSessionsByAgentGroup } from '../../db/sessions.js';
|
|
import { log } from '../../log.js';
|
|
import { registerResource } from '../crud.js';
|
|
|
|
/**
|
|
* Project the agent's central `agent_destinations` rows into every active
|
|
* session's `inbound.db`. The agent-to-agent module is optional, so we guard
|
|
* on `hasTable('agent_destinations')` and load `writeDestinations` lazily —
|
|
* same pattern as container-runner.ts on container wake.
|
|
*
|
|
* Called from every destination-mutating ncl command — `add` and `remove`
|
|
* here, plus `wirings create` (which writes a companion destination row in
|
|
* its postCreate hook) — so the live container picks up the change without
|
|
* waiting for the next spawn. Without this, send_message to the new
|
|
* local_name silently drops with "unknown destination" until restart.
|
|
* See the destination-projection invariant in
|
|
* src/modules/agent-to-agent/db/agent-destinations.ts.
|
|
*/
|
|
export async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
|
|
if (!hasTable(getDb(), 'agent_destinations')) return;
|
|
const { writeDestinations } = await import('../../modules/agent-to-agent/write-destinations.js');
|
|
for (const session of getSessionsByAgentGroup(agentGroupId)) {
|
|
try {
|
|
writeDestinations(agentGroupId, session.id);
|
|
} catch (err) {
|
|
log.warn('Failed to project destinations to session inbound.db', { agentGroupId, sessionId: session.id, err });
|
|
}
|
|
}
|
|
}
|
|
|
|
registerResource({
|
|
name: 'destination',
|
|
plural: 'destinations',
|
|
table: 'agent_destinations',
|
|
description:
|
|
'Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.',
|
|
idColumn: 'agent_group_id',
|
|
scopeField: 'agent_group_id',
|
|
columns: [
|
|
{
|
|
name: 'agent_group_id',
|
|
type: 'string',
|
|
description: 'The agent that owns this destination. References agent_groups.id.',
|
|
},
|
|
{
|
|
name: 'local_name',
|
|
type: 'string',
|
|
description:
|
|
'Name the agent uses to address this target (e.g. <message to="local_name">). Unique per agent. Lowercase, dash-separated.',
|
|
},
|
|
{
|
|
name: 'target_type',
|
|
type: 'string',
|
|
description: '"channel" for messaging group targets, "agent" for agent-to-agent targets.',
|
|
enum: ['channel', 'agent'],
|
|
},
|
|
{
|
|
name: 'target_id',
|
|
type: 'string',
|
|
description: "The target's ID — messaging_groups.id for channels, agent_groups.id for agents.",
|
|
},
|
|
{ name: 'channel_type', type: 'string', description: 'Resolved channel type for channel destinations.' },
|
|
{ name: 'display_name', type: 'string', description: 'Resolved chat title or agent name.' },
|
|
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
|
|
],
|
|
operations: {},
|
|
customOperations: {
|
|
list: {
|
|
access: 'open',
|
|
description: 'List destinations with resolved channel/title labels.',
|
|
handler: async (args) => {
|
|
const agentGroupId = (args.agent_group_id as string | undefined) ?? (args.id as string | undefined);
|
|
const params: unknown[] = [];
|
|
const where = agentGroupId ? 'WHERE ad.agent_group_id = ?' : '';
|
|
if (agentGroupId) params.push(agentGroupId);
|
|
return getDb()
|
|
.prepare(
|
|
`SELECT
|
|
ad.agent_group_id,
|
|
ad.local_name,
|
|
ad.target_type,
|
|
ad.target_id,
|
|
CASE WHEN ad.target_type = 'channel' THEN mg.channel_type ELSE NULL END AS channel_type,
|
|
CASE WHEN ad.target_type = 'channel' THEN mg.name ELSE ag.name END AS display_name,
|
|
ad.created_at
|
|
FROM agent_destinations ad
|
|
LEFT JOIN messaging_groups mg ON ad.target_type = 'channel' AND ad.target_id = mg.id
|
|
LEFT JOIN agent_groups ag ON ad.target_type = 'agent' AND ad.target_id = ag.id
|
|
${where}
|
|
ORDER BY ad.agent_group_id, ad.local_name`,
|
|
)
|
|
.all(...params);
|
|
},
|
|
},
|
|
add: {
|
|
access: 'approval',
|
|
description: 'Add a destination for an agent. Use --agent-group-id, --local-name, --target-type, --target-id.',
|
|
handler: async (args) => {
|
|
const agentGroupId = args.agent_group_id as string;
|
|
const localName = args.local_name as string;
|
|
const targetType = args.target_type as string;
|
|
const targetId = args.target_id as string;
|
|
if (!agentGroupId) throw new Error('--agent-group-id is required');
|
|
if (!localName) throw new Error('--local-name is required');
|
|
if (!targetType || !['channel', 'agent'].includes(targetType)) {
|
|
throw new Error('--target-type must be channel or agent');
|
|
}
|
|
if (!targetId) throw new Error('--target-id is required');
|
|
getDb()
|
|
.prepare(
|
|
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(agentGroupId, localName, targetType, targetId, new Date().toISOString());
|
|
await projectDestinationsToSessions(agentGroupId);
|
|
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
|
|
},
|
|
},
|
|
remove: {
|
|
access: 'approval',
|
|
description: 'Remove a destination from an agent. Use --agent-group-id and --local-name.',
|
|
handler: async (args) => {
|
|
const agentGroupId = args.agent_group_id as string;
|
|
const localName = args.local_name as string;
|
|
if (!agentGroupId) throw new Error('--agent-group-id is required');
|
|
if (!localName) throw new Error('--local-name is required');
|
|
const result = getDb()
|
|
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
|
|
.run(agentGroupId, localName);
|
|
if (result.changes === 0) throw new Error('destination not found');
|
|
await projectDestinationsToSessions(agentGroupId);
|
|
return { removed: { agent_group_id: agentGroupId, local_name: localName } };
|
|
},
|
|
},
|
|
},
|
|
});
|