@sixb/pg 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +1 -0
  2. package/dist/agents/agent-storage.d.ts +8 -2
  3. package/dist/agents/agent-storage.d.ts.map +1 -1
  4. package/dist/agents/approvals.d.ts +48 -0
  5. package/dist/agents/approvals.d.ts.map +1 -0
  6. package/dist/agents/checkpoints.d.ts +1 -0
  7. package/dist/agents/checkpoints.d.ts.map +1 -1
  8. package/dist/agents/index.d.ts +1 -0
  9. package/dist/agents/index.d.ts.map +1 -1
  10. package/dist/agents/memory.d.ts +19 -0
  11. package/dist/agents/memory.d.ts.map +1 -0
  12. package/dist/agents/messages.d.ts.map +1 -1
  13. package/dist/agents/rows.d.ts +15 -1
  14. package/dist/agents/rows.d.ts.map +1 -1
  15. package/dist/agents/runs.d.ts +3 -1
  16. package/dist/agents/runs.d.ts.map +1 -1
  17. package/dist/agents/settings.d.ts +29 -0
  18. package/dist/agents/settings.d.ts.map +1 -0
  19. package/dist/agents/threads.d.ts +6 -1
  20. package/dist/agents/threads.d.ts.map +1 -1
  21. package/dist/ai-cost-groups.d.ts.map +1 -1
  22. package/dist/auth-storage/auth-storage.d.ts +3 -2
  23. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  24. package/dist/auth-storage/users.d.ts +8 -4
  25. package/dist/auth-storage/users.d.ts.map +1 -1
  26. package/dist/index.js +3720 -1594
  27. package/dist/index.js.map +37 -31
  28. package/dist/migrations.d.ts.map +1 -1
  29. package/dist/objects/conditional-reader.d.ts +17 -0
  30. package/dist/objects/conditional-reader.d.ts.map +1 -0
  31. package/dist/objects/query-compiler.d.ts +27 -1
  32. package/dist/objects/query-compiler.d.ts.map +1 -1
  33. package/dist/objects/query-indexes.d.ts.map +1 -1
  34. package/dist/objects/read-scope.d.ts +10 -1
  35. package/dist/objects/read-scope.d.ts.map +1 -1
  36. package/dist/objects/reader.d.ts +2 -0
  37. package/dist/objects/reader.d.ts.map +1 -1
  38. package/dist/objects/scoped-reader.d.ts +12 -0
  39. package/dist/objects/scoped-reader.d.ts.map +1 -0
  40. package/dist/objects/selected-reader.d.ts +1 -0
  41. package/dist/objects/selected-reader.d.ts.map +1 -1
  42. package/dist/objects/storage.d.ts +7 -1
  43. package/dist/objects/storage.d.ts.map +1 -1
  44. package/dist/ontology-storage/index.d.ts +2 -0
  45. package/dist/ontology-storage/index.d.ts.map +1 -1
  46. package/dist/ontology-storage/materialization-session.d.ts +6 -0
  47. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  48. package/dist/ontology-storage/materialization-writer.d.ts +15 -0
  49. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  50. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  51. package/dist/ontology-storage/unique-values.d.ts +60 -0
  52. package/dist/ontology-storage/unique-values.d.ts.map +1 -0
  53. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  54. package/dist/pg-ai-usage-storage.d.ts +8 -1
  55. package/dist/pg-ai-usage-storage.d.ts.map +1 -1
  56. package/dist/pg-artifact-storage.d.ts +8 -1
  57. package/dist/pg-artifact-storage.d.ts.map +1 -1
  58. package/dist/pg-execution-storage.d.ts +2 -1
  59. package/dist/pg-execution-storage.d.ts.map +1 -1
  60. package/dist/storage-errors.d.ts +2 -0
  61. package/dist/storage-errors.d.ts.map +1 -1
  62. package/package.json +3 -3
  63. package/src/agents/agent-storage.ts +11 -2
  64. package/src/agents/approvals.ts +400 -0
  65. package/src/agents/checkpoints.ts +8 -4
  66. package/src/agents/index.ts +1 -0
  67. package/src/agents/memory.ts +175 -0
  68. package/src/agents/messages.ts +14 -7
  69. package/src/agents/rows.ts +46 -1
  70. package/src/agents/runs.ts +154 -22
  71. package/src/agents/settings.ts +105 -0
  72. package/src/agents/threads.ts +356 -3
  73. package/src/ai-cost-groups.ts +11 -1
  74. package/src/auth-storage/auth-storage.ts +45 -13
  75. package/src/auth-storage/users.ts +105 -43
  76. package/src/migrations/069-artifact-kinds.sql +4 -0
  77. package/src/migrations/070-artifact-state.sql +16 -0
  78. package/src/migrations/071-agent-thread-participants.sql +16 -0
  79. package/src/migrations/072-forked-context-checkpoints.sql +23 -0
  80. package/src/migrations/073-unique-values.sql +25 -0
  81. package/src/migrations/074-generated-media-usage.sql +21 -0
  82. package/src/migrations/075-model-call-files.sql +11 -0
  83. package/src/migrations/076-agent-memory.sql +33 -0
  84. package/src/migrations/077-artifact-threads.sql +21 -0
  85. package/src/migrations/078-agent-approvals.sql +58 -0
  86. package/src/migrations/079-agent-approval-resume.sql +20 -0
  87. package/src/migrations/080-subagent-threads.sql +145 -0
  88. package/src/migrations/081-subagent-questions.sql +6 -0
  89. package/src/migrations.ts +34 -0
  90. package/src/objects/conditional-reader.ts +44 -0
  91. package/src/objects/prepare-queries.ts +1 -1
  92. package/src/objects/query-compiler.ts +96 -33
  93. package/src/objects/query-indexes.ts +29 -10
  94. package/src/objects/read-scope.ts +140 -25
  95. package/src/objects/reader.ts +50 -6
  96. package/src/objects/scoped-reader.ts +101 -0
  97. package/src/objects/selected-reader.ts +16 -76
  98. package/src/objects/storage.ts +24 -5
  99. package/src/objects/text-count-index.ts +1 -0
  100. package/src/ontology-storage/index.ts +3 -0
  101. package/src/ontology-storage/materialization-session.ts +8 -0
  102. package/src/ontology-storage/materialization-writer.ts +76 -1
  103. package/src/ontology-storage/materializations.ts +20 -1
  104. package/src/ontology-storage/unique-values.ts +274 -0
  105. package/src/pg-ai-cost-storage.ts +62 -12
  106. package/src/pg-ai-usage-storage.ts +90 -1
  107. package/src/pg-artifact-storage.ts +205 -9
  108. package/src/pg-execution-storage.ts +31 -0
  109. package/src/storage-errors.ts +7 -0
  110. package/src/transactions.ts +1 -1
