This commit is contained in:
Ian Gulliver
2026-10-18 21:24:41 -07:00
parent 7728484e86
commit 57788cc711
59 changed files with 465 additions and 512 deletions
+33 -511
View File
@@ -4,10 +4,12 @@ import (
"context"
"crypto/rand"
"database/sql"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
@@ -19,484 +21,36 @@ import (
var ErrNotFound = errors.New("not found")
//go:embed migrations/*.sql
var migrationFiles embed.FS
type migration struct {
name string
sql string
}
var migrations = []migration{{"create-core-tables", `
CREATE TABLE IF NOT EXISTS users (
login TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
git_name TEXT NOT NULL DEFAULT '',
git_email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
func loadMigrations() ([]migration, error) {
entries, err := migrationFiles.ReadDir("migrations")
if err != nil {
return nil, err
}
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
repo_path TEXT NOT NULL UNIQUE,
default_branch TEXT NOT NULL,
remote TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(login),
created_at TEXT NOT NULL
);
migrations := []migration{}
for _, e := range entries {
num, name, ok := strings.Cut(strings.TrimSuffix(e.Name(), ".sql"), "-")
_, err = strconv.Atoi(num)
if !ok || len(num) != 3 || err != nil {
return nil, fmt.Errorf("migration file %s: want NNN-name.sql", e.Name())
}
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
title TEXT NOT NULL,
branch TEXT NOT NULL,
worktree_path TEXT NOT NULL,
base_ref TEXT NOT NULL,
status TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(login),
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS tasks_project ON tasks(project_id, created_at);
CREATE TABLE IF NOT EXISTS events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
project_id TEXT,
task_id TEXT,
actor TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
);
`}, {"add-agent-sessions", `
ALTER TABLE events ADD COLUMN agent_session_id TEXT;
CREATE TABLE agent_sessions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
vendor TEXT NOT NULL,
model TEXT NOT NULL,
vendor_session_id TEXT NOT NULL,
state TEXT NOT NULL,
role TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(login),
created_at TEXT NOT NULL
);
CREATE INDEX agent_sessions_task ON agent_sessions(task_id, created_at);
CREATE INDEX agent_sessions_state ON agent_sessions(state);
`}, {"add-inbox", `
CREATE TABLE inbox_items (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
task_id TEXT NOT NULL REFERENCES tasks(id),
agent_session_id TEXT NOT NULL REFERENCES agent_sessions(id),
thread_id TEXT NOT NULL,
kind TEXT NOT NULL,
priority INTEGER NOT NULL,
blocking INTEGER NOT NULL,
payload TEXT NOT NULL,
state TEXT NOT NULL,
snoozed_until TEXT,
created_at TEXT NOT NULL,
resolved_by TEXT REFERENCES users(login),
resolved_at TEXT
);
CREATE INDEX inbox_items_open ON inbox_items(state, priority, created_at);
CREATE INDEX inbox_items_session ON inbox_items(agent_session_id, state);
CREATE TABLE inbox_responses (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES inbox_items(id),
user_login TEXT NOT NULL REFERENCES users(login),
type TEXT NOT NULL,
option TEXT,
text TEXT,
attachments TEXT NOT NULL,
snooze_until TEXT,
created_at TEXT NOT NULL,
delivered_at TEXT
);
CREATE INDEX inbox_responses_item ON inbox_responses(item_id, created_at);
`}, {"index-events-by-task", `
CREATE INDEX events_task ON events(task_id, seq);
`}, {"add-turn-queue", `
CREATE TABLE turn_queue (
session_id TEXT PRIMARY KEY REFERENCES agent_sessions(id),
actor TEXT NOT NULL,
text TEXT NOT NULL,
created_at TEXT NOT NULL
);
`}, {"merge-response-types-into-other", `
UPDATE inbox_responses SET type = 'other' WHERE type IN ('free_form', 'redirect', 'question');
UPDATE inbox_items SET state = 'answered' WHERE state = 'redirected';
`}, {"flatten-report-payloads-to-text", `
UPDATE inbox_items SET payload = json_object('text', coalesce((SELECT group_concat('- ' || value, char(10)) FROM json_each(inbox_items.payload, '$.done')), ''))
WHERE kind = 'report' AND json_type(payload, '$.done') IS NOT NULL;
`}, {"rename-completed-tasks-to-discarded", `
ALTER TABLE tasks RENAME COLUMN completed_at TO closed_at;
ALTER TABLE tasks ADD COLUMN merge_commit TEXT NOT NULL DEFAULT '';
UPDATE tasks SET status = 'discarded' WHERE status = 'completed';
`}, {"dismiss-inbox-of-closed-tasks", `
UPDATE inbox_items SET state = 'dismissed', resolved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), snoozed_until = NULL
WHERE state = 'open' AND task_id IN (SELECT id FROM tasks WHERE status != 'active');
`}, {"add-blobs-and-queued-images", `
CREATE TABLE IF NOT EXISTS blobs (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
path TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(login),
created_at TEXT NOT NULL
);
CREATE TABLE turn_queue_next (
session_id TEXT PRIMARY KEY REFERENCES agent_sessions(id),
actor TEXT NOT NULL,
text TEXT NOT NULL,
images TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
INSERT INTO turn_queue_next (session_id, actor, text, created_at) SELECT session_id, actor, text, created_at FROM turn_queue;
DROP TABLE turn_queue;
ALTER TABLE turn_queue_next RENAME TO turn_queue;
`}, {"add-task-merge-title", `
ALTER TABLE tasks ADD COLUMN merge_title TEXT NOT NULL DEFAULT '';
`}, {"add-web-push", `
ALTER TABLE users ADD COLUMN notification_prefs TEXT NOT NULL
DEFAULT '{"blocking_questions":true,"questions":false,"reports":false,"errors":false,"rate_limits":false}';
CREATE TABLE vapid_keys (
id INTEGER PRIMARY KEY CHECK (id = 1),
public_key TEXT NOT NULL,
private_key TEXT NOT NULL
);
CREATE TABLE push_subscriptions (
endpoint TEXT PRIMARY KEY,
user_login TEXT NOT NULL REFERENCES users(login),
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
subscriber TEXT NOT NULL,
created_at TEXT NOT NULL
);
`}, {"add-snapshots", `
CREATE TABLE snapshots (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
agent_session_id TEXT NOT NULL REFERENCES agent_sessions(id),
n INTEGER NOT NULL,
commit_sha TEXT NOT NULL,
tree_sha TEXT NOT NULL,
event_seq INTEGER NOT NULL,
trigger TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (task_id, n)
);
`}, {"inline-question-choices", `
ALTER TABLE inbox_responses ADD COLUMN options TEXT;
UPDATE inbox_responses SET options = json_array(option) WHERE option IS NOT NULL;
ALTER TABLE inbox_responses DROP COLUMN option;
UPDATE inbox_items SET payload = json_set(json_remove(payload, '$.recommended_option'),
'$.context', json_extract(payload, '$.context') || char(10) || char(10) || (
SELECT group_concat(CASE WHEN o.value = json_extract(inbox_items.payload, '$.recommended_option') THEN '- (*) ' ELSE '- ( ) ' END || o.value, char(10))
FROM json_each(inbox_items.payload, '$.options') o),
'$.multiple', json('false'),
'$.recommended', json_array(json_extract(payload, '$.recommended_option')))
WHERE kind = 'question' AND json_type(payload, '$.recommended_option') IS NOT NULL;
`}, {"unsnooze-inbox-items", `
UPDATE inbox_items SET snoozed_until = NULL WHERE snoozed_until IS NOT NULL;
`}, {"add-task-todo-item", `
ALTER TABLE tasks ADD COLUMN todo_item TEXT NOT NULL DEFAULT '';
`}, {"add-lead-agents", `
CREATE TABLE agent_sessions_next (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks(id),
owner TEXT NOT NULL DEFAULT '',
vendor TEXT NOT NULL,
model TEXT NOT NULL,
vendor_session_id TEXT NOT NULL,
state TEXT NOT NULL,
role TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(login),
created_at TEXT NOT NULL
);
INSERT INTO agent_sessions_next (id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at)
SELECT id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at FROM agent_sessions;
DROP TABLE agent_sessions;
ALTER TABLE agent_sessions_next RENAME TO agent_sessions;
CREATE INDEX agent_sessions_task ON agent_sessions(task_id, created_at);
CREATE INDEX agent_sessions_state ON agent_sessions(state);
CREATE INDEX agent_sessions_owner ON agent_sessions(owner, created_at);
ALTER TABLE events ADD COLUMN owner TEXT;
CREATE INDEX events_owner ON events(owner, seq) WHERE owner IS NOT NULL;
ALTER TABLE users ADD COLUMN focus TEXT NOT NULL DEFAULT '{"tabs":["transcript"],"tab":"transcript"}';
CREATE TABLE lead_inputs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL REFERENCES users(login),
kind TEXT NOT NULL,
actor TEXT NOT NULL,
text TEXT NOT NULL,
images TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
CREATE INDEX lead_inputs_owner ON lead_inputs(owner, id);
`}, {"add-focus-project", `
UPDATE users SET focus = json_set(focus, '$.project_id', (SELECT project_id FROM tasks WHERE id = json_extract(users.focus, '$.task_id')))
WHERE json_extract(focus, '$.task_id') IS NOT NULL;
DELETE FROM lead_inputs WHERE text LIKE 'The human switched the right pane %' OR text = 'The human cleared the right pane.';
`}, {"add-task-merging-by", `
ALTER TABLE tasks ADD COLUMN merging_by TEXT NOT NULL DEFAULT '';
`}, {"flag-handoff-prompts", `
UPDATE events SET payload = json_set(payload, '$.handoff', json('true'))
WHERE kind = 'user.message' AND actor = 'byre' AND json_extract(payload, '$.text') LIKE 'Write a handoff for a fresh %';
`}, {"rename-todo-tab-to-project", `
UPDATE users SET focus = json_set(focus,
'$.tab', CASE json_extract(focus, '$.tab') WHEN 'todo' THEN 'project' ELSE json_extract(focus, '$.tab') END,
'$.tabs', json((SELECT json_group_array(CASE value WHEN 'todo' THEN 'project' ELSE value END) FROM json_each(users.focus, '$.tabs'))));
`}, {"add-snapshot-forks", `
ALTER TABLE tasks ADD COLUMN forked_from TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN forked_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN forked_from TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_sessions ADD COLUMN forked_at TEXT NOT NULL DEFAULT '';
`}, {"add-task-port-base", `
ALTER TABLE tasks ADD COLUMN port_base INTEGER NOT NULL DEFAULT 0;
UPDATE tasks SET port_base = (
SELECT 41000 + 10 * (count(*) - 1) FROM tasks AS earlier
WHERE earlier.status = 'active' AND (earlier.created_at < tasks.created_at OR (earlier.created_at = tasks.created_at AND earlier.id <= tasks.id))
) WHERE status = 'active' AND port_base = 0;
`}, {"add-task-merge-queue", `
ALTER TABLE tasks ADD COLUMN merge_queued_by TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN merge_queued_at TEXT NOT NULL DEFAULT '';
`}, {"add-task-previews", `
CREATE TABLE previews (
task_id TEXT NOT NULL REFERENCES tasks(id),
name TEXT NOT NULL,
port INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (task_id, name),
UNIQUE (task_id, port)
);
`}, {"add-task-color", `
ALTER TABLE tasks ADD COLUMN color INTEGER NOT NULL DEFAULT 0;
UPDATE tasks SET color = (SELECT count(*) FROM tasks AS earlier WHERE earlier.rowid < tasks.rowid) % 11;
UPDATE tasks SET color = (
SELECT count(*) FROM tasks AS earlier WHERE earlier.status = 'active' AND earlier.rowid < tasks.rowid
) % 11 WHERE status = 'active';
`}, {"drop-turn-queue", `
UPDATE agent_sessions SET state = 'idle' WHERE state = 'queued';
DROP TABLE turn_queue;
`}, {"add-snapshot-reviews", `
CREATE TABLE reviews (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
snapshot_n INTEGER NOT NULL,
agent_session_id TEXT NOT NULL REFERENCES agent_sessions(id),
vendor TEXT NOT NULL,
model TEXT NOT NULL,
state TEXT NOT NULL,
result TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
finished_at TEXT NOT NULL DEFAULT '',
UNIQUE (task_id, snapshot_n)
);
CREATE UNIQUE INDEX reviews_session ON reviews(agent_session_id);
`}, {"add-task-inputs", `
CREATE TABLE task_inputs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id),
actor TEXT NOT NULL,
text TEXT NOT NULL,
images TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
CREATE INDEX task_inputs_task ON task_inputs(task_id, id);
`}, {"drop-web-push", `
DROP TABLE push_subscriptions;
DROP TABLE vapid_keys;
`}, {"delete-report-inbox-items", `
DELETE FROM inbox_responses WHERE item_id IN (SELECT id FROM inbox_items WHERE kind = 'report');
DELETE FROM inbox_items WHERE kind = 'report';
`}, {"allow-sessionless-reviews", `
CREATE TABLE reviews_new (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
snapshot_n INTEGER NOT NULL,
agent_session_id TEXT REFERENCES agent_sessions(id),
vendor TEXT NOT NULL,
model TEXT NOT NULL,
state TEXT NOT NULL,
result TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
finished_at TEXT NOT NULL DEFAULT '',
UNIQUE (task_id, snapshot_n)
);
INSERT INTO reviews_new SELECT * FROM reviews;
DROP TABLE reviews;
ALTER TABLE reviews_new RENAME TO reviews;
CREATE UNIQUE INDEX reviews_session ON reviews(agent_session_id);
`}, {"add-workflows", `
CREATE TABLE workflows (
name TEXT PRIMARY KEY,
definition TEXT NOT NULL
);
ALTER TABLE tasks ADD COLUMN workflow_name TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN workflow_definition TEXT NOT NULL DEFAULT '';
`}, {"run-workflow-stages", `
ALTER TABLE tasks ADD COLUMN workflow_brief TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_sessions ADD COLUMN workflow_stage INTEGER NOT NULL DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN stage_output TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_sessions ADD COLUMN stage_finished INTEGER NOT NULL DEFAULT 0;
UPDATE workflows SET definition = json_set(definition, '$.stages',
(SELECT json_group_array(json_remove(value, '$.vendor')) FROM (SELECT value FROM json_each(definition, '$.stages') ORDER BY key)));
UPDATE tasks SET workflow_definition = json_set(workflow_definition, '$.stages',
(SELECT json_group_array(json_remove(value, '$.vendor')) FROM (SELECT value FROM json_each(workflow_definition, '$.stages') ORDER BY key)))
WHERE workflow_definition != '';
`}, {"workflow-stage-tasks", `
ALTER TABLE tasks ADD COLUMN workflow_run TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN workflow_stage TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN workflow_title TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN stage_output TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN stage_commit TEXT NOT NULL DEFAULT '';
UPDATE tasks SET workflow_run = id, workflow_title = title,
workflow_stage = coalesce(
(SELECT json_extract(tasks.workflow_definition, '$.stages[' || (s.workflow_stage - 1) || '].name') FROM agent_sessions s
WHERE s.task_id = tasks.id AND s.workflow_stage > 0 ORDER BY s.created_at DESC LIMIT 1),
json_extract(workflow_definition, '$.stages[0].name'))
WHERE workflow_name != '' AND workflow_run = '';
`}, {"drop-preview-tab", `
UPDATE users SET focus = json_set(focus,
'$.tab', CASE json_extract(focus, '$.tab') WHEN 'preview' THEN 'transcript' ELSE json_extract(focus, '$.tab') END,
'$.tabs', json((SELECT json_group_array(value) FROM json_each(users.focus, '$.tabs') WHERE value != 'preview')));
UPDATE users SET focus = json_set(focus, '$.tabs', json('["transcript"]')) WHERE json_array_length(focus, '$.tabs') = 0;
`}, {"drop-inbox-priority", `
CREATE TABLE inbox_items_next (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
task_id TEXT NOT NULL REFERENCES tasks(id),
agent_session_id TEXT NOT NULL REFERENCES agent_sessions(id),
thread_id TEXT NOT NULL,
kind TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
blocking INTEGER NOT NULL DEFAULT 1,
payload TEXT NOT NULL,
state TEXT NOT NULL,
snoozed_until TEXT,
created_at TEXT NOT NULL,
resolved_by TEXT REFERENCES users(login),
resolved_at TEXT
);
INSERT INTO inbox_items_next SELECT id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state, snoozed_until, created_at, resolved_by, resolved_at FROM inbox_items;
DROP TABLE inbox_items;
ALTER TABLE inbox_items_next RENAME TO inbox_items;
CREATE INDEX inbox_items_open ON inbox_items(state, created_at);
CREATE INDEX inbox_items_session ON inbox_items(agent_session_id, state);
CREATE TABLE users_next (
login TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
git_name TEXT NOT NULL DEFAULT '',
git_email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
notification_prefs TEXT NOT NULL DEFAULT '{"questions":true,"reports":false,"errors":false,"rate_limits":false}',
focus TEXT NOT NULL DEFAULT '{"tabs":["transcript"],"tab":"transcript"}'
);
INSERT INTO users_next (login, display_name, git_name, git_email, created_at, notification_prefs, focus)
SELECT login, display_name, git_name, git_email, created_at,
json_remove(json_set(notification_prefs, '$.questions', json(CASE WHEN json_extract(notification_prefs, '$.blocking_questions') THEN 'true' ELSE 'false' END)), '$.blocking_questions'),
focus
FROM users;
DROP TABLE users;
ALTER TABLE users_next RENAME TO users;
`}, {"add-task-summary-and-discarding-by", `
ALTER TABLE tasks ADD COLUMN summary TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN discarding_by TEXT NOT NULL DEFAULT '';
`}, {"add-task-discard-state", `
ALTER TABLE tasks ADD COLUMN discard_state TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN discard_session TEXT NOT NULL DEFAULT '';
UPDATE tasks SET discard_state = 'held' WHERE discarding_by != '' AND status = 'active';
`}, {"add-subtasks", `
ALTER TABLE tasks ADD COLUMN parent_task_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN parent_merge_by TEXT NOT NULL DEFAULT '';
CREATE INDEX tasks_parent ON tasks(parent_task_id) WHERE parent_task_id != '';
`}, {"add-review-finding-states", `
ALTER TABLE reviews ADD COLUMN finding_states TEXT NOT NULL DEFAULT '{}';
UPDATE events SET payload = json_set(payload, '$.finding_states', json('{}'))
WHERE kind IN ('review.started', 'review.finished') AND json_type(payload, '$.finding_states') IS NULL;
`}, {"add-lead-notes", `
CREATE TABLE lead_notes (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL REFERENCES users(login),
text TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX lead_notes_owner ON lead_notes(owner, created_at);
`}, {"add-summary-reads", `
CREATE INDEX events_summary ON events(task_id, seq) WHERE kind = 'turn.ended' AND json_extract(payload, '$.summary') = 1;
CREATE TABLE summary_reads (
login TEXT NOT NULL REFERENCES users(login),
task_id TEXT NOT NULL REFERENCES tasks(id),
seq INTEGER NOT NULL,
PRIMARY KEY (login, task_id)
);
`}, {"add-task-finalizing", `
ALTER TABLE tasks ADD COLUMN finalizing TEXT NOT NULL DEFAULT '';
`}, {"add-task-state", `
ALTER TABLE tasks ADD COLUMN state TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN review_requests INTEGER NOT NULL DEFAULT 0;
UPDATE tasks SET state = ` + taskStateExpr + `;
`}, {"add-task-finalizing-commit", `
ALTER TABLE tasks ADD COLUMN finalizing_commit TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN finalizing_notes TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN finalizing_unchanged INTEGER NOT NULL DEFAULT 0;
`}, {"add-task-merge-after-review", `
ALTER TABLE tasks ADD COLUMN merge_after_review_by TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN merge_after_review_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN merge_after_review_target TEXT NOT NULL DEFAULT '';
`}, {"add-inbox-response-images", `
ALTER TABLE inbox_responses ADD COLUMN images TEXT NOT NULL DEFAULT '[]';
`}, {"add-lead-note-projects", `
ALTER TABLE lead_notes ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
`}, {"add-session-subagents", `
ALTER TABLE agent_sessions ADD COLUMN subagents INTEGER NOT NULL DEFAULT 0;
`}, {"add-inbox-run-responses", `
ALTER TABLE inbox_responses ADD COLUMN client TEXT NOT NULL DEFAULT '';
ALTER TABLE inbox_responses ADD COLUMN exit_code INTEGER;
`}, {"drop-client-runs", `
DELETE FROM inbox_responses WHERE item_id IN (SELECT id FROM inbox_items WHERE kind = 'client_run');
DELETE FROM inbox_items WHERE kind = 'client_run';
ALTER TABLE inbox_responses DROP COLUMN client;
ALTER TABLE inbox_responses DROP COLUMN exit_code;
`}, {"add-user-default-model", `
ALTER TABLE users ADD COLUMN default_model TEXT NOT NULL DEFAULT 'claude-opus-5-5';
`}, {"add-team-members", `
ALTER TABLE agent_sessions ADD COLUMN member TEXT NOT NULL DEFAULT '';
ALTER TABLE task_inputs ADD COLUMN member TEXT NOT NULL DEFAULT '';
`}, {"rename-inbox-tab", `
UPDATE users SET focus = replace(focus, '"inbox"', '"questions"');
`}, {"add-inbox-response-snapshots", `
ALTER TABLE inbox_responses ADD COLUMN snapshot_n INTEGER;
`}, {"errors-to-transcript", `
UPDATE events SET kind = 'session.error', payload = json_object('error', json_extract(payload, '$.payload.error'))
WHERE kind = 'inbox.created' AND json_extract(payload, '$.kind') = 'session_error';
UPDATE events SET kind = 'session.rate_limited', payload = json_object(
'vendor', json_extract(payload, '$.payload.vendor'),
'rate_limit', json(json_extract(payload, '$.payload.rate_limit')),
'error', json_extract(payload, '$.payload.error'))
WHERE kind = 'inbox.created' AND json_extract(payload, '$.kind') = 'rate_limited';
DELETE FROM events WHERE kind IN ('inbox.created', 'inbox.updated') AND json_extract(payload, '$.kind') != 'question';
UPDATE events SET payload = json_remove(payload, '$.kind') WHERE kind IN ('inbox.created', 'inbox.updated');
DELETE FROM inbox_responses WHERE item_id IN (SELECT id FROM inbox_items WHERE kind != 'question');
DELETE FROM inbox_items WHERE kind != 'question';
ALTER TABLE inbox_items DROP COLUMN kind;
`}}
sql, err := migrationFiles.ReadFile("migrations/" + e.Name())
if err != nil {
return nil, err
}
migrations = append(migrations, migration{name: name, sql: string(sql)})
}
return migrations, nil
}
const (
DefaultPortFirst = 41000
@@ -519,7 +73,7 @@ func Open(path string, portFirst int, onEvent func([]api.Event)) (*Store, error)
return nil, err
}
err = migrate(db, migrations)
err = migrate(db)
if err != nil {
db.Close()
return nil, err
@@ -528,8 +82,13 @@ func Open(path string, portFirst int, onEvent func([]api.Event)) (*Store, error)
return &Store{db: db, path: path, portFirst: portFirst, onEvent: onEvent}, nil
}
func migrate(db *sql.DB, migrations []migration) error {
err := createMigrationsTable(db, migrations)
func migrate(db *sql.DB) error {
migrations, err := loadMigrations()
if err != nil {
return err
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`)
if err != nil {
return err
}
@@ -553,43 +112,6 @@ func migrate(db *sql.DB, migrations []migration) error {
return nil
}
func createMigrationsTable(db *sql.DB, migrations []migration) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
exists := 0
err = tx.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'migrations'`).Scan(&exists)
if err != nil {
return err
}
if exists > 0 {
return nil
}
_, err = tx.Exec(`CREATE TABLE migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`)
if err != nil {
return err
}
version := 0
err = tx.QueryRow(`PRAGMA user_version`).Scan(&version)
if err != nil {
return err
}
for _, m := range migrations[:min(version, len(migrations))] {
_, err = tx.Exec(`INSERT INTO migrations (name, applied_at) VALUES (?, ?)`, m.name, now())
if err != nil {
return err
}
}
return tx.Commit()
}
func appliedMigrations(db *sql.DB) (map[string]bool, error) {
rows, err := db.Query(`SELECT name FROM migrations`)
if err != nil {