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,