@@ -1,24 +1,40 @@
1
1
  import {
2
+ assertConversationThreadRow,
3
+ participantsToAdd,
4
+ planAgentThreadFork,
5
+ remapArtifactIds,
2
6
  snapshotAgentThreadSandboxParams,
3
7
  transitionAgentThreadSandbox,
4
8
  } from "@sixb/core/internal/agent-run-storage-provider"
5
9
  import {
10
+ type AddAgentThreadParticipantsInput,
11
+ type AgentContextCheckpointRecord,
6
12
  AgentStorageError,
13
+ type AgentThreadParticipantRecord,
7
14
  type AgentThreadRecord,
8
15
  type AgentThreadSandboxState,
9
16
  type AgentThreadStore,
10
17
  type CreateAgentThreadInput,
18
+ type ForkAgentThreadInput,
19
+ type ListAgentThreadChildrenInput,
20
+ type ListAgentThreadParticipantsInput,
11
21
  type ListAgentThreadsInput,
12
22
  type ListAgentThreadsResult,
23
+ type RemoveAgentThreadParticipantInput,
13
24
  type TransitionAgentThreadSandboxInput,
14
25
  } from "@sixb/core/storage"
15
- import type { SqlParameter } from "../pg-client"
16
- import { isUniqueViolation } from "../storage-errors"
26
+ import type { SQLClient, SqlParameter } from "../pg-client"
27
+ import { isUniqueViolation, isUniqueViolationOf } from "../storage-errors"
17
28
  import { type PgStoreClient, runPgTransaction } from "../transactions"
