Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/database/foreign_key_constraint.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions coderd/database/migrations/000562_aibridge_sessions.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
DROP TRIGGER IF EXISTS aibridge_user_prompts_track_session ON aibridge_user_prompts;
DROP TRIGGER IF EXISTS aibridge_interceptions_track_session ON aibridge_interceptions;

DROP FUNCTION IF EXISTS aibridge_session_track_prompt();
DROP FUNCTION IF EXISTS aibridge_session_track_interception();
DROP FUNCTION IF EXISTS aibridge_session_merge_value(text[], text);

DROP TABLE IF EXISTS aibridge_sessions;
155 changes: 155 additions & 0 deletions coderd/database/migrations/000562_aibridge_sessions.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
-- Materializes AI Bridge sessions, the logical grouping of interceptions
-- sharing a session_id. Without a table of their own, ordering and filtering
-- the sessions list meant aggregating every interception on each page load, and
-- no index could serve the ordering. Storing the ordering key and the
-- filterable attributes here makes a page an index scan that stops after LIMIT
-- rows.
--
-- The two timestamps are used for time-range filtering and sorting:
--
-- column | definition | used for
-- ---------------+---------------------------------------+------------------------
-- started_at | MIN(interception.started_at) | filtering
-- last_active_at | MAX(prompt times, interception starts)| ordering, filtering
CREATE TABLE aibridge_sessions (
session_id text NOT NULL,
initiator_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at timestamptz NOT NULL,
-- Ordering key: the latest event in the session, meaning the most recent
-- user prompt or interception start, whichever is later.
last_active_at timestamptz NOT NULL,
-- Filter attributes belong to interceptions, so they are denormalized here
-- to keep filtered pages on the index. Sessions genuinely may span several
-- providers and models, so those are arrays; client is a scalar because a
-- session_id is issued by a single client.
providers text[] NOT NULL DEFAULT '{}',
provider_names text[] NOT NULL DEFAULT '{}',
models text[] NOT NULL DEFAULT '{}',
client text NOT NULL DEFAULT 'Unknown',
-- session_id alone is not unique: it derives from the client-supplied
-- client_session_id, so two users can present the same value. Keying on
-- both columns keeps their sessions separate, matching the
-- GROUP BY session_id, initiator_id the query used before.
PRIMARY KEY (session_id, initiator_id)
);

COMMENT ON TABLE aibridge_sessions IS 'Materialized view of AI Bridge sessions, maintained by triggers on aibridge_interceptions and aibridge_user_prompts. Each row summarizes the interceptions sharing same session_id and initiator.';
COMMENT ON COLUMN aibridge_sessions.started_at IS 'Earliest started_at across the session''s interceptions. Paired with last_active_at so time-range filters can test whether the session overlaps the requested window.';
COMMENT ON COLUMN aibridge_sessions.last_active_at IS 'Timestamp of the latest event in the session: the most recent user prompt or interception start, whichever is later. Sort key for the sessions list, and the upper bound for time-range filters.';
COMMENT ON COLUMN aibridge_sessions.client IS 'The client that issued the session. Scalar rather than an array because a session_id originates from one client.';

-- Serves ORDER BY last_active_at DESC, session_id DESC LIMIT n for the ListAIBridgeSessions query.
CREATE INDEX idx_aibridge_sessions_last_active
ON aibridge_sessions (last_active_at DESC, session_id DESC);
-- `started_at` is deliberately left unindexed for now, as indexing it doesn't seem to provide much benefit.
-- Revisit later if necessary.

-- Answers the initiator and client filters.
CREATE INDEX idx_aibridge_sessions_initiator
ON aibridge_sessions (initiator_id);
CREATE INDEX idx_aibridge_sessions_client
ON aibridge_sessions (client);

-- Answers the array membership filters.
CREATE INDEX idx_aibridge_sessions_providers
ON aibridge_sessions USING gin (providers);
CREATE INDEX idx_aibridge_sessions_provider_names
ON aibridge_sessions USING gin (provider_names);
CREATE INDEX idx_aibridge_sessions_models
ON aibridge_sessions USING gin (models);

-- Adds value to arr only when absent, so repeated interceptions with the same
-- provider or model do not grow the arrays without bound.
CREATE FUNCTION aibridge_session_merge_value(arr text[], value text) RETURNS text[]
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN value IS NULL THEN arr
WHEN arr @> ARRAY[value] THEN arr
ELSE arr || value
END;
$$;

