New entry point: `bash migrate-v2.sh` from the v2 checkout. Replaces the old setup-embedded migration flow with a standalone 4-phase script + rewritten Claude skill for the interactive parts. Phase 0: Bootstrap (Node/pnpm/deps via setup.sh) + find v1 Phase 1: Core state (env, DB, groups, sessions, tasks) Phase 2: Channels (clack multiselect, auth copy, code install) Phase 3: Infrastructure (OneCLI, auth, Docker, skills, container build) Service switchover: stop v1 → start v2 → test → keep or revert Phase 4: Handoff → exec claude "/migrate-from-v1" The skill handles: owner seeding, access policy, CLAUDE.local.md cleanup, container config validation, fork customization porting. Key fixes found during testing: - triggerToEngage: requires_trigger=0 must override non-empty pattern - unknown_sender_policy defaults to 'public' (strict drops all msgs before owner is seeded) - Service revert must stop v2 (parse unit name from step log, not early tsx one-liner that can fail) - Session continuity: copy JSONL from -workspace-group/ to -workspace-agent/ and write continuation:claude into outbound.db - container_config.additionalMounts written directly to container.json (same shape in v1 and v2) - EXIT trap writes handoff.json; explicit write_handoff before exec Includes migrate-v2-reset.sh for dev iteration and docs/migration-dev.md for testing/debugging reference. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
182 lines
6.3 KiB
TypeScript
182 lines
6.3 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;
|
|
}
|
|
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();
|