18
29
  import {
30
+ type AgentContextCheckpointRow,
31
+ type AgentMessageRow,
19
32
  type AgentRunRow,
33
+ type AgentThreadParticipantRow,
20
34
  type AgentThreadRow,
21
35
  queryAgentList,
36
+ rowToContextCheckpointRecord,
37
+ rowToParticipantRecord,
22
38
  rowToRunRecord,
23
39
  rowToThreadRecord,
24
40
  } from "./rows"
@@ -100,6 +116,165 @@ export class PgAgentThreadStore implements AgentThreadStore {
100
116
  }
101
117
  }
102
118
 
119
+ async fork(input: ForkAgentThreadInput): Promise<AgentThreadRecord> {
120
+ try {
121
+ return await runPgTransaction(this.sql, async (tx) => {
122
+ // A share lock holds back the writes that take the thread for update: an admitted request
123
+ // or a finalized answer lands before the copy or after it, never inside it.
124
+ const [source] = await tx<Pick<AgentThreadRow, "id" | "kind" | "active_run_id">[]>`
125
+ SELECT id, kind, active_run_id FROM agent_threads
126
+ WHERE project_id = ${input.projectId} AND id = ${input.sourceThreadId}
127
+ FOR SHARE
128
+ `
129
+
130
+ if (!source) {
131
+ throw new AgentStorageError(
132
+ "thread_not_found",
133
+ `[SixbPg] Agent thread '${input.sourceThreadId}' not found for project '${input.projectId}'.`
134
+ )
135
+ }
136
+ assertConversationThreadRow(source, "can be forked", "SixbPg")
137
+
138
+ const [activeRun] =
139
+ source.active_run_id === null
140
+ ? []
141
+ : await tx<{ trigger_message_id: string | null }[]>`
142
+ SELECT trigger_message_id FROM agent_runs
143
+ WHERE project_id = ${input.projectId} AND id = ${source.active_run_id}
144
+ `
145
+
146
+ // Only what the plan needs: the parts are copied by the database, not through here. A
147
+ // message of approval outcomes only is not counted (`countsAsThreadMessage`).
148
+ const messages = await tx<(Pick<AgentMessageRow, "id" | "seq"> & { counted: boolean })[]>`
149
+ SELECT id, seq,
150
+ NOT (
151
+ jsonb_array_length(parts) > 0 AND NOT EXISTS (
152
+ SELECT 1 FROM jsonb_array_elements(parts) AS part
153
+ WHERE part->>'type' <> 'approval-outcome'
154
+ )
155
+ ) AS counted
156
+ FROM agent_messages
157
+ WHERE project_id = ${input.projectId} AND thread_id = ${source.id}
158
+ ORDER BY seq ASC
159
+ `
160
+ const checkpoints = await tx<AgentContextCheckpointRow[]>`
161
+ SELECT * FROM agent_context_checkpoints
162
+ WHERE project_id = ${input.projectId} AND thread_id = ${source.id}
163
+ ORDER BY summarized_through_seq DESC, created_at DESC, id DESC
164
+ `
165
+
166
+ const plan = planAgentThreadFork(
167
+ input,
168
+ {
169
+ ...(activeRun?.trigger_message_id
170
+ ? { triggerMessageId: activeRun.trigger_message_id }
171
+ : {}),
172
+ messages: messages.map((message) => ({
173
+ id: message.id,
174
+ seq: Number(message.seq),
175
+ counted: message.counted,
176
+ })),
177
+ checkpoints: checkpoints.map(rowToContextCheckpointRecord),
178
+ },
179
+ "SixbPg"
180
+ )
181
+ const { thread } = plan
182
+
183
+ const [row] = await tx<AgentThreadRow[]>`
184
+ INSERT INTO agent_threads (
185
+ project_id,
186
+ id,
187
+ owner_principal_type,
188
+ owner_principal_id,
189
+ title,
190
+ sandbox_params,
191
+ sandbox_state,
192
+ status,
193
+ last_message_at,
194
+ message_count,
195
+ created_at,
196
+ updated_at
197
+ ) VALUES (
198
+ ${thread.projectId},
199
+ ${thread.id},
200
+ ${thread.ownerPrincipal.type},
201
+ ${thread.ownerPrincipal.id},
202
+ ${thread.title ?? null},
203
+ ${thread.sandboxParams === undefined ? null : JSON.stringify(thread.sandboxParams)}::text::jsonb,
204
+ ${thread.sandboxState === undefined ? null : JSON.stringify(thread.sandboxState)}::text::jsonb,
205
+ ${thread.status},
206
+ ${thread.lastMessageAt ?? null},
207
+ ${thread.messageCount},
208
+ ${thread.createdAt},
209
+ ${thread.updatedAt}
210
+ )
211
+ RETURNING *
212
+ `
213
+
214
+ if (plan.messages.length > 0) {
215
+ await tx`
216
+ INSERT INTO agent_messages (
217
+ project_id,
218
+ id,
219
+ thread_id,
220
+ run_id,
221
+ role,
222
+ author_principal_type,
223
+ author_principal_id,
224
+ seq,
225
+ parts,
226
+ metadata,
227
+ content_version,
228
+ created_at,
229
+ completed_at
230
+ )
231
+ SELECT
232
+ message.project_id,
233
+ copy.id,
234
+ ${thread.id},
235
+ message.run_id,
236
+ message.role,
237
+ message.author_principal_type,
238
+ message.author_principal_id,
239
+ message.seq,
240
+ message.parts,
241
+ message.metadata,
242
+ message.content_version,
243
+ message.created_at,
244
+ message.completed_at
245
+ FROM unnest(
246
+ ${tx.array(plan.messages.map((copy) => copy.sourceId))}::text[],
247
+ ${tx.array(plan.messages.map((copy) => copy.id))}::text[]
248
+ ) AS copy(source_id, id)
249
+ JOIN agent_messages AS message
250
+ ON message.project_id = ${input.projectId} AND message.id = copy.source_id
251
+ `
252
+ await remapCopiedParts(
253
+ tx,
254
+ input.projectId,
255
+ plan.messages.map((copy) => copy.id),
256
+ input.artifactCopies
257
+ )
258
+ }
259
+
260
+ if (plan.checkpoint) {
261
+ await insertCopiedCheckpoint(tx, plan.checkpoint)
262
+ }
263
+
264
+ return rowToThreadRecord(row)
265
+ })
266
+ } catch (error) {
267
+ if (isUniqueViolationOf(error, "agent_threads_pkey")) {
268
+ throw new AgentStorageError(
269
+ "duplicate_id",
270
+ `[SixbPg] Agent thread '${input.id}' already exists for project '${input.projectId}'.`
271
+ )
272
+ }
273
+
274
+ throw error
275
+ }
276
+ }
277
+
103
278
  async getById(params: { projectId: string; id: string }): Promise<AgentThreadRecord | null> {
104
279
  const [row] = await this.sql<AgentThreadRow[]>`
105
280
  SELECT * FROM agent_threads WHERE project_id = ${params.projectId} AND id = ${params.id}
@@ -131,12 +306,29 @@ export class PgAgentThreadStore implements AgentThreadStore {
131
306
  })
132
307
  }
133
308
 
309
+ async listChildren(input: ListAgentThreadChildrenInput): Promise<readonly AgentThreadRecord[]> {
310
+ // By `idx_agent_threads_children`, which holds sub-agents' threads only.
311
+ const rows = await this.sql<AgentThreadRow[]>`
312
+ SELECT *
313
+ FROM agent_threads
314
+ WHERE project_id = ${input.projectId}
315
+ AND parent_thread_id = ${input.parentThreadId}
316
+ AND kind = 'subagent'
317
+ ${input.parentRunId === undefined ? this.sql`` : this.sql`AND parent_run_id = ${input.parentRunId}`}
318
+ ORDER BY created_at ASC, id ASC
319
+ LIMIT ${input.limit ?? 100}
320
+ OFFSET ${input.offset ?? 0}
321
+ `
322
+ return rows.map(rowToThreadRecord)
323
+ }
324
+
134
325
  async list(input: ListAgentThreadsInput): Promise<ListAgentThreadsResult> {
135
326
  if (input.statuses !== undefined && input.statuses.length === 0) {
136
327
  return { threads: [], hasMore: false, total: 0 }
137
328
  }
138
329
 
139
- const whereClauses = ["project_id = $1"]
330
+ // A sub-agent's thread is read through its conversation; the list indexes leave it out too.
331
+ const whereClauses = ["project_id = $1", "kind = 'conversation'"]
140
332
  const params: SqlParameter[] = [input.projectId]
141
333
  let index = 2
142
334
 
@@ -151,6 +343,17 @@ export class PgAgentThreadStore implements AgentThreadStore {
151
343
  params.push(input.ownerPrincipal.type, input.ownerPrincipal.id)
152
344
  }
153
345
 
346
+ if (input.participantPrincipal) {
347
+ whereClauses.push(`EXISTS (
348
+ SELECT 1 FROM agent_thread_participants participant
349
+ WHERE participant.project_id = agent_threads.project_id
350
+ AND participant.thread_id = agent_threads.id
351
+ AND participant.principal_type = $${index++}
352
+ AND participant.principal_id = $${index++}
353
+ )`)
354
+ params.push(input.participantPrincipal.type, input.participantPrincipal.id)
355
+ }
356
+
154
357
  const dir = input.order === "asc" ? "ASC" : "DESC"
155
358
  const { rows, total, hasMore } = await queryAgentList<AgentThreadRow>({
156
359
  sql: this.sql,
@@ -165,4 +368,154 @@ export class PgAgentThreadStore implements AgentThreadStore {
165
368
 
166
369
  return { threads: rows.map(rowToThreadRecord), hasMore, total }
167
370
  }
371
+
372
+ async addParticipants(
373
+ input: AddAgentThreadParticipantsInput
374
+ ): Promise<readonly AgentThreadParticipantRecord[]> {
375
+ return runPgTransaction(this.sql, async (tx) => {
376
+ // Concurrent adds to one thread queue on its row, so the limit counts every participant.
377
+ const [thread] = await tx<Pick<AgentThreadRow, "id" | "kind">[]>`
378
+ SELECT id, kind FROM agent_threads
379
+ WHERE project_id = ${input.projectId} AND id = ${input.threadId}
380
+ FOR UPDATE
381
+ `
382
+ if (!thread) {
383
+ throw new AgentStorageError(
384
+ "thread_not_found",
385
+ `[SixbPg] Agent thread '${input.threadId}' not found for project '${input.projectId}'.`
386
+ )
387
+ }
388
+ assertConversationThreadRow(thread, "has participants", "SixbPg")
389
+
390
+ const existing = await selectParticipants(tx, input.projectId, [input.threadId])
391
+ const added = participantsToAdd(input, existing, "SixbPg")
392
+ const addedAt = input.addedAt ?? new Date()
393
+
394
+ for (const principal of added) {
395
+ await tx`
396
+ INSERT INTO agent_thread_participants (
397
+ project_id,
398
+ thread_id,
399
+ principal_type,
400
+ principal_id,
401
+ added_at,
402
+ added_by_type,
403
+ added_by_id
404
+ ) VALUES (
405
+ ${input.projectId},
406
+ ${input.threadId},
407
+ ${principal.type},
408
+ ${principal.id},
409
+ ${addedAt},
410
+ ${input.addedBy.type},
411
+ ${input.addedBy.id}
412
+ )
413
+ `
414
+ }
415
+
416
+ return added.length === 0
417
+ ? existing
418
+ : selectParticipants(tx, input.projectId, [input.threadId])
419
+ })
420
+ }
421
+
422
+ async removeParticipant(input: RemoveAgentThreadParticipantInput): Promise<boolean> {
423
+ const rows = await this.sql<{ principal_id: string }[]>`
424
+ DELETE FROM agent_thread_participants
425
+ WHERE project_id = ${input.projectId}
426
+ AND thread_id = ${input.threadId}
427
+ AND principal_type = ${input.principal.type}
428
+ AND principal_id = ${input.principal.id}
429
+ RETURNING principal_id
430
+ `
431
+
432
+ return rows.length > 0
433
+ }
434
+
435
+ async listParticipants(
436
+ input: ListAgentThreadParticipantsInput
437
+ ): Promise<readonly AgentThreadParticipantRecord[]> {
438
+ return selectParticipants(this.sql, input.projectId, [...new Set(input.threadIds)])
439
+ }
440
+ }
441
+
442
+ async function selectParticipants(
443
+ sql: PgStoreClient,
444
+ projectId: string,
445
+ threadIds: readonly string[]
446
+ ): Promise<AgentThreadParticipantRecord[]> {
447
+ if (threadIds.length === 0) {
448
+ return []
449
+ }
450
+
451
+ const rows = await sql<AgentThreadParticipantRow[]>`
452
+ SELECT *
453
+ FROM agent_thread_participants
454
+ WHERE project_id = ${projectId}
455
+ AND thread_id = ANY(${sql.array([...threadIds])}::text[])
456
+ ORDER BY added_at, principal_id COLLATE "C", thread_id COLLATE "C"
457
+ `
458
+
459
+ return rows.map(rowToParticipantRecord)
460
+ }
461
+
462
+ async function insertCopiedCheckpoint(
463
+ tx: SQLClient,
464
+ checkpoint: AgentContextCheckpointRecord
465
+ ): Promise<void> {
466
+ await tx`
467
+ INSERT INTO agent_context_checkpoints (
468
+ project_id,
469
+ id,
470
+ thread_id,
471
+ created_by_run_id,
472
+ reason,
473
+ summary,
474
+ summary_format_version,
475
+ summarized_through_seq,
476
+ observed_head_seq,
477
+ estimated_input_tokens_before,
478
+ estimated_input_tokens_after,
479
+ summary_model_id,
480
+ created_at
481
+ ) VALUES (
482
+ ${checkpoint.projectId},
483
+ ${checkpoint.id},
484
+ ${checkpoint.threadId},
485
+ ${checkpoint.createdByRunId},
486
+ ${checkpoint.reason},
487
+ ${checkpoint.summary},
488
+ ${checkpoint.summaryFormatVersion},
489
+ ${checkpoint.summarizedThroughSeq},
490
+ ${checkpoint.observedHeadSeq},
491
+ ${checkpoint.estimatedInputTokensBefore},
492
+ ${checkpoint.estimatedInputTokensAfter},
493
+ ${checkpoint.summaryModelId},
494
+ ${checkpoint.createdAt}
495
+ )
496
+ `
497
+ }
498
+
499
+ /** The copied messages name the fork's copies of the source's artifacts. */
500
+ async function remapCopiedParts(
501
+ sql: SQLClient,
502
+ projectId: string,
503
+ messageIds: readonly string[],
504
+ copies: Readonly<Record<string, string>> | undefined
505
+ ): Promise<void> {
506
+ if (!copies || Object.keys(copies).length === 0 || messageIds.length === 0) return
507
+
508
+ const rows = await sql<{ id: string; parts: string }[]>`
509
+ SELECT id, parts::text AS parts FROM agent_messages
510
+ WHERE project_id = ${projectId} AND id = ANY(${sql.array([...messageIds])}::text[])
511
+ `
512
+ for (const row of rows) {
513
+ const parts = remapArtifactIds(row.parts, copies)
514
+ if (parts === row.parts) continue
515
+
516
+ await sql`
517
+ UPDATE agent_messages SET parts = ${parts}::text::jsonb
518
+ WHERE project_id = ${projectId} AND id = ${row.id}
519
+ `
520
+ }
168
521
  }
@@ -3,6 +3,7 @@ import {
3
3
  aiModelCallGroupFragmentFromRow,
4
4
  buildAiModelCallGroupsFromFragments,
5
5
  normalizeAiModelCallAccountingQuery,
6
+ TOKENLESS_MODEL_KINDS,
6
7
  } from "@sixb/core/internal/ai-cost-storage-provider"
7
8
  import type { ListAiModelCallGroupsInput, ListAiModelCallGroupsResult } from "@sixb/core/storage"
8
9
  import type { PgStoreClient } from "./transactions"
@@ -34,11 +35,15 @@ export async function listAiModelCallGroups(
34
35
  return { items, total, hasMore: query.offset + items.length < total }
35
36
  }
36
37
 
38
+ // Fixed kind names, never user input.
39
+ const TOKENLESS_KINDS = TOKENLESS_MODEL_KINDS.map((kind) => `'${kind}'`).join(", ")
40
+
37
41
  const FILTERED_SQL = `
38
42
  WITH filtered AS (
39
43
  SELECT COALESCE(root_agent.execution_id, usage.execution_id) AS root_execution_id,
40
44
  usage.execution_id, usage.provider_id, usage.requested_model_id, usage.occurred_at,
41
- usage.total_tokens,
45
+ usage.total_tokens, usage.model_kind, usage.output_images, usage.input_characters,
46
+ usage.audio_duration_ms,
42
47
  execution.executor_kind, execution.executor_id, execution.authority_primitive_id AS primitive_id,
43
48
  COALESCE(cost.status, 'unvalued') AS valuation_status,
44
49
  cost.currency AS amount_currency, cost.amount_nanos,
@@ -84,7 +89,12 @@ SELECT filtered.root_execution_id, execution_id, provider_id, requested_model_id
84
89
  MIN(occurred_at) AS first_call_at, MAX(occurred_at) AS last_call_at,
85
90
  CAST(COUNT(*) AS TEXT) AS call_count,
86
91
  CAST(COUNT(total_tokens) AS TEXT) AS token_count,
92
+ CAST(COUNT(CASE WHEN total_tokens IS NULL AND model_kind IN (${TOKENLESS_KINDS}) THEN 1 END) AS TEXT)
93
+ AS tokenless_count,
87
94
  CAST(SUM(total_tokens) AS TEXT) AS total_tokens,
95
+ CAST(SUM(output_images) AS TEXT) AS output_images,
96
+ CAST(SUM(input_characters) AS TEXT) AS input_characters,
97
+ CAST(SUM(audio_duration_ms) AS TEXT) AS audio_duration_ms,
88
98
  valuation_status, amount_currency,
89
99
  CAST(SUM(amount_nanos / 1000000000000) AS TEXT) AS amount_high,
90
100
  CAST(SUM((amount_nanos / 1000000) % 1000000) AS TEXT) AS amount_middle,
@@ -11,6 +11,7 @@ import type {
11
11
  InvitationRecord,
12
12
  SessionRecord,
13
13
  SuspendUserAndRevokeSessionsInput,
14
+ UpdateAuthUserResult,
14
15
  UserIdentityRecord,
15
16
  UserRecord,
16
17
  } from "@sixb/core/storage"
@@ -256,16 +257,17 @@ export class PgAuthStorage implements AuthStorage {
256
257
  consumedAt: completedAt,
257
258
  })
258
259
 
259
- const user = existingUserRow
260
- ? rowToUserRecord(existingUserRow)
261
- : await this.insertUser(tx, {
262
- id: newUserId ?? assertNonEmpty(input.newUserId, "User id"),
263
- projectId,
264
- email,
265
- displayName: input.newUserDisplayName,
266
- avatarUrl: input.newUserAvatarUrl,
267
- createdAt: completedAt,
268
- })
260
+ const previousUser = existingUserRow ? rowToUserRecord(existingUserRow) : null
261
+ const user =
262
+ previousUser ??
263
+ (await this.insertUser(tx, {
264
+ id: newUserId ?? assertNonEmpty(input.newUserId, "User id"),
265
+ projectId,
266
+ email,
267
+ displayName: input.newUserDisplayName,
268
+ avatarUrl: input.newUserAvatarUrl,
269
+ createdAt: completedAt,
270
+ }))
269
271
 
270
272
  const invitation = await this.acceptInvitationAndApplyGroups(tx, {
271
273
  activeInvitation,
@@ -293,6 +295,7 @@ export class PgAuthStorage implements AuthStorage {
293
295
  session,
294
296
  invitation: invitation.invitation,
295
297
  groupMemberships,
298
+ previousUser,
296
299
  }
297
300
  }
298
301
  )
@@ -410,6 +413,7 @@ export class PgAuthStorage implements AuthStorage {
410
413
  ])
411
414
  await this.consumeOidcAttempt(input, completedAt, projectId, tx)
412
415
 
416
+ const previousUser = userRow ? rowToUserRecord(userRow) : null
413
417
  let user: UserRecord
414
418
  if (!userRow) {
415
419
  user = await this.insertUser(tx, {
@@ -431,7 +435,7 @@ export class PgAuthStorage implements AuthStorage {
431
435
  updated_at: user.updatedAt,
432
436
  }
433
437
  } else {
434
- user = rowToUserRecord(userRow)
438
+ user = await this.refreshProviderProfile(tx, userRow, input, completedAt)
435
439
  }
436
440
 
437
441
  const invitation = await this.acceptInvitationAndApplyGroups(tx, {
@@ -476,6 +480,7 @@ export class PgAuthStorage implements AuthStorage {
476
480
  identity: nextIdentity,
477
481
  invitation: invitation.invitation,
478
482
  groupMemberships,
483
+ previousUser,
479
484
  }
480
485
  }
481
486
  )
@@ -489,7 +494,7 @@ export class PgAuthStorage implements AuthStorage {
489
494
 
490
495
  async suspendUserAndRevokeSessions(
491
496
  input: SuspendUserAndRevokeSessionsInput
492
- ): Promise<UserRecord> {
497
+ ): Promise<UpdateAuthUserResult> {
493
498
  return runPgTransaction(this.sql, async (tx) => {
494
499
  await lockAdvisoryKeys(tx, [authLockKey("sessions", input.projectId, input.userId)])
495
500
  const existing = await getUserRowById(
@@ -523,7 +528,7 @@ export class PgAuthStorage implements AuthStorage {
523
528
  revokedAt: input.suspendedAt,
524
529
  })
525
530
 
526
- return rowToUserRecord(updated)
531
+ return { user: rowToUserRecord(updated), previousUser: rowToUserRecord(existing) }
527
532
  })
528
533
  }
529
534
 
@@ -733,6 +738,33 @@ export class PgAuthStorage implements AuthStorage {
733
738
  })
734
739
  }
735
740
 
741
+ // The identity provider owns the name and picture it sends: every sign-in overwrites them, and
742
+ // one it leaves out keeps the stored value.
743
+ private async refreshProviderProfile(
744
+ sql: SQLClient,
745
+ row: PgAuthUserRow,
746
+ input: CompleteOidcSignInInput,
747
+ updatedAt: Date
748
+ ): Promise<UserRecord> {
749
+ const displayName = input.displayName ?? row.display_name
750
+ const avatarUrl = input.avatarUrl ?? row.avatar_url
751
+ if (displayName === row.display_name && avatarUrl === row.avatar_url) {
752
+ return rowToUserRecord(row)
753
+ }
754
+
755
+ const [updated] = await sql<PgAuthUserRow[]>`
756
+ UPDATE auth_users
757
+ SET display_name = ${displayName},
758
+ avatar_url = ${avatarUrl},
759
+ updated_at = ${updatedAt}
760
+ WHERE project_id = ${row.project_id}
761
+ AND id = ${row.id}
762
+ RETURNING *
763
+ `
764
+
765
+ return rowToUserRecord(updated)
766
+ }
767
+
736
768
  private async consumeOidcAttempt(
737
769
  input: CompleteOidcSignInInput,
738
770
  completedAt: Date,