diff --git a/.changeset/session-tfg-source-backfill.md b/.changeset/session-tfg-source-backfill.md new file mode 100644 index 000000000..27c3c650b --- /dev/null +++ b/.changeset/session-tfg-source-backfill.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Backfill `tfg.source: ui` session metadata on existing non-scheduled sessions so they stay in Chat History once it filters on that key. diff --git a/packages/trueforge/src/db/indexes.ts b/packages/trueforge/src/db/indexes.ts index e69c9757a..d848e5156 100644 --- a/packages/trueforge/src/db/indexes.ts +++ b/packages/trueforge/src/db/indexes.ts @@ -16,6 +16,12 @@ export const AGENT_EXTERNAL_ID_UQ = 'agent_external_id_uq'; /** GIN index for `session.metadata` containment queries (`@>`). */ export const SESSION_METADATA_GIN = 'session_metadata_gin'; +/** + * `(tenant_id, created_by_subject.subject_id, updated_at, session_id, metadata.tfg.source)` on + * session — a caller's sessions in keyset order, optionally filtered by `tfg.source`. + */ +export const SESSION_TFG_SOURCE_LIST_IDX = 'session_tfg_source_list_idx'; + /** `(tenant_id, created_by_subject.subject_id)` on agent. */ export const AGENT_CREATED_BY_SUBJECT_ID_IDX = 'agent_created_by_subject_id_idx'; diff --git a/packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfg_source_backfill.ts b/packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfg_source_backfill.ts new file mode 100644 index 000000000..b20441551 --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfg_source_backfill.ts @@ -0,0 +1,26 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * Stamp `metadata["tfg.source"] = "ui"` on pre-existing interactive sessions so Chat History, + * which now lists only sessions carrying that key, keeps showing them. + * + * Schedule runs are the only sessions with `external_id`; checking it alongside `source` also + * skips schedule runs created before `source` existed. `updated_at` is left untouched so list + * ordering does not change. + */ +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql` + UPDATE session + SET metadata = metadata || jsonb_build_object('tfg.source', 'ui') + WHERE source IS NULL + AND external_id IS NULL + AND metadata->'tfg.source' IS NULL + `.execute(db); +} + +/** Data backfill — not reversed: backfilled rows are indistinguishable from UI-stamped ones. */ +export function down(db: Kysely): Promise { + void db; + return Promise.reject(new Error('20261022_000001_session_tfg_source_backfill is irreversible')); +} diff --git a/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts b/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts new file mode 100644 index 000000000..eab4bf28c --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts @@ -0,0 +1,25 @@ +import { sql, type Kysely } from 'kysely'; +import { SESSION_TFG_SOURCE_LIST_IDX } from '../../indexes'; + +/** + * A caller's sessions in keyset order, so their newest-first listing needs no sort with or without + * a `tfg.source` filter; the filter is checked on index entries without visiting the table. + */ +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql` + CREATE INDEX ${sql.raw(SESSION_TFG_SOURCE_LIST_IDX)} + ON session ( + tenant_id, + (created_by_subject->>'subject_id'), + updated_at, + session_id, + (metadata->>'tfg.source') + ) + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql`DROP INDEX IF EXISTS ${sql.raw(SESSION_TFG_SOURCE_LIST_IDX)}`.execute(db); +} diff --git a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts index 2b998e226..b6f5e890a 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts @@ -285,6 +285,12 @@ export async function listSessions( query = query.where(sql`metadata = '{}'::jsonb`); } else { query = query.where(sql`metadata @> ${json(input.metadata)}`); + // Matches the same rows as the `@>` filter above (metadata values are always strings). + // Postgres can only use the GIN index for `@>`; spelling each pair as `metadata->>'key' = value` + // also lets it use single-key indexes. Those match on the literal key, so it is inlined, not bound. + for (const [key, value] of Object.entries(input.metadata)) { + query = query.where(sql`metadata->>${sql.lit(key)}`, '=', value); + } } } if (input.source_type !== undefined) { diff --git a/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfg_source_backfill.ts b/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfg_source_backfill.ts new file mode 100644 index 000000000..3441ab719 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfg_source_backfill.ts @@ -0,0 +1,22 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * SQLite mirror of postgres/migrations/20261022_000001_session_tfg_source_backfill.ts. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql` + UPDATE session + SET metadata = jsonb_set(metadata, '$."tfg.source"', 'ui') + WHERE source IS NULL + AND external_id IS NULL + AND json_extract(metadata, '$."tfg.source"') IS NULL + `.execute(trx); + }); +} + +/** Data backfill — not reversed: backfilled rows are indistinguishable from UI-stamped ones. */ +export function down(db: Kysely): Promise { + void db; + return Promise.reject(new Error('20261022_000001_session_tfg_source_backfill is irreversible')); +} diff --git a/packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts b/packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts new file mode 100644 index 000000000..18b5e9fbf --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts @@ -0,0 +1,27 @@ +import { sql, type Kysely } from 'kysely'; +import { SESSION_TFG_SOURCE_LIST_IDX } from '../../indexes'; + +/** + * SQLite mirror of postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts. + * The key contains a dot, so the JSON path must quote it. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql` + CREATE INDEX ${sql.raw(SESSION_TFG_SOURCE_LIST_IDX)} + ON session ( + tenant_id, + json_extract(created_by_subject, '$.subject_id'), + updated_at, + session_id, + json_extract(metadata, '$."tfg.source"') + ) + `.execute(trx); + }); +} + +export async function down(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql`DROP INDEX IF EXISTS ${sql.raw(SESSION_TFG_SOURCE_LIST_IDX)}`.execute(trx); + }); +}