From 2132c53f28d118266800003b41d2574efaf547b4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 21 Jan 2026 12:12:12 +0200 Subject: [PATCH] feat(coderd/database): add schema for task pause/resume lifecycle (#21557) Creates migration 000409 with the database foundation for pausing and resuming task workspaces. The task_snapshots table stores conversation history (AgentAPI messages) so users can view task logs even when the workspace is stopped. Each task gets one snapshot, overwritten on each pause. Three new build_reason values (task_auto_pause, task_manual_pause, task_resume) let us distinguish task lifecycle events in telemetry and audit logs from regular workspace operations. Uses a regular table rather than UNLOGGED for snapshots. While UNLOGGED would be faster, losing snapshots on database crash creates user confusion (logs disappear until next pause). We can switch to UNLOGGED post-GA if write performance becomes a problem. Closes coder/internal#1250 --- coderd/database/dump.sql | 25 ++++++++++++++++++- coderd/database/foreign_key_constraint.go | 1 + .../migrations/000409_task_lifecycle.down.sql | 5 ++++ .../migrations/000409_task_lifecycle.up.sql | 19 ++++++++++++++ .../fixtures/000409_task_lifecycle.up.sql | 16 ++++++++++++ coderd/database/models.go | 21 +++++++++++++++- coderd/database/unique_constraint.go | 1 + 7 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 coderd/database/migrations/000409_task_lifecycle.down.sql create mode 100644 coderd/database/migrations/000409_task_lifecycle.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000409_task_lifecycle.up.sql diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ae4dcd728e..6cd3eb56bf 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -248,7 +248,10 @@ CREATE TYPE build_reason AS ENUM ( 'cli', 'ssh_connection', 'vscode_connection', - 'jetbrains_connection' + 'jetbrains_connection', + 'task_auto_pause', + 'task_manual_pause', + 'task_resume' ); CREATE TYPE connection_status AS ENUM ( @@ -1846,6 +1849,20 @@ CREATE TABLE tailnet_tunnels ( updated_at timestamp with time zone NOT NULL ); +CREATE TABLE task_snapshots ( + task_id uuid NOT NULL, + log_snapshot jsonb NOT NULL, + log_snapshot_created_at timestamp with time zone DEFAULT now() NOT NULL +); + +COMMENT ON TABLE task_snapshots IS 'Stores snapshots of task state when paused, currently limited to conversation history.'; + +COMMENT ON COLUMN task_snapshots.task_id IS 'The task this snapshot belongs to.'; + +COMMENT ON COLUMN task_snapshots.log_snapshot IS 'Task conversation history in JSON format, allowing users to view logs when the workspace is stopped.'; + +COMMENT ON COLUMN task_snapshots.log_snapshot_created_at IS 'When this log snapshot was captured.'; + CREATE TABLE task_workspace_apps ( task_id uuid NOT NULL, workspace_agent_id uuid, @@ -3157,6 +3174,9 @@ ALTER TABLE ONLY tailnet_peers ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id); +ALTER TABLE ONLY task_snapshots + ADD CONSTRAINT task_snapshots_pkey PRIMARY KEY (task_id); + ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_pkey PRIMARY KEY (task_id, workspace_build_number); @@ -3720,6 +3740,9 @@ ALTER TABLE ONLY tailnet_peers ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE; +ALTER TABLE ONLY task_snapshots + ADD CONSTRAINT task_snapshots_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 0c295e4316..ac2c87fc95 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -46,6 +46,7 @@ const ( ForeignKeyTailnetClientsCoordinatorID ForeignKeyConstraint = "tailnet_clients_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_clients ADD CONSTRAINT tailnet_clients_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE; ForeignKeyTailnetPeersCoordinatorID ForeignKeyConstraint = "tailnet_peers_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE; ForeignKeyTailnetTunnelsCoordinatorID ForeignKeyConstraint = "tailnet_tunnels_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE; + ForeignKeyTaskSnapshotsTaskID ForeignKeyConstraint = "task_snapshots_task_id_fkey" // ALTER TABLE ONLY task_snapshots ADD CONSTRAINT task_snapshots_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; ForeignKeyTaskWorkspaceAppsTaskID ForeignKeyConstraint = "task_workspace_apps_task_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; ForeignKeyTaskWorkspaceAppsWorkspaceAgentID ForeignKeyConstraint = "task_workspace_apps_workspace_agent_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyTaskWorkspaceAppsWorkspaceAppID ForeignKeyConstraint = "task_workspace_apps_workspace_app_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_workspace_app_id_fkey FOREIGN KEY (workspace_app_id) REFERENCES workspace_apps(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000409_task_lifecycle.down.sql b/coderd/database/migrations/000409_task_lifecycle.down.sql new file mode 100644 index 0000000000..b90abe3785 --- /dev/null +++ b/coderd/database/migrations/000409_task_lifecycle.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS task_snapshots; + +-- Note: Cannot remove enum values in PostgreSQL. +-- The build_reason enum values (task_auto_pause, task_manual_pause, task_resume) +-- will remain but become unused. diff --git a/coderd/database/migrations/000409_task_lifecycle.up.sql b/coderd/database/migrations/000409_task_lifecycle.up.sql new file mode 100644 index 0000000000..c061db85df --- /dev/null +++ b/coderd/database/migrations/000409_task_lifecycle.up.sql @@ -0,0 +1,19 @@ +-- Create task_snapshots table for storing log snapshots when tasks are paused. +-- This table holds the conversation history from AgentAPI, allowing users to view +-- task logs even when the workspace is stopped. +CREATE TABLE task_snapshots ( + task_id UUID NOT NULL PRIMARY KEY REFERENCES tasks (id) ON DELETE CASCADE, + log_snapshot JSONB NOT NULL, + log_snapshot_created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE task_snapshots IS 'Stores snapshots of task state when paused, currently limited to conversation history.'; +COMMENT ON COLUMN task_snapshots.task_id IS 'The task this snapshot belongs to.'; +COMMENT ON COLUMN task_snapshots.log_snapshot IS 'Task conversation history in JSON format, allowing users to view logs when the workspace is stopped.'; +COMMENT ON COLUMN task_snapshots.log_snapshot_created_at IS 'When this log snapshot was captured.'; + +-- Add build reasons for task lifecycle events. +-- These distinguish task pause/resume operations from regular workspace lifecycle events. +ALTER TYPE build_reason ADD VALUE IF NOT EXISTS 'task_auto_pause'; +ALTER TYPE build_reason ADD VALUE IF NOT EXISTS 'task_manual_pause'; +ALTER TYPE build_reason ADD VALUE IF NOT EXISTS 'task_resume'; diff --git a/coderd/database/migrations/testdata/fixtures/000409_task_lifecycle.up.sql b/coderd/database/migrations/testdata/fixtures/000409_task_lifecycle.up.sql new file mode 100644 index 0000000000..41eb31cdb2 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000409_task_lifecycle.up.sql @@ -0,0 +1,16 @@ +INSERT INTO task_snapshots VALUES ( + 'f5a1c3e4-8b2d-4f6a-9d7e-2a8b5c9e1f3d', -- task_id (references existing task from 000366) + '{ + "format": "agentapi", + "data": { + "messages": [ + {"id": 0, "type": "output", "content": "Starting task execution...", "time": "2024-11-02T13:10:05Z"}, + {"id": 1, "type": "input", "content": "Create a React component for tasks", "time": "2024-11-02T13:10:06Z"}, + {"id": 2, "type": "output", "content": "Creating component structure...", "time": "2024-11-02T13:10:10Z"} + ], + "truncated": false, + "total_count": 3 + } + }'::JSONB, -- log_snapshot + '2024-11-02 13:15:00.000000+02' -- log_snapshot_at +) ON CONFLICT DO NOTHING; diff --git a/coderd/database/models.go b/coderd/database/models.go index 2f0a14b2e5..dc56bcebeb 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -936,6 +936,9 @@ const ( BuildReasonSshConnection BuildReason = "ssh_connection" BuildReasonVscodeConnection BuildReason = "vscode_connection" BuildReasonJetbrainsConnection BuildReason = "jetbrains_connection" + BuildReasonTaskAutoPause BuildReason = "task_auto_pause" + BuildReasonTaskManualPause BuildReason = "task_manual_pause" + BuildReasonTaskResume BuildReason = "task_resume" ) func (e *BuildReason) Scan(src interface{}) error { @@ -985,7 +988,10 @@ func (e BuildReason) Valid() bool { BuildReasonCli, BuildReasonSshConnection, BuildReasonVscodeConnection, - BuildReasonJetbrainsConnection: + BuildReasonJetbrainsConnection, + BuildReasonTaskAutoPause, + BuildReasonTaskManualPause, + BuildReasonTaskResume: return true } return false @@ -1004,6 +1010,9 @@ func AllBuildReasonValues() []BuildReason { BuildReasonSshConnection, BuildReasonVscodeConnection, BuildReasonJetbrainsConnection, + BuildReasonTaskAutoPause, + BuildReasonTaskManualPause, + BuildReasonTaskResume, } } @@ -4235,6 +4244,16 @@ type Task struct { OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"` } +// Stores snapshots of task state when paused, currently limited to conversation history. +type TaskSnapshot struct { + // The task this snapshot belongs to. + TaskID uuid.UUID `db:"task_id" json:"task_id"` + // Task conversation history in JSON format, allowing users to view logs when the workspace is stopped. + LogSnapshot json.RawMessage `db:"log_snapshot" json:"log_snapshot"` + // When this log snapshot was captured. + LogSnapshotCreatedAt time.Time `db:"log_snapshot_created_at" json:"log_snapshot_created_at"` +} + type TaskTable struct { ID uuid.UUID `db:"id" json:"id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 94fe9cc239..dc2c3bd1dc 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -59,6 +59,7 @@ const ( UniqueTailnetCoordinatorsPkey UniqueConstraint = "tailnet_coordinators_pkey" // ALTER TABLE ONLY tailnet_coordinators ADD CONSTRAINT tailnet_coordinators_pkey PRIMARY KEY (id); UniqueTailnetPeersPkey UniqueConstraint = "tailnet_peers_pkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_pkey PRIMARY KEY (id, coordinator_id); UniqueTailnetTunnelsPkey UniqueConstraint = "tailnet_tunnels_pkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id); + UniqueTaskSnapshotsPkey UniqueConstraint = "task_snapshots_pkey" // ALTER TABLE ONLY task_snapshots ADD CONSTRAINT task_snapshots_pkey PRIMARY KEY (task_id); UniqueTaskWorkspaceAppsPkey UniqueConstraint = "task_workspace_apps_pkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_pkey PRIMARY KEY (task_id, workspace_build_number); UniqueTasksPkey UniqueConstraint = "tasks_pkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_pkey PRIMARY KEY (id); UniqueTelemetryItemsPkey UniqueConstraint = "telemetry_items_pkey" // ALTER TABLE ONLY telemetry_items ADD CONSTRAINT telemetry_items_pkey PRIMARY KEY (key);