diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 8018482a1c..95c7e11660 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1748,6 +1748,38 @@ COMMENT ON COLUMN boundary_usage_stats.window_start IS 'Start of the time window COMMENT ON COLUMN boundary_usage_stats.updated_at IS 'Timestamp of the last update to this row.'; +CREATE TABLE chat_context_resources ( + chat_id uuid NOT NULL, + source text NOT NULL, + body_kind workspace_agent_context_body_kind NOT NULL, + body jsonb NOT NULL, + content_hash bytea NOT NULL, + size_bytes bigint NOT NULL, + status workspace_agent_context_resource_status NOT NULL, + error text DEFAULT ''::text NOT NULL, + source_path text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + +COMMENT ON TABLE chat_context_resources IS 'Per-chat pinned copy of the agent context resources a chat is hydrated against. Copied from workspace_agent_context_resources at chat hydration and context refresh; survives agent replacement and workspace rebuilds.'; + +COMMENT ON COLUMN chat_context_resources.source IS 'Resource locator: canonical file path for file-backed kinds, or the MCP server name for mcp_server resources.'; + +COMMENT ON COLUMN chat_context_resources.body_kind IS 'Discriminator for the body JSON shape. Matches the proto oneof variant: instruction_file, skill, mcp_config, mcp_server. PLUGIN/HOOK/SUBAGENT/COMMAND are reserved for the Claude Code plugin RFC.'; + +COMMENT ON COLUMN chat_context_resources.body IS 'protojson-encoded variant body matching body_kind. Always populated; non-OK statuses use the variant zero value so the wire kind is still attributable.'; + +COMMENT ON COLUMN chat_context_resources.content_hash IS 'sha256 over the resource''s original bytes (or transport-encoded server tool list).'; + +COMMENT ON COLUMN chat_context_resources.size_bytes IS 'Original payload size in bytes; populated regardless of status.'; + +COMMENT ON COLUMN chat_context_resources.status IS 'Per-resource status. ok carries a populated body; oversize, unreadable, invalid, and excluded carry an empty body plus an error string.'; + +COMMENT ON COLUMN chat_context_resources.error IS 'Per-resource error or warning string. Populated whenever status is non-ok; may also carry a non-fatal warning when status is ok.'; + +COMMENT ON COLUMN chat_context_resources.source_path IS 'User-declared scan root that produced this resource. Empty for built-in scan roots.'; + CREATE TABLE chat_debug_runs ( id uuid DEFAULT gen_random_uuid() NOT NULL, chat_id uuid NOT NULL, @@ -4096,6 +4128,9 @@ ALTER TABLE ONLY boundary_sessions ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id); +ALTER TABLE ONLY chat_context_resources + ADD CONSTRAINT chat_context_resources_pkey PRIMARY KEY (chat_id, source); + ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_pkey PRIMARY KEY (id); @@ -4906,6 +4941,9 @@ ALTER TABLE ONLY boundary_sessions ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id); +ALTER TABLE ONLY chat_context_resources + ADD CONSTRAINT chat_context_resources_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 8b8d658fa6..f3c9920a52 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -14,6 +14,7 @@ const ( ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyBoundarySessionsOwnerID ForeignKeyConstraint = "boundary_sessions_owner_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; ForeignKeyBoundarySessionsWorkspaceAgentID ForeignKeyConstraint = "boundary_sessions_workspace_agent_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id); + ForeignKeyChatContextResourcesChatID ForeignKeyConstraint = "chat_context_resources_chat_id_fkey" // ALTER TABLE ONLY chat_context_resources ADD CONSTRAINT chat_context_resources_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatDebugStepsChatID ForeignKeyConstraint = "chat_debug_steps_chat_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatDiffStatusesChatID ForeignKeyConstraint = "chat_diff_statuses_chat_id_fkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000525_chat_context_resources.down.sql b/coderd/database/migrations/000525_chat_context_resources.down.sql new file mode 100644 index 0000000000..8f80309bfa --- /dev/null +++ b/coderd/database/migrations/000525_chat_context_resources.down.sql @@ -0,0 +1,4 @@ +-- The workspace_agent_context_* enum types are owned by migration +-- 000522 and are still in use by workspace_agent_context_resources, so +-- they are intentionally left in place here. +DROP TABLE IF EXISTS chat_context_resources; diff --git a/coderd/database/migrations/000525_chat_context_resources.up.sql b/coderd/database/migrations/000525_chat_context_resources.up.sql new file mode 100644 index 0000000000..03c014b4bd --- /dev/null +++ b/coderd/database/migrations/000525_chat_context_resources.up.sql @@ -0,0 +1,30 @@ +-- Creates chat_context_resources: a per-chat pinned copy of +-- workspace_agent_context_resources (semantics in COMMENT ON TABLE +-- below). Migration-specific notes: there is deliberately no FK to +-- workspace_agents so the pin survives agent replacement and workspace +-- rebuilds, and the body_kind/status enum types are reused from 000522 +-- and must not be recreated here. +CREATE TABLE chat_context_resources ( + chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + source TEXT NOT NULL, + body_kind workspace_agent_context_body_kind NOT NULL, + body JSONB NOT NULL, + content_hash BYTEA NOT NULL, + size_bytes BIGINT NOT NULL, + status workspace_agent_context_resource_status NOT NULL, + error TEXT NOT NULL DEFAULT '', + source_path TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (chat_id, source) +); + +COMMENT ON TABLE chat_context_resources IS 'Per-chat pinned copy of the agent context resources a chat is hydrated against. Copied from workspace_agent_context_resources at chat hydration and context refresh; survives agent replacement and workspace rebuilds.'; +COMMENT ON COLUMN chat_context_resources.source IS 'Resource locator: canonical file path for file-backed kinds, or the MCP server name for mcp_server resources.'; +COMMENT ON COLUMN chat_context_resources.body_kind IS 'Discriminator for the body JSON shape. Matches the proto oneof variant: instruction_file, skill, mcp_config, mcp_server. PLUGIN/HOOK/SUBAGENT/COMMAND are reserved for the Claude Code plugin RFC.'; +COMMENT ON COLUMN chat_context_resources.body IS 'protojson-encoded variant body matching body_kind. Always populated; non-OK statuses use the variant zero value so the wire kind is still attributable.'; +COMMENT ON COLUMN chat_context_resources.content_hash IS 'sha256 over the resource''s original bytes (or transport-encoded server tool list).'; +COMMENT ON COLUMN chat_context_resources.size_bytes IS 'Original payload size in bytes; populated regardless of status.'; +COMMENT ON COLUMN chat_context_resources.status IS 'Per-resource status. ok carries a populated body; oversize, unreadable, invalid, and excluded carry an empty body plus an error string.'; +COMMENT ON COLUMN chat_context_resources.error IS 'Per-resource error or warning string. Populated whenever status is non-ok; may also carry a non-fatal warning when status is ok.'; +COMMENT ON COLUMN chat_context_resources.source_path IS 'User-declared scan root that produced this resource. Empty for built-in scan roots.'; diff --git a/coderd/database/migrations/testdata/fixtures/000525_chat_context_resources.up.sql b/coderd/database/migrations/testdata/fixtures/000525_chat_context_resources.up.sql new file mode 100644 index 0000000000..0b1e60a5a5 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000525_chat_context_resources.up.sql @@ -0,0 +1,82 @@ +-- Pinned context resources covering each non-reserved body kind plus a +-- non-OK status. The earlier chat fixtures already insert at least one row +-- into chats; we attach the resources to the first such chat (ordered +-- deterministically) so migration tests see a non-empty +-- chat_context_resources table without hard-coding a specific chat ID. +INSERT INTO chat_context_resources ( + chat_id, + source, + body_kind, + body, + content_hash, + size_bytes, + status, + error, + source_path +) +SELECT + c.id, + v.source, + v.body_kind::workspace_agent_context_body_kind, + v.body::jsonb, + decode(v.content_hash, 'hex'), + v.size_bytes, + v.status::workspace_agent_context_resource_status, + v.error, + v.source_path +FROM ( + SELECT id FROM chats ORDER BY created_at, id LIMIT 1 +) AS c +CROSS JOIN ( + VALUES + ( + '/home/coder/workspace/AGENTS.md', + 'instruction_file', + '{"content":"aGVsbG8="}', + '1111111111111111111111111111111111111111111111111111111111111111', + 5::bigint, + 'ok', + '', + '' + ), + ( + '/home/coder/workspace/.agents/skills/example/SKILL.md', + 'skill', + '{"meta":"LS0tCm5hbWU6IGV4YW1wbGUKLS0tCmJvZHk=","name":"example","description":"Example skill"}', + '2222222222222222222222222222222222222222222222222222222222222222', + 32::bigint, + 'ok', + '', + '/home/coder/workspace' + ), + ( + '/home/coder/workspace/.mcp.json', + 'mcp_config', + '{}', + '3333333333333333333333333333333333333333333333333333333333333333', + 128::bigint, + 'ok', + '', + '' + ), + ( + 'mcp:echo', + 'mcp_server', + '{"server_name":"echo","description":"echoes input"}', + '4444444444444444444444444444444444444444444444444444444444444444', + 256::bigint, + 'ok', + '', + '/home/coder/workspace/.mcp.json' + ), + ( + '/home/coder/workspace/big.md', + 'instruction_file', + '{}', + '5555555555555555555555555555555555555555555555555555555555555555', + 99999::bigint, + 'oversize', + 'file exceeds 64KiB per-resource cap', + '' + ) +) AS v(source, body_kind, body, content_hash, size_bytes, status, error, source_path); diff --git a/coderd/database/models.go b/coderd/database/models.go index 617da5aa16..349bed3a75 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4809,6 +4809,29 @@ type Chat struct { ContextError string `db:"context_error" json:"context_error"` } +// Per-chat pinned copy of the agent context resources a chat is hydrated against. Copied from workspace_agent_context_resources at chat hydration and context refresh; survives agent replacement and workspace rebuilds. +type ChatContextResource struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + // Resource locator: canonical file path for file-backed kinds, or the MCP server name for mcp_server resources. + Source string `db:"source" json:"source"` + // Discriminator for the body JSON shape. Matches the proto oneof variant: instruction_file, skill, mcp_config, mcp_server. PLUGIN/HOOK/SUBAGENT/COMMAND are reserved for the Claude Code plugin RFC. + BodyKind WorkspaceAgentContextBodyKind `db:"body_kind" json:"body_kind"` + // protojson-encoded variant body matching body_kind. Always populated; non-OK statuses use the variant zero value so the wire kind is still attributable. + Body json.RawMessage `db:"body" json:"body"` + // sha256 over the resource's original bytes (or transport-encoded server tool list). + ContentHash []byte `db:"content_hash" json:"content_hash"` + // Original payload size in bytes; populated regardless of status. + SizeBytes int64 `db:"size_bytes" json:"size_bytes"` + // Per-resource status. ok carries a populated body; oversize, unreadable, invalid, and excluded carry an empty body plus an error string. + Status WorkspaceAgentContextResourceStatus `db:"status" json:"status"` + // Per-resource error or warning string. Populated whenever status is non-ok; may also carry a non-fatal warning when status is ok. + Error string `db:"error" json:"error"` + // User-declared scan root that produced this resource. Empty for built-in scan roots. + SourcePath string `db:"source_path" json:"source_path"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + type ChatDebugRun struct { ID uuid.UUID `db:"id" json:"id"` ChatID uuid.UUID `db:"chat_id" json:"chat_id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c2fcd6d5e7..2c4d46556a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -200,8 +200,7 @@ type sqlcQuerier interface { DeleteOldChatFiles(ctx context.Context, arg DeleteOldChatFilesParams) (int64, error) // Deletes chats that have been archived for longer than the given // threshold. Active (non-archived) chats are never deleted. - // Related chat_messages, chat_diff_statuses, and - // chat_queued_messages are removed via ON DELETE CASCADE. + // All chat-scoped child tables are removed via ON DELETE CASCADE. // Parent/root references on child chats are SET NULL. DeleteOldChats(ctx context.Context, arg DeleteOldChatsParams) (int64, error) DeleteOldConnectionLogs(ctx context.Context, arg DeleteOldConnectionLogsParams) (int64, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6bc2ac5409..3730a6c448 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6697,8 +6697,7 @@ type DeleteOldChatsParams struct { // Deletes chats that have been archived for longer than the given // threshold. Active (non-archived) chats are never deleted. -// Related chat_messages, chat_diff_statuses, and -// chat_queued_messages are removed via ON DELETE CASCADE. +// All chat-scoped child tables are removed via ON DELETE CASCADE. // Parent/root references on child chats are SET NULL. func (q *sqlQuerier) DeleteOldChats(ctx context.Context, arg DeleteOldChatsParams) (int64, error) { result, err := q.db.ExecContext(ctx, deleteOldChats, arg.BeforeTime, arg.LimitCount) diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 36234f3ea2..10f9f993b2 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -2551,8 +2551,7 @@ WHERE id = @id::uuid; -- name: DeleteOldChats :execrows -- Deletes chats that have been archived for longer than the given -- threshold. Active (non-archived) chats are never deleted. --- Related chat_messages, chat_diff_statuses, and --- chat_queued_messages are removed via ON DELETE CASCADE. +-- All chat-scoped child tables are removed via ON DELETE CASCADE. -- Parent/root references on child chats are SET NULL. WITH deletable AS ( SELECT id diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 8bae44cfd4..68058923b3 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -21,6 +21,7 @@ const ( UniqueBoundaryLogsPkey UniqueConstraint = "boundary_logs_pkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_pkey PRIMARY KEY (id); UniqueBoundarySessionsPkey UniqueConstraint = "boundary_sessions_pkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_pkey PRIMARY KEY (id); UniqueBoundaryUsageStatsPkey UniqueConstraint = "boundary_usage_stats_pkey" // ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id); + UniqueChatContextResourcesPkey UniqueConstraint = "chat_context_resources_pkey" // ALTER TABLE ONLY chat_context_resources ADD CONSTRAINT chat_context_resources_pkey PRIMARY KEY (chat_id, source); UniqueChatDebugRunsPkey UniqueConstraint = "chat_debug_runs_pkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_pkey PRIMARY KEY (id); UniqueChatDebugStepsPkey UniqueConstraint = "chat_debug_steps_pkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_pkey PRIMARY KEY (id); UniqueChatDiffStatusesPkey UniqueConstraint = "chat_diff_statuses_pkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id);