Files
nanoclaw/setup/migrate-v1/db.ts
gabi-simons 9faa8a9a2c fix(migrate-v1): splice guild_id into Discord platform_id during seed
v2's Chat SDK Discord adapter emits `platform_id` as
`discord:<guild_id>:<channel_id>` at runtime, but v1 only stored
`dc:<channel_id>` (no guild). Before this fix `migrate-db` wrote
`discord:<channel_id>` into `messaging_groups.platform_id`, which didn't
match what v2 saw on incoming messages — v2 treated every message as a
new channel and fired its channel-registration approval flow instead of
routing to the migrated agent_group.

Now `migrate-db` fetches the bot's guilds once per channel_type via
`GET /users/@me/guilds`. When the bot is in exactly one guild (the
common case), the guild id is spliced into every Discord platform_id at
seed time — matching v2's runtime format. Multi-guild bots fall back to
the v1-format id; v2's channel-registration flow repairs on first
message.

Cost: one extra Discord API call per migration run (not per channel).
No new failure modes — network/auth issues return null, fall through to
the existing behavior.

## Surface

- `v2PlatformId(channelType, jid, { guildId })` — new optional `extra`
  parameter. Back-compat with existing callers.
- `fetchBotGuilds(channelType, lookup)` — new helper in `shared.ts`,
  same pattern as `autoResolveV2Keys`. Handles Discord today; extending
  to other channels is a case-by-case API check.
- `migrate-db` pre-loop: builds `v1EnvMap`, fetches guilds per channel
  type, caches single-guild IDs for the row loop.

## Testing

Verified on a 300-channel Discord v1 install:
- Fresh run produced `discord:<guild>:<channel>` platform_ids from the
  start
- Incoming messages now route to the migrated agent_group instead of
  firing the unwire approval flow

Rate-limit note: `/users/@me/guilds` is a single call. Per-channel
`/guilds/<id>/channels` lookups for multi-guild bots would need proper
rate-limit handling — deferred.
2026-04-23 13:06:14 +00:00

322 lines
11 KiB
TypeScript