-- Upserts the session row when an interception completes.
--
-- Every accumulator is monotonic: last_active_at only moves forward, started_at
-- only moves back, and the arrays only grow. Interceptions can therefore arrive
-- in any order and out of order relative to prompts, and the row converges on
-- the same values.
CREATE FUNCTION aibridge_session_track_interception() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO aibridge_sessions (
session_id, initiator_id, started_at, last_active_at,
providers, provider_names, models, client
)
VALUES (
NEW.session_id, NEW.initiator_id, NEW.started_at, NEW.started_at,
ARRAY[NEW.provider], ARRAY[NEW.provider_name], ARRAY[NEW.model],
COALESCE(NEW.client, 'Unknown')
)
ON CONFLICT (session_id, initiator_id) DO UPDATE SET
started_at = LEAST(aibridge_sessions.started_at, EXCLUDED.started_at),
last_active_at = GREATEST(aibridge_sessions.last_active_at, EXCLUDED.last_active_at),
providers = aibridge_session_merge_value(aibridge_sessions.providers, NEW.provider),
provider_names = aibridge_session_merge_value(aibridge_sessions.provider_names, NEW.provider_name),
models = aibridge_session_merge_value(aibridge_sessions.models, NEW.model);
-- client is deliberately absent: the first interception to complete
-- sets it and later ones leave it alone, since a session_id comes from
-- a single client.
RETURN NULL;
END;
$$;

-- Creates or updates the session row for each completed interception, keeping
-- aibridge_sessions in sync with aibridge_interceptions.
CREATE TRIGGER aibridge_interceptions_track_session

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-6] The trigger, the backfill, and the display query disagree on which client a session has. (Netero)

The trigger keeps the first non-NULL client (COALESCE(existing, EXCLUDED)); the backfill and the outer display lateral keep the first client ordered by started_at, id, which can be NULL even when later interceptions have one.

A session whose first interception has NULL client filters as client=X (trigger-maintained) but displays as empty, and the same session backfilled stores NULL and does not match the filter. The old filter matched any interception's client. Pick one rule and use it in all three places.

🤖

AFTER INSERT OR UPDATE ON aibridge_interceptions
FOR EACH ROW
WHEN (NEW.ended_at IS NOT NULL)
EXECUTE FUNCTION aibridge_session_track_interception();

-- Advances the session's last_active_at when a prompt arrives, keeping
-- aibridge_sessions in sync with aibridge_user_prompts.
CREATE FUNCTION aibridge_session_track_prompt() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE aibridge_sessions s
SET last_active_at = GREATEST(s.last_active_at, NEW.created_at)
-- Join aibridge_user_prompts with aibridge_interceptions to enrich the
-- prompt with session_id and initiator_id, then filter the session by them.
FROM aibridge_interceptions ai
WHERE ai.id = NEW.interception_id
AND s.session_id = ai.session_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 [CRF-2] Prompts recorded before their interception ends never advance last_active_at, so the sort key is wrong for the normal production flow. (Netero)

Production order is RecordPromptUsage (inserts the prompt) during the interception, then RecordInterceptionEnded (sets ended_at): coderd/aibridgedserver/aibridgedserver.go:481 and :311. For a session's first interception, the prompt trigger's UPDATE matches no aibridge_sessions row (the row is only created when an interception completes), so the prompt timestamp is silently dropped. The interception trigger then seeds last_active_at from started_at; its GREATEST only ever compares started_at values and never sees the prompt's created_at.

Reproduced empirically by Netero: interception in-flight, prompt at 00:05, interception ended at 00:06 yields last_active_at = 00:00 (started_at), where the old query returned 00:05. The trigger comment claiming the interception trigger's GREATEST "keeps whichever timestamp is later" is false. Every single-interception session, and the first interception of every session, sorts by start time instead of prompt time, diverging from the column's own documented semantics and from pre-PR behavior.

🤖

AND s.initiator_id = ai.initiator_id;
RETURN NULL;
END;
$$;

CREATE TRIGGER aibridge_user_prompts_track_session
AFTER INSERT ON aibridge_user_prompts
FOR EACH ROW
EXECUTE FUNCTION aibridge_session_track_prompt();

-- Backfills sessions from existing interceptions.
INSERT INTO aibridge_sessions (
session_id, initiator_id, started_at, last_active_at,
providers, provider_names, models, client
)
SELECT
ai.session_id,
ai.initiator_id,
MIN(ai.started_at),
GREATEST(MAX(up.created_at), MAX(ai.started_at)),
ARRAY_AGG(DISTINCT ai.provider ORDER BY ai.provider),
ARRAY_AGG(DISTINCT ai.provider_name ORDER BY ai.provider_name),
ARRAY_AGG(DISTINCT ai.model ORDER BY ai.model),
COALESCE((ARRAY_AGG(ai.client ORDER BY ai.started_at, ai.id))[1], 'Unknown')
FROM aibridge_interceptions ai
LEFT JOIN aibridge_user_prompts up ON up.interception_id = ai.id
WHERE ai.ended_at IS NOT NULL
GROUP BY ai.session_id, ai.initiator_id
ON CONFLICT (session_id, initiator_id) DO NOTHING;
15 changes: 15 additions & 0 deletions coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading