Skip to content

V2: V1 session history is never imported into session_v2 — old sessions invisible in OpenCode 2 #41217

Closed

Description

@RennNathan

Summary

OpenCode 2 (beta) and OpenCode 1 share the same SQLite data directory
(~/.local/share/opencode/opencode.db), but V2 reads/writes a separate
session_v2 / session_message schema family and never imports legacy V1
session / message / part rows. As a result, a user who upgrades to V2
loses visibility of their entire V1 session history — it remains in the DB but
is unreachable from OpenCode 2, with no supported migration path.

Environment

  • opencode version: 0.0.0-next-17028 (V2, next/beta channel); 1.18.15 (V1)
  • OS: macOS arm64 (Darwin 27.0.0, Kernel 27.0.0 arm64)
  • Terminal: Apple_Terminal, TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /bin/zsh
  • Install/channel: V2 installed via npm @opencode-ai/cli@next, running as opencode2
  • Active plugins: fusion-audit.js, @slkiser/opencode-quota@latest,
    @tarquinen/opencode-dcp, opencode-caveman, opencode-memfs, opencode-rtk

Reproduction

  1. Have a V1 install with session history (legacy session / message / part rows).
  2. Install the V2 beta (npm install -g @opencode-ai/cli@next) and run opencode2 in the same project.
  3. Open the session list in V2.

Expected Behavior

V2 either (a) surfaces existing V1 session history, or (b) offers a documented
migration that imports legacy session/message/part data into session_v2
— or the migration guide clearly states that V1 session history is not carried
over and how to access it.

Actual Behavior

V1 sessions never appear in V2. They remain in the legacy tables and are not
copied or converted. The DB migration table (41 applied rows) contains no
import/backfill of legacy sessions — the only import migration is
20260805200742_import_legacy_credentials (credentials, not sessions).
session_v2/session_message rows only begin from the first V2 use.

Additional Context

  • DB schema evidence (read-only, from ~/.local/share/opencode/opencode.db):
    • Legacy family: session (674 rows), message (12259), part (53858)
    • V2 family: session_v2 (592 rows), session_message (10860),
      session_pending
    • session.version values are V1 CLI versions (1.18.x); session_v2.version
      includes 0.0.0-next-* beta versions.
    • V2 marker migration: 20260622170816_reset_v2_session_state
    • session_v2 schema differs (adds fork_session_id, fork_boundary,
      time_suspended) so a naive copy would be unsafe.
  • This is likely a missing feature/compat gap rather than a crash.
  • Workaround: reopen old sessions in V1 (opencode), which still reads the
    legacy tables. No V2-side workaround exists.
  • Frequency: consistent/reproducible.
  • Recent change: adopting the V2 beta; no V1 config changes.

