From 971d10494ba57aece6245d043d2cbcb37d664241 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 00:49:39 +0530 Subject: [PATCH 1/5] chore(cli): vendor ReverbCode goose migrations 0001-0012 @ 43ae7eb Vendored SQLite migrations from aoagents/ReverbCode pinned at commit 43ae7eb for the offline `ao migrate` DB writer (#2129). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/lib/migrations/0001_init.sql | 215 +++++++++++ .../0002_remove_activity_source.sql | 11 + .../0003_add_session_display_name.sql | 9 + .../migrations/0004_scm_observer_schema.sql | 365 ++++++++++++++++++ .../0005_pr_last_nudge_signature.sql | 18 + .../0006_pr_session_changed_cdc.sql | 345 +++++++++++++++++ .../0007_allow_implemented_harnesses.sql | 42 ++ .../migrations/0008_add_project_config.sql | 15 + .../migrations/0009_workspace_projects.sql | 37 ++ .../migrations/0010_add_first_signal_at.sql | 60 +++ .../src/lib/migrations/0011_notifications.sql | 35 ++ .../lib/migrations/0012_add_review_tables.sql | 45 +++ 12 files changed, 1197 insertions(+) create mode 100644 packages/cli/src/lib/migrations/0001_init.sql create mode 100644 packages/cli/src/lib/migrations/0002_remove_activity_source.sql create mode 100644 packages/cli/src/lib/migrations/0003_add_session_display_name.sql create mode 100644 packages/cli/src/lib/migrations/0004_scm_observer_schema.sql create mode 100644 packages/cli/src/lib/migrations/0005_pr_last_nudge_signature.sql create mode 100644 packages/cli/src/lib/migrations/0006_pr_session_changed_cdc.sql create mode 100644 packages/cli/src/lib/migrations/0007_allow_implemented_harnesses.sql create mode 100644 packages/cli/src/lib/migrations/0008_add_project_config.sql create mode 100644 packages/cli/src/lib/migrations/0009_workspace_projects.sql create mode 100644 packages/cli/src/lib/migrations/0010_add_first_signal_at.sql create mode 100644 packages/cli/src/lib/migrations/0011_notifications.sql create mode 100644 packages/cli/src/lib/migrations/0012_add_review_tables.sql diff --git a/packages/cli/src/lib/migrations/0001_init.sql b/packages/cli/src/lib/migrations/0001_init.sql new file mode 100644 index 0000000000..d308fb337b --- /dev/null +++ b/packages/cli/src/lib/migrations/0001_init.sql @@ -0,0 +1,215 @@ +-- +goose Up +-- +goose StatementBegin + +-- projects is the durable registry of repos AO manages (the SQLite twin of the +-- YAML config). id is a short human/LLM-friendly slug (mer, ao) with a numeric +-- suffix on collision (ao, ao1, ao2). Soft-delete via archived_at keeps the row +-- so a session's project_id always resolves. +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL, + repo_origin_url TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + registered_at TIMESTAMP NOT NULL, + archived_at TIMESTAMP +); + +-- sessions is the durable session fact row. id is "{project_id}-{num}" +-- (e.g. mer-1), so every inbound FK is single-column. num is the per-project +-- counter. The only persisted status-like facts are activity_state and +-- is_terminated; display status is derived on read from this row plus PR facts. +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects (id), + num INTEGER NOT NULL, + issue_id TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT 'worker' + CHECK (kind IN ('worker', 'orchestrator')), + harness TEXT NOT NULL DEFAULT '' + CHECK (harness IN ('', 'claude-code', 'codex', 'aider', 'opencode')), + + activity_state TEXT NOT NULL DEFAULT 'idle' + CHECK (activity_state IN ('active', 'idle', 'waiting_input', 'blocked', 'exited')), + activity_last_at TIMESTAMP NOT NULL, + activity_source TEXT NOT NULL DEFAULT 'none' + CHECK (activity_source IN ('native', 'terminal', 'hook', 'runtime', 'none')), + is_terminated BOOLEAN NOT NULL DEFAULT FALSE, + + branch TEXT NOT NULL DEFAULT '', + workspace_path TEXT NOT NULL DEFAULT '', + runtime_handle_id TEXT NOT NULL DEFAULT '', + agent_session_id TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + UNIQUE (project_id, num) +); +CREATE INDEX idx_sessions_project ON sessions (project_id); + +-- pr holds PR facts keyed by the normalized PR URL. One session can own many PRs +-- (session_id FK), but a PR belongs to one session (enforced at runtime). ci_state +-- is the rolled-up status; the per-check history lives in pr_checks. +CREATE TABLE pr ( + url TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, + number INTEGER NOT NULL DEFAULT 0, + pr_state TEXT NOT NULL DEFAULT 'open' + CHECK (pr_state IN ('draft', 'open', 'merged', 'closed')), + review_decision TEXT NOT NULL DEFAULT 'none' + CHECK (review_decision IN ('none', 'approved', 'changes_requested', 'review_required')), + ci_state TEXT NOT NULL DEFAULT 'unknown' + CHECK (ci_state IN ('unknown', 'pending', 'passing', 'failing')), + mergeability TEXT NOT NULL DEFAULT 'unknown' + CHECK (mergeability IN ('unknown', 'mergeable', 'conflicting', 'blocked', 'unstable')), + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_pr_session ON pr (session_id); + +-- pr_checks is CI run history: one row per (PR, check, commit). Re-polling the +-- same commit upserts the same row. +CREATE TABLE pr_checks ( + pr_url TEXT NOT NULL REFERENCES pr (url) ON DELETE CASCADE, + name TEXT NOT NULL, + commit_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown' + CHECK (status IN ('unknown', 'queued', 'in_progress', 'passed', 'failed', 'skipped', 'cancelled')), + url TEXT NOT NULL DEFAULT '', + log_tail TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (pr_url, name, commit_hash) +); +CREATE INDEX idx_pr_checks_lookup ON pr_checks (pr_url, name, created_at); + +-- pr_comment holds review comments, persisted so a session page does not wait on +-- GitHub. Cascades from pr. +CREATE TABLE pr_comment ( + pr_url TEXT NOT NULL REFERENCES pr (url) ON DELETE CASCADE, + comment_id TEXT NOT NULL, + author TEXT NOT NULL DEFAULT '', + file TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + body TEXT NOT NULL DEFAULT '', + resolved INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (pr_url, comment_id) +); + +-- change_log is the durable, append-only CDC event log. seq is the monotonic +-- ordering + idempotency key. Rows are written by TRIGGERS on the user-visible +-- tables (DB-native capture, atomic with the change) — never by application +-- emit-code. project_id is required, session_id is nullable (project-level events +-- have no session). The log is immutable (no published flag); consumers track +-- their own offset (SSE Last-Event-ID). +CREATE TABLE change_log ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects (id), + session_id TEXT REFERENCES sessions (id), + event_type TEXT NOT NULL + CHECK (event_type IN ('session_created', 'session_updated', 'pr_created', 'pr_updated', 'pr_check_recorded')), + payload TEXT NOT NULL CHECK (json_valid(payload)), + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_change_log_project ON change_log (project_id, seq); + +-- +goose StatementEnd + +-- CDC capture triggers. Each is its own goose statement (the trigger body holds +-- semicolons). They write change_log atomically with the originating change, so +-- the application never emits events — it just writes sessions/pr/pr_checks. + +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_insert +AFTER INSERT ON sessions +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_created', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.activity_state <> NEW.activity_state + OR OLD.is_terminated <> NEW.is_terminated +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_updated', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_insert +AFTER INSERT ON pr +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_created', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_update +AFTER UPDATE ON pr +WHEN OLD.pr_state <> NEW.pr_state + OR OLD.ci_state <> NEW.ci_state + OR OLD.review_decision <> NEW.review_decision + OR OLD.mergeability <> NEW.mergeability +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_updated', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_insert +AFTER INSERT ON pr_checks +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + NEW.created_at); +END; +-- +goose StatementEnd + +-- A re-polled check can change status on the same commit (in_progress -> failed) +-- via UpsertPRCheck's ON CONFLICT DO UPDATE. Without this trigger that status +-- transition would update the row silently, so CDC consumers would never see it. +-- Guarded on the status so a no-op re-poll emits nothing. +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_update +AFTER UPDATE ON pr_checks +WHEN OLD.status <> NEW.status +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + datetime('now')); +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE change_log; +DROP TABLE pr_comment; +DROP TABLE pr_checks; +DROP TABLE pr; +DROP TABLE sessions; +DROP TABLE projects; +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0002_remove_activity_source.sql b/packages/cli/src/lib/migrations/0002_remove_activity_source.sql new file mode 100644 index 0000000000..885f24e0d7 --- /dev/null +++ b/packages/cli/src/lib/migrations/0002_remove_activity_source.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +UPDATE sessions SET activity_state = 'waiting_input' WHERE activity_state = 'blocked'; +ALTER TABLE sessions DROP COLUMN activity_source; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE sessions ADD COLUMN activity_source TEXT NOT NULL DEFAULT 'none' + CHECK (activity_source IN ('native', 'terminal', 'hook', 'runtime', 'none')); +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0003_add_session_display_name.sql b/packages/cli/src/lib/migrations/0003_add_session_display_name.sql new file mode 100644 index 0000000000..38a8183dc5 --- /dev/null +++ b/packages/cli/src/lib/migrations/0003_add_session_display_name.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE sessions ADD COLUMN display_name TEXT NOT NULL DEFAULT ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE sessions DROP COLUMN display_name; +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0004_scm_observer_schema.sql b/packages/cli/src/lib/migrations/0004_scm_observer_schema.sql new file mode 100644 index 0000000000..d29dc68354 --- /dev/null +++ b/packages/cli/src/lib/migrations/0004_scm_observer_schema.sql @@ -0,0 +1,365 @@ +-- Summary: extend PR persistence for provider-neutral SCM observations, CI/check detail, +-- review-thread storage, and semantic hashes used by the SCM observer. +-- +goose Up +-- +goose StatementBegin +ALTER TABLE pr ADD COLUMN provider TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN host TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN repo TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN source_branch TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN target_branch TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN head_sha TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN title TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN additions INTEGER NOT NULL DEFAULT 0; +ALTER TABLE pr ADD COLUMN deletions INTEGER NOT NULL DEFAULT 0; +ALTER TABLE pr ADD COLUMN changed_files INTEGER NOT NULL DEFAULT 0; +ALTER TABLE pr ADD COLUMN author TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN base_sha TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN merge_commit_sha TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN is_draft INTEGER NOT NULL DEFAULT 0; +ALTER TABLE pr ADD COLUMN is_merged INTEGER NOT NULL DEFAULT 0; +ALTER TABLE pr ADD COLUMN is_closed INTEGER NOT NULL DEFAULT 0; +ALTER TABLE pr ADD COLUMN provider_state TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN provider_mergeable TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN provider_merge_state_status TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN html_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN created_at_provider TIMESTAMP; +ALTER TABLE pr ADD COLUMN updated_at_provider TIMESTAMP; +ALTER TABLE pr ADD COLUMN merged_at_provider TIMESTAMP; +ALTER TABLE pr ADD COLUMN closed_at_provider TIMESTAMP; +ALTER TABLE pr ADD COLUMN metadata_hash TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN ci_hash TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN review_hash TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr ADD COLUMN observed_at TIMESTAMP; +ALTER TABLE pr ADD COLUMN ci_observed_at TIMESTAMP; +ALTER TABLE pr ADD COLUMN review_observed_at TIMESTAMP; + +ALTER TABLE pr_checks ADD COLUMN conclusion TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr_checks ADD COLUMN details TEXT NOT NULL DEFAULT ''; + +ALTER TABLE pr_comment ADD COLUMN thread_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr_comment ADD COLUMN url TEXT NOT NULL DEFAULT ''; +ALTER TABLE pr_comment ADD COLUMN is_bot INTEGER NOT NULL DEFAULT 0; + +-- Widen change_log.event_type CHECK to include the new pr_review_thread_* events. +-- SQLite cannot ALTER an in-place CHECK constraint. Drop CDC triggers before +-- rebuilding change_log; otherwise dropping the old table invalidates triggers +-- that still reference it. +DROP TRIGGER IF EXISTS sessions_cdc_insert; +DROP TRIGGER IF EXISTS sessions_cdc_update; +DROP TRIGGER IF EXISTS pr_cdc_insert; +DROP TRIGGER IF EXISTS pr_cdc_update; +DROP TRIGGER IF EXISTS pr_checks_cdc_insert; +DROP TRIGGER IF EXISTS pr_checks_cdc_update; + +CREATE TABLE change_log_new ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects (id), + session_id TEXT REFERENCES sessions (id), + event_type TEXT NOT NULL + CHECK (event_type IN ( + 'session_created', + 'session_updated', + 'pr_created', + 'pr_updated', + 'pr_check_recorded', + 'pr_review_thread_added', + 'pr_review_thread_resolved' + )), + payload TEXT NOT NULL CHECK (json_valid(payload)), + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) +); +INSERT INTO change_log_new (seq, project_id, session_id, event_type, payload, created_at) +SELECT seq, project_id, session_id, event_type, payload, created_at FROM change_log; +DROP INDEX IF EXISTS idx_change_log_project; +DROP TABLE change_log; +ALTER TABLE change_log_new RENAME TO change_log; +CREATE INDEX idx_change_log_project ON change_log (project_id, seq); + +CREATE TABLE pr_review_threads ( + pr_url TEXT NOT NULL REFERENCES pr (url) ON DELETE CASCADE, + thread_id TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + resolved INTEGER NOT NULL DEFAULT 0, + is_bot INTEGER NOT NULL DEFAULT 0, + semantic_hash TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (pr_url, thread_id) +); +CREATE INDEX idx_pr_review_threads_lookup ON pr_review_threads (pr_url, updated_at); +-- +goose StatementEnd + +-- +goose StatementBegin +-- Emit on every new review thread the SCM observer persists, so the broadcaster +-- can stream per-thread additions instead of waiting for a rolled-up review_decision flip. +CREATE TRIGGER pr_review_threads_cdc_insert +AFTER INSERT ON pr_review_threads +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_review_thread_added', + json_object( + 'pr', NEW.pr_url, + 'thread', NEW.thread_id, + 'path', NEW.path, + 'line', NEW.line, + 'resolved', json(CASE WHEN NEW.resolved THEN 'true' ELSE 'false' END), + 'isBot', json(CASE WHEN NEW.is_bot THEN 'true' ELSE 'false' END) + ), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +-- Emit only on resolved <-> unresolved transitions. Other thread mutations +-- (semantic_hash refresh, line shifts) are captured by the slower review-decision +-- rollup so we don't flood CDC with no-op semantic-hash updates. +CREATE TRIGGER pr_review_threads_cdc_update +AFTER UPDATE ON pr_review_threads +WHEN OLD.resolved <> NEW.resolved +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_review_thread_resolved', + json_object( + 'pr', NEW.pr_url, + 'thread', NEW.thread_id, + 'path', NEW.path, + 'line', NEW.line, + 'resolved', json(CASE WHEN NEW.resolved THEN 'true' ELSE 'false' END) + ), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_insert +AFTER INSERT ON sessions +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_created', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.activity_state <> NEW.activity_state + OR OLD.is_terminated <> NEW.is_terminated +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_updated', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_insert +AFTER INSERT ON pr +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_created', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_update +AFTER UPDATE ON pr +WHEN OLD.pr_state <> NEW.pr_state + OR OLD.ci_state <> NEW.ci_state + OR OLD.review_decision <> NEW.review_decision + OR OLD.mergeability <> NEW.mergeability +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_updated', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_insert +AFTER INSERT ON pr_checks +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + NEW.created_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_update +AFTER UPDATE ON pr_checks +WHEN OLD.status <> NEW.status +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + datetime('now')); +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TRIGGER IF EXISTS pr_review_threads_cdc_update; +DROP TRIGGER IF EXISTS pr_review_threads_cdc_insert; +DROP TRIGGER IF EXISTS sessions_cdc_insert; +DROP TRIGGER IF EXISTS sessions_cdc_update; +DROP TRIGGER IF EXISTS pr_cdc_insert; +DROP TRIGGER IF EXISTS pr_cdc_update; +DROP TRIGGER IF EXISTS pr_checks_cdc_insert; +DROP TRIGGER IF EXISTS pr_checks_cdc_update; + +CREATE TABLE change_log_old ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects (id), + session_id TEXT REFERENCES sessions (id), + event_type TEXT NOT NULL + CHECK (event_type IN ('session_created', 'session_updated', 'pr_created', 'pr_updated', 'pr_check_recorded')), + payload TEXT NOT NULL CHECK (json_valid(payload)), + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) +); +INSERT INTO change_log_old (seq, project_id, session_id, event_type, payload, created_at) +SELECT seq, project_id, session_id, event_type, payload, created_at FROM change_log +WHERE event_type IN ('session_created', 'session_updated', 'pr_created', 'pr_updated', 'pr_check_recorded'); +DROP INDEX IF EXISTS idx_change_log_project; +DROP TABLE change_log; +ALTER TABLE change_log_old RENAME TO change_log; +CREATE INDEX idx_change_log_project ON change_log (project_id, seq); + +DROP TABLE pr_review_threads; +ALTER TABLE pr_comment DROP COLUMN is_bot; +ALTER TABLE pr_comment DROP COLUMN url; +ALTER TABLE pr_comment DROP COLUMN thread_id; +ALTER TABLE pr_checks DROP COLUMN details; +ALTER TABLE pr_checks DROP COLUMN conclusion; +ALTER TABLE pr DROP COLUMN review_observed_at; +ALTER TABLE pr DROP COLUMN ci_observed_at; +ALTER TABLE pr DROP COLUMN observed_at; +ALTER TABLE pr DROP COLUMN review_hash; +ALTER TABLE pr DROP COLUMN ci_hash; +ALTER TABLE pr DROP COLUMN metadata_hash; +ALTER TABLE pr DROP COLUMN closed_at_provider; +ALTER TABLE pr DROP COLUMN merged_at_provider; +ALTER TABLE pr DROP COLUMN updated_at_provider; +ALTER TABLE pr DROP COLUMN created_at_provider; +ALTER TABLE pr DROP COLUMN html_url; +ALTER TABLE pr DROP COLUMN provider_merge_state_status; +ALTER TABLE pr DROP COLUMN provider_mergeable; +ALTER TABLE pr DROP COLUMN provider_state; +ALTER TABLE pr DROP COLUMN is_closed; +ALTER TABLE pr DROP COLUMN is_merged; +ALTER TABLE pr DROP COLUMN is_draft; +ALTER TABLE pr DROP COLUMN merge_commit_sha; +ALTER TABLE pr DROP COLUMN base_sha; +ALTER TABLE pr DROP COLUMN author; +ALTER TABLE pr DROP COLUMN changed_files; +ALTER TABLE pr DROP COLUMN deletions; +ALTER TABLE pr DROP COLUMN additions; +ALTER TABLE pr DROP COLUMN title; +ALTER TABLE pr DROP COLUMN head_sha; +ALTER TABLE pr DROP COLUMN target_branch; +ALTER TABLE pr DROP COLUMN source_branch; +ALTER TABLE pr DROP COLUMN repo; +ALTER TABLE pr DROP COLUMN host; +ALTER TABLE pr DROP COLUMN provider; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_insert +AFTER INSERT ON sessions +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_created', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.activity_state <> NEW.activity_state + OR OLD.is_terminated <> NEW.is_terminated +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_updated', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_insert +AFTER INSERT ON pr +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_created', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_update +AFTER UPDATE ON pr +WHEN OLD.pr_state <> NEW.pr_state + OR OLD.ci_state <> NEW.ci_state + OR OLD.review_decision <> NEW.review_decision + OR OLD.mergeability <> NEW.mergeability +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_updated', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_insert +AFTER INSERT ON pr_checks +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + NEW.created_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_update +AFTER UPDATE ON pr_checks +WHEN OLD.status <> NEW.status +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + datetime('now')); +END; +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0005_pr_last_nudge_signature.sql b/packages/cli/src/lib/migrations/0005_pr_last_nudge_signature.sql new file mode 100644 index 0000000000..026e039624 --- /dev/null +++ b/packages/cli/src/lib/migrations/0005_pr_last_nudge_signature.sql @@ -0,0 +1,18 @@ +-- Summary: persist per-PR reaction dedup signatures so agent nudges +-- (CI failure, review feedback, merge conflict) survive a daemon restart +-- instead of re-firing on the first post-restart observer poll. +-- +-- The column carries a small JSON document encoded by lifecycle.Manager: +-- {"seen":{:}, "attempts":{:}} +-- where reaction_key uniquely identifies a nudge target (e.g. "ci::", +-- "review:", "merge-conflict:") and signature is the content +-- fingerprint that gates whether a re-fire is warranted. +-- +goose Up +-- +goose StatementBegin +ALTER TABLE pr ADD COLUMN last_nudge_signature TEXT NOT NULL DEFAULT ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE pr DROP COLUMN last_nudge_signature; +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0006_pr_session_changed_cdc.sql b/packages/cli/src/lib/migrations/0006_pr_session_changed_cdc.sql new file mode 100644 index 0000000000..815ca483cf --- /dev/null +++ b/packages/cli/src/lib/migrations/0006_pr_session_changed_cdc.sql @@ -0,0 +1,345 @@ +-- +goose Up +-- +goose StatementBegin +DROP TRIGGER IF EXISTS pr_review_threads_cdc_update; +DROP TRIGGER IF EXISTS pr_review_threads_cdc_insert; +DROP TRIGGER IF EXISTS sessions_cdc_insert; +DROP TRIGGER IF EXISTS sessions_cdc_update; +DROP TRIGGER IF EXISTS pr_cdc_insert; +DROP TRIGGER IF EXISTS pr_cdc_update; +DROP TRIGGER IF EXISTS pr_session_cdc_update; +DROP TRIGGER IF EXISTS pr_checks_cdc_insert; +DROP TRIGGER IF EXISTS pr_checks_cdc_update; + +CREATE TABLE change_log_new ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects (id), + session_id TEXT REFERENCES sessions (id), + event_type TEXT NOT NULL + CHECK (event_type IN ( + 'session_created', + 'session_updated', + 'pr_created', + 'pr_updated', + 'pr_check_recorded', + 'pr_session_changed', + 'pr_review_thread_added', + 'pr_review_thread_resolved' + )), + payload TEXT NOT NULL CHECK (json_valid(payload)), + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO change_log_new (seq, project_id, session_id, event_type, payload, created_at) +SELECT seq, project_id, session_id, event_type, payload, created_at +FROM change_log; + +DROP INDEX IF EXISTS idx_change_log_project; +DROP TABLE change_log; +ALTER TABLE change_log_new RENAME TO change_log; +CREATE INDEX idx_change_log_project ON change_log (project_id, seq); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_review_threads_cdc_insert +AFTER INSERT ON pr_review_threads +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_review_thread_added', + json_object( + 'pr', NEW.pr_url, + 'thread', NEW.thread_id, + 'path', NEW.path, + 'line', NEW.line, + 'resolved', json(CASE WHEN NEW.resolved THEN 'true' ELSE 'false' END), + 'isBot', json(CASE WHEN NEW.is_bot THEN 'true' ELSE 'false' END) + ), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_review_threads_cdc_update +AFTER UPDATE ON pr_review_threads +WHEN OLD.resolved <> NEW.resolved +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_review_thread_resolved', + json_object( + 'pr', NEW.pr_url, + 'thread', NEW.thread_id, + 'path', NEW.path, + 'line', NEW.line, + 'resolved', json(CASE WHEN NEW.resolved THEN 'true' ELSE 'false' END) + ), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_insert +AFTER INSERT ON sessions +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_created', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.activity_state <> NEW.activity_state + OR OLD.is_terminated <> NEW.is_terminated +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_updated', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_insert +AFTER INSERT ON pr +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_created', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_update +AFTER UPDATE ON pr +WHEN OLD.pr_state <> NEW.pr_state + OR OLD.ci_state <> NEW.ci_state + OR OLD.review_decision <> NEW.review_decision + OR OLD.mergeability <> NEW.mergeability +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_updated', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_session_cdc_update +AFTER UPDATE ON pr +WHEN OLD.session_id <> NEW.session_id +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT project_id FROM sessions WHERE id = NEW.session_id), + NEW.session_id, + 'pr_session_changed', + json_object( + 'url', NEW.url, + 'fromSession', OLD.session_id, + 'toSession', NEW.session_id), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_insert +AFTER INSERT ON pr_checks +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + NEW.created_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_update +AFTER UPDATE ON pr_checks +WHEN OLD.status <> NEW.status +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + datetime('now')); +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TRIGGER IF EXISTS pr_review_threads_cdc_update; +DROP TRIGGER IF EXISTS pr_review_threads_cdc_insert; +DROP TRIGGER IF EXISTS sessions_cdc_insert; +DROP TRIGGER IF EXISTS sessions_cdc_update; +DROP TRIGGER IF EXISTS pr_cdc_insert; +DROP TRIGGER IF EXISTS pr_cdc_update; +DROP TRIGGER IF EXISTS pr_session_cdc_update; +DROP TRIGGER IF EXISTS pr_checks_cdc_insert; +DROP TRIGGER IF EXISTS pr_checks_cdc_update; + +CREATE TABLE change_log_old ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects (id), + session_id TEXT REFERENCES sessions (id), + event_type TEXT NOT NULL + CHECK (event_type IN ( + 'session_created', + 'session_updated', + 'pr_created', + 'pr_updated', + 'pr_check_recorded', + 'pr_review_thread_added', + 'pr_review_thread_resolved' + )), + payload TEXT NOT NULL CHECK (json_valid(payload)), + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO change_log_old (seq, project_id, session_id, event_type, payload, created_at) +SELECT seq, project_id, session_id, event_type, payload, created_at +FROM change_log +WHERE event_type <> 'pr_session_changed'; + +DROP INDEX IF EXISTS idx_change_log_project; +DROP TABLE change_log; +ALTER TABLE change_log_old RENAME TO change_log; +CREATE INDEX idx_change_log_project ON change_log (project_id, seq); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_review_threads_cdc_insert +AFTER INSERT ON pr_review_threads +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_review_thread_added', + json_object( + 'pr', NEW.pr_url, + 'thread', NEW.thread_id, + 'path', NEW.path, + 'line', NEW.line, + 'resolved', json(CASE WHEN NEW.resolved THEN 'true' ELSE 'false' END), + 'isBot', json(CASE WHEN NEW.is_bot THEN 'true' ELSE 'false' END) + ), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_review_threads_cdc_update +AFTER UPDATE ON pr_review_threads +WHEN OLD.resolved <> NEW.resolved +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_review_thread_resolved', + json_object( + 'pr', NEW.pr_url, + 'thread', NEW.thread_id, + 'path', NEW.path, + 'line', NEW.line, + 'resolved', json(CASE WHEN NEW.resolved THEN 'true' ELSE 'false' END) + ), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_insert +AFTER INSERT ON sessions +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_created', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.activity_state <> NEW.activity_state + OR OLD.is_terminated <> NEW.is_terminated +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_updated', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_insert +AFTER INSERT ON pr +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_created', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_update +AFTER UPDATE ON pr +WHEN OLD.pr_state <> NEW.pr_state + OR OLD.ci_state <> NEW.ci_state + OR OLD.review_decision <> NEW.review_decision + OR OLD.mergeability <> NEW.mergeability +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_updated', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_insert +AFTER INSERT ON pr_checks +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + NEW.created_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_update +AFTER UPDATE ON pr_checks +WHEN OLD.status <> NEW.status +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + datetime('now')); +END; +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0007_allow_implemented_harnesses.sql b/packages/cli/src/lib/migrations/0007_allow_implemented_harnesses.sql new file mode 100644 index 0000000000..36c9d652b5 --- /dev/null +++ b/packages/cli/src/lib/migrations/0007_allow_implemented_harnesses.sql @@ -0,0 +1,42 @@ +-- Widen the sessions.harness CHECK to allow every agent harness AO ships, in a +-- single step. SQLite cannot ALTER a CHECK, so we surgically rewrite the stored +-- CREATE TABLE text in sqlite_master. writable_schema edits must run outside a +-- transaction, and RESET forces an immediate schema reparse on the connection. +-- +-- New harnesses are added here by extending this list, not by chaining a fresh +-- per-harness migration onto the previous one's exact text. + +-- +goose NO TRANSACTION +-- +goose Up +-- +goose StatementBegin +PRAGMA writable_schema = ON; +-- +goose StatementEnd +-- +goose StatementBegin +UPDATE sqlite_master +SET sql = replace( + sql, + 'CHECK (harness IN ('''', ''claude-code'', ''codex'', ''aider'', ''opencode''))', + 'CHECK (harness IN ('''', ''claude-code'', ''codex'', ''aider'', ''opencode'', ''grok'', ''droid'', ''amp'', ''agy'', ''crush'', ''cursor'', ''qwen'', ''copilot'', ''goose'', ''auggie'', ''continue'', ''devin'', ''cline'', ''kimi'', ''kiro'', ''kilocode'', ''vibe'', ''pi'', ''autohand''))' +) +WHERE type = 'table' AND name = 'sessions'; +-- +goose StatementEnd +-- +goose StatementBegin +PRAGMA writable_schema = RESET; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +PRAGMA writable_schema = ON; +-- +goose StatementEnd +-- +goose StatementBegin +UPDATE sqlite_master +SET sql = replace( + sql, + 'CHECK (harness IN ('''', ''claude-code'', ''codex'', ''aider'', ''opencode'', ''grok'', ''droid'', ''amp'', ''agy'', ''crush'', ''cursor'', ''qwen'', ''copilot'', ''goose'', ''auggie'', ''continue'', ''devin'', ''cline'', ''kimi'', ''kiro'', ''kilocode'', ''vibe'', ''pi'', ''autohand''))', + 'CHECK (harness IN ('''', ''claude-code'', ''codex'', ''aider'', ''opencode''))' +) +WHERE type = 'table' AND name = 'sessions'; +-- +goose StatementEnd +-- +goose StatementBegin +PRAGMA writable_schema = RESET; +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0008_add_project_config.sql b/packages/cli/src/lib/migrations/0008_add_project_config.sql new file mode 100644 index 0000000000..e8f987c958 --- /dev/null +++ b/packages/cli/src/lib/migrations/0008_add_project_config.sql @@ -0,0 +1,15 @@ +-- Per-project configuration. A single nullable JSON column on projects holds the +-- typed ProjectConfig (agent settings, env, symlinks, post-create, rules, role +-- overrides, tracker/scm, …) AO resolves at spawn. NULL means unset; a non-NULL +-- value is a JSON object. One blob per project keeps the registry's "SQLite twin +-- of the YAML config" shape rather than splitting config into many columns. + +-- +goose Up +-- +goose StatementBegin +ALTER TABLE projects ADD COLUMN config TEXT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE projects DROP COLUMN config; +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0009_workspace_projects.sql b/packages/cli/src/lib/migrations/0009_workspace_projects.sql new file mode 100644 index 0000000000..cd9e074df4 --- /dev/null +++ b/packages/cli/src/lib/migrations/0009_workspace_projects.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE projects ADD COLUMN kind TEXT NOT NULL DEFAULT 'single_repo' + CHECK (kind IN ('single_repo', 'workspace')); + +CREATE TABLE workspace_repos ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + relative_path TEXT NOT NULL, + repo_origin_url TEXT NOT NULL DEFAULT '', + registered_at TIMESTAMP NOT NULL, + PRIMARY KEY (project_id, name), + UNIQUE (project_id, relative_path) +); + +CREATE TABLE session_worktrees ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + repo_name TEXT NOT NULL, + branch TEXT NOT NULL, + base_sha TEXT NOT NULL, + worktree_path TEXT NOT NULL, + preserved_ref TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'active' + CHECK (state IN ('active', 'removed', 'retry_remove', 'unavailable', 'stray_moved')), + PRIMARY KEY (session_id, repo_name) +); +CREATE INDEX idx_session_worktrees_session ON session_worktrees(session_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE session_worktrees; +DROP TABLE workspace_repos; +-- SQLite cannot drop projects.kind without rebuilding the table. Existing down +-- migrations in this project are best-effort for dev databases; leave the +-- backward-compatible column in place. +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0010_add_first_signal_at.sql b/packages/cli/src/lib/migrations/0010_add_first_signal_at.sql new file mode 100644 index 0000000000..080c44e983 --- /dev/null +++ b/packages/cli/src/lib/migrations/0010_add_first_signal_at.sql @@ -0,0 +1,60 @@ +-- +goose Up +-- first_signal_at records when the FIRST agent hook callback arrived for a +-- session: raw signal receipt, independent of the derived activity state. +-- NULL means no hook has ever reported for the current spawn/restore; the +-- session service derives the "no_signal" display status from it so a broken +-- hook pipeline (agent upgrade, PATH problem, blocked interactive prompt) +-- surfaces as "no activity signal" instead of a confident "idle". +-- +-- Backfill existing rows from activity_last_at: sessions created before this +-- column are treated as having signaled so an upgrade doesn't flip every +-- historical session to no_signal. +-- +goose StatementBegin +ALTER TABLE sessions ADD COLUMN first_signal_at TIMESTAMP; +-- +goose StatementEnd +-- +goose StatementBegin +UPDATE sessions SET first_signal_at = activity_last_at; +-- +goose StatementEnd + +-- Recreate the sessions update CDC trigger so the first hook receipt also +-- fans out a session_updated event: hook deliveries are best-effort, so the +-- first signal to arrive may repeat the seeded activity state (a lost "active" +-- POST followed by a Stop hook landing idle on the idle-seeded row), and +-- without this clause the dashboard would keep showing no_signal until the +-- next real state change. +-- +goose StatementBegin +DROP TRIGGER IF EXISTS sessions_cdc_update; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.activity_state <> NEW.activity_state + OR OLD.is_terminated <> NEW.is_terminated + OR (OLD.first_signal_at IS NULL AND NEW.first_signal_at IS NOT NULL) +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_updated', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TRIGGER IF EXISTS sessions_cdc_update; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.activity_state <> NEW.activity_state + OR OLD.is_terminated <> NEW.is_terminated +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_updated', + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd +-- +goose StatementBegin +ALTER TABLE sessions DROP COLUMN first_signal_at; +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0011_notifications.sql b/packages/cli/src/lib/migrations/0011_notifications.sql new file mode 100644 index 0000000000..9e24d95d0b --- /dev/null +++ b/packages/cli/src/lib/migrations/0011_notifications.sql @@ -0,0 +1,35 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE notifications ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + pr_url TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL CHECK ( + type IN ( + 'needs_input', + 'ready_to_merge', + 'pr_merged', + 'pr_closed_unmerged' + ) + ), + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'unread' CHECK (status IN ('read', 'unread')), + created_at TIMESTAMP NOT NULL +); + +CREATE INDEX idx_notifications_status + ON notifications(status, created_at DESC); + +CREATE UNIQUE INDEX idx_notifications_unread_dedupe + ON notifications(session_id, type, pr_url) + WHERE status = 'unread'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_notifications_unread_dedupe; +DROP INDEX IF EXISTS idx_notifications_status; +DROP TABLE IF EXISTS notifications; +-- +goose StatementEnd diff --git a/packages/cli/src/lib/migrations/0012_add_review_tables.sql b/packages/cli/src/lib/migrations/0012_add_review_tables.sql new file mode 100644 index 0000000000..88419a3a85 --- /dev/null +++ b/packages/cli/src/lib/migrations/0012_add_review_tables.sql @@ -0,0 +1,45 @@ +-- Configurable AO code review (issue #192). review holds one row per worker +-- session under review (session_id UNIQUE); a repeat trigger reuses the row. +-- review_run holds the per-pass facts. The reviewer agent posts its review to +-- the PR itself; `ao review submit` records the verdict and body on the run. + +-- +goose Up +-- +goose StatementBegin +CREATE TABLE review ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE REFERENCES sessions (id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects (id), + harness TEXT NOT NULL, + pr_url TEXT NOT NULL DEFAULT '', + -- runtime handle id of the live reviewer pane, reused across passes and + -- exposed so the UI can attach its terminal over /mux. + reviewer_handle_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE review_run ( + id TEXT PRIMARY KEY, + review_id TEXT NOT NULL REFERENCES review (id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, + harness TEXT NOT NULL, + pr_url TEXT NOT NULL DEFAULT '', + -- the commit the pass reviewed; lets a repeat trigger for the same head + -- short-circuit to the existing run. + target_sha TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'running', + verdict TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE review_run; +-- +goose StatementEnd +-- +goose StatementBegin +DROP TABLE review; +-- +goose StatementEnd From 78838eec75b9867590015fdb782036b4fa305bae Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 00:56:21 +0530 Subject: [PATCH 2/5] feat(cli): offline SQLite writer for ao migrate (migrate-db.ts) Goose-compatible runner (StatementBegin/End spans, NO-TRANSACTION + 0007 writable_schema via better-sqlite3 unsafeMode), v3 goose_db_version stamping, the >= vendored precondition guard (absent/locked/older/missing), and FK-ordered ON CONFLICT DO NOTHING inserts. Adds better-sqlite3 to the CLI's optionalDependencies for deterministic createRequire resolution and copies the vendored migrations into dist on build. Integration tests assert MAX(version_id)==12 and a cursor-harness insert (proves 0007 + writable_schema=RESET took effect) plus the full precondition matrix and a checksum drift guard pinned to 43ae7eb. Refs #2129. Co-Authored-By: Claude Opus 4.8 --- packages/cli/__tests__/lib/migrate-db.test.ts | 244 +++++++++++ packages/cli/__tests__/lib/migrations.test.ts | 56 +++ packages/cli/package.json | 5 +- packages/cli/src/lib/migrate-db.ts | 379 ++++++++++++++++++ pnpm-lock.yaml | 4 + 5 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 packages/cli/__tests__/lib/migrate-db.test.ts create mode 100644 packages/cli/__tests__/lib/migrations.test.ts create mode 100644 packages/cli/src/lib/migrate-db.ts diff --git a/packages/cli/__tests__/lib/migrate-db.test.ts b/packages/cli/__tests__/lib/migrate-db.test.ts new file mode 100644 index 0000000000..d46c204aa6 --- /dev/null +++ b/packages/cli/__tests__/lib/migrate-db.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +import { + VENDORED_SCHEMA_VERSION, + MigrateRefusal, + openMigrationDb, + insertMigration, + loadBetterSqlite3, + type ProjectRow, + type SessionRow, + type Sqlite3Ctor, +} from "../../src/lib/migrate-db.js"; + +const _require = createRequire(import.meta.url); + +// Skip the whole suite if better-sqlite3 cannot load in this environment +// (native build failure). The migrator itself refuses in that case; there is +// nothing to integration-test without it. +let sqlite3Available = true; +try { + loadBetterSqlite3(); +} catch { + sqlite3Available = false; +} + +const NOW = "2026-06-18T00:00:00.000Z"; + +function projectRow(overrides: Partial = {}): ProjectRow { + return { + id: "proj", + path: "/repos/proj", + repo_origin_url: "", + display_name: "", + registered_at: NOW, + kind: "single_repo", + config: null, + ...overrides, + }; +} + +function sessionRow(overrides: Partial = {}): SessionRow { + return { + id: "proj-orchestrator", + project_id: "proj", + num: 0, + kind: "orchestrator", + harness: "claude-code", + activity_state: "idle", + activity_last_at: NOW, + is_terminated: 0, + branch: "", + workspace_path: "", + runtime_handle_id: "", + agent_session_id: "", + prompt: "", + display_name: "", + first_signal_at: NOW, + created_at: NOW, + updated_at: NOW, + ...overrides, + }; +} + +describe.skipIf(!sqlite3Available)("migrate-db (integration, better-sqlite3)", () => { + let dir: string; + let dbPath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ao-migrate-db-")); + dbPath = join(dir, "ao.db"); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("builds the DB from vendored migrations at the vendored schema version", () => { + const { db, dbCreated, schemaVersion } = openMigrationDb(dbPath); + try { + expect(dbCreated).toBe(true); + expect(schemaVersion).toBe(VENDORED_SCHEMA_VERSION); + expect(schemaVersion).toBe(12); + + const max = db + .prepare("SELECT MAX(version_id) AS v FROM goose_db_version") + .get() as { v: number }; + expect(max.v).toBe(12); + + const tables = ( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() as Array<{ name: string }> + ).map((r) => r.name); + expect(tables).toEqual( + expect.arrayContaining(["projects", "sessions", "pr", "change_log", "goose_db_version"]), + ); + } finally { + db.close(); + } + }); + + // The single highest-value assertion: proves 0007 ran AND that + // `PRAGMA writable_schema = RESET` took effect on better-sqlite3's bundled + // SQLite — otherwise a `cursor` harness would fail the (un-widened) CHECK. + it("applies 0007 so a widened harness (cursor) inserts successfully", () => { + const { db } = openMigrationDb(dbPath); + try { + db.prepare( + `INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, kind, config) + VALUES ('p', '/p', '', '', ?, 'single_repo', NULL)`, + ).run(NOW); + expect(() => + db + .prepare( + `INSERT INTO sessions (id, project_id, num, kind, harness, activity_state, activity_last_at, created_at, updated_at) + VALUES ('p-1', 'p', 1, 'worker', 'cursor', 'idle', ?, ?, ?)`, + ) + .run(NOW, NOW, NOW), + ).not.toThrow(); + } finally { + db.close(); + } + }); + + it("inserts projects before sessions and fires exactly one session_created CDC row", () => { + const { db } = openMigrationDb(dbPath); + try { + const result = insertMigration(db, [projectRow()], [sessionRow()]); + expect(result.projects).toEqual({ created: 1, skipped: 0, failed: 0 }); + expect(result.orchestrators).toEqual({ created: 1, skipped: 0, failed: 0 }); + + const changes = db + .prepare("SELECT event_type, project_id, session_id FROM change_log") + .all() as Array<{ event_type: string; project_id: string; session_id: string }>; + expect(changes).toEqual([ + { event_type: "session_created", project_id: "proj", session_id: "proj-orchestrator" }, + ]); + } finally { + db.close(); + } + }); + + it("is idempotent: a re-run inserts nothing and counts everything skipped", () => { + const first = openMigrationDb(dbPath); + insertMigration(first.db, [projectRow()], [sessionRow()]); + first.db.close(); + + const second = openMigrationDb(dbPath); + try { + expect(second.dbCreated).toBe(false); + const result = insertMigration(second.db, [projectRow()], [sessionRow()]); + expect(result.projects).toEqual({ created: 0, skipped: 1, failed: 0 }); + expect(result.orchestrators).toEqual({ created: 0, skipped: 1, failed: 0 }); + + const projectCount = second.db + .prepare("SELECT COUNT(*) AS c FROM projects") + .get() as { c: number }; + expect(projectCount.c).toBe(1); + // No new CDC row from the no-op session insert. + const changeCount = second.db + .prepare("SELECT COUNT(*) AS c FROM change_log") + .get() as { c: number }; + expect(changeCount.c).toBe(1); + } finally { + second.db.close(); + } + }); + + describe("preconditions", () => { + it("absent DB -> creates (dbCreated=true)", () => { + expect(existsSync(dbPath)).toBe(false); + const { db, dbCreated } = openMigrationDb(dbPath); + db.close(); + expect(dbCreated).toBe(true); + expect(existsSync(dbPath)).toBe(true); + }); + + it("present at >= vendored -> opens read-existing and inserts (dbCreated=false)", () => { + openMigrationDb(dbPath).db.close(); + const { db, dbCreated, schemaVersion } = openMigrationDb(dbPath); + try { + expect(dbCreated).toBe(false); + expect(schemaVersion).toBe(12); + const result = insertMigration(db, [projectRow()], []); + expect(result.projects.created).toBe(1); + } finally { + db.close(); + } + }); + + it("present below vendored -> refuses", () => { + // Build a DB then strip versions down to 11 to simulate an older schema. + const created = openMigrationDb(dbPath); + created.db.prepare("DELETE FROM goose_db_version WHERE version_id >= 12").run(); + created.db.close(); + + expect(() => openMigrationDb(dbPath)).toThrow(MigrateRefusal); + try { + openMigrationDb(dbPath); + } catch (err) { + expect((err as MigrateRefusal).code).toBe("SCHEMA_TOO_OLD"); + } + }); + + it("locked DB -> refuses", () => { + openMigrationDb(dbPath).db.close(); + const Database = loadBetterSqlite3(); + const holder = new Database(dbPath); + // Hold a write lock so the migrator's busy_timeout=0 probe sees SQLITE_BUSY. + holder.exec("BEGIN IMMEDIATE"); + try { + expect(() => openMigrationDb(dbPath)).toThrow(MigrateRefusal); + try { + openMigrationDb(dbPath); + } catch (err) { + expect((err as MigrateRefusal).code).toBe("DB_LOCKED"); + } + } finally { + holder.exec("ROLLBACK"); + holder.close(); + } + }); + + it("better-sqlite3 unavailable -> refuses", () => { + const loader = (): Sqlite3Ctor => { + throw new Error("Cannot find module 'better-sqlite3'"); + }; + expect(() => openMigrationDb(dbPath, { loader })).toThrow(MigrateRefusal); + try { + openMigrationDb(dbPath, { loader }); + } catch (err) { + expect((err as MigrateRefusal).code).toBe("BETTER_SQLITE3_UNAVAILABLE"); + } + }); + }); +}); + +// Sanity guard so the suite is not silently skipped in CI where the native +// module is expected to be present. +it("better-sqlite3 is resolvable for the integration suite", () => { + expect(() => _require.resolve("better-sqlite3")).not.toThrow(); +}); diff --git a/packages/cli/__tests__/lib/migrations.test.ts b/packages/cli/__tests__/lib/migrations.test.ts new file mode 100644 index 0000000000..6987d0634e --- /dev/null +++ b/packages/cli/__tests__/lib/migrations.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +/** + * Drift guard for the vendored goose migrations. + * + * These `.sql` files are copied verbatim from aoagents/ReverbCode @ commit + * 43ae7eb. If ReverbCode changes a migration (or someone edits a vendored copy), + * these checksums change and this test fails — forcing a deliberate re-vendor + + * `VENDORED_SCHEMA_VERSION` bump rather than a silent schema drift. + */ +const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../src/lib/migrations"); + +// sha256 of each file's exact 43ae7eb content. +const EXPECTED: Record = { + "0001_init.sql": "dd14f805954310190359da414532c0eeff2bed1f5cbee5311512fc72a2999c54", + "0002_remove_activity_source.sql": + "608ae733fad29cc1be9e67697fed57019f39998345237c42d975591296f64225", + "0003_add_session_display_name.sql": + "691fe9702006c2194443d4fa593f9a94cf02b17eee97db84668c6133b46ae77d", + "0004_scm_observer_schema.sql": + "dbd2eab13fa80db44c6a17cd199a0a1bcc40540580361a6ae15857736fe2068a", + "0005_pr_last_nudge_signature.sql": + "69eb49a17ecb5a1974f0fde3bdb45f1f3cfd5b2c44430ba993ae77f059ca6345", + "0006_pr_session_changed_cdc.sql": + "1bff6ec226f5fa31f8eeddf5839fd7fda2aa44ff394164e4a96a83a77417514b", + "0007_allow_implemented_harnesses.sql": + "c688f73979e334e4f637c9a2e986673bdc4d6a7ecc8511511b93ee330d622735", + "0008_add_project_config.sql": + "b1630baba5a41b309197b1f05a41e7b9ba6a1c993d8e618d8e107379a310f070", + "0009_workspace_projects.sql": + "7d73830cdd4f344804cf2d4eed1d6ef2a9acd5263b14b67b57ef3782a05af871", + "0010_add_first_signal_at.sql": + "20970792497b49964eb61ab1998d67a185c2294591436184fd2c02f96c381abd", + "0011_notifications.sql": "231cf983109e2d8a87af0af15d68d9b2a22b944818a8165105e5fda2fa0aa990", + "0012_add_review_tables.sql": + "68878b097954e6725936c83e2017ad8d91aba0a6ee34f49b25b8cafc1fa84b08", +}; + +describe("vendored migrations (43ae7eb pin)", () => { + it("vendors exactly the expected files in numeric order", () => { + const files = readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith(".sql")) + .sort(); + expect(files).toEqual(Object.keys(EXPECTED)); + }); + + it.each(Object.entries(EXPECTED))("%s matches the pinned checksum", (file, expected) => { + const content = readFileSync(join(MIGRATIONS_DIR, file)); + const actual = createHash("sha256").update(content).digest("hex"); + expect(actual).toBe(expected); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index a7501c411b..e42274ebc1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,7 +25,7 @@ "node": ">=20.18.3" }, "scripts": { - "build": "tsc && node --input-type=commonjs -e \"require('fs').cpSync('src/assets','dist/assets',{recursive:true})\"", + "build": "tsc && node --input-type=commonjs -e \"const fs=require('fs');fs.cpSync('src/assets','dist/assets',{recursive:true});fs.cpSync('src/lib/migrations','dist/lib/migrations',{recursive:true})\"", "dev": "tsx src/index.ts", "test": "vitest run", "test:watch": "vitest", @@ -67,6 +67,9 @@ "yaml": "^2.7.0", "zod": "^3.25.76" }, + "optionalDependencies": { + "better-sqlite3": "^12.10.0" + }, "devDependencies": { "@types/node": "^25.2.3", "@vitest/coverage-v8": "^3.0.0", diff --git a/packages/cli/src/lib/migrate-db.ts b/packages/cli/src/lib/migrate-db.ts new file mode 100644 index 0000000000..19129af09d --- /dev/null +++ b/packages/cli/src/lib/migrate-db.ts @@ -0,0 +1,379 @@ +/** + * Offline SQLite writer for `ao migrate` (#2129). + * + * Runs with the rewrite daemon STOPPED. Creates the rewrite's `~/.ao/data/ao.db` + * from a vendored copy of its goose migrations when absent, or inserts into an + * existing (>= vendored) schema. Never re-runs migrations on a present DB. + * + * The vendored migrations under `./migrations/` are pinned to aoagents/ReverbCode + * @ commit 43ae7eb (see the checksum test in __tests__/lib/migrations.test.ts). + * `VENDORED_SCHEMA_VERSION` must equal the highest vendored migration number. + */ + +import { createRequire } from "node:module"; +import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Highest vendored migration number. The `>= vendored` guard pivots on this. */ +export const VENDORED_SCHEMA_VERSION = 12; + +// Minimal structural type for the slice of better-sqlite3 we use (no @types dep). +export interface BetterSqlite3Statement { + run(...args: unknown[]): { changes: number; lastInsertRowid: number | bigint }; + get(...args: unknown[]): unknown; + all(...args: unknown[]): unknown[]; +} +export interface BetterSqlite3Database { + pragma(source: string, options?: { simple?: boolean }): unknown; + exec(source: string): void; + prepare(source: string): BetterSqlite3Statement; + transaction unknown>(fn: T): T; + /** Lift the safe-mode guard so `writable_schema` edits (0007) are permitted. */ + unsafeMode(enabled: boolean): void; + close(): void; +} +export type Sqlite3Ctor = new ( + path: string, + options?: { fileMustExist?: boolean }, +) => BetterSqlite3Database; + +/** + * A refusal: a precondition that means the caller must abort and leave the user + * on legacy. Carries a stable `code` for the summary/exit-code contract. + */ +export class MigrateRefusal extends Error { + constructor( + readonly code: + | "BETTER_SQLITE3_UNAVAILABLE" + | "DB_LOCKED" + | "SCHEMA_TOO_OLD", + message: string, + ) { + super(message); + this.name = "MigrateRefusal"; + } +} + +/** + * Lazy-load better-sqlite3 via createRequire so a native build failure surfaces + * as a catchable refusal rather than an import-time crash (mirrors + * packages/core/src/events-db.ts). + */ +export function loadBetterSqlite3(): Sqlite3Ctor { + const _require = createRequire(import.meta.url); + try { + return _require("better-sqlite3") as Sqlite3Ctor; + } catch (err) { + throw new MigrateRefusal( + "BETTER_SQLITE3_UNAVAILABLE", + "better-sqlite3 is not available. Install it (it ships as an optional native dependency) and retry: " + + (err instanceof Error ? err.message : String(err)), + ); + } +} + +/** Directory holding the vendored `0001…0012.sql` files (works in src and dist). */ +function defaultMigrationsDir(): string { + return join(dirname(fileURLToPath(import.meta.url)), "migrations"); +} + +interface ParsedMigration { + versionId: number; + noTransaction: boolean; + blocks: string[]; +} + +/** + * Extract the executable `-- +goose Up` StatementBegin/End blocks from a goose v3 + * `.sql` file. CDC trigger bodies contain semicolons, so each StatementBegin…End + * span is kept whole (a naive `;`-split would corrupt them). + */ +function parseGooseUp(versionId: number, sql: string): ParsedMigration { + const noTransaction = /--\s*\+goose\s+NO\s+TRANSACTION/i.test(sql); + + const upIdx = sql.indexOf("-- +goose Up"); + let up = upIdx >= 0 ? sql.slice(upIdx) : sql; + const downIdx = up.indexOf("-- +goose Down"); + if (downIdx >= 0) up = up.slice(0, downIdx); + + const blocks: string[] = []; + const re = /--\s*\+goose\s+StatementBegin\b([\s\S]*?)--\s*\+goose\s+StatementEnd\b/g; + let m: RegExpExecArray | null; + while ((m = re.exec(up)) !== null) { + const text = m[1].trim(); + if (text) blocks.push(text); + } + return { versionId, noTransaction, blocks }; +} + +/** Load + parse all vendored migrations in numeric order. */ +function loadMigrations(migrationsDir: string): ParsedMigration[] { + const files = readdirSync(migrationsDir) + .filter((f) => /^\d{4}_.*\.sql$/.test(f)) + .sort(); + return files.map((file) => { + const versionId = parseInt(file.slice(0, 4), 10); + return parseGooseUp(versionId, readFileSync(join(migrationsDir, file), "utf-8")); + }); +} + +/** + * Stamp `goose_db_version` in the exact goose v3.27.1 sqlite3 format (one row per + * applied migration; no version-0 seed row). After this, the rewrite's + * `SELECT MAX(version_id)` reads the vendored version and boot-time `goose.Up` + * applies nothing older. + */ +function stampGooseVersion(db: BetterSqlite3Database, versionIds: number[]): void { + db.exec(` + CREATE TABLE goose_db_version ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL, + is_applied INTEGER NOT NULL, + tstamp TIMESTAMP DEFAULT (datetime('now')) + ); + `); + const insert = db.prepare("INSERT INTO goose_db_version (version_id, is_applied) VALUES (?, 1)"); + const stamp = db.transaction((ids: number[]) => { + for (const id of ids) insert.run(id); + }); + stamp(versionIds); +} + +/** + * Build the schema by running the vendored migrations through a minimal + * goose-compatible runner. NO-TRANSACTION files (0007's writable_schema rewrite) + * run outside a transaction; everything else is wrapped per-file. + */ +function runGooseMigrations(db: BetterSqlite3Database, migrationsDir: string): void { + const migrations = loadMigrations(migrationsDir); + for (const migration of migrations) { + if (migration.noTransaction) { + // 0007 rewrites sqlite_master via `PRAGMA writable_schema`. better-sqlite3 + // blocks sqlite_master writes in safe mode, so lift the guard for the file. + db.unsafeMode(true); + try { + for (const block of migration.blocks) db.exec(block); + } finally { + db.unsafeMode(false); + } + } else { + const apply = db.transaction((blocks: string[]) => { + for (const block of blocks) db.exec(block); + }); + apply(migration.blocks); + } + } + stampGooseVersion( + db, + migrations.map((m) => m.versionId), + ); +} + +/** Read MAX(version_id); returns 0 if the goose table is absent (not a v-stamped DB). */ +function readSchemaVersion(db: BetterSqlite3Database): number { + try { + const row = db.prepare("SELECT MAX(version_id) AS v FROM goose_db_version").get() as { + v: number | null; + } | null; + return row?.v ?? 0; + } catch { + return 0; + } +} + +/** Probe for a write lock (a running rewrite daemon) with no wait. */ +function assertNotLocked(db: BetterSqlite3Database): void { + db.pragma("busy_timeout = 0"); + try { + db.exec("BEGIN IMMEDIATE"); + db.exec("ROLLBACK"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (/SQLITE_BUSY|database is locked/i.test(message)) { + throw new MigrateRefusal( + "DB_LOCKED", + "The target database is locked. A rewrite daemon appears to be running; stop it (ao stop) and retry.", + ); + } + throw err; + } +} + +export interface OpenOptions { + /** Override the better-sqlite3 loader (tests / mocking the missing-module path). */ + loader?: () => Sqlite3Ctor; + /** Override the vendored migrations directory (tests). */ + migrationsDir?: string; +} + +export interface OpenResult { + db: BetterSqlite3Database; + dbCreated: boolean; + /** MAX(version_id) after preconditions — the `schemaVersion` reported in the summary. */ + schemaVersion: number; +} + +/** + * Apply the LOCKED precondition guard and return an open DB ready for inserts: + * - better-sqlite3 unavailable -> REFUSE (BETTER_SQLITE3_UNAVAILABLE) + * - absent -> CREATE at the vendored schema (dbCreated=true) + * - present + locked -> REFUSE (DB_LOCKED) + * - present + MAX(version) < 12 -> REFUSE (SCHEMA_TOO_OLD) + * - present + MAX(version) >= 12 -> open as-is (dbCreated=false) + * + * Existence is checked FIRST so a refusal never leaves a stray empty DB + * (better-sqlite3 creates the file on open unless `fileMustExist`). + */ +export function openMigrationDb(dbPath: string, opts: OpenOptions = {}): OpenResult { + let Database: Sqlite3Ctor; + try { + Database = (opts.loader ?? loadBetterSqlite3)(); + } catch (err) { + if (err instanceof MigrateRefusal) throw err; + // A custom loader (or any non-refusal failure) that cannot yield the + // constructor means better-sqlite3 is effectively unavailable. + throw new MigrateRefusal( + "BETTER_SQLITE3_UNAVAILABLE", + err instanceof Error ? err.message : String(err), + ); + } + const migrationsDir = opts.migrationsDir ?? defaultMigrationsDir(); + + if (!existsSync(dbPath)) { + mkdirSync(dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + runGooseMigrations(db, migrationsDir); + return { db, dbCreated: true, schemaVersion: readSchemaVersion(db) }; + } + + const db = new Database(dbPath, { fileMustExist: true }); + try { + assertNotLocked(db); + const schemaVersion = readSchemaVersion(db); + if (schemaVersion < VENDORED_SCHEMA_VERSION) { + throw new MigrateRefusal( + "SCHEMA_TOO_OLD", + `The target database is at schema v${schemaVersion}, older than this migrator expected (v${VENDORED_SCHEMA_VERSION}). Update the rewrite first.`, + ); + } + return { db, dbCreated: false, schemaVersion }; + } catch (err) { + db.close(); + throw err; + } +} + +// --------------------------------------------------------------------------- +// Row shapes + inserts +// --------------------------------------------------------------------------- + +/** A row for the `projects` table (§13). */ +export interface ProjectRow { + id: string; + path: string; + repo_origin_url: string; + display_name: string; + registered_at: string; + kind: string; + config: string | null; +} + +/** A row for the `sessions` table (§13). `is_terminated` is 0/1. */ +export interface SessionRow { + id: string; + project_id: string; + num: number; + kind: string; + harness: string; + activity_state: string; + activity_last_at: string; + is_terminated: 0 | 1; + branch: string; + workspace_path: string; + runtime_handle_id: string; + agent_session_id: string; + prompt: string; + display_name: string; + first_signal_at: string | null; + created_at: string; + updated_at: string; +} + +export interface InsertCounts { + created: number; + skipped: number; + failed: number; +} + +export interface InsertResult { + projects: InsertCounts; + orchestrators: InsertCounts; +} + +const PROJECT_INSERT = ` + INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, kind, config) + VALUES (@id, @path, @repo_origin_url, @display_name, @registered_at, @kind, @config) + ON CONFLICT(id) DO NOTHING`; + +const SESSION_INSERT = ` + INSERT INTO sessions ( + id, project_id, num, kind, harness, activity_state, activity_last_at, + is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, + prompt, display_name, first_signal_at, created_at, updated_at + ) VALUES ( + @id, @project_id, @num, @kind, @harness, @activity_state, @activity_last_at, + @is_terminated, @branch, @workspace_path, @runtime_handle_id, @agent_session_id, + @prompt, @display_name, @first_signal_at, @created_at, @updated_at + ) + ON CONFLICT(id) DO NOTHING`; + +/** + * Insert projects then orchestrators in one transaction (§10.4). Projects MUST + * precede sessions: `sessions.project_id` FKs `projects(id)`, and the + * `sessions_cdc_insert` trigger writes a `change_log` row whose `project_id` also + * FKs `projects(id)`. + * + * `ON CONFLICT(id) DO NOTHING` makes this idempotent: an existing row stays + * untouched (changes=0 -> skipped). A row that errors is counted as failed + * (SQLite statement-level ABORT keeps the surrounding transaction usable). + */ +export function insertMigration( + db: BetterSqlite3Database, + projects: ProjectRow[], + orchestrators: SessionRow[], +): InsertResult { + db.pragma("foreign_keys = ON"); + + const projectStmt = db.prepare(PROJECT_INSERT); + const sessionStmt = db.prepare(SESSION_INSERT); + + const result: InsertResult = { + projects: { created: 0, skipped: 0, failed: 0 }, + orchestrators: { created: 0, skipped: 0, failed: 0 }, + }; + + const run = db.transaction(() => { + for (const row of projects) { + try { + const info = projectStmt.run(row); + if (info.changes === 1) result.projects.created++; + else result.projects.skipped++; + } catch { + result.projects.failed++; + } + } + for (const row of orchestrators) { + try { + const info = sessionStmt.run({ ...row, is_terminated: row.is_terminated }); + if (info.changes === 1) result.orchestrators.created++; + else result.orchestrators.skipped++; + } catch { + result.orchestrators.failed++; + } + } + }); + run(); + + return result; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cdcee7c54..d7577dd63c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,10 @@ importers: zod: specifier: ^3.25.76 version: 3.25.76 + optionalDependencies: + better-sqlite3: + specifier: ^12.10.0 + version: 12.10.0 devDependencies: '@types/node': specifier: ^25.2.3 From 7961e8bd38a5ca42c7aec995f22180d06eb4b781 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 01:01:23 +0530 Subject: [PATCH 3/5] feat(cli): pure project + orchestrator mappers for ao migrate migrate.ts: recovered pure project mappers from the closed draft #2127 (buildProjectPlan/buildRewriteConfig/mapPermission/mapHarness/isValidRewriteProjectId), dropping the loopback-REST half now that migrate writes SQLite directly. migrate-orchestrator.ts: maps a project's single non-terminated orchestrator record to one sessions row (verbatim {prefix}-orchestrator, num=0), with the 8->5 activity-state map, per-harness agent_session_id selection, the terminal/aider skip filters, double-decoded lifecycle/statePayload, and claude-code transcript relocation inputs. Refs #2129. Co-Authored-By: Claude Opus 4.8 --- .../lib/migrate-orchestrator.test.ts | 174 +++++++++++ packages/cli/__tests__/lib/migrate.test.ts | 176 +++++++++++ packages/cli/src/lib/migrate-orchestrator.ts | 288 ++++++++++++++++++ packages/cli/src/lib/migrate.ts | 246 +++++++++++++++ 4 files changed, 884 insertions(+) create mode 100644 packages/cli/__tests__/lib/migrate-orchestrator.test.ts create mode 100644 packages/cli/__tests__/lib/migrate.test.ts create mode 100644 packages/cli/src/lib/migrate-orchestrator.ts create mode 100644 packages/cli/src/lib/migrate.ts diff --git a/packages/cli/__tests__/lib/migrate-orchestrator.test.ts b/packages/cli/__tests__/lib/migrate-orchestrator.test.ts new file mode 100644 index 0000000000..62592a1b91 --- /dev/null +++ b/packages/cli/__tests__/lib/migrate-orchestrator.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from "vitest"; +import type { ProjectConfig } from "@aoagents/ao-core"; +import { + mapOrchestratorRow, + resolveOrchestratorPrefix, +} from "../../src/lib/migrate-orchestrator.js"; + +const MTIME = "2026-01-01T00:00:00.000Z"; + +/** A real-shaped V2 orchestrator metadata record. */ +function record(overrides: Record = {}): Record { + return { + role: "orchestrator", + agent: "claude-code", + branch: "orchestrator/app-orchestrator", + worktree: "/data/worktrees/app/orchestrator/app-orchestrator", + userPrompt: "drive the project", + displayName: "App Orchestrator", + claudeSessionUuid: "11111111-2222-3333-4444-555555555555", + createdAt: "2026-06-01T10:00:00.000Z", + lifecycle: { + version: 2, + session: { + kind: "orchestrator", + state: "working", + reason: "task_in_progress", + startedAt: "2026-06-01T10:00:00.000Z", + completedAt: null, + terminatedAt: null, + lastTransitionAt: "2026-06-02T12:00:00.000Z", + }, + runtime: { state: "alive", lastObservedAt: "2026-06-02T12:30:00.000Z" }, + }, + ...overrides, + }; +} + +describe("mapOrchestratorRow", () => { + it("maps a working claude-code orchestrator with verbatim id and num=0", () => { + const result = mapOrchestratorRow(record(), "app-project", "app", MTIME); + expect(result.status).toBe("mapped"); + expect(result.row).toMatchObject({ + id: "app-orchestrator", + num: 0, + project_id: "app-project", + kind: "orchestrator", + harness: "claude-code", + activity_state: "active", + is_terminated: 0, + runtime_handle_id: "", + branch: "orchestrator/app-orchestrator", + workspace_path: "/data/worktrees/app/orchestrator/app-orchestrator", + prompt: "drive the project", + display_name: "App Orchestrator", + agent_session_id: "11111111-2222-3333-4444-555555555555", + }); + }); + + it("derives timestamps: activity/first_signal from lastTransitionAt, created from createdAt", () => { + const { row } = mapOrchestratorRow(record(), "app", "app", MTIME); + expect(row?.activity_last_at).toBe("2026-06-02T12:00:00.000Z"); + expect(row?.first_signal_at).toBe("2026-06-02T12:00:00.000Z"); + expect(row?.updated_at).toBe("2026-06-02T12:00:00.000Z"); + expect(row?.created_at).toBe("2026-06-01T10:00:00.000Z"); + }); + + it("falls back created_at -> startedAt -> file mtime", () => { + const noCreated = record({ createdAt: undefined }); + expect(mapOrchestratorRow(noCreated, "app", "app", MTIME).row?.created_at).toBe( + "2026-06-01T10:00:00.000Z", // startedAt + ); + const noTimes = record({ + createdAt: undefined, + lifecycle: { version: 2, session: { state: "idle", terminatedAt: null }, runtime: {} }, + }); + const row = mapOrchestratorRow(noTimes, "app", "app", MTIME).row; + expect(row?.created_at).toBe(MTIME); + expect(row?.activity_last_at).toBe(MTIME); // falls through to created_at + }); + + it("maps the 8-state enum onto 5 activity states", () => { + const states: Array<[string, string]> = [ + ["working", "active"], + ["not_started", "idle"], + ["idle", "idle"], + ["detecting", "idle"], + ["stuck", "idle"], + ["needs_input", "waiting_input"], + ]; + for (const [legacy, expected] of states) { + const rec = record({ lifecycle: { session: { state: legacy, terminatedAt: null } } }); + expect(mapOrchestratorRow(rec, "p", "p", MTIME).row?.activity_state).toBe(expected); + } + }); + + it("selects agent_session_id by harness", () => { + const codex = record({ agent: "codex", codexThreadId: "thread-abc", claudeSessionUuid: undefined }); + expect(mapOrchestratorRow(codex, "p", "p", MTIME).row?.agent_session_id).toBe("thread-abc"); + + const opencode = record({ agent: "opencode", opencodeSessionId: "oc-1", claudeSessionUuid: undefined }); + expect(mapOrchestratorRow(opencode, "p", "p", MTIME).row?.agent_session_id).toBe("oc-1"); + + const claudeNoUuid = record({ claudeSessionUuid: undefined }); + expect(mapOrchestratorRow(claudeNoUuid, "p", "p", MTIME).row?.agent_session_id).toBe(""); + }); + + it("plans a transcript relocation only for claude-code with a uuid + worktree", () => { + expect(mapOrchestratorRow(record(), "p", "p", MTIME).transcript).toEqual({ + worktree: "/data/worktrees/app/orchestrator/app-orchestrator", + uuid: "11111111-2222-3333-4444-555555555555", + }); + // codex carries its resume id in agent_session_id, no file move. + const codex = record({ agent: "codex", codexThreadId: "t", claudeSessionUuid: undefined }); + expect(mapOrchestratorRow(codex, "p", "p", MTIME).transcript).toBeUndefined(); + // claude-code without a worktree cannot compute a destination slug. + const noWorktree = record({ worktree: undefined }); + expect(mapOrchestratorRow(noWorktree, "p", "p", MTIME).transcript).toBeUndefined(); + }); + + it("skips a terminal orchestrator (state done/terminated or terminatedAt set)", () => { + const done = record({ lifecycle: { session: { state: "done", terminatedAt: null } } }); + expect(mapOrchestratorRow(done, "p", "p", MTIME)).toMatchObject({ status: "skipped" }); + + const terminated = record({ + lifecycle: { session: { state: "working", terminatedAt: "2026-06-03T00:00:00.000Z" } }, + }); + expect(mapOrchestratorRow(terminated, "p", "p", MTIME)).toMatchObject({ status: "skipped" }); + }); + + it("skips a non-migratable harness (aider) with a note", () => { + const aider = record({ agent: "aider" }); + const result = mapOrchestratorRow(aider, "p", "p", MTIME); + expect(result.status).toBe("skipped"); + expect(result.note).toMatch(/aider.*not migratable/); + }); + + it("double-decodes a stringified lifecycle and a stringified nested session", () => { + const stringifiedSession = record({ + lifecycle: JSON.stringify({ + version: 2, + session: JSON.stringify({ state: "needs_input", terminatedAt: null }), + runtime: {}, + }), + }); + const result = mapOrchestratorRow(stringifiedSession, "p", "p", MTIME); + expect(result.status).toBe("mapped"); + expect(result.row?.activity_state).toBe("waiting_input"); + }); + + it("reads lifecycle from statePayload when stateVersion === '2'", () => { + const legacyShape = record({ + lifecycle: undefined, + stateVersion: "2", + statePayload: JSON.stringify({ session: { state: "working", terminatedAt: null } }), + }); + expect(mapOrchestratorRow(legacyShape, "p", "p", MTIME).row?.activity_state).toBe("active"); + }); +}); + +describe("resolveOrchestratorPrefix", () => { + it("uses the configured sessionPrefix", () => { + expect(resolveOrchestratorPrefix("some-long-project-id", { sessionPrefix: "app" } as ProjectConfig)).toBe( + "app", + ); + }); + it("falls back to the first 12 chars of the project id", () => { + expect(resolveOrchestratorPrefix("some-long-project-id", {} as ProjectConfig)).toBe( + "some-long-pr", + ); + expect(resolveOrchestratorPrefix("short", { sessionPrefix: " " } as ProjectConfig)).toBe( + "short", + ); + }); +}); diff --git a/packages/cli/__tests__/lib/migrate.test.ts b/packages/cli/__tests__/lib/migrate.test.ts new file mode 100644 index 0000000000..dc5496c8f2 --- /dev/null +++ b/packages/cli/__tests__/lib/migrate.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from "vitest"; +import type { ProjectConfig } from "@aoagents/ao-core"; +import { + buildProjectPlan, + buildRewriteConfig, + isValidRewriteProjectId, + mapHarness, + mapPermission, +} from "../../src/lib/migrate.js"; + +// --------------------------------------------------------------------------- +// fixtures +// --------------------------------------------------------------------------- + +function project(overrides: Partial = {}): ProjectConfig { + return { + name: "My Project", + path: "/repos/my-project", + defaultBranch: "main", + // Empty by default so per-field assertions stay focused; a dedicated test + // covers sessionPrefix carry-over. + sessionPrefix: "", + ...overrides, + } as ProjectConfig; +} + +// --------------------------------------------------------------------------- +// isValidRewriteProjectId +// --------------------------------------------------------------------------- + +describe("isValidRewriteProjectId", () => { + it("accepts legacy-style ids (a strict subset of the rewrite grammar)", () => { + expect(isValidRewriteProjectId("agent-orchestrator")).toBe(true); + expect(isValidRewriteProjectId("repo_1")).toBe(true); + }); + it("rejects empty, dot-dot, and path separators", () => { + expect(isValidRewriteProjectId("")).toBe(false); + expect(isValidRewriteProjectId(".")).toBe(false); + expect(isValidRewriteProjectId("a..b")).toBe(false); + expect(isValidRewriteProjectId("a/b")).toBe(false); + expect(isValidRewriteProjectId("a\\b")).toBe(false); + expect(isValidRewriteProjectId(".hidden")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// mapPermission / mapHarness +// --------------------------------------------------------------------------- + +describe("mapPermission", () => { + it("maps each legacy mode per #247 §3", () => { + expect(mapPermission("permissionless")).toEqual({ mode: "bypass-permissions", lossy: false }); + expect(mapPermission("skip")).toEqual({ mode: "bypass-permissions", lossy: false }); + expect(mapPermission("auto-edit")).toEqual({ mode: "accept-edits", lossy: false }); + expect(mapPermission("default")).toEqual({ mode: "default", lossy: false }); + }); + it("flags suggest and unknown values as lossy", () => { + expect(mapPermission("suggest")).toEqual({ mode: "default", lossy: true }); + expect(mapPermission("wat")).toEqual({ mode: "default", lossy: true }); + }); + it("returns null for unset", () => { + expect(mapPermission(undefined)).toBeNull(); + expect(mapPermission("")).toBeNull(); + }); +}); + +describe("mapHarness", () => { + it("passes through harnesses the rewrite knows", () => { + expect(mapHarness("claude-code")).toBe("claude-code"); + expect(mapHarness("codex")).toBe("codex"); + expect(mapHarness("opencode")).toBe("opencode"); + expect(mapHarness("cursor")).toBe("cursor"); + }); + it("returns null for unknown or unset", () => { + expect(mapHarness("frobnicator")).toBeNull(); + expect(mapHarness(undefined)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// buildRewriteConfig +// --------------------------------------------------------------------------- + +describe("buildRewriteConfig", () => { + it("omits a 'main' default branch and keeps a non-main one", () => { + const notes: string[] = []; + expect(buildRewriteConfig(project({ defaultBranch: "main" }), notes)).toBeNull(); + expect(buildRewriteConfig(project({ defaultBranch: "develop" }), [])).toEqual({ + defaultBranch: "develop", + }); + }); + + it("carries a non-empty sessionPrefix", () => { + expect(buildRewriteConfig(project({ sessionPrefix: "app" }), [])).toEqual({ + sessionPrefix: "app", + }); + }); + + it("carries env, symlinks, and postCreate verbatim", () => { + const config = buildRewriteConfig( + project({ + defaultBranch: "main", + env: { FOO: "bar" }, + symlinks: [".env"], + postCreate: ["pnpm i"], + }), + [], + ); + expect(config).toEqual({ + env: { FOO: "bar" }, + symlinks: [".env"], + postCreate: ["pnpm i"], + }); + }); + + it("remaps the agent permission and notes a lossy suggest", () => { + const notes: string[] = []; + const config = buildRewriteConfig( + project({ agentConfig: { model: "opus", permissions: "suggest" } }), + notes, + ); + expect(config).toEqual({ agentConfig: { model: "opus", permissions: "default" } }); + expect(notes.join()).toMatch(/lossily/); + }); + + it("maps worker/orchestrator harness and drops unknown ones with a note", () => { + const notes: string[] = []; + const config = buildRewriteConfig( + project({ + worker: { agent: "codex", agentConfig: { permissions: "auto-edit" } }, + orchestrator: { agent: "frobnicator" }, + }), + notes, + ); + expect(config).toEqual({ + worker: { agent: "codex", agentConfig: { permissions: "accept-edits" } }, + }); + expect(notes.join()).toMatch(/frobnicator.*dropped/); + }); + + it("notes project-level fields with no rewrite home", () => { + const notes: string[] = []; + buildRewriteConfig( + project({ + tracker: { provider: "github" } as ProjectConfig["tracker"], + agentRules: "be nice", + }), + notes, + ); + expect(notes.join()).toMatch(/no rewrite home dropped: tracker, rules/); + }); +}); + +// --------------------------------------------------------------------------- +// buildProjectPlan +// --------------------------------------------------------------------------- + +describe("buildProjectPlan", () => { + it("uses the legacy id and path, and only sends a name that differs from the id", () => { + const withName = buildProjectPlan("my-project", project({ name: "Pretty Name" })); + expect(withName.add).toEqual({ + path: "/repos/my-project", + projectId: "my-project", + name: "Pretty Name", + }); + + const nameEqualsId = buildProjectPlan("my-project", project({ name: "my-project" })); + expect(nameEqualsId.add).toEqual({ path: "/repos/my-project", projectId: "my-project" }); + }); + + it("captures the config blob and its notes on the plan", () => { + const plan = buildProjectPlan("p", project({ defaultBranch: "develop" })); + expect(plan.config).toEqual({ defaultBranch: "develop" }); + expect(plan.notes).toEqual([]); + }); +}); diff --git a/packages/cli/src/lib/migrate-orchestrator.ts b/packages/cli/src/lib/migrate-orchestrator.ts new file mode 100644 index 0000000000..7acfff53f5 --- /dev/null +++ b/packages/cli/src/lib/migrate-orchestrator.ts @@ -0,0 +1,288 @@ +/** + * `ao migrate` — orchestrator session mapping (#2129, §8). + * + * Reads a project's single non-terminated orchestrator metadata record and maps + * it to one rewrite `sessions` row. Workers are NOT migrated (they respawn fresh + * in the rewrite). The row id is the verbatim `{prefix}-orchestrator` with + * `num = 0` — the rewrite finds its orchestrator by `kind`, never by recomputing + * the id, and `NextSessionNum` (MAX(num)+1) leaves the first rewrite-spawned + * worker at num=1 with no UNIQUE(project_id,num) collision (§8.2). + * + * The pure mapper (`mapOrchestratorRow`) is fully unit-tested; the reader + * (`readOrchestratorMapping`) globs the legacy sessions dir and feeds the mapper. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; +import type { ProjectConfig } from "@aoagents/ao-core"; +import { getOrchestratorPath, getProjectSessionsDir } from "@aoagents/ao-core"; +import type { SessionRow } from "./migrate-db.js"; + +/** Harnesses whose orchestrator we migrate. aider (and anything else) is skipped. */ +const MIGRATABLE_HARNESSES = new Set(["claude-code", "codex", "opencode"]); + +/** Legacy canonical states that mean "do not migrate" (§8.1 step 5). */ +const TERMINAL_STATES = new Set(["done", "terminated"]); + +/** Inputs needed to relocate a claude-code transcript (§9). */ +export interface TranscriptRelocation { + /** Legacy worktree path on disk (realpath-resolved by the relocator). */ + worktree: string; + /** Claude session UUID = the transcript filename stem. */ + uuid: string; +} + +export type OrchestratorMappingStatus = "mapped" | "skipped" | "absent"; + +export interface OrchestratorMapping { + projectId: string; + prefix: string; + status: OrchestratorMappingStatus; + /** Present when status === "mapped". */ + row?: SessionRow; + /** Present only for a mapped claude-code orchestrator that carries a transcript uuid. */ + transcript?: TranscriptRelocation; + /** Skip reason or a lossy note, surfaced in the summary. */ + note?: string; +} + +/** Coerce a value that may be an object OR a JSON-encoded string into an object. */ +function asObject(value: unknown): Record | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + if (typeof value === "string" && value.trim()) { + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + /* not JSON */ + } + } + return undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +interface LegacyLifecycle { + session?: Record; + runtime?: Record; +} + +/** + * Extract the lifecycle, double-decoding stringified nested fields. Prefers the + * V2 `lifecycle` key, falling back to `statePayload` when `stateVersion === "2"` + * (mirrors parseLifecycleField in packages/core/src/metadata.ts). + */ +function extractLifecycle(raw: Record): LegacyLifecycle | undefined { + let lifecycle = asObject(raw["lifecycle"]); + if (!lifecycle && raw["stateVersion"] === "2") { + lifecycle = asObject(raw["statePayload"]); + } + if (!lifecycle) return undefined; + return { + session: asObject(lifecycle["session"]), + runtime: asObject(lifecycle["runtime"]), + }; +} + +/** Legacy 8-state enum → rewrite 5-state activity_state (§8.2). */ +function mapActivityState(state: string | undefined): SessionRow["activity_state"] { + switch (state) { + case "working": + return "active"; + case "needs_input": + return "waiting_input"; + // not_started / idle / detecting / stuck (and any unknown) -> idle + default: + return "idle"; + } +} + +/** Pick the rewrite `agent_session_id` for resume, by harness (§8.2). */ +function resumeId(harness: string, raw: Record): string { + switch (harness) { + case "claude-code": + return asString(raw["claudeSessionUuid"]) ?? ""; + case "codex": + return asString(raw["codexThreadId"]) ?? ""; + case "opencode": + return asString(raw["opencodeSessionId"]) ?? ""; + default: + return ""; + } +} + +/** + * Map a parsed legacy orchestrator record to a rewrite session row. Pure. + * + * `fileMtime` is the last-resort ISO timestamp for `created_at` when the record + * carries neither `createdAt` nor `lifecycle.session.startedAt`. + */ +export function mapOrchestratorRow( + raw: Record, + projectId: string, + prefix: string, + fileMtime: string, +): OrchestratorMapping { + const base: Pick = { projectId, prefix }; + + const lifecycle = extractLifecycle(raw); + const session = lifecycle?.session; + const state = asString(session?.["state"]); + const terminatedAt = session?.["terminatedAt"]; + + // §8.1 step 5: migrate ONLY non-terminal, non-terminated orchestrators. + if ((state && TERMINAL_STATES.has(state)) || (terminatedAt !== null && terminatedAt !== undefined)) { + return { ...base, status: "skipped", note: `orchestrator is terminal (state=${state ?? "?"})` }; + } + + // §8.1 step 6: harness filter. + const agent = asString(raw["agent"]); + if (!agent || !MIGRATABLE_HARNESSES.has(agent)) { + return { + ...base, + status: "skipped", + note: `harness "${agent ?? "?"}" is not migratable (only claude-code, codex, opencode)`, + }; + } + + const startedAt = asString(session?.["startedAt"]); + const lastTransitionAt = asString(session?.["lastTransitionAt"]); + const lastObservedAt = asString(lifecycle?.runtime?.["lastObservedAt"]); + + const createdAt = asString(raw["createdAt"]) ?? startedAt ?? fileMtime; + const activityLastAt = lastTransitionAt ?? lastObservedAt ?? createdAt; + const updatedAt = lastTransitionAt ?? createdAt; + + const id = `${prefix}-orchestrator`; + const worktree = asString(raw["worktree"]) ?? ""; + const agentSessionId = resumeId(agent, raw); + + const row: SessionRow = { + id, + project_id: projectId, + num: 0, + kind: "orchestrator", + harness: agent, + activity_state: mapActivityState(state), + activity_last_at: activityLastAt, + is_terminated: 0, + branch: asString(raw["branch"]) ?? "", + workspace_path: worktree, + runtime_handle_id: "", + agent_session_id: agentSessionId, + prompt: asString(raw["userPrompt"]) ?? "", + display_name: asString(raw["displayName"]) ?? "", + first_signal_at: activityLastAt, + created_at: createdAt, + updated_at: updatedAt, + }; + + // §9: claude-code orchestrators carry a transcript to relocate (needs both a + // uuid and a worktree to compute source + destination slugs). + let transcript: TranscriptRelocation | undefined; + if (agent === "claude-code") { + const uuid = asString(raw["claudeSessionUuid"]); + if (uuid && worktree) transcript = { worktree, uuid }; + } + + return { ...base, status: "mapped", row, transcript }; +} + +/** Resolve the migration prefix: configured sessionPrefix, else first 12 chars of id (§8.1 step 1). */ +export function resolveOrchestratorPrefix(projectId: string, pc: ProjectConfig): string { + const configured = typeof pc.sessionPrefix === "string" ? pc.sessionPrefix.trim() : ""; + return configured.length > 0 ? configured : projectId.slice(0, 12); +} + +/** Parse JSON; returns null on invalid content. */ +function parseJsonRecord(content: string): Record | null { + try { + const parsed: unknown = JSON.parse(content); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + /* corrupt */ + } + return null; +} + +/** + * Locate the orchestrator metadata file for a project: the sessions-dir record + * whose raw `role === "orchestrator"`, else the one named `{prefix}-orchestrator`, + * else the legacy `projects/{id}/orchestrator.json`. Skips 0-byte and + * `*.corrupt-*` files (§8.1 steps 2-3). + */ +function findOrchestratorFile(projectId: string, prefix: string): string | null { + const sessionsDir = getProjectSessionsDir(projectId); + let candidates: string[] = []; + try { + candidates = readdirSync(sessionsDir) + .filter((f) => f.endsWith(".json") && !f.includes(".corrupt-")) + .map((f) => join(sessionsDir, f)); + } catch { + /* sessions dir absent */ + } + + let byName: string | null = null; + for (const file of candidates) { + let content: string; + try { + content = readFileSync(file, "utf-8").trim(); + } catch { + continue; + } + if (!content) continue; // 0-byte / reserved id + const raw = parseJsonRecord(content); + if (!raw) continue; + if (raw["role"] === "orchestrator") return file; + if (basename(file, ".json") === `${prefix}-orchestrator`) byName = file; + } + if (byName) return byName; + + // Defensive: the pre-V2 standalone orchestrator file. + const legacy = getOrchestratorPath(projectId); + if (existsSync(legacy)) { + try { + if (readFileSync(legacy, "utf-8").trim()) return legacy; + } catch { + /* unreadable */ + } + } + return null; +} + +/** + * Read + map a project's orchestrator. Returns `absent` when there is no + * orchestrator file to migrate, `skipped` for terminal/non-migratable ones, and + * `mapped` with the row (and transcript for claude-code) otherwise. + */ +export function readOrchestratorMapping(projectId: string, pc: ProjectConfig): OrchestratorMapping { + const prefix = resolveOrchestratorPrefix(projectId, pc); + const file = findOrchestratorFile(projectId, prefix); + if (!file) return { projectId, prefix, status: "absent" }; + + let content: string; + try { + content = readFileSync(file, "utf-8"); + } catch { + return { projectId, prefix, status: "absent" }; + } + const raw = parseJsonRecord(content.trim()); + if (!raw) return { projectId, prefix, status: "absent" }; + + let mtime: string; + try { + mtime = statSync(file).mtime.toISOString(); + } catch { + mtime = new Date(0).toISOString(); + } + + return mapOrchestratorRow(raw, projectId, prefix, mtime); +} diff --git a/packages/cli/src/lib/migrate.ts b/packages/cli/src/lib/migrate.ts new file mode 100644 index 0000000000..dfb9fd71f1 --- /dev/null +++ b/packages/cli/src/lib/migrate.ts @@ -0,0 +1,246 @@ +import type { ProjectConfig } from "@aoagents/ao-core"; + +/** + * `ao migrate` — pure project mappers (#2129). + * + * Maps the legacy flat-file project registry + per-project settings into the + * rewrite (Go/Electron) daemon's SQLite `projects` table. Unlike the closed + * draft #2127 (which POSTed to a loopback REST API), migrate now writes the DB + * directly while the daemon is stopped (see migrate-db.ts), so this module is + * the pure field-mapping half only — no network, no I/O. + * + * Cross-repo contract verified against aoagents/ReverbCode @ 43ae7eb: + * - domain/{projectconfig,agentconfig,harness}.go (config JSON shape + enums) + * - service/project/service.go (validateProjectID) + * Mapping spec: aoagents/ReverbCode#247 §1 + §3. + */ + +// --------------------------------------------------------------------------- +// Rewrite vocabulary (domain enums, mirrored as literals so core stays free of +// any rewrite dependency) +// --------------------------------------------------------------------------- + +/** `domain.PermissionMode` (agentconfig.go). `""` (unset) is also valid. */ +export type RewritePermissionMode = "default" | "accept-edits" | "auto" | "bypass-permissions"; + +/** `domain.AgentHarness` (harness.go) — the set the rewrite `RoleOverride.agent` accepts. */ +const KNOWN_REWRITE_HARNESSES = new Set([ + "claude-code", + "codex", + "aider", + "opencode", + "grok", + "droid", + "amp", + "agy", + "crush", + "cursor", + "qwen", + "copilot", + "goose", + "auggie", + "continue", + "devin", + "cline", + "kimi", + "kiro", + "kilocode", + "vibe", + "pi", + "autohand", +]); + +// --------------------------------------------------------------------------- +// Field mapping (pure — fully unit tested) +// --------------------------------------------------------------------------- + +/** Rewrite project-id gate (`validateProjectID`, service.go). */ +const REWRITE_PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export function isValidRewriteProjectId(id: string): boolean { + return ( + id.length > 0 && + id !== "." && + !id.includes("..") && + !/[/\\]/.test(id) && + REWRITE_PROJECT_ID.test(id) + ); +} + +/** + * Legacy `AgentPermissionMode` → rewrite `PermissionMode` (#247 §3 table). + * `lossy` flags a remap that drops a distinction the rewrite cannot represent. + * + * Note: legacy `skip` is already collapsed to `permissionless` by the config + * schema, but a hand-edited config could still carry the raw value, so we map + * it explicitly. + */ +export function mapPermission(legacy: string | undefined): { + mode: RewritePermissionMode; + lossy: boolean; +} | null { + switch (legacy) { + case undefined: + case "": + return null; + case "permissionless": + case "skip": + return { mode: "bypass-permissions", lossy: false }; + case "auto-edit": + return { mode: "accept-edits", lossy: false }; + case "default": + return { mode: "default", lossy: false }; + case "suggest": + // The rewrite has no suggest/plan mode (#247 G8). + return { mode: "default", lossy: true }; + default: + return { mode: "default", lossy: true }; + } +} + +/** Legacy agent plugin id → rewrite harness, or null if the rewrite has no such harness. */ +export function mapHarness(agent: string | undefined): string | null { + if (!agent) return null; + return KNOWN_REWRITE_HARNESSES.has(agent) ? agent : null; +} + +/** Rewrite `domain.AgentConfig` JSON shape. */ +interface RewriteAgentConfig { + model?: string; + permissions?: RewritePermissionMode; +} + +/** Rewrite `domain.RoleOverride` JSON shape (note: harness key is `agent`). */ +interface RewriteRoleOverride { + agent?: string; + agentConfig?: RewriteAgentConfig; +} + +/** Rewrite `domain.ProjectConfig` JSON shape (the `config` column). */ +export interface RewriteProjectConfig { + defaultBranch?: string; + sessionPrefix?: string; + env?: Record; + symlinks?: string[]; + postCreate?: string[]; + agentConfig?: RewriteAgentConfig; + worker?: RewriteRoleOverride; + orchestrator?: RewriteRoleOverride; +} + +function buildAgentConfig( + source: { model?: string; permissions?: string } | undefined, + notes: string[], + label: string, +): RewriteAgentConfig | undefined { + if (!source) return undefined; + const out: RewriteAgentConfig = {}; + if (typeof source.model === "string" && source.model.length > 0) out.model = source.model; + const perm = mapPermission(source.permissions); + if (perm) { + out.permissions = perm.mode; + if (perm.lossy) { + notes.push(`${label} permission "${source.permissions}" mapped lossily to "${perm.mode}"`); + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function buildRoleOverride( + role: { agent?: string; agentConfig?: { model?: string; permissions?: string } } | undefined, + notes: string[], + label: string, +): RewriteRoleOverride | undefined { + if (!role) return undefined; + const out: RewriteRoleOverride = {}; + if (role.agent) { + const harness = mapHarness(role.agent); + if (harness) { + out.agent = harness; + } else { + notes.push(`${label} agent "${role.agent}" has no rewrite harness — dropped`); + } + } + const agentConfig = buildAgentConfig(role.agentConfig, notes, `${label} agent`); + if (agentConfig) out.agentConfig = agentConfig; + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Build the rewrite `config` blob from a legacy effective ProjectConfig (#247 §3). + * Returns null when nothing worth persisting remains (the rewrite stores NULL + * for a zero config). `notes` accumulates lossy/dropped-field warnings. + */ +export function buildRewriteConfig(pc: ProjectConfig, notes: string[]): RewriteProjectConfig | null { + const config: RewriteProjectConfig = {}; + + // defaultBranch: omit "main" so the common case keeps config NULL (#247 §3). + if (typeof pc.defaultBranch === "string" && pc.defaultBranch && pc.defaultBranch !== "main") { + config.defaultBranch = pc.defaultBranch; + } + if (typeof pc.sessionPrefix === "string" && pc.sessionPrefix.length > 0) { + config.sessionPrefix = pc.sessionPrefix; + } + if (pc.env && Object.keys(pc.env).length > 0) { + config.env = { ...pc.env }; + } + if (Array.isArray(pc.symlinks) && pc.symlinks.length > 0) { + config.symlinks = [...pc.symlinks]; + } + if (Array.isArray(pc.postCreate) && pc.postCreate.length > 0) { + config.postCreate = [...pc.postCreate]; + } + + const agentConfig = buildAgentConfig(pc.agentConfig, notes, "agentConfig"); + if (agentConfig) config.agentConfig = agentConfig; + + const worker = buildRoleOverride(pc.worker, notes, "worker"); + if (worker) config.worker = worker; + + const orchestrator = buildRoleOverride(pc.orchestrator, notes, "orchestrator"); + if (orchestrator) config.orchestrator = orchestrator; + + // Surface project-level fields the rewrite has no home for (#247 §4). + const dropped: string[] = []; + if (pc.tracker) dropped.push("tracker"); + if (pc.scm) dropped.push("scm"); + if (pc.agentRules || pc.agentRulesFile || pc.orchestratorRules) dropped.push("rules"); + if (pc.runtime) dropped.push("runtime"); + if (pc.workspace) dropped.push("workspace"); + if (pc.reactions && Object.keys(pc.reactions).length > 0) dropped.push("reactions"); + if (dropped.length > 0) { + notes.push(`project-level fields with no rewrite home dropped: ${dropped.join(", ")}`); + } + + return Object.keys(config).length > 0 ? config : null; +} + +// --------------------------------------------------------------------------- +// Per-project plan +// --------------------------------------------------------------------------- + +/** The legacy identity we carry into the `projects` row. */ +export interface ProjectAddInput { + path: string; + projectId?: string; + name?: string; +} + +export interface ProjectPlan { + id: string; + add: ProjectAddInput; + config: RewriteProjectConfig | null; + notes: string[]; +} + +/** Build the full create+config plan for one legacy project. Pure. */ +export function buildProjectPlan(id: string, pc: ProjectConfig): ProjectPlan { + const notes: string[] = []; + const add: ProjectAddInput = { path: pc.path, projectId: id }; + // displayName falls back to id on the rewrite read side; only send a real name. + if (typeof pc.name === "string" && pc.name.length > 0 && pc.name !== id) { + add.name = pc.name; + } + const config = buildRewriteConfig(pc, notes); + return { id, add, config, notes }; +} From b41ce2502563e201b167f5f8488bef32d3d44559 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 01:02:33 +0530 Subject: [PATCH 4/5] feat(cli): claude transcript relocation for ao migrate migrate-claude.ts: compute source (realpath-resolved worktree) + destination (literal orchestrator-worktree template) Claude project slugs via the claude-code plugin's toClaudeProjectPath, and copy the transcript jsonl. Idempotent (dest exists -> already-present; source missing -> skipped). Refs #2129. Co-Authored-By: Claude Opus 4.8 --- .../cli/__tests__/lib/migrate-claude.test.ts | 88 +++++++++++++++++++ packages/cli/src/lib/migrate-claude.ts | 85 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 packages/cli/__tests__/lib/migrate-claude.test.ts create mode 100644 packages/cli/src/lib/migrate-claude.ts diff --git a/packages/cli/__tests__/lib/migrate-claude.test.ts b/packages/cli/__tests__/lib/migrate-claude.test.ts new file mode 100644 index 0000000000..893bef1efa --- /dev/null +++ b/packages/cli/__tests__/lib/migrate-claude.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { toClaudeProjectPath } from "@aoagents/ao-plugin-agent-claude-code"; +import { planTranscriptCopy, relocateTranscript } from "../../src/lib/migrate-claude.js"; + +const UUID = "abcdabcd-1111-2222-3333-444455556666"; + +describe("planTranscriptCopy", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ao-migrate-claude-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("computes the source slug from the worktree and the dest from the orchestrator template", async () => { + const dataDir = join(dir, "data"); + const worktree = join(dir, "legacy-worktree"); + mkdirSync(worktree, { recursive: true }); // exists -> realpath resolves it + const claudeProjectsDir = join(dir, "claude-projects"); + + const plan = await planTranscriptCopy({ + dataDir, + projectId: "app", + prefix: "app", + worktree, + uuid: UUID, + claudeProjectsDir, + }); + + // Destination uses the LITERAL orchestrator-worktree template (no realpath). + const destTemplate = join(dataDir, "worktrees", "app", "orchestrator", "app-orchestrator"); + expect(plan.destPath).toBe( + join(claudeProjectsDir, toClaudeProjectPath(destTemplate), `${UUID}.jsonl`), + ); + expect(plan.sourcePath.endsWith(`${UUID}.jsonl`)).toBe(true); + expect(plan.sourcePath.startsWith(claudeProjectsDir)).toBe(true); + }); +}); + +describe("relocateTranscript", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ao-migrate-claude-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function plan(): { sourcePath: string; destPath: string; projectId: string; uuid: string } { + return { + projectId: "app", + uuid: UUID, + sourcePath: join(dir, "src", `${UUID}.jsonl`), + destPath: join(dir, "dest", "nested", `${UUID}.jsonl`), + }; + } + + it("copies the transcript, creating the destination dir", () => { + const p = plan(); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(p.sourcePath, '{"type":"summary"}\n'); + + expect(relocateTranscript(p)).toBe("copied"); + expect(existsSync(p.destPath)).toBe(true); + expect(readFileSync(p.destPath, "utf-8")).toBe('{"type":"summary"}\n'); + }); + + it("is a no-op when the destination already exists", () => { + const p = plan(); + mkdirSync(join(dir, "src"), { recursive: true }); + mkdirSync(join(dir, "dest", "nested"), { recursive: true }); + writeFileSync(p.sourcePath, "new\n"); + writeFileSync(p.destPath, "existing\n"); + + expect(relocateTranscript(p)).toBe("already-present"); + expect(readFileSync(p.destPath, "utf-8")).toBe("existing\n"); // not clobbered + }); + + it("skips silently when the source is missing", () => { + const p = plan(); + expect(relocateTranscript(p)).toBe("source-missing"); + expect(existsSync(p.destPath)).toBe(false); + }); +}); diff --git a/packages/cli/src/lib/migrate-claude.ts b/packages/cli/src/lib/migrate-claude.ts new file mode 100644 index 0000000000..a0d7d341b1 --- /dev/null +++ b/packages/cli/src/lib/migrate-claude.ts @@ -0,0 +1,85 @@ +/** + * `ao migrate` — Claude transcript relocation (#2129, §9). + * + * Only claude-code orchestrators need this: codex/opencode resume by the global + * id already carried in `agent_session_id`. We copy the legacy transcript to the + * slug the rewrite/Claude will compute for the orchestrator worktree, so the + * resumed orchestrator keeps its context. + * + * Slug helpers are imported from the claude-code plugin (the CLI already depends + * on it) so the `\\`->`/` + `[^a-zA-Z0-9-]` normalization is never re-copied. + */ + +import { homedir } from "node:os"; +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + resolveWorkspaceForClaude, + toClaudeProjectPath, +} from "@aoagents/ao-plugin-agent-claude-code"; + +export interface TranscriptCopyPlan { + projectId: string; + uuid: string; + /** ~/.claude/projects//.jsonl */ + sourcePath: string; + /** ~/.claude/projects//.jsonl */ + destPath: string; +} + +export interface TranscriptPlanArgs { + dataDir: string; + projectId: string; + prefix: string; + /** Legacy worktree path on disk (exists; realpath-resolved for the source slug). */ + worktree: string; + uuid: string; + /** Override the Claude projects dir (tests). Defaults to ~/.claude/projects. */ + claudeProjectsDir?: string; +} + +/** + * Compute the source + destination transcript paths. + * + * Source slug realpath-resolves the legacy worktree (it exists on disk). + * Destination slug uses the LITERAL orchestrator-worktree template + * `{dataDir}/worktrees/{projectId}/orchestrator/{prefix}-orchestrator` with NO + * realpath — that dir does not exist yet (the rewrite creates it on first + * resume) and ~/.ao/data is confirmed not a symlink, so the literal-path slug + * matches what the rewrite/Claude will compute. + */ +export async function planTranscriptCopy(args: TranscriptPlanArgs): Promise { + const claudeProjectsDir = args.claudeProjectsDir ?? join(homedir(), ".claude", "projects"); + + const sourceSlug = toClaudeProjectPath(await resolveWorkspaceForClaude(args.worktree)); + const destTemplate = join( + args.dataDir, + "worktrees", + args.projectId, + "orchestrator", + `${args.prefix}-orchestrator`, + ); + const destSlug = toClaudeProjectPath(destTemplate); + + return { + projectId: args.projectId, + uuid: args.uuid, + sourcePath: join(claudeProjectsDir, sourceSlug, `${args.uuid}.jsonl`), + destPath: join(claudeProjectsDir, destSlug, `${args.uuid}.jsonl`), + }; +} + +export type TranscriptCopyOutcome = "copied" | "already-present" | "source-missing"; + +/** + * Execute a transcript copy. Idempotent: an existing destination is left as-is + * (already-present); a missing source is skipped silently (source-missing). + * Only "copied" increments `relocatedTranscripts` in the summary. + */ +export function relocateTranscript(plan: TranscriptCopyPlan): TranscriptCopyOutcome { + if (existsSync(plan.destPath)) return "already-present"; + if (!existsSync(plan.sourcePath)) return "source-missing"; + mkdirSync(dirname(plan.destPath), { recursive: true }); + copyFileSync(plan.sourcePath, plan.destPath); + return "copied"; +} From 46eaf41bdfd96ae2d8c10689d8c4bb62d63a1bb5 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 01:13:43 +0530 Subject: [PATCH 5/5] feat(cli): ao migrate command with --dry-run, --json, exit-code contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the offline migrator end-to-end: resolve dataDir/dbPath (§4), build the project rows (git origin, registered_at, display_name, config blob) + read each project's orchestrator + plan transcript copies, then either preview (--dry-run, no writes) or run preconditions -> relocate transcripts -> FK-ordered inserts. Emits the locked summary (human or --json) and the exit-code contract (non-zero on refusal or any failed row). Registers next to migrate-storage in program.ts, records start/finish activity events, and adds the ao-cli minor changeset. Refs #2129. Co-Authored-By: Claude Opus 4.8 --- .changeset/ao-migrate.md | 12 + .../cli/__tests__/commands/migrate.test.ts | 143 ++++++ packages/cli/__tests__/lib/migrate.test.ts | 45 ++ packages/cli/src/commands/migrate.ts | 411 ++++++++++++++++++ packages/cli/src/lib/migrate-orchestrator.ts | 3 +- packages/cli/src/lib/migrate.ts | 52 +++ packages/cli/src/program.ts | 2 + 7 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 .changeset/ao-migrate.md create mode 100644 packages/cli/__tests__/commands/migrate.test.ts create mode 100644 packages/cli/src/commands/migrate.ts diff --git a/.changeset/ao-migrate.md b/.changeset/ao-migrate.md new file mode 100644 index 0000000000..0efe56f8c8 --- /dev/null +++ b/.changeset/ao-migrate.md @@ -0,0 +1,12 @@ +--- +"@aoagents/ao-cli": minor +--- + +Add `ao migrate`: an offline command (run with the rewrite daemon stopped) that +ports the legacy flat-file project registry and each project's single +non-terminated orchestrator session into the rewrite's SQLite database, creating +the DB from vendored goose migrations (pinned to ReverbCode @ 43ae7eb) when +absent. Relocates claude-code orchestrator transcripts so they resume with +context. Idempotent, with `--dry-run` and `--json` for the `ao update` cutover +contract (locked exit codes + summary). Workers are not migrated; they respawn +fresh in the rewrite. Refs #2129. diff --git a/packages/cli/__tests__/commands/migrate.test.ts b/packages/cli/__tests__/commands/migrate.test.ts new file mode 100644 index 0000000000..f0787c9960 --- /dev/null +++ b/packages/cli/__tests__/commands/migrate.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { LoadedConfig, ProjectConfig } from "@aoagents/ao-core"; +import { + runMigrate, + resolveDataDir, + resolveDbPath, + type ProjectRowDeps, +} from "../../src/commands/migrate.js"; +import { loadBetterSqlite3 } from "../../src/lib/migrate-db.js"; + +let sqlite3Available = true; +try { + loadBetterSqlite3(); +} catch { + sqlite3Available = false; +} + +function project(overrides: Partial = {}): ProjectConfig { + return { name: "Project", path: "/repos/p", defaultBranch: "main", ...overrides } as ProjectConfig; +} + +function loaded( + projects: Record, + degraded: LoadedConfig["degradedProjects"] = {}, +): LoadedConfig { + return { projects, degradedProjects: degraded } as unknown as LoadedConfig; +} + +// Inject all environment lookups so the test needs neither git nor a registry. +const deps: Partial = { + repoOriginUrl: () => "https://example.com/repo.git", + registeredAt: () => "2026-01-02T03:04:05.000Z", + configFileMtime: () => null, +}; + +const NOW = "2026-06-18T00:00:00.000Z"; + +describe("resolveDataDir", () => { + const saved = process.env.AO_DATA_DIR; + afterEach(() => { + if (saved === undefined) delete process.env.AO_DATA_DIR; + else process.env.AO_DATA_DIR = saved; + }); + + it("honors a non-empty AO_DATA_DIR", () => { + process.env.AO_DATA_DIR = "/custom/dir"; + expect(resolveDataDir()).toBe("/custom/dir"); + expect(resolveDbPath(resolveDataDir())).toBe("/custom/dir/ao.db"); + }); + it("falls back to ~/.ao/data when unset", () => { + delete process.env.AO_DATA_DIR; + expect(resolveDataDir().endsWith(join(".ao", "data"))).toBe(true); + }); +}); + +describe.skipIf(!sqlite3Available)("runMigrate", () => { + let dataDir: string; + beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), "ao-migrate-cmd-")); + }); + afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); + }); + + // Unique ids so the orchestrator reader (real ~/.agent-orchestrator) finds none. + const ids = () => ({ + [`ao-migrate-test-${process.pid}-a`]: project({ path: "/repos/a", name: "A" }), + [`ao-migrate-test-${process.pid}-b`]: project({ path: "/repos/b", name: "B" }), + }); + + it("dry run computes the plan and writes nothing", async () => { + const result = await runMigrate({ + dryRun: true, + config: loaded(ids(), { + broken: { projectId: "broken", path: "/x", resolveError: "gone" }, + }), + dataDir, + deps, + now: NOW, + }); + + expect(result.dryRun).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.summary.dbCreated).toBe(true); + expect(result.summary.schemaVersion).toBe(12); + expect(result.summary.projects).toEqual({ created: 2, skipped: 1, failed: 0 }); + expect(result.summary.orchestrators).toMatchObject({ created: 0, skipped: 0, failed: 0 }); + // No DB written. + expect(existsSync(resolveDbPath(dataDir))).toBe(false); + }); + + it("creates the DB and inserts projects on a real run", async () => { + const result = await runMigrate({ config: loaded(ids()), dataDir, deps, now: NOW }); + + expect(result.exitCode).toBe(0); + expect(result.summary.dbCreated).toBe(true); + expect(result.summary.schemaVersion).toBe(12); + expect(result.summary.projects).toEqual({ created: 2, skipped: 0, failed: 0 }); + expect(existsSync(resolveDbPath(dataDir))).toBe(true); + }); + + it("counts degraded and invalid-id projects as skipped, never inserted", async () => { + const result = await runMigrate({ + config: loaded( + { ...ids(), "bad/id": project({ path: "/repos/bad" }) }, + { broken: { projectId: "broken", path: "/x", resolveError: "gone" } }, + ), + dataDir, + deps, + now: NOW, + }); + // 2 valid created; degraded + invalid-id => 2 skipped. + expect(result.summary.projects).toEqual({ created: 2, skipped: 2, failed: 0 }); + expect(result.notes.some((n) => n.id === "broken")).toBe(true); + expect(result.notes.some((n) => n.id === "bad/id")).toBe(true); + }); + + it("is idempotent: a re-run inserts nothing and counts ON CONFLICT no-ops as skipped", async () => { + const config = loaded(ids()); + await runMigrate({ config, dataDir, deps, now: NOW }); + const second = await runMigrate({ config, dataDir, deps, now: NOW }); + + expect(second.summary.dbCreated).toBe(false); + expect(second.summary.projects).toEqual({ created: 0, skipped: 2, failed: 0 }); + expect(second.exitCode).toBe(0); + }); + + it("refuses (exit 1) when the DB schema is older than vendored", async () => { + // First create the DB, then strip versions to simulate an older schema. + await runMigrate({ config: loaded(ids()), dataDir, deps, now: NOW }); + const Database = loadBetterSqlite3(); + const db = new Database(resolveDbPath(dataDir)); + db.prepare("DELETE FROM goose_db_version WHERE version_id >= 12").run(); + db.close(); + + const result = await runMigrate({ config: loaded(ids()), dataDir, deps, now: NOW }); + expect(result.exitCode).toBe(1); + expect(result.refusal?.code).toBe("SCHEMA_TOO_OLD"); + }); +}); diff --git a/packages/cli/__tests__/lib/migrate.test.ts b/packages/cli/__tests__/lib/migrate.test.ts index dc5496c8f2..344a1ed613 100644 --- a/packages/cli/__tests__/lib/migrate.test.ts +++ b/packages/cli/__tests__/lib/migrate.test.ts @@ -2,10 +2,12 @@ import { describe, it, expect } from "vitest"; import type { ProjectConfig } from "@aoagents/ao-core"; import { buildProjectPlan, + buildProjectRow, buildRewriteConfig, isValidRewriteProjectId, mapHarness, mapPermission, + type ProjectRowDeps, } from "../../src/lib/migrate.js"; // --------------------------------------------------------------------------- @@ -174,3 +176,46 @@ describe("buildProjectPlan", () => { expect(plan.notes).toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// buildProjectRow (server-side fields — §7) +// --------------------------------------------------------------------------- + +describe("buildProjectRow", () => { + const deps: ProjectRowDeps = { + repoOriginUrl: () => "https://example.com/repo.git", + registeredAt: () => "2026-01-02T03:04:05.000Z", + configFileMtime: () => "2026-02-02T00:00:00.000Z", + now: "2026-06-18T00:00:00.000Z", + }; + + it("computes the server-side fields and serializes a non-null config", () => { + const { row } = buildProjectRow("app", project({ name: "App", defaultBranch: "develop" }), deps); + expect(row).toEqual({ + id: "app", + path: "/repos/my-project", + repo_origin_url: "https://example.com/repo.git", + display_name: "App", + registered_at: "2026-01-02T03:04:05.000Z", + kind: "single_repo", + config: JSON.stringify({ defaultBranch: "develop" }), + }); + }); + + it("stores NULL config and empty display_name when there is nothing to persist", () => { + const { row } = buildProjectRow("app", project({ name: "app", defaultBranch: "main" }), deps); + expect(row.config).toBeNull(); + expect(row.display_name).toBe(""); // name === id falls back to id on read + }); + + it("falls back registered_at: registry -> config mtime -> now", () => { + const noRegistry: ProjectRowDeps = { ...deps, registeredAt: () => null }; + expect(buildProjectRow("p", project(), noRegistry).row.registered_at).toBe( + "2026-02-02T00:00:00.000Z", + ); + const noneAtAll: ProjectRowDeps = { ...deps, registeredAt: () => null, configFileMtime: () => null }; + expect(buildProjectRow("p", project(), noneAtAll).row.registered_at).toBe( + "2026-06-18T00:00:00.000Z", + ); + }); +}); diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts new file mode 100644 index 0000000000..9576500514 --- /dev/null +++ b/packages/cli/src/commands/migrate.ts @@ -0,0 +1,411 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Command } from "commander"; +import chalk from "chalk"; +import { + getGlobalConfigPath, + loadConfig, + loadRegistered, + recordActivityEvent, + type LoadedConfig, + type ProjectConfig, +} from "@aoagents/ao-core"; +import { + buildProjectRow, + isValidRewriteProjectId, + type ProjectRowDeps, +} from "../lib/migrate.js"; +import { + insertMigration, + openMigrationDb, + MigrateRefusal, + VENDORED_SCHEMA_VERSION, + type ProjectRow, + type SessionRow, +} from "../lib/migrate-db.js"; +import { readOrchestratorMapping } from "../lib/migrate-orchestrator.js"; +import { + planTranscriptCopy, + relocateTranscript, + type TranscriptCopyPlan, +} from "../lib/migrate-claude.js"; + +// --------------------------------------------------------------------------- +// Data dir resolution (§4 — must match the rewrite's resolveDataDir exactly) +// --------------------------------------------------------------------------- + +export function resolveDataDir(): string { + const fromEnv = process.env.AO_DATA_DIR; + if (fromEnv && fromEnv.trim() !== "") return fromEnv; + return join(homedir(), ".ao", "data"); +} + +export function resolveDbPath(dataDir: string): string { + return join(dataDir, "ao.db"); +} + +// --------------------------------------------------------------------------- +// The output contract (§3, LOCKED) +// --------------------------------------------------------------------------- + +export interface MigrateSummary { + dbCreated: boolean; + schemaVersion: number; + projects: { created: number; skipped: number; failed: number }; + orchestrators: { created: number; skipped: number; failed: number; relocatedTranscripts: number }; +} + +/** A note surfaced in the human summary (never in --json). */ +interface PlanNote { + scope: "project" | "orchestrator"; + id: string; + note: string; +} + +interface MigrationPlan { + projectRows: ProjectRow[]; + orchestratorRows: SessionRow[]; + transcripts: TranscriptCopyPlan[]; + /** Items deliberately not inserted (degraded / invalid id / terminal / non-migratable). */ + projectSkips: number; + orchestratorSkips: number; + notes: PlanNote[]; +} + +// --------------------------------------------------------------------------- +// Environment deps (injectable for tests) +// --------------------------------------------------------------------------- + +function defaultRepoOriginUrl(path: string): string { + try { + return execFileSync("git", ["-C", path, "remote", "get-url", "origin"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +function defaultConfigFileMtime(path: string): string | null { + for (const name of ["agent-orchestrator.yaml", "agent-orchestrator.yml"]) { + try { + return statSync(join(path, name)).mtime.toISOString(); + } catch { + /* try next */ + } + } + return null; +} + +function makeRegisteredAtLookup(): (id: string, path: string) => string | null { + let entries: ReturnType["projects"] = []; + try { + entries = loadRegistered().projects; + } catch { + entries = []; + } + return (id, path) => { + const match = entries.find( + (e) => e.configProjectKey === id || join(e.path) === join(path), + ); + return match?.addedAt ?? null; + }; +} + +function makeProjectRowDeps(now: string, overrides?: Partial): ProjectRowDeps { + return { + repoOriginUrl: overrides?.repoOriginUrl ?? defaultRepoOriginUrl, + registeredAt: overrides?.registeredAt ?? makeRegisteredAtLookup(), + configFileMtime: overrides?.configFileMtime ?? defaultConfigFileMtime, + now, + }; +} + +// --------------------------------------------------------------------------- +// Plan building +// --------------------------------------------------------------------------- + +async function buildMigrationPlan( + config: LoadedConfig, + dataDir: string, + deps: ProjectRowDeps, +): Promise { + const projectRows: ProjectRow[] = []; + const orchestratorRows: SessionRow[] = []; + const transcripts: TranscriptCopyPlan[] = []; + const notes: PlanNote[] = []; + let projectSkips = 0; + let orchestratorSkips = 0; + + // Degraded projects: local config could not be resolved — never inserted (§6). + for (const [id, entry] of Object.entries(config.degradedProjects)) { + projectSkips++; + notes.push({ + scope: "project", + id, + note: `local config could not be resolved: ${entry.resolveError}`, + }); + } + + for (const [id, pc] of Object.entries(config.projects as Record)) { + if (!isValidRewriteProjectId(id)) { + projectSkips++; + notes.push({ + scope: "project", + id, + note: "project id fails rewrite validation — rename before migrating (orchestrator skipped too)", + }); + continue; + } + + const { row, notes: projectNotes } = buildProjectRow(id, pc, deps); + projectRows.push(row); + for (const note of projectNotes) notes.push({ scope: "project", id, note }); + + const mapping = readOrchestratorMapping(id, pc); + if (mapping.status === "mapped" && mapping.row) { + orchestratorRows.push(mapping.row); + if (mapping.transcript) { + transcripts.push( + await planTranscriptCopy({ + dataDir, + projectId: id, + prefix: mapping.prefix, + worktree: mapping.transcript.worktree, + uuid: mapping.transcript.uuid, + }), + ); + } + } else if (mapping.status === "skipped") { + orchestratorSkips++; + notes.push({ scope: "orchestrator", id, note: mapping.note ?? "skipped" }); + } + // "absent" -> the project has no orchestrator to migrate; nothing to count. + } + + return { projectRows, orchestratorRows, transcripts, projectSkips, orchestratorSkips, notes }; +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +export interface RunMigrateOptions { + dryRun?: boolean; + /** Test override for the source config. */ + config?: LoadedConfig; + /** Test override for the data dir (else resolveDataDir). */ + dataDir?: string; + /** Test override for environment deps. */ + deps?: Partial; + /** Test override for the "now" timestamp. */ + now?: string; +} + +export interface RunMigrateResult { + summary: MigrateSummary; + exitCode: number; + dryRun: boolean; + refusal?: MigrateRefusal; + notes: PlanNote[]; +} + +/** + * Run the migration. Pure-ish: all I/O goes through injectable deps so the flow + * is testable. Never calls process.exit — returns the exit code for the caller. + */ +export async function runMigrate(opts: RunMigrateOptions = {}): Promise { + const dryRun = opts.dryRun === true; + const dataDir = opts.dataDir ?? resolveDataDir(); + const dbPath = resolveDbPath(dataDir); + const now = opts.now ?? new Date().toISOString(); + + const config = opts.config ?? loadConfig(getGlobalConfigPath()); + const deps = makeProjectRowDeps(now, opts.deps); + const plan = await buildMigrationPlan(config, dataDir, deps); + + if (dryRun) { + // Preview only: no DB open-for-write, no file copies. + const wouldCopy = plan.transcripts.filter( + (t) => !existsSync(t.destPath) && existsSync(t.sourcePath), + ).length; + const summary: MigrateSummary = { + dbCreated: !existsSync(dbPath), + schemaVersion: readSchemaVersionForPreview(dbPath), + projects: { created: plan.projectRows.length, skipped: plan.projectSkips, failed: 0 }, + orchestrators: { + created: plan.orchestratorRows.length, + skipped: plan.orchestratorSkips, + failed: 0, + relocatedTranscripts: wouldCopy, + }, + }; + return { summary, exitCode: 0, dryRun: true, notes: plan.notes }; + } + + // Preconditions + create-if-missing open (§10). A refusal aborts non-zero. + let opened: ReturnType; + try { + opened = openMigrationDb(dbPath); + } catch (err) { + if (err instanceof MigrateRefusal) { + const summary: MigrateSummary = { + dbCreated: false, + schemaVersion: 0, + projects: { created: 0, skipped: plan.projectSkips, failed: 0 }, + orchestrators: { + created: 0, + skipped: plan.orchestratorSkips, + failed: 0, + relocatedTranscripts: 0, + }, + }; + return { summary, exitCode: 1, dryRun: false, refusal: err, notes: plan.notes }; + } + throw err; + } + + let relocated = 0; + try { + // Transcript relocation is independent of the DB write (§6 step 6). + for (const transcript of plan.transcripts) { + if (relocateTranscript(transcript) === "copied") relocated++; + } + + const inserted = insertMigration(opened.db, plan.projectRows, plan.orchestratorRows); + + const summary: MigrateSummary = { + dbCreated: opened.dbCreated, + schemaVersion: opened.schemaVersion, + projects: { + created: inserted.projects.created, + skipped: inserted.projects.skipped + plan.projectSkips, + failed: inserted.projects.failed, + }, + orchestrators: { + created: inserted.orchestrators.created, + skipped: inserted.orchestrators.skipped + plan.orchestratorSkips, + failed: inserted.orchestrators.failed, + relocatedTranscripts: relocated, + }, + }; + const exitCode = summary.projects.failed + summary.orchestrators.failed > 0 ? 1 : 0; + return { summary, exitCode, dryRun: false, notes: plan.notes }; + } finally { + opened.db.close(); + } +} + +/** Best-effort read-only schema version for the dry-run preview (no write). */ +function readSchemaVersionForPreview(dbPath: string): number { + if (!existsSync(dbPath)) return VENDORED_SCHEMA_VERSION; + try { + const opened = openMigrationDb(dbPath); + try { + return opened.schemaVersion; + } finally { + opened.db.close(); + } + } catch { + // Locked / too old / unavailable — report what we'd require; the real run refuses. + return VENDORED_SCHEMA_VERSION; + } +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +function printHumanSummary(result: RunMigrateResult): void { + const { summary, dryRun, refusal, notes } = result; + if (refusal) { + console.error(chalk.red(`Migration refused (${refusal.code}): ${refusal.message}`)); + return; + } + + const header = dryRun ? "Migration plan (dry run — nothing written):" : "Migration complete."; + console.log(chalk.bold(header)); + console.log( + ` DB ${summary.dbCreated ? "created at" : "already at"} schema v${summary.schemaVersion}`, + ); + console.log( + ` Projects: ${summary.projects.created} ${dryRun ? "to create" : "created"}, ${summary.projects.skipped} skipped, ${summary.projects.failed} failed`, + ); + console.log( + ` Orchestrators: ${summary.orchestrators.created} ${dryRun ? "to create" : "created"}, ${summary.orchestrators.skipped} skipped, ${summary.orchestrators.failed} failed`, + ); + console.log( + ` Transcripts: ${summary.orchestrators.relocatedTranscripts} ${dryRun ? "to relocate" : "relocated"}`, + ); + if (notes.length > 0) { + console.log(chalk.dim("\nNotes:")); + for (const note of notes) { + console.log(chalk.dim(` - [${note.scope}] ${note.id}: ${note.note}`)); + } + } +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +export function registerMigrate(program: Command): void { + program + .command("migrate") + .description( + "Migrate the legacy project registry + orchestrator sessions into the rewrite's SQLite DB (run with the rewrite daemon stopped)", + ) + .option("--dry-run", "Compute and print the plan without writing anything") + .option("--json", "Emit the machine-readable summary instead of the human summary") + .action(async (opts: { dryRun?: boolean; json?: boolean }) => { + recordActivityEvent({ + source: "cli", + kind: "cli.migration_invoked", + level: "info", + summary: "ao migrate invoked", + data: { dryRun: opts.dryRun === true, json: opts.json === true }, + }); + + try { + const result = await runMigrate({ dryRun: opts.dryRun }); + + if (opts.json) { + console.log(JSON.stringify(result.summary)); + if (result.refusal) console.error(chalk.red(result.refusal.message)); + } else { + printHumanSummary(result); + } + + recordActivityEvent({ + source: "cli", + kind: result.refusal ? "cli.migration_failed" : "cli.migration_completed", + level: result.refusal ? "error" : "info", + summary: result.refusal + ? `ao migrate refused (${result.refusal.code})` + : "ao migrate completed", + data: { + dryRun: result.dryRun, + dbCreated: result.summary.dbCreated, + schemaVersion: result.summary.schemaVersion, + projects: result.summary.projects, + orchestrators: result.summary.orchestrators, + }, + }); + + if (result.exitCode !== 0) process.exit(result.exitCode); + } catch (err) { + recordActivityEvent({ + source: "cli", + kind: "cli.migration_failed", + level: "error", + summary: "ao migrate failed", + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + }); +} diff --git a/packages/cli/src/lib/migrate-orchestrator.ts b/packages/cli/src/lib/migrate-orchestrator.ts index 7acfff53f5..6d87a508ef 100644 --- a/packages/cli/src/lib/migrate-orchestrator.ts +++ b/packages/cli/src/lib/migrate-orchestrator.ts @@ -14,8 +14,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { basename, join } from "node:path"; -import type { ProjectConfig } from "@aoagents/ao-core"; -import { getOrchestratorPath, getProjectSessionsDir } from "@aoagents/ao-core"; +import { getOrchestratorPath, getProjectSessionsDir, type ProjectConfig } from "@aoagents/ao-core"; import type { SessionRow } from "./migrate-db.js"; /** Harnesses whose orchestrator we migrate. aider (and anything else) is skipped. */ diff --git a/packages/cli/src/lib/migrate.ts b/packages/cli/src/lib/migrate.ts index dfb9fd71f1..4286e0cb57 100644 --- a/packages/cli/src/lib/migrate.ts +++ b/packages/cli/src/lib/migrate.ts @@ -1,4 +1,5 @@ import type { ProjectConfig } from "@aoagents/ao-core"; +import type { ProjectRow } from "./migrate-db.js"; /** * `ao migrate` — pure project mappers (#2129). @@ -244,3 +245,54 @@ export function buildProjectPlan(id: string, pc: ProjectConfig): ProjectPlan { const config = buildRewriteConfig(pc, notes); return { id, add, config, notes }; } + +// --------------------------------------------------------------------------- +// Project DB row (server-side fields migrate now computes itself — §7) +// --------------------------------------------------------------------------- + +/** + * Environment-dependent inputs for a project row, injected so the row builder + * stays pure (no child_process / fs of its own). + */ +export interface ProjectRowDeps { + /** `git -C remote get-url origin` trimmed, `''` on any failure. */ + repoOriginUrl: (path: string) => string; + /** registered.json `addedAt` (ISO) for this project, or null if unregistered. */ + registeredAt: (id: string, path: string) => string | null; + /** Project config file mtime (ISO), or null if it cannot be stat'd. */ + configFileMtime: (path: string) => string | null; + /** Fallback "now" ISO timestamp (last resort for registered_at). */ + now: string; +} + +/** + * Build the rewrite `projects` row for one legacy project (§7). The rewrite no + * longer fills the server-side fields (we write SQL directly), so migrate + * computes them: repo_origin_url, registered_at, kind, display_name, config. + */ +export function buildProjectRow( + id: string, + pc: ProjectConfig, + deps: ProjectRowDeps, +): { row: ProjectRow; notes: string[] } { + const notes: string[] = []; + const config = buildRewriteConfig(pc, notes); + + // display_name: the rewrite falls back to id on read, so only persist a real name. + const displayName = + typeof pc.name === "string" && pc.name.length > 0 && pc.name !== id ? pc.name : ""; + + const registeredAt = + deps.registeredAt(id, pc.path) ?? deps.configFileMtime(pc.path) ?? deps.now; + + const row: ProjectRow = { + id, + path: pc.path, + repo_origin_url: deps.repoOriginUrl(pc.path), + display_name: displayName, + registered_at: registeredAt, + kind: "single_repo", + config: config ? JSON.stringify(config) : null, + }; + return { row, notes }; +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 108118d5a7..6091e7d95e 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -17,6 +17,7 @@ import { registerPlugin } from "./commands/plugin.js"; import { registerNotify } from "./commands/notify.js"; import { registerProjectCommand } from "./commands/project.js"; import { registerMigrateStorage } from "./commands/migrate-storage.js"; +import { registerMigrate } from "./commands/migrate.js"; import { registerCompletion } from "./commands/completion.js"; import { registerEvents } from "./commands/events.js"; import { registerConfig } from "./commands/config.js"; @@ -52,6 +53,7 @@ export function createProgram(): Command { registerNotify(program); registerProjectCommand(program); registerMigrateStorage(program); + registerMigrate(program); registerCompletion(program); registerEvents(program); registerConfig(program);