mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
aec7ddd099
- shared.ts: parseJid now recognizes raw Baileys WhatsApp JIDs (`<id>@s.whatsapp.net`, `@g.us`, etc.); v2PlatformId returns the raw JID for whatsapp to match what the runtime adapter emits. Without this, every WhatsApp group in a v1 install was silently skipped. - discord-resolver.ts: new helper that uses DISCORD_BOT_TOKEN to look up channelId → guildId via the Discord API, since v1 stored only the channel id but v2 needs `discord:<guildId>:<channelId>`. Best-effort: on missing/invalid token or network error, returns empty resolver and the affected groups are skipped with the reason surfaced per channel. - db.ts, tasks.ts: route Discord groups through the resolver; other channels go through v2PlatformId unchanged. Resolver only built when at least one Discord group exists, so non-Discord installs incur no network. - db.ts: when every v1 group is skipped, exit non-zero with a FAIL line instead of `OK:groups=N,...,skipped=N`, so the wrapper doesn't hide total failure under a successful-looking summary. - migrate-v2.sh: run_step now surfaces ERROR: lines from successful steps (with count + first 3 + raw log path); phase 2c install loop populates STEP_RESULTS so install failures show in handoff.json instead of silently passing. - sessions.ts: copyTree skips dangling symlinks (e.g. v1's `.claude/debug/latest`) instead of crashing the entire step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
184 lines
6.4 KiB
TypeScript
184 lines
6.4 KiB
TypeScript
/**
|
|
* migrate-v2 step: sessions
|
|
*
|
|
* For each v1 session folder, create a proper v2 session:
|
|
* 1. Create a sessions row in v2.db (via resolveSession)
|
|
* 2. Initialize the session folder (inbound.db, outbound.db, outbox/)
|
|
* 3. Write session routing so the container knows where to reply
|
|
* 4. Copy v1 .claude/ state into v2's .claude-shared/ directory
|
|
*
|
|
* v1: data/sessions/<folder>/.claude/ (settings, conversation history, skills)
|
|
* v2: data/v2-sessions/<agent_group_id>/.claude-shared/ + session folder
|
|
*
|
|
* v1's agent-runner-src/ is NOT copied — v2 uses a completely different
|
|
* Bun-based agent-runner.
|
|
*
|
|
* Idempotent — reuses existing sessions, does not overwrite files.
|
|
*
|
|
* Usage: pnpm exec tsx setup/migrate-v2/sessions.ts <v1-path>
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import Database from 'better-sqlite3';
|
|
|
|
import { DATA_DIR } from '../../src/config.js';
|
|
import { initDb, closeDb } from '../../src/db/connection.js';
|
|
import { getAllAgentGroups } from '../../src/db/agent-groups.js';
|
|
import { getMessagingGroupsByAgentGroup } from '../../src/db/messaging-groups.js';
|
|
import { runMigrations } from '../../src/db/migrations/index.js';
|
|
import {
|
|
resolveSession,
|
|
writeSessionRouting,
|
|
outboundDbPath,
|
|
} from '../../src/session-manager.js';
|
|
|
|
const SKIP_NAMES = new Set(['.DS_Store']);
|
|
|
|
/** Recursively copy, never overwriting existing files. */
|
|
function copyTree(src: string, dst: string): number {
|
|
let written = 0;
|
|
if (!fs.existsSync(src)) return 0;
|
|
fs.mkdirSync(dst, { recursive: true });
|
|
|
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
if (SKIP_NAMES.has(entry.name)) continue;
|
|
const s = path.join(src, entry.name);
|
|
const d = path.join(dst, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
written += copyTree(s, d);
|
|
continue;
|
|
}
|
|
// Skip dangling symlinks (e.g. v1's .claude/debug/latest pointer).
|
|
if (entry.isSymbolicLink() && !fs.existsSync(s)) continue;
|
|
if (fs.existsSync(d)) continue;
|
|
fs.copyFileSync(s, d);
|
|
written += 1;
|
|
}
|
|
return written;
|
|
}
|
|
|
|
function main(): void {
|
|
const v1Path = process.argv[2];
|
|
if (!v1Path) {
|
|
console.error('Usage: tsx setup/migrate-v2/sessions.ts <v1-path>');
|
|
process.exit(1);
|
|
}
|
|
|
|
const v1SessionsDir = path.join(v1Path, 'data', 'sessions');
|
|
if (!fs.existsSync(v1SessionsDir)) {
|
|
console.log('SKIPPED:no v1 data/sessions/ directory');
|
|
process.exit(0);
|
|
}
|
|
|
|
// Init v2 central DB
|
|
const v2DbPath = path.join(DATA_DIR, 'v2.db');
|
|
if (!fs.existsSync(v2DbPath)) {
|
|
console.error('v2.db not found — run db step first');
|
|
process.exit(1);
|
|
}
|
|
|
|
const v2Db = initDb(v2DbPath);
|
|
runMigrations(v2Db);
|
|
|
|
const agentGroups = getAllAgentGroups();
|
|
const folderToAg = new Map<string, { id: string; folder: string }>();
|
|
for (const ag of agentGroups) {
|
|
folderToAg.set(ag.folder, ag);
|
|
}
|
|
|
|
let sessionsCreated = 0;
|
|
let sessionsReused = 0;
|
|
let sessionsSkipped = 0;
|
|
let filesCopied = 0;
|
|
|
|
for (const entry of fs.readdirSync(v1SessionsDir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const folder = entry.name;
|
|
|
|
const ag = folderToAg.get(folder);
|
|
if (!ag) {
|
|
sessionsSkipped++;
|
|
continue;
|
|
}
|
|
|
|
// Find the messaging groups wired to this agent group
|
|
const messagingGroups = getMessagingGroupsByAgentGroup(ag.id);
|
|
if (messagingGroups.length === 0) {
|
|
sessionsSkipped++;
|
|
continue;
|
|
}
|
|
|
|
// Create a session for each messaging group (v1 had one session per
|
|
// folder, v2 has one per agent_group + messaging_group pair)
|
|
for (const mg of messagingGroups) {
|
|
const { session, created } = resolveSession(ag.id, mg.id, null, 'shared');
|
|
|
|
if (created) {
|
|
// Write routing so the container knows where to reply
|
|
writeSessionRouting(ag.id, session.id);
|
|
sessionsCreated++;
|
|
} else {
|
|
sessionsReused++;
|
|
}
|
|
}
|
|
|
|
// Copy v1 .claude/ state into v2's .claude-shared/ directory
|
|
// This is per-agent-group, shared across all sessions for that group
|
|
const v1ClaudeDir = path.join(v1SessionsDir, folder, '.claude');
|
|
if (fs.existsSync(v1ClaudeDir)) {
|
|
const v2ClaudeDir = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared');
|
|
filesCopied += copyTree(v1ClaudeDir, v2ClaudeDir);
|
|
|
|
// v1 containers worked in /workspace/group, v2 works in /workspace/agent.
|
|
// Claude Code stores sessions under projects/<hashed-cwd>/. Copy the v1
|
|
// project dir to the v2 path so Claude Code finds the conversation history.
|
|
const projectsDir = path.join(v2ClaudeDir, 'projects');
|
|
const v1ProjectDir = path.join(projectsDir, '-workspace-group');
|
|
const v2ProjectDir = path.join(projectsDir, '-workspace-agent');
|
|
if (fs.existsSync(v1ProjectDir) && !fs.existsSync(v2ProjectDir)) {
|
|
filesCopied += copyTree(v1ProjectDir, v2ProjectDir);
|
|
}
|
|
|
|
// Write the v1 Claude Code session ID as the continuation in outbound.db
|
|
// so the agent-runner resumes the exact same conversation.
|
|
// The session ID is the JSONL filename (without extension) under the
|
|
// project dir.
|
|
const sourceDir = fs.existsSync(v2ProjectDir) ? v2ProjectDir : v1ProjectDir;
|
|
if (fs.existsSync(sourceDir)) {
|
|
const jsonlFiles = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.jsonl'));
|
|
if (jsonlFiles.length > 0) {
|
|
// Use the most recent JSONL file (by mtime from v1)
|
|
const v1SessionId = jsonlFiles
|
|
.map((f) => ({
|
|
name: f.replace('.jsonl', ''),
|
|
mtime: fs.statSync(path.join(sourceDir, f)).mtimeMs,
|
|
}))
|
|
.sort((a, b) => b.mtime - a.mtime)[0].name;
|
|
|
|
// Write into each v2 session's outbound.db for this agent group
|
|
const sessions = getMessagingGroupsByAgentGroup(ag.id);
|
|
for (const mg of sessions) {
|
|
const { session } = resolveSession(ag.id, mg.id, null, 'shared');
|
|
const obPath = outboundDbPath(ag.id, session.id);
|
|
if (fs.existsSync(obPath)) {
|
|
const ob = new Database(obPath);
|
|
ob.prepare(
|
|
"INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES ('continuation:claude', ?, ?)",
|
|
).run(v1SessionId, new Date().toISOString());
|
|
ob.close();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
closeDb();
|
|
|
|
console.log(`OK:created=${sessionsCreated},reused=${sessionsReused},skipped=${sessionsSkipped},files=${filesCopied}`);
|
|
}
|
|
|
|
main();
|