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
+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() {