From 3ee7d2147e570fc77a50f8e1d89a09844db2a56d Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 23 Apr 2026 12:05:34 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20v1=20=E2=86=92=20v2=20migration?= =?UTF-8?q?=20to=20setup=20flow=20(experimental)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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-.sh` for each detected channel in non-interactive mode. Install-script output is captured to `logs/setup-migration/install-.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 ..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. --- .claude/skills/migrate-from-v1/SKILL.md | 120 +++++ CHANGELOG.md | 4 + CLAUDE.md | 2 + README.md | 2 + docs/v1-to-v2-changes.md | 172 +++++++ setup/auto.ts | 16 +- setup/index.ts | 8 + setup/migrate-v1.ts | 257 ++++++++++ setup/migrate-v1/channel-auth.ts | 213 ++++++++ setup/migrate-v1/channels.ts | 172 +++++++ setup/migrate-v1/db.ts | 296 +++++++++++ setup/migrate-v1/detect.ts | 107 ++++ setup/migrate-v1/env.ts | 135 +++++ setup/migrate-v1/groups.ts | 230 +++++++++ setup/migrate-v1/shared.ts | 639 ++++++++++++++++++++++++ setup/migrate-v1/tasks.ts | 307 ++++++++++++ setup/migrate-v1/validate.ts | 213 ++++++++ 17 files changed, 2891 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/migrate-from-v1/SKILL.md create mode 100644 docs/v1-to-v2-changes.md create mode 100644 setup/migrate-v1.ts create mode 100644 setup/migrate-v1/channel-auth.ts create mode 100644 setup/migrate-v1/channels.ts create mode 100644 setup/migrate-v1/db.ts create mode 100644 setup/migrate-v1/detect.ts create mode 100644 setup/migrate-v1/env.ts create mode 100644 setup/migrate-v1/groups.ts create mode 100644 setup/migrate-v1/shared.ts create mode 100644 setup/migrate-v1/tasks.ts create mode 100644 setup/migrate-v1/validate.ts diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md new file mode 100644 index 0000000..810ae03 --- /dev/null +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -0,0 +1,120 @@ +--- +name: migrate-from-v1 +description: Finish migrating a NanoClaw v1 install into v2. Run this after `bash nanoclaw.sh` has completed its automated migration step. Seeds the owner user, applies v1 access defaults, fixes any migration sub-step that didn't finish, and interviews the user about custom v1 code to port forward. Triggers on "migrate from v1", "finish migration", "v1 migration", or automatically after setup when `logs/setup-migration/handoff.json` exists. +--- + +# Migrate from v1 to v2 + +> ⚠️ **Experimental.** This skill and the setup migration step are early. Remind the user to back up `data/v2.db` + `groups/` before making destructive changes, and prefer small, reversible edits. Not recommended yet for high-stakes production installs. + +The setup flow's `migration` step (in `setup/migrate-v1.ts`) already ran a best-effort automated pass. Your job is to finish what it couldn't do automatically, then interview the user about any custom code they had in v1 and help port it forward. + +Read [docs/v1-to-v2-changes.md](../../../docs/v1-to-v2-changes.md) before doing anything — it's the vocabulary for where v1 things moved to in v2. + +## What the automation did + +The setup flow ran these sub-steps (each as its own progression-log entry): + +| Sub-step | What it did | +|----------|-------------| +| `migrate-detect` | Found v1 install on disk (scanned `~/nanoclaw`, `~/.nanoclaw`, `~/Code/nanoclaw`, etc., or `$NANOCLAW_V1_PATH`). | +| `migrate-validate` | Checked v1 DB has expected tables + required columns. | +| `migrate-db` | Seeded `agent_groups` + `messaging_groups` + `messaging_group_agents` from `registered_groups`. Mapped `trigger_pattern`/`requires_trigger` → `engage_mode`/`engage_pattern`. Did NOT seed `users`/`user_roles`. | +| `migrate-groups` | Copied v1 `groups//` to v2. v1 `CLAUDE.md` → v2 `CLAUDE.local.md`. v1 `container_config` JSON → `.v1-container-config.json` sidecar (don't silent-map to v2's `container.json`). | +| `migrate-env` | Merged v1 `.env` keys into v2 `.env` (never overwrote existing keys). | +| `migrate-channel-auth` | Copied non-env auth state per channel (Baileys keystore, matrix state, etc.) based on `CHANNEL_AUTH_REGISTRY` in `setup/migrate-v1/shared.ts`. | +| `migrate-channels` | Ran `setup/install-.sh` for each channel detected in `registered_groups`. | +| `migrate-tasks` | Ported active v1 `scheduled_tasks` into each session's `inbound.db` as `kind='task'` rows. Inactive tasks dumped to `inactive-tasks.json` for reference. | + +## Artifacts to read first + +- `logs/setup-migration/handoff.json` — **start here.** Structured summary of every sub-step: `status`, `fields`, `notes`, plus detected channels, group selection, and a top-level `followups` list. The top-level `overall_status` tells you at a glance what kind of session this is. +- `logs/setup.log` — the progression log. Each `migrate-*` sub-step has one entry with status, duration, and a pointer to its raw log. +- `logs/setup-steps/NN-migrate-*.log` — raw per-sub-step stdout+stderr. Read these when a step failed or you need to understand why. +- `logs/setup-migration/schema-mismatch.json` — only exists if `migrate-validate` rejected the v1 DB shape. Describes what was missing. +- `logs/setup-migration/inactive-tasks.json` — v1 scheduled tasks we didn't migrate (completed, stopped, or unmappable schedule types). + +## Flow + +### Phase A — always run: owner seeding + access policy + +The automation deliberately did not seed `users`, `user_roles`, or flip `messaging_groups.unknown_sender_policy`. v1 has no ground truth for who the owner is, and no single source for the "anyone can message / only known users" setting. Ask the user. + +1. Read `handoff.json` → `detected_channels` to know which channel(s) to address the user on. +2. Use `AskUserQuestion` to ask "Which handle on `` is yours?" with options pulled from context if you have any hints (e.g. recent v1 message senders), plus "Let me type it" and "Use a different channel." Build the user id as `:`. +3. Insert into v2 central DB (`data/v2.db`): + - `users(id, kind, display_name, created_at)` — use the channel_type as `kind`. + - `user_roles(user_id, role='owner', agent_group_id=NULL, granted_by=NULL, granted_at=now)`. +4. Ask "In v1, could anyone message your assistant, or only known users?" via `AskUserQuestion`: + - "Anyone could message it" → update every row in `messaging_groups` (for migrated channel_types) to `unknown_sender_policy='public'`. + - "Only known users" → leave `unknown_sender_policy='strict'`; walk the user through seeding `agent_group_members` rows for each trusted handle they name. + +Use the DB helpers in `src/db/agent-groups.ts`, `src/db/messaging-groups.ts`, and `src/db/user-roles.ts` rather than hand-rolling SQL — they keep the companion `agent_destinations` and indexes correct. Always init the central DB first: + +```ts +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; +import { DATA_DIR } from '../src/config.js'; +import path from 'path'; +const db = initDb(path.join(DATA_DIR, 'v2.db')); +runMigrations(db); +``` + +### Phase B — branch on `handoff.json: overall_status` + +**If `overall_status === 'success'`** and `followups` is empty: go straight to Phase C (customization interview). + +**Otherwise (partial, failed, or non-empty followups)**: walk `handoff.steps` and `handoff.followups` top-to-bottom. For each entry: + +- Read the step's `fields` and `notes` and its raw log (`logs/setup-steps/NN-.log`). +- Explain the situation to the user in one sentence, then propose a fix. +- Do the fix yourself when it's mechanical (re-running an install script, seeding a missed `agent_destinations` row, re-copying a channel's auth files, manually translating an unsupported `schedule_type`). Use `AskUserQuestion` when a judgment call is needed (is this orphan channel worth keeping? is this v1 container_config still relevant?). + +Common cases: + +- **`migrate-validate` status=failed**: the v1 DB had an unexpected shape. Read `schema-mismatch.json`. If tables are missing, the user may have run a very old or customized v1 — ask before trying to salvage. If only columns are missing, you can often proceed by hand-writing the SELECT with the columns that exist. +- **`migrate-db` status=partial, SKIPPED>0**: some `registered_groups` rows didn't seed. The `notes` field of the step entry names each failed folder. Most commonly: a JID we couldn't parse. Ask the user whether to manually wire each. +- **`migrate-channels` status=partial, some entries `not_supported`**: v1 had channels v2 doesn't ship a skill for yet. Ask the user whether to keep the `messaging_groups` rows (they'll stay orphaned until v2 grows the adapter) or delete them. +- **`migrate-channel-auth` has `files_missing`**: for WhatsApp specifically, encryption sessions often can't survive the copy — tell the user a fresh pair may be needed via `/add-whatsapp`. +- **Per-folder `.v1-container-config.json` sidecars exist**: read each, discuss with the user, and translate to v2's `groups//container.json` format. + +### Phase C — customizations (fork-aware) + +NanoClaw recommends running on a fork, so most real v1 installs have at least some customizations. + +**Start with divergence detection.** In the v1 repo at `handoff.v1_path`: + +```bash +cd +git remote -v # identify the upstream remote +git log --oneline /main..HEAD # commits ahead of upstream +``` + +If the log is **empty**: stock v1. Tell the user "no customizations to port" and skip the rest of Phase C. + +If the log has commits, show them to the user and offer a scope via `AskUserQuestion`: + +1. **Mechanical** (recommended) — copy the portable categories (skills, docs), stash the rest as reference. +2. **Full interview** — walk each commit with me, decide one-by-one. Use `Explore` sub-agents for diffs > 10 files. +3. **Reference only** — stash everything to `docs/v1-fork-reference/`, copy nothing now. + +**Portability rules of thumb:** +- **Portable**: `container/skills/*`, `.claude/skills/*`, `docs/*`, top-level config. Scan each with `scanForV1Patterns` (in `setup/migrate-v1/shared.ts`) before copying — clean ones land as-is, dirty ones get a followup. +- **Not portable**: `src/*` (host) and `container/agent-runner/src/*` (agent-runner). v2's architecture is fundamentally different (Node host with split session DBs vs v1's single process + IPC file queue). Stash to `docs/v1-fork-reference/` with a README explaining the v1→v2 mapping — **don't translate**. Mechanical translation is a trap; let the user rebuild the feature on v2 primitives. +- **Already handled**: `groups/*` — `migrate-groups` copied these and flagged v1 patterns. Don't redo in Phase C. +- **Case by case**: `package.json` deps — check whether v2 already has each; never add to v2's lockfile without approval (supply-chain `minimumReleaseAge` applies). + +When stashing, write `docs/v1-fork-reference/README.md` with commits list, stashed source files, and the suggested porting plan. + +## Principles + +- **Never silently copy code.** Read, explain, propose, apply. Show diffs before applying when non-trivial. +- **Credentials are masked when displayed** (first 4 + `...` + last 4 characters). The handoff file doesn't contain values; keep it that way. +- **The v1 checkout is read-only.** We never delete or modify `~/nanoclaw`. If the user wants to retire it later, that's a separate conversation. +- **No migration re-runs.** The `migrate-*` sub-steps are idempotent, but re-running them from inside this skill is almost always the wrong move — finish by hand. Re-running is for when the user re-runs `bash nanoclaw.sh`. +- **`handoff.json` is source of truth across context compactions.** If the conversation gets compacted mid-work, re-read it and `git status` to recover state. Do not maintain a separate state file. + +## When you're done + +- Delete `logs/setup-migration/handoff.json` once every followup is cleared and the user confirms. The file is a working document, not a record — if the user wants a record, offer to move it to `docs/migration-.md` before deleting. +- Tell the user: if the service is running (check `launchctl list | grep nanoclaw` on macOS or `systemctl --user status nanoclaw*` on Linux), restart it so the seeded `users` / `user_roles` / any channel installs take effect. diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2fd5a..cf6992b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to NanoClaw will be documented in this file. For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog). +## [Unreleased] + +- **v1 → v2 migration (experimental).** `bash nanoclaw.sh` now detects a v1 install (`~/nanoclaw`, `~/.nanoclaw`, siblings of the v2 checkout, or `$NANOCLAW_V1_PATH`) and runs a best-effort port before channel pairing: seeds `agent_groups`/`messaging_groups`/wirings from v1's `registered_groups` (with trigger_pattern → engage_mode/engage_pattern), copies group folders, merges `.env`, installs v2 channel adapters, and ports `scheduled_tasks`. Hands off to `/migrate-from-v1` for owner seeding and fork customizations. Experimental — back up `data/v2.db` and `groups/` first; see [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). + ## [2.0.0] - 2026-04-22 Major version. NanoClaw v2 is a substantial architectural rewrite. Existing forks should run `/migrate-nanoclaw` (clean-base replay of customizations) or `/update-nanoclaw` (selective cherry-pick) before resuming work. diff --git a/CLAUDE.md b/CLAUDE.md index 7115c4c..b584c73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,7 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f | `container/skills/` | Container skills mounted into every agent session | | `groups//` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) | | `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) | +| `setup/migrate-v1.ts` + `setup/migrate-v1/` | v1→v2 migration (**experimental**); runs inside `setup/auto.ts` before channel pairing. Seeds `agent_groups` + `messaging_groups` + wirings from v1's `registered_groups`, copies group folders, merges `.env`, installs channel adapters, ports scheduled tasks. Writes `logs/setup-migration/handoff.json` for the `/migrate-from-v1` skill to pick up (owner seeding + fork customizations). | ## Channels and Providers (skill-installed) @@ -211,6 +212,7 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac | [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow | | [docs/architecture-diagram.md](docs/architecture-diagram.md) | Diagram version of the architecture | | [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants | +| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved. Referenced by the migration step and `/migrate-from-v1` skill | ## Container Build Cache diff --git a/README.md b/README.md index de61f6d..9228d40 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ bash nanoclaw.sh `nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke. +**Coming from v1?** `nanoclaw.sh` detects your old install (scans siblings of the v2 checkout + common `$HOME` locations) and migrates automatically; `/migrate-from-v1` in Claude finishes owner setup and helps port custom fork work. If v1 is at a non-standard path, run with `NANOCLAW_V1_PATH=/path/to/nanoclaw bash nanoclaw.sh`. See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). _Experimental — back up `data/v2.db` and `groups/` first; not recommended yet for high-stakes production installs._ + ## Philosophy **Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it. diff --git a/docs/v1-to-v2-changes.md b/docs/v1-to-v2-changes.md new file mode 100644 index 0000000..f81fb58 --- /dev/null +++ b/docs/v1-to-v2-changes.md @@ -0,0 +1,172 @@ +# NanoClaw v1 → v2 — what changed + +Big-picture differences between NanoClaw v1 (the `~/nanoclaw` checkout you've been running) and v2 (this rewrite). Not a migration guide — that's what `bash nanoclaw.sh` and the `/migrate-from-v1` skill are for. This doc is the **vocabulary**: when something has moved or been renamed, find it here. + +Read this before touching the migration code or porting customizations forward. + +--- + +## One-line summary + +v1 was one Node process with one SQLite file and native channel adapters. v2 is a host that spawns per-session Docker containers, splits state across a central DB + per-session DB pair, routes through an explicit entity model, and installs channels as skills from a sibling branch. + +--- + +## Entity model — the biggest shift + +**v1:** one flat table `registered_groups(jid, name, folder, trigger_pattern, requires_trigger, is_main, channel_name)`. A group folder is the unit of agent identity. A chat (JID) is wired to exactly one folder, and `trigger_pattern` is an opaque regex the router applies to every incoming message. + +**v2:** three tables, with a deliberate many-to-many in the middle: + +``` +agent_groups ─┐ + ├─ messaging_group_agents ─┬─ messaging_groups + │ (engage_mode, │ (channel_type, + │ engage_pattern, │ platform_id, + │ sender_scope, │ unknown_sender_policy) + │ ignored_message_policy, + │ session_mode, priority) +``` + +Consequences: + +- **One agent can answer on many chats, and one chat can fan out to many agents.** v1 couldn't do either. +- **No `is_main` flag.** Privilege is now explicit via `user_roles` (owner/admin, global or scoped). See below. +- **No `trigger_pattern` regex.** Replaced with four orthogonal columns. Mapping rule used by the automated migration and by the `/migrate-from-v1` skill: + - v1 `trigger_pattern` non-empty → v2 `engage_mode='pattern'`, `engage_pattern = ` + - v1 `requires_trigger=0` or pattern was `.`/`.*` → v2 `engage_mode='pattern'`, `engage_pattern='.'` (the "always" flavor) + - no pattern and requires a trigger → v2 `engage_mode='mention'` + - `sender_scope` and `ignored_message_policy` are new; defaults `all` / `drop` +- **JID decomposition.** v1's `jid` column stored `dc:12345` / `tg:67890`. v2 splits this into `channel_type` + `platform_id`. Concretely: `dc:12345` becomes `channel_type='discord'`, `platform_id='discord:12345'`. Prefix aliases (`dc` → `discord`, `tg` → `telegram`, `wa` → `whatsapp`) are in `setup/migrate-v1/shared.ts`. +- **`channel_name` was unreliable in v1.** Many rows had it empty; the actual channel had to be guessed from the JID prefix. v2's `channel_type` is always explicit. + +--- + +## Central DB vs session DBs + +**v1:** one SQLite file at `store/messages.db`. Every chat, message, registered group, scheduled task, and session lived there. Host and any agent processes all opened the same file. + +**v2:** three DB shapes. + +1. `data/v2.db` — **central**. Everything that isn't per-session: users, roles, agent groups, messaging groups, wirings, pending approvals, user DMs, schema migrations. +2. `data/v2-sessions//inbound.db` — **host writes, container reads**. `messages_in`, routing, destinations, pending questions, processing_ack. This is where scheduled tasks live (see "Scheduling" below). +3. `data/v2-sessions//outbound.db` — **container writes, host reads**. `messages_out`, session_state. + +Exactly one writer per file. No cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd. + +Message history (v1 `messages` table, v1 `chats` table) is **not migrated**. The migration copies operationally important state forward (agents, channels, wirings, scheduled tasks, group folders) and leaves chat logs behind. + +--- + +## Scheduling + +**v1:** dedicated `scheduled_tasks` table in `store/messages.db` with its own columns (`schedule_type`, `schedule_value`, `next_run`, `last_run`, `context_mode`, `script`, `status`). A separate cron-ish scheduler process read from it. + +**v2:** scheduled tasks are **`messages_in` rows with `kind='task'`** in a session's `inbound.db`. Relevant columns: +- `process_after` (ISO8601) — host sweep wakes the container when `datetime(process_after) <= datetime('now')` +- `recurrence` — cron string; `NULL` = one-shot +- `series_id` — groups recurring occurrences; set to the task id on first insert +- `status` — `pending` | `processing` | `completed` | `failed` | `paused` + +The public API is `insertTask()` in `src/modules/scheduling/db.ts`. Recurrence is computed in the user's TZ via `cron-parser` (see `src/modules/scheduling/recurrence.ts`). The migration maps v1's `schedule_type`+`schedule_value` pair into a single cron string before calling `insertTask()`. + +Tasks can exist before a session is awake — the host sweep creates/wakes the container on the first due tick. + +--- + +## Credentials + +**v1:** `.env` — plain environment variables. `DISCORD_BOT_TOKEN`, `ANTHROPIC_API_KEY`, etc. The host read them directly and passed them in to any code that needed them. + +**v2:** OneCLI Agent Vault. A separate local service at `http://127.0.0.1:10254` holds secrets. Agents are *scoped* to specific secrets and the vault injects them into approved API requests as they leave the container. The container never sees the raw secret value. + +Gotcha: auto-created agents default to `selective` secret mode — no secrets attached, even if matching secrets exist in the vault. See the "auto-created agents start in selective secret mode" section of the root CLAUDE.md for the fix (`onecli agents set-secret-mode --mode all`). + +**What the automated migration does:** copies every v1 `.env` key verbatim into v2 `.env`, never overwriting existing v2 keys. The OneCLI vault migration is a separate step owned by the `/init-onecli` skill, which knows how to pull from `.env`. + +--- + +## Channel adapters + +**v1:** native adapters (e.g. `discord.js` used directly) imported in `src/channels/`. Installing a channel meant editing code, adding a dependency, and setting env vars. + +**v2:** channel adapters live on a sibling `channels` branch. Each `/add-` skill: +1. `git fetch origin channels` +2. `git show channels:src/channels/.ts > src/channels/.ts` +3. Appends `import './.js';` to `src/channels/index.ts` +4. `pnpm install @chat-adapter/@` +5. `pnpm run build` + +Idempotent — re-running is a no-op. Pinned versions keep the supply chain honest. The automated migration detects which channels were wired in v1 (via distinct `channel_name` / JID prefix) and runs the matching `setup/install-.sh` for each. Channels in v1 that don't have a v2 skill (rare now, more common as v2 catches up) are recorded in the handoff file for the `/migrate-from-v1` skill to raise with the user. + +**Channel auth beyond `.env`.** Some channels store session state on disk (Baileys WhatsApp keystore, Matrix sync state, iMessage tokens). The `channel-auth` sub-step has a per-channel registry (`setup/migrate-v1/shared.ts: CHANNEL_AUTH_REGISTRY`) that knows which file globs to copy alongside env keys. + +--- + +## Privilege — from implicit to explicit + +**v1:** `registered_groups.is_main = 1` flagged one group as the privileged one. No `users` table. Permissions were conventions, not enforced. + +**v2:** explicit tables. +- `users(id = ":", kind, display_name)` — one row per messaging-platform identifier +- `user_roles(user_id, role ∈ {owner, admin}, agent_group_id nullable, granted_by, granted_at)` — owner is always global; admin can be global or scoped +- `agent_group_members(user_id, agent_group_id, ...)` — "known" membership for the `sender_scope='known'` gate + +Owner gets seeded during the `/migrate-from-v1` skill's interview phase ("Which handle is you?"). The automated migration doesn't guess — v1 has no source of truth for it. + +**Default access — "anyone can talk to the bot" vs "only known users".** v1 stored this implicitly (via trigger regex + `is_main`). v2 exposes it as `messaging_groups.unknown_sender_policy ∈ {'strict', 'request_approval', 'public'}`. The skill asks the user which mode v1 ran in and flips the migrated messaging groups accordingly. + +--- + +## Group folders on disk + +**v1:** `groups//CLAUDE.md` and optional `logs/`. `CLAUDE.md` was a plain instruction file, group-specific. + +**v2:** each group still lives at `groups//`, but the shape is richer: +- `CLAUDE.md` — **composed at container spawn** from `.claude-shared.md` (symlink to global) + `.claude-fragments/*.md` (module fragments) + `CLAUDE.local.md`. **Don't edit `CLAUDE.md` directly.** +- `CLAUDE.local.md` — per-group content. The migration writes v1's old `CLAUDE.md` here. +- `container.json` — optional per-group container config (apt deps, env, mounts). v1's `registered_groups.container_config` JSON is close but not identical — the migration stores the v1 payload at `groups//.v1-container-config.json` for the skill to reconcile, rather than silently mapping it. +- `.claude-fragments/` and `.claude-shared.md` are installed by `initGroupFilesystem()` the first time the host touches the group, so the migration only has to write `CLAUDE.local.md` and leave the scaffolding to the host. + +--- + +## Host process vs containers + +**v1:** single Node process. The "agent" was the same process as the router. + +**v2:** Node host at top, Bun-runtime Docker container per session. They communicate only via the two session DBs. No shared modules, no IPC, no stdin piping. If you wrote custom code that reached from the agent into host internals (or vice versa), that surface no longer exists — porting it is a `/migrate-from-v1` skill topic, not a mechanical copy. + +Lockfiles: host uses `pnpm-lock.yaml`, agent-runner uses `bun.lock`. `minimumReleaseAge: 4320` on the host side (3-day supply-chain wait); agent-runner has no release-age gate. + +--- + +## Self-modification and MCP tools + +**v1:** if you added MCP servers or self-modification plumbing, it was usually direct edits to the long-running process. + +**v2:** +- MCP servers register through `container/agent-runner/src/mcp-tools/*.ts` and load per-session. There's also `install_packages` and `add_mcp_server` self-mod tools that go through an admin-approval flow (`src/modules/self-mod/apply.ts`) before rebuilding the container image. +- Custom MCP tools you wrote in v1 map cleanly to the v2 tool registry, but the import paths, runtime (Bun vs Node), and SQL helper differences (`bun:sqlite` uses `$name`-prefixed params) may need adjustment. The skill walks through this. + +--- + +## Things that are gone or don't map + +- **`scheduled_tasks` as a separate table** — moved into session `inbound.db` under `kind='task'`. Migration ports active rows; inactive/completed are exported to `logs/setup-migration/inactive-tasks.json` for reference. +- **`messages` / `chats` tables (chat history)** — not migrated. Stay in the v1 checkout if you need them. +- **`router_state` (key/value)** — not migrated. v2 state lives in the explicit tables above. +- **`sessions` (v1 group→session_id)** — v1 sessions don't map; v2 sessions are keyed by `(agent_group_id, messaging_group_id, thread_id)` and are created on demand. +- **Raw access to the old `store/messages.db`** — the v1 DB is left in place and untouched. If migration goes wrong you can re-run it (the migration sub-steps are idempotent for agents/channels/wirings; folders use rsync semantics). + +--- + +## Migration surface — where the code lives + +- `setup/migrate-v1.ts` — orchestrator called from `setup/auto.ts` between the timezone and channel steps. +- `setup/migrate-v1/.ts` — registered in `setup/index.ts` STEPS; each runs under `runQuietStep`. +- `logs/setup.log` — progression log. Each sub-step appends one entry. +- `logs/setup-steps/NN-migrate-.log` — raw per-sub-step stdout/stderr. +- `logs/setup-migration/handoff.json` — summary of what was migrated, what failed, what was deferred. Read by the `/migrate-from-v1` skill. +- `logs/setup-migration/schema-mismatch.json` — written only if `migrate-validate` finds a v1 DB that doesn't match the expected shape. The skill uses this to decide what to hand back to the user. +- `logs/setup-migration/inactive-tasks.json` — completed or stopped v1 `scheduled_tasks`, exported for reference. +- `.claude/skills/migrate-from-v1/SKILL.md` — tells Claude how to finish anything the automation couldn't and then interview the user about custom code changes. diff --git a/setup/auto.ts b/setup/auto.ts index 4c20262..720ea0f 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -14,8 +14,10 @@ * "Terminal Agent". * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| - * service|cli-agent|timezone|channel|verify| - * first-chat) + * service|cli-agent|timezone|migration|channel| + * verify|first-chat) + * NANOCLAW_V1_PATH explicit path to a v1 install to migrate + * from (default: scan common locations) * * Timezone is auto-detected after the CLI agent step. UTC resolves are * confirmed with the user, and free-text replies fall through to a @@ -36,6 +38,7 @@ import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { brightSelect } from './lib/bright-select.js'; import { offerClaudeAssist } from './lib/claude-assist.js'; import { runWindowedStep } from './lib/windowed-runner.js'; +import { runMigrateV1 } from './migrate-v1.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { claudeCliAvailable, @@ -306,7 +309,16 @@ async function main(): Promise { await runTimezoneStep(); } + if (!skip.has('migration')) { + // Runs silently when there's no v1 install; otherwise orchestrates the + // detect → validate → db → groups → env → channel-auth → channels → + // tasks sub-steps and writes logs/setup-migration/handoff.json for the + // /migrate-from-v1 skill to pick up. + await runMigrateV1(); + } + let channelChoice: ChannelChoice = 'skip'; + if (!skip.has('channel')) { channelChoice = await askChannelChoice(); if (channelChoice === 'telegram') { diff --git a/setup/index.ts b/setup/index.ts index 25d1934..b327541 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -22,6 +22,14 @@ const STEPS: Record< onecli: () => import('./onecli.js'), auth: () => import('./auth.js'), 'cli-agent': () => import('./cli-agent.js'), + 'migrate-detect': () => import('./migrate-v1/detect.js'), + 'migrate-validate': () => import('./migrate-v1/validate.js'), + 'migrate-db': () => import('./migrate-v1/db.js'), + 'migrate-groups': () => import('./migrate-v1/groups.js'), + 'migrate-env': () => import('./migrate-v1/env.js'), + 'migrate-channel-auth': () => import('./migrate-v1/channel-auth.js'), + 'migrate-channels': () => import('./migrate-v1/channels.js'), + 'migrate-tasks': () => import('./migrate-v1/tasks.js'), }; async function main(): Promise { diff --git a/setup/migrate-v1.ts b/setup/migrate-v1.ts new file mode 100644 index 0000000..a3c1883 --- /dev/null +++ b/setup/migrate-v1.ts @@ -0,0 +1,257 @@ +/** + * v1 → v2 migration orchestrator. Called from setup/auto.ts after the + * timezone step and before the channel step. + * + * Silent happy path: if no v1 install is found, we emit one "skipped" step + * and return. Users on a fresh v2 install never see anything. + * + * When v1 IS found: detect → [confirm] → group-selection prompt → validate + * → db → groups → env → channel-auth → channels → tasks → handoff. + * Every sub-step is a separate entry in the progression log; failures never + * abort the chain (the handoff file records them for the skill to finish). + * + * After everything runs, a one-line note points the user at the + * `/migrate-from-v1` skill. + */ +import fs from 'fs'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import Database from 'better-sqlite3'; +import k from 'kleur'; + +import { ensureAnswer, runQuietStep } from './lib/runner.js'; +import { wrapForGutter } from './lib/theme.js'; +import * as setupLog from './logs.js'; +import { + HANDOFF_PATH, + MIGRATION_DIR, + inferChannelType, + readHandoff, + v1PathsFor, + writeHandoff, +} from './migrate-v1/shared.js'; + +/** + * Count groups in v1's registered_groups, split by whether the channel_type + * can be inferred. Uses the same `inferChannelType` logic as migrate-db so + * the displayed count matches what will actually get seeded. Open-and-close + * because this runs in the orchestrator before migrate-db's child process. + */ +function countV1Groups(v1Root: string): { total: number; wired: number } { + const dbPath = v1PathsFor(v1Root).db; + try { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + const rows = db + .prepare('SELECT jid, channel_name FROM registered_groups') + .all() as Array<{ jid: string; channel_name: string | null }>; + db.close(); + let wired = 0; + for (const r of rows) { + if (inferChannelType(r.jid, r.channel_name)) wired++; + } + return { total: rows.length, wired }; + } catch { + return { total: 0, wired: 0 }; + } +} + +async function askGroupSelection(counts: { total: number; wired: number }): Promise<'all' | 'wired-only' | 'cancel'> { + // Non-interactive escape hatch for CI / re-runs / scripted migrations. + // NANOCLAW_MIGRATE_SELECTION = 'all' | 'wired-only' | 'cancel'. + const envChoice = process.env.NANOCLAW_MIGRATE_SELECTION?.trim(); + if (envChoice === 'all' || envChoice === 'wired-only' || envChoice === 'cancel') { + setupLog.userInput('migrate_selection', `${envChoice} (from NANOCLAW_MIGRATE_SELECTION)`); + return envChoice; + } + // Most v1 installs accumulated many orphan folders. Default the user to + // wired-only (the ones we can actually route) — explicit opt-in for "all". + const choice = ensureAnswer( + await p.select({ + message: `Found ${counts.total} v1 group folders (${counts.wired} wired to a channel). Which to bring over?`, + options: [ + { + value: 'wired-only', + label: `Only the ${counts.wired} wired ones`, + hint: 'recommended — skips orphans', + }, + { + value: 'all', + label: `All ${counts.total} folders`, + hint: 'brings dead/orphan folders over too', + }, + { + value: 'cancel', + label: 'Skip migration', + hint: "I'll migrate later", + }, + ], + }), + ) as 'all' | 'wired-only' | 'cancel'; + setupLog.userInput('migrate_selection', choice); + return choice; +} + +/** + * Finalize the handoff record after every sub-step has run. Computes an + * overall status from per-step statuses: anything `failed` → partial; + * anything `partial` → partial; else success. + */ +function finalizeHandoff(): 'success' | 'partial' | 'failed' { + const h = readHandoff(); + const statuses = Object.values(h.steps).map((s) => s?.status); + const anyFailed = statuses.includes('failed'); + const anyPartial = statuses.includes('partial'); + const overall: 'success' | 'partial' | 'failed' = anyFailed + ? 'partial' // DB or files may have landed; the skill can pick up the rest + : anyPartial + ? 'partial' + : 'success'; + h.overall_status = overall; + writeHandoff(h); + return overall; +} + +function printHandoffNote(overall: 'success' | 'partial' | 'failed'): void { + const relHandoff = path.relative(process.cwd(), HANDOFF_PATH); + const lines: string[] = []; + if (overall === 'success') { + lines.push( + wrapForGutter( + 'Your v1 install has been migrated. Run `/migrate-from-v1` in Claude next — it will seed your owner account and help port any custom code you had.', + 4, + ), + ); + } else { + lines.push( + wrapForGutter( + 'Migration finished with some items for a human. Run `/migrate-from-v1` in Claude — it will read the handoff, finish the unfinished steps, and walk through custom code.', + 4, + ), + ); + } + lines.push(''); + lines.push(k.dim(` Handoff: ${relHandoff}`)); + lines.push(k.dim(` Full log: ${setupLog.progressLogPath}`)); + lines.push(k.dim(` Raw logs: ${setupLog.stepsDir}/`)); + p.note(lines.join('\n'), 'Migration handoff'); +} + +export async function runMigrateV1(): Promise<'proceeded' | 'skipped' | 'cancelled'> { + // 0. Ensure migration log dir exists before any sub-step writes to it. + fs.mkdirSync(MIGRATION_DIR, { recursive: true }); + + // 1. Detect. If nothing obvious, give the user one subtle chance to point + // us at a non-standard path — then accept silently. + const detect = await runQuietStep('migrate-detect', { + running: 'Checking for a previous NanoClaw install…', + done: 'Found a previous install.', + skipped: 'No previous install to migrate.', + }); + + const v1Found = detect.ok && detect.terminal?.fields.STATUS === 'success'; + + if (!v1Found) { + // Silent skip — the 99% case is a fresh install with no v1 anywhere. + // Prompting for a custom path on every fresh run is UX noise. Users + // with a v1 at a non-standard location use `NANOCLAW_V1_PATH= + // bash nanoclaw.sh` (documented in README + setup/auto.ts header). + return 'skipped'; + } + + // 2. Ask the user which groups to bring over. + const h = readHandoff(); + if (!h.v1_path) { + // Shouldn't happen — detect set it if v1Found. Guard anyway. + return 'skipped'; + } + + // Experimental warning — fires only when a v1 install is found, so stock + // v2 users (no v1 to migrate) never see it. Not a blocker; the user can + // still proceed. Skip when NANOCLAW_MIGRATE_SELECTION is set (scripted / + // CI runs have already accepted the risk by defining their selection). + if (!process.env.NANOCLAW_MIGRATE_SELECTION) { + p.log.warn( + wrapForGutter( + 'v1 → v2 migration is experimental. Back up your v2 state (data/v2.db, groups/) before continuing. Not recommended for high-stakes production installs — it does a best-effort port and a human still has to finish via /migrate-from-v1.', + 4, + ), + ); + } + + const counts = countV1Groups(h.v1_path); + const selection = await askGroupSelection(counts); + if (selection === 'cancel') { + // Mark the handoff so the skill can still see what would have happened. + const ho = readHandoff(); + ho.overall_status = 'skipped'; + writeHandoff(ho); + return 'cancelled'; + } + + // 3. Validate — if it fails, subsequent steps will short-circuit the + // DB-dependent parts. Groups + env still run. + await runQuietStep('migrate-validate', { + running: "Checking the v1 database's shape…", + done: 'v1 database looks good.', + failed: "v1 database didn't match what I expected.", + skipped: 'Skipped database validation.', + }); + + // 4. DB seeding — parameterized by the user's selection. + await runQuietStep( + 'migrate-db', + { + running: 'Seeding v2 agents and channels from v1…', + done: 'Seeded v2 database.', + skipped: 'Skipped database seeding.', + failed: "Couldn't seed the v2 database.", + }, + ['--selection', selection], + ); + + // 5. Group folders. + await runQuietStep('migrate-groups', { + running: 'Copying group folders…', + done: 'Group folders copied.', + skipped: 'Skipped group-folder copy.', + failed: "Couldn't copy some group folders.", + }); + + // 6. Env keys. + await runQuietStep('migrate-env', { + running: 'Merging v1 .env into v2 .env…', + done: 'Env keys migrated.', + skipped: 'No env keys to migrate.', + failed: "Couldn't merge .env.", + }); + + // 7. Non-env channel auth (Baileys keystore, matrix state, etc.). + await runQuietStep('migrate-channel-auth', { + running: 'Copying channel auth files…', + done: 'Channel auth copied.', + skipped: 'No channel auth to copy.', + failed: 'Some channel auth files need attention.', + }); + + // 8. Install v2 channel adapters for the detected channels. + await runQuietStep('migrate-channels', { + running: 'Installing v2 channel adapters…', + done: 'Channel adapters installed.', + skipped: 'No channels to install.', + failed: 'Some channel adapters need attention.', + }); + + // 9. Scheduled tasks. + await runQuietStep('migrate-tasks', { + running: 'Porting scheduled tasks…', + done: 'Scheduled tasks ported.', + skipped: 'No scheduled tasks to port.', + failed: 'Some scheduled tasks need attention.', + }); + + // 10. Finalize + hand off. + const overall = finalizeHandoff(); + printHandoffNote(overall); + return 'proceeded'; +} diff --git a/setup/migrate-v1/channel-auth.ts b/setup/migrate-v1/channel-auth.ts new file mode 100644 index 0000000..1ef2bc4 --- /dev/null +++ b/setup/migrate-v1/channel-auth.ts @@ -0,0 +1,213 @@ +/** + * Step: migrate-channel-auth + * + * For each channel detected in migrate-db, copy non-.env auth state from v1 + * to the matching v2 location. Env keys are handled by migrate-env (this + * step reads the registry to confirm they made it over, but doesn't rewrite + * them). Files are copied from the first matching candidate path in the + * registry — missing paths are recorded so the skill can prompt the user. + * + * Destination uses the same relative path on v2 (e.g. v1 has + * `data/sessions/baileys/` → v2 gets `data/sessions/baileys/`). If v2 already + * has a different file/dir at that path, we skip and flag it — never clobber. + */ +import fs from 'fs'; +import path from 'path'; + +import { emitStatus } from '../status.js'; +import { + CHANNEL_AUTH_REGISTRY, + readHandoff, + recordStep, + v1PathsFor, + writeHandoff, +} from './shared.js'; + +/** + * Copy file or directory tree from src to dst. `force: false` means existing + * files on the v2 side are never clobbered — important because we'd otherwise + * overwrite auth state the user may have set up on v2 directly. Returns a + * rough count of files copied (post-hoc walk of the destination). + */ +function copyRecursive(src: string, dst: string): number { + if (!fs.existsSync(src)) return 0; + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.cpSync(src, dst, { recursive: true, force: false, errorOnExist: false }); + return countFilesUnder(dst); +} + +function countFilesUnder(p: string): number { + if (!fs.existsSync(p)) return 0; + if (fs.statSync(p).isFile()) return 1; + let n = 0; + for (const entry of fs.readdirSync(p, { withFileTypes: true })) { + n += countFilesUnder(path.join(p, entry.name)); + } + return n; +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-channel-auth', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_CHANNEL_AUTH', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + const channels = h.detected_channels; + if (channels.length === 0) { + recordStep('migrate-channel-auth', { + status: 'skipped', + fields: { REASON: 'no-channels-detected' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_CHANNEL_AUTH', { STATUS: 'skipped', REASON: 'no_channels' }); + return; + } + + const v1Paths = v1PathsFor(h.v1_path); + const v1Env = fs.existsSync(v1Paths.env) ? fs.readFileSync(v1Paths.env, 'utf-8') : ''; + const v1EnvKeys = new Set( + v1Env + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => line.split('=')[0].trim()) + .filter(Boolean), + ); + + const results: typeof h.channel_auth = []; + const followups: string[] = []; + let anyMissingRequired = false; + + for (const ch of channels) { + const spec = CHANNEL_AUTH_REGISTRY[ch.channel_type]; + if (!spec) { + // Unknown channel — give the skill enough context to drive a useful + // interview instead of a generic "we don't know." Scan v1's .env for + // keys that look related (substring match on channel name + common + // suffixes) and list v1 state directories the user should check. + const haystack = ch.channel_type.toLowerCase(); + const candidateEnvKeys = [...v1EnvKeys].filter((k) => { + const lk = k.toLowerCase(); + return ( + lk.includes(haystack) || + (haystack.length >= 3 && lk.includes(haystack.slice(0, 3))) + ); + }); + const v1DataDirs = ['data', 'store', 'data/sessions'] + .map((d) => path.join(h.v1_path, d)) + .filter((p) => fs.existsSync(p)); + + results.push({ + channel_type: ch.channel_type, + env_keys_copied: [], + files_copied: [], + files_missing: [], + notes: `Unknown channel (not in CHANNEL_AUTH_REGISTRY). Inferred via ${ch.source}. Candidate v1 env keys: ${candidateEnvKeys.join(', ') || 'none found'}. Check v1 dirs: ${v1DataDirs.join(', ') || '(none)'}.`, + }); + followups.push( + `Channel "${ch.channel_type}" (${ch.group_count} group(s), inferred via ${ch.source}) is not in the auth registry. ` + + `Candidate v1 env keys that may belong to it: ${candidateEnvKeys.length > 0 ? candidateEnvKeys.join(', ') : '(none obvious)'}. ` + + `Check v1 for on-disk auth state under ${v1DataDirs.join(', ') || '(no standard dirs found)'}. ` + + `The skill should interview the user, then add a registry entry to setup/migrate-v1/shared.ts for future migrations.`, + ); + continue; + } + + const envKeysPresentInV1 = spec.v1EnvKeys.filter((key) => v1EnvKeys.has(key)); + + // Check v2's .env for required keys the v2 adapter needs to boot. v1 + // may not have had all of them (e.g. v1's Discord used discord.js + // directly and never stored DISCORD_PUBLIC_KEY which v2's Chat SDK + // requires). Surface missing ones as actionable followups. + const v2EnvPath = path.join(process.cwd(), '.env'); + const v2Env = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; + const v2EnvKeys = new Set( + v2Env + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')) + .map((l) => l.split('=')[0].trim()) + .filter(Boolean), + ); + const missingRequired = spec.requiredV2Keys.filter((r) => !v2EnvKeys.has(r.key)); + if (missingRequired.length > 0) { + anyMissingRequired = true; + followups.push( + `Channel "${ch.channel_type}" is missing required v2 keys in .env: ${missingRequired + .map((r) => `${r.key} (${r.where})`) + .join('; ')}. The v2 adapter won't boot until these are set.`, + ); + } + + const filesCopied: string[] = []; + const filesMissing: string[] = []; + + for (const relPath of spec.candidatePaths) { + const src = path.join(h.v1_path, relPath); + if (!fs.existsSync(src)) continue; + + const dst = path.join(process.cwd(), relPath); + if (fs.existsSync(dst)) { + followups.push( + `Channel "${ch.channel_type}": v2 already has ${relPath} — left untouched. Reconcile manually if needed.`, + ); + filesMissing.push(`${relPath} (already exists in v2)`); + continue; + } + + try { + const count = copyRecursive(src, dst); + filesCopied.push(`${relPath} (${count} files)`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + filesMissing.push(`${relPath} (copy failed: ${message})`); + followups.push(`Channel "${ch.channel_type}": failed to copy ${relPath} — ${message}`); + } + } + + if (spec.candidatePaths.length > 0 && filesCopied.length === 0) { + filesMissing.push(`(no candidate paths existed under ${h.v1_path})`); + } + + results.push({ + channel_type: ch.channel_type, + env_keys_copied: envKeysPresentInV1, + files_copied: filesCopied, + files_missing: filesMissing, + notes: spec.note ?? '', + }); + } + + const handoffAfter = readHandoff(); + handoffAfter.channel_auth = results; + handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; + writeHandoff(handoffAfter); + + const anyFileMissing = results.some((r) => r.files_missing.length > 0); + const anyPartial = anyFileMissing || anyMissingRequired; + recordStep('migrate-channel-auth', { + status: anyPartial ? 'partial' : 'success', + fields: { + CHANNELS: channels.map((c) => c.channel_type).join(','), + FILES_COPIED: results.reduce((sum, r) => sum + r.files_copied.length, 0), + FILES_MISSING: results.reduce((sum, r) => sum + r.files_missing.length, 0), + }, + notes: followups, + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_CHANNEL_AUTH', { + STATUS: anyPartial ? 'partial' : 'success', + CHANNELS: channels.map((c) => c.channel_type).join(','), + FILES_COPIED: String(results.reduce((sum, r) => sum + r.files_copied.length, 0)), + FILES_MISSING: String(results.reduce((sum, r) => sum + r.files_missing.length, 0)), + }); +} diff --git a/setup/migrate-v1/channels.ts b/setup/migrate-v1/channels.ts new file mode 100644 index 0000000..89df966 --- /dev/null +++ b/setup/migrate-v1/channels.ts @@ -0,0 +1,172 @@ +/** + * Step: migrate-channels + * + * For each channel detected in migrate-db, run the corresponding v2 + * `setup/install-.sh` script in non-interactive mode. The script + * copies the adapter from the `channels` branch, installs the pinned + * dependency, and rebuilds. Credentials in v2 `.env` (migrate-env already + * copied them) are picked up automatically on the next service restart. + * + * This step does NOT run the pairing flow for each channel (that needs + * interactive prompts). The user is guided through pairing by the normal + * channel-selection step in setup/auto.ts, which happens immediately after + * migration. Installing the adapter first means that step won't have to + * re-install. + * + * Channels not supported in v2 are recorded in the handoff as + * `not_supported` so the skill can raise them with the user. + */ +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { log } from '../../src/log.js'; +import { emitStatus } from '../status.js'; +import { + installScriptForChannel, + readHandoff, + recordStep, + writeHandoff, +} from './shared.js'; + +function runScript(script: string): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn('bash', [script], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, MIGRATION_NONINTERACTIVE: '1' }, + }); + // Capture both streams silently — the parent is under a clack spinner, + // and forwarding to stdout/stderr would break the spinner UI. The full + // transcript still lands in this step's raw log via the parent's tee + // (runner.ts: spawnStep writes this step's stdout/stderr to logs/setup- + // steps/NN-migrate-channels.log already). + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (c: Buffer) => { + stdout += c.toString('utf-8'); + }); + child.stderr.on('data', (c: Buffer) => { + stderr += c.toString('utf-8'); + }); + child.on('close', (code) => + resolve({ code: code ?? 1, stdout, stderr }), + ); + child.on('error', () => + resolve({ code: 1, stdout, stderr: stderr || 'spawn_error' }), + ); + }); +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-channels', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_CHANNELS', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + const channels = h.detected_channels; + if (channels.length === 0) { + recordStep('migrate-channels', { + status: 'skipped', + fields: { REASON: 'no-channels-detected' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_CHANNELS', { STATUS: 'skipped', REASON: 'no_channels' }); + return; + } + + const results: typeof h.channels_installed = []; + const followups: string[] = []; + + for (const ch of channels) { + const script = installScriptForChannel(ch.channel_type); + if (!script) { + results.push({ + channel_type: ch.channel_type, + status: 'not_supported', + }); + followups.push( + `Channel "${ch.channel_type}" has no v2 install script. The /migrate-from-v1 skill should ask the user whether to keep it as an orphan messaging_group or drop it.`, + ); + continue; + } + + const absoluteScript = path.join(process.cwd(), script); + if (!fs.existsSync(absoluteScript)) { + results.push({ + channel_type: ch.channel_type, + status: 'failed', + error: `install script missing at ${script}`, + }); + followups.push(`Install script for "${ch.channel_type}" missing at ${script} — this is a v2 repo issue, not a user issue.`); + continue; + } + + log.info('Running channel install script', { channel: ch.channel_type, script: absoluteScript }); + const { code, stdout, stderr } = await runScript(absoluteScript); + // Persist the install-script output to a sidecar so the skill can read it + // if diagnosis is needed. The parent's tee already captures our own + // stdout/stderr but the nested script's output is lost otherwise. + try { + const sidecar = path.join( + process.cwd(), + 'logs', + 'setup-migration', + `install-${ch.channel_type}.log`, + ); + fs.mkdirSync(path.dirname(sidecar), { recursive: true }); + fs.writeFileSync(sidecar, `# ${script}\n# exit ${code}\n\n=== stdout ===\n${stdout}\n=== stderr ===\n${stderr}\n`); + } catch { + // Sidecar is diagnostic-only — don't abort if the log dir is unwritable. + } + if (code === 0) { + results.push({ channel_type: ch.channel_type, status: 'success' }); + } else { + results.push({ + channel_type: ch.channel_type, + status: 'failed', + error: stderr.trim().slice(0, 400) || `exit ${code}`, + }); + followups.push( + `Installing "${ch.channel_type}" failed (exit ${code}). The /migrate-from-v1 skill should retry ${script} or walk the user through /add-${ch.channel_type}.`, + ); + } + } + + const handoffAfter = readHandoff(); + handoffAfter.channels_installed = results; + handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; + writeHandoff(handoffAfter); + + // `not_supported` is an expected/known outcome for channels whose v1 adapter + // has no v2 equivalent yet. It's a followup for the skill to raise — not a + // partial success. Only real install failures degrade status. + const anyFailed = results.some((r) => r.status === 'failed'); + const status: 'success' | 'partial' | 'failed' = anyFailed ? 'partial' : 'success'; + + recordStep('migrate-channels', { + status, + fields: { + INSTALLED: results.filter((r) => r.status === 'success').length, + FAILED: results.filter((r) => r.status === 'failed').length, + NOT_SUPPORTED: results.filter((r) => r.status === 'not_supported').length, + CHANNELS: results.map((r) => `${r.channel_type}=${r.status}`).join(','), + }, + notes: followups, + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_CHANNELS', { + STATUS: status, + INSTALLED: String(results.filter((r) => r.status === 'success').length), + FAILED: String(results.filter((r) => r.status === 'failed').length), + NOT_SUPPORTED: String(results.filter((r) => r.status === 'not_supported').length), + }); +} diff --git a/setup/migrate-v1/db.ts b/setup/migrate-v1/db.ts new file mode 100644 index 0000000..e455012 --- /dev/null +++ b/setup/migrate-v1/db.ts @@ -0,0 +1,296 @@ +/** + * 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 ` 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 { + 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 { + 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(); + + 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); + + // 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 platformId = v2PlatformId(channelType, g.jid); + 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', + }); +} diff --git a/setup/migrate-v1/detect.ts b/setup/migrate-v1/detect.ts new file mode 100644 index 0000000..983531d --- /dev/null +++ b/setup/migrate-v1/detect.ts @@ -0,0 +1,107 @@ +/** + * Step: migrate-detect + * + * Find a v1 install on disk. Scans the standard candidate paths; if none + * matches, exits with a NOT_FOUND status (the orchestrator then offers a + * clack prompt so the user can point at a custom path). + * + * Never prompts — this step is pure discovery so it stays safe to run under + * NANOCLAW_SKIP= without blocking on stdin. + */ +import fs from 'fs'; +import path from 'path'; + +import { emitStatus } from '../status.js'; +import { + defaultV1Candidates, + looksLikeV1Install, + readHandoff, + recordStep, + v1PathsFor, + writeHandoff, +} from './shared.js'; + +interface DetectArgs { + /** Explicit path to check, skipping the default candidate list. */ + path?: string; +} + +function parseArgs(args: string[]): DetectArgs { + const out: DetectArgs = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--path') { + out.path = args[++i] || undefined; + } + } + return out; +} + +export async function run(args: string[]): Promise { + const parsed = parseArgs(args); + + // An explicit path — either from --path or $NANOCLAW_V1_PATH — is + // authoritative. If it doesn't validate, we don't fall through to + // the default candidate list. That keeps the user's explicit intent + // from being silently overridden. + const envOverride = process.env.NANOCLAW_V1_PATH?.trim(); + const explicit = parsed.path ?? envOverride ?? null; + const candidates = explicit ? [explicit] : defaultV1Candidates(); + + for (const candidate of candidates) { + const absolute = path.resolve(candidate); + // Don't self-match — if the candidate resolves to the v2 checkout we're + // running inside, skip it. Protects users who cloned v2 into `~/nanoclaw` + // after deleting v1. + if (absolute === path.resolve(process.cwd())) continue; + + const check = looksLikeV1Install(absolute); + if (!check.ok) continue; + + const paths = v1PathsFor(absolute); + let version = 'unknown'; + try { + const pkg = JSON.parse(fs.readFileSync(paths.packageJson, 'utf-8')) as { version?: string }; + version = pkg.version ?? 'unknown'; + } catch { + // Already sanity-checked by looksLikeV1Install — a failure here means + // the file changed under us between calls. Unlikely, not fatal. + } + + const h = readHandoff(); + h.v1_path = absolute; + h.v1_version = version; + writeHandoff(h); + + recordStep('migrate-detect', { + status: 'success', + fields: { V1_PATH: absolute, V1_VERSION: version }, + notes: [], + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_DETECT', { + STATUS: 'success', + V1_PATH: absolute, + V1_VERSION: version, + DB_PATH: paths.db, + ENV_PATH: paths.env, + GROUPS_PATH: paths.groups, + }); + return; + } + + // Nothing matched. Not an error — most v2 installs are fresh, not migrations. + const scanned = candidates.map((c) => path.resolve(c)).join(','); + recordStep('migrate-detect', { + status: 'skipped', + fields: { REASON: 'no-v1-install-found' }, + notes: [`Scanned: ${scanned}`], + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_DETECT', { + STATUS: 'skipped', + REASON: 'not_found', + CANDIDATES_SCANNED: String(candidates.length), + }); +} diff --git a/setup/migrate-v1/env.ts b/setup/migrate-v1/env.ts new file mode 100644 index 0000000..e2530e0 --- /dev/null +++ b/setup/migrate-v1/env.ts @@ -0,0 +1,135 @@ +/** + * Step: migrate-env + * + * Copy every key from v1 `.env` to v2 `.env`. Preserves v2 values that + * already exist (never overwrites). Skips lines that don't look like a + * `KEY=value` pair. + * + * Why copy everything, not a curated list? v1 installs accumulate + * project-specific keys (custom MCP creds, feature flags, webhook tokens) + * that the migration can't enumerate ahead of time. The user explicitly + * asked for everything. We log what we carried so the skill can review. + * + * Security note: we do NOT log values here — only keys. The raw log already + * contains the file contents; we don't echo them to stdout. + */ +import fs from 'fs'; +import path from 'path'; + +import { emitStatus } from '../status.js'; +import { readHandoff, recordStep, v1PathsFor } from './shared.js'; + +interface EnvLine { + key: string; + value: string; + raw: string; +} + +function parseEnv(text: string): EnvLine[] { + const out: EnvLine[] = []; + for (const raw of text.split('\n')) { + const line = raw.trimEnd(); + if (!line) continue; + if (line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue; + const value = line.slice(eq + 1); + out.push({ key, value, raw: line }); + } + return out; +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-env', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_ENV', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + const paths = v1PathsFor(h.v1_path); + if (!fs.existsSync(paths.env)) { + recordStep('migrate-env', { + status: 'skipped', + fields: { REASON: 'v1-env-missing' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_ENV', { STATUS: 'skipped', REASON: 'v1_env_missing' }); + return; + } + + const v2EnvPath = path.join(process.cwd(), '.env'); + const v1Text = fs.readFileSync(paths.env, 'utf-8'); + const v1Lines = parseEnv(v1Text); + + let v2Text = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; + const v2Lines = parseEnv(v2Text); + const v2Keys = new Set(v2Lines.map((l) => l.key)); + + const copied: string[] = []; + const skipped: string[] = []; + const appended: string[] = []; + + // Tag the appended block so a later re-run can find it and not double-append. + const BLOCK_START = '# ── migrated from v1 ──'; + const alreadyMigrated = v2Text.includes(BLOCK_START); + + for (const line of v1Lines) { + if (v2Keys.has(line.key)) { + skipped.push(line.key); + continue; + } + copied.push(line.key); + appended.push(line.raw); + } + + if (appended.length > 0) { + const suffix = [ + v2Text.endsWith('\n') || v2Text === '' ? '' : '\n', + alreadyMigrated ? '' : `\n${BLOCK_START}\n`, + appended.join('\n'), + '\n', + ].join(''); + v2Text = v2Text + suffix; + fs.writeFileSync(v2EnvPath, v2Text); + } + + // Container reads from data/env/env (host mounts it). Keep it in sync. + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + try { + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); + } catch { + // Non-fatal; the service restart (later step) will rehydrate if needed. + } + + recordStep('migrate-env', { + status: 'success', + fields: { + KEYS_COPIED: copied.length, + KEYS_SKIPPED_EXISTING: skipped.length, + V1_ENV: paths.env, + V2_ENV: v2EnvPath, + }, + notes: [ + copied.length > 0 ? `Copied: ${copied.join(', ')}` : '', + skipped.length > 0 ? `Skipped (already in v2 .env): ${skipped.join(', ')}` : '', + ].filter(Boolean), + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_ENV', { + STATUS: 'success', + KEYS_COPIED: String(copied.length), + KEYS_SKIPPED_EXISTING: String(skipped.length), + COPIED_KEYS: copied.join(',') || 'none', + }); +} diff --git a/setup/migrate-v1/groups.ts b/setup/migrate-v1/groups.ts new file mode 100644 index 0000000..206f441 --- /dev/null +++ b/setup/migrate-v1/groups.ts @@ -0,0 +1,230 @@ +/** + * Step: migrate-groups + * + * Copy v1 group folders into v2. For each folder selected in migrate-db: + * - Create groups// in v2 if missing + * - Copy v1's CLAUDE.md to v2 as CLAUDE.local.md (v2 composes CLAUDE.md at + * container spawn — don't write directly to CLAUDE.md) + * - If v1 had a container_config JSON, write it to .v1-container-config.json + * for the /migrate-from-v1 skill to reconcile (v2's container.json shape + * has drifted enough that a silent 1:1 copy would be wrong) + * - Preserve any other non-standard files from the v1 folder (e.g. SOUL.md, + * personality.md, custom subdirs) — rsync-style, skipping destination files + * that already exist. + * + * Does not overwrite files already present in v2 — re-running is safe. + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +import { log } from '../../src/log.js'; +import { emitStatus } from '../status.js'; +import { + readHandoff, + recordStep, + safeJsonStringify, + scanForV1Patterns, + v1PathsFor, + writeHandoff, +} from './shared.js'; + +const SKIP_NAMES = new Set(['CLAUDE.md', 'logs', '.git', '.DS_Store', 'node_modules']); + +/** + * Copy everything in src except SKIP_NAMES. CLAUDE.md is handled separately. + * Returns the count of files actually written (skipped-existing not counted). + */ +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; + } + // Don't clobber files v2 already has (e.g. CLAUDE.local.md that the + // operator already wrote). Append-only semantics for this step. + if (fs.existsSync(d)) continue; + fs.copyFileSync(s, d); + written += 1; + } + return written; +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-groups', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_GROUPS', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + if (h.group_selection.selected_folders.length === 0) { + recordStep('migrate-groups', { + status: 'skipped', + fields: { REASON: 'no-folders-selected' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_GROUPS', { STATUS: 'skipped', REASON: 'no_selection' }); + return; + } + + const paths = v1PathsFor(h.v1_path); + const v2GroupsDir = path.join(process.cwd(), 'groups'); + fs.mkdirSync(v2GroupsDir, { recursive: true }); + + // Pull container_config for each selected folder up-front so we can write + // the .v1-container-config.json sidecar without holding the DB open per-folder. + const containerConfigs = new Map(); + try { + const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); + const rows = v1Db + .prepare('SELECT folder, container_config FROM registered_groups WHERE folder IN (SELECT value FROM json_each(?))') + .all(JSON.stringify(h.group_selection.selected_folders)) as Array<{ folder: string; container_config: string | null }>; + for (const r of rows) containerConfigs.set(r.folder, r.container_config); + v1Db.close(); + } catch (err) { + // Older sqlite without json_each would break the query. Fall back to + // per-folder reads — slower but reliable. + log.info('Falling back to per-folder container_config lookup', { err }); + try { + const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); + const stmt = v1Db.prepare('SELECT container_config FROM registered_groups WHERE folder = ?'); + for (const folder of h.group_selection.selected_folders) { + const row = stmt.get(folder) as { container_config: string | null } | undefined; + containerConfigs.set(folder, row?.container_config ?? null); + } + v1Db.close(); + } catch { + // Give up — we still migrate files; the skill handles missing config. + } + } + + let foldersProcessed = 0; + let foldersSkippedMissing = 0; + let claudeMdMigrated = 0; + let claudeLocalPreserved = 0; + let containerConfigsStashed = 0; + let otherFilesCopied = 0; + const followups: string[] = []; + + for (const folder of h.group_selection.selected_folders) { + const v1Folder = path.join(paths.groups, folder); + const v2Folder = path.join(v2GroupsDir, folder); + + if (!fs.existsSync(v1Folder)) { + foldersSkippedMissing += 1; + followups.push( + `Folder "${folder}" was in v1's registered_groups but not on disk at ${v1Folder} — DB entry was seeded, no files to migrate.`, + ); + continue; + } + + fs.mkdirSync(v2Folder, { recursive: true }); + + // CLAUDE.md → CLAUDE.local.md. Don't write CLAUDE.md directly — v2's + // group-init.ts composes that file from shared + fragments + local. + const v1Claude = path.join(v1Folder, 'CLAUDE.md'); + const v2Local = path.join(v2Folder, 'CLAUDE.local.md'); + let claudeContent: string | null = null; + if (fs.existsSync(v1Claude)) { + if (fs.existsSync(v2Local)) { + claudeLocalPreserved += 1; + try { + claudeContent = fs.readFileSync(v2Local, 'utf-8'); + } catch { + claudeContent = null; + } + } else { + try { + claudeContent = fs.readFileSync(v1Claude, 'utf-8'); + fs.writeFileSync(v2Local, claudeContent); + claudeMdMigrated += 1; + } catch (err) { + followups.push(`Failed to copy CLAUDE.md for "${folder}": ${err instanceof Error ? err.message : err}`); + } + } + } + + // Scan the copied content for v1-specific infrastructure patterns. If we + // find any, add a followup so the /migrate-from-v1 skill can triage the + // file with the user. We DON'T edit the file — v1 CLAUDE.md can be + // author-specific and heuristic translation is worse than a flag. + if (claudeContent) { + const matches = scanForV1Patterns(claudeContent); + if (matches.length > 0) { + const summary = matches + .map((m) => `${m.description} (lines ${m.lines.join(',')})`) + .join('; '); + followups.push( + `Folder "${folder}" CLAUDE.local.md references v1-specific infrastructure: ${summary}. The skill should read the file and translate patterns using docs/v1-to-v2-changes.md.`, + ); + } + } + + // Stash container_config JSON so the skill can reconcile it. + const config = containerConfigs.get(folder); + if (config) { + const sidecar = path.join(v2Folder, '.v1-container-config.json'); + try { + // Pretty-print so humans can read it during reconciliation. + const parsed = JSON.parse(config) as unknown; + fs.writeFileSync(sidecar, safeJsonStringify(parsed)); + containerConfigsStashed += 1; + followups.push( + `Folder "${folder}" has a v1 container_config — stashed at ${path.relative(process.cwd(), sidecar)}. The /migrate-from-v1 skill will map it to v2's container.json shape.`, + ); + } catch { + // Non-JSON container_config — write raw so the skill can still read it. + fs.writeFileSync(sidecar, config); + containerConfigsStashed += 1; + } + } + + otherFilesCopied += copyTree(v1Folder, v2Folder); + foldersProcessed += 1; + } + + // Merge followups. + const handoffAfter = readHandoff(); + handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; + writeHandoff(handoffAfter); + + const partial = foldersSkippedMissing > 0; + recordStep('migrate-groups', { + status: partial ? 'partial' : 'success', + fields: { + FOLDERS_PROCESSED: foldersProcessed, + FOLDERS_SKIPPED_MISSING: foldersSkippedMissing, + CLAUDE_MD_MIGRATED: claudeMdMigrated, + CLAUDE_LOCAL_PRESERVED: claudeLocalPreserved, + CONTAINER_CONFIGS_STASHED: containerConfigsStashed, + OTHER_FILES_COPIED: otherFilesCopied, + }, + notes: followups, + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_GROUPS', { + STATUS: partial ? 'partial' : 'success', + FOLDERS_PROCESSED: String(foldersProcessed), + FOLDERS_SKIPPED_MISSING: String(foldersSkippedMissing), + CLAUDE_MD_MIGRATED: String(claudeMdMigrated), + CONTAINER_CONFIGS_STASHED: String(containerConfigsStashed), + OTHER_FILES_COPIED: String(otherFilesCopied), + }); +} diff --git a/setup/migrate-v1/shared.ts b/setup/migrate-v1/shared.ts new file mode 100644 index 0000000..f9f03bc --- /dev/null +++ b/setup/migrate-v1/shared.ts @@ -0,0 +1,639 @@ +/** + * Shared types, constants, and helpers for the v1 → v2 migration. + * + * The migration is a sequence of small steps registered in setup/index.ts + * (migrate-detect, migrate-validate, migrate-db, …). Every step: + * - Reads state it needs from `logs/setup-migration/handoff.json` + * - Writes its own outcome back to that handoff file + * - Emits exactly one `=== NANOCLAW SETUP: MIGRATE_ ===` block on stdout + * + * No step aborts the chain on failure — the orchestrator in setup/migrate-v1.ts + * reads the handoff after each step to decide whether to continue, skip, or + * hand off to the Claude `/migrate-from-v1` skill. + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// ── Paths ────────────────────────────────────────────────────────────── + +export const MIGRATION_DIR = path.join('logs', 'setup-migration'); +export const HANDOFF_PATH = path.join(MIGRATION_DIR, 'handoff.json'); +export const SCHEMA_MISMATCH_PATH = path.join(MIGRATION_DIR, 'schema-mismatch.json'); +export const INACTIVE_TASKS_PATH = path.join(MIGRATION_DIR, 'inactive-tasks.json'); + +// ── V1 install discovery ─────────────────────────────────────────────── + +/** + * Default candidate paths to scan for a v1 install. Combines: + * - `$NANOCLAW_V1_PATH` (explicit override, takes priority) + * - Sibling directories of the v2 checkout whose name contains "nanoclaw" + * or "clawdbot" (most common layout — v1 lives next to v2) + * - Common checkout locations under $HOME + * - Common XDG-style state dirs (.nanoclaw, .clawdbot — v1's predecessor) + * + * Kept generic — don't bake specific usernames in. Deduped so a path that + * satisfies multiple rules only appears once. + */ +export function defaultV1Candidates(): string[] { + const home = os.homedir(); + const cwd = process.cwd(); + const cwdParent = path.dirname(cwd); + + const siblings: string[] = []; + try { + if (fs.existsSync(cwdParent)) { + for (const entry of fs.readdirSync(cwdParent, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const lower = entry.name.toLowerCase(); + // Match anything claw-ish next to v2: "nanoclaw", "nanoclaw-v1", + // "clawdbot", user's fork name like "nanoclaw-prod". Excludes the + // v2 checkout we're running from so we don't self-match. + if (!lower.includes('claw')) continue; + const full = path.join(cwdParent, entry.name); + if (path.resolve(full) === path.resolve(cwd)) continue; + siblings.push(full); + } + } + } catch { + // Can't list parent — fall through to the fixed list. + } + + const fixed = [ + path.join(home, 'nanoclaw'), + path.join(home, '.nanoclaw'), + path.join(home, 'clawdbot'), + path.join(home, '.clawdbot'), + path.join(home, 'Code', 'nanoclaw'), + path.join(home, 'code', 'nanoclaw'), + path.join(home, 'projects', 'nanoclaw'), + path.join(home, 'Projects', 'nanoclaw'), + path.join(home, 'src', 'nanoclaw'), + path.join(home, 'dev', 'nanoclaw'), + path.join(home, 'workspace', 'nanoclaw'), + path.join(home, 'Documents', 'nanoclaw'), + path.join(home, 'GitHub', 'nanoclaw'), + path.join(home, 'github', 'nanoclaw'), + path.join(home, 'repos', 'nanoclaw'), + ]; + + // NANOCLAW_V1_PATH is handled authoritatively by detect.ts — if it's set, + // detect doesn't call this function at all. So we only build the + // auto-discovery list here. + const all = [...siblings, ...fixed]; + + // Dedupe by resolved path. A sibling "nanoclaw" and a fixed "$HOME/nanoclaw" + // often resolve to the same thing on single-user machines. + const seen = new Set(); + const out: string[] = []; + for (const p of all) { + const resolved = path.resolve(p); + if (seen.has(resolved)) continue; + seen.add(resolved); + out.push(p); + } + return out; +} + +export interface V1Paths { + root: string; + db: string; + env: string; + groups: string; + packageJson: string; +} + +/** + * Build the expected v1 file layout relative to a root. All paths are returned + * even if they don't exist — callers check existence on the ones they care about. + */ +export function v1PathsFor(root: string): V1Paths { + return { + root, + db: path.join(root, 'store', 'messages.db'), + env: path.join(root, '.env'), + groups: path.join(root, 'groups'), + packageJson: path.join(root, 'package.json'), + }; +} + +/** + * Quick "does this path look like a v1 install?" check — used by detect. + * + * Strategy: the strongest signal is `store/messages.db`, so that's required. + * The package.json check is a weaker corroboration — forks may rename + * `"name"` or strip it, so we allow: + * - `name` missing or non-string + * - `name` containing "nanoclaw" or "clawdbot" (case-insensitive) + * We reject only if `name` looks like a completely unrelated project, OR + * the version is 2.x (the v2 rewrite itself). + * + * This keeps stock + forked v1 installs detectable while filtering out + * unrelated repos that happen to have a `store/messages.db`. + */ +export function looksLikeV1Install(root: string): { ok: boolean; reason?: string } { + if (!fs.existsSync(root)) return { ok: false, reason: 'root_missing' }; + const { db, packageJson } = v1PathsFor(root); + if (!fs.existsSync(db)) return { ok: false, reason: 'db_missing' }; + + // package.json is optional — a user may have stripped it, or be running + // from a state-only dir (.nanoclaw). The DB shape is checked separately + // by migrate-validate, which is authoritative for "is this schema v1?" + if (!fs.existsSync(packageJson)) return { ok: true }; + + try { + const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf-8')) as { name?: string; version?: string }; + const name = (pkg.name ?? '').toLowerCase(); + if (pkg.version && /^2\./.test(pkg.version)) return { ok: false, reason: 'already_v2' }; + if (name && !name.includes('nanoclaw') && !name.includes('clawdbot')) { + return { ok: false, reason: 'unrelated_project' }; + } + } catch { + // Broken package.json doesn't rule out v1 — DB presence is enough. + return { ok: true }; + } + return { ok: true }; +} + +// ── Handoff state (single source of truth across sub-steps) ──────────── + +/** + * Rich state shared between migration sub-steps. Each step reads the whole + * file, merges its section, and writes it back. Never hand-edit — it's + * consumed by the `/migrate-from-v1` skill too. + * + * All paths stored are ABSOLUTE, so subsequent steps don't need to guess + * about cwd. Relative paths would be a footgun once the skill reads this + * file later from a different cwd. + */ +export interface Handoff { + version: 1; + started_at: string; + v1_path: string | null; + v1_version: string | null; + + /** Overall status once migrate-handoff finalizes the run. */ + overall_status: 'pending' | 'success' | 'partial' | 'failed' | 'skipped'; + + steps: Partial>; + + /** Group folders the user chose to bring over (migrate-db populates). */ + group_selection: { + mode: 'all' | 'wired-only' | 'cancelled' | null; + selected_folders: string[]; + total_v1_groups: number; + wired_v1_groups: number; + }; + + /** Distinct channels inferred from v1 registered_groups. */ + detected_channels: Array<{ + channel_type: string; + source: 'channel_name' | 'jid_prefix'; + group_count: number; + }>; + + /** Per-channel auth copy results (migrate-channel-auth populates). */ + channel_auth: Array<{ + channel_type: string; + env_keys_copied: string[]; + files_copied: string[]; + files_missing: string[]; + notes: string; + }>; + + /** Result of each `setup/install-.sh` invocation. */ + channels_installed: Array<{ + channel_type: string; + status: 'success' | 'failed' | 'skipped' | 'not_supported'; + error?: string; + }>; + + /** Scheduled-task migration results (migrate-tasks populates). */ + tasks: { + v1_active: number; + v1_inactive: number; + migrated: number; + failed: number; + skipped: number; + }; + + /** Things the skill must finish manually. Always safe to append to. */ + followups: string[]; +} + +export type MigrateStep = + | 'migrate-detect' + | 'migrate-validate' + | 'migrate-db' + | 'migrate-groups' + | 'migrate-env' + | 'migrate-channel-auth' + | 'migrate-channels' + | 'migrate-tasks' + | 'migrate-handoff'; + +export interface StepOutcome { + status: 'success' | 'partial' | 'failed' | 'skipped'; + fields: Record; + notes: string[]; + at: string; +} + +function emptyHandoff(): Handoff { + return { + version: 1, + started_at: new Date().toISOString(), + v1_path: null, + v1_version: null, + overall_status: 'pending', + steps: {}, + group_selection: { + mode: null, + selected_folders: [], + total_v1_groups: 0, + wired_v1_groups: 0, + }, + detected_channels: [], + channel_auth: [], + channels_installed: [], + tasks: { v1_active: 0, v1_inactive: 0, migrated: 0, failed: 0, skipped: 0 }, + followups: [], + }; +} + +/** Read the handoff, creating an empty one if it doesn't exist yet. */ +export function readHandoff(): Handoff { + fs.mkdirSync(MIGRATION_DIR, { recursive: true }); + if (!fs.existsSync(HANDOFF_PATH)) return emptyHandoff(); + try { + const parsed = JSON.parse(fs.readFileSync(HANDOFF_PATH, 'utf-8')) as Handoff; + if (parsed.version !== 1) throw new Error(`unsupported handoff version ${parsed.version}`); + return parsed; + } catch { + // Broken handoff shouldn't wedge the migration — start fresh and let the + // step that called us re-record its outcome. + return emptyHandoff(); + } +} + +/** Persist a handoff mutation atomically (write-tmp + rename). */ +export function writeHandoff(h: Handoff): void { + fs.mkdirSync(MIGRATION_DIR, { recursive: true }); + const tmp = HANDOFF_PATH + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(h, null, 2)); + fs.renameSync(tmp, HANDOFF_PATH); +} + +/** Convenience: merge a step outcome into the handoff and persist. */ +export function recordStep(step: MigrateStep, outcome: StepOutcome): void { + const h = readHandoff(); + h.steps[step] = outcome; + writeHandoff(h); +} + +// ── JID parsing + channel inference ──────────────────────────────────── + +/** + * v1 stored chat identifiers as `:` in `registered_groups.jid`. + * The prefix was often a short code (`dc` for Discord, `tg` for Telegram) + * that doesn't match v2's `channel_type` names. This table normalizes them. + * + * Unknown prefixes fall through as-is (`channel_type = prefix`) so a channel + * we didn't anticipate still ends up with a distinct messaging_group per + * chat — the skill can reconcile it interactively. + */ +export const JID_PREFIX_TO_CHANNEL: Record = { + dc: 'discord', + discord: 'discord', + tg: 'telegram', + telegram: 'telegram', + wa: 'whatsapp', + whatsapp: 'whatsapp', + slack: 'slack', + matrix: 'matrix', + mx: 'matrix', + teams: 'teams', + imessage: 'imessage', + im: 'imessage', + email: 'email', + webex: 'webex', + gchat: 'gchat', + linear: 'linear', + github: 'github', +}; + +export interface ParsedJid { + raw: string; + prefix: string; + id: string; + channel_type: string; +} + +export function parseJid(raw: string): ParsedJid | null { + const colon = raw.indexOf(':'); + if (colon === -1) return null; + const prefix = raw.slice(0, colon).toLowerCase(); + const id = raw.slice(colon + 1); + if (!prefix || !id) return null; + return { + raw, + prefix, + id, + channel_type: JID_PREFIX_TO_CHANNEL[prefix] ?? prefix, + }; +} + +/** + * Prefer an explicit v1 `channel_name` when one is set; fall back to the JID + * prefix. v1 left `channel_name` empty on most rows (it was a late addition), + * so the JID prefix is often the only honest source. + */ +export function inferChannelType(jid: string, channelName: string | null): string | null { + if (channelName && channelName.trim()) return channelName.trim(); + const parsed = parseJid(jid); + return parsed?.channel_type ?? null; +} + +/** + * v2's messaging_groups.platform_id is always prefixed with the channel_type + * (see setup/register.ts:118-120). This helper normalizes v1's `jid` into + * that shape so router lookups at runtime find the right row. + */ +export function v2PlatformId(channelType: string, jid: string): string { + const parsed = parseJid(jid); + const id = parsed?.id ?? jid; + return id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`; +} + +// ── Trigger rules → engage mode (ports migration 010's backfill) ─────── + +/** + * Mirrors the backfill() logic in src/db/migrations/010-engage-modes.ts so + * rows written by the migration land in the same shape as rows written by + * setup/register.ts (which goes through migration 010 at boot). + */ +export function triggerToEngage(input: { + trigger_pattern: string | null; + requires_trigger: number | null; +}): { + engage_mode: 'pattern' | 'mention' | 'mention-sticky'; + engage_pattern: string | null; +} { + const pattern = input.trigger_pattern && input.trigger_pattern.trim().length > 0 ? input.trigger_pattern : null; + const requiresTrigger = input.requires_trigger !== 0; // NULL/1 → true; 0 → false + + if (pattern === '.' || pattern === '.*') { + return { engage_mode: 'pattern', engage_pattern: '.' }; + } + if (pattern) { + return { engage_mode: 'pattern', engage_pattern: pattern }; + } + if (!requiresTrigger) { + return { engage_mode: 'pattern', engage_pattern: '.' }; + } + return { engage_mode: 'mention', engage_pattern: null }; +} + +// ── Channel auth registry (non-.env state per channel) ───────────────── + +/** + * Describes the auth surface for a channel beyond `.env`. Each entry tells + * the channel-auth step: + * + * - `v1EnvKeys`: env keys we might find on the v1 side and carry over + * - `requiredV2Keys`: env keys v2's adapter REQUIRES to boot — if missing + * from v2's merged .env after migrate-env runs, a followup is emitted so + * the user knows exactly what to add (and where to get it). + * - `candidatePaths`: relative paths under the v1 root that may hold + * on-disk auth state (WhatsApp keystore, matrix sync state, etc.) + * - `note`: short human-readable hint surfaced to the user + * + * Unknown channels fall through as {v1EnvKeys:[], requiredV2Keys:[], + * candidatePaths:[]} — the skill asks the user how to proceed. + * + * Keep `requiredV2Keys` honest: list only what the v2 adapter actually + * refuses to boot without. False positives spam the followups; false + * negatives let the agent silently fail. Verify against the actual + * `@chat-adapter/` package when adding/updating entries. + */ +export interface ChannelAuthSpec { + v1EnvKeys: string[]; + requiredV2Keys: { key: string; where: string }[]; + candidatePaths: string[]; + note?: string; +} + +export const CHANNEL_AUTH_REGISTRY: Record = { + discord: { + v1EnvKeys: ['DISCORD_BOT_TOKEN', 'DISCORD_CLIENT_ID', 'DISCORD_GUILD_ID'], + // v1 used raw discord.js (bot token only). v2 uses Chat SDK which needs + // the interaction-verification public key + application id on top. + requiredV2Keys: [ + { key: 'DISCORD_BOT_TOKEN', where: 'Discord Developer Portal → Application → Bot → Token' }, + { key: 'DISCORD_APPLICATION_ID', where: 'Discord Developer Portal → Application → General → Application ID' }, + { key: 'DISCORD_PUBLIC_KEY', where: 'Discord Developer Portal → Application → General → Public Key' }, + ], + candidatePaths: [], + note: 'v1 used raw discord.js (bot token only). v2 uses Chat SDK and needs APPLICATION_ID + PUBLIC_KEY too.', + }, + 'discord-supervisor': { + v1EnvKeys: ['DISCORD_SUPERVISOR_BOT_TOKEN'], + requiredV2Keys: [], + candidatePaths: [], + note: 'v1-specific secondary bot. v2 does not have a native supervisor channel; the token is preserved in .env for the skill to reconcile.', + }, + telegram: { + v1EnvKeys: ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_API_ID', 'TELEGRAM_API_HASH'], + requiredV2Keys: [ + { key: 'TELEGRAM_BOT_TOKEN', where: 'BotFather on Telegram → /mybots → Bot → API Token' }, + ], + candidatePaths: ['data/sessions/telegram', 'store/telegram-session'], + }, + whatsapp: { + v1EnvKeys: ['WHATSAPP_PHONE', 'WHATSAPP_OWNER'], + requiredV2Keys: [], + candidatePaths: [ + 'data/sessions/baileys', + 'data/baileys_auth', + 'store/auth_info_baileys', + 'store/baileys', + 'auth_info_baileys', + ], + note: 'Baileys keystore — copying is best-effort. Encryption sessions may still need a fresh pair via /add-whatsapp.', + }, + matrix: { + v1EnvKeys: ['MATRIX_HOMESERVER', 'MATRIX_USER_ID', 'MATRIX_ACCESS_TOKEN'], + requiredV2Keys: [ + { key: 'MATRIX_HOMESERVER', where: 'your Matrix homeserver URL (e.g. https://matrix.org)' }, + { key: 'MATRIX_ACCESS_TOKEN', where: 'Element → Settings → Help & About → Access Token (keep secret)' }, + ], + candidatePaths: ['data/matrix-store', 'store/matrix', 'data/sessions/matrix'], + }, + slack: { + v1EnvKeys: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_SIGNING_SECRET'], + requiredV2Keys: [ + { key: 'SLACK_BOT_TOKEN', where: 'Slack app → OAuth & Permissions → Bot User OAuth Token (xoxb-…)' }, + { key: 'SLACK_SIGNING_SECRET', where: 'Slack app → Basic Information → Signing Secret' }, + ], + candidatePaths: [], + }, + teams: { + v1EnvKeys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_TENANT_ID'], + requiredV2Keys: [ + { key: 'TEAMS_APP_ID', where: 'Azure portal → App registration → Application (client) ID' }, + { key: 'TEAMS_APP_PASSWORD', where: 'Azure portal → App registration → Certificates & secrets' }, + ], + candidatePaths: [], + }, + imessage: { + v1EnvKeys: ['IMESSAGE_PHOTON_URL', 'IMESSAGE_PHOTON_TOKEN'], + requiredV2Keys: [], + candidatePaths: ['data/imessage', 'store/imessage'], + }, + webex: { + v1EnvKeys: ['WEBEX_BOT_TOKEN'], + requiredV2Keys: [{ key: 'WEBEX_BOT_TOKEN', where: 'Webex developer portal → Bot → Bot Access Token' }], + candidatePaths: [], + }, + gchat: { + v1EnvKeys: ['GCHAT_SERVICE_ACCOUNT', 'GCHAT_WEBHOOK_URL'], + requiredV2Keys: [], + candidatePaths: ['data/gchat-credentials.json', 'store/gchat-sa.json'], + }, + resend: { + v1EnvKeys: ['RESEND_API_KEY', 'RESEND_FROM'], + requiredV2Keys: [{ key: 'RESEND_API_KEY', where: 'resend.com → API Keys' }], + candidatePaths: [], + }, + github: { + v1EnvKeys: ['GITHUB_WEBHOOK_SECRET', 'GITHUB_APP_ID', 'GITHUB_PRIVATE_KEY_PATH'], + requiredV2Keys: [], + candidatePaths: [], + note: 'Webhook channel — secrets carry over, but GitHub webhook URLs are new per v2 install.', + }, + linear: { + v1EnvKeys: ['LINEAR_API_KEY', 'LINEAR_WEBHOOK_SECRET'], + requiredV2Keys: [{ key: 'LINEAR_API_KEY', where: 'Linear → Settings → API → Personal API keys' }], + candidatePaths: [], + }, +}; + +/** + * Map a v2 `channel_type` name to the corresponding `setup/install-.sh` + * script, if one exists. `null` means no v2 skill is available yet — the + * handoff lists the channel as "not supported" and the skill raises it with + * the user. + */ +export function installScriptForChannel(channelType: string): string | null { + const known = new Set([ + 'discord', + 'telegram', + 'whatsapp', + 'whatsapp-cloud', + 'teams', + 'slack', + 'matrix', + 'imessage', + 'webex', + 'gchat', + 'resend', + 'github', + 'linear', + ]); + if (!known.has(channelType)) return null; + return `setup/install-${channelType}.sh`; +} + +// ── Misc helpers ─────────────────────────────────────────────────────── + +export function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +// ── v1-specific pattern scan (for migrate-groups) ────────────────────── + +/** + * Tight set of v1-only infrastructure patterns. When one of these shows up + * in a copied CLAUDE.md, the content referencing v1 plumbing that is genuinely + * gone in v2 (IPC file queue, single-DB paths, v1 pr-factory conventions). + * + * Deliberately excludes portable patterns — `mcp__nanoclaw__*` tool names, + * `agent-browser`, generic `/workspace/` paths — which v2 supports the same + * way. The list is scan-only; the migration does NOT modify file content. It + * just adds a followup so the /migrate-from-v1 skill can triage each file + * with the user. + * + * Keep this list conservative: false positives spam the skill with noise, + * false negatives leave the user with silently-broken agents. When adding, + * include a comment naming the specific v1 thing each pattern points at. + */ +export interface V1PatternMatch { + pattern: string; + description: string; + lines: number[]; +} + +const V1_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + { + pattern: /\/workspace\/ipc\/tasks/, + description: "v1 IPC file queue (gone in v2 — agents talk to the host via session DBs, not JSON files)", + }, + { + pattern: /\/workspace\/extra\/project\/store\b/, + description: "v1-specific mount + store/ path (v2 mounts differ; state lives under data/)", + }, + { + pattern: /\bstore\/messages\.db\b/, + description: "v1 central DB path (v2 uses data/v2.db + data/v2-sessions//{inbound,outbound}.db)", + }, + { + pattern: /"clear_session"|"retrigger"/, + description: "v1 IPC task types (no v2 equivalent; use session lifecycle + the scheduling MCP tool instead)", + }, + { + pattern: /\[PR_CONTEXT:/, + description: "v1 pr-factory context-tag convention (specific to the supervisor group; needs reworking in v2)", + }, + { + pattern: /\brequires_trigger\b|\btrigger_pattern\b/, + description: "v1 column names on registered_groups (v2 uses engage_mode + engage_pattern on messaging_group_agents)", + }, + { + pattern: /\bchatJid\b(?!\s*[:=]\s*["']dc:)/, + description: "v1 routing key (v2 uses messaging_group_id or channel_type+platform_id)", + }, +]; + +/** Scan a CLAUDE.md-ish text blob for v1-specific infrastructure patterns. */ +export function scanForV1Patterns(text: string): V1PatternMatch[] { + const matches: V1PatternMatch[] = []; + const lines = text.split('\n'); + + for (const entry of V1_PATTERNS) { + const hitLines: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (entry.pattern.test(lines[i])) { + hitLines.push(i + 1); + } + } + if (hitLines.length > 0) { + matches.push({ + pattern: entry.pattern.source, + description: entry.description, + // Cap to first 5 line numbers — we're generating a followup summary, + // not a code index. Full context is in the file itself. + lines: hitLines.slice(0, 5), + }); + } + } + + return matches; +} + +export function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return '{"error":"unserializable"}'; + } +} diff --git a/setup/migrate-v1/tasks.ts b/setup/migrate-v1/tasks.ts new file mode 100644 index 0000000..836be7c --- /dev/null +++ b/setup/migrate-v1/tasks.ts @@ -0,0 +1,307 @@ +/** + * Step: migrate-tasks + * + * Port v1's `scheduled_tasks` into v2's session inbound DBs. v1 had a + * dedicated table with its own scheduling grammar; v2 treats tasks as + * `messages_in` rows with `kind='task'`, `process_after`, and `recurrence` + * (cron string). See docs/v1-to-v2-changes.md "Scheduling". + * + * Flow per v1 row: + * 1. Resolve (agent_group_id, messaging_group_id) from v1 (group_folder, chat_jid) + * 2. resolveSession() — creates the session on demand if absent + * 3. insertTask() into the session's inbound.db + * + * Active v1 rows (status='active') are migrated. Completed/stopped rows get + * exported to logs/setup-migration/inactive-tasks.json for reference. + * + * v1's schedule_type / schedule_value are mapped to cron here. Known types: + * 'cron' → schedule_value is already a cron string + * 'interval' → e.g. '5m'/'1h' → cron equivalent (best effort) + * 'once' → no recurrence, process_after = schedule_value if parseable + * Unknown types go to inactive-tasks.json with a note. + */ +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 { getAgentGroupByFolder } from '../../src/db/agent-groups.js'; +import { getMessagingGroupByPlatform } from '../../src/db/messaging-groups.js'; +import { runMigrations } from '../../src/db/migrations/index.js'; +import { log } from '../../src/log.js'; +import { insertTask } from '../../src/modules/scheduling/db.js'; +import { openInboundDb, resolveSession } from '../../src/session-manager.js'; +import { emitStatus } from '../status.js'; +import { + INACTIVE_TASKS_PATH, + MIGRATION_DIR, + inferChannelType, + readHandoff, + recordStep, + safeJsonStringify, + v1PathsFor, + v2PlatformId, + writeHandoff, +} from './shared.js'; + +interface V1Task { + id: string; + group_folder: string; + chat_jid: string; + prompt: string; + schedule_type: string; + schedule_value: string; + next_run: string | null; + last_run: string | null; + status: string; + context_mode: string | null; + script: string | null; +} + +/** Convert v1 schedule_type + schedule_value into (processAfter, recurrence). */ +function toProcessAfterAndRecurrence(t: V1Task): { + processAfter: string; + recurrence: string | null; + note?: string; +} | null { + const now = new Date().toISOString(); + + if (t.schedule_type === 'cron') { + // Validate shape — 5 or 6 fields separated by whitespace. cron-parser is + // the runtime source of truth; here we just reject obvious garbage so + // we don't insert tasks that will explode on the first sweep tick. + const fields = t.schedule_value.trim().split(/\s+/).length; + if (fields < 5 || fields > 6) return null; + return { + processAfter: t.next_run || now, + recurrence: t.schedule_value.trim(), + }; + } + + if (t.schedule_type === 'interval') { + // '5m' → '*/5 * * * *'; '1h' → '0 * * * *'; '1d' → '0 0 * * *'. + // Best effort — any unit we don't recognize falls through to null. + const m = /^(\d+)([smhd])$/.exec(t.schedule_value.trim()); + if (!m) return null; + const n = parseInt(m[1], 10); + const unit = m[2]; + if (!n || n < 1) return null; + let cron: string | null = null; + if (unit === 'm' && n < 60) cron = `*/${n} * * * *`; + else if (unit === 'h' && n < 24) cron = `0 */${n} * * *`; + else if (unit === 'd' && n < 28) cron = `0 0 */${n} * *`; + if (!cron) return null; + return { processAfter: t.next_run || now, recurrence: cron }; + } + + if (t.schedule_type === 'once' || t.schedule_type === 'at') { + return { + processAfter: t.next_run || t.schedule_value || now, + recurrence: null, + }; + } + + return null; +} + +export async function run(_args: string[]): Promise { + const h = readHandoff(); + if (!h.v1_path) { + recordStep('migrate-tasks', { + status: 'skipped', + fields: { REASON: 'detect-not-run' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'no_v1_path' }); + return; + } + + const validate = h.steps['migrate-validate']; + if (validate && validate.status === 'failed') { + recordStep('migrate-tasks', { + status: 'skipped', + fields: { REASON: 'validate-failed' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'validate_failed' }); + return; + } + + const paths = v1PathsFor(h.v1_path); + + // Read v1 tasks into memory so we can close the v1 DB before we open v2's + // central DB via initDb() (which is a module singleton and doesn't love + // having two files open through it). + let activeTasks: V1Task[] = []; + let inactiveTasks: V1Task[] = []; + try { + const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); + const all = v1Db.prepare('SELECT * FROM scheduled_tasks').all() as V1Task[]; + v1Db.close(); + activeTasks = all.filter((t) => t.status === 'active'); + inactiveTasks = all.filter((t) => t.status !== 'active'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + recordStep('migrate-tasks', { + status: 'failed', + fields: { REASON: 'v1-read-failed' }, + notes: [message], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_TASKS', { STATUS: 'failed', REASON: 'v1_read_failed', ERROR: message }); + return; + } + + if (activeTasks.length === 0 && inactiveTasks.length === 0) { + recordStep('migrate-tasks', { + status: 'skipped', + fields: { REASON: 'no-v1-tasks' }, + notes: [], + at: new Date().toISOString(), + }); + emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'no_v1_tasks' }); + return; + } + + // Dump inactive tasks for reference — always, even if there are no active ones. + if (inactiveTasks.length > 0) { + fs.mkdirSync(MIGRATION_DIR, { recursive: true }); + fs.writeFileSync(INACTIVE_TASKS_PATH, safeJsonStringify({ tasks: inactiveTasks })); + } + + // Connect to v2 central DB to resolve (folder → ag) and (channel+pid → mg). + const v2Path = path.join(DATA_DIR, 'v2.db'); + fs.mkdirSync(path.dirname(v2Path), { recursive: true }); + const v2Db = initDb(v2Path); + runMigrations(v2Db); + + const followups: string[] = []; + let migrated = 0; + let failed = 0; + let skipped = 0; + + for (const t of activeTasks) { + try { + const ag = getAgentGroupByFolder(t.group_folder); + if (!ag) { + skipped += 1; + followups.push( + `Task "${t.id}" (folder "${t.group_folder}"): agent_group not seeded in v2 — run migrate-db first or deselect the task.`, + ); + continue; + } + + const channelType = inferChannelType(t.chat_jid, null); + if (!channelType) { + skipped += 1; + followups.push(`Task "${t.id}": could not infer channel from chat_jid "${t.chat_jid}".`); + continue; + } + const platformId = v2PlatformId(channelType, t.chat_jid); + const mg = getMessagingGroupByPlatform(channelType, platformId); + if (!mg) { + skipped += 1; + followups.push( + `Task "${t.id}": messaging_group for (${channelType}, ${platformId}) not seeded. Add the channel then re-run this step.`, + ); + continue; + } + + const scheduling = toProcessAfterAndRecurrence(t); + if (!scheduling) { + skipped += 1; + followups.push( + `Task "${t.id}": schedule_type "${t.schedule_type}" / value "${t.schedule_value}" did not map to a v2 cron — exported to inactive-tasks.json for manual review.`, + ); + inactiveTasks.push(t); + continue; + } + + // resolveSession creates (ag, mg) session if not present; 'shared' mode + // matches v1 which had one session per group_folder. + const { session } = resolveSession(ag.id, mg.id, null, 'shared'); + const inboxDb = openInboundDb(ag.id, session.id); + try { + // Idempotence: skip if we've already migrated this task id. We use the + // v1 task id verbatim as the v2 messages_in.id (stable — lets users + // re-run migration without duplicate-key errors or shadow tasks). + const existing = inboxDb + .prepare("SELECT id FROM messages_in WHERE id = ? AND kind = 'task'") + .get(t.id) as { id: string } | undefined; + if (existing) { + skipped += 1; + continue; + } + + insertTask(inboxDb, { + id: t.id, + processAfter: scheduling.processAfter, + recurrence: scheduling.recurrence, + platformId, + channelType, + threadId: null, + content: JSON.stringify({ + prompt: t.prompt, + script: t.script ?? null, + migrated_from_v1: { original_id: t.id, context_mode: t.context_mode ?? null }, + }), + }); + } finally { + inboxDb.close(); + } + + log.info('Migrated v1 scheduled task', { taskId: t.id, session: session.id, mg: mg.id }); + migrated += 1; + } catch (err) { + failed += 1; + const message = err instanceof Error ? err.message : String(err); + followups.push(`Task "${t.id}" failed to migrate: ${message}`); + } + } + + // Re-dump inactive tasks in case scheduling-translation pushed any in. + if (inactiveTasks.length > 0) { + fs.writeFileSync(INACTIVE_TASKS_PATH, safeJsonStringify({ tasks: inactiveTasks })); + } + + closeDb(); + + const handoffAfter = readHandoff(); + handoffAfter.tasks = { + v1_active: activeTasks.length, + v1_inactive: inactiveTasks.length, + migrated, + failed, + skipped, + }; + handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; + writeHandoff(handoffAfter); + + const partial = failed > 0 || skipped > 0; + recordStep('migrate-tasks', { + status: failed > 0 ? 'partial' : partial ? 'partial' : 'success', + fields: { + V1_ACTIVE: activeTasks.length, + V1_INACTIVE: inactiveTasks.length, + MIGRATED: migrated, + FAILED: failed, + SKIPPED: skipped, + INACTIVE_EXPORT: inactiveTasks.length > 0 ? INACTIVE_TASKS_PATH : '', + }, + notes: followups, + at: new Date().toISOString(), + }); + + emitStatus('MIGRATE_TASKS', { + STATUS: partial ? 'partial' : 'success', + V1_ACTIVE: String(activeTasks.length), + V1_INACTIVE: String(inactiveTasks.length), + MIGRATED: String(migrated), + FAILED: String(failed), + SKIPPED: String(skipped), + }); +} diff --git a/setup/migrate-v1/validate.ts b/setup/migrate-v1/validate.ts new file mode 100644 index 0000000..73cd377 --- /dev/null +++ b/setup/migrate-v1/validate.ts @@ -0,0 +1,213 @@ +/** + * 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 { + 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, + }); + } +}