sessions: OpenCode v2's sessions are read (Kumo31: opencode v2的session改写到新表里了) — OpenCode 2 (v2.0.x, checked against v2.0.18's source) keeps sessions in the same opencode.db but in tables of its own (packages/core/src/session/sql.ts): session_v2 (the session row as before — directory, parent_id, a title that may now be null, time_created/updated, model {id, providerID, variant}, session totals) and session_message (id, session_id, type, seq, time_created/updated, data), not "sessionmessage". A message's kind is its type column (user, assistant, synthetic, system, compaction, idle, …); a user message's data is its prompt {text, files, …} with no part rows; an assistant's has model {id, providerID, variant}, content (text/reasoning/tool), cost, tokens {input, output, reasoning, cache {read, write}} and time {created, completed} (packages/schema/src/session-message.ts, token-usage.ts), counted as before: input without the cache, output without the reasoning (core/src/session/usage.ts). A compaction carries its own tokens, counted on its model, else the one last replied with. On first start OpenCode 2 copies every old session over (core/src/database/v1-migration.bun.ts), leaving session/message/part unwritten and recording kv migration.v1-v2 {"phase":"completed"}; so with session_v2 present only it is read once that is recorded (or no old table is left, as for the pre-split v2 databases it renamed in place), and while the copy is under way an old session not yet in session_v2 is read from the old tables — never both, so nothing counts twice and a session deleted in v2 doesn't come back. Resume stays opencode --session <ses…> (cli/src/commands/commands.ts). Tested with a v2 fixture made in the test from SQL beside stale old tables; the old-table and JSON-file tests still pass.
This commit is contained in:
2 files changed
+235
-8
No files matched your search
@@ -3,6 +3,7 @@ package sessions
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -21,6 +22,24 @@ import (
|
||||
// assistant message carries its model and its tokens, input without the
|
||||
// cache and output without the reasoning. A subagent's work is a session
|
||||
// of its own whose parent is the session it ran in, and counts there.
|
||||
//
|
||||
// OpenCode 2 keeps its sessions in the same database but in tables of its
|
||||
// own (packages/core/src/session/sql.ts): session_v2, the session row as
|
||||
// before (directory, a title that may be null, parent_id, time_created and
|
||||
// _updated), and session_message, one row a message with its kind in type
|
||||
// (user, assistant, synthetic, system, compaction, idle, …), its order in
|
||||
// seq and the rest in data. A user message's data is its prompt, {text,
|
||||
// files, …}, with no parts of its own; an assistant's has model {id,
|
||||
// providerID, variant}, content (its text, reasoning and tools), cost,
|
||||
// tokens {input, output, reasoning, cache {read, write}} counted as before,
|
||||
// and time {created, completed} (packages/schema/src/session-message.ts,
|
||||
// token-usage.ts). A compaction's data has its own tokens. On its first
|
||||
// start OpenCode 2 copies every old session into these tables
|
||||
// (packages/core/src/database/v1-migration.bun.ts, which records kv
|
||||
// migration.v1-v2 {"phase":"completed"} when done) and leaves the old
|
||||
// tables behind unwritten, so once it has, only the new tables are read;
|
||||
// while it is under way, an old session not yet copied is read from the
|
||||
// old ones.
|
||||
|
||||
// OpenCodeDir is OpenCode's data folder: $XDG_DATA_HOME/opencode, else
|
||||
// ~/.local/share/opencode — on Windows too, where OpenCode keeps it there.
|
||||
@@ -171,18 +190,37 @@ func openCodeDBFiles(agent, path string) []file {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := db.Query(`SELECT s.id, COALESCE(s.parent_id, ''), s.time_updated, COUNT(m.id), COALESCE(MAX(m.time_updated), 0)
|
||||
FROM session s LEFT JOIN message m ON m.session_id = s.id GROUP BY s.id`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var store ocStore = ocDB{db}
|
||||
if agent == "zcode" {
|
||||
store = zcDB{ocDB{db}}
|
||||
}
|
||||
var out []file
|
||||
const q = `SELECT s.id, COALESCE(s.parent_id, ''), s.time_updated, COUNT(m.id), COALESCE(MAX(m.time_updated), 0)
|
||||
FROM %s s LEFT JOIN %s m ON m.session_id = s.id %s GROUP BY s.id`
|
||||
parent := map[string]string{}
|
||||
old := fmt.Sprintf(q, "session", "message", "")
|
||||
var out []file
|
||||
if agent == "opencode" && ocHasTable(db, "session_v2") {
|
||||
out = ocDBRows(db, fmt.Sprintf(q, "session_v2", "session_message", ""), agent, path, ocV2DB{db}, parent)
|
||||
switch {
|
||||
case !ocHasTable(db, "session") || ocMigrated(db):
|
||||
old = ""
|
||||
default: // being copied over: what isn't yet
|
||||
old = fmt.Sprintf(q, "session", "message", "WHERE s.id NOT IN (SELECT id FROM session_v2)")
|
||||
}
|
||||
}
|
||||
if old != "" {
|
||||
out = append(out, ocDBRows(db, old, agent, path, store, parent)...)
|
||||
}
|
||||
return ocRoots(out, parent)
|
||||
}
|
||||
|
||||
func ocDBRows(db *sql.DB, query, agent, path string, store ocStore, parent map[string]string) []file {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []file
|
||||
for rows.Next() {
|
||||
var id, par string
|
||||
var updated, n, last int64
|
||||
@@ -192,7 +230,24 @@ func openCodeDBFiles(agent, path string) []file {
|
||||
parent[id] = par
|
||||
out = append(out, file{agent: agent, path: path + "#" + id, sid: id, oc: store, size: n, mod: ms(max(updated, last))})
|
||||
}
|
||||
return ocRoots(out, parent)
|
||||
return out
|
||||
}
|
||||
|
||||
func ocHasTable(db *sql.DB, name string) bool {
|
||||
var n int
|
||||
return db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n) == nil && n > 0
|
||||
}
|
||||
|
||||
// ocMigrated is whether OpenCode 2 has copied every old session over.
|
||||
func ocMigrated(db *sql.DB) bool {
|
||||
var b []byte
|
||||
if db.QueryRow(`SELECT value FROM kv WHERE key = 'migration.v1-v2'`).Scan(&b) != nil {
|
||||
return false
|
||||
}
|
||||
var v struct {
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
return json.Unmarshal(b, &v) == nil && v.Phase == "completed"
|
||||
}
|
||||
|
||||
func (s ocDB) info(sid string) (ocInfo, bool) {
|
||||
@@ -226,6 +281,79 @@ func (s ocDB) parts(mid string) [][]byte {
|
||||
return s.rows(`SELECT data FROM part WHERE message_id = ? ORDER BY id`, mid)
|
||||
}
|
||||
|
||||
// ---- OpenCode 2's tables ------------------------------------------------------
|
||||
|
||||
// ocV2DB is OpenCode 2's session_v2 and session_message. Its messages are
|
||||
// given as the older ones are, to be read the same way.
|
||||
type ocV2DB struct{ db *sql.DB }
|
||||
|
||||
func (s ocV2DB) info(sid string) (ocInfo, bool) {
|
||||
var i ocInfo
|
||||
err := s.db.QueryRow(`SELECT id, COALESCE(parent_id, ''), directory, COALESCE(title, ''), time_created, time_updated FROM session_v2 WHERE id = ?`, sid).
|
||||
Scan(&i.ID, &i.ParentID, &i.Directory, &i.Title, &i.Time.Created, &i.Time.Updated)
|
||||
return i, err == nil
|
||||
}
|
||||
|
||||
// messages are a session's prompts, replies and compactions: a reply's
|
||||
// model is its model's id, a compaction's (which may name none) the one
|
||||
// last replied with.
|
||||
func (s ocV2DB) messages(sid string) [][]byte {
|
||||
rows, err := s.db.Query(`SELECT id, type, data FROM session_message
|
||||
WHERE session_id = ? AND type IN ('user', 'assistant', 'compaction') ORDER BY seq`, sid)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out [][]byte
|
||||
last := ""
|
||||
for rows.Next() {
|
||||
var id, typ string
|
||||
var b []byte
|
||||
if rows.Scan(&id, &typ, &b) != nil {
|
||||
continue
|
||||
}
|
||||
var m ocMessage
|
||||
var v struct {
|
||||
Model *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"model"`
|
||||
}
|
||||
if json.Unmarshal(b, &m) != nil || json.Unmarshal(b, &v) != nil {
|
||||
continue
|
||||
}
|
||||
m.ID, m.Role, m.ModelID = id, "assistant", last
|
||||
if v.Model != nil && v.Model.ID != "" {
|
||||
m.ModelID = v.Model.ID
|
||||
}
|
||||
switch typ {
|
||||
case "user":
|
||||
m.Role, m.ModelID, m.Tokens = "user", "", nil
|
||||
case "assistant":
|
||||
last = m.ModelID
|
||||
}
|
||||
if o, err := json.Marshal(m); err == nil {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parts are a prompt's words, which OpenCode 2 keeps in the prompt itself.
|
||||
func (s ocV2DB) parts(mid string) [][]byte {
|
||||
var b []byte
|
||||
if s.db.QueryRow(`SELECT data FROM session_message WHERE id = ? AND type = 'user'`, mid).Scan(&b) != nil {
|
||||
return nil
|
||||
}
|
||||
var u struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if json.Unmarshal(b, &u) != nil {
|
||||
return nil
|
||||
}
|
||||
p, _ := json.Marshal(ocPart{Type: "text", Text: u.Text})
|
||||
return [][]byte{p}
|
||||
}
|
||||
|
||||
// ---- the files ----------------------------------------------------------------
|
||||
|
||||
type ocFiles struct{ root string } // storage/
|
||||
|
||||
@@ -287,3 +287,102 @@ func TestDirs(t *testing.T) {
|
||||
t.Fatalf("a Pi folder that isn't there listed: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// ocMakeV2 makes OpenCode 2's tables (packages/core/src/session/sql.ts,
|
||||
// the columns read and those beside them) beside the old ones, with the
|
||||
// fixtures' sessions as its migration copies them over: a prompt's words
|
||||
// in the prompt, what OpenCode put in beside them a synthetic message of
|
||||
// its own, a reply's model {id, providerID, variant}.
|
||||
func ocMakeV2(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
db := ocMakeEmpty(t, path, "")
|
||||
for _, q := range []string{
|
||||
"CREATE TABLE session_v2 (id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, parent_id text, slug text NOT NULL, directory text NOT NULL, path text, title text, version text NOT NULL, cost real DEFAULT 0 NOT NULL, tokens_input integer DEFAULT 0 NOT NULL, tokens_output integer DEFAULT 0 NOT NULL, tokens_reasoning integer DEFAULT 0 NOT NULL, tokens_cache_read integer DEFAULT 0 NOT NULL, tokens_cache_write integer DEFAULT 0 NOT NULL, agent text, model text, time_created integer NOT NULL, time_updated integer NOT NULL, time_archived integer)",
|
||||
"CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)",
|
||||
"CREATE TABLE kv (key text PRIMARY KEY, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL)",
|
||||
`INSERT INTO kv VALUES ('migration.v1-v2', '{"phase":"completed"}', 0, 0)`,
|
||||
`INSERT INTO session_v2 (id, project_id, slug, directory, title, version, model, time_created, time_updated) VALUES
|
||||
('` + ocMain + `', 'global', 'brave-otter', '/work/oc', 'Flaky test hunt', '2.0.18', '{"id":"codex/gpt-6-luna","providerID":"magpie","variant":"default"}', 1790416800000, 1790416800000),
|
||||
('ses_1a2b3c4d5e6fCCCCCCCCCCCCCC', 'global', 'idle-crow', '/work/oc', NULL, '2.0.18', NULL, 1790413200000, 1790413200000)`,
|
||||
`INSERT INTO session_v2 (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated) VALUES
|
||||
('` + ocChild + `', 'global', '` + ocMain + `', 'calm-heron', '/work/oc', NULL, '2.0.18', 1790416860000, 1790416860000)`,
|
||||
} {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i, m := range []struct{ sid, id, typ, data string }{
|
||||
{ocMain, "msg_0001", "user", `{"text":"Why does the login test\nflake?","time":{"created":1790416805000}}`},
|
||||
{ocMain, "msg_0001s", "synthetic", `{"text":"Called the Read tool with the following input","time":{"created":1790416805000}}`},
|
||||
{ocMain, "msg_0002", "assistant", `{"agent":"build","model":{"id":"gpt-6-astra","providerID":"openai","variant":"default"},"content":[{"type":"reasoning","text":"…"},{"type":"text","text":"Let me look."}],"cost":0.01,"tokens":{"input":1000,"output":100,"reasoning":20,"cache":{"read":5000,"write":0}},"time":{"created":1790416806000,"completed":1790416830000}}`},
|
||||
{ocMain, "msg_0003", "assistant", `{"agent":"build","model":{"id":"codex/gpt-6-luna","providerID":"magpie","variant":"default"},"content":[{"type":"text","text":"It races the clock."}],"cost":0,"tokens":{"input":300,"output":50,"reasoning":10,"cache":{"read":2000,"write":0}},"time":{"created":1790416890000,"completed":1790416980000}}`},
|
||||
{ocMain, "msg_0003i", "idle", `{"outcome":"succeeded","time":{"created":1790416980000}}`},
|
||||
{ocChild, "msg_0004", "assistant", `{"agent":"general","model":{"id":"claude-opus-5-5","providerID":"anthropic"},"content":[],"cost":0,"tokens":{"input":40,"output":60,"reasoning":0,"cache":{"read":700,"write":300}},"time":{"created":1790416860000,"completed":1790416890000}}`},
|
||||
} {
|
||||
var d struct {
|
||||
Time struct{ Created, Completed int64 }
|
||||
}
|
||||
json.Unmarshal([]byte(m.data), &d)
|
||||
if _, err := db.Exec("INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)", m.id, m.sid, m.typ, i, d.Time.Created, max(d.Time.Created, d.Time.Completed), m.data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestOpenCodeV2(t *testing.T) {
|
||||
inZone(t, 0)
|
||||
data, _ := setupMore(t)
|
||||
db := ocMakeV2(t, filepath.Join(data, "opencode", "opencode.db"))
|
||||
// an old session left behind in the old tables that OpenCode 2 no
|
||||
// longer has (deleted since it was copied over)
|
||||
const gone = "ses_1a2b3c4d5e6fDDDDDDDDDDDDDD"
|
||||
if _, err := db.Exec("INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (?, '0f3a', 's', '/work/gone', 'Gone', '1.1.53', 1790416800000, 1790416800000)", gone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ocInsert(t, db, gone, "msg_0009", 1790416800000, 1790416810000,
|
||||
`{"id":"msg_0009","role":"assistant","modelID":"claude-opus-5-5","tokens":{"input":1,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1790416800000,"completed":1790416810000}}`)
|
||||
|
||||
ss := List(0)
|
||||
checkOpenCode(t, ss)
|
||||
oc := find(t, ss, "opencode", ocMain)
|
||||
if filepath.Base(oc.Path) != "opencode.db#"+ocMain {
|
||||
t.Fatalf("v2 session: %+v", oc)
|
||||
}
|
||||
s := statsAt(0, statsNow)
|
||||
if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300}) || sec != 180 {
|
||||
t.Fatalf("opencode 2 on the 26th: %+v, %ds active", got, sec)
|
||||
}
|
||||
|
||||
// a compaction names no model: it counts on the one last replied with
|
||||
if _, err := db.Exec(`INSERT INTO session_message VALUES ('msg_0005', ?, 'compaction', 9, 1790417000000, 1790417010000, ?)`, ocMain,
|
||||
`{"status":"failed","reason":"auto","error":{"type":"unknown","message":"x"},"cost":0,"tokens":{"input":5,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1790417000000}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oc = find(t, List(0), "opencode", ocMain)
|
||||
if l := model(oc, "codex/gpt-6-luna"); l.Tokens != (Tokens{305, 61, 2000, 0}) {
|
||||
t.Fatalf("luna after a compaction: %+v", l)
|
||||
}
|
||||
|
||||
// while the old sessions are still being copied over, one not copied
|
||||
// yet is read from the old tables, one copied only from the new
|
||||
if _, err := db.Exec(`UPDATE kv SET value = '{"phase":"sessions","cursor":"x"}'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ss = List(0)
|
||||
if g := find(t, ss, "opencode", gone); g.Cwd != "/work/gone" {
|
||||
t.Fatalf("an old session not copied yet: %+v", g)
|
||||
}
|
||||
n := 0
|
||||
for _, s := range ss {
|
||||
if s.ID == ocMain {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("a copied session read %d times", n)
|
||||
}
|
||||
if len(dbs) != 0 {
|
||||
t.Fatalf("databases left open: %v", dbs)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user