diff --git a/docs/operations.md b/docs/operations.md index 142e6ca..2a1710a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -22,7 +22,7 @@ State lives in the directory given by `byre serve --data`, by default the `byre` directory under Go's `os.UserConfigDir()` (on macOS, `~/Library/Application Support/byre`): `byre.db`, `worktrees//`, `worktrees//-media` (the task's media folder, see "Screenshots from implementers"), `leads/` (each lead agent's working directory, the login path-escaped), `hands//byre-hands` (each running implementer session's copy of `byre-hands`, see "Screen and app control"), `blobs/` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory). Task branches are `byre/` in the project's repo. -Schema changes are the `migrations` slice in `internal/store/store.go`: named entries, each with a short kebab-case name saying what it does. On open, byre applies, in slice order, every entry whose name isn't in the `migrations` table, each in its own transaction that also records the name. Each runs with foreign keys off, so a migration can rebuild a table other tables reference, and `PRAGMA foreign_key_check` must come back empty before it commits. byre is deployed from task branches that each append their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. `sqlite3 byre.db 'SELECT * FROM migrations'` lists what has been applied. A database from before the table existed is moved over on open: the first `PRAGMA user_version` entries are recorded as applied, and `user_version` is no longer read or written. +Schema changes are the files in `internal/store/migrations/`, embedded in the binary, each named `NNN-name.sql`: a three-digit number, then a short kebab-case name saying what it does. On open, byre applies, in file name order, every file whose name (without the number) isn't in the `migrations` table, each in its own transaction that also records the name. The number only orders: a migration that needs another's tables or columns takes a higher number, and migrations with the same number must not depend on each other. Each runs with foreign keys off, so a migration can rebuild a table other tables reference, and `PRAGMA foreign_key_check` must come back empty before it commits. A migration's SQL is fixed once applied anywhere: write it out in full rather than sharing Go constants that later change. byre is deployed from task branches that each add their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. `sqlite3 byre.db 'SELECT * FROM migrations'` lists what has been applied. ## Worktree setup and ports diff --git a/internal/store/migrations/001-create-core-tables.sql b/internal/store/migrations/001-create-core-tables.sql new file mode 100644 index 0000000..2254ec2 --- /dev/null +++ b/internal/store/migrations/001-create-core-tables.sql @@ -0,0 +1,42 @@ +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 +); + +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 +); + +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 +); diff --git a/internal/store/migrations/002-add-agent-sessions.sql b/internal/store/migrations/002-add-agent-sessions.sql new file mode 100644 index 0000000..fcb1708 --- /dev/null +++ b/internal/store/migrations/002-add-agent-sessions.sql @@ -0,0 +1,16 @@ +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); diff --git a/internal/store/migrations/003-add-inbox.sql b/internal/store/migrations/003-add-inbox.sql new file mode 100644 index 0000000..2cb0997 --- /dev/null +++ b/internal/store/migrations/003-add-inbox.sql @@ -0,0 +1,34 @@ +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); diff --git a/internal/store/migrations/004-index-events-by-task.sql b/internal/store/migrations/004-index-events-by-task.sql new file mode 100644 index 0000000..73a1c9f --- /dev/null +++ b/internal/store/migrations/004-index-events-by-task.sql @@ -0,0 +1 @@ +CREATE INDEX events_task ON events(task_id, seq); diff --git a/internal/store/migrations/005-add-turn-queue.sql b/internal/store/migrations/005-add-turn-queue.sql new file mode 100644 index 0000000..3de0b16 --- /dev/null +++ b/internal/store/migrations/005-add-turn-queue.sql @@ -0,0 +1,6 @@ +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 +); diff --git a/internal/store/migrations/006-merge-response-types-into-other.sql b/internal/store/migrations/006-merge-response-types-into-other.sql new file mode 100644 index 0000000..28894da --- /dev/null +++ b/internal/store/migrations/006-merge-response-types-into-other.sql @@ -0,0 +1,2 @@ +UPDATE inbox_responses SET type = 'other' WHERE type IN ('free_form', 'redirect', 'question'); +UPDATE inbox_items SET state = 'answered' WHERE state = 'redirected'; diff --git a/internal/store/migrations/007-flatten-report-payloads-to-text.sql b/internal/store/migrations/007-flatten-report-payloads-to-text.sql new file mode 100644 index 0000000..179c990 --- /dev/null +++ b/internal/store/migrations/007-flatten-report-payloads-to-text.sql @@ -0,0 +1,2 @@ +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; diff --git a/internal/store/migrations/008-rename-completed-tasks-to-discarded.sql b/internal/store/migrations/008-rename-completed-tasks-to-discarded.sql new file mode 100644 index 0000000..067b8c3 --- /dev/null +++ b/internal/store/migrations/008-rename-completed-tasks-to-discarded.sql @@ -0,0 +1,3 @@ +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'; diff --git a/internal/store/migrations/009-dismiss-inbox-of-closed-tasks.sql b/internal/store/migrations/009-dismiss-inbox-of-closed-tasks.sql new file mode 100644 index 0000000..f565e2e --- /dev/null +++ b/internal/store/migrations/009-dismiss-inbox-of-closed-tasks.sql @@ -0,0 +1,2 @@ +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'); diff --git a/internal/store/migrations/010-add-blobs-and-queued-images.sql b/internal/store/migrations/010-add-blobs-and-queued-images.sql new file mode 100644 index 0000000..54b615c --- /dev/null +++ b/internal/store/migrations/010-add-blobs-and-queued-images.sql @@ -0,0 +1,18 @@ +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; diff --git a/internal/store/migrations/011-add-task-merge-title.sql b/internal/store/migrations/011-add-task-merge-title.sql new file mode 100644 index 0000000..1bee9cd --- /dev/null +++ b/internal/store/migrations/011-add-task-merge-title.sql @@ -0,0 +1 @@ +ALTER TABLE tasks ADD COLUMN merge_title TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/migrations/012-add-web-push.sql b/internal/store/migrations/012-add-web-push.sql new file mode 100644 index 0000000..e638f18 --- /dev/null +++ b/internal/store/migrations/012-add-web-push.sql @@ -0,0 +1,17 @@ +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 +); diff --git a/internal/store/migrations/013-add-snapshots.sql b/internal/store/migrations/013-add-snapshots.sql new file mode 100644 index 0000000..c2f8532 --- /dev/null +++ b/internal/store/migrations/013-add-snapshots.sql @@ -0,0 +1,12 @@ +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) +); diff --git a/internal/store/migrations/014-inline-question-choices.sql b/internal/store/migrations/014-inline-question-choices.sql new file mode 100644 index 0000000..30ebf1a --- /dev/null +++ b/internal/store/migrations/014-inline-question-choices.sql @@ -0,0 +1,11 @@ +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; diff --git a/internal/store/migrations/015-unsnooze-inbox-items.sql b/internal/store/migrations/015-unsnooze-inbox-items.sql new file mode 100644 index 0000000..a8d10e3 --- /dev/null +++ b/internal/store/migrations/015-unsnooze-inbox-items.sql @@ -0,0 +1 @@ +UPDATE inbox_items SET snoozed_until = NULL WHERE snoozed_until IS NOT NULL; diff --git a/internal/store/migrations/016-add-task-todo-item.sql b/internal/store/migrations/016-add-task-todo-item.sql new file mode 100644 index 0000000..3c02865 --- /dev/null +++ b/internal/store/migrations/016-add-task-todo-item.sql @@ -0,0 +1 @@ +ALTER TABLE tasks ADD COLUMN todo_item TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/migrations/017-add-lead-agents.sql b/internal/store/migrations/017-add-lead-agents.sql new file mode 100644 index 0000000..63b9396 --- /dev/null +++ b/internal/store/migrations/017-add-lead-agents.sql @@ -0,0 +1,35 @@ +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); diff --git a/internal/store/migrations/018-add-focus-project.sql b/internal/store/migrations/018-add-focus-project.sql new file mode 100644 index 0000000..0583d64 --- /dev/null +++ b/internal/store/migrations/018-add-focus-project.sql @@ -0,0 +1,3 @@ +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.'; diff --git a/internal/store/migrations/019-add-task-merging-by.sql b/internal/store/migrations/019-add-task-merging-by.sql new file mode 100644 index 0000000..0db1fc2 --- /dev/null +++ b/internal/store/migrations/019-add-task-merging-by.sql @@ -0,0 +1 @@ +ALTER TABLE tasks ADD COLUMN merging_by TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/migrations/020-flag-handoff-prompts.sql b/internal/store/migrations/020-flag-handoff-prompts.sql new file mode 100644 index 0000000..1b14a06 --- /dev/null +++ b/internal/store/migrations/020-flag-handoff-prompts.sql @@ -0,0 +1,2 @@ +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 %'; diff --git a/internal/store/migrations/021-rename-todo-tab-to-project.sql b/internal/store/migrations/021-rename-todo-tab-to-project.sql new file mode 100644 index 0000000..984ff13 --- /dev/null +++ b/internal/store/migrations/021-rename-todo-tab-to-project.sql @@ -0,0 +1,3 @@ +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')))); diff --git a/internal/store/migrations/022-add-snapshot-forks.sql b/internal/store/migrations/022-add-snapshot-forks.sql new file mode 100644 index 0000000..7bf250b --- /dev/null +++ b/internal/store/migrations/022-add-snapshot-forks.sql @@ -0,0 +1,4 @@ +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 ''; diff --git a/internal/store/migrations/023-add-task-port-base.sql b/internal/store/migrations/023-add-task-port-base.sql new file mode 100644 index 0000000..7205592 --- /dev/null +++ b/internal/store/migrations/023-add-task-port-base.sql @@ -0,0 +1,5 @@ +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; diff --git a/internal/store/migrations/024-add-task-merge-queue.sql b/internal/store/migrations/024-add-task-merge-queue.sql new file mode 100644 index 0000000..683e085 --- /dev/null +++ b/internal/store/migrations/024-add-task-merge-queue.sql @@ -0,0 +1,2 @@ +ALTER TABLE tasks ADD COLUMN merge_queued_by TEXT NOT NULL DEFAULT ''; +ALTER TABLE tasks ADD COLUMN merge_queued_at TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/migrations/025-add-task-previews.sql b/internal/store/migrations/025-add-task-previews.sql new file mode 100644 index 0000000..3b8aa7b --- /dev/null +++ b/internal/store/migrations/025-add-task-previews.sql @@ -0,0 +1,8 @@ +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) +); diff --git a/internal/store/migrations/026-add-task-color.sql b/internal/store/migrations/026-add-task-color.sql new file mode 100644 index 0000000..3bae440 --- /dev/null +++ b/internal/store/migrations/026-add-task-color.sql @@ -0,0 +1,5 @@ +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'; diff --git a/internal/store/migrations/027-drop-turn-queue.sql b/internal/store/migrations/027-drop-turn-queue.sql new file mode 100644 index 0000000..ca9bb39 --- /dev/null +++ b/internal/store/migrations/027-drop-turn-queue.sql @@ -0,0 +1,2 @@ +UPDATE agent_sessions SET state = 'idle' WHERE state = 'queued'; +DROP TABLE turn_queue; diff --git a/internal/store/migrations/028-add-snapshot-reviews.sql b/internal/store/migrations/028-add-snapshot-reviews.sql new file mode 100644 index 0000000..fa195e8 --- /dev/null +++ b/internal/store/migrations/028-add-snapshot-reviews.sql @@ -0,0 +1,15 @@ +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); diff --git a/internal/store/migrations/029-add-task-inputs.sql b/internal/store/migrations/029-add-task-inputs.sql new file mode 100644 index 0000000..f64d2d0 --- /dev/null +++ b/internal/store/migrations/029-add-task-inputs.sql @@ -0,0 +1,9 @@ +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); diff --git a/internal/store/migrations/030-drop-web-push.sql b/internal/store/migrations/030-drop-web-push.sql new file mode 100644 index 0000000..a56d3ce --- /dev/null +++ b/internal/store/migrations/030-drop-web-push.sql @@ -0,0 +1,2 @@ +DROP TABLE push_subscriptions; +DROP TABLE vapid_keys; diff --git a/internal/store/migrations/031-delete-report-inbox-items.sql b/internal/store/migrations/031-delete-report-inbox-items.sql new file mode 100644 index 0000000..33ad328 --- /dev/null +++ b/internal/store/migrations/031-delete-report-inbox-items.sql @@ -0,0 +1,2 @@ +DELETE FROM inbox_responses WHERE item_id IN (SELECT id FROM inbox_items WHERE kind = 'report'); +DELETE FROM inbox_items WHERE kind = 'report'; diff --git a/internal/store/migrations/032-allow-sessionless-reviews.sql b/internal/store/migrations/032-allow-sessionless-reviews.sql new file mode 100644 index 0000000..2fba338 --- /dev/null +++ b/internal/store/migrations/032-allow-sessionless-reviews.sql @@ -0,0 +1,18 @@ +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); diff --git a/internal/store/migrations/033-add-workflows.sql b/internal/store/migrations/033-add-workflows.sql new file mode 100644 index 0000000..a40d4cf --- /dev/null +++ b/internal/store/migrations/033-add-workflows.sql @@ -0,0 +1,6 @@ +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 ''; diff --git a/internal/store/migrations/034-run-workflow-stages.sql b/internal/store/migrations/034-run-workflow-stages.sql new file mode 100644 index 0000000..8e4de86 --- /dev/null +++ b/internal/store/migrations/034-run-workflow-stages.sql @@ -0,0 +1,9 @@ +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 != ''; diff --git a/internal/store/migrations/035-workflow-stage-tasks.sql b/internal/store/migrations/035-workflow-stage-tasks.sql new file mode 100644 index 0000000..111f6a0 --- /dev/null +++ b/internal/store/migrations/035-workflow-stage-tasks.sql @@ -0,0 +1,11 @@ +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 = ''; diff --git a/internal/store/migrations/036-drop-preview-tab.sql b/internal/store/migrations/036-drop-preview-tab.sql new file mode 100644 index 0000000..ba05afd --- /dev/null +++ b/internal/store/migrations/036-drop-preview-tab.sql @@ -0,0 +1,4 @@ +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; diff --git a/internal/store/migrations/037-drop-inbox-priority.sql b/internal/store/migrations/037-drop-inbox-priority.sql new file mode 100644 index 0000000..c8e16c5 --- /dev/null +++ b/internal/store/migrations/037-drop-inbox-priority.sql @@ -0,0 +1,38 @@ +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; diff --git a/internal/store/migrations/038-add-task-summary-and-discarding-by.sql b/internal/store/migrations/038-add-task-summary-and-discarding-by.sql new file mode 100644 index 0000000..f980cc6 --- /dev/null +++ b/internal/store/migrations/038-add-task-summary-and-discarding-by.sql @@ -0,0 +1,2 @@ +ALTER TABLE tasks ADD COLUMN summary TEXT NOT NULL DEFAULT ''; +ALTER TABLE tasks ADD COLUMN discarding_by TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/migrations/039-add-task-discard-state.sql b/internal/store/migrations/039-add-task-discard-state.sql new file mode 100644 index 0000000..e7c939e --- /dev/null +++ b/internal/store/migrations/039-add-task-discard-state.sql @@ -0,0 +1,3 @@ +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'; diff --git a/internal/store/migrations/040-add-subtasks.sql b/internal/store/migrations/040-add-subtasks.sql new file mode 100644 index 0000000..2c40e52 --- /dev/null +++ b/internal/store/migrations/040-add-subtasks.sql @@ -0,0 +1,3 @@ +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 != ''; diff --git a/internal/store/migrations/041-add-review-finding-states.sql b/internal/store/migrations/041-add-review-finding-states.sql new file mode 100644 index 0000000..62a91ee --- /dev/null +++ b/internal/store/migrations/041-add-review-finding-states.sql @@ -0,0 +1,3 @@ +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; diff --git a/internal/store/migrations/042-add-lead-notes.sql b/internal/store/migrations/042-add-lead-notes.sql new file mode 100644 index 0000000..0df4f04 --- /dev/null +++ b/internal/store/migrations/042-add-lead-notes.sql @@ -0,0 +1,8 @@ +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); diff --git a/internal/store/migrations/043-add-summary-reads.sql b/internal/store/migrations/043-add-summary-reads.sql new file mode 100644 index 0000000..d170706 --- /dev/null +++ b/internal/store/migrations/043-add-summary-reads.sql @@ -0,0 +1,7 @@ +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) +); diff --git a/internal/store/migrations/044-add-task-finalizing.sql b/internal/store/migrations/044-add-task-finalizing.sql new file mode 100644 index 0000000..8806546 --- /dev/null +++ b/internal/store/migrations/044-add-task-finalizing.sql @@ -0,0 +1 @@ +ALTER TABLE tasks ADD COLUMN finalizing TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/migrations/045-add-session-subagents.sql b/internal/store/migrations/045-add-session-subagents.sql new file mode 100644 index 0000000..67038a4 --- /dev/null +++ b/internal/store/migrations/045-add-session-subagents.sql @@ -0,0 +1 @@ +ALTER TABLE agent_sessions ADD COLUMN subagents INTEGER NOT NULL DEFAULT 0; diff --git a/internal/store/migrations/046-add-task-state.sql b/internal/store/migrations/046-add-task-state.sql new file mode 100644 index 0000000..1b90070 --- /dev/null +++ b/internal/store/migrations/046-add-task-state.sql @@ -0,0 +1,19 @@ +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 = CASE + WHEN tasks.status != 'active' THEN tasks.status + WHEN tasks.discard_state != '' THEN 'discarding' + WHEN tasks.merging_by != '' OR tasks.finalizing != '' THEN 'merging' + ELSE coalesce(( + SELECT CASE + WHEN s.state != 'idle' THEN s.state + WHEN s.subagents > 0 THEN 'running' + WHEN EXISTS (SELECT 1 FROM reviews r WHERE r.task_id = tasks.id AND r.state = 'running') THEN 'reviewing' + WHEN tasks.review_requests > 0 THEN 'review_pending' + ELSE 'idle' + END + FROM agent_sessions s + WHERE s.task_id = tasks.id AND s.role != 'reviewer' AND s.state != 'completed' + ORDER BY s.state = 'running' DESC, s.subagents > 0 DESC, s.created_at DESC LIMIT 1 + ), 'no_agent') +END; diff --git a/internal/store/migrations/047-add-task-finalizing-commit.sql b/internal/store/migrations/047-add-task-finalizing-commit.sql new file mode 100644 index 0000000..9d1a793 --- /dev/null +++ b/internal/store/migrations/047-add-task-finalizing-commit.sql @@ -0,0 +1,3 @@ +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; diff --git a/internal/store/migrations/048-add-task-merge-after-review.sql b/internal/store/migrations/048-add-task-merge-after-review.sql new file mode 100644 index 0000000..ac7242b --- /dev/null +++ b/internal/store/migrations/048-add-task-merge-after-review.sql @@ -0,0 +1,3 @@ +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 ''; diff --git a/internal/store/migrations/049-add-inbox-response-images.sql b/internal/store/migrations/049-add-inbox-response-images.sql new file mode 100644 index 0000000..f5d075f --- /dev/null +++ b/internal/store/migrations/049-add-inbox-response-images.sql @@ -0,0 +1 @@ +ALTER TABLE inbox_responses ADD COLUMN images TEXT NOT NULL DEFAULT '[]'; diff --git a/internal/store/migrations/050-add-lead-note-projects.sql b/internal/store/migrations/050-add-lead-note-projects.sql new file mode 100644 index 0000000..0c93cf0 --- /dev/null +++ b/internal/store/migrations/050-add-lead-note-projects.sql @@ -0,0 +1 @@ +ALTER TABLE lead_notes ADD COLUMN project_id TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/migrations/051-add-inbox-run-responses.sql b/internal/store/migrations/051-add-inbox-run-responses.sql new file mode 100644 index 0000000..acaf438 --- /dev/null +++ b/internal/store/migrations/051-add-inbox-run-responses.sql @@ -0,0 +1,2 @@ +ALTER TABLE inbox_responses ADD COLUMN client TEXT NOT NULL DEFAULT ''; +ALTER TABLE inbox_responses ADD COLUMN exit_code INTEGER; diff --git a/internal/store/migrations/052-drop-client-runs.sql b/internal/store/migrations/052-drop-client-runs.sql new file mode 100644 index 0000000..cd396af --- /dev/null +++ b/internal/store/migrations/052-drop-client-runs.sql @@ -0,0 +1,4 @@ +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; diff --git a/internal/store/migrations/053-add-user-default-model.sql b/internal/store/migrations/053-add-user-default-model.sql new file mode 100644 index 0000000..94e2765 --- /dev/null +++ b/internal/store/migrations/053-add-user-default-model.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN default_model TEXT NOT NULL DEFAULT 'claude-opus-5-5'; diff --git a/internal/store/migrations/054-add-team-members.sql b/internal/store/migrations/054-add-team-members.sql new file mode 100644 index 0000000..27ddd32 --- /dev/null +++ b/internal/store/migrations/054-add-team-members.sql @@ -0,0 +1,2 @@ +ALTER TABLE agent_sessions ADD COLUMN member TEXT NOT NULL DEFAULT ''; +ALTER TABLE task_inputs ADD COLUMN member TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/migrations/055-rename-inbox-tab.sql b/internal/store/migrations/055-rename-inbox-tab.sql new file mode 100644 index 0000000..58b455b --- /dev/null +++ b/internal/store/migrations/055-rename-inbox-tab.sql @@ -0,0 +1 @@ +UPDATE users SET focus = replace(focus, '"inbox"', '"questions"'); diff --git a/internal/store/migrations/056-add-inbox-response-snapshots.sql b/internal/store/migrations/056-add-inbox-response-snapshots.sql new file mode 100644 index 0000000..9d0ec10 --- /dev/null +++ b/internal/store/migrations/056-add-inbox-response-snapshots.sql @@ -0,0 +1 @@ +ALTER TABLE inbox_responses ADD COLUMN snapshot_n INTEGER; diff --git a/internal/store/migrations/057-errors-to-transcript.sql b/internal/store/migrations/057-errors-to-transcript.sql new file mode 100644 index 0000000..d2689e9 --- /dev/null +++ b/internal/store/migrations/057-errors-to-transcript.sql @@ -0,0 +1,12 @@ +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; diff --git a/internal/store/store.go b/internal/store/store.go index 8e5f2bf..bf193cc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 {