feat: add chat context pinning storage and push trigger (#26385)

Foundation for the Workspace Context Sources RFC (phase 3). The agent
push (#25983) and coderd snapshot storage (#26145) already persist
per-agent context snapshots; this PR lands the **chat-side storage**
plus the **`agentapi` push trigger** that a follow-up will use to read
them. It does **not** touch `chatd` and changes no behavior — nothing
wires an implementation yet.

## What changed

- Adds four nullable columns to `chats` — `context_aggregate_hash`,
`context_dirty_since`, `context_dirty_resources`, and `context_error` —
and rebuilds the `chats_expanded` view.
- Adds three queries — `SetChatContextSnapshot`,
`HydrateAgentChatsContext`, `MarkChatsContextDirtyByAgent` — with
`dbauthz` wrappers and `audit` entries. They are store-interface methods
covered by a Postgres test (`TestChatContextHydration`).
- Adds the `agentapi.ContextDirtyMarker` interface and invokes it inside
the `PushContextState` transaction, publishing collected events only
after commit.

## Intentionally inert

There are **no production callers** of the three queries and **no
implementation** wired for `ContextDirtyMarker`, so the push trigger is
dormant. This is deliberate: the PR is the durable storage/query
foundation only.

The actual integration — the `chatd` implementation that
hydrates/dirties chats and backs a refresh endpoint, consuming the
pinned context in prompt building, the rich SDK types + UI, and retiring
the live per-turn pull — lands as a single follow-up PR. Splitting this
way keeps the schema/query layer reviewable on its own and keeps the
integration whole in one place.

Refs #25983, #26145.

<details>
<summary>Decision log</summary>

- **Columns over a side table.** The four `chats` columns are the
durable model (accepting the one-time `chats_expanded` view/CTE churn).
`last_injected_context` is deliberately left untouched — it is
load-bearing for the live per-turn context pull.
- **Keep `agentapi`, drop `chatd`.** The earlier revision wired the
hydrate/dirty implementation through `chatd` and added a `PUT
/chats/{chat}/context` refresh endpoint. Those were removed so this PR
is pure foundation; `agentapi` defines the trigger + interface (it does
not import `chatd`), and the `chatd` implementation arrives with the
full integration.
- **No new experiment flag.** The columns are dark and unread by prompt
building.
- **Authz.** The new query wrappers authorize chat updates under the
chat RBAC object / `ResourceChat`, consistent with the existing system
chat mutators.

</details>

---

🤖 Generated by Coder Agents on behalf of @kylecarbs.
This commit is contained in:
Kyle Carberry
2026-06-15 14:41:00 -07:00
committed by GitHub
parent 89f200872b
commit 210261b143
18 changed files with 1116 additions and 140 deletions
+30
View File
@@ -5660,6 +5660,16 @@ func (q *querier) GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]datab
return q.db.GetWorkspacesForWorkspaceMetrics(ctx)
}
func (q *querier) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) error {
// System-level operation: an agent context push fans hydration out
// across every not-yet-pinned chat for the agent, so it authorizes at
// the resource level rather than per-chat.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil {
return err
}
return q.db.HydrateAgentChatsContext(ctx, arg)
}
func (q *querier) IncrementChatGenerationAttempt(ctx context.Context, id uuid.UUID) (int64, error) {
chat, err := q.db.GetChatByID(ctx, id)
if err != nil {
@@ -6642,6 +6652,15 @@ func (q *querier) MarkAllInboxNotificationsAsRead(ctx context.Context, arg datab
return q.db.MarkAllInboxNotificationsAsRead(ctx, arg)
}
func (q *querier) MarkChatsContextDirtyByAgent(ctx context.Context, arg database.MarkChatsContextDirtyByAgentParams) ([]database.MarkChatsContextDirtyByAgentRow, error) {
// System-level operation: the dirty fan-out runs across every active
// chat for the agent in response to a context push.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil {
return nil, err
}
return q.db.MarkChatsContextDirtyByAgent(ctx, arg)
}
func (q *querier) OIDCClaimFieldValues(ctx context.Context, args database.OIDCClaimFieldValuesParams) ([]string, error) {
resource := rbac.ResourceIdpsyncSettings
if args.OrganizationID != uuid.Nil {
@@ -6772,6 +6791,17 @@ func (q *querier) SelectUsageEventsForPublishing(ctx context.Context, arg time.T
return q.db.SelectUsageEventsForPublishing(ctx, arg)
}
func (q *querier) SetChatContextSnapshot(ctx context.Context, arg database.SetChatContextSnapshotParams) error {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return err
}
return q.db.SetChatContextSnapshot(ctx, arg)
}
func (q *querier) SoftDeleteChatMessageByID(ctx context.Context, id int64) error {
msg, err := q.db.GetChatMessageByID(ctx, id)
if err != nil {
+18
View File
@@ -529,6 +529,24 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().AcquireChats(gomock.Any(), arg).Return([]database.Chat{chat}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns([]database.Chat{chat})
}))
s.Run("HydrateAgentChatsContext", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.HydrateAgentChatsContextParams{AgentID: uuid.New()}
dbm.EXPECT().HydrateAgentChatsContext(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate)
}))
s.Run("MarkChatsContextDirtyByAgent", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.MarkChatsContextDirtyByAgentParams{AgentID: uuid.New()}
rows := []database.MarkChatsContextDirtyByAgentRow{{ID: uuid.New(), OwnerID: uuid.New()}}
dbm.EXPECT().MarkChatsContextDirtyByAgent(gomock.Any(), arg).Return(rows, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns(rows)
}))
s.Run("SetChatContextSnapshot", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.SetChatContextSnapshotParams{ID: chat.ID}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().SetChatContextSnapshot(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate)
}))
s.Run("GetChatWorkerAcquisitionCandidates", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.GetChatWorkerAcquisitionCandidatesParams{
StaleSeconds: 30,
+24
View File
@@ -3874,6 +3874,14 @@ func (m queryMetricsStore) GetWorkspacesForWorkspaceMetrics(ctx context.Context)
return r0, r1
}
func (m queryMetricsStore) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) error {
start := time.Now()
r0 := m.s.HydrateAgentChatsContext(ctx, arg)
m.queryLatencies.WithLabelValues("HydrateAgentChatsContext").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "HydrateAgentChatsContext").Inc()
return r0
}
func (m queryMetricsStore) IncrementChatGenerationAttempt(ctx context.Context, id uuid.UUID) (int64, error) {
start := time.Now()
r0, r1 := m.s.IncrementChatGenerationAttempt(ctx, id)
@@ -4762,6 +4770,14 @@ func (m queryMetricsStore) MarkAllInboxNotificationsAsRead(ctx context.Context,
return r0
}
func (m queryMetricsStore) MarkChatsContextDirtyByAgent(ctx context.Context, arg database.MarkChatsContextDirtyByAgentParams) ([]database.MarkChatsContextDirtyByAgentRow, error) {
start := time.Now()
r0, r1 := m.s.MarkChatsContextDirtyByAgent(ctx, arg)
m.queryLatencies.WithLabelValues("MarkChatsContextDirtyByAgent").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "MarkChatsContextDirtyByAgent").Inc()
return r0, r1
}
func (m queryMetricsStore) OIDCClaimFieldValues(ctx context.Context, arg database.OIDCClaimFieldValuesParams) ([]string, error) {
start := time.Now()
r0, r1 := m.s.OIDCClaimFieldValues(ctx, arg)
@@ -4874,6 +4890,14 @@ func (m queryMetricsStore) SelectUsageEventsForPublishing(ctx context.Context, n
return r0, r1
}
func (m queryMetricsStore) SetChatContextSnapshot(ctx context.Context, arg database.SetChatContextSnapshotParams) error {
start := time.Now()
r0 := m.s.SetChatContextSnapshot(ctx, arg)
m.queryLatencies.WithLabelValues("SetChatContextSnapshot").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "SetChatContextSnapshot").Inc()
return r0
}
func (m queryMetricsStore) SoftDeleteChatMessageByID(ctx context.Context, id int64) error {
start := time.Now()
r0 := m.s.SoftDeleteChatMessageByID(ctx, id)
+43
View File
@@ -7243,6 +7243,20 @@ func (mr *MockStoreMockRecorder) GetWorkspacesForWorkspaceMetrics(ctx any) *gomo
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesForWorkspaceMetrics", reflect.TypeOf((*MockStore)(nil).GetWorkspacesForWorkspaceMetrics), ctx)
}
// HydrateAgentChatsContext mocks base method.
func (m *MockStore) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HydrateAgentChatsContext", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// HydrateAgentChatsContext indicates an expected call of HydrateAgentChatsContext.
func (mr *MockStoreMockRecorder) HydrateAgentChatsContext(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HydrateAgentChatsContext", reflect.TypeOf((*MockStore)(nil).HydrateAgentChatsContext), ctx, arg)
}
// InTx mocks base method.
func (m *MockStore) InTx(arg0 func(database.Store) error, arg1 *database.TxOptions) error {
m.ctrl.T.Helper()
@@ -8966,6 +8980,21 @@ func (mr *MockStoreMockRecorder) MarkAllInboxNotificationsAsRead(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkAllInboxNotificationsAsRead", reflect.TypeOf((*MockStore)(nil).MarkAllInboxNotificationsAsRead), ctx, arg)
}
// MarkChatsContextDirtyByAgent mocks base method.
func (m *MockStore) MarkChatsContextDirtyByAgent(ctx context.Context, arg database.MarkChatsContextDirtyByAgentParams) ([]database.MarkChatsContextDirtyByAgentRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "MarkChatsContextDirtyByAgent", ctx, arg)
ret0, _ := ret[0].([]database.MarkChatsContextDirtyByAgentRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// MarkChatsContextDirtyByAgent indicates an expected call of MarkChatsContextDirtyByAgent.
func (mr *MockStoreMockRecorder) MarkChatsContextDirtyByAgent(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkChatsContextDirtyByAgent", reflect.TypeOf((*MockStore)(nil).MarkChatsContextDirtyByAgent), ctx, arg)
}
// OIDCClaimFieldValues mocks base method.
func (m *MockStore) OIDCClaimFieldValues(ctx context.Context, arg database.OIDCClaimFieldValuesParams) ([]string, error) {
m.ctrl.T.Helper()
@@ -9203,6 +9232,20 @@ func (mr *MockStoreMockRecorder) SelectUsageEventsForPublishing(ctx, now any) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SelectUsageEventsForPublishing", reflect.TypeOf((*MockStore)(nil).SelectUsageEventsForPublishing), ctx, now)
}
// SetChatContextSnapshot mocks base method.
func (m *MockStore) SetChatContextSnapshot(ctx context.Context, arg database.SetChatContextSnapshotParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetChatContextSnapshot", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// SetChatContextSnapshot indicates an expected call of SetChatContextSnapshot.
func (mr *MockStoreMockRecorder) SetChatContextSnapshot(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetChatContextSnapshot", reflect.TypeOf((*MockStore)(nil).SetChatContextSnapshot), ctx, arg)
}
// SoftDeleteChatMessageByID mocks base method.
func (m *MockStore) SoftDeleteChatMessageByID(ctx context.Context, id int64) error {
m.ctrl.T.Helper()
+17 -1
View File
@@ -1969,6 +1969,10 @@ CREATE TABLE chats (
retry_state_version bigint DEFAULT 0 NOT NULL,
runner_id uuid,
requires_action_deadline_at timestamp with time zone,
context_aggregate_hash bytea,
context_dirty_since timestamp with time zone,
context_dirty_resources jsonb,
context_error text DEFAULT ''::text NOT NULL,
CONSTRAINT chat_acl_only_on_root_chats CHECK ((((parent_chat_id IS NULL) AND (root_chat_id IS NULL)) OR ((user_acl = '{}'::jsonb) AND (group_acl = '{}'::jsonb)))),
CONSTRAINT chat_group_acl_not_null_jsonb CHECK (((group_acl IS NOT NULL) AND (jsonb_typeof(group_acl) = 'object'::text))),
CONSTRAINT chat_user_acl_not_null_jsonb CHECK (((user_acl IS NOT NULL) AND (jsonb_typeof(user_acl) = 'object'::text))),
@@ -1982,6 +1986,14 @@ COMMENT ON COLUMN chats.history_version IS 'Snapshot version of the latest durab
COMMENT ON COLUMN chats.queue_version IS 'Snapshot version of the latest queued-message change. Starts at 0 until chat_queued_messages triggers set it to the current snapshot_version.';
COMMENT ON COLUMN chats.context_aggregate_hash IS 'Aggregate hash of the agent context snapshot this chat is pinned to. NULL until first hydrated; compared against the agent''s latest snapshot hash to detect drift.';
COMMENT ON COLUMN chats.context_dirty_since IS 'Set when an agent push changes the pinned hash; cleared on refresh. NULL means clean.';
COMMENT ON COLUMN chats.context_dirty_resources IS 'Deterministic prefix of resources that changed since the pinned hash. Reserved for the dirty diff; left NULL until the UI phase populates it.';
COMMENT ON COLUMN chats.context_error IS 'Snapshot-level error copied from the pinned snapshot (count cap exceeded, watcher degraded, etc.). Empty when healthy.';
CREATE TABLE users (
id uuid NOT NULL,
email text NOT NULL,
@@ -2073,7 +2085,11 @@ CREATE VIEW chats_expanded AS
COALESCE(root.user_acl, c.user_acl) AS user_acl,
COALESCE(root.group_acl, c.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
c.context_aggregate_hash,
c.context_dirty_since,
c.context_dirty_resources,
c.context_error
FROM ((chats c
LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id))))
JOIN visible_users owner ON ((owner.id = c.owner_id)));
@@ -0,0 +1,54 @@
-- Recreate chats_expanded without the new chat columns. The view must
-- be dropped before the columns it references can be removed.
DROP VIEW IF EXISTS chats_expanded;
ALTER TABLE chats
DROP COLUMN IF EXISTS context_aggregate_hash,
DROP COLUMN IF EXISTS context_dirty_since,
DROP COLUMN IF EXISTS context_dirty_resources,
DROP COLUMN IF EXISTS context_error;
CREATE VIEW chats_expanded AS
SELECT c.id,
c.owner_id,
c.workspace_id,
c.title,
c.status,
c.worker_id,
c.started_at,
c.heartbeat_at,
c.created_at,
c.updated_at,
c.parent_chat_id,
c.root_chat_id,
c.last_model_config_id,
c.archived,
c.last_error,
c.mode,
c.mcp_server_ids,
c.labels,
c.build_id,
c.agent_id,
c.pin_order,
c.last_read_message_id,
c.last_injected_context,
c.dynamic_tools,
c.organization_id,
c.plan_mode,
c.client_type,
c.last_turn_summary,
c.snapshot_version,
c.history_version,
c.queue_version,
c.generation_attempt,
c.retry_state,
c.retry_state_version,
c.runner_id,
c.requires_action_deadline_at,
COALESCE(root.user_acl, c.user_acl) AS user_acl,
COALESCE(root.group_acl, c.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM ((chats c
LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id))))
JOIN visible_users owner ON ((owner.id = c.owner_id)));
@@ -0,0 +1,70 @@
-- Chat-side pin of the agent's latest pushed context snapshot
-- (workspace_agent_context_snapshots). Written by hydration (chat
-- create and agent push) and the dirty fan-out, and re-pinned by the
-- refresh endpoint. These columns are dark plumbing: they do not feed
-- prompt building and the per-turn context pull is unchanged. They are
-- read by drift detection and the refresh endpoint only.
ALTER TABLE chats
ADD COLUMN context_aggregate_hash bytea,
ADD COLUMN context_dirty_since timestamptz,
ADD COLUMN context_dirty_resources jsonb,
ADD COLUMN context_error text NOT NULL DEFAULT '';
COMMENT ON COLUMN chats.context_aggregate_hash IS 'Aggregate hash of the agent context snapshot this chat is pinned to. NULL until first hydrated; compared against the agent''s latest snapshot hash to detect drift.';
COMMENT ON COLUMN chats.context_dirty_since IS 'Set when an agent push changes the pinned hash; cleared on refresh. NULL means clean.';
COMMENT ON COLUMN chats.context_dirty_resources IS 'Deterministic prefix of resources that changed since the pinned hash. Reserved for the dirty diff; left NULL until the UI phase populates it.';
COMMENT ON COLUMN chats.context_error IS 'Snapshot-level error copied from the pinned snapshot (count cap exceeded, watcher degraded, etc.). Empty when healthy.';
-- Refresh chats_expanded to include the new chat columns. The gentest
-- TestViewSubsetChat requires every chats column to appear in the view.
-- Drop and recreate because a view cannot have columns inserted in the
-- middle of its column list.
DROP VIEW IF EXISTS chats_expanded;
CREATE VIEW chats_expanded AS
SELECT c.id,
c.owner_id,
c.workspace_id,
c.title,
c.status,
c.worker_id,
c.started_at,
c.heartbeat_at,
c.created_at,
c.updated_at,
c.parent_chat_id,
c.root_chat_id,
c.last_model_config_id,
c.archived,
c.last_error,
c.mode,
c.mcp_server_ids,
c.labels,
c.build_id,
c.agent_id,
c.pin_order,
c.last_read_message_id,
c.last_injected_context,
c.dynamic_tools,
c.organization_id,
c.plan_mode,
c.client_type,
c.last_turn_summary,
c.snapshot_version,
c.history_version,
c.queue_version,
c.generation_attempt,
c.retry_state,
c.retry_state_version,
c.runner_id,
c.requires_action_deadline_at,
COALESCE(root.user_acl, c.user_acl) AS user_acl,
COALESCE(root.group_acl, c.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
c.context_aggregate_hash,
c.context_dirty_since,
c.context_dirty_resources,
c.context_error
FROM ((chats c
LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id))))
JOIN visible_users owner ON ((owner.id = c.owner_id)));
+9 -1
View File
@@ -836,6 +836,10 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
&i.Chat.GroupACL,
&i.Chat.OwnerUsername,
&i.Chat.OwnerName,
&i.Chat.ContextAggregateHash,
&i.Chat.ContextDirtySince,
&i.Chat.ContextDirtyResources,
&i.Chat.ContextError,
&i.HasUnread); err != nil {
return nil, err
}
@@ -910,7 +914,11 @@ func (q *sqlQuerier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName); err != nil {
&i.OwnerName,
&i.ContextAggregateHash,
&i.ContextDirtySince,
&i.ContextDirtyResources,
&i.ContextError); err != nil {
return nil, err
}
items = append(items, i)
+12
View File
@@ -4797,6 +4797,10 @@ type Chat struct {
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
OwnerUsername string `db:"owner_username" json:"owner_username"`
OwnerName string `db:"owner_name" json:"owner_name"`
ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"`
ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"`
ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"`
ContextError string `db:"context_error" json:"context_error"`
}
type ChatDebugRun struct {
@@ -4983,6 +4987,14 @@ type ChatTable struct {
RetryStateVersion int64 `db:"retry_state_version" json:"retry_state_version"`
RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"`
RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"`
// Aggregate hash of the agent context snapshot this chat is pinned to. NULL until first hydrated; compared against the agent's latest snapshot hash to detect drift.
ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"`
// Set when an agent push changes the pinned hash; cleared on refresh. NULL means clean.
ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"`
// Deterministic prefix of resources that changed since the pinned hash. Reserved for the dirty diff; left NULL until the UI phase populates it.
ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"`
// Snapshot-level error copied from the pinned snapshot (count cap exceeded, watcher degraded, etc.). Empty when healthy.
ContextError string `db:"context_error" json:"context_error"`
}
type ChatUsageLimitConfig struct {
+16
View File
@@ -994,6 +994,11 @@ type sqlcQuerier interface {
GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error)
GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error)
GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]GetWorkspacesForWorkspaceMetricsRow, error)
// Stamps the pinned hash and error on every not-yet-hydrated chat for
// an agent (context_aggregate_hash IS NULL). Runs as a side effect of
// an agent push so chats created before the agent was ready pick up the
// snapshot without a dirty event. Does not bump updated_at.
HydrateAgentChatsContext(ctx context.Context, arg HydrateAgentChatsContextParams) error
// Increments generation_attempt and returns the resulting value.
IncrementChatGenerationAttempt(ctx context.Context, id uuid.UUID) (int64, error)
InsertAIBridgeInterception(ctx context.Context, arg InsertAIBridgeInterceptionParams) (AIBridgeInterception, error)
@@ -1175,6 +1180,12 @@ type sqlcQuerier interface {
// allocate a new snapshot version in one round trip.
LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid.UUID) (Chat, error)
MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error
// Flips active, already-hydrated chats for an agent to dirty when the
// agent's latest snapshot hash differs from the chat's pinned hash. The
// pinned hash is intentionally left untouched; the refresh endpoint
// re-pins it. Returns the chats that transitioned so the caller can
// emit watch events after the transaction commits.
MarkChatsContextDirtyByAgent(ctx context.Context, arg MarkChatsContextDirtyByAgentParams) ([]MarkChatsContextDirtyByAgentRow, error)
OIDCClaimFieldValues(ctx context.Context, arg OIDCClaimFieldValuesParams) ([]string, error)
// OIDCClaimFields returns a list of distinct keys in the the merged_claims fields.
// This query is used to generate the list of available sync fields for idp sync settings.
@@ -1219,6 +1230,11 @@ type sqlcQuerier interface {
// for the table.
// The CTE and the reorder is required because UPDATE doesn't guarantee order.
SelectUsageEventsForPublishing(ctx context.Context, now time.Time) ([]UsageEvent, error)
// Pins a single chat to the supplied context snapshot hash and error
// and clears any dirty marker. Used by chat-create hydration and the
// refresh endpoint. Does not bump updated_at: context pinning is
// background state and must not reorder chat lists.
SetChatContextSnapshot(ctx context.Context, arg SetChatContextSnapshotParams) error
SoftDeleteChatMessageByID(ctx context.Context, id int64) error
SoftDeleteChatMessagesAfterID(ctx context.Context, arg SoftDeleteChatMessagesAfterIDParams) error
SoftDeleteContextFileMessages(ctx context.Context, chatID uuid.UUID) error
+116
View File
@@ -1235,6 +1235,122 @@ func TestGetAuthorizedWorkspacesAndAgentsByOwnerID(t *testing.T) {
})
}
func TestChatContextHydration(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
sqlDB := testSQLDB(t)
require.NoError(t, migrations.Up(sqlDB))
db := database.New(sqlDB)
ctx := testutil.Context(t, testutil.WaitMedium)
org := dbgen.Organization(t, db, database.Organization{})
owner := dbgen.User(t, db, database.User{})
_ = dbgen.ChatProvider(t, db, database.ChatProvider{Provider: "openai", DisplayName: "OpenAI"})
modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Provider: "openai",
Model: "test-model",
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
IsDefault: true,
CompressionThreshold: 80,
})
// Chats are scoped per agent, so build two independent agents.
newAgent := func() database.WorkspaceAgent {
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{OrganizationID: org.ID})
resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: job.ID})
return dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: resource.ID})
}
agent := newAgent()
otherAgent := newAgent()
newChat := func(status database.ChatStatus, agentID uuid.UUID) database.Chat {
return dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
AgentID: uuid.NullUUID{UUID: agentID, Valid: true},
Status: status,
})
}
hashH := []byte{0x01, 0x02, 0x03}
hashOther := []byte{0xff, 0xee}
chatNull := newChat(database.ChatStatusWaiting, agent.ID) // never hydrated
chatMatch := newChat(database.ChatStatusRunning, agent.ID) // already at hashH
chatDrift := newChat(database.ChatStatusRunning, agent.ID) // drifted, active
chatTerminal := newChat(database.ChatStatusCompleted, agent.ID) // drifted, terminal
chatArchived := newChat(database.ChatStatusRunning, agent.ID) // drifted, archived
chatOtherAgent := newChat(database.ChatStatusRunning, otherAgent.ID)
// Pin starting hashes; chatNull is intentionally left NULL.
require.NoError(t, db.SetChatContextSnapshot(ctx, database.SetChatContextSnapshotParams{ID: chatMatch.ID, AggregateHash: hashH}))
for _, id := range []uuid.UUID{chatDrift.ID, chatTerminal.ID, chatArchived.ID, chatOtherAgent.ID} {
require.NoError(t, db.SetChatContextSnapshot(ctx, database.SetChatContextSnapshotParams{ID: id, AggregateHash: hashOther}))
}
_, err := db.ArchiveChatByID(ctx, chatArchived.ID)
require.NoError(t, err)
// Hydrate stamps only the NULL-hash chat for this agent.
require.NoError(t, db.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
AgentID: agent.ID,
AggregateHash: hashH,
}))
gotNull, err := db.GetChatByID(ctx, chatNull.ID)
require.NoError(t, err)
require.Equal(t, hashH, gotNull.ContextAggregateHash, "NULL-hash chat is hydrated")
gotDrift, err := db.GetChatByID(ctx, chatDrift.ID)
require.NoError(t, err)
require.Equal(t, hashOther, gotDrift.ContextAggregateHash, "hydrate must not overwrite an already-pinned hash")
// Mark dirty: only the active, pinned, drifted chat for THIS agent flips.
// chatNull (now matches), chatMatch (matches), chatTerminal (status
// excluded), chatArchived (archived), and chatOtherAgent (other agent)
// are all left clean.
now := dbtime.Now()
flipped, err := db.MarkChatsContextDirtyByAgent(ctx, database.MarkChatsContextDirtyByAgentParams{
AgentID: agent.ID,
AggregateHash: hashH,
DirtySince: sql.NullTime{Time: now, Valid: true},
})
require.NoError(t, err)
flippedIDs := make([]uuid.UUID, 0, len(flipped))
for _, f := range flipped {
flippedIDs = append(flippedIDs, f.ID)
}
require.ElementsMatch(t, []uuid.UUID{chatDrift.ID}, flippedIDs)
gotDrift, err = db.GetChatByID(ctx, chatDrift.ID)
require.NoError(t, err)
require.True(t, gotDrift.ContextDirtySince.Valid, "drifted chat is marked dirty")
// Refresh re-pins to the latest hash and clears the dirty marker.
require.NoError(t, db.SetChatContextSnapshot(ctx, database.SetChatContextSnapshotParams{ID: chatDrift.ID, AggregateHash: hashH}))
gotDrift, err = db.GetChatByID(ctx, chatDrift.ID)
require.NoError(t, err)
require.Equal(t, hashH, gotDrift.ContextAggregateHash)
require.False(t, gotDrift.ContextDirtySince.Valid, "refresh clears the dirty marker")
// With every chat now matching, a second mark is a no-op.
flipped, err = db.MarkChatsContextDirtyByAgent(ctx, database.MarkChatsContextDirtyByAgentParams{
AgentID: agent.ID,
AggregateHash: hashH,
DirtySince: sql.NullTime{Time: now, Valid: true},
})
require.NoError(t, err)
require.Empty(t, flipped)
// The other agent's chat is never touched by this agent's push.
gotOther, err := db.GetChatByID(ctx, chatOtherAgent.ID)
require.NoError(t, err)
require.Equal(t, hashOther, gotOther.ContextAggregateHash)
require.False(t, gotOther.ContextDirtySince.Valid)
}
func TestGetAuthorizedChats(t *testing.T) {
t.Parallel()
if testing.Short() {
+388 -72
View File
File diff suppressed because it is too large Load Diff
+141 -20
View File
@@ -46,7 +46,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chats.context_aggregate_hash,
updated_chats.context_dirty_since,
updated_chats.context_dirty_resources,
updated_chats.context_error
FROM
updated_chats
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
@@ -109,7 +113,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chats.context_aggregate_hash,
updated_chats.context_dirty_since,
updated_chats.context_dirty_resources,
updated_chats.context_error
FROM
updated_chats
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
@@ -771,7 +779,11 @@ chats_expanded AS (
COALESCE(root.user_acl, inserted_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, inserted_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
inserted_chat.context_aggregate_hash,
inserted_chat.context_dirty_since,
inserted_chat.context_dirty_resources,
inserted_chat.context_error
FROM
inserted_chat
LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id)
@@ -916,7 +928,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -979,7 +995,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -1040,7 +1060,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -1101,7 +1125,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -1162,7 +1190,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -1222,7 +1254,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -1282,7 +1318,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -1344,7 +1384,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -1422,7 +1466,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -1431,6 +1479,47 @@ chats_expanded AS (
SELECT *
FROM chats_expanded;
-- name: SetChatContextSnapshot :exec
-- Pins a single chat to the supplied context snapshot hash and error
-- and clears any dirty marker. Used by chat-create hydration and the
-- refresh endpoint. Does not bump updated_at: context pinning is
-- background state and must not reorder chat lists.
UPDATE chats
SET
context_aggregate_hash = @aggregate_hash,
context_error = @context_error,
context_dirty_since = NULL
WHERE id = @id::uuid;
-- name: HydrateAgentChatsContext :exec
-- Stamps the pinned hash and error on every not-yet-hydrated chat for
-- an agent (context_aggregate_hash IS NULL). Runs as a side effect of
-- an agent push so chats created before the agent was ready pick up the
-- snapshot without a dirty event. Does not bump updated_at.
UPDATE chats
SET
context_aggregate_hash = @aggregate_hash,
context_error = @context_error
WHERE agent_id = @agent_id::uuid
AND archived = false
AND context_aggregate_hash IS NULL;
-- name: MarkChatsContextDirtyByAgent :many
-- Flips active, already-hydrated chats for an agent to dirty when the
-- agent's latest snapshot hash differs from the chat's pinned hash. The
-- pinned hash is intentionally left untouched; the refresh endpoint
-- re-pins it. Returns the chats that transitioned so the caller can
-- emit watch events after the transaction commits.
UPDATE chats
SET context_dirty_since = @dirty_since
WHERE agent_id = @agent_id::uuid
AND archived = false
AND status IN ('waiting', 'running', 'paused', 'pending', 'requires_action')
AND context_aggregate_hash IS NOT NULL
AND context_aggregate_hash IS DISTINCT FROM @aggregate_hash
AND context_dirty_since IS NULL
RETURNING id, owner_id;
-- name: LinkChatFiles :one
-- LinkChatFiles inserts file associations into the chat_file_links
-- join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
@@ -1539,7 +1628,11 @@ chats_expanded AS (
COALESCE(root.user_acl, acquired_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, acquired_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
acquired_chats.context_aggregate_hash,
acquired_chats.context_dirty_since,
acquired_chats.context_dirty_resources,
acquired_chats.context_error
FROM
acquired_chats
LEFT JOIN chats root ON root.id = COALESCE(acquired_chats.root_chat_id, acquired_chats.parent_chat_id)
@@ -1604,7 +1697,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -1669,7 +1766,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
@@ -1941,7 +2042,11 @@ chats_expanded AS (
COALESCE(root.user_acl, locked_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, locked_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
locked_chat.context_aggregate_hash,
locked_chat.context_dirty_since,
locked_chat.context_dirty_resources,
locked_chat.context_error
FROM
locked_chat
LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id)
@@ -1998,7 +2103,11 @@ chats_expanded AS (
COALESCE(root.user_acl, shared_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, shared_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
shared_chat.context_aggregate_hash,
shared_chat.context_dirty_since,
shared_chat.context_dirty_resources,
shared_chat.context_error
FROM
shared_chat
LEFT JOIN chats root ON root.id = COALESCE(shared_chat.root_chat_id, shared_chat.parent_chat_id)
@@ -2675,7 +2784,11 @@ chats_expanded AS (
COALESCE(root.user_acl, bumped_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, bumped_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
bumped_chat.context_aggregate_hash,
bumped_chat.context_dirty_since,
bumped_chat.context_dirty_resources,
bumped_chat.context_error
FROM bumped_chat
LEFT JOIN chats root ON root.id = COALESCE(bumped_chat.root_chat_id, bumped_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = bumped_chat.owner_id
@@ -2743,7 +2856,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
@@ -2803,7 +2920,11 @@ chats_expanded AS (
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id