mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Generated
+43
@@ -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()
|
||||
|
||||
Generated
+17
-1
@@ -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)));
|
||||
@@ -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)
|
||||
|
||||
Generated
+12
@@ -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 {
|
||||
|
||||
Generated
+16
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Generated
+388
-72
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user