Activity

  1. github-actions commented on Aug 8, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of an existing issue. Please check:

    If your issue is distinct (specifically about V1 SQLite session/message/part rows not being imported into the V2 session_v2 schema, rather than JSON→SQLite migration), feel free to clarify the difference and this note can be disregarded.

  2. kitlangton commented on Aug 11, 2026

    @kitlangton
    Contributor

    This was generated by AI during triage.

    V1 session import is now implemented by #40723 and runs as an application migration tracked under migration.v1-v2 in KV, rather than as a schema migration row. Closing this broad feature report as completed. Current concrete migration failures are tracked separately in #41341 and #41739.

  3. 627-hub commented on Oct 14, 2026

    @627-hub

    This is a critical blocker for V1→V2 migration. Many of us have long-running sessions with important context, code decisions, and architectural discussions in V1. Without session history, we cannot continue development seamlessly after upgrading.

    Could you please:

    1. Provide a migration tool/script to import V1 sessions into V2's session_v2 table
    2. Or at minimum, document a manual workaround (e.g., sqlite3 commands to convert the data)

    The current situation forces users to either stay on V1 or lose all historical context, which significantly impacts productivity. Thank you!

  4. Afzal50 commented on Oct 15, 2026

    @Afzal50

    very sad. Have some really import sessions from V1 that I really wanted to continue on V2. Please provide a workaround to the earliest. At this point, I am not sure if we can retrieve the sessions after downgrading to V1.

  5. 627-hub commented on Oct 16, 2026

    @627-hub

    Here is a workaround script that packages the manual steps I used to recover these sessions on my own database (219 of 238 sessions were stuck on global after the V1→V2 migration reported completed).

    What it fixes

    1. Invisible sessions — sessions V1 stored under project_id='global' (directories that are not git repos) stay on global in session_v2. Since /sessions and session list are project-scoped, they never show up. Re-attributing them through POST /api/session/{sessionID}/move (the workaround documented in session: v1 "global" sessions stay unattributed after migration and are hidden from v2's session list #51176) works.
    2. Timestamps suddenly all say "today" — the move event rewrites time_updated/time_idle/time_viewed on every moved row, so afterwards the entire session list shows the move time instead of the original activity dates. The script restores those fields from a pre-move backup (skipping sessions whose cost/tokens changed, i.e. ones with real activity since the backup).

    Usage

    python3 fix_v1_global_sessions.py --dry-run          # report only, no writes
    python3 fix_v1_global_sessions.py                    # backup → move → restore times → verify
    python3 fix_v1_global_sessions.py --restore-times-from <backup-dir>   # time-only restore,
                                                                          # if you already moved
                                                                          # without a backup
    • Single file, Python 3 stdlib only, POSIX paths.
    • Requires the OpenCode V2 server running (it checks GET /api/experimental/migration/v1 and refuses to run unless migration status is completed).
    • Takes a backup of opencode.db (+ -wal/-shm) before writing anything.

    Script

    #!/usr/bin/env python3
    """Recover V1 sessions stuck on the pseudo-project 'global' after a V1→V2 migration.
    
    Context: after upgrading, sessions V1 stored under project_id='global' (directories
    that are not git repos) stay attributed to 'global' in session_v2. V2 scopes the
    session list per project, so those sessions never show up. Re-attributing them via
    POST /api/session/{id}/move (the workaround from #51176) works, but the move event
    stamps time_updated/time_idle/time_viewed on every row, making the whole list show
    "today". This tool does both steps safely:
    
      1. backs up opencode.db (db + -wal + -shm)
      2. re-attributes every global session through the official move API
      3. restores the time fields from the pre-move backup for rows the move touched
         (skips rows whose cost/tokens changed = sessions with real activity)
      4. verifies and prints a summary
    
    Modes:
      python3 fix_v1_global_sessions.py                  full fix (backup first)
      python3 fix_v1_global_sessions.py --dry-run        report only, no writes
      python3 fix_v1_global_sessions.py --restore-times-from <backup-dir>
                                                         time-only restore for moves
                                                         that already happened
    
    Requires: the OpenCode V2 server running (for status check + move API).
    Standard library only. POSIX paths (adjust for Windows).
    """
    import argparse
    import base64
    import json
    import os
    import shutil
    import sqlite3
    import sys
    import time
    import urllib.error
    import urllib.request
    from concurrent.futures import ThreadPoolExecutor
    from pathlib import Path
    
    TIME_FIELDS = ("time_updated", "time_idle", "time_viewed", "idle_outcome")
    ACTIVE_FIELDS = ("cost", "tokens_input", "tokens_output")
    
    
    def find_db() -> Path:
        cands = []
        if os.environ.get("XDG_DATA_HOME"):
            cands.append(Path(os.environ["XDG_DATA_HOME"]) / "opencode" / "opencode.db")
        cands += [
            Path("~/.local/share/opencode/opencode.db"),
            Path("~/Library/Application Support/opencode/opencode.db"),
        ]
        for c in cands:
            if c.expanduser().exists():
                return c.expanduser()
        sys.exit("opencode.db not found; pass it explicitly with --db")
    
    
    def find_service():
        for c in (Path("~/.local/state/opencode/service.json"), Path("~/.config/opencode/service.json")):
            p = c.expanduser()
            if p.exists():
                try:
                    data = json.loads(p.read_text())
                    if data.get("url") and data.get("password"):
                        return data["url"], data["password"]
                except (json.JSONDecodeError, OSError):
                    pass
        return None, None
    
    
    def api(url, password, path, payload=None):
        req = urllib.request.Request(
            url + path,
            data=json.dumps(payload).encode() if payload is not None else None,
            headers={
                "Content-Type": "application/json",
                "Authorization": "Basic " + base64.b64encode(f"opencode:{password}".encode()).decode(),
            },
            method="POST" if payload is not None else "GET",
        )
        try:
            with urllib.request.urlopen(req, timeout=30) as r:
                return r.status, r.read().decode()[:300]
        except urllib.error.HTTPError as e:
            return e.code, e.read().decode()[:300]
        except OSError as e:
            return 0, str(e)
    
    
    def take_backup(db: Path) -> Path:
        stamp = time.strftime("%Y%m%d-%H%M%S")
        out = db.parent / f"opencode.db.backup-{stamp}"
        out.mkdir(parents=True, exist_ok=True)
        for suf in ("", "-wal", "-shm"):
            src = Path(str(db) + suf)
            if src.exists():
                shutil.copy2(src, out / src.name)
        print(f"backup: {out}")
        return out
    
    
    def connect(db: Path):
        conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
        conn.row_factory = sqlite3.Row
        return conn
    
    
    def restore_times(db: Path, backup_dir: Path) -> int:
        bak = backup_dir / "opencode.db"
        if not bak.exists():
            sys.exit(f"no opencode.db in {backup_dir}")
        conn = sqlite3.connect(db)
        conn.execute(f"ATTACH DATABASE 'file:{bak}?mode=ro' AS bak")
        where = (
            "a.project_id IS NOT b.project_id"            # this row was moved
            " AND (a.time_updated IS NOT b.time_updated"  # ...and its time was stamped
            "      OR a.time_idle IS NOT b.time_idle"
            "      OR a.time_viewed IS NOT b.time_viewed)"
            " AND a.cost = b.cost AND a.tokens_input = b.tokens_input"  # no real activity
            " AND a.tokens_output = b.tokens_output"
        )
        set_sql = ", ".join(
            f"{f} = (SELECT b.{f} FROM bak.session_v2 b WHERE b.id = session_v2.id)" for f in TIME_FIELDS
        )
        ids = [r[0] for r in conn.execute(
            f"SELECT a.id FROM session_v2 a JOIN bak.session_v2 b ON a.id = b.id WHERE {where}")]
        if ids:
            conn.executemany(f"UPDATE session_v2 SET {set_sql} WHERE id = ?", [(i,) for i in ids])
            conn.commit()
        conn.close()
        print(f"time fields restored on {len(ids)} session(s)")
        return len(ids)
    
    
    def main() -> int:
        ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
        ap.add_argument("--db", type=Path, help="path to opencode.db")
        ap.add_argument("--dry-run", action="store_true", help="report only, no writes")
        ap.add_argument("--restore-times-from", type=Path, metavar="BACKUP_DIR",
                        help="only restore time fields from a pre-move backup dir")
        args = ap.parse_args()
    
        db = (args.db or find_db()).expanduser()
        print(f"db: {db}")
    
        if args.restore_times_from:
            restore_times(db, args.restore_times_from.expanduser())
            return 0
    
        conn = connect(db)
        rows = conn.execute(
            "SELECT id, directory FROM session_v2 WHERE project_id='global' ORDER BY id").fetchall()
        total = conn.execute("SELECT COUNT(*) FROM session_v2").fetchone()[0]
        conn.close()
        print(f"sessions: {total} total, {len(rows)} on 'global'")
    
        if not rows:
            print("nothing to fix")
            return 0
        if args.dry_run:
            for r in rows[:20]:
                print(f"  would move {r['id']} -> {r['directory']}")
            print(f"  ... {len(rows)} total" if len(rows) > 20 else "")
            return 0
    
        url, password = find_service()
        if not url:
            sys.exit("server not running (service.json with url+password not found); start OpenCode first")
    
        status, body = api(url, password, "/api/experimental/migration/v1")
        if status != 200 or '"completed"' not in body:
            sys.exit(f"V1 migration status is not completed ({status}: {body}); refusing to run")
    
        backup = take_backup(db)
    
        started = int(time.time() * 1000)
        ok, fails = 0, []
    
        def move(r):
            st, msg = api(url, password, f"/api/session/{r['id']}/move", {"directory": r["directory"]})
            return st, r, msg
    
        with ThreadPoolExecutor(max_workers=4) as ex:
            for st, r, msg in ex.map(move, rows):
                if st == 204:
                    ok += 1
                else:
                    fails.append((r["id"], st, msg))
        print(f"move: {ok}/{len(rows)} accepted (HTTP 204)")
        for f in fails[:10]:
            print(f"  FAILED {f}")
    
        # moves apply asynchronously through the session inbox; wait for it to settle
        conn = connect(db)
        remaining = len(rows)
        deadline = time.time() + 120
        while time.time() < deadline:
            time.sleep(3)
            remaining = conn.execute(
                "SELECT COUNT(*) FROM session_v2 WHERE project_id='global'").fetchone()[0]
            if remaining == 0:
                break
        done = int(time.time() * 1000)
        print(f"attribution settled: {remaining} still on 'global' (waited {done - started}ms)")
    
        restore_times(db, backup)
    
        # verify
        conn.close()
        conn = connect(db)
        still_global = conn.execute(
            "SELECT COUNT(*) FROM session_v2 WHERE project_id='global'").fetchone()[0]
        print("\nverify:")
        print(f"  still on 'global': {still_global}")
        print("  sessions per project:")
        for r in conn.execute(
            "SELECT p.worktree, COUNT(*) c FROM session_v2 s JOIN project p ON p.id=s.project_id"
            " GROUP BY p.worktree ORDER BY c DESC LIMIT 8"):
            print(f"    {r['c']:>4}  {r['worktree']}")
        print("  time_updated distribution (top):")
        for r in conn.execute(
            "SELECT date(time_updated/1000,'unixepoch','localtime') d, COUNT(*) c"
            " FROM session_v2 GROUP BY d ORDER BY c DESC LIMIT 5"):
            print(f"    {r['c']:>4}  {r['d']}")
        conn.close()
        print(f"\ndone. backup kept at {backup} — delete it once V2 looks right.")
        return 1 if fails or remaining else 0
    
    
    if __name__ == "__main__":
        sys.exit(main())

    Results from my run

    sessions: 238 total, 219 on 'global'
    V1 migration status: completed
    move: 219/219 accepted (HTTP 204)
    attribution settled: 0 still on 'global'
    time fields restored on 219 session(s)
    
    verify:
      still on 'global': 0
      sessions per project:
         227  /Users/name
           7  /Users/name/projects/my-project
           2  /private/var/folders/.../T/opencode   (project row auto-created)
           2  /private/tmp
      time_updated distribution: back to the original dates (08-14, 10-02, 10-09, ...)
    

    Afterwards, in a freshly started TUI, migrated sessions open normally through both /sessions and the ctrl+o menu. A TUI that has been running since before the fix may still show the stamped "today" times and refuse to switch until it is restarted.

    This is only a user-side workaround — happy to see it replaced by the attribution fix in flight (#51935, #54048).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions