// Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.27.0 // source: bot.sql package repository import ( "context" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) const abandonExpiredInflightForOwner = `-- name: AbandonExpiredInflightForOwner :exec UPDATE bot_turns bt SET status = 'abandoned', error = jsonb_build_object( 'code', 'turn_abandoned_grace_expired', 'message', 'no terminal status reached within grace window' ) WHERE bt.session_id = $1 AND bt.status = 'in_flight' AND bt.attempt_started_at < NOW() - make_interval(secs => $2::int) AND EXISTS ( SELECT 1 FROM bot_sessions s WHERE s.id = bt.session_id AND s.client_id = $3 AND s.created_by = $4 AND s.is_deleted = false ) ` type AbandonExpiredInflightForOwnerParams struct { SessionID uuid.UUID `db:"session_id"` GraceSeconds int32 `db:"grace_seconds"` ClientID string `db:"client_id"` CreatedBy string `db:"created_by"` } // Owner-scoped grace-window abandonment. Mutates bot_turns rows whose // attempt_started_at is older than NOW() - grace_seconds AND whose owning // session matches the caller's (client_id, created_by, is_deleted=false). // The EXISTS predicate is defense in depth — the service layer is // expected to call LockBotSessionForOwner first. Plan §5. // // UPDATE bot_turns bt // SET status = 'abandoned', // error = jsonb_build_object( // 'code', 'turn_abandoned_grace_expired', // 'message', 'no terminal status reached within grace window' // ) // WHERE bt.session_id = $1 // AND bt.status = 'in_flight' // AND bt.attempt_started_at < NOW() - make_interval(secs => $2::int) // AND EXISTS ( // SELECT 1 // FROM bot_sessions s // WHERE s.id = bt.session_id // AND s.client_id = $3 // AND s.created_by = $4 // AND s.is_deleted = false // ) func (q *Queries) AbandonExpiredInflightForOwner(ctx context.Context, arg *AbandonExpiredInflightForOwnerParams) error { _, err := q.db.Exec(ctx, abandonExpiredInflightForOwner, arg.SessionID, arg.GraceSeconds, arg.ClientID, arg.CreatedBy, ) return err } const completeBotTurn = `-- name: CompleteBotTurn :execrows UPDATE bot_turns SET status = 'completed', completion = $1, latency_ms = $2, tokens_in = $3, tokens_out = $4 WHERE session_id = $5 AND ordinal = $6 AND attempt_id = $7 AND status = 'in_flight' ` type CompleteBotTurnParams struct { Completion *string `db:"completion"` LatencyMs *int32 `db:"latency_ms"` TokensIn *int32 `db:"tokens_in"` TokensOut *int32 `db:"tokens_out"` SessionID uuid.UUID `db:"session_id"` Ordinal int32 `db:"ordinal"` AttemptID uuid.UUID `db:"attempt_id"` } // Compare-and-set: flips status from in_flight to completed when // attempt_id matches. Returns affected row count so a stale tx2 // callback (whose attempt_id no longer matches) reports 0 rows and the // service returns ErrTurnSuperseded. Plan §6 + plan §11. // // UPDATE bot_turns // SET status = 'completed', // completion = $1, // latency_ms = $2, // tokens_in = $3, // tokens_out = $4 // WHERE session_id = $5 // AND ordinal = $6 // AND attempt_id = $7 // AND status = 'in_flight' func (q *Queries) CompleteBotTurn(ctx context.Context, arg *CompleteBotTurnParams) (int64, error) { result, err := q.db.Exec(ctx, completeBotTurn, arg.Completion, arg.LatencyMs, arg.TokensIn, arg.TokensOut, arg.SessionID, arg.Ordinal, arg.AttemptID, ) if err != nil { return 0, err } return result.RowsAffected(), nil } const deleteBotSessionDocumentsForSession = `-- name: DeleteBotSessionDocumentsForSession :exec DELETE FROM bot_session_documents WHERE session_id = $1 ` // @sqlc-vet-disable // Wipe the persisted document scope for a session. Used as the first // half of the replace-set semantics in PatchBotSessionScope. The caller // must follow with InsertBotSessionDocument inside the same transaction // so the union write is atomic. // // DELETE FROM bot_session_documents // WHERE session_id = $1 func (q *Queries) DeleteBotSessionDocumentsForSession(ctx context.Context, sessionID uuid.UUID) error { _, err := q.db.Exec(ctx, deleteBotSessionDocumentsForSession, sessionID) return err } const deleteBotSessionFoldersForSession = `-- name: DeleteBotSessionFoldersForSession :exec DELETE FROM bot_session_folders WHERE session_id = $1 ` // @sqlc-vet-disable // Wipe the persisted folder scope for a session. Same role as // DeleteBotSessionDocumentsForSession. // // DELETE FROM bot_session_folders // WHERE session_id = $1 func (q *Queries) DeleteBotSessionFoldersForSession(ctx context.Context, sessionID uuid.UUID) error { _, err := q.db.Exec(ctx, deleteBotSessionFoldersForSession, sessionID) return err } const failBotTurn = `-- name: FailBotTurn :execrows UPDATE bot_turns SET status = 'errored', error = $1 WHERE session_id = $2 AND ordinal = $3 AND attempt_id = $4 AND status = 'in_flight' ` type FailBotTurnParams struct { Error []byte `db:"error"` SessionID uuid.UUID `db:"session_id"` Ordinal int32 `db:"ordinal"` AttemptID uuid.UUID `db:"attempt_id"` } // Compare-and-set: flips status from in_flight to errored when // attempt_id matches. Error JSON shape is `{"code","message","attempt"}` // per plan §6. // // UPDATE bot_turns // SET status = 'errored', // error = $1 // WHERE session_id = $2 // AND ordinal = $3 // AND attempt_id = $4 // AND status = 'in_flight' func (q *Queries) FailBotTurn(ctx context.Context, arg *FailBotTurnParams) (int64, error) { result, err := q.db.Exec(ctx, failBotTurn, arg.Error, arg.SessionID, arg.Ordinal, arg.AttemptID, ) if err != nil { return 0, err } return result.RowsAffected(), nil } const findBotTurnByPrompt = `-- name: FindBotTurnByPrompt :one SELECT session_id, ordinal, prompt, completion, status FROM bot_turns WHERE session_id = $1 AND prompt = $2 ORDER BY ordinal ASC LIMIT 1 ` type FindBotTurnByPromptParams struct { SessionID uuid.UUID `db:"session_id"` Prompt string `db:"prompt"` } type FindBotTurnByPromptRow struct { SessionID uuid.UUID `db:"session_id"` Ordinal int32 `db:"ordinal"` Prompt string `db:"prompt"` Completion *string `db:"completion"` Status string `db:"status"` } // Find the lowest-ordinal turn whose prompt matches input. Used by the // handler to pick a target ordinal under prompt-match semantics: a // retry of an errored/abandoned/completed/session_deleted turn must // reuse the existing row's ordinal so the service classifies it via // the existing-row branch of plan §6. pgx.ErrNoRows when no match. // // SELECT session_id, ordinal, prompt, completion, status // FROM bot_turns // WHERE session_id = $1 // AND prompt = $2 // ORDER BY ordinal ASC // LIMIT 1 func (q *Queries) FindBotTurnByPrompt(ctx context.Context, arg *FindBotTurnByPromptParams) (*FindBotTurnByPromptRow, error) { row := q.db.QueryRow(ctx, findBotTurnByPrompt, arg.SessionID, arg.Prompt) var i FindBotTurnByPromptRow err := row.Scan( &i.SessionID, &i.Ordinal, &i.Prompt, &i.Completion, &i.Status, ) return &i, err } const getBotSessionForOwner = `-- name: GetBotSessionForOwner :one SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal FROM bot_sessions s WHERE s.id = $1 AND s.client_id = $2 AND s.created_by = $3 AND s.is_deleted = false ` type GetBotSessionForOwnerParams struct { SessionID uuid.UUID `db:"session_id"` ClientID string `db:"client_id"` CreatedBy string `db:"created_by"` } type GetBotSessionForOwnerRow struct { ID uuid.UUID `db:"id"` ClientID string `db:"client_id"` CreatedBy string `db:"created_by"` CreatedAt pgtype.Timestamptz `db:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at"` Title string `db:"title"` State []byte `db:"state"` IsDeleted bool `db:"is_deleted"` LastSeenOrdinal int32 `db:"last_seen_ordinal"` LastTerminalOrdinal int32 `db:"last_terminal_ordinal"` } // Read-only owner-scoped session read. Mirrors LockBotSessionForOwner // but without FOR UPDATE so callers that only need to display a session // do not block writers. The derived ordinal columns match plan §4. // // SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, // COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, // COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal // FROM bot_sessions s // WHERE s.id = $1 // AND s.client_id = $2 // AND s.created_by = $3 // AND s.is_deleted = false func (q *Queries) GetBotSessionForOwner(ctx context.Context, arg *GetBotSessionForOwnerParams) (*GetBotSessionForOwnerRow, error) { row := q.db.QueryRow(ctx, getBotSessionForOwner, arg.SessionID, arg.ClientID, arg.CreatedBy) var i GetBotSessionForOwnerRow err := row.Scan( &i.ID, &i.ClientID, &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, &i.Title, &i.State, &i.IsDeleted, &i.LastSeenOrdinal, &i.LastTerminalOrdinal, ) return &i, err } const getBotSessionForSuperAdmin = `-- name: GetBotSessionForSuperAdmin :one SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal FROM bot_sessions s WHERE s.id = $1 AND s.client_id = $2 AND s.is_deleted = false ` type GetBotSessionForSuperAdminParams struct { SessionID uuid.UUID `db:"session_id"` ClientID string `db:"client_id"` } type GetBotSessionForSuperAdminRow struct { ID uuid.UUID `db:"id"` ClientID string `db:"client_id"` CreatedBy string `db:"created_by"` CreatedAt pgtype.Timestamptz `db:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at"` Title string `db:"title"` State []byte `db:"state"` IsDeleted bool `db:"is_deleted"` LastSeenOrdinal int32 `db:"last_seen_ordinal"` LastTerminalOrdinal int32 `db:"last_terminal_ordinal"` } // Super-admin client-scoped session read. No created_by predicate per // plan §9. is_deleted=false filter is the M2 default; including // soft-deleted rows is out of scope for M2. // // SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, // COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, // COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal // FROM bot_sessions s // WHERE s.id = $1 // AND s.client_id = $2 // AND s.is_deleted = false func (q *Queries) GetBotSessionForSuperAdmin(ctx context.Context, arg *GetBotSessionForSuperAdminParams) (*GetBotSessionForSuperAdminRow, error) { row := q.db.QueryRow(ctx, getBotSessionForSuperAdmin, arg.SessionID, arg.ClientID) var i GetBotSessionForSuperAdminRow err := row.Scan( &i.ID, &i.ClientID, &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, &i.Title, &i.State, &i.IsDeleted, &i.LastSeenOrdinal, &i.LastTerminalOrdinal, ) return &i, err } const getBotTurn = `-- name: GetBotTurn :one SELECT session_id, ordinal, prompt, completion, status, attempt_id, attempt_started_at, created_at, latency_ms, tokens_in, tokens_out, error FROM bot_turns WHERE session_id = $1 AND ordinal = $2 ` type GetBotTurnParams struct { SessionID uuid.UUID `db:"session_id"` Ordinal int32 `db:"ordinal"` } // ---------- Milestone M4 queries ---------- // // Turn CRUD + AddTurn algorithm queries per plan §6. Each mutating // query compares-and-sets on attempt_id so a stale tx2 callback after // abandonment or retry affects zero rows (plan §11 invariant). // Read one turn by (session_id, ordinal). Returns pgx.ErrNoRows when // no row exists. The service uses this in tx1 after // LockBotSessionForOwner so the existing-row classification (plan §6) // runs against a stable snapshot. // // SELECT session_id, ordinal, prompt, completion, status, attempt_id, // attempt_started_at, created_at, latency_ms, tokens_in, // tokens_out, error // FROM bot_turns // WHERE session_id = $1 // AND ordinal = $2 func (q *Queries) GetBotTurn(ctx context.Context, arg *GetBotTurnParams) (*BotTurn, error) { row := q.db.QueryRow(ctx, getBotTurn, arg.SessionID, arg.Ordinal) var i BotTurn err := row.Scan( &i.SessionID, &i.Ordinal, &i.Prompt, &i.Completion, &i.Status, &i.AttemptID, &i.AttemptStartedAt, &i.CreatedAt, &i.LatencyMs, &i.TokensIn, &i.TokensOut, &i.Error, ) return &i, err } const getCompletedTurnsForSessionAscending = `-- name: GetCompletedTurnsForSessionAscending :many WITH recent AS ( SELECT ordinal, prompt, completion, created_at FROM bot_turns WHERE session_id = $1 AND status = 'completed' ORDER BY ordinal DESC LIMIT $2::int ) SELECT ordinal, prompt, completion, created_at FROM recent ORDER BY ordinal ASC ` type GetCompletedTurnsForSessionAscendingParams struct { SessionID uuid.UUID `db:"session_id"` TurnLimit int32 `db:"turn_limit"` } type GetCompletedTurnsForSessionAscendingRow struct { Ordinal int32 `db:"ordinal"` Prompt string `db:"prompt"` Completion *string `db:"completion"` CreatedAt pgtype.Timestamptz `db:"created_at"` } // ---------- Milestone M3 queries ---------- // Returns the most recent N completed turns for a session, ordered by // ordinal ASCENDING for the FastAPI conversation_history payload (plan §7). // The CTE first filters to completed turns and DESCENDS by ordinal so the // LIMIT clause picks the newest N; the outer SELECT then re-orders ASC // because conversation_history is "two messages per turn, ascending // ordinal" per plan §7. The service caller passes turn_limit = cfg.RecentTurns. // // WITH recent AS ( // SELECT ordinal, prompt, completion, created_at // FROM bot_turns // WHERE session_id = $1 // AND status = 'completed' // ORDER BY ordinal DESC // LIMIT $2::int // ) // SELECT ordinal, prompt, completion, created_at // FROM recent // ORDER BY ordinal ASC func (q *Queries) GetCompletedTurnsForSessionAscending(ctx context.Context, arg *GetCompletedTurnsForSessionAscendingParams) ([]*GetCompletedTurnsForSessionAscendingRow, error) { rows, err := q.db.Query(ctx, getCompletedTurnsForSessionAscending, arg.SessionID, arg.TurnLimit) if err != nil { return nil, err } defer rows.Close() items := []*GetCompletedTurnsForSessionAscendingRow{} for rows.Next() { var i GetCompletedTurnsForSessionAscendingRow if err := rows.Scan( &i.Ordinal, &i.Prompt, &i.Completion, &i.CreatedAt, ); err != nil { return nil, err } items = append(items, &i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getDocumentClientIDsForBotScope = `-- name: GetDocumentClientIDsForBotScope :many SELECT id, clientId AS client_id FROM documents WHERE id = ANY($1::uuid[]) ` type GetDocumentClientIDsForBotScopeRow struct { ID uuid.UUID `db:"id"` ClientID string `db:"client_id"` } // Bulk client-id lookup for a candidate document set. Returns one row // per matching document so the service can detect cross-client ids by // comparing against the session's client_id and missing rows. Documents // not found return no row (caller must compare set sizes). // // SELECT id, clientId AS client_id // FROM documents // WHERE id = ANY($1::uuid[]) func (q *Queries) GetDocumentClientIDsForBotScope(ctx context.Context, documentIds []uuid.UUID) ([]*GetDocumentClientIDsForBotScopeRow, error) { rows, err := q.db.Query(ctx, getDocumentClientIDsForBotScope, documentIds) if err != nil { return nil, err } defer rows.Close() items := []*GetDocumentClientIDsForBotScopeRow{} for rows.Next() { var i GetDocumentClientIDsForBotScopeRow if err := rows.Scan(&i.ID, &i.ClientID); err != nil { return nil, err } items = append(items, &i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const getFolderClientIDsForBotScope = `-- name: GetFolderClientIDsForBotScope :many SELECT id, clientId AS client_id FROM folders WHERE id = ANY($1::uuid[]) ` type GetFolderClientIDsForBotScopeRow struct { ID uuid.UUID `db:"id"` ClientID string `db:"client_id"` } // Bulk client-id lookup for a candidate folder set. Same shape and // semantics as GetDocumentClientIDsForBotScope. // // SELECT id, clientId AS client_id // FROM folders // WHERE id = ANY($1::uuid[]) func (q *Queries) GetFolderClientIDsForBotScope(ctx context.Context, folderIds []uuid.UUID) ([]*GetFolderClientIDsForBotScopeRow, error) { rows, err := q.db.Query(ctx, getFolderClientIDsForBotScope, folderIds) if err != nil { return nil, err } defer rows.Close() items := []*GetFolderClientIDsForBotScopeRow{} for rows.Next() { var i GetFolderClientIDsForBotScopeRow if err := rows.Scan(&i.ID, &i.ClientID); err != nil { return nil, err } items = append(items, &i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const insertBotSession = `-- name: InsertBotSession :one INSERT INTO bot_sessions (client_id, created_by) VALUES ($1, $2) RETURNING id, client_id, created_by, created_at, updated_at, title, state, is_deleted ` type InsertBotSessionParams struct { ClientID string `db:"client_id"` CreatedBy string `db:"created_by"` } // ---------- Milestone M2 queries ---------- // // Session/scope CRUD queries used by internal/bot/service_session.go. // All reads carry the derived last_terminal_ordinal column (plan §4) so // the API response can populate `lastTurn` without a follow-up query. // Owner-scoped queries filter on (client_id, created_by, is_deleted=false); // super-admin reads filter on (client_id, is_deleted=false) only. // Insert a new owner-scoped session. Title and state default to ” / '{}' // on the schema; we let those defaults apply rather than passing the // caller's intent on creation. Plan §6 says title is derived from the // first turn's prompt (handled by M4's AddTurn path, not here). // // INSERT INTO bot_sessions (client_id, created_by) // VALUES ($1, $2) // RETURNING id, client_id, created_by, created_at, updated_at, title, state, is_deleted func (q *Queries) InsertBotSession(ctx context.Context, arg *InsertBotSessionParams) (*BotSession, error) { row := q.db.QueryRow(ctx, insertBotSession, arg.ClientID, arg.CreatedBy) var i BotSession err := row.Scan( &i.ID, &i.ClientID, &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, &i.Title, &i.State, &i.IsDeleted, ) return &i, err } const insertBotSessionDocument = `-- name: InsertBotSessionDocument :exec INSERT INTO bot_session_documents (session_id, document_id) VALUES ($1, $2) ON CONFLICT (session_id, document_id) DO NOTHING ` type InsertBotSessionDocumentParams struct { SessionID uuid.UUID `db:"session_id"` DocumentID uuid.UUID `db:"document_id"` } // Add one (session_id, document_id) link. ON CONFLICT DO NOTHING covers // the rare case where the same id appears twice in the new set; the // service-layer dedupe runs first so this is defense in depth. // // INSERT INTO bot_session_documents (session_id, document_id) // VALUES ($1, $2) // ON CONFLICT (session_id, document_id) DO NOTHING func (q *Queries) InsertBotSessionDocument(ctx context.Context, arg *InsertBotSessionDocumentParams) error { _, err := q.db.Exec(ctx, insertBotSessionDocument, arg.SessionID, arg.DocumentID) return err } const insertBotSessionFolder = `-- name: InsertBotSessionFolder :exec INSERT INTO bot_session_folders (session_id, folder_id) VALUES ($1, $2) ON CONFLICT (session_id, folder_id) DO NOTHING ` type InsertBotSessionFolderParams struct { SessionID uuid.UUID `db:"session_id"` FolderID uuid.UUID `db:"folder_id"` } // Add one (session_id, folder_id) link. // // INSERT INTO bot_session_folders (session_id, folder_id) // VALUES ($1, $2) // ON CONFLICT (session_id, folder_id) DO NOTHING func (q *Queries) InsertBotSessionFolder(ctx context.Context, arg *InsertBotSessionFolderParams) error { _, err := q.db.Exec(ctx, insertBotSessionFolder, arg.SessionID, arg.FolderID) return err } const insertBotTurnPrompt = `-- name: InsertBotTurnPrompt :one INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at) VALUES ($1, $2, $3, 'in_flight', $4, NOW(), NOW()) RETURNING session_id, ordinal, prompt, completion, status, attempt_id, attempt_started_at, created_at, latency_ms, tokens_in, tokens_out, error ` type InsertBotTurnPromptParams struct { SessionID uuid.UUID `db:"session_id"` Ordinal int32 `db:"ordinal"` Prompt string `db:"prompt"` AttemptID uuid.UUID `db:"attempt_id"` } // Insert a new in_flight turn at the requested ordinal. The partial // unique index `bot_turns_one_inflight_per_session` rejects insertion // when another in_flight row exists for the session — the service // catches the unique-violation pgconn.PgError and maps it to // ErrPriorTurnInFlight. Returns the inserted row so the caller can // surface created_at without a follow-up read. // // INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, // attempt_started_at, created_at) // VALUES ($1, $2, $3, 'in_flight', $4, // NOW(), NOW()) // RETURNING session_id, ordinal, prompt, completion, status, attempt_id, // attempt_started_at, created_at, latency_ms, tokens_in, // tokens_out, error func (q *Queries) InsertBotTurnPrompt(ctx context.Context, arg *InsertBotTurnPromptParams) (*BotTurn, error) { row := q.db.QueryRow(ctx, insertBotTurnPrompt, arg.SessionID, arg.Ordinal, arg.Prompt, arg.AttemptID, ) var i BotTurn err := row.Scan( &i.SessionID, &i.Ordinal, &i.Prompt, &i.Completion, &i.Status, &i.AttemptID, &i.AttemptStartedAt, &i.CreatedAt, &i.LatencyMs, &i.TokensIn, &i.TokensOut, &i.Error, ) return &i, err } const listBotSessionDocumentsForSession = `-- name: ListBotSessionDocumentsForSession :many SELECT document_id FROM bot_session_documents WHERE session_id = $1 ORDER BY document_id ` // Returns the document_id set persisted in bot_session_documents for the // given session, in deterministic order. M2 returns these as-is — no // folder expansion (that lives in the M3 FastAPI payload assembly). // // SELECT document_id // FROM bot_session_documents // WHERE session_id = $1 // ORDER BY document_id func (q *Queries) ListBotSessionDocumentsForSession(ctx context.Context, sessionID uuid.UUID) ([]uuid.UUID, error) { rows, err := q.db.Query(ctx, listBotSessionDocumentsForSession, sessionID) if err != nil { return nil, err } defer rows.Close() items := []uuid.UUID{} for rows.Next() { var document_id uuid.UUID if err := rows.Scan(&document_id); err != nil { return nil, err } items = append(items, document_id) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listBotSessionFoldersForSession = `-- name: ListBotSessionFoldersForSession :many SELECT folder_id FROM bot_session_folders WHERE session_id = $1 ORDER BY folder_id ` // Returns the folder_id set persisted in bot_session_folders for the // given session, in deterministic order. // // SELECT folder_id // FROM bot_session_folders // WHERE session_id = $1 // ORDER BY folder_id func (q *Queries) ListBotSessionFoldersForSession(ctx context.Context, sessionID uuid.UUID) ([]uuid.UUID, error) { rows, err := q.db.Query(ctx, listBotSessionFoldersForSession, sessionID) if err != nil { return nil, err } defer rows.Close() items := []uuid.UUID{} for rows.Next() { var folder_id uuid.UUID if err := rows.Scan(&folder_id); err != nil { return nil, err } items = append(items, folder_id) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listBotSessionsForOwner = `-- name: ListBotSessionsForOwner :many SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal FROM bot_sessions s WHERE s.client_id = $1 AND s.created_by = $2 AND s.is_deleted = false ORDER BY s.updated_at DESC LIMIT $4::int OFFSET $3::int ` type ListBotSessionsForOwnerParams struct { ClientID string `db:"client_id"` CreatedBy string `db:"created_by"` OffsetVal int32 `db:"offset_val"` LimitVal int32 `db:"limit_val"` } type ListBotSessionsForOwnerRow struct { ID uuid.UUID `db:"id"` ClientID string `db:"client_id"` CreatedBy string `db:"created_by"` CreatedAt pgtype.Timestamptz `db:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at"` Title string `db:"title"` State []byte `db:"state"` IsDeleted bool `db:"is_deleted"` LastSeenOrdinal int32 `db:"last_seen_ordinal"` LastTerminalOrdinal int32 `db:"last_terminal_ordinal"` } // Owner-scoped list with limit/offset. Backed by // idx_bot_sessions_client_user_active (plan §4): the WHERE filter and // the (client_id, created_by, updated_at DESC) ordering match the index // column order so the planner can satisfy the request without a sort. // // SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, // COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, // COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal // FROM bot_sessions s // WHERE s.client_id = $1 // AND s.created_by = $2 // AND s.is_deleted = false // ORDER BY s.updated_at DESC // LIMIT $4::int OFFSET $3::int func (q *Queries) ListBotSessionsForOwner(ctx context.Context, arg *ListBotSessionsForOwnerParams) ([]*ListBotSessionsForOwnerRow, error) { rows, err := q.db.Query(ctx, listBotSessionsForOwner, arg.ClientID, arg.CreatedBy, arg.OffsetVal, arg.LimitVal, ) if err != nil { return nil, err } defer rows.Close() items := []*ListBotSessionsForOwnerRow{} for rows.Next() { var i ListBotSessionsForOwnerRow if err := rows.Scan( &i.ID, &i.ClientID, &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, &i.Title, &i.State, &i.IsDeleted, &i.LastSeenOrdinal, &i.LastTerminalOrdinal, ); err != nil { return nil, err } items = append(items, &i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listBotSessionsForSuperAdmin = `-- name: ListBotSessionsForSuperAdmin :many SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal FROM bot_sessions s WHERE s.client_id = $1 AND s.is_deleted = false ORDER BY s.updated_at DESC LIMIT $3::int OFFSET $2::int ` type ListBotSessionsForSuperAdminParams struct { ClientID string `db:"client_id"` OffsetVal int32 `db:"offset_val"` LimitVal int32 `db:"limit_val"` } type ListBotSessionsForSuperAdminRow struct { ID uuid.UUID `db:"id"` ClientID string `db:"client_id"` CreatedBy string `db:"created_by"` CreatedAt pgtype.Timestamptz `db:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at"` Title string `db:"title"` State []byte `db:"state"` IsDeleted bool `db:"is_deleted"` LastSeenOrdinal int32 `db:"last_seen_ordinal"` LastTerminalOrdinal int32 `db:"last_terminal_ordinal"` } // Super-admin client-scoped list. Backed by idx_bot_sessions_client_active. // Same ordering invariant as ListBotSessionsForOwner. // // SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, // COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, // COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal // FROM bot_sessions s // WHERE s.client_id = $1 // AND s.is_deleted = false // ORDER BY s.updated_at DESC // LIMIT $3::int OFFSET $2::int func (q *Queries) ListBotSessionsForSuperAdmin(ctx context.Context, arg *ListBotSessionsForSuperAdminParams) ([]*ListBotSessionsForSuperAdminRow, error) { rows, err := q.db.Query(ctx, listBotSessionsForSuperAdmin, arg.ClientID, arg.OffsetVal, arg.LimitVal) if err != nil { return nil, err } defer rows.Close() items := []*ListBotSessionsForSuperAdminRow{} for rows.Next() { var i ListBotSessionsForSuperAdminRow if err := rows.Scan( &i.ID, &i.ClientID, &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, &i.Title, &i.State, &i.IsDeleted, &i.LastSeenOrdinal, &i.LastTerminalOrdinal, ); err != nil { return nil, err } items = append(items, &i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listLastTurnsForSessions = `-- name: ListLastTurnsForSessions :many SELECT session_id, ordinal, prompt, completion, status, created_at, latency_ms, tokens_in, tokens_out, error FROM ( SELECT bt.session_id, bt.ordinal, bt.prompt, bt.completion, bt.status, bt.attempt_id, bt.attempt_started_at, bt.created_at, bt.latency_ms, bt.tokens_in, bt.tokens_out, bt.error, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY ordinal DESC) AS rn FROM bot_turns bt WHERE bt.session_id = ANY($1::uuid[]) ) ranked WHERE rn <= $2::int ORDER BY session_id, ordinal DESC ` type ListLastTurnsForSessionsParams struct { SessionIds []uuid.UUID `db:"session_ids"` TurnLimit int32 `db:"turn_limit"` } type ListLastTurnsForSessionsRow struct { SessionID uuid.UUID `db:"session_id"` Ordinal int32 `db:"ordinal"` Prompt string `db:"prompt"` Completion *string `db:"completion"` Status string `db:"status"` CreatedAt pgtype.Timestamptz `db:"created_at"` LatencyMs *int32 `db:"latency_ms"` TokensIn *int32 `db:"tokens_in"` TokensOut *int32 `db:"tokens_out"` Error []byte `db:"error"` } // Session-list preview query. For each session_id in @session_ids, // return the most recent @turn_limit turns ordered by ordinal DESC. // The service caller is expected to pass turn_limit = min(request.limit, // cfg.RecentTurns) per plan §3. The full row column set is returned per // plan §5: session_id, ordinal, prompt, completion, status, created_at, // latency_ms, tokens_in, tokens_out, error. // // SELECT session_id, ordinal, prompt, completion, status, created_at, // latency_ms, tokens_in, tokens_out, error // FROM ( // SELECT bt.session_id, bt.ordinal, bt.prompt, bt.completion, bt.status, bt.attempt_id, bt.attempt_started_at, bt.created_at, bt.latency_ms, bt.tokens_in, bt.tokens_out, bt.error, // ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY ordinal DESC) AS rn // FROM bot_turns bt // WHERE bt.session_id = ANY($1::uuid[]) // ) ranked // WHERE rn <= $2::int // ORDER BY session_id, ordinal DESC func (q *Queries) ListLastTurnsForSessions(ctx context.Context, arg *ListLastTurnsForSessionsParams) ([]*ListLastTurnsForSessionsRow, error) { rows, err := q.db.Query(ctx, listLastTurnsForSessions, arg.SessionIds, arg.TurnLimit) if err != nil { return nil, err } defer rows.Close() items := []*ListLastTurnsForSessionsRow{} for rows.Next() { var i ListLastTurnsForSessionsRow if err := rows.Scan( &i.SessionID, &i.Ordinal, &i.Prompt, &i.Completion, &i.Status, &i.CreatedAt, &i.LatencyMs, &i.TokensIn, &i.TokensOut, &i.Error, ); err != nil { return nil, err } items = append(items, &i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const listTurnsForSession = `-- name: ListTurnsForSession :many SELECT session_id, ordinal, prompt, completion, status, attempt_id, attempt_started_at, created_at, latency_ms, tokens_in, tokens_out, error FROM bot_turns WHERE session_id = $1 ORDER BY ordinal ASC ` // Owner-scoped list of every turn for a session, ordered ASC by ordinal. // The owner check is the responsibility of the caller; the service // runs GetBotSessionForOwner first so a non-owner caller never reaches // this query. // // SELECT session_id, ordinal, prompt, completion, status, attempt_id, // attempt_started_at, created_at, latency_ms, tokens_in, // tokens_out, error // FROM bot_turns // WHERE session_id = $1 // ORDER BY ordinal ASC func (q *Queries) ListTurnsForSession(ctx context.Context, sessionID uuid.UUID) ([]*BotTurn, error) { rows, err := q.db.Query(ctx, listTurnsForSession, sessionID) if err != nil { return nil, err } defer rows.Close() items := []*BotTurn{} for rows.Next() { var i BotTurn if err := rows.Scan( &i.SessionID, &i.Ordinal, &i.Prompt, &i.Completion, &i.Status, &i.AttemptID, &i.AttemptStartedAt, &i.CreatedAt, &i.LatencyMs, &i.TokensIn, &i.TokensOut, &i.Error, ); err != nil { return nil, err } items = append(items, &i) } if err := rows.Err(); err != nil { return nil, err } return items, nil } const lockBotSessionForOwner = `-- name: LockBotSessionForOwner :one SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal FROM bot_sessions s WHERE s.id = $1 AND s.client_id = $2 AND s.created_by = $3 AND s.is_deleted = false FOR UPDATE OF s ` type LockBotSessionForOwnerParams struct { SessionID uuid.UUID `db:"session_id"` ClientID string `db:"client_id"` CreatedBy string `db:"created_by"` } type LockBotSessionForOwnerRow struct { ID uuid.UUID `db:"id"` ClientID string `db:"client_id"` CreatedBy string `db:"created_by"` CreatedAt pgtype.Timestamptz `db:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at"` Title string `db:"title"` State []byte `db:"state"` IsDeleted bool `db:"is_deleted"` LastSeenOrdinal int32 `db:"last_seen_ordinal"` LastTerminalOrdinal int32 `db:"last_terminal_ordinal"` } // Chatbot backend support, milestone M1: bot.sql. // See plans/chatbot_plan_codex.v10.md §5 (Core SQL Queries) for the // canonical query shapes. Only M1 queries live here; M4 algorithm // queries (CompleteBotTurn, FailBotTurn, MarkBotTurnSessionDeleted, // InsertBotTurnPrompt, GetBotTurn, etc.) are added in M4. // // Naming conventions: // - All bot_* tables use snake_case columns. New named params // (@session_id, @client_id, @created_by, @grace_seconds, @attempt_id, // @ordinal, @session_ids, @turn_limit) follow snake_case so sqlc // generates Go field names ClientID, SessionID, CreatedBy, etc. // // Owner-scoped session lookup with row-level lock. Returns the session // plus two derived ordinals computed from bot_turns: // // last_seen_ordinal = MAX(ordinal) regardless of status // last_terminal_ordinal = MAX(ordinal) where status <> 'in_flight' // // The FOR UPDATE OF s clause locks the session row but not the // bot_turns rows (those are read uncontended by the subselects). // Must be called inside a transaction. Plan §5. // // SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, // COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, // COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal // FROM bot_sessions s // WHERE s.id = $1 // AND s.client_id = $2 // AND s.created_by = $3 // AND s.is_deleted = false // FOR UPDATE OF s func (q *Queries) LockBotSessionForOwner(ctx context.Context, arg *LockBotSessionForOwnerParams) (*LockBotSessionForOwnerRow, error) { row := q.db.QueryRow(ctx, lockBotSessionForOwner, arg.SessionID, arg.ClientID, arg.CreatedBy) var i LockBotSessionForOwnerRow err := row.Scan( &i.ID, &i.ClientID, &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, &i.Title, &i.State, &i.IsDeleted, &i.LastSeenOrdinal, &i.LastTerminalOrdinal, ) return &i, err } const markBotTurnSessionDeleted = `-- name: MarkBotTurnSessionDeleted :execrows UPDATE bot_turns SET status = 'session_deleted', error = $1 WHERE session_id = $2 AND ordinal = $3 AND attempt_id = $4 AND status = 'in_flight' ` type MarkBotTurnSessionDeletedParams struct { Error []byte `db:"error"` SessionID uuid.UUID `db:"session_id"` Ordinal int32 `db:"ordinal"` AttemptID uuid.UUID `db:"attempt_id"` } // Compare-and-set: flips status from in_flight to session_deleted when // attempt_id matches. Used by the AddTurn tx2 path when // LockBotSessionForOwner finds the session disappeared mid-FastAPI-call. // Plan §6. // // UPDATE bot_turns // SET status = 'session_deleted', // error = $1 // WHERE session_id = $2 // AND ordinal = $3 // AND attempt_id = $4 // AND status = 'in_flight' func (q *Queries) MarkBotTurnSessionDeleted(ctx context.Context, arg *MarkBotTurnSessionDeletedParams) (int64, error) { result, err := q.db.Exec(ctx, markBotTurnSessionDeleted, arg.Error, arg.SessionID, arg.Ordinal, arg.AttemptID, ) if err != nil { return 0, err } return result.RowsAffected(), nil } const markInflightTurnsSessionDeletedForSession = `-- name: MarkInflightTurnsSessionDeletedForSession :exec UPDATE bot_turns SET status = 'session_deleted', error = jsonb_build_object( 'code', 'session_deleted_by_admin', 'message', 'session deleted while turn in flight' ) WHERE session_id = $1 AND status = 'in_flight' ` // Side-effect of a super-admin DELETE: any in-flight turn on the deleted // session must transition to status='session_deleted' with an explanatory // error JSON so the M4 AddTurn tx2 callback finds a terminal row instead // of a stale in_flight row. The bot_turns_status_fields_consistent CHECK // requires error IS NOT NULL on session_deleted rows. // // UPDATE bot_turns // SET status = 'session_deleted', // error = jsonb_build_object( // 'code', 'session_deleted_by_admin', // 'message', 'session deleted while turn in flight' // ) // WHERE session_id = $1 // AND status = 'in_flight' func (q *Queries) MarkInflightTurnsSessionDeletedForSession(ctx context.Context, sessionID uuid.UUID) error { _, err := q.db.Exec(ctx, markInflightTurnsSessionDeletedForSession, sessionID) return err } const resetBotTurnForRetry = `-- name: ResetBotTurnForRetry :exec UPDATE bot_turns SET status = 'in_flight', attempt_id = $1, attempt_started_at = NOW(), created_at = NOW(), completion = NULL, error = NULL WHERE session_id = $2 AND ordinal = $3 AND status IN ('errored', 'abandoned') ` type ResetBotTurnForRetryParams struct { AttemptID uuid.UUID `db:"attempt_id"` SessionID uuid.UUID `db:"session_id"` Ordinal int32 `db:"ordinal"` } // Reset a terminal-failure turn back to in_flight for retry. Mints a // fresh attempt_id, resets attempt_started_at and created_at, and // nullifies completion/error. Only rows in (errored, abandoned) are // eligible; in_flight, completed, and session_deleted rows are no-ops. // Plan §5. // // UPDATE bot_turns // SET status = 'in_flight', // attempt_id = $1, // attempt_started_at = NOW(), // created_at = NOW(), // completion = NULL, // error = NULL // WHERE session_id = $2 // AND ordinal = $3 // AND status IN ('errored', 'abandoned') func (q *Queries) ResetBotTurnForRetry(ctx context.Context, arg *ResetBotTurnForRetryParams) error { _, err := q.db.Exec(ctx, resetBotTurnForRetry, arg.AttemptID, arg.SessionID, arg.Ordinal) return err } const setBotSessionTitle = `-- name: SetBotSessionTitle :exec UPDATE bot_sessions SET title = $1, updated_at = NOW() WHERE id = $2 AND title = '' ` type SetBotSessionTitleParams struct { Title string `db:"title"` SessionID uuid.UUID `db:"session_id"` } // Sets the session title and bumps updated_at, but ONLY when the // existing title is empty. Plan §6: title is derived from the first // turn's prompt on ordinal=1; the empty-title guard prevents a retry // on ordinal=1 (after an errored/abandoned reset) from clobbering a // title that was already set on a prior successful attempt. // // UPDATE bot_sessions // SET title = $1, // updated_at = NOW() // WHERE id = $2 // AND title = '' func (q *Queries) SetBotSessionTitle(ctx context.Context, arg *SetBotSessionTitleParams) error { _, err := q.db.Exec(ctx, setBotSessionTitle, arg.Title, arg.SessionID) return err } const softDeleteBotSessionForSuperAdmin = `-- name: SoftDeleteBotSessionForSuperAdmin :execrows UPDATE bot_sessions SET is_deleted = true, updated_at = NOW() WHERE id = $1 AND client_id = $2 AND is_deleted = false ` type SoftDeleteBotSessionForSuperAdminParams struct { SessionID uuid.UUID `db:"session_id"` ClientID string `db:"client_id"` } // Mark a session as deleted. Returns the affected row count so the // caller can distinguish 0 (not found / already deleted) from 1 (soft // delete applied). Filters on (client_id, is_deleted=false) so a second // delete on an already-deleted row returns 0. // // UPDATE bot_sessions // SET is_deleted = true, // updated_at = NOW() // WHERE id = $1 // AND client_id = $2 // AND is_deleted = false func (q *Queries) SoftDeleteBotSessionForSuperAdmin(ctx context.Context, arg *SoftDeleteBotSessionForSuperAdminParams) (int64, error) { result, err := q.db.Exec(ctx, softDeleteBotSessionForSuperAdmin, arg.SessionID, arg.ClientID) if err != nil { return 0, err } return result.RowsAffected(), nil } const touchBotSessionUpdatedAt = `-- name: TouchBotSessionUpdatedAt :exec UPDATE bot_sessions SET updated_at = NOW() WHERE id = $1 ` // Bumps updated_at on a bot_sessions row so the owner list-by-updated_at // ordering reflects the most recent activity. Used after PatchScope. // // UPDATE bot_sessions // SET updated_at = NOW() // WHERE id = $1 func (q *Queries) TouchBotSessionUpdatedAt(ctx context.Context, sessionID uuid.UUID) error { _, err := q.db.Exec(ctx, touchBotSessionUpdatedAt, sessionID) return err } const updateBotSessionState = `-- name: UpdateBotSessionState :exec UPDATE bot_sessions SET state = COALESCE($1, '{}'::jsonb), updated_at = NOW() WHERE id = $2 AND client_id = $3 ` type UpdateBotSessionStateParams struct { State []byte `db:"state"` SessionID uuid.UUID `db:"session_id"` ClientID string `db:"client_id"` } // Persists a session_state object after tx2's compare-and-set. Plan §6 // mandates that null updated_session_state preserves prior; this query // is only invoked when the service has decided to write a non-null // (possibly stripped) value. NULL @state explicitly clears state. // // UPDATE bot_sessions // SET state = COALESCE($1, '{}'::jsonb), // updated_at = NOW() // WHERE id = $2 // AND client_id = $3 func (q *Queries) UpdateBotSessionState(ctx context.Context, arg *UpdateBotSessionStateParams) error { _, err := q.db.Exec(ctx, updateBotSessionState, arg.State, arg.SessionID, arg.ClientID) return err }