`bash nanoclaw.sh` detects a v1 install before channel pairing and does a best-effort automated port of operationally important state. Hands off to a new `/migrate-from-v1` skill for owner seeding and fork customizations. Between the timezone and channel steps, `setup/auto.ts` calls `runMigrateV1()` which orchestrates these registered sub-steps (each a separate entry in the progression log with its own raw log + status block — failures never abort the chain): - **migrate-detect** — scans siblings of the v2 checkout + common $HOME locations; `$NANOCLAW_V1_PATH` overrides authoritatively. Relaxed `package.json` check lets forks + partial installs still match; DB presence is the strongest signal. - **migrate-validate** — asserts v1 DB shape (tables + required columns); writes `schema-mismatch.json` on failure. Subsequent steps short-circuit their DB-dependent parts but still run. - **migrate-db** — seeds `agent_groups` + `messaging_groups` + `messaging_group_agents` from v1's `registered_groups`. JID decomposition (`dc:123` → `channel_type='discord'`, `platform_id='discord:123'`); `trigger_pattern` + `requires_trigger` → `engage_mode` + `engage_pattern` (mirrors migration 010 backfill). Users + user_roles are NOT seeded — the skill does that with an owner interview. Idempotent: existing rows reused, not duplicated. - **migrate-groups** — rsync group folders. v1 `CLAUDE.md` → v2 `CLAUDE.local.md` (v2 composes `CLAUDE.md` at container spawn); v1 `container_config` JSON → `.v1-container-config.json` sidecar for the skill to translate. Tight v1-pattern scan (`/workspace/ipc/tasks`, `store/messages.db`, `[PR_CONTEXT:`, etc.) flags files referencing v1-specific infrastructure — content is NOT modified, just flagged in the handoff. - **migrate-env** — merges v1 `.env` into v2 `.env`, never overwriting existing v2 keys. - **migrate-channel-auth** — per-channel registry tracks v1 env keys, v2 required keys (with source-of-key instructions — e.g. Discord needs `DISCORD_PUBLIC_KEY` which v1 never stored), and candidate on-disk auth state paths (Baileys keystore, matrix sync state, etc.). Missing required v2 keys surface as actionable followups and flip the step to `partial`. - **migrate-channels** — runs `setup/install-<channel>.sh` for each detected channel in non-interactive mode. Install-script output is captured to `logs/setup-migration/install-<channel>.log` sidecars (silent under the parent spinner). Channels with no v2 adapter get a `not_supported` followup but don't degrade status. - **migrate-tasks** — v1 `scheduled_tasks` → `messages_in` rows with `kind='task'` in each session's `inbound.db`. `schedule_type` mapping (cron / interval / once → v2 cron). Idempotent: skips v1 task ids already present. Inactive rows dumped to `inactive-tasks.json` for reference. Everything writes to `logs/setup-migration/handoff.json` — the source of truth the skill consumes. `.claude/skills/migrate-from-v1/SKILL.md`: - **Phase A** (always): owner seeding + v1 access policy flip (`unknown_sender_policy` public/strict) via `AskUserQuestion`. Pulls sender candidates from v1's `messages` table as hints. - **Phase B** (if followups exist): walks `handoff.followups` — translates `.v1-container-config.json` sidecars, handles `not_supported` channels, fills in missing required keys with instructions on where to get them. - **Phase C** (fork-aware): `git log <upstream>..HEAD` in v1. Empty → "no customizations to port." Non-empty → scope choice (mechanical / full interview / reference-only). Portable categories (`container/skills/*`, `.claude/skills/*`, docs) scan+copy with `scanForV1Patterns`. Non-portable (`src/*`, `container/agent-runner/src/*`) stash to `docs/v1-fork-reference/` — explicit "don't translate v1 infra to v2" warning because v1's IPC file queue / single DB don't exist in v2. Clearly marked in README, CLAUDE.md, SKILL.md header, and via a `p.warn` that fires once per run when v1 is detected. Users with no v1 install see a silent skip — no prompts, no noise. Verified end-to-end against a live v1 install (300 discord + 1 discord-supervisor groups, fork with ~15 commits of PR-factory work): - Detect → validate → db (301 rows seeded) → groups (301 CLAUDE.local.md + 178 other files + 1 container_config sidecar) → env (4 keys copied) → channel-auth (flagged missing `DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY`) → channels (discord installed, discord-supervisor → not_supported) → tasks (0 rows, skipped) - Idempotent re-run: 0 rows created, 903 rows reused; tasks skip if id already present - Fresh-user case: silent skip, no prompts, straight to "You're ready!" - Schema-mismatch case: recorded to `schema-mismatch.json`, chain continues - Unit tests for the pure transforms (`parseJid`, `inferChannelType`, `triggerToEngage`, `scanForV1Patterns`, `looksLikeV1Install`) - Validate `requiredV2Keys` for telegram/slack/matrix/teams/webex/ resend/linear against the actual Chat SDK packages (Discord was verified from real error output) - Widen candidate auth file paths for WhatsApp/Matrix/iMessage based on real non-Discord v1 installs once we have some See docs/v1-to-v2-changes.md for the v1 → v2 architecture diff.
214 lines
6.1 KiB
TypeScript
214 lines
6.1 KiB
TypeScript
/**
|
|
* Step: migrate-validate
|
|
*
|
|
* Before touching v1 data, assert the DB has the shape we expect. We know
|
|
* v1's schema (see docs/v1-to-v2-changes.md "Entity model") — different
|
|
* shapes happened over v1's development, but by v1.2.x the `registered_groups`
|
|
* columns and `scheduled_tasks` columns stabilized. If we see something else,
|
|
* we bail early so later steps don't write garbage to v2.db.
|
|
*
|
|
* Output:
|
|
* - `logs/setup-migration/schema-mismatch.json` on failure (read by the skill)
|
|
* - Status block MIGRATE_VALIDATE with OK/FAILED
|
|
* - Even on failure, subsequent steps still run — they'll short-circuit
|
|
* on their own if validate marked the DB unusable. This keeps env + group
|
|
* folder migration working when only the DB is broken.
|
|
*/
|
|
import fs from 'fs';
|
|
|
|
import Database from 'better-sqlite3';
|
|
|
|
import { emitStatus } from '../status.js';
|
|
import {
|
|
SCHEMA_MISMATCH_PATH,
|
|
readHandoff,
|
|
recordStep,
|
|
safeJsonStringify,
|
|
v1PathsFor,
|
|
} from './shared.js';
|
|
|
|
const EXPECTED_TABLES = [
|
|
'registered_groups',
|
|
'scheduled_tasks',
|
|
'chats',
|
|
'messages',
|
|
'sessions',
|
|
'router_state',
|
|
];
|
|
|
|
const REQUIRED_REGISTERED_GROUPS_COLUMNS = [
|
|
'jid',
|
|
'name',
|
|
'folder',
|
|
'trigger_pattern',
|
|
'added_at',
|
|
'requires_trigger',
|
|
];
|
|
|
|
const REQUIRED_SCHEDULED_TASKS_COLUMNS = [
|
|
'id',
|
|
'group_folder',
|
|
'chat_jid',
|
|
'prompt',
|
|
'schedule_type',
|
|
'schedule_value',
|
|
'status',
|
|
];
|
|
|
|
interface TableInfo {
|
|
table: string;
|
|
columns: string[];
|
|
missing_columns: string[];
|
|
}
|
|
|
|
export async function run(_args: string[]): Promise<void> {
|
|
const h = readHandoff();
|
|
if (!h.v1_path) {
|
|
recordStep('migrate-validate', {
|
|
status: 'skipped',
|
|
fields: { REASON: 'detect-not-run' },
|
|
notes: [],
|
|
at: new Date().toISOString(),
|
|
});
|
|
emitStatus('MIGRATE_VALIDATE', { STATUS: 'skipped', REASON: 'no_v1_path' });
|
|
return;
|
|
}
|
|
|
|
const paths = v1PathsFor(h.v1_path);
|
|
if (!fs.existsSync(paths.db)) {
|
|
recordStep('migrate-validate', {
|
|
status: 'failed',
|
|
fields: { REASON: 'db-missing', DB_PATH: paths.db },
|
|
notes: ['v1 DB file does not exist at expected path'],
|
|
at: new Date().toISOString(),
|
|
});
|
|
emitStatus('MIGRATE_VALIDATE', {
|
|
STATUS: 'failed',
|
|
REASON: 'db_missing',
|
|
DB_PATH: paths.db,
|
|
});
|
|
return;
|
|
}
|
|
|
|
let db: Database.Database;
|
|
try {
|
|
db = new Database(paths.db, { readonly: true, fileMustExist: true });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
recordStep('migrate-validate', {
|
|
status: 'failed',
|
|
fields: { REASON: 'db-open-failed' },
|
|
notes: [message],
|
|
at: new Date().toISOString(),
|
|
});
|
|
emitStatus('MIGRATE_VALIDATE', {
|
|
STATUS: 'failed',
|
|
REASON: 'db_open_failed',
|
|
ERROR: message,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const tableRows = db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
.all() as Array<{ name: string }>;
|
|
const tables = new Set(tableRows.map((r) => r.name));
|
|
|
|
const missingTables = EXPECTED_TABLES.filter((t) => !tables.has(t));
|
|
const tableInfos: TableInfo[] = [];
|
|
|
|
for (const t of EXPECTED_TABLES) {
|
|
if (!tables.has(t)) continue;
|
|
const cols = db.prepare(`PRAGMA table_info(${t})`).all() as Array<{ name: string }>;
|
|
const columnNames = cols.map((c) => c.name);
|
|
const missing =
|
|
t === 'registered_groups'
|
|
? REQUIRED_REGISTERED_GROUPS_COLUMNS.filter((c) => !columnNames.includes(c))
|
|
: t === 'scheduled_tasks'
|
|
? REQUIRED_SCHEDULED_TASKS_COLUMNS.filter((c) => !columnNames.includes(c))
|
|
: [];
|
|
tableInfos.push({ table: t, columns: columnNames, missing_columns: missing });
|
|
}
|
|
|
|
const columnMismatches = tableInfos.filter((t) => t.missing_columns.length > 0);
|
|
const groupCount =
|
|
tables.has('registered_groups')
|
|
? ((db.prepare('SELECT COUNT(*) AS c FROM registered_groups').get() as { c: number }).c)
|
|
: 0;
|
|
const taskCount =
|
|
tables.has('scheduled_tasks')
|
|
? ((db.prepare('SELECT COUNT(*) AS c FROM scheduled_tasks').get() as { c: number }).c)
|
|
: 0;
|
|
|
|
db.close();
|
|
|
|
if (missingTables.length > 0 || columnMismatches.length > 0) {
|
|
const mismatch = {
|
|
v1_path: h.v1_path,
|
|
v1_version: h.v1_version,
|
|
present_tables: [...tables].sort(),
|
|
missing_tables: missingTables,
|
|
column_mismatches: columnMismatches,
|
|
scanned_at: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(SCHEMA_MISMATCH_PATH, safeJsonStringify(mismatch));
|
|
|
|
recordStep('migrate-validate', {
|
|
status: 'failed',
|
|
fields: {
|
|
MISSING_TABLES: missingTables.join(',') || 'none',
|
|
COLUMN_MISMATCHES: String(columnMismatches.length),
|
|
REPORT: SCHEMA_MISMATCH_PATH,
|
|
},
|
|
notes: [
|
|
missingTables.length > 0 ? `Missing tables: ${missingTables.join(', ')}` : '',
|
|
columnMismatches.length > 0
|
|
? `Column mismatches in: ${columnMismatches.map((c) => c.table).join(', ')}`
|
|
: '',
|
|
].filter(Boolean),
|
|
at: new Date().toISOString(),
|
|
});
|
|
|
|
emitStatus('MIGRATE_VALIDATE', {
|
|
STATUS: 'failed',
|
|
REASON: 'schema_mismatch',
|
|
MISSING_TABLES: missingTables.join(',') || 'none',
|
|
COLUMN_MISMATCHES: String(columnMismatches.length),
|
|
REPORT: SCHEMA_MISMATCH_PATH,
|
|
});
|
|
return;
|
|
}
|
|
|
|
recordStep('migrate-validate', {
|
|
status: 'success',
|
|
fields: {
|
|
V1_GROUPS: groupCount,
|
|
V1_TASKS: taskCount,
|
|
},
|
|
notes: [],
|
|
at: new Date().toISOString(),
|
|
});
|
|
|
|
emitStatus('MIGRATE_VALIDATE', {
|
|
STATUS: 'success',
|
|
V1_GROUPS: String(groupCount),
|
|
V1_TASKS: String(taskCount),
|
|
});
|
|
} catch (err) {
|
|
db.close();
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
recordStep('migrate-validate', {
|
|
status: 'failed',
|
|
fields: { REASON: 'validate-error' },
|
|
notes: [message],
|
|
at: new Date().toISOString(),
|
|
});
|
|
emitStatus('MIGRATE_VALIDATE', {
|
|
STATUS: 'failed',
|
|
REASON: 'validate_error',
|
|
ERROR: message,
|
|
});
|
|
}
|
|
}
|