/**
* Step: migrate-db
*
* Seed v2.db with the essentials derived from v1's `registered_groups`:
* - agent_groups: one per v1 folder the user selected
* - messaging_groups: one per distinct (channel_type, platform_id) pair
* - messaging_group_agents: the wiring between them, with engage fields
* backfilled from v1's trigger_pattern / requires_trigger
*
* Does NOT seed users, user_roles, or agent_group_members. v1 has no ground
* truth for them — the /migrate-from-v1 skill interviews the user for the
* owner and seeds those tables.
*
* Idempotent: re-running skips any (folder) agent_group, (channel, platform_id)
* messaging_group, and (mg, ag) wiring that already exist. Safe to re-run
* after a partial failure.
*
* Expects `--selection <mode>` where mode is 'all' | 'wired-only'. The
* orchestrator asks the user via clack and passes the result.
*/
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { DATA_DIR } from '../../src/config.js';
import { createAgentGroup, getAgentGroupByFolder } from '../../src/db/agent-groups.js';
import { initDb } from '../../src/db/connection.js';
import {
createMessagingGroup,
createMessagingGroupAgent,
getMessagingGroupAgentByPair,
getMessagingGroupByPlatform,
} from '../../src/db/messaging-groups.js';
import { runMigrations } from '../../src/db/migrations/index.js';
import { log } from '../../src/log.js';
import { emitStatus } from '../status.js';
import {
fetchBotGuilds,
generateId,
inferChannelType,
readHandoff,
recordStep,
triggerToEngage,
v1PathsFor,
v2PlatformId,
writeHandoff,
} from './shared.js';
interface V1Group {
jid: string;
name: string;
folder: string;
trigger_pattern: string | null;
requires_trigger: number | null;
is_main: number | null;
channel_name: string | null;
}
interface DbArgs {
selection: 'all' | 'wired-only';
}
function parseArgs(args: string[]): DbArgs {
let selection: 'all' | 'wired-only' = 'wired-only';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--selection') {
const v = args[++i];
if (v === 'all' || v === 'wired-only') selection = v;
}
}
return { selection };
}
export async function run(args: string[]): Promise<void> {
const parsed = parseArgs(args);
const h = readHandoff();
if (!h.v1_path) {
recordStep('migrate-db', {
status: 'skipped',
fields: { REASON: 'detect-not-run' },
notes: [],
at: new Date().toISOString(),
});
emitStatus('MIGRATE_DB', { STATUS: 'skipped', REASON: 'no_v1_path' });
return;
}
const validate = h.steps['migrate-validate'];
if (validate && validate.status === 'failed') {
recordStep('migrate-db', {
status: 'skipped',
fields: { REASON: 'validate-failed' },
notes: ['DB shape did not validate; skipping DB migration.'],
at: new Date().toISOString(),
});
emitStatus('MIGRATE_DB', { STATUS: 'skipped', REASON: 'validate_failed' });
return;
}
const paths = v1PathsFor(h.v1_path);
let v1Db: Database.Database;
try {
v1Db = new Database(paths.db, { readonly: true, fileMustExist: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
recordStep('migrate-db', {
status: 'failed',
fields: { REASON: 'v1-db-open-failed' },
notes: [message],
at: new Date().toISOString(),
});
emitStatus('MIGRATE_DB', { STATUS: 'failed', REASON: 'v1_db_open_failed', ERROR: message });
return;
}
const v1Groups = v1Db
.prepare(
'SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main, channel_name FROM registered_groups',
)
.all() as V1Group[];
v1Db.close();
// Filter by selection mode. "wired-only" keeps rows where we can confidently
// say which channel they belong to — either `channel_name` is set, or the
// JID prefix resolves to a known channel type.
const selected: V1Group[] = [];
const detectedChannels = new Map<string, { source: 'channel_name' | 'jid_prefix'; count: number }>();
for (const g of v1Groups) {
const channelType = inferChannelType(g.jid, g.channel_name);
const source: 'channel_name' | 'jid_prefix' = g.channel_name?.trim() ? 'channel_name' : 'jid_prefix';
if (!channelType) {
// Can't infer — skip in both modes; the skill raises it with the user.
continue;
}
if (parsed.selection === 'wired-only' && source === 'jid_prefix' && !channelType) {
continue;
}
selected.push(g);
const entry = detectedChannels.get(channelType) ?? { source, count: 0 };
entry.count += 1;
// Prefer explicit channel_name as the source if any row had it.
if (source === 'channel_name') entry.source = 'channel_name';
detectedChannels.set(channelType, entry);
}
h.group_selection = {
mode: parsed.selection,
selected_folders: selected.map((g) => g.folder),
total_v1_groups: v1Groups.length,
wired_v1_groups: selected.length,
};
h.detected_channels = [...detectedChannels.entries()].map(([channel_type, info]) => ({
channel_type,
source: info.source,
group_count: info.count,
}));
writeHandoff(h);
// For channels where v2's platform_id includes a component v1 didn't record
// (Discord's guild id), fetch the bot's guilds up-front. If the bot is in
// a single guild we can splice that id into every platform_id; otherwise
// fall back to the v1-format id (v2's channel-registration flow will repair
// on first message). Done ONCE per channel_type, not per-row, so this is
// cheap regardless of group count.
const v1EnvText = fs.existsSync(paths.env) ? fs.readFileSync(paths.env, 'utf-8') : '';
const v1EnvMap = new Map<string, string>();
for (const line of v1EnvText.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq <= 0) continue;
v1EnvMap.set(t.slice(0, eq).trim(), t.slice(eq + 1));
}
const singleGuildByChannel = new Map<string, string>();
for (const channelType of detectedChannels.keys()) {
const info = await fetchBotGuilds(channelType, (k) => v1EnvMap.get(k));
if (info && info.guildIds.length === 1) {
singleGuildByChannel.set(channelType, info.guildIds[0]);
}
}
// Initialize v2.db (creates schema if not present — runMigrations is no-op
// when the schema is already current, so this is safe on a live v2 install).
fs.mkdirSync(path.join(process.cwd(), 'data'), { recursive: true });
const v2Path = path.join(DATA_DIR, 'v2.db');
const v2Db = initDb(v2Path);
runMigrations(v2Db);
let agentGroupsCreated = 0;
let agentGroupsReused = 0;
let messagingGroupsCreated = 0;
let messagingGroupsReused = 0;
let wiringsCreated = 0;
let wiringsReused = 0;
let skipped = 0;
const followups: string[] = [];
for (const g of selected) {
const channelType = inferChannelType(g.jid, g.channel_name);
if (!channelType) {
skipped += 1;
continue;
}
const guildId = singleGuildByChannel.get(channelType);
const platformId = v2PlatformId(channelType, g.jid, { guildId });
const createdAt = new Date().toISOString();
try {
// agent_group — one per folder
let ag = getAgentGroupByFolder(g.folder);
if (!ag) {
createAgentGroup({
id: generateId('ag'),
name: g.name || g.folder,
folder: g.folder,
agent_provider: null,
created_at: createdAt,
});
ag = getAgentGroupByFolder(g.folder)!;
agentGroupsCreated += 1;
} else {
agentGroupsReused += 1;
}
// messaging_group — one per (channel_type, platform_id)
let mg = getMessagingGroupByPlatform(channelType, platformId);
if (!mg) {
createMessagingGroup({
id: generateId('mg'),
channel_type: channelType,
platform_id: platformId,
name: g.name || null,
is_group: 1, // v1 didn't distinguish; default to group (safe for routing)
unknown_sender_policy: 'strict', // skill's interview flips this if v1 was "public"
created_at: createdAt,
});
mg = getMessagingGroupByPlatform(channelType, platformId)!;
messagingGroupsCreated += 1;
} else {
messagingGroupsReused += 1;
}
// messaging_group_agents — wire them if not already wired
const existingWiring = getMessagingGroupAgentByPair(mg.id, ag.id);
if (!existingWiring) {
const engage = triggerToEngage({
trigger_pattern: g.trigger_pattern,
requires_trigger: g.requires_trigger,
});
createMessagingGroupAgent({
id: generateId('mga'),
messaging_group_id: mg.id,
agent_group_id: ag.id,
engage_mode: engage.engage_mode,
engage_pattern: engage.engage_pattern,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
priority: 0,
created_at: createdAt,
});
wiringsCreated += 1;
} else {
wiringsReused += 1;
}
if (g.is_main === 1) {
followups.push(
`Folder "${g.folder}" was the v1 main group (is_main=1). v2 has no is_main flag — the /migrate-from-v1 skill should grant this folder's channel to the owner user when it runs.`,
);
}
} catch (err) {
skipped += 1;
const message = err instanceof Error ? err.message : String(err);
log.error('Failed to seed v1 group', { folder: g.folder, err: message });
followups.push(`Folder "${g.folder}" failed to seed: ${message}`);
}
}
v2Db.close();
const partial = skipped > 0;
const handoffAfter = readHandoff();
handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])];
writeHandoff(handoffAfter);
recordStep('migrate-db', {
status: partial ? 'partial' : 'success',
fields: {
SELECTION: parsed.selection,
V1_GROUPS_TOTAL: v1Groups.length,
SELECTED: selected.length,
AGENT_GROUPS_CREATED: agentGroupsCreated,
AGENT_GROUPS_REUSED: agentGroupsReused,
MESSAGING_GROUPS_CREATED: messagingGroupsCreated,
MESSAGING_GROUPS_REUSED: messagingGroupsReused,
WIRINGS_CREATED: wiringsCreated,
WIRINGS_REUSED: wiringsReused,
SKIPPED: skipped,
CHANNELS: [...detectedChannels.keys()].join(','),
},
notes: followups,
at: new Date().toISOString(),
});
emitStatus('MIGRATE_DB', {
STATUS: partial ? 'partial' : 'success',
SELECTION: parsed.selection,
V1_GROUPS_TOTAL: String(v1Groups.length),
SELECTED: String(selected.length),
AGENT_GROUPS_CREATED: String(agentGroupsCreated),
MESSAGING_GROUPS_CREATED: String(messagingGroupsCreated),
WIRINGS_CREATED: String(wiringsCreated),
SKIPPED: String(skipped),
CHANNELS: [...detectedChannels.keys()].join(',') || 'none',
});
}