diff --git a/docs/rewrite/plan-m1-go-session-store.md b/docs/rewrite/plan-m1-go-session-store.md new file mode 100644 index 00000000..4f6b0071 --- /dev/null +++ b/docs/rewrite/plan-m1-go-session-store.md @@ -0,0 +1,4623 @@ +# M1 Go Session Store Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make go-core able to own session, message, part and todo persistence on the same SQLite file and drizzle schema the TypeScript side uses today, exposed as `/v1/session/*` and `/v1/events`, without changing TypeScript behavior. + +**Architecture:** Four new Go packages under `go-core/internal/` (`store` opens the file and checks the schema, `ident` generates TS-compatible ids, `sessiondb` holds the queries, `eventlog` is the sequenced event bus) plus HTTP handlers in `go-core/cmd/server`. Message and part payloads stay opaque JSON. Tests build a temporary database by replaying the TS drizzle migrations from `packages/teamcode/migration/`. + +**Tech Stack:** Go 1.22+ (stdlib `net/http`, `database/sql`), `modernc.org/sqlite` (pure Go driver), Bun for the parity test and the id fixture generator. + +**Spec:** `docs/rewrite/spec-m1-go-session-store.md` + +## Global Constraints + +- No comments in code. A non-obvious "why" becomes a function or variable name, never a comment (repository owner rule). +- Files end with LF. The worktree checks out CRLF (`core.autocrlf=true`); write files with LF and let git normalize. +- Go code blocks in this plan are indented with spaces so the document lints; run `gofmt -w` after pasting, which restores tabs. +- `go-core/go.mod` module path is `github.com/ElioNeto/teamcode/go-core`. `modernc.org/sqlite` is the only new dependency allowed by the spec. +- All `time_*` columns and every `time.*` JSON field are integer epoch milliseconds. No `time.Time` on the wire. +- Session ids are descending, message/part/event ids ascending (spec §3.4). +- Go never runs or embeds migrations. Schema check: `SELECT max(created_at) FROM __drizzle_migrations` must equal `1781910501000` (folder `20260619230821_add-session-indexes` parsed as `Date.UTC`, see `packages/teamcode/src/storage/db.ts:117-127`). +- Go never deletes `-wal`/`-shm`, never runs `wal_checkpoint(TRUNCATE)` or `integrity_check` on open (spec §3.1). +- Every mutation runs in `BEGIN IMMEDIATE`. `SQLITE_BUSY` after `busy_timeout` becomes HTTP 503 `{"error":"busy"}`. +- Existing `/session/*` routes and `internal/eventbus` stay untouched until M2. New code lives beside them. +- Nothing under `packages/teamcode/src` changes. `bun test` in `packages/teamcode` must stay green and unmodified. +- Run Go commands from `go-core/`. Run `gofmt -l`, `go vet ./...`, `go test ./...` before every commit. Lint findings in files you touch are fixed, not labeled. +- Commit messages: Conventional Commits, English, no first person, end with `Co-Authored-By: Claude Fable 5.1 `. + +--- + +## File Structure + +| Path | Responsibility | +| --- | --- | +| `go-core/internal/store/path.go` | Resolve the database path from env (`TEAMCODE_DB`, `TEAMCODE_CHANNEL`, `TEAMCODE_DISABLE_CHANNEL_DB`, `XDG_DATA_HOME`) | +| `go-core/internal/store/open.go` | Open the file with the TS pragmas; one serialized writer, one read pool; `Close` with passive checkpoint | +| `go-core/internal/store/schema.go` | `CheckSchema`: read `__drizzle_migrations`, return `ErrSchemaOutdated` | +| `go-core/internal/store/testdb/testdb.go` | Test helper: temp DB built by replaying `packages/teamcode/migration/*/migration.sql` and seeding `__drizzle_migrations` | +| `go-core/internal/ident/ident.go` | `New`, `NewAt`, `Timestamp`, `Prefix` constants | +| `go-core/internal/ident/testdata/ids-from-ts.json` | Fixture generated by `script/gen-id-fixture.ts` | +| `go-core/internal/sessiondb/model.go` | `Session`, `Message`, `Part`, `Todo` structs and JSON wire types | +| `go-core/internal/sessiondb/session.go` | Session create/get/list/children/patch/delete | +| `go-core/internal/sessiondb/message.go` | Message and part upsert/get/remove, usage deltas, clock guard, pagination | +| `go-core/internal/sessiondb/todo.go` | Todo read/replace | +| `go-core/internal/sessiondb/fork.go` | Fork a session | +| `go-core/internal/eventlog/eventlog.go` | Sequenced bus with ring buffer, replay, lag disconnect | +| `go-core/cmd/server/v1_session_handlers.go` | `/v1/session/*` handlers | +| `go-core/cmd/server/v1_events_handler.go` | `/v1/events` SSE | +| `go-core/cmd/server/v1_routes.go` | Route registration and degraded-mode gate | +| `packages/teamcode/test/gocore/session-parity.test.ts` | TS-vs-Go parity by table dump, concurrency test | +| `script/gen-id-fixture.ts` | Generates the id fixture from `packages/teamcode/src/id/id.ts` | +| `.github/workflows/test.yml` | New `go-core` job | + +--- + +### Task 1: Dependency and database opening (`internal/store`) + +**Files:** + +- Modify: `go-core/go.mod`, `go-core/go.sum` +- Create: `go-core/internal/store/path.go` +- Create: `go-core/internal/store/open.go` +- Test: `go-core/internal/store/path_test.go`, `go-core/internal/store/open_test.go` + +**Interfaces:** + +- Produces: `store.ResolvePath(env func(string) string, home string) string`; `store.Open(path string) (*DB, error)`; `type DB struct` with `Writer() *sql.DB` (max 1 open conn), `Reader() *sql.DB`, `Close() error`, `ExecWrite(ctx, func(*sql.Tx) error) error` (runs `BEGIN IMMEDIATE`, commits or rolls back); `store.IsBusy(err error) bool`. + +- [ ] **Step 1: Add the driver** + +```bash +cd go-core && go get modernc.org/sqlite@latest && go mod tidy +``` + +Expected: `go.mod` gains `require modernc.org/sqlite vX.Y.Z` plus indirect deps; `go.sum` created. + +- [ ] **Step 2: Write the failing path test** + +`go-core/internal/store/path_test.go`: + +```go +package store + +import ( + "path/filepath" + "testing" +) + +func envFrom(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +func TestResolvePathUsesTeamcodeDBWhenAbsolute(t *testing.T) { + got := ResolvePath(envFrom(map[string]string{"TEAMCODE_DB": "/tmp/x.db"}), "/home/u") + if got != "/tmp/x.db" { + t.Fatalf("got %q", got) + } +} + +func TestResolvePathMemoryPassesThrough(t *testing.T) { + got := ResolvePath(envFrom(map[string]string{"TEAMCODE_DB": ":memory:"}), "/home/u") + if got != ":memory:" { + t.Fatalf("got %q", got) + } +} + +func TestResolvePathRelativeTeamcodeDBUnderDataDir(t *testing.T) { + got := ResolvePath(envFrom(map[string]string{"TEAMCODE_DB": "x.db", "XDG_DATA_HOME": "/data"}), "/home/u") + want := filepath.Join("/data", "teamcode", "x.db") + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestResolvePathChannelFile(t *testing.T) { + cases := map[string]string{ + "latest": "opencode.db", + "beta": "opencode.db", + "prod": "opencode.db", + "local": "opencode-local.db", + "": "opencode-local.db", + "my/ch": "opencode-my-ch.db", + } + for channel, file := range cases { + got := ResolvePath(envFrom(map[string]string{"TEAMCODE_CHANNEL": channel}), "/home/u") + want := filepath.Join("/home/u", ".local", "share", "teamcode", file) + if got != want { + t.Fatalf("channel %q: got %q want %q", channel, got, want) + } + } +} + +func TestResolvePathDisableChannelDB(t *testing.T) { + got := ResolvePath(envFrom(map[string]string{"TEAMCODE_CHANNEL": "local", "TEAMCODE_DISABLE_CHANNEL_DB": "1"}), "/home/u") + want := filepath.Join("/home/u", ".local", "share", "teamcode", "opencode.db") + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `cd go-core && go test ./internal/store/ -run TestResolvePath` +Expected: FAIL, `undefined: ResolvePath`. + +- [ ] **Step 4: Implement `path.go`** + +```go +package store + +import ( + "path/filepath" + "regexp" +) + +var unsafeChannelChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func dataDir(env func(string) string, home string) string { + if xdg := env("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "teamcode") + } + return filepath.Join(home, ".local", "share", "teamcode") +} + +func channelFile(env func(string) string) string { + channel := env("TEAMCODE_CHANNEL") + if channel == "" { + channel = "local" + } + sharedChannel := channel == "latest" || channel == "beta" || channel == "prod" + if sharedChannel || env("TEAMCODE_DISABLE_CHANNEL_DB") != "" { + return "opencode.db" + } + return "opencode-" + unsafeChannelChars.ReplaceAllString(channel, "-") + ".db" +} + +func ResolvePath(env func(string) string, home string) string { + if explicit := env("TEAMCODE_DB"); explicit != "" { + if explicit == ":memory:" || filepath.IsAbs(explicit) { + return explicit + } + return filepath.Join(dataDir(env, home), explicit) + } + return filepath.Join(dataDir(env, home), channelFile(env)) +} +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd go-core && go test ./internal/store/ -run TestResolvePath` +Expected: PASS. + +- [ ] **Step 6: Write the failing open test** + +`go-core/internal/store/open_test.go`: + +```go +package store + +import ( + "context" + "database/sql" + "path/filepath" + "testing" +) + +func openTemp(t *testing.T) *DB { + t.Helper() + db, err := Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func pragma(t *testing.T, db *sql.DB, name string) string { + t.Helper() + var v string + if err := db.QueryRow("PRAGMA " + name).Scan(&v); err != nil { + t.Fatal(err) + } + return v +} + +func TestOpenAppliesPragmas(t *testing.T) { + db := openTemp(t) + for _, conn := range []*sql.DB{db.Writer(), db.Reader()} { + if got := pragma(t, conn, "journal_mode"); got != "wal" { + t.Fatalf("journal_mode=%s", got) + } + if got := pragma(t, conn, "foreign_keys"); got != "1" { + t.Fatalf("foreign_keys=%s", got) + } + if got := pragma(t, conn, "busy_timeout"); got != "10000" { + t.Fatalf("busy_timeout=%s", got) + } + if got := pragma(t, conn, "synchronous"); got != "1" { + t.Fatalf("synchronous=%s", got) + } + } +} + +func TestExecWriteCommitsAndRollsBack(t *testing.T) { + db := openTemp(t) + ctx := context.Background() + if _, err := db.Writer().Exec("CREATE TABLE t (v INTEGER)"); err != nil { + t.Fatal(err) + } + err := db.ExecWrite(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO t VALUES (1)") + return err + }) + if err != nil { + t.Fatal(err) + } + err = db.ExecWrite(ctx, func(tx *sql.Tx) error { + if _, err := tx.Exec("INSERT INTO t VALUES (2)"); err != nil { + return err + } + return sql.ErrTxDone + }) + if err == nil { + t.Fatal("expected error") + } + var n int + if err := db.Reader().QueryRow("SELECT count(*) FROM t").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("rows=%d", n) + } +} + +func TestIsBusyDetectsSQLiteBusy(t *testing.T) { + db := openTemp(t) + if _, err := db.Writer().Exec("CREATE TABLE t (v INTEGER)"); err != nil { + t.Fatal(err) + } + other, err := Open(db.path) + if err != nil { + t.Fatal(err) + } + defer other.Close() + other.busyTimeoutMs = 50 + tx, err := db.Writer().Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + if _, err := tx.Exec("INSERT INTO t VALUES (1)"); err != nil { + t.Fatal(err) + } + err = other.ExecWrite(context.Background(), func(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO t VALUES (2)") + return err + }) + if !IsBusy(err) { + t.Fatalf("expected busy, got %v", err) + } +} +``` + +- [ ] **Step 7: Run to verify it fails** + +Run: `cd go-core && go test ./internal/store/ -run 'TestOpen|TestExecWrite|TestIsBusy'` +Expected: FAIL, `undefined: Open`. + +- [ ] **Step 8: Implement `open.go`** + +```go +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "sync" + + sqlite "modernc.org/sqlite" +) + +const defaultBusyTimeoutMs = 10000 + +type DB struct { + path string + writer *sql.DB + reader *sql.DB + writeMu sync.Mutex + busyTimeoutMs int +} + +func pragmaDSN(path string, busyTimeoutMs int) string { + pragmas := []string{ + "journal_mode(WAL)", + "synchronous(NORMAL)", + fmt.Sprintf("busy_timeout(%d)", busyTimeoutMs), + "cache_size(-64000)", + "foreign_keys(ON)", + } + return path + "?_pragma=" + strings.Join(pragmas, "&_pragma=") +} + +func Open(path string) (*DB, error) { + return openWithBusyTimeout(path, defaultBusyTimeoutMs) +} + +func openWithBusyTimeout(path string, busyTimeoutMs int) (*DB, error) { + writer, err := sql.Open("sqlite", pragmaDSN(path, busyTimeoutMs)+"&_txlock=immediate") + if err != nil { + return nil, err + } + writer.SetMaxOpenConns(1) + reader, err := sql.Open("sqlite", pragmaDSN(path, busyTimeoutMs)) + if err != nil { + _ = writer.Close() + return nil, err + } + if err := writer.Ping(); err != nil { + _ = writer.Close() + _ = reader.Close() + return nil, err + } + return &DB{path: path, writer: writer, reader: reader, busyTimeoutMs: busyTimeoutMs}, nil +} + +func (d *DB) Path() string { return d.path } +func (d *DB) Writer() *sql.DB { return d.writer } +func (d *DB) Reader() *sql.DB { return d.reader } + +func (d *DB) ExecWrite(ctx context.Context, fn func(*sql.Tx) error) error { + d.writeMu.Lock() + defer d.writeMu.Unlock() + tx, err := d.writer.BeginTx(ctx, nil) + if err != nil { + return err + } + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (d *DB) Close() error { + _, _ = d.writer.Exec("PRAGMA wal_checkpoint(PASSIVE)") + return errors.Join(d.writer.Close(), d.reader.Close()) +} + +func IsBusy(err error) bool { + var se *sqlite.Error + if errors.As(err, &se) { + code := se.Code() + return code == 5 || code == 6 || code == 261 + } + return err != nil && strings.Contains(err.Error(), "database is locked") +} +``` + +`_txlock=immediate` in the writer DSN makes `BeginTx` issue `BEGIN IMMEDIATE`, which is how the spec's "every mutation runs in BEGIN IMMEDIATE" is met without raw SQL. In `TestIsBusyDetectsSQLiteBusy`, replace `other.busyTimeoutMs = 50` by opening the second handle with `openWithBusyTimeout(db.path, 50)` so the busy wait is short. + +- [ ] **Step 9: Run to verify it passes** + +Run: `cd go-core && gofmt -l ./internal/store && go vet ./internal/store/ && go test ./internal/store/` +Expected: PASS, no gofmt output. + +- [ ] **Step 10: Commit** + +```bash +git add go-core/go.mod go-core/go.sum go-core/internal/store +git commit -m "feat(go-core): open the teamcode SQLite file with the TS pragmas and path rules" +``` + +--- + +### Task 2: Schema check and migration-replay test helper + +**Files:** + +- Create: `go-core/internal/store/schema.go` +- Create: `go-core/internal/store/testdb/testdb.go` +- Test: `go-core/internal/store/schema_test.go` + +**Interfaces:** + +- Consumes: `store.Open`, `store.DB`. +- Produces: `store.CheckSchema(ctx, *sql.DB) error`; `var store.ErrSchemaOutdated`; `const store.KnownMigrationCreatedAt int64 = 1781910501000`; `testdb.New(t testing.TB) *store.DB` (temp file, TS migrations replayed, `__drizzle_migrations` seeded); `testdb.SeedProject(t, db, projectID string)`. + +- [ ] **Step 1: Write the failing test** + +`go-core/internal/store/schema_test.go`: + +```go +package store_test + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/ElioNeto/teamcode/go-core/internal/store" + "github.com/ElioNeto/teamcode/go-core/internal/store/testdb" +) + +func TestCheckSchemaAcceptsReplayedMigrations(t *testing.T) { + db := testdb.New(t) + if err := store.CheckSchema(context.Background(), db.Reader()); err != nil { + t.Fatal(err) + } +} + +func TestCheckSchemaRejectsEmptyFile(t *testing.T) { + db, err := store.Open(filepath.Join(t.TempDir(), "empty.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + err = store.CheckSchema(context.Background(), db.Reader()) + if !errors.Is(err, store.ErrSchemaOutdated) { + t.Fatalf("got %v", err) + } +} + +func TestCheckSchemaRejectsOlderMigration(t *testing.T) { + db := testdb.New(t) + if _, err := db.Writer().Exec("DELETE FROM __drizzle_migrations WHERE created_at = ?", store.KnownMigrationCreatedAt); err != nil { + t.Fatal(err) + } + err := store.CheckSchema(context.Background(), db.Reader()) + if !errors.Is(err, store.ErrSchemaOutdated) { + t.Fatalf("got %v", err) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd go-core && go test ./internal/store/ -run TestCheckSchema` +Expected: FAIL, package `testdb` not found / `undefined: store.CheckSchema`. + +- [ ] **Step 3: Implement `schema.go`** + +```go +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +const KnownMigrationCreatedAt int64 = 1781910501000 + +var ErrSchemaOutdated = errors.New("schema_outdated") + +func CheckSchema(ctx context.Context, db *sql.DB) error { + var latest sql.NullInt64 + err := db.QueryRowContext(ctx, "SELECT max(created_at) FROM __drizzle_migrations").Scan(&latest) + if err != nil { + return fmt.Errorf("%w: %v", ErrSchemaOutdated, err) + } + if !latest.Valid || latest.Int64 < KnownMigrationCreatedAt { + return fmt.Errorf("%w: latest migration %d, need %d", ErrSchemaOutdated, latest.Int64, KnownMigrationCreatedAt) + } + return nil +} +``` + +- [ ] **Step 4: Implement `testdb/testdb.go`** + +```go +package testdb + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/ElioNeto/teamcode/go-core/internal/store" +) + +var migrationDirName = regexp.MustCompile(`^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})_`) + +func repoRoot(t testing.TB) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("caller unavailable") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..", "..")) +} + +func createdAt(dir string) int64 { + m := migrationDirName.FindStringSubmatch(dir) + if m == nil { + return 0 + } + n := func(s string) int { v, _ := strconv.Atoi(s); return v } + return time.Date(n(m[1]), time.Month(n(m[2])), n(m[3]), n(m[4]), n(m[5]), n(m[6]), 0, time.UTC).UnixMilli() +} + +type migration struct { + name string + createdAt int64 + sql string +} + +func loadMigrations(t testing.TB) []migration { + t.Helper() + dir := filepath.Join(repoRoot(t), "packages", "teamcode", "migration") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var out []migration + for _, e := range entries { + if !e.IsDir() { + continue + } + body, err := os.ReadFile(filepath.Join(dir, e.Name(), "migration.sql")) + if err != nil { + continue + } + out = append(out, migration{name: e.Name(), createdAt: createdAt(e.Name()), sql: string(body)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].createdAt < out[j].createdAt }) + return out +} + +func statements(sqlText string) []string { + var out []string + for _, chunk := range strings.Split(sqlText, "--> statement-breakpoint") { + for _, stmt := range strings.Split(chunk, ";") { + stmt = strings.TrimSpace(stmt) + if stmt != "" { + out = append(out, stmt) + } + } + } + return out +} + +func New(t testing.TB) *store.DB { + t.Helper() + db, err := store.Open(filepath.Join(t.TempDir(), "teamcode-test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + w := db.Writer() + if _, err := w.Exec(`CREATE TABLE "__drizzle_migrations" (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`); err != nil { + t.Fatal(err) + } + for _, m := range loadMigrations(t) { + for _, stmt := range statements(m.sql) { + if _, err := w.Exec(stmt); err != nil { + t.Fatalf("migration %s: %v\n%s", m.name, err, stmt) + } + } + if _, err := w.Exec("INSERT INTO __drizzle_migrations (hash, created_at, name) VALUES ('', ?, ?)", m.createdAt, m.name); err != nil { + t.Fatal(err) + } + } + return db +} + +func SeedProject(t testing.TB, db *store.DB, projectID string) { + t.Helper() + now := time.Now().UnixMilli() + _, err := db.Writer().Exec( + `INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, '[]')`, + projectID, "/tmp/"+projectID, now, now, + ) + if err != nil { + t.Fatal(err) + } +} +``` + +If the `project` table has more NOT NULL columns without defaults than `id, worktree, time_created, time_updated, sandboxes`, the `SeedProject` insert fails at test time; read `packages/teamcode/src/project/project.sql.ts` and add the missing columns with neutral values. + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd go-core && gofmt -l ./internal && go vet ./internal/store/... && go test ./internal/store/...` +Expected: PASS. If a migration statement fails to replay, print it (the helper already does) and adjust `statements()` splitting; do not edit the migration files. + +- [ ] **Step 6: Commit** + +```bash +git add go-core/internal/store +git commit -m "feat(go-core): verify the drizzle schema version and replay TS migrations in tests" +``` + +--- + +### Task 3: TS-compatible identifiers (`internal/ident`) + +**Files:** + +- Create: `script/gen-id-fixture.ts` +- Create: `go-core/internal/ident/testdata/ids-from-ts.json` +- Create: `go-core/internal/ident/ident.go` +- Test: `go-core/internal/ident/ident_test.go` + +**Interfaces:** + +- Produces: `ident.Session()`, `ident.Message()`, `ident.Part()`, `ident.Event()` return `string`; `ident.NewAt(prefix string, descending bool, unixMs int64) string`; `ident.Timestamp(id string) (int64, error)` (ascending ids only); `ident.HasPrefix(id, prefix string) bool`; constants `ident.PrefixSession = "ses"`, `PrefixMessage = "msg"`, `PrefixPart = "prt"`, `PrefixEvent = "evt"`. + +- [ ] **Step 1: Write the fixture generator** + +`script/gen-id-fixture.ts`: + +```ts +import { create, timestamp } from "../packages/teamcode/src/id/id" +import { writeFileSync } from "fs" + +type Row = { id: string; prefix: string; direction: "ascending" | "descending"; timestamp: number } + +const rows: Row[] = [] +const base = Date.UTC(2026, 8, 9, 12, 0, 0) +for (let i = 0; i < 200; i++) { + const t = base + i * 37 + rows.push({ id: create("ses", "descending", t), prefix: "ses", direction: "descending", timestamp: t }) + rows.push({ id: create("msg", "ascending", t), prefix: "msg", direction: "ascending", timestamp: t }) + rows.push({ id: create("prt", "ascending", t), prefix: "prt", direction: "ascending", timestamp: t }) + rows.push({ id: create("evt", "ascending", t), prefix: "evt", direction: "ascending", timestamp: t }) +} +for (const row of rows.filter((r) => r.direction === "ascending")) { + if (timestamp(row.id) !== row.timestamp) throw new Error(`timestamp mismatch for ${row.id}`) +} +const out = process.argv[2] ?? "go-core/internal/ident/testdata/ids-from-ts.json" +writeFileSync(out, JSON.stringify(rows, null, 2) + "\n") +console.log(`${rows.length} ids written to ${out}`) +``` + +Run: `bun run script/gen-id-fixture.ts` +Expected: `800 ids written to go-core/internal/ident/testdata/ids-from-ts.json`. + +- [ ] **Step 2: Write the failing Go test** + +`go-core/internal/ident/ident_test.go`: + +```go +package ident + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" +) + +type fixtureRow struct { + ID string `json:"id"` + Prefix string `json:"prefix"` + Direction string `json:"direction"` + Timestamp int64 `json:"timestamp"` +} + +func loadFixture(t *testing.T) []fixtureRow { + t.Helper() + body, err := os.ReadFile("testdata/ids-from-ts.json") + if err != nil { + t.Fatal(err) + } + var rows []fixtureRow + if err := json.Unmarshal(body, &rows); err != nil { + t.Fatal(err) + } + return rows +} + +func TestFormat(t *testing.T) { + id := NewAt(PrefixMessage, false, 1757419200000) + if !strings.HasPrefix(id, "msg_") || len(id) != len("msg_")+26 { + t.Fatalf("bad id %q", id) + } + for _, c := range id[4:16] { + if !strings.ContainsRune("0123456789abcdef", c) { + t.Fatalf("non-hex time part in %q", id) + } + } +} + +func TestTimestampMatchesTS(t *testing.T) { + for _, row := range loadFixture(t) { + if row.Direction != "ascending" { + continue + } + got, err := Timestamp(row.ID) + if err != nil { + t.Fatal(err) + } + if got != row.Timestamp { + t.Fatalf("%s: got %d want %d", row.ID, got, row.Timestamp) + } + } +} + +func TestOrderingMatchesTS(t *testing.T) { + byPrefix := map[string][]fixtureRow{} + for _, row := range loadFixture(t) { + byPrefix[row.Prefix] = append(byPrefix[row.Prefix], row) + } + for prefix, rows := range byPrefix { + sorted := append([]fixtureRow(nil), rows...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) + for i := 1; i < len(sorted); i++ { + prev, cur := sorted[i-1].Timestamp, sorted[i].Timestamp + if rows[0].Direction == "descending" && prev < cur { + t.Fatalf("%s: descending ids not reverse chronological", prefix) + } + if rows[0].Direction == "ascending" && prev > cur { + t.Fatalf("%s: ascending ids not chronological", prefix) + } + } + } +} + +func TestGoIdsInterleaveWithTSIds(t *testing.T) { + rows := loadFixture(t) + var ts []string + for _, row := range rows { + if row.Prefix == "msg" { + ts = append(ts, row.ID) + } + } + mid := rows[len(rows)/2] + goID := NewAt(PrefixMessage, false, mid.Timestamp+1) + all := append(append([]string(nil), ts...), goID) + sort.Strings(all) + idx := sort.SearchStrings(all, goID) + before, _ := Timestamp(all[idx-1]) + after, _ := Timestamp(all[idx+1]) + if before > mid.Timestamp+1 || after < mid.Timestamp+1 { + t.Fatalf("go id %s sorted between %d and %d", goID, before, after) + } +} + +func TestSessionIsDescending(t *testing.T) { + a := NewAt(PrefixSession, true, 1000) + b := NewAt(PrefixSession, true, 2000) + if !(b < a) { + t.Fatalf("newer session id %s should sort before older %s", b, a) + } +} + +func TestCounterKeepsSameMillisecondOrdered(t *testing.T) { + a := NewAt(PrefixMessage, false, 5000) + b := NewAt(PrefixMessage, false, 5000) + if !(a < b) { + t.Fatalf("%s should sort before %s", a, b) + } +} +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `cd go-core && go test ./internal/ident/` +Expected: FAIL, `undefined: NewAt`. + +- [ ] **Step 4: Implement `ident.go`** + +```go +package ident + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "strconv" + "strings" + "sync" + "time" +) + +const ( + PrefixSession = "ses" + PrefixMessage = "msg" + PrefixPart = "prt" + PrefixEvent = "evt" +) + +const randomLength = 14 +const base62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +var mu sync.Mutex +var lastMs int64 +var counter uint64 + +func nextValue(unixMs int64) uint64 { + mu.Lock() + defer mu.Unlock() + if unixMs != lastMs { + lastMs = unixMs + counter = 0 + } + counter++ + return uint64(unixMs)*0x1000 + counter +} + +func randomBase62() string { + buf := make([]byte, randomLength) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + out := make([]byte, randomLength) + for i, b := range buf { + out[i] = base62[int(b)%62] + } + return string(out) +} + +func NewAt(prefix string, descending bool, unixMs int64) string { + value := nextValue(unixMs) + if descending { + value = ^value + } + timeBytes := make([]byte, 6) + for i := 0; i < 6; i++ { + timeBytes[i] = byte(value >> (40 - 8*uint(i))) + } + return prefix + "_" + hex.EncodeToString(timeBytes) + randomBase62() +} + +func now() int64 { return time.Now().UnixMilli() } + +func Session() string { return NewAt(PrefixSession, true, now()) } +func Message() string { return NewAt(PrefixMessage, false, now()) } +func Part() string { return NewAt(PrefixPart, false, now()) } +func Event() string { return NewAt(PrefixEvent, false, now()) } + +func HasPrefix(id, prefix string) bool { return strings.HasPrefix(id, prefix+"_") } + +func Timestamp(id string) (int64, error) { + underscore := strings.IndexByte(id, '_') + if underscore < 0 || len(id) < underscore+13 { + return 0, errors.New("malformed id") + } + value, err := strconv.ParseUint(id[underscore+1:underscore+13], 16, 64) + if err != nil { + return 0, err + } + return int64(value / 0x1000), nil +} +``` + +The TS bitwise NOT on a BigInt keeps only the low 48 bits when written into 6 bytes; `^value` on `uint64` followed by taking the low 48 bits via the byte loop produces the same bytes. + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd go-core && gofmt -l ./internal/ident && go vet ./internal/ident/ && go test ./internal/ident/` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add script/gen-id-fixture.ts go-core/internal/ident +git commit -m "feat(go-core): generate ids with the TypeScript id scheme, checked against a TS fixture" +``` + +--- + +### Task 4: Session model and CRUD (`internal/sessiondb`, part 1) + +**Files:** + +- Create: `go-core/internal/sessiondb/model.go` +- Create: `go-core/internal/sessiondb/session.go` +- Test: `go-core/internal/sessiondb/session_test.go` + +**Interfaces:** + +- Consumes: `store.DB` (`Writer`, `Reader`, `ExecWrite`), `testdb.New`, `testdb.SeedProject`, `ident.Session`, `ident.HasPrefix`. +- Produces: `sessiondb.Store` (`New(db *store.DB) *Store`, `DB() *store.DB`), `Store.CreateSession(ctx, CreateSessionInput) (Session, error)`, `Store.GetSession(ctx, id) (Session, error)` returning `ErrNotFound{Kind:"Session", ID:id}` whose `Error()` is `Session not found: `, `Store.ListSessions(ctx, ListFilter) ([]Session, error)`, `Store.Children(ctx, parentID) ([]Session, error)`, `Store.PatchSession(ctx, id, patch json.RawMessage) (Session, error)`, `Store.DeleteSession(ctx, id) ([]Session, error)` returning the deleted sessions, descendants first, root last. Wire type `Session` marshals like `Session.Info` in TS (`packages/teamcode/src/session/session.ts:255-275`). `Message` and `Part` wire types merge opaque `Data` with their id fields. + +- [ ] **Step 1: Write `model.go`** + +```go +package sessiondb + +import ( + "encoding/json" + "fmt" +) + +type Tokens struct { + Input int64 `json:"input"` + Output int64 `json:"output"` + Reasoning int64 `json:"reasoning"` + Cache TokensCache `json:"cache"` +} + +type TokensCache struct { + Read int64 `json:"read"` + Write int64 `json:"write"` +} + +type Summary struct { + Additions int64 `json:"additions"` + Deletions int64 `json:"deletions"` + Files int64 `json:"files"` + Diffs json.RawMessage `json:"diffs,omitempty"` +} + +type Share struct { + URL string `json:"url"` +} + +type Time struct { + Created int64 `json:"created"` + Updated int64 `json:"updated"` + Compacting *int64 `json:"compacting,omitempty"` + Archived *int64 `json:"archived,omitempty"` +} + +type Session struct { + ID string `json:"id"` + Slug string `json:"slug"` + ProjectID string `json:"projectID"` + WorkspaceID *string `json:"workspaceID,omitempty"` + Directory string `json:"directory"` + Path *string `json:"path,omitempty"` + ParentID *string `json:"parentID,omitempty"` + Summary *Summary `json:"summary,omitempty"` + Cost float64 `json:"cost"` + Tokens Tokens `json:"tokens"` + Share *Share `json:"share,omitempty"` + Title string `json:"title"` + Agent *string `json:"agent,omitempty"` + Model json.RawMessage `json:"model,omitempty"` + Version string `json:"version"` + Time Time `json:"time"` + Permission json.RawMessage `json:"permission,omitempty"` + Revert json.RawMessage `json:"revert,omitempty"` +} + +type Message struct { + ID string + SessionID string + TimeCreated int64 + Data json.RawMessage +} + +type Part struct { + ID string + SessionID string + MessageID string + TimeCreated int64 + Data json.RawMessage +} + +type Todo struct { + Content string `json:"content"` + Status string `json:"status"` + Priority string `json:"priority"` +} + +type ErrNotFound struct { + Kind string + ID string +} + +func (e ErrNotFound) Error() string { return fmt.Sprintf("%s not found: %s", e.Kind, e.ID) } + +func IsNotFound(err error) bool { + var target ErrNotFound + return errors.As(err, &target) +} + +func mergeIDs(data json.RawMessage, ids map[string]string) ([]byte, error) { + obj := map[string]json.RawMessage{} + if len(data) > 0 { + if err := json.Unmarshal(data, &obj); err != nil { + return nil, err + } + } + for k, v := range ids { + quoted, _ := json.Marshal(v) + obj[k] = quoted + } + return json.Marshal(obj) +} + +func (m Message) MarshalJSON() ([]byte, error) { + return mergeIDs(m.Data, map[string]string{"id": m.ID, "sessionID": m.SessionID}) +} + +func (p Part) MarshalJSON() ([]byte, error) { + return mergeIDs(p.Data, map[string]string{"id": p.ID, "sessionID": p.SessionID, "messageID": p.MessageID}) +} + +func splitIDs(body []byte, keys ...string) (map[string]string, json.RawMessage, error) { + obj := map[string]json.RawMessage{} + if err := json.Unmarshal(body, &obj); err != nil { + return nil, nil, err + } + ids := map[string]string{} + for _, k := range keys { + raw, ok := obj[k] + if !ok { + continue + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, nil, fmt.Errorf("%s must be a string", k) + } + ids[k] = s + delete(obj, k) + } + rest, err := json.Marshal(obj) + return ids, rest, err +} +``` + +Add `"errors"` to the import block. `mergeIDs` and `splitIDs` mirror `info()`/`part()` in `message-v2.ts:592-605` and the `{ id, sessionID, ...rest }` destructuring in `projectors.ts:127,174`. + +- [ ] **Step 2: Write the failing session tests** + +`go-core/internal/sessiondb/session_test.go`: + +```go +package sessiondb_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/ElioNeto/teamcode/go-core/internal/sessiondb" + "github.com/ElioNeto/teamcode/go-core/internal/store/testdb" +) + +var ctx = context.Background() + +func newStore(t *testing.T) *sessiondb.Store { + t.Helper() + db := testdb.New(t) + testdb.SeedProject(t, db, "prj_test") + return sessiondb.New(db) +} + +func create(t *testing.T, s *sessiondb.Store, in sessiondb.CreateSessionInput) sessiondb.Session { + t.Helper() + if in.ProjectID == "" { + in.ProjectID = "prj_test" + } + if in.Directory == "" { + in.Directory = "/tmp/dir" + } + if in.Version == "" { + in.Version = "test" + } + out, err := s.CreateSession(ctx, in) + if err != nil { + t.Fatal(err) + } + return out +} + +func strp(s string) *string { return &s } + +func ids(list []sessiondb.Session) []string { + out := make([]string, len(list)) + for i, s := range list { + out[i] = s.ID + } + return out +} + +func sameSession(a, b sessiondb.Session) bool { + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + return string(ja) == string(jb) +} + +func TestCreateSessionDefaults(t *testing.T) { + s := newStore(t) + got := create(t, s, sessiondb.CreateSessionInput{}) + if !strings.HasPrefix(got.ID, "ses_") || got.Slug == "" || !strings.HasPrefix(got.Title, "New session - ") { + t.Fatalf("%+v", got) + } + if got.Cost != 0 || got.Tokens != (sessiondb.Tokens{}) || got.Time.Created == 0 || got.Time.Updated != got.Time.Created { + t.Fatalf("%+v", got) + } + again, err := s.GetSession(ctx, got.ID) + if err != nil || !sameSession(again, got) { + t.Fatalf("get mismatch: %+v vs %+v (%v)", again, got, err) + } +} + +func TestCreateSessionChildTitleAndParent(t *testing.T) { + s := newStore(t) + parent := create(t, s, sessiondb.CreateSessionInput{}) + child := create(t, s, sessiondb.CreateSessionInput{ParentID: &parent.ID}) + if !strings.HasPrefix(child.Title, "Child session - ") || *child.ParentID != parent.ID { + t.Fatalf("%+v", child) + } + kids, err := s.Children(ctx, parent.ID) + if err != nil || len(kids) != 1 || kids[0].ID != child.ID { + t.Fatalf("children=%v err=%v", ids(kids), err) + } +} + +func TestCreateSessionRequiresProject(t *testing.T) { + s := newStore(t) + _, err := s.CreateSession(ctx, sessiondb.CreateSessionInput{ProjectID: "prj_missing", Directory: "/d", Version: "v"}) + if err == nil { + t.Fatal("expected foreign key failure") + } +} + +func TestGetSessionNotFound(t *testing.T) { + s := newStore(t) + _, err := s.GetSession(ctx, "ses_missing") + if !sessiondb.IsNotFound(err) || err.Error() != "Session not found: ses_missing" { + t.Fatalf("got %v", err) + } +} + +func TestListSessionsOrderAndFilters(t *testing.T) { + s := newStore(t) + a := create(t, s, sessiondb.CreateSessionInput{Title: strp("alpha one")}) + b := create(t, s, sessiondb.CreateSessionInput{Title: strp("beta two")}) + if _, err := s.PatchSession(ctx, a.ID, json.RawMessage(`{"time":{"updated":9999999999999}}`)); err != nil { + t.Fatal(err) + } + all, err := s.ListSessions(ctx, sessiondb.ListFilter{ProjectID: "prj_test"}) + if err != nil || len(all) != 2 || all[0].ID != a.ID || all[1].ID != b.ID { + t.Fatalf("list=%v err=%v", ids(all), err) + } + found, _ := s.ListSessions(ctx, sessiondb.ListFilter{ProjectID: "prj_test", Search: "beta"}) + if len(found) != 1 || found[0].ID != b.ID { + t.Fatalf("search=%v", ids(found)) + } + roots, _ := s.ListSessions(ctx, sessiondb.ListFilter{ProjectID: "prj_test", RootsOnly: true}) + if len(roots) != 2 { + t.Fatalf("roots=%v", ids(roots)) + } + if _, err := s.PatchSession(ctx, b.ID, json.RawMessage(`{"time":{"archived":1}}`)); err != nil { + t.Fatal(err) + } + active, _ := s.ListSessions(ctx, sessiondb.ListFilter{ProjectID: "prj_test"}) + if len(active) != 1 || active[0].ID != a.ID { + t.Fatalf("archived filter=%v", ids(active)) + } + withArchived, _ := s.ListSessions(ctx, sessiondb.ListFilter{ProjectID: "prj_test", IncludeArchived: true}) + if len(withArchived) != 2 { + t.Fatalf("include archived=%v", ids(withArchived)) + } +} + +func TestPatchSessionNullClearsAndAbsentKeeps(t *testing.T) { + s := newStore(t) + created := create(t, s, sessiondb.CreateSessionInput{Title: strp("t")}) + patched, err := s.PatchSession(ctx, created.ID, json.RawMessage(`{"summary":{"additions":1,"deletions":2,"files":3,"diffs":[]},"share":{"url":"https://x"}}`)) + if err != nil || patched.Summary == nil || patched.Summary.Files != 3 || patched.Share == nil || patched.Share.URL != "https://x" { + t.Fatalf("%+v %v", patched, err) + } + if patched.Time.Updated != created.Time.Updated { + t.Fatal("time.updated must not change without an explicit patch") + } + cleared, err := s.PatchSession(ctx, created.ID, json.RawMessage(`{"summary":null}`)) + if err != nil || cleared.Summary != nil || cleared.Share == nil { + t.Fatalf("%+v %v", cleared, err) + } + if _, err := s.PatchSession(ctx, "ses_missing", json.RawMessage(`{"title":"x"}`)); !sessiondb.IsNotFound(err) { + t.Fatalf("got %v", err) + } +} + +func TestDeleteSessionRecursive(t *testing.T) { + s := newStore(t) + root := create(t, s, sessiondb.CreateSessionInput{}) + child := create(t, s, sessiondb.CreateSessionInput{ParentID: &root.ID}) + grand := create(t, s, sessiondb.CreateSessionInput{ParentID: &child.ID}) + deleted, err := s.DeleteSession(ctx, root.ID) + if err != nil { + t.Fatal(err) + } + got := ids(deleted) + if len(got) != 3 || got[0] != grand.ID || got[1] != child.ID || got[2] != root.ID { + t.Fatalf("order=%v", got) + } + for _, id := range got { + if _, err := s.GetSession(ctx, id); !sessiondb.IsNotFound(err) { + t.Fatalf("%s still exists", id) + } + } +} + +func TestSessionRowWithBadModelStillLoads(t *testing.T) { + s := newStore(t) + created := create(t, s, sessiondb.CreateSessionInput{}) + if _, err := s.DB().Writer().Exec(`UPDATE session SET model = 'not json' WHERE id = ?`, created.ID); err != nil { + t.Fatal(err) + } + got, err := s.GetSession(ctx, created.ID) + if err != nil || got.Model != nil { + t.Fatalf("%+v %v", got, err) + } +} +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `cd go-core && go test ./internal/sessiondb/` +Expected: FAIL, `undefined: sessiondb.New`. + +- [ ] **Step 4: Implement `session.go`, read side and create** + +```go +package sessiondb + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "math/rand" + "path/filepath" + "strings" + "time" + + "github.com/ElioNeto/teamcode/go-core/internal/ident" + "github.com/ElioNeto/teamcode/go-core/internal/store" +) + +type Store struct { + db *store.DB +} + +func New(db *store.DB) *Store { return &Store{db: db} } + +func (s *Store) DB() *store.DB { return s.db } + +type CreateSessionInput struct { + ID *string `json:"id"` + ProjectID string `json:"projectID"` + WorkspaceID *string `json:"workspaceID"` + ParentID *string `json:"parentID"` + Directory string `json:"directory"` + Path *string `json:"path"` + Title *string `json:"title"` + Agent *string `json:"agent"` + Model json.RawMessage `json:"model"` + Permission json.RawMessage `json:"permission"` + Version string `json:"version"` +} + +type ListFilter struct { + ProjectID string + WorkspaceID string + Directory string + Path string + ParentID string + RootsOnly bool + Search string + Start int64 + Limit int + IncludeArchived bool +} + +var slugAdjectives = strings.Fields("brave calm clever cosmic crisp curious eager gentle glowing happy hidden jolly kind lucky mighty misty neon nimble playful proud quick quiet shiny silent stellar sunny swift tidy witty") +var slugNouns = strings.Fields("cabin cactus canyon circuit comet eagle engine falcon forest garden harbor island knight lagoon meadow moon mountain nebula orchid otter panda pixel planet river rocket sailor squid star tiger wizard wolf") + +func newSlug() string { + return slugAdjectives[rand.Intn(len(slugAdjectives))] + "-" + slugNouns[rand.Intn(len(slugNouns))] +} + +func defaultTitle(isChild bool, now int64) string { + prefix := "New session - " + if isChild { + prefix = "Child session - " + } + return prefix + time.UnixMilli(now).UTC().Format("2006-01-02T15:04:05.000Z") +} + +const sessionColumns = `id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url, + summary_additions, summary_deletions, summary_files, summary_diffs, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, + revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived` + +type sessionRow struct { + id, projectID, slug, directory, title, version string + workspaceID, parentID, path, shareURL, agent sql.NullString + summaryAdditions, summaryDeletions, summaryFiles sql.NullInt64 + summaryDiffs, revert, permission, model sql.NullString + cost float64 + tokensInput, tokensOutput, tokensReasoning int64 + tokensCacheRead, tokensCacheWrite int64 + timeCreated, timeUpdated int64 + timeCompacting, timeArchived sql.NullInt64 +} + +type rowScanner interface{ Scan(dest ...any) error } + +func scanSession(r rowScanner) (Session, error) { + var row sessionRow + err := r.Scan(&row.id, &row.projectID, &row.workspaceID, &row.parentID, &row.slug, &row.directory, &row.path, + &row.title, &row.version, &row.shareURL, &row.summaryAdditions, &row.summaryDeletions, &row.summaryFiles, + &row.summaryDiffs, &row.cost, &row.tokensInput, &row.tokensOutput, &row.tokensReasoning, &row.tokensCacheRead, + &row.tokensCacheWrite, &row.revert, &row.permission, &row.agent, &row.model, &row.timeCreated, &row.timeUpdated, + &row.timeCompacting, &row.timeArchived) + if err != nil { + return Session{}, err + } + return row.toSession(), nil +} + +func optString(v sql.NullString) *string { + if !v.Valid { + return nil + } + return &v.String +} + +func optInt(v sql.NullInt64) *int64 { + if !v.Valid { + return nil + } + return &v.Int64 +} + +func rawIfValidJSON(v sql.NullString, field, id string) json.RawMessage { + if !v.Valid || v.String == "" || v.String == "null" { + return nil + } + if !json.Valid([]byte(v.String)) { + log.Printf("sessiondb: session %s has invalid %s json, dropping field", id, field) + return nil + } + return json.RawMessage(v.String) +} + +func (row sessionRow) toSession() Session { + out := Session{ + ID: row.id, Slug: row.slug, ProjectID: row.projectID, Directory: row.directory, Title: row.title, Version: row.version, + WorkspaceID: optString(row.workspaceID), ParentID: optString(row.parentID), Path: optString(row.path), Agent: optString(row.agent), + Cost: row.cost, + Tokens: Tokens{Input: row.tokensInput, Output: row.tokensOutput, Reasoning: row.tokensReasoning, + Cache: TokensCache{Read: row.tokensCacheRead, Write: row.tokensCacheWrite}}, + Time: Time{Created: row.timeCreated, Updated: row.timeUpdated, Compacting: optInt(row.timeCompacting), Archived: optInt(row.timeArchived)}, + Model: rawIfValidJSON(row.model, "model", row.id), Permission: rawIfValidJSON(row.permission, "permission", row.id), + Revert: rawIfValidJSON(row.revert, "revert", row.id), + } + if row.shareURL.Valid && row.shareURL.String != "" { + out.Share = &Share{URL: row.shareURL.String} + } + if row.summaryAdditions.Valid || row.summaryDeletions.Valid || row.summaryFiles.Valid { + out.Summary = &Summary{Additions: row.summaryAdditions.Int64, Deletions: row.summaryDeletions.Int64, Files: row.summaryFiles.Int64, + Diffs: rawIfValidJSON(row.summaryDiffs, "summary.diffs", row.id)} + } + return out +} + +func nullableJSON(raw json.RawMessage) any { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + return string(raw) +} + +func (s *Store) CreateSession(ctx context.Context, in CreateSessionInput) (Session, error) { + now := time.Now().UnixMilli() + id := ident.Session() + if in.ID != nil && *in.ID != "" { + if !ident.HasPrefix(*in.ID, ident.PrefixSession) { + return Session{}, fmt.Errorf("ID %s does not start with ses", *in.ID) + } + id = *in.ID + } + title := defaultTitle(in.ParentID != nil, now) + if in.Title != nil { + title = *in.Title + } + err := s.db.ExecWrite(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `INSERT INTO session (id, project_id, workspace_id, parent_id, slug, directory, path, title, version, + cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, revert, permission, agent, model, + time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0, NULL, ?, ?, ?, ?, ?)`, + id, in.ProjectID, in.WorkspaceID, in.ParentID, newSlug(), in.Directory, in.Path, title, in.Version, + nullableJSON(in.Permission), in.Agent, nullableJSON(in.Model), now, now) + if err != nil { + return err + } + if in.WorkspaceID != nil { + _, err = tx.ExecContext(ctx, `UPDATE workspace SET time_used = ? WHERE id = ?`, now, *in.WorkspaceID) + } + return err + }) + if err != nil { + return Session{}, err + } + return s.GetSession(ctx, id) +} + +func (s *Store) GetSession(ctx context.Context, id string) (Session, error) { + row := s.db.Reader().QueryRowContext(ctx, `SELECT `+sessionColumns+` FROM session WHERE id = ?`, id) + out, err := scanSession(row) + if errors.Is(err, sql.ErrNoRows) { + return Session{}, ErrNotFound{Kind: "Session", ID: id} + } + return out, err +} + +func normalizeDirectory(dir string) string { + dir = filepath.ToSlash(filepath.Clean(dir)) + for len(dir) > 1 && strings.HasSuffix(dir, "/") { + dir = strings.TrimSuffix(dir, "/") + } + return dir +} + +func (s *Store) ListSessions(ctx context.Context, f ListFilter) ([]Session, error) { + var where []string + var args []any + add := func(cond string, vals ...any) { + where = append(where, cond) + args = append(args, vals...) + } + if f.ProjectID != "" { + add("project_id = ?", f.ProjectID) + } + if f.WorkspaceID != "" { + add("workspace_id = ?", f.WorkspaceID) + } + if f.Path != "" { + if f.Directory != "" { + add("(path = ? OR path LIKE ? OR (path IS NULL AND directory = ?))", f.Path, f.Path+"/%", normalizeDirectory(f.Directory)) + } else { + add("(path = ? OR path LIKE ?)", f.Path, f.Path+"/%") + } + } else if f.Directory != "" { + add("directory = ?", normalizeDirectory(f.Directory)) + } + if f.RootsOnly { + add("parent_id IS NULL") + } else if f.ParentID != "" { + add("parent_id = ?", f.ParentID) + } + if f.Start > 0 { + add("time_updated >= ?", f.Start) + } + if f.Search != "" { + add("title LIKE ?", "%"+f.Search+"%") + } + if !f.IncludeArchived { + add("time_archived IS NULL") + } + limit := f.Limit + if limit <= 0 { + limit = 100 + } + query := `SELECT ` + sessionColumns + ` FROM session` + if len(where) > 0 { + query += " WHERE " + strings.Join(where, " AND ") + } + query += " ORDER BY time_updated DESC, id DESC LIMIT ?" + args = append(args, limit) + rows, err := s.db.Reader().QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Session{} + for rows.Next() { + item, err := scanSession(rows) + if err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() +} + +func (s *Store) Children(ctx context.Context, parentID string) ([]Session, error) { + return s.ListSessions(ctx, ListFilter{ParentID: parentID, IncludeArchived: true, Limit: 100000}) +} +``` + +- [ ] **Step 5: Implement `PatchSession` and `DeleteSession`** + +The patch body is the TS `UpdatedInfo` deep partial (`session.ts:356-380`): a key present with `null` clears its columns, an absent key leaves them alone. Append to `session.go`: + +```go +type patchColumn struct { + column string + value any +} + +var scalarPatchColumns = map[string]string{ + "title": "title", "slug": "slug", "directory": "directory", "path": "path", "version": "version", + "agent": "agent", "cost": "cost", "workspaceID": "workspace_id", "parentID": "parent_id", +} + +var jsonPatchColumns = map[string]string{"model": "model", "permission": "permission", "revert": "revert"} + +var nestedPatchColumns = map[string]map[string]string{ + "share": {"url": "share_url"}, + "summary": {"additions": "summary_additions", "deletions": "summary_deletions", "files": "summary_files", "diffs": "summary_diffs"}, + "time": {"created": "time_created", "updated": "time_updated", "compacting": "time_compacting", "archived": "time_archived"}, + "tokens": {"input": "tokens_input", "output": "tokens_output", "reasoning": "tokens_reasoning"}, +} + +var jsonNestedColumns = map[string]bool{"summary_diffs": true} + +func decodeScalar(raw json.RawMessage) (any, error) { + var v any + err := json.Unmarshal(raw, &v) + return v, err +} + +func patchColumns(patch json.RawMessage) ([]patchColumn, error) { + var top map[string]json.RawMessage + if err := json.Unmarshal(patch, &top); err != nil { + return nil, err + } + var out []patchColumn + for key, column := range scalarPatchColumns { + raw, ok := top[key] + if !ok { + continue + } + v, err := decodeScalar(raw) + if err != nil { + return nil, err + } + out = append(out, patchColumn{column, v}) + } + for key, column := range jsonPatchColumns { + if raw, ok := top[key]; ok { + out = append(out, patchColumn{column, nullableJSON(raw)}) + } + } + for key, columns := range nestedPatchColumns { + raw, ok := top[key] + if !ok { + continue + } + if string(raw) == "null" { + for _, column := range columns { + out = append(out, patchColumn{column, nil}) + } + continue + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, err + } + for field, column := range columns { + inner, ok := obj[field] + if !ok { + continue + } + if jsonNestedColumns[column] { + out = append(out, patchColumn{column, nullableJSON(inner)}) + continue + } + v, err := decodeScalar(inner) + if err != nil { + return nil, err + } + out = append(out, patchColumn{column, v}) + } + if key == "tokens" { + if cache, ok := obj["cache"]; ok && string(cache) != "null" { + var c map[string]json.RawMessage + if err := json.Unmarshal(cache, &c); err != nil { + return nil, err + } + for field, column := range map[string]string{"read": "tokens_cache_read", "write": "tokens_cache_write"} { + if inner, ok := c[field]; ok { + v, err := decodeScalar(inner) + if err != nil { + return nil, err + } + out = append(out, patchColumn{column, v}) + } + } + } + } + } + return out, nil +} + +func (s *Store) PatchSession(ctx context.Context, id string, patch json.RawMessage) (Session, error) { + columns, err := patchColumns(patch) + if err != nil { + return Session{}, err + } + err = s.db.ExecWrite(ctx, func(tx *sql.Tx) error { + sets := []string{"time_updated = time_updated"} + args := []any{} + for _, c := range columns { + sets = append(sets, c.column+" = ?") + args = append(args, c.value) + } + args = append(args, id) + res, err := tx.ExecContext(ctx, `UPDATE session SET `+strings.Join(sets, ", ")+` WHERE id = ?`, args...) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound{Kind: "Session", ID: id} + } + return nil + }) + if err != nil { + return Session{}, err + } + return s.GetSession(ctx, id) +} + +func (s *Store) DeleteSession(ctx context.Context, id string) ([]Session, error) { + root, err := s.GetSession(ctx, id) + if err != nil { + return nil, err + } + var ordered []Session + var walk func(parent Session) error + walk = func(parent Session) error { + kids, err := s.Children(ctx, parent.ID) + if err != nil { + return err + } + for _, kid := range kids { + if err := walk(kid); err != nil { + return err + } + } + ordered = append(ordered, parent) + return nil + } + if err := walk(root); err != nil { + return nil, err + } + err = s.db.ExecWrite(ctx, func(tx *sql.Tx) error { + for _, item := range ordered { + if _, err := tx.ExecContext(ctx, `DELETE FROM session WHERE id = ?`, item.ID); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return ordered, nil +} +``` + +`ordered` ends with the deepest descendants first and the root last, the order in which `session.ts:681-685` emits `session.deleted`. + +- [ ] **Step 6: Run to verify it passes** + +Run: `cd go-core && gofmt -l ./internal/sessiondb && go vet ./internal/sessiondb/ && go test ./internal/sessiondb/` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add go-core/internal/sessiondb +git commit -m "feat(go-core): session CRUD over the teamcode SQLite schema" +``` + +--- + +### Task 5: Messages and parts (`internal/sessiondb`, part 2) + +**Files:** + +- Create: `go-core/internal/sessiondb/message.go` +- Test: `go-core/internal/sessiondb/message_test.go` + +**Interfaces:** + +- Consumes: `Store`, `Session`, `Message`, `Part`, `splitIDs`, `ErrNotFound`, `store.DB.ExecWrite`, `ident.Message`, `ident.Part`. +- Produces: `Store.UpsertMessage(ctx, sessionID string, body json.RawMessage) (Message, error)` (body is the full TS `MessageV2.Info` JSON; `id` optional; applies the clock guard), `Store.RemoveMessage(ctx, sessionID, messageID) error`, `Store.UpsertPart(ctx, sessionID, messageID string, body json.RawMessage, timeCreated int64) (Part, error)`, `Store.GetPart(ctx, sessionID, messageID, partID) (Part, error)`, `Store.RemovePart(ctx, sessionID, messageID, partID) error`, `Store.PageMessages(ctx, sessionID string, limit int, before string) (MessagePage, error)` with `type MessagePage struct { Items []MessageWithParts; More bool; Cursor string }` and `type MessageWithParts struct { Info Message "json:\"info\""; Parts []Part "json:\"parts\"" }`, `Store.GetMessage(ctx, sessionID, messageID) (MessageWithParts, error)`, `Store.AllMessages(ctx, sessionID) ([]MessageWithParts, error)` (oldest first, no limit), `sessiondb.EncodeCursor(id string, time int64) string`, `sessiondb.DecodeCursor(string) (id string, time int64, err error)`. Errors: `ErrNotFound{Kind:"Message"}` for missing messages; `ErrLateWrite` (sentinel) when a message or part targets a session that no longer exists (FK failure), which callers treat as success plus a warning log. + +- [ ] **Step 1: Write the failing tests** + +`go-core/internal/sessiondb/message_test.go`: + +```go +package sessiondb_test + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/ElioNeto/teamcode/go-core/internal/sessiondb" +) + +func userMessage(t *testing.T, s *sessiondb.Store, sessionID string, created int64) sessiondb.Message { + t.Helper() + body := fmt.Sprintf(`{"role":"user","agent":"build","model":{"providerID":"p","modelID":"m"},"time":{"created":%d}}`, created) + msg, err := s.UpsertMessage(ctx, sessionID, json.RawMessage(body)) + if err != nil { + t.Fatal(err) + } + return msg +} + +func stepFinish(t *testing.T, s *sessiondb.Store, sessionID, messageID string, cost float64, input int64) sessiondb.Part { + t.Helper() + body := fmt.Sprintf(`{"type":"step-finish","reason":"stop","cost":%v,"tokens":{"input":%d,"output":2,"reasoning":3,"cache":{"read":4,"write":5}}}`, cost, input) + part, err := s.UpsertPart(ctx, sessionID, messageID, json.RawMessage(body), time.Now().UnixMilli()) + if err != nil { + t.Fatal(err) + } + return part +} + +func TestUpsertMessageAssignsIDAndRoundTrips(t *testing.T) { + s := newStore(t) + ses := create(t, s, sessiondb.CreateSessionInput{}) + msg := userMessage(t, s, ses.ID, 1000) + if msg.ID[:4] != "msg_" || msg.SessionID != ses.ID || msg.TimeCreated != 1000 { + t.Fatalf("%+v", msg) + } + got, err := s.GetMessage(ctx, ses.ID, msg.ID) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(got.Info) + var decoded map[string]any + _ = json.Unmarshal(encoded, &decoded) + if decoded["id"] != msg.ID || decoded["sessionID"] != ses.ID || decoded["role"] != "user" || decoded["agent"] != "build" { + t.Fatalf("%s", encoded) + } +} + +func TestUpsertMessageKeepsTimeCreatedOnConflict(t *testing.T) { + s := newStore(t) + ses := create(t, s, sessiondb.CreateSessionInput{}) + msg := userMessage(t, s, ses.ID, 1000) + body := fmt.Sprintf(`{"id":"%s","role":"user","agent":"plan","model":{"providerID":"p","modelID":"m"},"time":{"created":5000}}`, msg.ID) + again, err := s.UpsertMessage(ctx, ses.ID, json.RawMessage(body)) + if err != nil || again.TimeCreated != 1000 { + t.Fatalf("%+v %v", again, err) + } + got, _ := s.GetMessage(ctx, ses.ID, msg.ID) + var decoded map[string]any + _ = json.Unmarshal(got.Info.Data, &decoded) + if decoded["agent"] != "plan" { + t.Fatalf("data not replaced: %s", got.Info.Data) + } +} + +func TestUpsertMessageClockGuard(t *testing.T) { + s := newStore(t) + ses := create(t, s, sessiondb.CreateSessionInput{}) + future := time.Now().UnixMilli() + 60_000 + userMessage(t, s, ses.ID, future) + next := userMessage(t, s, ses.ID, time.Now().UnixMilli()) + if next.TimeCreated != future+1 { + t.Fatalf("got %d want %d", next.TimeCreated, future+1) + } + var stored map[string]any + _ = json.Unmarshal(next.Data, &stored) + if int64(stored["time"].(map[string]any)["created"].(float64)) != future+1 { + t.Fatalf("data.time.created not adjusted: %s", next.Data) + } +} + +func TestUpsertMessageLateWriteIsSwallowed(t *testing.T) { + s := newStore(t) + _, err := s.UpsertMessage(ctx, "ses_gone", json.RawMessage(`{"role":"user","agent":"a","model":{"providerID":"p","modelID":"m"},"time":{"created":1}}`)) + if err != sessiondb.ErrLateWrite { + t.Fatalf("got %v", err) + } +} + +func TestPartUsageDeltas(t *testing.T) { + s := newStore(t) + ses := create(t, s, sessiondb.CreateSessionInput{}) + msg := userMessage(t, s, ses.ID, 1000) + part := stepFinish(t, s, ses.ID, msg.ID, 0.5, 10) + after, _ := s.GetSession(ctx, ses.ID) + if after.Cost != 0.5 || after.Tokens.Input != 10 || after.Tokens.Cache.Write != 5 { + t.Fatalf("%+v", after) + } + if after.Time.Updated != ses.Time.Updated { + t.Fatal("usage must not bump time_updated") + } + body := fmt.Sprintf(`{"id":"%s","type":"step-finish","reason":"stop","cost":2,"tokens":{"input":100,"output":2,"reasoning":3,"cache":{"read":4,"write":5}}}`, part.ID) + if _, err := s.UpsertPart(ctx, ses.ID, msg.ID, json.RawMessage(body), 1); err != nil { + t.Fatal(err) + } + after, _ = s.GetSession(ctx, ses.ID) + if after.Cost != 2 || after.Tokens.Input != 100 || after.Tokens.Output != 2 { + t.Fatalf("reupsert did not reverse previous usage: %+v", after) + } + if err := s.RemovePart(ctx, ses.ID, msg.ID, part.ID); err != nil { + t.Fatal(err) + } + after, _ = s.GetSession(ctx, ses.ID) + if after.Cost != 0 || after.Tokens.Input != 0 || after.Tokens.Cache.Read != 0 { + t.Fatalf("remove did not reverse usage: %+v", after) + } + stepFinish(t, s, ses.ID, msg.ID, 1, 7) + if err := s.RemoveMessage(ctx, ses.ID, msg.ID); err != nil { + t.Fatal(err) + } + after, _ = s.GetSession(ctx, ses.ID) + if after.Cost != 0 || after.Tokens.Input != 0 { + t.Fatalf("remove message did not reverse usage: %+v", after) + } + if _, err := s.GetMessage(ctx, ses.ID, msg.ID); !sessiondb.IsNotFound(err) || err.Error() != "Message not found: "+msg.ID { + t.Fatalf("got %v", err) + } +} + +func TestPartsOrderedByIDAndTextPartIgnoredForUsage(t *testing.T) { + s := newStore(t) + ses := create(t, s, sessiondb.CreateSessionInput{}) + msg := userMessage(t, s, ses.ID, 1000) + first, _ := s.UpsertPart(ctx, ses.ID, msg.ID, json.RawMessage(`{"type":"text","text":"a"}`), 5) + second, _ := s.UpsertPart(ctx, ses.ID, msg.ID, json.RawMessage(`{"type":"text","text":"b"}`), 1) + got, _ := s.GetMessage(ctx, ses.ID, msg.ID) + if len(got.Parts) != 2 || got.Parts[0].ID != first.ID || got.Parts[1].ID != second.ID { + t.Fatalf("%+v", got.Parts) + } + after, _ := s.GetSession(ctx, ses.ID) + if after.Cost != 0 { + t.Fatal("text parts must not touch usage") + } +} + +func TestPageMessagesCursor(t *testing.T) { + s := newStore(t) + ses := create(t, s, sessiondb.CreateSessionInput{}) + var created []string + for i := 0; i < 5; i++ { + created = append(created, userMessage(t, s, ses.ID, int64(1000+i)).ID) + } + page, err := s.PageMessages(ctx, ses.ID, 2, "") + if err != nil || !page.More || len(page.Items) != 2 || page.Items[0].Info.ID != created[3] || page.Items[1].Info.ID != created[4] { + t.Fatalf("%+v %v", page, err) + } + page2, err := s.PageMessages(ctx, ses.ID, 2, page.Cursor) + if err != nil || !page2.More || page2.Items[0].Info.ID != created[1] || page2.Items[1].Info.ID != created[2] { + t.Fatalf("%+v %v", page2, err) + } + page3, err := s.PageMessages(ctx, ses.ID, 2, page2.Cursor) + if err != nil || page3.More || len(page3.Items) != 1 || page3.Items[0].Info.ID != created[0] || page3.Cursor != "" { + t.Fatalf("%+v %v", page3, err) + } + all, err := s.AllMessages(ctx, ses.ID) + if err != nil || len(all) != 5 || all[0].Info.ID != created[0] || all[4].Info.ID != created[4] { + t.Fatalf("%v %v", err, all) + } +} + +func TestPageMessagesSameTimeTieBreakOnID(t *testing.T) { + s := newStore(t) + ses := create(t, s, sessiondb.CreateSessionInput{}) + a := userMessage(t, s, ses.ID, 1000) + b := userMessage(t, s, ses.ID, 1000) + page, _ := s.PageMessages(ctx, ses.ID, 1, "") + if page.Items[0].Info.ID != b.ID { + t.Fatalf("newest by id first, got %s", page.Items[0].Info.ID) + } + page2, _ := s.PageMessages(ctx, ses.ID, 1, page.Cursor) + if page2.Items[0].Info.ID != a.ID { + t.Fatalf("got %s", page2.Items[0].Info.ID) + } +} + +func TestPageMessagesUnknownSessionIs404AndEmptySessionIsEmpty(t *testing.T) { + s := newStore(t) + if _, err := s.PageMessages(ctx, "ses_nope", 10, ""); !sessiondb.IsNotFound(err) || err.Error() != "Session not found: ses_nope" { + t.Fatalf("got %v", err) + } + ses := create(t, s, sessiondb.CreateSessionInput{}) + page, err := s.PageMessages(ctx, ses.ID, 10, "") + if err != nil || page.More || len(page.Items) != 0 { + t.Fatalf("%+v %v", page, err) + } +} + +func TestCursorRoundTrip(t *testing.T) { + c := sessiondb.EncodeCursor("msg_abc", 1234) + id, tm, err := sessiondb.DecodeCursor(c) + if err != nil || id != "msg_abc" || tm != 1234 { + t.Fatalf("%s %d %v", id, tm, err) + } + if _, _, err := sessiondb.DecodeCursor("!!"); err == nil { + t.Fatal("expected error") + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd go-core && go test ./internal/sessiondb/ -run 'Message|Part|Cursor'` +Expected: FAIL, `undefined: UpsertMessage` and friends. + +- [ ] **Step 3: Implement `message.go`** + +```go +package sessiondb + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "time" + + "github.com/ElioNeto/teamcode/go-core/internal/ident" +) + +var ErrLateWrite = errors.New("late write for a deleted session") + +const clockGuardMs = 5000 + +type MessageWithParts struct { + Info Message `json:"info"` + Parts []Part `json:"parts"` +} + +type MessagePage struct { + Items []MessageWithParts `json:"messages"` + More bool `json:"more"` + Cursor string `json:"cursor,omitempty"` +} + +type usage struct { + cost float64 + input, output, reasoning, cacheRead, cacheWrite int64 +} + +func usageOf(data json.RawMessage) (usage, bool) { + var probe struct { + Type string `json:"type"` + Cost *float64 `json:"cost"` + Tokens *struct { + Input int64 `json:"input"` + Output int64 `json:"output"` + Reasoning int64 `json:"reasoning"` + Cache struct { + Read int64 `json:"read"` + Write int64 `json:"write"` + } `json:"cache"` + } `json:"tokens"` + } + if err := json.Unmarshal(data, &probe); err != nil || probe.Type != "step-finish" || probe.Cost == nil || probe.Tokens == nil { + return usage{}, false + } + return usage{cost: *probe.Cost, input: probe.Tokens.Input, output: probe.Tokens.Output, reasoning: probe.Tokens.Reasoning, + cacheRead: probe.Tokens.Cache.Read, cacheWrite: probe.Tokens.Cache.Write}, true +} + +func applyUsage(ctx context.Context, tx *sql.Tx, sessionID string, u usage, sign float64) error { + _, err := tx.ExecContext(ctx, `UPDATE session SET cost = cost + ?, tokens_input = tokens_input + ?, tokens_output = tokens_output + ?, + tokens_reasoning = tokens_reasoning + ?, tokens_cache_read = tokens_cache_read + ?, tokens_cache_write = tokens_cache_write + ?, + time_updated = time_updated WHERE id = ?`, + u.cost*sign, int64(sign)*u.input, int64(sign)*u.output, int64(sign)*u.reasoning, int64(sign)*u.cacheRead, int64(sign)*u.cacheWrite, sessionID) + return err +} + +func isForeignKeyFailure(err error) bool { + return err != nil && strings.Contains(err.Error(), "FOREIGN KEY constraint failed") +} + +func timeCreatedOf(data json.RawMessage) int64 { + var probe struct { + Time struct { + Created int64 `json:"created"` + } `json:"time"` + } + _ = json.Unmarshal(data, &probe) + return probe.Time.Created +} + +func withTimeCreated(data json.RawMessage, created int64) (json.RawMessage, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err != nil { + return nil, err + } + var tm map[string]json.RawMessage + if raw, ok := obj["time"]; ok { + _ = json.Unmarshal(raw, &tm) + } + if tm == nil { + tm = map[string]json.RawMessage{} + } + tm["created"] = json.RawMessage(fmt.Sprint(created)) + encodedTime, _ := json.Marshal(tm) + obj["time"] = encodedTime + return json.Marshal(obj) +} + +func (s *Store) UpsertMessage(ctx context.Context, sessionID string, body json.RawMessage) (Message, error) { + ids, data, err := splitIDs(body, "id", "sessionID") + if err != nil { + return Message{}, err + } + id := ids["id"] + if id == "" { + id = ident.Message() + } else if !ident.HasPrefix(id, ident.PrefixMessage) { + return Message{}, fmt.Errorf("ID %s does not start with msg", id) + } + created := timeCreatedOf(data) + if created == 0 { + created = time.Now().UnixMilli() + } + var maxExisting sql.NullInt64 + if err := s.db.Reader().QueryRowContext(ctx, `SELECT max(time_created) FROM message WHERE session_id = ?`, sessionID).Scan(&maxExisting); err != nil { + return Message{}, err + } + if maxExisting.Valid && maxExisting.Int64 > time.Now().UnixMilli()+clockGuardMs { + created = maxExisting.Int64 + 1 + } + if data, err = withTimeCreated(data, created); err != nil { + return Message{}, err + } + err = s.db.ExecWrite(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET data = excluded.data`, id, sessionID, created, created, string(data)) + return err + }) + if isForeignKeyFailure(err) { + log.Printf("sessiondb: ignored late message update message=%s session=%s", id, sessionID) + return Message{}, ErrLateWrite + } + if err != nil { + return Message{}, err + } + row := s.db.Reader().QueryRowContext(ctx, `SELECT time_created FROM message WHERE id = ?`, id) + var storedCreated int64 + if err := row.Scan(&storedCreated); err != nil { + return Message{}, err + } + return Message{ID: id, SessionID: sessionID, TimeCreated: storedCreated, Data: data}, nil +} + +func (s *Store) RemoveMessage(ctx context.Context, sessionID, messageID string) error { + return s.db.ExecWrite(ctx, func(tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `SELECT data FROM part WHERE message_id = ? AND session_id = ?`, messageID, sessionID) + if err != nil { + return err + } + var previous []usage + for rows.Next() { + var data string + if err := rows.Scan(&data); err != nil { + rows.Close() + return err + } + if u, ok := usageOf(json.RawMessage(data)); ok { + previous = append(previous, u) + } + } + rows.Close() + for _, u := range previous { + if err := applyUsage(ctx, tx, sessionID, u, -1); err != nil { + return err + } + } + _, err = tx.ExecContext(ctx, `DELETE FROM message WHERE id = ? AND session_id = ?`, messageID, sessionID) + return err + }) +} + +func (s *Store) UpsertPart(ctx context.Context, sessionID, messageID string, body json.RawMessage, timeCreated int64) (Part, error) { + ids, data, err := splitIDs(body, "id", "sessionID", "messageID") + if err != nil { + return Part{}, err + } + id := ids["id"] + if id == "" { + id = ident.Part() + } else if !ident.HasPrefix(id, ident.PrefixPart) { + return Part{}, fmt.Errorf("ID %s does not start with prt", id) + } + err = s.db.ExecWrite(ctx, func(tx *sql.Tx) error { + var previousData sql.NullString + var previousSession sql.NullString + err := tx.QueryRowContext(ctx, `SELECT data, session_id FROM part WHERE id = ?`, id).Scan(&previousData, &previousSession) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET data = excluded.data`, id, messageID, sessionID, timeCreated, timeCreated, string(data)) + if err != nil { + return err + } + if previousData.Valid { + if u, ok := usageOf(json.RawMessage(previousData.String)); ok { + if err := applyUsage(ctx, tx, previousSession.String, u, -1); err != nil { + return err + } + } + } + if u, ok := usageOf(data); ok { + return applyUsage(ctx, tx, sessionID, u, 1) + } + return nil + }) + if isForeignKeyFailure(err) { + log.Printf("sessiondb: ignored late part update part=%s message=%s session=%s", id, messageID, sessionID) + return Part{}, ErrLateWrite + } + if err != nil { + return Part{}, err + } + return s.GetPart(ctx, sessionID, messageID, id) +} + +func scanPart(r rowScanner) (Part, error) { + var p Part + var data string + if err := r.Scan(&p.ID, &p.MessageID, &p.SessionID, &p.TimeCreated, &data); err != nil { + return Part{}, err + } + p.Data = json.RawMessage(data) + return p, nil +} + +func (s *Store) GetPart(ctx context.Context, sessionID, messageID, partID string) (Part, error) { + row := s.db.Reader().QueryRowContext(ctx, `SELECT id, message_id, session_id, time_created, data FROM part WHERE id = ? AND message_id = ? AND session_id = ?`, partID, messageID, sessionID) + p, err := scanPart(row) + if errors.Is(err, sql.ErrNoRows) { + return Part{}, ErrNotFound{Kind: "Part", ID: partID} + } + return p, err +} + +func (s *Store) RemovePart(ctx context.Context, sessionID, messageID, partID string) error { + return s.db.ExecWrite(ctx, func(tx *sql.Tx) error { + var data sql.NullString + err := tx.QueryRowContext(ctx, `SELECT data FROM part WHERE id = ? AND session_id = ?`, partID, sessionID).Scan(&data) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if data.Valid { + if u, ok := usageOf(json.RawMessage(data.String)); ok { + if err := applyUsage(ctx, tx, sessionID, u, -1); err != nil { + return err + } + } + } + _, err = tx.ExecContext(ctx, `DELETE FROM part WHERE id = ? AND session_id = ?`, partID, sessionID) + return err + }) +} + +func EncodeCursor(id string, timeCreated int64) string { + body, _ := json.Marshal(map[string]any{"id": id, "time": timeCreated}) + return base64.RawURLEncoding.EncodeToString(body) +} + +func DecodeCursor(cursor string) (string, int64, error) { + body, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return "", 0, err + } + var c struct { + ID string `json:"id"` + Time int64 `json:"time"` + } + if err := json.Unmarshal(body, &c); err != nil { + return "", 0, err + } + if c.ID == "" || c.Time < 0 { + return "", 0, errors.New("malformed cursor") + } + return c.ID, c.Time, nil +} + +func (s *Store) hydrate(ctx context.Context, messages []Message) ([]MessageWithParts, error) { + out := make([]MessageWithParts, len(messages)) + if len(messages) == 0 { + return out, nil + } + placeholders := make([]string, len(messages)) + args := make([]any, len(messages)) + index := map[string]int{} + for i, m := range messages { + placeholders[i] = "?" + args[i] = m.ID + index[m.ID] = i + out[i] = MessageWithParts{Info: m, Parts: []Part{}} + } + rows, err := s.db.Reader().QueryContext(ctx, `SELECT id, message_id, session_id, time_created, data FROM part WHERE message_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY message_id, id`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + p, err := scanPart(rows) + if err != nil { + return nil, err + } + i := index[p.MessageID] + out[i].Parts = append(out[i].Parts, p) + } + return out, rows.Err() +} + +func scanMessages(rows *sql.Rows) ([]Message, error) { + var out []Message + for rows.Next() { + var m Message + var data string + if err := rows.Scan(&m.ID, &m.SessionID, &m.TimeCreated, &data); err != nil { + return nil, err + } + m.Data = json.RawMessage(data) + out = append(out, m) + } + return out, rows.Err() +} + +func (s *Store) PageMessages(ctx context.Context, sessionID string, limit int, before string) (MessagePage, error) { + if limit <= 0 { + limit = 50 + } + query := `SELECT id, session_id, time_created, data FROM message WHERE session_id = ?` + args := []any{sessionID} + if before != "" { + id, tm, err := DecodeCursor(before) + if err != nil { + return MessagePage{}, err + } + query += ` AND (time_created < ? OR (time_created = ? AND id < ?))` + args = append(args, tm, tm, id) + } + query += ` ORDER BY time_created DESC, id DESC LIMIT ?` + args = append(args, limit+1) + rows, err := s.db.Reader().QueryContext(ctx, query, args...) + if err != nil { + return MessagePage{}, err + } + messages, err := scanMessages(rows) + rows.Close() + if err != nil { + return MessagePage{}, err + } + if len(messages) == 0 { + if _, err := s.GetSession(ctx, sessionID); err != nil { + return MessagePage{}, err + } + return MessagePage{Items: []MessageWithParts{}}, nil + } + more := len(messages) > limit + if more { + messages = messages[:limit] + } + items, err := s.hydrate(ctx, messages) + if err != nil { + return MessagePage{}, err + } + for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 { + items[i], items[j] = items[j], items[i] + } + page := MessagePage{Items: items, More: more} + if more { + tail := messages[len(messages)-1] + page.Cursor = EncodeCursor(tail.ID, tail.TimeCreated) + } + return page, nil +} + +func (s *Store) AllMessages(ctx context.Context, sessionID string) ([]MessageWithParts, error) { + rows, err := s.db.Reader().QueryContext(ctx, `SELECT id, session_id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC`, sessionID) + if err != nil { + return nil, err + } + messages, err := scanMessages(rows) + rows.Close() + if err != nil { + return nil, err + } + if len(messages) == 0 { + if _, err := s.GetSession(ctx, sessionID); err != nil { + return nil, err + } + } + return s.hydrate(ctx, messages) +} + +func (s *Store) GetMessage(ctx context.Context, sessionID, messageID string) (MessageWithParts, error) { + rows, err := s.db.Reader().QueryContext(ctx, `SELECT id, session_id, time_created, data FROM message WHERE id = ? AND session_id = ?`, messageID, sessionID) + if err != nil { + return MessageWithParts{}, err + } + messages, err := scanMessages(rows) + rows.Close() + if err != nil { + return MessageWithParts{}, err + } + if len(messages) == 0 { + return MessageWithParts{}, ErrNotFound{Kind: "Message", ID: messageID} + } + items, err := s.hydrate(ctx, messages) + if err != nil { + return MessageWithParts{}, err + } + return items[0], nil +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd go-core && gofmt -l ./internal/sessiondb && go vet ./internal/sessiondb/ && go test ./internal/sessiondb/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add go-core/internal/sessiondb +git commit -m "feat(go-core): message and part persistence with usage deltas and keyset pagination" +``` + +--- + +### Task 6: Todos and fork (`internal/sessiondb`, part 3) + +**Files:** + +- Create: `go-core/internal/sessiondb/todo.go` +- Create: `go-core/internal/sessiondb/fork.go` +- Test: `go-core/internal/sessiondb/todo_test.go`, `go-core/internal/sessiondb/fork_test.go` + +**Interfaces:** + +- Consumes: `Store`, `CreateSessionInput`, `AllMessages`, `UpsertMessage`, `UpsertPart`, `ident.Message`, `ident.Part`. +- Produces: `Store.GetTodos(ctx, sessionID) ([]Todo, error)`, `Store.ReplaceTodos(ctx, sessionID string, todos []Todo) error`, `Store.Fork(ctx, sessionID string, beforeMessageID string, directory, path, version string) (ForkResult, error)` with `type ForkResult struct { Session Session; Messages []MessageWithParts }` (the copied messages, so the HTTP layer can emit `message.updated` and `message.part.updated`). + +- [ ] **Step 1: Write the failing todo test** + +`go-core/internal/sessiondb/todo_test.go`: + +```go +package sessiondb_test + +import ( + "testing" + + "github.com/ElioNeto/teamcode/go-core/internal/sessiondb" +) + +func TestReplaceTodosKeepsPositionOrder(t *testing.T) { + s := newStore(t) + ses := create(t, s, sessiondb.CreateSessionInput{}) + todos := []sessiondb.Todo{{Content: "b", Status: "pending", Priority: "high"}, {Content: "a", Status: "completed", Priority: "low"}} + if err := s.ReplaceTodos(ctx, ses.ID, todos); err != nil { + t.Fatal(err) + } + got, err := s.GetTodos(ctx, ses.ID) + if err != nil || len(got) != 2 || got[0].Content != "b" || got[1].Content != "a" { + t.Fatalf("%v %v", got, err) + } + if err := s.ReplaceTodos(ctx, ses.ID, nil); err != nil { + t.Fatal(err) + } + got, _ = s.GetTodos(ctx, ses.ID) + if len(got) != 0 { + t.Fatalf("expected empty, got %v", got) + } +} +``` + +- [ ] **Step 2: Implement `todo.go`** + +```go +package sessiondb + +import ( + "context" + "database/sql" + "time" +) + +func (s *Store) GetTodos(ctx context.Context, sessionID string) ([]Todo, error) { + rows, err := s.db.Reader().QueryContext(ctx, `SELECT content, status, priority FROM todo WHERE session_id = ? ORDER BY position ASC`, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Todo{} + for rows.Next() { + var t Todo + if err := rows.Scan(&t.Content, &t.Status, &t.Priority); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *Store) ReplaceTodos(ctx context.Context, sessionID string, todos []Todo) error { + now := time.Now().UnixMilli() + return s.db.ExecWrite(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM todo WHERE session_id = ?`, sessionID); err != nil { + return err + } + for position, t := range todos { + _, err := tx.ExecContext(ctx, `INSERT INTO todo (session_id, content, status, priority, position, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)`, + sessionID, t.Content, t.Status, t.Priority, position, now, now) + if err != nil { + return err + } + } + return nil + }) +} +``` + +- [ ] **Step 3: Write the failing fork test** + +`go-core/internal/sessiondb/fork_test.go`: + +```go +package sessiondb_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/ElioNeto/teamcode/go-core/internal/sessiondb" +) + +func TestForkCopiesMessagesBeforeCutoffWithNewIDs(t *testing.T) { + s := newStore(t) + original := create(t, s, sessiondb.CreateSessionInput{Title: strp("Work")}) + first := userMessage(t, s, original.ID, 1000) + assistantBody := fmt.Sprintf(`{"role":"assistant","parentID":"%s","modelID":"m","providerID":"p","mode":"build","agent":"build","path":{"cwd":"/","root":"/"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1001}}`, first.ID) + second, err := s.UpsertMessage(ctx, original.ID, json.RawMessage(assistantBody)) + if err != nil { + t.Fatal(err) + } + compaction := fmt.Sprintf(`{"type":"compaction","auto":true,"tail_start_id":"%s"}`, first.ID) + if _, err := s.UpsertPart(ctx, original.ID, second.ID, json.RawMessage(compaction), 1); err != nil { + t.Fatal(err) + } + third := userMessage(t, s, original.ID, 1002) + + result, err := s.Fork(ctx, original.ID, third.ID, "/tmp/dir", "", "test") + if err != nil { + t.Fatal(err) + } + if result.Session.Title != "Work (fork #1)" || result.Session.ID == original.ID { + t.Fatalf("%+v", result.Session) + } + if len(result.Messages) != 2 { + t.Fatalf("copied %d messages", len(result.Messages)) + } + copiedFirst, copiedSecond := result.Messages[0].Info, result.Messages[1].Info + if copiedFirst.ID == first.ID || copiedFirst.SessionID != result.Session.ID { + t.Fatalf("%+v", copiedFirst) + } + var assistant map[string]any + _ = json.Unmarshal(copiedSecond.Data, &assistant) + if assistant["parentID"] != copiedFirst.ID { + t.Fatalf("parentID not remapped: %v", assistant["parentID"]) + } + var part map[string]any + _ = json.Unmarshal(result.Messages[1].Parts[0].Data, &part) + if part["tail_start_id"] != copiedFirst.ID { + t.Fatalf("tail_start_id not remapped: %v", part["tail_start_id"]) + } + again, _ := s.Fork(ctx, result.Session.ID, "", "/tmp/dir", "", "test") + if again.Session.Title != "Work (fork #2)" { + t.Fatalf("%q", again.Session.Title) + } +} +``` + +- [ ] **Step 4: Implement `fork.go`** + +```go +package sessiondb + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strconv" + + "github.com/ElioNeto/teamcode/go-core/internal/ident" +) + +type ForkResult struct { + Session Session + Messages []MessageWithParts +} + +var forkTitle = regexp.MustCompile(`^(.+) \(fork #(\d+)\)$`) + +func forkedTitle(title string) string { + if m := forkTitle.FindStringSubmatch(title); m != nil { + n, _ := strconv.Atoi(m[2]) + return fmt.Sprintf("%s (fork #%d)", m[1], n+1) + } + return title + " (fork #1)" +} + +func replaceStringField(data json.RawMessage, field string, mapping map[string]string) json.RawMessage { + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err != nil { + return data + } + raw, ok := obj[field] + if !ok { + return data + } + var current string + if err := json.Unmarshal(raw, ¤t); err != nil { + return data + } + replacement, ok := mapping[current] + if !ok { + return data + } + quoted, _ := json.Marshal(replacement) + obj[field] = quoted + out, _ := json.Marshal(obj) + return out +} + +func (s *Store) Fork(ctx context.Context, sessionID, beforeMessageID, directory, path, version string) (ForkResult, error) { + original, err := s.GetSession(ctx, sessionID) + if err != nil { + return ForkResult{}, err + } + title := forkedTitle(original.Title) + var pathPtr *string + if path != "" { + pathPtr = &path + } + created, err := s.CreateSession(ctx, CreateSessionInput{ + ProjectID: original.ProjectID, WorkspaceID: original.WorkspaceID, Directory: directory, Path: pathPtr, Title: &title, Version: version, + }) + if err != nil { + return ForkResult{}, err + } + source, err := s.AllMessages(ctx, sessionID) + if err != nil { + return ForkResult{}, err + } + idMap := map[string]string{} + var copied []MessageWithParts + for _, item := range source { + if beforeMessageID != "" && item.Info.ID >= beforeMessageID { + break + } + newID := ident.Message() + idMap[item.Info.ID] = newID + data := replaceStringField(item.Info.Data, "parentID", idMap) + body, err := mergeIDs(data, map[string]string{"id": newID}) + if err != nil { + return ForkResult{}, err + } + message, err := s.UpsertMessage(ctx, created.ID, body) + if err != nil { + return ForkResult{}, err + } + with := MessageWithParts{Info: message, Parts: []Part{}} + for _, part := range item.Parts { + partData := replaceStringField(part.Data, "tail_start_id", idMap) + partBody, err := mergeIDs(partData, map[string]string{"id": ident.Part()}) + if err != nil { + return ForkResult{}, err + } + stored, err := s.UpsertPart(ctx, created.ID, message.ID, partBody, part.TimeCreated) + if err != nil { + return ForkResult{}, err + } + with.Parts = append(with.Parts, stored) + } + copied = append(copied, with) + } + final, err := s.GetSession(ctx, created.ID) + if err != nil { + return ForkResult{}, err + } + return ForkResult{Session: final, Messages: copied}, nil +} +``` + +`item.Info.ID >= beforeMessageID` is a string comparison, valid because message ids are ascending (`session.ts:782` does the same). + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd go-core && gofmt -l ./internal/sessiondb && go vet ./internal/sessiondb/ && go test ./internal/sessiondb/` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add go-core/internal/sessiondb +git commit -m "feat(go-core): todo replacement and session fork" +``` + +--- + +### Task 7: Sequenced event log (`internal/eventlog`) + +**Files:** + +- Create: `go-core/internal/eventlog/eventlog.go` +- Test: `go-core/internal/eventlog/eventlog_test.go` + +**Interfaces:** + +- Consumes: `ident.Event`. +- Produces: `eventlog.Event{ID string; Seq uint64; Type string; SessionID string; Data json.RawMessage; Timestamp int64}`; `eventlog.New(perSessionRing, globalRing int) *Log`; `Log.Publish(eventType, sessionID string, data any) Event`; `Log.Subscribe(sessionID string, afterSeq uint64, buffer int) (*Subscription, error)` where `sessionID == ""` means the global stream, `afterSeq == 0` means live only, and `ErrReplayUnavailable` is returned when `afterSeq` is older than the ring; `Subscription.Events() <-chan Event`, `Subscription.Lagged() <-chan struct{}`, `Subscription.Close()`; `Event.SSE() []byte` producing `event: \nid: \ndata: \n\n`. + +- [ ] **Step 1: Write the failing tests** + +`go-core/internal/eventlog/eventlog_test.go`: + +```go +package eventlog + +import ( + "errors" + "strings" + "testing" + "time" +) + +func recv(t *testing.T, sub *Subscription) Event { + t.Helper() + select { + case ev := <-sub.Events(): + return ev + case <-time.After(time.Second): + t.Fatal("timeout waiting for event") + return Event{} + } +} + +func TestSeqIsMonotonicPerSessionAndGlobal(t *testing.T) { + l := New(10, 10) + a1 := l.Publish("session.updated", "ses_a", map[string]int{"n": 1}) + b1 := l.Publish("session.updated", "ses_b", map[string]int{"n": 1}) + a2 := l.Publish("session.updated", "ses_a", map[string]int{"n": 2}) + if a1.Seq != 1 || a2.Seq != 2 || b1.Seq != 1 { + t.Fatalf("per-session seq: %d %d %d", a1.Seq, a2.Seq, b1.Seq) + } + if !strings.HasPrefix(a1.ID, "evt_") || a1.ID >= a2.ID { + t.Fatalf("ids not ascending: %s %s", a1.ID, a2.ID) + } +} + +func TestLiveSubscriptionReceivesOnlyItsSession(t *testing.T) { + l := New(10, 10) + sub, err := l.Subscribe("ses_a", 0, 8) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + l.Publish("x", "ses_b", nil) + l.Publish("y", "ses_a", nil) + if ev := recv(t, sub); ev.Type != "y" { + t.Fatalf("got %s", ev.Type) + } +} + +func TestGlobalSubscriptionSeesEverything(t *testing.T) { + l := New(10, 10) + sub, _ := l.Subscribe("", 0, 8) + defer sub.Close() + l.Publish("x", "ses_b", nil) + l.Publish("y", "ses_a", nil) + if recv(t, sub).Type != "x" || recv(t, sub).Type != "y" { + t.Fatal("global order broken") + } +} + +func TestReplayFromRing(t *testing.T) { + l := New(10, 10) + for i := 0; i < 5; i++ { + l.Publish("x", "ses_a", map[string]int{"i": i}) + } + sub, err := l.Subscribe("ses_a", 3, 8) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + if recv(t, sub).Seq != 4 || recv(t, sub).Seq != 5 { + t.Fatal("replay order broken") + } + l.Publish("x", "ses_a", nil) + if recv(t, sub).Seq != 6 { + t.Fatal("live after replay broken") + } +} + +func TestReplayUnavailableWhenRingOverflowed(t *testing.T) { + l := New(3, 3) + for i := 0; i < 10; i++ { + l.Publish("x", "ses_a", nil) + } + _, err := l.Subscribe("ses_a", 2, 8) + if !errors.Is(err, ErrReplayUnavailable) { + t.Fatalf("got %v", err) + } +} + +func TestSlowSubscriberIsLaggedNotDropped(t *testing.T) { + l := New(100, 100) + sub, _ := l.Subscribe("ses_a", 0, 2) + defer sub.Close() + for i := 0; i < 5; i++ { + l.Publish("x", "ses_a", nil) + } + select { + case <-sub.Lagged(): + case <-time.After(time.Second): + t.Fatal("expected lagged signal") + } +} + +func TestSSEFrame(t *testing.T) { + ev := Event{Seq: 7, Type: "session.updated", Data: []byte(`{"a":1}`)} + got := string(ev.SSE()) + if got != "event: session.updated\nid: 7\ndata: {\"a\":1}\n\n" { + t.Fatalf("%q", got) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd go-core && go test ./internal/eventlog/` +Expected: FAIL, `undefined: New`. + +- [ ] **Step 3: Implement `eventlog.go`** + +```go +package eventlog + +import ( + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/ElioNeto/teamcode/go-core/internal/ident" +) + +var ErrReplayUnavailable = errors.New("replay_unavailable") + +type Event struct { + ID string `json:"id"` + Seq uint64 `json:"seq"` + Type string `json:"type"` + SessionID string `json:"sessionID,omitempty"` + Data json.RawMessage `json:"data"` + Timestamp int64 `json:"timestamp"` +} + +func (e Event) SSE() []byte { + data := e.Data + if len(data) == 0 { + data = json.RawMessage("{}") + } + return []byte(fmt.Sprintf("event: %s\nid: %d\ndata: %s\n\n", e.Type, e.Seq, data)) +} + +type ring struct { + size int + items []Event + nextSeq uint64 +} + +func newRing(size int) *ring { return &ring{size: size, nextSeq: 1} } + +func (r *ring) append(ev Event) Event { + ev.Seq = r.nextSeq + r.nextSeq++ + r.items = append(r.items, ev) + if len(r.items) > r.size { + r.items = r.items[len(r.items)-r.size:] + } + return ev +} + +func (r *ring) after(seq uint64) ([]Event, error) { + if len(r.items) == 0 { + if seq >= r.nextSeq-1 { + return nil, nil + } + return nil, ErrReplayUnavailable + } + oldest := r.items[0].Seq + if seq+1 < oldest { + return nil, ErrReplayUnavailable + } + var out []Event + for _, ev := range r.items { + if ev.Seq > seq { + out = append(out, ev) + } + } + return out, nil +} + +type Subscription struct { + sessionID string + events chan Event + lagged chan struct{} + closeOnce sync.Once + done chan struct{} +} + +func (s *Subscription) Events() <-chan Event { return s.events } +func (s *Subscription) Lagged() <-chan struct{} { return s.lagged } +func (s *Subscription) Close() { + s.closeOnce.Do(func() { close(s.done) }) +} + +func (s *Subscription) deliver(ev Event) bool { + select { + case <-s.done: + return false + default: + } + select { + case s.events <- ev: + return true + default: + select { + case s.lagged <- struct{}{}: + default: + } + s.Close() + return false + } +} + +type Log struct { + mu sync.Mutex + perSession map[string]*ring + global *ring + ringSize int + subscribers map[*Subscription]struct{} +} + +func New(perSessionRing, globalRing int) *Log { + return &Log{perSession: map[string]*ring{}, global: newRing(globalRing), ringSize: perSessionRing, subscribers: map[*Subscription]struct{}{}} +} + +func (l *Log) sessionRing(sessionID string) *ring { + r, ok := l.perSession[sessionID] + if !ok { + r = newRing(l.ringSize) + l.perSession[sessionID] = r + } + return r +} + +func (l *Log) Publish(eventType, sessionID string, data any) Event { + body, err := json.Marshal(data) + if err != nil || data == nil { + body = json.RawMessage("{}") + } + l.mu.Lock() + defer l.mu.Unlock() + ev := Event{ID: ident.Event(), Type: eventType, SessionID: sessionID, Data: body, Timestamp: time.Now().UnixMilli()} + stored := l.sessionRing(sessionID).append(ev) + globalCopy := l.global.append(ev) + for sub := range l.subscribers { + var toDeliver Event + switch sub.sessionID { + case "": + toDeliver = globalCopy + case sessionID: + toDeliver = stored + default: + continue + } + if !sub.deliver(toDeliver) { + delete(l.subscribers, sub) + } + } + return stored +} + +func (l *Log) Subscribe(sessionID string, afterSeq uint64, buffer int) (*Subscription, error) { + l.mu.Lock() + defer l.mu.Unlock() + source := l.global + if sessionID != "" { + source = l.sessionRing(sessionID) + } + var backlog []Event + if afterSeq > 0 { + var err error + if backlog, err = source.after(afterSeq); err != nil { + return nil, err + } + } + if buffer < len(backlog)+1 { + buffer = len(backlog) + 1 + } + sub := &Subscription{sessionID: sessionID, events: make(chan Event, buffer), lagged: make(chan struct{}, 1), done: make(chan struct{})} + for _, ev := range backlog { + sub.events <- ev + } + l.subscribers[sub] = struct{}{} + return sub, nil +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd go-core && gofmt -l ./internal/eventlog && go vet ./internal/eventlog/ && go test -race ./internal/eventlog/` +Expected: PASS with the race detector. + +- [ ] **Step 5: Commit** + +```bash +git add go-core/internal/eventlog +git commit -m "feat(go-core): sequenced event log with replay ring and lag signalling" +``` + +--- + +### Task 8: `/v1/session` HTTP handlers and degraded mode + +**Files:** + +- Create: `go-core/cmd/server/v1_routes.go` +- Create: `go-core/cmd/server/v1_session_handlers.go` +- Modify: `go-core/cmd/server/main.go` (call `registerV1Routes(mux)` after the existing routes; open the store at startup) +- Modify: `go-core/cmd/server/health.go` (degraded status) +- Test: `go-core/cmd/server/v1_session_handlers_test.go` + +**Interfaces:** + +- Consumes: `store.ResolvePath`, `store.Open`, `store.CheckSchema`, `store.ErrSchemaOutdated`, `store.IsBusy`, `sessiondb.*`, `eventlog.*`, `writeError` (`main.go:251`), `writeJSON` (`fs_handlers.go:586`). +- Produces: package-level `v1 *v1State` with `store *sessiondb.Store`, `events *eventlog.Log`, `schemaErr error`; `registerV1Routes(mux *http.ServeMux)`; `newV1State(dbPath string) *v1State` (used by tests with a `testdb` file); event names published per spec §5.2. + +- [ ] **Step 1: Write the failing handler tests** + +`go-core/cmd/server/v1_session_handlers_test.go`: + +```go +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ElioNeto/teamcode/go-core/internal/store/testdb" +) + +func v1Server(t *testing.T) *httptest.Server { + t.Helper() + db := testdb.New(t) + testdb.SeedProject(t, db, "prj_test") + path := db.Path() + _ = db.Close() + state, err := newV1State(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = state.Close() }) + mux := http.NewServeMux() + state.register(mux) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func call(t *testing.T, srv *httptest.Server, method, path string, body any) (int, []byte) { + t.Helper() + var reader io.Reader + if body != nil { + raw, _ := json.Marshal(body) + reader = bytes.NewReader(raw) + } + req, _ := http.NewRequest(method, srv.URL+path, reader) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + out, _ := io.ReadAll(resp.Body) + return resp.StatusCode, out +} + +func decode(t *testing.T, raw []byte) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("%v: %s", err, raw) + } + return m +} + +func newSession(t *testing.T, srv *httptest.Server) map[string]any { + t.Helper() + code, raw := call(t, srv, "POST", "/v1/session", map[string]any{"projectID": "prj_test", "directory": "/tmp/d", "version": "t"}) + if code != 201 { + t.Fatalf("%d %s", code, raw) + } + return decode(t, raw) +} + +func TestV1SessionLifecycle(t *testing.T) { + srv := v1Server(t) + ses := newSession(t, srv) + id := ses["id"].(string) + code, raw := call(t, srv, "GET", "/v1/session/"+id, nil) + if code != 200 || decode(t, raw)["id"] != id { + t.Fatalf("%d %s", code, raw) + } + code, raw = call(t, srv, "PATCH", "/v1/session/"+id, map[string]any{"title": "renamed"}) + if code != 200 || decode(t, raw)["title"] != "renamed" { + t.Fatalf("%d %s", code, raw) + } + code, raw = call(t, srv, "GET", "/v1/session?projectID=prj_test", nil) + var list []map[string]any + _ = json.Unmarshal(raw, &list) + if code != 200 || len(list) != 1 || list[0]["title"] != "renamed" { + t.Fatalf("%d %s", code, raw) + } + code, _ = call(t, srv, "DELETE", "/v1/session/"+id, nil) + if code != 204 { + t.Fatalf("delete %d", code) + } + code, raw = call(t, srv, "GET", "/v1/session/"+id, nil) + if code != 404 || decode(t, raw)["error"] != "Session not found: "+id { + t.Fatalf("%d %s", code, raw) + } +} + +func TestV1MessagesAndParts(t *testing.T) { + srv := v1Server(t) + ses := newSession(t, srv) + id := ses["id"].(string) + msgBody := map[string]any{"role": "user", "agent": "build", "model": map[string]string{"providerID": "p", "modelID": "m"}, "time": map[string]int64{"created": 1000}} + code, raw := call(t, srv, "POST", "/v1/session/"+id+"/message", msgBody) + if code != 200 { + t.Fatalf("%d %s", code, raw) + } + msg := decode(t, raw) + msgID := msg["id"].(string) + partBody := map[string]any{"type": "step-finish", "reason": "stop", "cost": 1.5, "tokens": map[string]any{"input": 3, "output": 0, "reasoning": 0, "cache": map[string]int{"read": 0, "write": 0}}} + code, raw = call(t, srv, "POST", "/v1/session/"+id+"/message/"+msgID+"/part", partBody) + if code != 200 { + t.Fatalf("%d %s", code, raw) + } + partID := decode(t, raw)["id"].(string) + code, raw = call(t, srv, "PUT", "/v1/session/"+id+"/message/"+msgID+"/part/"+partID, partBody) + if code != 200 { + t.Fatalf("%d %s", code, raw) + } + code, raw = call(t, srv, "GET", "/v1/session/"+id+"/messages?limit=10", nil) + page := decode(t, raw) + items := page["messages"].([]any) + if code != 200 || len(items) != 1 || page["more"] != false { + t.Fatalf("%d %s", code, raw) + } + first := items[0].(map[string]any) + if first["info"].(map[string]any)["id"] != msgID || len(first["parts"].([]any)) != 1 { + t.Fatalf("%s", raw) + } + code, raw = call(t, srv, "GET", "/v1/session/"+id, nil) + if code != 200 || decode(t, raw)["cost"] != 1.5 { + t.Fatalf("usage not applied: %s", raw) + } + code, _ = call(t, srv, "DELETE", "/v1/session/"+id+"/message/"+msgID+"/part/"+partID, nil) + if code != 204 { + t.Fatalf("%d", code) + } + code, _ = call(t, srv, "DELETE", "/v1/session/"+id+"/message/"+msgID, nil) + if code != 204 { + t.Fatalf("%d", code) + } + code, raw = call(t, srv, "GET", "/v1/session/"+id+"/message/"+msgID, nil) + if code != 404 || decode(t, raw)["error"] != "Message not found: "+msgID { + t.Fatalf("%d %s", code, raw) + } +} + +func TestV1LateWriteIs200(t *testing.T) { + srv := v1Server(t) + code, _ := call(t, srv, "POST", "/v1/session/ses_gone/message", map[string]any{"role": "user", "agent": "a", "model": map[string]string{"providerID": "p", "modelID": "m"}, "time": map[string]int64{"created": 1}}) + if code != 200 { + t.Fatalf("late write must be swallowed, got %d", code) + } +} + +func TestV1TodosAndFork(t *testing.T) { + srv := v1Server(t) + ses := newSession(t, srv) + id := ses["id"].(string) + code, raw := call(t, srv, "PUT", "/v1/session/"+id+"/todo", []map[string]string{{"content": "x", "status": "pending", "priority": "high"}}) + if code != 200 { + t.Fatalf("%d %s", code, raw) + } + code, raw = call(t, srv, "GET", "/v1/session/"+id+"/todo", nil) + var todos []map[string]any + _ = json.Unmarshal(raw, &todos) + if code != 200 || len(todos) != 1 || todos[0]["content"] != "x" { + t.Fatalf("%d %s", code, raw) + } + code, raw = call(t, srv, "POST", "/v1/session/"+id+"/fork", map[string]any{"directory": "/tmp/d", "version": "t"}) + if code != 201 || decode(t, raw)["parentID"] != nil { + t.Fatalf("%d %s", code, raw) + } +} + +func TestV1SchemaOutdatedIs503(t *testing.T) { + dir := t.TempDir() + state, err := newV1State(dir + "/empty.db") + if err != nil { + t.Fatal(err) + } + defer state.Close() + mux := http.NewServeMux() + state.register(mux) + srv := httptest.NewServer(mux) + defer srv.Close() + code, raw := call(t, srv, "GET", "/v1/session?projectID=x", nil) + if code != 503 || decode(t, raw)["error"] != "schema_outdated" { + t.Fatalf("%d %s", code, raw) + } + if !state.degraded() { + t.Fatal("state should be degraded") + } +} + +func TestV1PublishesEvents(t *testing.T) { + srv := v1Server(t) + ses := newSession(t, srv) + sub, err := v1.events.Subscribe(ses["id"].(string), 0, 8) + if err != nil { + t.Fatal(err) + } + defer sub.Close() + call(t, srv, "PATCH", "/v1/session/"+ses["id"].(string), map[string]any{"title": "t2"}) + ev := <-sub.Events() + if ev.Type != "session.updated" { + t.Fatalf("%s", ev.Type) + } + var payload map[string]any + _ = json.Unmarshal(ev.Data, &payload) + if payload["info"].(map[string]any)["title"] != "t2" { + t.Fatalf("%s", ev.Data) + } +} +``` + +`TestV1PublishesEvents` reads the package-level `v1` because `newV1State` assigns it; the test server built by `v1Server` is the last one created in each test, so the global points at it. Keep tests in this file sequential (no `t.Parallel()`). + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd go-core && go test ./cmd/server/ -run TestV1` +Expected: FAIL, `undefined: newV1State`. + +- [ ] **Step 3: Implement `v1_routes.go`** + +```go +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "sync/atomic" + + "github.com/ElioNeto/teamcode/go-core/internal/eventlog" + "github.com/ElioNeto/teamcode/go-core/internal/sessiondb" + "github.com/ElioNeto/teamcode/go-core/internal/store" +) + +const perSessionRing = 1000 +const globalRing = 10000 + +type v1State struct { + db *store.DB + store *sessiondb.Store + events *eventlog.Log + schemaErr atomic.Value +} + +var v1 *v1State + +func newV1State(dbPath string) (*v1State, error) { + db, err := store.Open(dbPath) + if err != nil { + return nil, err + } + state := &v1State{db: db, store: sessiondb.New(db), events: eventlog.New(perSessionRing, globalRing)} + if err := store.CheckSchema(context.Background(), db.Reader()); err != nil { + log.Printf("go-core: session store degraded: %v", err) + state.schemaErr.Store(err) + } + v1 = state + return state, nil +} + +func (s *v1State) degraded() bool { + err, _ := s.schemaErr.Load().(error) + return err != nil +} + +func (s *v1State) Close() error { return s.db.Close() } + +func (s *v1State) gate(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.degraded() { + writeError(w, store.ErrSchemaOutdated.Error(), http.StatusServiceUnavailable) + return + } + next(w, r) + } +} + +func (s *v1State) register(mux *http.ServeMux) { + mux.HandleFunc("POST /v1/session", s.gate(s.handleCreateSession)) + mux.HandleFunc("GET /v1/session", s.gate(s.handleListSessions)) + mux.HandleFunc("GET /v1/session/{id}", s.gate(s.handleGetSession)) + mux.HandleFunc("PATCH /v1/session/{id}", s.gate(s.handlePatchSession)) + mux.HandleFunc("DELETE /v1/session/{id}", s.gate(s.handleDeleteSession)) + mux.HandleFunc("GET /v1/session/{id}/children", s.gate(s.handleChildren)) + mux.HandleFunc("POST /v1/session/{id}/fork", s.gate(s.handleFork)) + mux.HandleFunc("GET /v1/session/{id}/messages", s.gate(s.handleMessages)) + mux.HandleFunc("POST /v1/session/{id}/message", s.gate(s.handleUpsertMessage)) + mux.HandleFunc("GET /v1/session/{id}/message/{messageID}", s.gate(s.handleGetMessage)) + mux.HandleFunc("PUT /v1/session/{id}/message/{messageID}", s.gate(s.handleUpsertMessage)) + mux.HandleFunc("DELETE /v1/session/{id}/message/{messageID}", s.gate(s.handleRemoveMessage)) + mux.HandleFunc("POST /v1/session/{id}/message/{messageID}/part", s.gate(s.handleUpsertPart)) + mux.HandleFunc("GET /v1/session/{id}/message/{messageID}/part/{partID}", s.gate(s.handleGetPart)) + mux.HandleFunc("PUT /v1/session/{id}/message/{messageID}/part/{partID}", s.gate(s.handleUpsertPart)) + mux.HandleFunc("DELETE /v1/session/{id}/message/{messageID}/part/{partID}", s.gate(s.handleRemovePart)) + mux.HandleFunc("GET /v1/session/{id}/todo", s.gate(s.handleGetTodos)) + mux.HandleFunc("PUT /v1/session/{id}/todo", s.gate(s.handleReplaceTodos)) + mux.HandleFunc("GET /v1/events", s.handleEvents) +} + +func registerV1Routes(mux *http.ServeMux) { + path := store.ResolvePath(os.Getenv, homeDir()) + state, err := newV1State(path) + if err != nil { + log.Printf("go-core: session store unavailable at %s: %v", path, err) + return + } + state.register(mux) + log.Printf("go-core: session store at %s (degraded=%v)", path, state.degraded()) +} + +func homeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} + +func writeStoreError(w http.ResponseWriter, err error) { + switch { + case sessiondb.IsNotFound(err): + writeError(w, err.Error(), http.StatusNotFound) + case store.IsBusy(err): + writeError(w, "busy", http.StatusServiceUnavailable) + case errors.Is(err, store.ErrSchemaOutdated): + writeError(w, store.ErrSchemaOutdated.Error(), http.StatusServiceUnavailable) + default: + writeError(w, err.Error(), http.StatusInternalServerError) + } +} +``` + +Wire it in `main.go` right after the existing session CRUD routes: + +```go + registerV1Routes(mux) +``` + +`handleEvents` is written in Task 9; add a stub in `v1_routes.go` for this task only so the package compiles: + +```go +func (s *v1State) handleEvents(w http.ResponseWriter, r *http.Request) { + writeError(w, "not implemented", http.StatusNotImplemented) +} +``` + +- [ ] **Step 4: Implement `v1_session_handlers.go`** + +```go +package main + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "time" + + "github.com/ElioNeto/teamcode/go-core/internal/sessiondb" +) + +func readBody(r *http.Request) (json.RawMessage, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, 64<<20)) + if err != nil { + return nil, err + } + if len(body) == 0 { + return json.RawMessage("{}"), nil + } + if !json.Valid(body) { + return nil, errors.New("invalid json") + } + return body, nil +} + +func writeJSONStatus(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (s *v1State) publishSession(eventType string, info sessiondb.Session) { + s.events.Publish(eventType, info.ID, map[string]any{"sessionID": info.ID, "info": info}) +} + +func (s *v1State) handleCreateSession(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + var in sessiondb.CreateSessionInput + if err := json.Unmarshal(body, &in); err != nil || in.ProjectID == "" || in.Directory == "" || in.Version == "" { + writeError(w, "projectID, directory and version are required", http.StatusBadRequest) + return + } + info, err := s.store.CreateSession(r.Context(), in) + if err != nil { + writeStoreError(w, err) + return + } + s.publishSession("session.created", info) + writeJSONStatus(w, http.StatusCreated, info) +} + +func (s *v1State) handleGetSession(w http.ResponseWriter, r *http.Request) { + info, err := s.store.GetSession(r.Context(), r.PathValue("id")) + if err != nil { + writeStoreError(w, err) + return + } + writeJSONStatus(w, http.StatusOK, info) +} + +func (s *v1State) handleListSessions(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + limit, _ := strconv.Atoi(q.Get("limit")) + start, _ := strconv.ParseInt(q.Get("start"), 10, 64) + filter := sessiondb.ListFilter{ + ProjectID: q.Get("projectID"), WorkspaceID: q.Get("workspaceID"), Directory: q.Get("directory"), Path: q.Get("path"), + Search: q.Get("search"), Limit: limit, Start: start, IncludeArchived: q.Get("archived") == "true", + } + switch parent := q.Get("parentID"); parent { + case "": + case "null": + filter.RootsOnly = true + default: + filter.ParentID = parent + } + list, err := s.store.ListSessions(r.Context(), filter) + if err != nil { + writeStoreError(w, err) + return + } + writeJSONStatus(w, http.StatusOK, list) +} + +func (s *v1State) handleChildren(w http.ResponseWriter, r *http.Request) { + list, err := s.store.Children(r.Context(), r.PathValue("id")) + if err != nil { + writeStoreError(w, err) + return + } + writeJSONStatus(w, http.StatusOK, list) +} + +func (s *v1State) handlePatchSession(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + info, err := s.store.PatchSession(r.Context(), r.PathValue("id"), body) + if err != nil { + writeStoreError(w, err) + return + } + s.publishSession("session.updated", info) + writeJSONStatus(w, http.StatusOK, info) +} + +func (s *v1State) handleDeleteSession(w http.ResponseWriter, r *http.Request) { + deleted, err := s.store.DeleteSession(r.Context(), r.PathValue("id")) + if err != nil { + writeStoreError(w, err) + return + } + for _, info := range deleted { + s.publishSession("session.deleted", info) + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *v1State) handleFork(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + var in struct { + MessageID string `json:"messageID"` + Directory string `json:"directory"` + Path string `json:"path"` + Version string `json:"version"` + } + if err := json.Unmarshal(body, &in); err != nil || in.Directory == "" || in.Version == "" { + writeError(w, "directory and version are required", http.StatusBadRequest) + return + } + result, err := s.store.Fork(r.Context(), r.PathValue("id"), in.MessageID, in.Directory, in.Path, in.Version) + if err != nil { + writeStoreError(w, err) + return + } + s.publishSession("session.created", result.Session) + now := time.Now().UnixMilli() + for _, item := range result.Messages { + s.events.Publish("message.updated", result.Session.ID, map[string]any{"info": item.Info}) + for _, part := range item.Parts { + s.events.Publish("message.part.updated", result.Session.ID, map[string]any{"sessionID": result.Session.ID, "part": part, "time": now}) + } + } + writeJSONStatus(w, http.StatusCreated, result.Session) +} + +func (s *v1State) handleMessages(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + page, err := s.store.PageMessages(r.Context(), r.PathValue("id"), limit, r.URL.Query().Get("before")) + if err != nil { + writeStoreError(w, err) + return + } + writeJSONStatus(w, http.StatusOK, page) +} + +func (s *v1State) handleGetMessage(w http.ResponseWriter, r *http.Request) { + item, err := s.store.GetMessage(r.Context(), r.PathValue("id"), r.PathValue("messageID")) + if err != nil { + writeStoreError(w, err) + return + } + writeJSONStatus(w, http.StatusOK, item) +} + +func withPathID(body json.RawMessage, key, value string) json.RawMessage { + if value == "" { + return body + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err != nil { + return body + } + quoted, _ := json.Marshal(value) + obj[key] = quoted + out, _ := json.Marshal(obj) + return out +} + +func (s *v1State) handleUpsertMessage(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + sessionID := r.PathValue("id") + body = withPathID(body, "id", r.PathValue("messageID")) + message, err := s.store.UpsertMessage(r.Context(), sessionID, body) + if errors.Is(err, sessiondb.ErrLateWrite) { + writeJSONStatus(w, http.StatusOK, map[string]any{"ignored": "late message update"}) + return + } + if err != nil { + writeStoreError(w, err) + return + } + s.events.Publish("message.updated", sessionID, map[string]any{"info": message}) + writeJSONStatus(w, http.StatusOK, message) +} + +func (s *v1State) handleRemoveMessage(w http.ResponseWriter, r *http.Request) { + sessionID, messageID := r.PathValue("id"), r.PathValue("messageID") + if err := s.store.RemoveMessage(r.Context(), sessionID, messageID); err != nil { + writeStoreError(w, err) + return + } + s.events.Publish("message.removed", sessionID, map[string]any{"sessionID": sessionID, "messageID": messageID}) + w.WriteHeader(http.StatusNoContent) +} + +func (s *v1State) handleUpsertPart(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + sessionID, messageID := r.PathValue("id"), r.PathValue("messageID") + body = withPathID(body, "id", r.PathValue("partID")) + now := time.Now().UnixMilli() + part, err := s.store.UpsertPart(r.Context(), sessionID, messageID, body, now) + if errors.Is(err, sessiondb.ErrLateWrite) { + writeJSONStatus(w, http.StatusOK, map[string]any{"ignored": "late part update"}) + return + } + if err != nil { + writeStoreError(w, err) + return + } + s.events.Publish("message.part.updated", sessionID, map[string]any{"sessionID": sessionID, "part": part, "time": now}) + writeJSONStatus(w, http.StatusOK, part) +} + +func (s *v1State) handleGetPart(w http.ResponseWriter, r *http.Request) { + part, err := s.store.GetPart(r.Context(), r.PathValue("id"), r.PathValue("messageID"), r.PathValue("partID")) + if err != nil { + writeStoreError(w, err) + return + } + writeJSONStatus(w, http.StatusOK, part) +} + +func (s *v1State) handleRemovePart(w http.ResponseWriter, r *http.Request) { + sessionID, messageID, partID := r.PathValue("id"), r.PathValue("messageID"), r.PathValue("partID") + if err := s.store.RemovePart(r.Context(), sessionID, messageID, partID); err != nil { + writeStoreError(w, err) + return + } + s.events.Publish("message.part.removed", sessionID, map[string]any{"sessionID": sessionID, "messageID": messageID, "partID": partID}) + w.WriteHeader(http.StatusNoContent) +} + +func (s *v1State) handleGetTodos(w http.ResponseWriter, r *http.Request) { + todos, err := s.store.GetTodos(r.Context(), r.PathValue("id")) + if err != nil { + writeStoreError(w, err) + return + } + writeJSONStatus(w, http.StatusOK, todos) +} + +func (s *v1State) handleReplaceTodos(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + var todos []sessiondb.Todo + if err := json.Unmarshal(body, &todos); err != nil { + writeError(w, "body must be a todo array", http.StatusBadRequest) + return + } + sessionID := r.PathValue("id") + if err := s.store.ReplaceTodos(r.Context(), sessionID, todos); err != nil { + writeStoreError(w, err) + return + } + s.events.Publish("todo.updated", sessionID, map[string]any{"sessionID": sessionID, "todos": todos}) + stored, err := s.store.GetTodos(r.Context(), sessionID) + if err != nil { + writeStoreError(w, err) + return + } + writeJSONStatus(w, http.StatusOK, stored) +} +``` + +- [ ] **Step 5: Update `health.go` for degraded mode** + +Replace `handleHealth` with: + +```go +func handleHealth(w http.ResponseWriter, r *http.Request) { + payload := map[string]any{ + "status": "ok", + "version": "0.1.0", + "time": time.Now().UTC().Format(time.RFC3339), + } + if v1 != nil && v1.degraded() { + payload["status"] = "degraded" + payload["reason"] = "schema_outdated" + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Printf("go-core: write health response: %v", err) + } +} +``` + +Add `"log"` to the import block. `/health` keeps returning HTTP 200 so the TS spawner still considers the process ready. + +- [ ] **Step 6: Run to verify it passes** + +Run: `cd go-core && gofmt -l ./cmd/server && go vet ./cmd/server/ && go test ./cmd/server/ -run 'TestV1|TestHandle'` +Expected: PASS. The existing `/session/*` tests still pass. + +- [ ] **Step 7: Commit** + +```bash +git add go-core/cmd/server +git commit -m "feat(go-core): /v1/session endpoints over the SQLite store with degraded mode" +``` + +--- + +### Task 9: `/v1/events` SSE + +**Files:** + +- Modify: `go-core/cmd/server/v1_routes.go` (delete the stub `handleEvents`) +- Create: `go-core/cmd/server/v1_events_handler.go` +- Test: `go-core/cmd/server/v1_events_handler_test.go` + +**Interfaces:** + +- Consumes: `v1State.events`, `eventlog.Subscription`, `eventlog.ErrReplayUnavailable`. +- Produces: `GET /v1/events?sessionID=` and `GET /v1/events` with `Last-Event-ID` replay; control events `server.connected`, `server.heartbeat`, `server.lagged`, `server.replay_unavailable`. + +- [ ] **Step 1: Write the failing test** + +`go-core/cmd/server/v1_events_handler_test.go`: + +```go +package main + +import ( + "bufio" + "net/http" + "strings" + "testing" + "time" +) + +func readFrame(t *testing.T, r *bufio.Reader) (event, id, data string) { + t.Helper() + deadline := time.After(3 * time.Second) + done := make(chan struct{}) + go func() { + defer close(done) + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + switch { + case strings.HasPrefix(line, "event: "): + event = strings.TrimPrefix(line, "event: ") + case strings.HasPrefix(line, "id: "): + id = strings.TrimPrefix(line, "id: ") + case strings.HasPrefix(line, "data: "): + data = strings.TrimPrefix(line, "data: ") + case line == "": + if event != "" { + return + } + } + } + }() + select { + case <-done: + case <-deadline: + t.Fatal("timeout reading sse frame") + } + return event, id, data +} + +func TestV1EventsStreamsSessionUpdates(t *testing.T) { + srv := v1Server(t) + ses := newSession(t, srv) + id := ses["id"].(string) + resp, err := http.Get(srv.URL + "/v1/events?sessionID=" + id) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.Header.Get("Content-Type") != "text/event-stream" { + t.Fatalf("content-type %q", resp.Header.Get("Content-Type")) + } + reader := bufio.NewReader(resp.Body) + if event, _, _ := readFrame(t, reader); event != "server.connected" { + t.Fatalf("first frame %s", event) + } + call(t, srv, "PATCH", "/v1/session/"+id, map[string]any{"title": "sse"}) + event, seq, data := readFrame(t, reader) + if event != "session.updated" || seq == "" || !strings.Contains(data, `"title":"sse"`) { + t.Fatalf("%s %s %s", event, seq, data) + } +} + +func TestV1EventsReplayWithLastEventID(t *testing.T) { + srv := v1Server(t) + ses := newSession(t, srv) + id := ses["id"].(string) + call(t, srv, "PATCH", "/v1/session/"+id, map[string]any{"title": "one"}) + call(t, srv, "PATCH", "/v1/session/"+id, map[string]any{"title": "two"}) + req, _ := http.NewRequest("GET", srv.URL+"/v1/events?sessionID="+id, nil) + req.Header.Set("Last-Event-ID", "1") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + reader := bufio.NewReader(resp.Body) + readFrame(t, reader) + event, seq, data := readFrame(t, reader) + if event != "session.updated" || seq != "2" || !strings.Contains(data, `"title":"two"`) { + t.Fatalf("%s %s %s", event, seq, data) + } +} + +func TestV1EventsReplayUnavailableIsSignalled(t *testing.T) { + srv := v1Server(t) + ses := newSession(t, srv) + id := ses["id"].(string) + for i := 0; i < perSessionRing+5; i++ { + v1.events.Publish("x", id, nil) + } + req, _ := http.NewRequest("GET", srv.URL+"/v1/events?sessionID="+id, nil) + req.Header.Set("Last-Event-ID", "1") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + reader := bufio.NewReader(resp.Body) + event, _, _ := readFrame(t, reader) + if event != "server.replay_unavailable" { + t.Fatalf("got %s", event) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd go-core && go test ./cmd/server/ -run TestV1Events` +Expected: FAIL (stub returns 501, no `text/event-stream`). + +- [ ] **Step 3: Implement `v1_events_handler.go`** + +```go +package main + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/ElioNeto/teamcode/go-core/internal/eventlog" +) + +const sseHeartbeat = 10 * time.Second +const sseBuffer = 256 + +func writeSSE(w http.ResponseWriter, flusher http.Flusher, ev eventlog.Event) bool { + if _, err := w.Write(ev.SSE()); err != nil { + return false + } + flusher.Flush() + return true +} + +func (s *v1State) handleEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, "streaming not supported", http.StatusInternalServerError) + return + } + sessionID := r.URL.Query().Get("sessionID") + afterSeq, _ := strconv.ParseUint(r.Header.Get("Last-Event-ID"), 10, 64) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusOK) + control := func(eventType string) eventlog.Event { + return eventlog.Event{Type: eventType, SessionID: sessionID, Timestamp: time.Now().UnixMilli()} + } + sub, err := s.events.Subscribe(sessionID, afterSeq, sseBuffer) + if errors.Is(err, eventlog.ErrReplayUnavailable) { + writeSSE(w, flusher, control("server.replay_unavailable")) + if sub, err = s.events.Subscribe(sessionID, 0, sseBuffer); err != nil { + return + } + } else if err != nil { + return + } + defer sub.Close() + if !writeSSE(w, flusher, control("server.connected")) { + return + } + ticker := time.NewTicker(sseHeartbeat) + defer ticker.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-sub.Lagged(): + writeSSE(w, flusher, control("server.lagged")) + return + case ev, open := <-sub.Events(): + if !open { + return + } + if !writeSSE(w, flusher, ev) { + return + } + case <-ticker.C: + if !writeSSE(w, flusher, control("server.heartbeat")) { + return + } + } + } +} +``` + +Remove the stub `handleEvents` from `v1_routes.go`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd go-core && gofmt -l ./cmd/server && go vet ./cmd/server/ && go test -race ./cmd/server/ ./internal/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add go-core/cmd/server +git commit -m "feat(go-core): /v1/events SSE with sequence ids and replay" +``` + +--- + +### Task 10: Parity TS×Go by table dump + +**Files:** + +- Create: `packages/teamcode/test/gocore/harness.ts` +- Create: `packages/teamcode/test/gocore/session-parity.test.ts` + +**Interfaces:** + +- Consumes: `/v1/session/*` from Task 8; TS `Session.Service`, `MessageV2`, `Todo.Service` from `packages/teamcode/src/session/*`; test fixtures `testEffect`, `provideInstance`, `tmpdirScoped` from `packages/teamcode/test/lib/effect.ts` and `test/fixture/fixture.ts` (same wiring as `test/session/session.test.ts:18-29`). +- Produces: `startGoCore({ dbPath }): Promise<{ baseUrl, stop }>` that spawns `GO_CORE_BINARY` with `TEAMCODE_DB=` and a free port, waits for `/health`; `dumpTables(dbPath)` returning normalized rows; the parity test itself. The test is skipped, not failed, when `GO_CORE_BINARY` is unset, so `bun turbo test:ci --filter=teamcode` keeps working without Go. + +- [ ] **Step 1: Write `harness.ts`** + +```ts +import { Database } from "bun:sqlite" +import fs from "fs" +import net from "net" + +export function goCoreBinary(): string | undefined { + const binary = process.env["GO_CORE_BINARY"] + if (!binary || !fs.existsSync(binary)) return undefined + return binary +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.listen(0, "127.0.0.1", () => { + const address = server.address() + server.close(() => (typeof address === "object" && address ? resolve(address.port) : reject(new Error("no port")))) + }) + }) +} + +export async function startGoCore(input: { dbPath: string }) { + const binary = goCoreBinary() + if (!binary) throw new Error("GO_CORE_BINARY not set") + const port = await freePort() + const proc = Bun.spawn([binary], { + env: { ...process.env, GO_CORE_PORT: String(port), TEAMCODE_DB: input.dbPath, GO_CORE_PARENT_PID: String(process.pid) }, + stdout: "pipe", + stderr: "pipe", + }) + const baseUrl = `http://127.0.0.1:${port}` + for (let attempt = 0; attempt < 50; attempt++) { + try { + const resp = await fetch(`${baseUrl}/health`) + if (resp.ok) { + const body = (await resp.json()) as { status: string } + if (body.status !== "ok") throw new Error(`go-core health ${body.status}`) + return { baseUrl, stop: () => proc.kill() } + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("go-core health")) { + proc.kill() + throw error + } + } + await new Promise((r) => setTimeout(r, 100)) + } + proc.kill() + throw new Error("go-core did not become healthy") +} + +export async function api(baseUrl: string, method: string, path: string, body?: unknown) { + const resp = await fetch(baseUrl + path, { + method, + headers: body === undefined ? {} : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await resp.text() + if (!resp.ok) throw new Error(`${method} ${path} -> ${resp.status} ${text}`) + return text ? JSON.parse(text) : undefined +} + +type Row = Record + +const volatileColumns = new Set(["id", "session_id", "message_id", "parent_id", "slug", "time_created", "time_updated", "time_compacting", "time_archived"]) + +function normalizeData(value: unknown, idMap: Map): unknown { + if (typeof value === "string") { + let out = value + for (const [from, to] of idMap) out = out.split(from).join(to) + return out + } + return value +} + +export function dumpTables(dbPath: string) { + const db = new Database(dbPath, { readonly: true }) + const idMap = new Map() + const label = (prefix: string, rows: Row[], key: string) => rows.forEach((row, i) => idMap.set(String(row[key]), `${prefix}#${i}`)) + const sessions = db.query("SELECT * FROM session ORDER BY time_created, title").all() as Row[] + const messages = db.query("SELECT * FROM message ORDER BY time_created, id").all() as Row[] + const parts = db.query("SELECT * FROM part ORDER BY time_created, id").all() as Row[] + const todos = db.query("SELECT * FROM todo ORDER BY session_id, position").all() as Row[] + label("ses", sessions, "id") + label("msg", messages, "id") + label("prt", parts, "id") + db.close() + const strip = (rows: Row[]) => + rows.map((row) => { + const out: Row = {} + for (const [column, value] of Object.entries(row)) { + if (volatileColumns.has(column)) { + if (typeof value === "string" && idMap.has(value)) out[column] = idMap.get(value) + continue + } + if (column === "data" && typeof value === "string") { + const parsed = JSON.parse(value) as Record + delete parsed["time"] + out[column] = JSON.parse(String(normalizeData(JSON.stringify(parsed), idMap))) + continue + } + out[column] = value + } + return out + }) + return { sessions: strip(sessions), messages: strip(messages), parts: strip(parts), todos: strip(todos) } +} +``` + +`time` inside `data` is removed because the TS scenario and the Go scenario run at different instants; every other field of every row must match. + +- [ ] **Step 2: Write the failing parity test** + +`packages/teamcode/test/gocore/session-parity.test.ts`: + +```ts +import { describe, expect, test } from "bun:test" +import fs from "fs" +import path from "path" +import { Effect, Layer } from "effect" +import { Session as SessionNs } from "@/session/session" +import { MessageV2 } from "@/session/message-v2" +import { Todo } from "@/session/todo" +import { MessageID, PartID } from "@/session/schema" +import { Bus } from "@/bus" +import { Storage } from "@/storage/storage" +import { SyncEvent } from "@/sync" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { BackgroundJob } from "@/background/job" +import { Database } from "@/storage/db" +import { CrossSpawnSpawner } from "@teamcode-ai/core/cross-spawn-spawner" +import { InstallationVersion } from "@teamcode-ai/core/installation/version" +import { InstanceState } from "@/effect/instance-state" +import { testEffect } from "../lib/effect" +import { api, dumpTables, goCoreBinary, startGoCore } from "./harness" + +const it = testEffect( + Layer.mergeAll( + SessionNs.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), + Layer.provide(BackgroundJob.defaultLayer), + ), + Todo.defaultLayer, + CrossSpawnSpawner.defaultLayer, + ), +) + +const userInfo = (sessionID: string, created: number) => ({ + id: MessageID.ascending(), + sessionID, + role: "user" as const, + time: { created }, + agent: "build", + model: { providerID: "p", modelID: "m" }, +}) + +const assistantInfo = (sessionID: string, parentID: string, created: number) => ({ + id: MessageID.ascending(), + sessionID, + role: "assistant" as const, + time: { created }, + parentID, + modelID: "m", + providerID: "p", + mode: "build", + agent: "build", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, +}) + +const stepFinish = (sessionID: string, messageID: string, cost: number, input: number) => ({ + id: PartID.ascending(), + sessionID, + messageID, + type: "step-finish" as const, + reason: "stop", + cost, + tokens: { input, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, +}) + +const scenario = { + async run(ops: { + create: (input: { title?: string; parentID?: string }) => Promise<{ id: string }> + setTitle: (id: string, title: string) => Promise + message: (sessionID: string, info: Record) => Promise<{ id: string }> + part: (sessionID: string, messageID: string, part: Record) => Promise<{ id: string }> + removePart: (sessionID: string, messageID: string, partID: string) => Promise + removeMessage: (sessionID: string, messageID: string) => Promise + todos: (sessionID: string, todos: { content: string; status: string; priority: string }[]) => Promise + remove: (id: string) => Promise + }) { + const root = await ops.create({ title: "Parity root" }) + await ops.setTitle(root.id, "Parity root renamed") + const user = await ops.message(root.id, userInfo(root.id, 1_000)) + const assistant = await ops.message(root.id, assistantInfo(root.id, user.id, 1_001)) + await ops.part(root.id, assistant.id, { id: PartID.ascending(), sessionID: root.id, messageID: assistant.id, type: "text", text: "hello" }) + const finish = await ops.part(root.id, assistant.id, stepFinish(root.id, assistant.id, 0.25, 10)) + const finish2 = await ops.part(root.id, assistant.id, stepFinish(root.id, assistant.id, 0.5, 20)) + await ops.removePart(root.id, assistant.id, finish2.id) + const stray = await ops.message(root.id, userInfo(root.id, 1_002)) + await ops.part(root.id, stray.id, stepFinish(root.id, stray.id, 9, 900)) + await ops.removeMessage(root.id, stray.id) + await ops.todos(root.id, [ + { content: "second", status: "pending", priority: "low" }, + { content: "first", status: "completed", priority: "high" }, + ]) + const child = await ops.create({ title: "Parity child", parentID: root.id }) + await ops.message(child.id, userInfo(child.id, 2_000)) + const doomed = await ops.create({ title: "Doomed" }) + await ops.create({ title: "Doomed child", parentID: doomed.id }) + await ops.remove(doomed.id) + void finish + }, +} + +describe("go-core session parity", () => { + const binary = goCoreBinary() + if (!binary) { + test.skip("GO_CORE_BINARY not set; skipping parity", () => {}) + return + } + + it.instance("TS and Go produce the same tables for the same scenario", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const todo = yield* Todo.Service + const ctx = yield* InstanceState.context + const run = (effect: Effect.Effect) => Effect.runPromise(effect) + + const tsDb = Database.getPath() + const goDb = tsDb.replace(/\.db$/, "-go.db") + Database.use((db) => db.run("PRAGMA wal_checkpoint(TRUNCATE)")) + fs.copyFileSync(tsDb, goDb) + + yield* Effect.promise(() => + scenario.run({ + create: (input) => run(session.create({ title: input.title, parentID: input.parentID as never })), + setTitle: (id, title) => run(session.setTitle({ sessionID: id as never, title })).then(() => undefined), + message: (_, info) => run(session.updateMessage(info as MessageV2.Info)), + part: (_, __, part) => run(session.updatePart(part as MessageV2.Part)), + removePart: (sessionID, messageID, partID) => + run(session.removePart({ sessionID: sessionID as never, messageID: messageID as never, partID: partID as never })).then(() => undefined), + removeMessage: (sessionID, messageID) => + run(session.removeMessage({ sessionID: sessionID as never, messageID: messageID as never })).then(() => undefined), + todos: (sessionID, todos) => run(todo.update({ sessionID: sessionID as never, todos })), + remove: (id) => run(session.remove(id as never)), + }), + ) + + const go = yield* Effect.promise(() => startGoCore({ dbPath: goDb })) + try { + yield* Effect.promise(() => + scenario.run({ + create: (input) => + api(go.baseUrl, "POST", "/v1/session", { + projectID: ctx.project.id, + directory: ctx.directory, + version: InstallationVersion, + title: input.title, + parentID: input.parentID, + }), + setTitle: (id, title) => api(go.baseUrl, "PATCH", `/v1/session/${id}`, { title }).then(() => undefined), + message: (sessionID, info) => api(go.baseUrl, "PUT", `/v1/session/${sessionID}/message/${info.id}`, info), + part: (sessionID, messageID, part) => api(go.baseUrl, "PUT", `/v1/session/${sessionID}/message/${messageID}/part/${part.id}`, part), + removePart: (sessionID, messageID, partID) => + api(go.baseUrl, "DELETE", `/v1/session/${sessionID}/message/${messageID}/part/${partID}`).then(() => undefined), + removeMessage: (sessionID, messageID) => + api(go.baseUrl, "DELETE", `/v1/session/${sessionID}/message/${messageID}`).then(() => undefined), + todos: (sessionID, todos) => api(go.baseUrl, "PUT", `/v1/session/${sessionID}/todo`, todos).then(() => undefined), + remove: (id) => api(go.baseUrl, "DELETE", `/v1/session/${id}`).then(() => undefined), + }), + ) + } finally { + go.stop() + } + + Database.use((db) => db.run("PRAGMA wal_checkpoint(TRUNCATE)")) + const tsDump = dumpTables(tsDb) + const goDump = dumpTables(goDb) + expect(goDump.sessions).toEqual(tsDump.sessions) + expect(goDump.messages).toEqual(tsDump.messages) + expect(goDump.parts).toEqual(tsDump.parts) + expect(goDump.todos).toEqual(tsDump.todos) + expect(tsDump.sessions.length).toBe(2) + expect(tsDump.sessions[0]!["cost"]).toBe(0.25) + }), + ) +}) +``` + +The `as never` casts satisfy the branded `SessionID`/`MessageID`/`PartID` types without importing their constructors for values the scenario already produced. `Database.getPath()` and `Database.use` exist in `packages/teamcode/src/storage/db.ts`. + +- [ ] **Step 3: Run to verify it fails** + +Build the binary first: + +```bash +cd go-core && go build -o build/go-core-server ./cmd/server && cd .. +GO_CORE_BINARY=$PWD/go-core/build/go-core-server bun test --cwd packages/teamcode test/gocore/session-parity.test.ts +``` + +Expected on the first run: FAIL on a `toEqual` diff, or a TS compile error in the test wiring. Fix wiring errors in the test file only. A `toEqual` diff is a real parity gap: fix it in `go-core/internal/sessiondb`, never by loosening `dumpTables`. + +- [ ] **Step 4: Iterate until the dumps match** + +Run the same command. Expected: PASS. Then confirm the TS suite is untouched: + +```bash +cd packages/teamcode && bun test test/session/ +``` + +Expected: same pass count as `develop`. + +- [ ] **Step 5: Commit** + +```bash +git add packages/teamcode/test/gocore +git commit -m "test(go-core): TS versus Go session parity by table dump" +``` + +--- + +### Task 11: Concurrency on one file + +**Files:** + +- Create: `packages/teamcode/test/gocore/session-concurrency.test.ts` + +**Interfaces:** + +- Consumes: `startGoCore`, `api`, `goCoreBinary` from Task 10; TS `Session.Service` wiring from Task 10. + +- [ ] **Step 1: Write the test** + +```ts +import { describe, expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { Session as SessionNs } from "@/session/session" +import { MessageID, PartID } from "@/session/schema" +import { Bus } from "@/bus" +import { Storage } from "@/storage/storage" +import { SyncEvent } from "@/sync" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { BackgroundJob } from "@/background/job" +import { Database } from "@/storage/db" +import { CrossSpawnSpawner } from "@teamcode-ai/core/cross-spawn-spawner" +import { testEffect } from "../lib/effect" +import { api, goCoreBinary, startGoCore } from "./harness" + +const it = testEffect( + Layer.mergeAll( + SessionNs.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), + Layer.provide(BackgroundJob.defaultLayer), + ), + CrossSpawnSpawner.defaultLayer, + ), +) + +const PARTS_PER_SIDE = 500 + +describe("go-core and TS writing the same SQLite file", () => { + if (!goCoreBinary()) { + test.skip("GO_CORE_BINARY not set; skipping concurrency", () => {}) + return + } + + it.instance("no lost writes and consistent usage counters", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const info = yield* session.create({ title: "Concurrency" }) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + sessionID: info.id, + role: "user" as const, + time: { created: Date.now() }, + agent: "build", + model: { providerID: "p", modelID: "m" }, + }) + const go = yield* Effect.promise(() => startGoCore({ dbPath: Database.getPath() })) + const stepFinish = (id: string) => ({ + id, + sessionID: info.id, + messageID: message.id, + type: "step-finish" as const, + reason: "stop", + cost: 1, + tokens: { input: 1, output: 1, reasoning: 1, cache: { read: 1, write: 1 } }, + }) + try { + const tsWrites = Array.from({ length: PARTS_PER_SIDE }, () => + Effect.runPromise(session.updatePart(stepFinish(PartID.ascending()) as never)), + ) + const goWrites = Array.from({ length: PARTS_PER_SIDE }, () => { + const id = PartID.ascending() + return api(go.baseUrl, "PUT", `/v1/session/${info.id}/message/${message.id}/part/${id}`, stepFinish(id)) + }) + yield* Effect.promise(() => Promise.all([...tsWrites, ...goWrites])) + const stored = yield* Effect.promise(() => api(go.baseUrl, "GET", `/v1/session/${info.id}`)) + expect(stored.cost).toBe(PARTS_PER_SIDE * 2) + expect(stored.tokens.input).toBe(PARTS_PER_SIDE * 2) + const page = yield* Effect.promise(() => api(go.baseUrl, "GET", `/v1/session/${info.id}/messages?limit=10`)) + expect(page.messages[0].parts.length).toBe(PARTS_PER_SIDE * 2) + const fromTs = yield* session.messages({ sessionID: info.id }) + expect(fromTs[0]!.parts.length).toBe(PARTS_PER_SIDE * 2) + } finally { + go.stop() + } + }), + ) +}) +``` + +- [ ] **Step 2: Run it** + +```bash +GO_CORE_BINARY=$PWD/go-core/build/go-core-server bun test --cwd packages/teamcode test/gocore/session-concurrency.test.ts +``` + +Expected: PASS. A `busy` 503 from the Go side or a `SQLITE_BUSY` from Bun means the `busy_timeout` chain is broken on one side; investigate before lowering `PARTS_PER_SIDE`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/teamcode/test/gocore/session-concurrency.test.ts +git commit -m "test(go-core): TS and Go writing the same SQLite file without lost writes" +``` + +--- + +### Task 12: Read performance budget + +**Files:** + +- Create: `go-core/internal/sessiondb/perf_test.go` + +**Interfaces:** + +- Consumes: `Store`, `testdb.New`, `testdb.SeedProject`. + +- [ ] **Step 1: Write the test** + +```go +package sessiondb_test + +import ( + "database/sql" + "fmt" + "testing" + "time" + + "github.com/ElioNeto/teamcode/go-core/internal/ident" + "github.com/ElioNeto/teamcode/go-core/internal/sessiondb" +) + +const perfMessages = 50000 +const perfPartsPerMessage = 5 +const perfBudget = 5 * time.Second + +func TestAllMessagesWithinBudget(t *testing.T) { + if testing.Short() { + t.Skip("perf test skipped with -short") + } + s := newStore(t) + ses := create(t, s, sessiondb.CreateSessionInput{}) + err := s.DB().ExecWrite(ctx, func(tx *sql.Tx) error { + for i := 0; i < perfMessages; i++ { + msgID := ident.Message() + data := fmt.Sprintf(`{"role":"user","agent":"a","model":{"providerID":"p","modelID":"m"},"time":{"created":%d}}`, 1000+i) + if _, err := tx.Exec(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`, msgID, ses.ID, 1000+i, 1000+i, data); err != nil { + return err + } + for p := 0; p < perfPartsPerMessage; p++ { + if _, err := tx.Exec(`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`, ident.Part(), msgID, ses.ID, 1000+i, 1000+i, `{"type":"text","text":"x"}`); err != nil { + return err + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + start := time.Now() + all, err := s.AllMessages(ctx, ses.ID) + elapsed := time.Since(start) + if err != nil || len(all) != perfMessages || len(all[0].Parts) != perfPartsPerMessage { + t.Fatalf("len=%d err=%v", len(all), err) + } + t.Logf("AllMessages(%d x %d parts) took %s", perfMessages, perfPartsPerMessage, elapsed) + if elapsed > perfBudget { + t.Fatalf("took %s, budget %s", elapsed, perfBudget) + } +} +``` + +- [ ] **Step 2: Run it** + +Run: `cd go-core && go test ./internal/sessiondb/ -run TestAllMessagesWithinBudget -v` +Expected: PASS with the measured time in the log. If it exceeds 5 s, the `IN (...)` hydrate query with 50000 placeholders is the first suspect: switch `hydrate` to `SELECT ... FROM part WHERE session_id = ? ORDER BY message_id, id` when it is called from `AllMessages`, and keep the `IN` form for pages. + +- [ ] **Step 3: Commit** + +```bash +git add go-core/internal/sessiondb/perf_test.go +git commit -m "test(go-core): read budget for 50k messages" +``` + +--- + +### Task 13: CI job for go-core + +**Files:** + +- Modify: `.github/workflows/test.yml` +- Create: `go-core/.golangci.yml` + +- [ ] **Step 1: Add the lint config** + +`go-core/.golangci.yml`: + +```yaml +version: "2" +linters: + default: standard + enable: + - errcheck + - govet + - staticcheck + - unused +formatters: + enable: + - gofmt +``` + +- [ ] **Step 2: Add the job** + +Append to `jobs:` in `.github/workflows/test.yml`: + +```yaml + go-core: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go-core/go.mod + cache-dependency-path: go-core/go.sum + - uses: ./.github/actions/setup-bun + - name: Format check + working-directory: go-core + run: test -z "$(gofmt -l ./cmd ./internal)" + - name: Vet + working-directory: go-core + run: go vet ./... + - uses: golangci/golangci-lint-action@v8 + with: + working-directory: go-core + - name: Unit tests + working-directory: go-core + run: go test -race -count=1 ./... + - name: Build server + working-directory: go-core + run: go build -o build/go-core-server ./cmd/server + - name: Parity and concurrency against TypeScript + working-directory: packages/teamcode + run: bun test test/gocore/ + env: + GO_CORE_BINARY: ${{ github.workspace }}/go-core/build/go-core-server + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true +``` + +`go.mod` declares `go 1.22.2`; `modernc.org/sqlite` may raise the floor when `go get` runs in Task 1, and `go-version-file` follows whatever `go.mod` says. + +- [ ] **Step 3: Verify locally what CI will run** + +```bash +cd go-core && test -z "$(gofmt -l ./cmd ./internal)" && go vet ./... && go test -race -count=1 ./... && go build -o build/go-core-server ./cmd/server +``` + +Expected: all green. `gofmt -l` on the existing `cmd/server/*.go` files reports CRLF-checked-out files on Windows; run it in Git Bash after `git config core.autocrlf false` in the worktree, or trust the Linux CI run. + +- [ ] **Step 4: Commit and push** + +```bash +git add .github/workflows/test.yml go-core/.golangci.yml +git commit -m "ci: lint, test, build and parity-check go-core" +``` + +Push the branch and open the PR as draft; mark it ready only when every check is green. + +--- + +### Task 14: Retire the dead Go session store and update the docs + +**Files:** + +- Delete: `go-core/internal/session/persistent_store.go`, `go-core/internal/session/persistent_store_test.go` +- Modify: `docs/ARCHITECTURE.md` (§4.2 "Session Store Architecture", §5.1 storage table) +- Modify: `docs/PERFORMANCE_PLAN.md` (§1 table: P0 rows point to `internal/sessiondb`) + +**Interfaces:** + +- Consumes: nothing new. `internal/session/store.go` stays because `/session/*` legacy handlers use it until M2. + +- [ ] **Step 1: Delete the dead code and prove nothing referenced it** + +```bash +cd go-core && grep -rn "PersistentStore\|DefaultDataPath" --include=*.go . ; git rm internal/session/persistent_store.go internal/session/persistent_store_test.go && go build ./... && go test ./internal/session/ +``` + +Expected: the grep lists only the two files being removed; build and tests pass afterwards. + +- [ ] **Step 2: Update `docs/ARCHITECTURE.md`** + +Replace the "Session Store Architecture" block in §4.2 (the ASCII diagram and the "TTL Policy" paragraph) with: + +```markdown +**Session Store (M1):** + +`internal/sessiondb` persists sessions, messages, parts and todos in the same SQLite file and +drizzle schema used by `packages/teamcode` (`opencode.db` or `opencode-.db`). The Go side +never migrates the schema; `internal/store.CheckSchema` verifies `__drizzle_migrations` and the +server runs `/v1/session/*` in degraded mode (`503 schema_outdated`) when the file is behind. +Message and part payloads are stored as opaque JSON; only `step-finish` parts are inspected to +maintain session usage counters. Events are published through `internal/eventlog` with a +per-session sequence and a replay ring, exposed at `GET /v1/events`. + +Design and test plan: `docs/rewrite/spec-m1-go-session-store.md`. +``` + +In §5.1 remove the row `~/.local/share/teamcode/go-core/sessions.json` and change the `opencode.db` row's purpose to `SQLite database (sessions, messages) shared by TypeScript and go-core`. + +- [ ] **Step 3: Update `docs/PERFORMANCE_PLAN.md`** + +In the §1 table, change the two P0 rows' "Go Core Alvo" cells to `internal/sessiondb` (`session.go`) and `internal/sessiondb` (`message.go`), and their "Justificativa" cells to `Entregue em M1 sobre o SQLite existente`. + +- [ ] **Step 4: Commit** + +```bash +git add -A go-core/internal/session docs/ARCHITECTURE.md docs/PERFORMANCE_PLAN.md +git commit -m "chore(go-core): remove the unused JSON session store and document the SQLite store" +``` + +--- + +## Self-Review + +### Spec coverage + +| Spec section | Task | +| --- | --- | +| §3.1 path resolution, pragmas, no WAL recovery, passive checkpoint, serialized writer, `BEGIN IMMEDIATE`, 503 busy | Task 1, Task 8 (`writeStoreError`) | +| §3.2 schema check and degraded mode | Task 2, Task 8 (`gate`, `/health`) | +| §3.3 model, placeholder on bad JSON, epoch ms | Task 4 (`toSession`, `rawIfValidJSON`), Task 5 | +| §3.4 identifiers and cross fixture | Task 3 | +| §3.5 write rules (time_updated, usage deltas, upsert keeps time_created, FK swallow, clock guard, null/absent patch, recursive delete, todo replace, project FK, workspace time_used) | Tasks 4, 5, 6 | +| §4 endpoints, error strings, cursor, fork semantics, opaque JSON | Tasks 5, 6, 8 | +| §5 event log, types, SSE frames, replay, lagged | Tasks 7, 9 | +| §6 concurrency | Task 1 (writer serialization), Task 11 | +| §7.1 unit tests over replayed migrations | Task 2 helper, used everywhere | +| §7.2 id fixture | Task 3 | +| §7.3 parity by dump | Task 10 | +| §7.4 concurrency test | Task 11 | +| §7.5 performance | Task 12 | +| §7.6 CI | Task 13 | +| §2 "Não entra": legacy `/session/*` untouched; `PersistentStore` removed as spec §1 says it is replaced | Task 14 | + +Gap check: the spec lists `todo.updated` under neither §5.2 nor "não entra"; Task 8 publishes it because the TS `Todo.update` publishes `todo.updated` on the Bus (`todo.ts:53`) and M2 will need it. `X-Session-Id`-style headers are not part of M1. + +### Placeholder scan + +no TBD/TODO; every code step has full code; Task 10 step 3 names the two possible failure classes and where each is fixed. + +### Type consistency + +`sessiondb.Store` methods used by Task 8 match Tasks 4-6 signatures (`UpsertMessage(ctx, sessionID, body)`, `UpsertPart(ctx, sessionID, messageID, body, timeCreated)`, `PageMessages(ctx, sessionID, limit, before)`, `Fork(ctx, sessionID, beforeMessageID, directory, path, version)`, `ReplaceTodos(ctx, sessionID, todos)`); `eventlog.Log.Publish(eventType, sessionID, data)` and `Subscribe(sessionID, afterSeq, buffer)` match Task 9; `store.IsBusy`, `store.ErrSchemaOutdated`, `store.CheckSchema` match Tasks 1-2; `testdb.New`/`SeedProject` match every test; `MessagePage` JSON keys (`messages`, `more`, `cursor`) match the Task 8 handler test and the spec §4.2 response. diff --git a/docs/rewrite/spec-m1-go-session-store.md b/docs/rewrite/spec-m1-go-session-store.md new file mode 100644 index 00000000..bf391f27 --- /dev/null +++ b/docs/rewrite/spec-m1-go-session-store.md @@ -0,0 +1,230 @@ +# Spec M1: store de sessão e mensagens no go-core + +Data: 2026-09-09 +Status: proposta aprovada em conversa, aguardando revisão do texto +Relacionados: `docs/PERFORMANCE_PLAN.md` §1 (P0 "Session store + CRUD" e "Message persistence"), `docs/ARCHITECTURE.md` §3.1, `docs/rewrite/adr-swarm-scheduler.md` + +## 1. Contexto + +O `ARCHITECTURE.md` declara o go-core como fonte de verdade de sessão, mas hoje toda a persistência de sessão, mensagem e parte vive em TypeScript (`packages/teamcode/src/session/*`) sobre SQLite (`opencode.db`). O go-core tem um store em memória com 6 campos (`internal/session/store.go`), um `PersistentStore` sem uso com TTL de 7 dias e nenhum armazenamento de mensagens. O único uso do go-core pelo `packages/teamcode` é o `startGoCore()` em `index.ts`. + +Este documento é o primeiro de quatro marcos que levam a "ler e interagir com sessões do Claude Code e do Codex de dentro do teamcode": + +| Marco | Entrega | +| --- | --- | +| **M1 (este)** | go-core capaz de ser dono da persistência de sessão, sem mudar o TS | +| M2 | TS passa a falar com o go-core por HTTP; dupla escrita atrás de flag; SQLite acessado só pelo Go | +| M3 | Scanner e conversores de sessões Claude Code e Codex em Go; importação e reimportação incremental | +| M4 | Superfície TS: lista na TUI com origem, `import --from`, tool `external_session` | + +Decisões já tomadas que este documento não reabre: + +- Persistência em SQLite, no mesmo arquivo e esquema que o TS usa hoje (`opencode.db` ou `opencode-.db`). ApexStore fica para a fase prevista no roadmap. +- Driver `modernc.org/sqlite` (Go puro, sem cgo). É a primeira dependência externa do `go-core/go.mod`. +- O esquema continua sendo do TS (migrações drizzle em `packages/teamcode/migration/`). O Go não roda nem embute migrações. + +## 2. Escopo + +### Entra + +- Pacote `go-core/internal/store`: abertura do arquivo SQLite com os mesmos pragmas do TS e verificação de esquema. +- Pacote `go-core/internal/sessiondb`: modelo e consultas para `session`, `message`, `part`, `todo`. +- Pacote `go-core/internal/ident`: geração e ordenação de IDs no esquema do `packages/teamcode/src/id/id.ts`. +- Pacote `go-core/internal/eventbus` reescrito: sequência por sessão, replay, sem descarte silencioso. +- Endpoints `/v1/session/...` e `/v1/events` em `go-core/cmd/server`. +- Testes unitários Go, fixture cruzada de IDs, teste de paridade TS×Go e teste de concorrência. +- Job de CI para `go-core/`. + +### Não entra + +- Qualquer mudança de comportamento no TS. `Session.Service`, projetores, TUI, tool e comando ficam intactos. +- Dupla escrita, flags de roteamento, remoção da rotina de recuperação de WAL do TS. São M2. +- Tabela `session_message` (V2, 29 projetores), `session_diff` (arquivo JSON fora do SQLite), `event`/`event_sequence` (só com `experimentalWorkspaces`), `session_share`, `permission` por projeto. +- `updatePartDelta`: hoje é só Bus, sem persistência; vira evento SSE em M2. +- Autenticação dos endpoints. O go-core continua em `127.0.0.1`, como hoje. +- Interpretação das variantes de `MessageV2.Part` e dos erros de `Assistant`. O Go guarda e devolve o JSON intacto (ver §4.3). + +## 3. Camada de dados + +### 3.1 Abertura do arquivo (`internal/store`) + +Resolução do caminho, na mesma ordem do `packages/teamcode/src/storage/db.ts`: + +1. `TEAMCODE_DB`, se definido. `:memory:` e caminhos absolutos passam direto; caminho relativo é resolvido sob o diretório de dados. +2. Diretório de dados: `XDG_DATA_HOME/teamcode`, com fallback `~/.local/share/teamcode`. +3. Nome do arquivo: `opencode.db` para canais `latest`, `beta`, `prod` ou quando `TEAMCODE_DISABLE_CHANNEL_DB` está definido; senão `opencode-.db`. O canal vem de `TEAMCODE_CHANNEL`, padrão `local`. + +Pragmas, nesta ordem, em toda conexão: `journal_mode=WAL`, `synchronous=NORMAL`, `busy_timeout=10000`, `cache_size=-64000`, `foreign_keys=ON`. + +O que o Go **não** faz na abertura, porque o TS faz e com dois processos no mesmo arquivo isso corrompe: apagar `-wal`/`-shm`, `wal_checkpoint(TRUNCATE)`, `integrity_check`. Ao fechar, o Go faz `wal_checkpoint(PASSIVE)`. + +Pool: uma conexão dedicada a escrita, serializada por mutex no processo; pool de leitura separado. Toda mutação abre `BEGIN IMMEDIATE`. `SQLITE_BUSY` após o `busy_timeout` vira resposta 503 `{"error":"busy"}`; não há retry silencioso, porque retry reordenaria eventos. + +### 3.2 Verificação de esquema + +Na abertura, o Go lê `__drizzle_migrations` e exige que a última migração aplicada seja a última que ele conhece (`20260619230821_add-session-indexes` na data deste documento; a constante vive em `internal/store/schema.go` e é atualizada quando o TS ganha migração que toca tabelas usadas pelo Go). Se a tabela não existe ou a migração falta, o servidor sobe em modo degradado: `/health` responde `{"status":"degraded","reason":"schema_outdated"}` e todo `/v1/session/*` responde 503 `{"error":"schema_outdated"}`. Os demais serviços do go-core (filesystem, provider, swarm) seguem normais. + +### 3.3 Modelo + +Uma struct por tabela, colunas planas tipadas, colunas `data` como `json.RawMessage`. + +`Session` cobre as 30 colunas de `session` (ver `packages/teamcode/src/session/session.sql.ts:16-66`). No wire, o Go produz o mesmo objeto `Session.Info` do TS: `share.url` a partir de `share_url`; `summary` a partir das 3 colunas inteiras mais `summary_diffs`, ausente quando as 3 são NULL; `tokens.*` a partir de `tokens_*`; `time.*` a partir de `time_*`; `model` a partir do JSON. Falha ao decodificar `model` descarta só o `model`; falha em qualquer outro campo devolve o placeholder mínimo que o `fromRow` do TS devolve (id, slug, projectID, directory, title, version, time), logado em `warn`. + +`Message`: `id`, `session_id`, `time_created`, `time_updated`, `data`. `Part`: `id`, `message_id`, `session_id`, `time_created`, `time_updated`, `data`. `Todo`: `session_id`, `content`, `status`, `priority`, `position`, `time_created`, `time_updated`, chave composta `(session_id, position)`. + +Do JSON de `part.data`, o Go lê apenas `type` e, quando `type == "step-finish"`, `cost` e `tokens.{input,output,reasoning,cache.read,cache.write}`. Nada mais é interpretado. + +Tempo: toda coluna `time_*` e todo `time.*` no JSON é inteiro epoch em milissegundos. Nenhum `time.Time` chega ao wire. + +### 3.4 Identificadores (`internal/ident`) + +Reimplementação do `packages/teamcode/src/id/id.ts:51-70`: + +- Formato `_` + 12 hex + 14 base62. +- Os 12 hex são 6 bytes big-endian de `ms*4096 + contador`, com contador por processo zerado a cada milissegundo. +- Sessão é **descendente**: o valor é invertido bit a bit antes de codificar, de modo que ordem lexicográfica é ordem cronológica inversa. Mensagem, parte e evento são ascendentes. +- Prefixos: `ses`, `msg`, `prt`, `evt`. + +Um teste cruzado usa uma fixture de 200 IDs gerados pelo TS e verifica que o Go extrai o mesmo timestamp e produz a mesma ordenação. + +### 3.5 Regras de escrita copiadas do TS + +Listadas aqui porque cada uma tem teste próprio: + +| Regra | Origem no TS | +| --- | --- | +| `time_updated` nunca é alterado implicitamente; só muda quando o patch traz `time.updated` | `storage/schema.sql.ts:7-8`, `session/projectors.ts:114` | +| Uso da sessão (`cost`, `tokens_*`) é delta incremental: soma ao gravar parte `step-finish`, subtrai a versão anterior ao regravar a mesma parte, subtrai ao remover parte ou mensagem | `session/projectors.ts:32-45,145-196` | +| Upsert de `message` e `part` atualiza só `data`; `time_created` é preservado no conflito | `session/projectors.ts:130-138,178-187` | +| Violação de FK ao gravar mensagem ou parte de sessão já apagada é engolida e logada, não é erro | `session/projectors.ts:16-20,139-142,192-195` | +| Guarda de relógio: se o maior `message.time_created` da sessão excede `agora + 5000`, a nova mensagem recebe `max + 1` | `session/session.ts:694-706` | +| Em patch parcial, `null` limpa o campo e chave ausente não toca a coluna; `summary` limpo grava NULL nas 3 colunas e no diff | `session/projectors.ts:57-97` | +| `remove` apaga descendentes recursivamente em aplicação (não há FK em `parent_id`) e emite `session.deleted` de cada um antes do pai | `session/session.ts:647-690` | +| `todo` é substituição total por sessão dentro de uma transação | `session/todo.ts:43-46` | +| Inserção de sessão exige linha em `project`; FK real com `foreign_keys=ON` | `session.sql.ts:20-23` | +| `session.created` incrementa `workspace.time_used` quando há `workspaceID` | `session/projectors.ts:105-107` | + +## 4. API HTTP + +### 4.1 Convenções + +- Prefixo `/v1/session`. Os endpoints atuais `/session/*` permanecem intocados até M2 removê-los, porque `packages/core/src/router/client.ts` e `packages/core/test/parity/session-crud.test.ts` dependem deles. +- JSON com os nomes de campo do `Session.Info` e do `MessageV2` do TS, em camelCase, para M2 usar `Schema.decode` sem camada de tradução. +- Erros: `{"error":""}`. Mensagens de não-encontrado são as mesmas que os testes TS afirmam verbatim: `Session not found: `, `Message not found: `. +- O cliente pode enviar `id`; ausente, o Go gera. +- Toda mutação roda em `BEGIN IMMEDIATE` e publica seus eventos após o commit. + +### 4.2 Endpoints + +| Método TS | Endpoint | Corpo / parâmetros | Resposta | +| --- | --- | --- | --- | +| `create` | `POST /v1/session` | `{id?, projectID, workspaceID?, parentID?, title?, agent?, model?, permission?, directory, path?, version}` | 201 `Info` | +| `get` | `GET /v1/session/{id}` | | `Info`; 404 | +| `list`, `listByProject`, `listGlobal` | `GET /v1/session` | `projectID`, `directory`, `path`, `archived`, `search`, `limit`, `parentID` (`null` lista só raízes) | `Info[]` em `time_updated DESC, id DESC` | +| `children` | `GET /v1/session/{id}/children` | | `Info[]` | +| `touch`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `clearRevert`, `setSummary` | `PATCH /v1/session/{id}` | patch parcial `UpdatedInfo` (`session/session.ts:356-380`) | `Info` completo relido | +| `remove` | `DELETE /v1/session/{id}` | | 204 | +| `fork` | `POST /v1/session/{id}/fork` | `{messageID?}` | 201 `Info` da nova sessão | +| `messages` | `GET /v1/session/{id}/messages` | `limit` (padrão 50), `before` (cursor opaco) | `{messages: WithParts[], more, cursor}` | +| `updateMessage` | `PUT /v1/session/{id}/message/{messageID}` | `Info` de mensagem | `Info` | +| `removeMessage` | `DELETE /v1/session/{id}/message/{messageID}` | | 204 | +| `getPart` | `GET /v1/session/{id}/message/{messageID}/part/{partID}` | | `Part`; 404 | +| `updatePart` | `PUT /v1/session/{id}/message/{messageID}/part/{partID}` | `Part` | `Part` | +| `removePart` | `DELETE /v1/session/{id}/message/{messageID}/part/{partID}` | | 204 | +| `Todo.get` | `GET /v1/session/{id}/todo` | | `Todo[]` em `position ASC` | +| `Todo.update` | `PUT /v1/session/{id}/todo` | `Todo[]` | `Todo[]` | + +Paginação de mensagens replica `MessageV2.page` (`message-v2.ts:947-986`): `ORDER BY time_created DESC, id DESC LIMIT limit+1`, predicado `time_created < t OR (time_created = t AND id < id)`, cursor `base64url({id, time})`, resposta invertida para ordem cronológica, partes ordenadas por `id`. Resposta vazia consulta a existência da sessão e devolve 404 se não existir. + +`fork` replica `session.ts:768-808`: nova sessão com mesmo `projectID`, `directory`, `parentID`; copia mensagens e partes com `id < messageID` (ou todas), gerando IDs novos e mantendo a ordem, e emite `message.updated` e `message.part.updated` para cada uma. + +### 4.3 O que o Go não valida + +O Go não valida o conteúdo de `message.data` e `part.data` além de ser JSON válido. A responsabilidade de esquema segue no cliente (TS em M2, conversores em M3). Isso mantém o Go estável quando o TS acrescenta variante de parte, e é a razão de M3 nascer com structs completas em vez de reaproveitar structs de M1. + +## 5. Eventos + +### 5.1 Bus + +O `internal/eventbus` atual é substituído. Problemas conhecidos: ID gerado por contador formatado como hora (não único, não monotônico), `Publish` descarta evento quando o buffer do assinante está cheio, frame SSE sem `id:`. + +Novo comportamento: + +- `seq` monotônico por sessão, iniciado em 0 na primeira publicação do processo, e `seq` global para o fluxo sem filtro. +- `id` do evento no esquema `evt_` de §3.4. +- Anel dos últimos 1.000 eventos por sessão e 10.000 no global, para replay. +- Sem descarte: assinante que não consome recebe `server.lagged` e é desconectado; ao reconectar com `Last-Event-ID: ` recebe o que perdeu se ainda estiver no anel, senão `server.replay_unavailable` e o cliente relê o estado por HTTP. + +### 5.2 Tipos e payloads + +Mesmos nomes e formas do Bus TS (`session/session.ts:382-418`, `session/message-v2.ts`): + +| Tipo | Payload | +| --- | --- | +| `session.created` | `{sessionID, info}` | +| `session.updated` | `{sessionID, info}` com `info` completo relido após o patch, como `server/projectors.ts:11-27` | +| `session.deleted` | `{sessionID, info}` | +| `message.updated` | `{info}` | +| `message.removed` | `{sessionID, messageID}` | +| `message.part.updated` | `{sessionID, part, time}` | +| `message.part.removed` | `{sessionID, messageID, partID}` | +| `server.connected`, `server.heartbeat`, `server.lagged`, `server.replay_unavailable` | controle | + +### 5.3 Endpoints SSE + +- `GET /v1/events?sessionID=`: eventos da sessão. +- `GET /v1/events`: todos os eventos, para a lista de sessões em M2. +- Frame: `event: `, `id: `, `data: `. Heartbeat a cada 10 segundos. Cabeçalhos iguais aos do SSE atual. + +## 6. Concorrência TS×Go no mesmo arquivo + +Durante M2 os dois processos escrevem no mesmo arquivo. Garantias de M1 do lado Go: + +- Escrita serializada no processo, `BEGIN IMMEDIATE`, `busy_timeout=10000`. Estouro vira 503 `busy`. +- Go não executa a rotina de recuperação de WAL. O TS executa e presume processo único; retirá-la ou condicioná-la é pré-requisito de M2 e fica registrado lá. +- Eventos SSE descrevem só escritas feitas pelo Go. Escrita do TS direto no arquivo não gera evento; a dupla escrita de M2 resolve mandando tudo pelo Go. +- Linha com `data` que não decodifica não derruba a resposta: vira placeholder e é logada. + +## 7. Testes + +### 7.1 Unitários Go + +Banco temporário criado pela reprodução das migrações do TS, lidas em tempo de teste de `packages/teamcode/migration/*/migration.sql` (ordenadas pelo prefixo `YYYYMMDDHHMMSS`, separadas por `;`). O Go não embute o esquema; migração nova no TS é exercitada automaticamente. Cobertura mínima: cada endpoint; ordenações; cursor e `more`; delta de uso com reversão; patch com `null` e chave ausente; guarda de relógio; FK tardia engolida; `remove` recursivo; `fork`; `todo`; `schema_outdated`; 503 `busy`. + +### 7.2 IDs cruzados + +Fixture `go-core/internal/ident/testdata/ids-from-ts.json` gerada por script Bun a partir de `id.ts`, com 200 IDs de cada prefixo e o timestamp esperado. O teste Go verifica timestamp e ordenação. + +### 7.3 Paridade TS×Go + +Em `packages/core/test/parity/`, o harness existente passa a rodar o mesmo roteiro contra o `Session.Service` do TS e contra `/v1/session` do Go, cada um em seu arquivo `.db` temporário. Ao final, os dumps das tabelas `session`, `message`, `part`, `todo` são comparados campo a campo, ignorando IDs gerados e mapeando-os por posição. Hoje o harness compara o Go com expectativas fixas; M1 introduz a comparação com o TS como baseline. + +### 7.4 Concorrência + +Teste que grava 1.000 partes em paralelo pelo TS e pelo Go no mesmo arquivo. Critério: zero `SQLITE_BUSY` além do timeout, zero linha perdida, contadores de uso da sessão iguais à soma esperada. + +### 7.5 Desempenho + +`GET /v1/session/{id}/messages` com 50.000 mensagens e 5 partes cada, medido no teste de paridade com limite de 5 segundos em CI. Serve para decidir se `modernc.org/sqlite` basta ou se M2 precisa de driver cgo. + +### 7.6 CI + +Job `go-core` no workflow existente: `gofmt -l`, `go vet ./...`, `golangci-lint run`, `go test ./...`, disparado por mudança em `go-core/**`. Hoje não há job de Go. + +## 8. Riscos + +| Risco | Mitigação | +| --- | --- | +| `modernc.org/sqlite` mais lento que cgo em escrita pesada | Medido em §7.5; troca de driver é local ao `internal/store` | +| Esquema do TS muda sem atualizar a constante do Go | Modo degradado explícito em vez de escrita cega; teste unitário replica as migrações e quebra se uma tabela usada mudar | +| Dois processos no mesmo arquivo | §6; teste §7.4; M2 remove a recuperação de WAL do TS | +| Paridade incompleta por regra do TS não catalogada | Comparação de dumps em §7.3 pega divergência que o roteiro não previu | + +## 9. Critérios de aceite + +1. `go test ./...` verde, incluindo fixture de IDs e reprodução das 22 migrações. +2. Paridade §7.3 sem diferença de dump para o roteiro completo. +3. Concorrência §7.4 sem perda. +4. `GET /v1/session/{id}/messages` dentro do limite de §7.5. +5. `bun test` do `packages/teamcode` inalterado e verde: nada no TS muda. +6. Job de CI `go-core` verde no PR.