Files
nanoclaw/src/cli/resources/destinations.ts
T
gavrielc c2e5b14bf8 fix: ISO storage + local-time display for all timestamps
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
2026-07-10 21:06:33 +03:00

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 } };
},
},
},
});