@sixb/sqlite 0.1.26 → 0.2.0

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 (39) hide show
  1. package/dist/agents/agent-storage.d.ts +8 -3
  2. package/dist/agents/agent-storage.d.ts.map +1 -1
  3. package/dist/agents/computers.d.ts +22 -0
  4. package/dist/agents/computers.d.ts.map +1 -0
  5. package/dist/agents/rows.d.ts +3 -0
  6. package/dist/agents/rows.d.ts.map +1 -1
  7. package/dist/agents/runs.d.ts.map +1 -1
  8. package/dist/agents/threads.d.ts +3 -1
  9. package/dist/agents/threads.d.ts.map +1 -1
  10. package/dist/agents/token-rates.d.ts +13 -0
  11. package/dist/agents/token-rates.d.ts.map +1 -0
  12. package/dist/ai-accounting-filters.d.ts +14 -0
  13. package/dist/ai-accounting-filters.d.ts.map +1 -0
  14. package/dist/ai-cost-groups.d.ts +1 -1
  15. package/dist/ai-cost-groups.d.ts.map +1 -1
  16. package/dist/ai-cost-storage.d.ts.map +1 -1
  17. package/dist/index.js +414 -69
  18. package/dist/index.js.map +16 -13
  19. package/dist/migrations.d.ts.map +1 -1
  20. package/dist/objects/query-compiler.d.ts.map +1 -1
  21. package/dist/ontology-storage/commits.d.ts.map +1 -1
  22. package/dist/ontology-storage/materializations.d.ts +2 -1
  23. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  24. package/package.json +3 -3
  25. package/src/agents/agent-storage.ts +10 -3
  26. package/src/agents/computers.ts +112 -0
  27. package/src/agents/rows.ts +9 -1
  28. package/src/agents/runs.ts +25 -3
  29. package/src/agents/threads.ts +100 -22
  30. package/src/agents/token-rates.ts +90 -0
  31. package/src/ai-accounting-filters.ts +51 -0
  32. package/src/ai-cost-groups.ts +52 -14
  33. package/src/ai-cost-storage.ts +101 -39
  34. package/src/migrations/082-agent-token-rates.sql +17 -0
  35. package/src/migrations/083-agent-computer-sessions.sql +27 -0
  36. package/src/migrations.ts +6 -0
  37. package/src/objects/query-compiler.ts +26 -15
  38. package/src/ontology-storage/commits.ts +9 -0
  39. package/src/ontology-storage/materializations.ts +18 -1
@@ -0,0 +1,51 @@
1
+ import type { normalizeAiAccountingQuery } from "@sixb/core/internal/ai-cost-storage-provider"
2
+
3
+ // The kind, source and requester filters every accounting read shares, over the joins it makes:
4
+ // `execution`, `direct_agent`, `workflow_node`.
5
+
6
+ /** Where a call came from, as `aiAccountingSource` reads its attribution. */
7
+ export const SQLITE_SOURCE_WORKFLOW = `COALESCE(
8
+ workflow_node.workflow_id,
9
+ CASE WHEN execution.executor_kind = 'workflow' THEN execution.authority_primitive_id END
10
+ )`
11
+ export const SQLITE_SOURCE_KIND = `CASE
12
+ WHEN direct_agent.id IS NOT NULL THEN 'agent'
13
+ WHEN ${SQLITE_SOURCE_WORKFLOW} IS NOT NULL THEN 'workflow'
14
+ WHEN execution.executor_kind = 'action' THEN 'action'
15
+ ELSE 'other'
16
+ END`
17
+
18
+ /** The kind, source and requester filters over the columns that hold them; 12 positional params. */
19
+ export function sqliteAccountingFilter(columns: {
20
+ readonly kind: string
21
+ readonly sourceKind: string
22
+ readonly sourceWorkflow: string
23
+ readonly userId: string
24
+ readonly serviceAccountId: string
25
+ }): string {
26
+ return `(? IS NULL OR ${columns.kind} = ?)
27
+ AND (? IS NULL OR ${columns.sourceKind} = ?)
28
+ AND (? IS NULL OR ${columns.sourceWorkflow} = ?)
29
+ AND (
30
+ ? IS NULL
31
+ OR (? = 'none' AND ${columns.userId} IS NULL AND ${columns.serviceAccountId} IS NULL)
32
+ OR (? = 'user' AND ${columns.userId} = ?)
33
+ OR (? = 'serviceAccount' AND ${columns.serviceAccountId} = ?)
34
+ )`
35
+ }
36
+
37
+ export function sqliteAccountingFilterParameters(
38
+ query: ReturnType<typeof normalizeAiAccountingQuery>
39
+ ): readonly (string | null)[] {
40
+ const kind = query.modelKind ?? null
41
+ const source = query.source?.kind ?? null
42
+ const workflow = query.source?.kind === "workflow" ? (query.source.workflowId ?? null) : null
43
+ const mode =
44
+ query.requester === undefined
45
+ ? null
46
+ : query.requester === "none"
47
+ ? "none"
48
+ : query.requester.type
49
+ const id = query.requester === undefined || query.requester === "none" ? null : query.requester.id
50
+ return [kind, kind, source, source, workflow, workflow, mode, mode, mode, id, mode, id]
51
+ }
@@ -3,17 +3,31 @@ import {
3
3
  aiModelCallGroupFragmentFromRow,
4
4
  buildAiModelCallGroupsFromFragments,
5
5
  normalizeAiModelCallAccountingQuery,
6
+ normalizeAiModelCallGroupSort,
6
7
  TOKENLESS_MODEL_KINDS,
7
8
  } from "@sixb/core/internal/ai-cost-storage-provider"
8
9
  import type { ListAiModelCallGroupsInput, ListAiModelCallGroupsResult } from "@sixb/core/storage"
10
+ import {
11
+ SQLITE_SOURCE_KIND,
12
+ SQLITE_SOURCE_WORKFLOW,
13
+ sqliteAccountingFilter,
14
+ sqliteAccountingFilterParameters,
15
+ } from "./ai-accounting-filters"
9
16
  import type { SqliteStoreConnection } from "./transactions"
10
17
 
11
- /** Paginate initiating executions, then aggregate their matching calls without loading usage payloads. */
18
+ /** Positional `?` placeholders numbered from `start`, to sit among this file's numbered ones. */
19
+ function numbered(sql: string, start: number): string {
20
+ let next = start
21
+ return sql.replace(/\?(?!\d)/g, () => `?${next++}`)
22
+ }
23
+
24
+ /** Paginate conversations, workflow runs and other initiating executions, then aggregate their matching calls without loading usage payloads. */
12
25
  export async function listAiModelCallGroups(
13
26
  connection: SqliteStoreConnection,
14
27
  input: ListAiModelCallGroupsInput
15
28
  ): Promise<ListAiModelCallGroupsResult> {
16
29
  const query = normalizeAiModelCallAccountingQuery(input)
30
+ const sort = normalizeAiModelCallGroupSort(input.sort)
17
31
  const parameters = [
18
32
  query.projectId,
19
33
  query.from.toISOString(),
@@ -22,14 +36,15 @@ export async function listAiModelCallGroups(
22
36
  query.modelId ?? null,
23
37
  query.valuationStatus ?? null,
24
38
  ]
25
- const count = connection.db.query(COUNT_SQL).get(...parameters) as { total: string }
39
+ const filters = sqliteAccountingFilterParameters(query)
40
+ const count = connection.db.query(COUNT_SQL).get(...parameters, ...filters) as { total: string }
26
41
  const rows = connection.db
27
42
  .query(PAGE_SQL)
28
- .all(...parameters, query.limit, query.offset) as AiModelCallGroupRow[]
43
+ .all(...parameters, ...filters, query.limit, query.offset, sort) as AiModelCallGroupRow[]
29
44
  const total = Number(count?.total)
30
45
  if (!Number.isSafeInteger(total) || total < 0)
31
46
  throw new RangeError("[Sixb] Invalid model-call group count.")
32
- const items = buildAiModelCallGroupsFromFragments(rows.map(aiModelCallGroupFragmentFromRow))
47
+ const items = buildAiModelCallGroupsFromFragments(rows.map(aiModelCallGroupFragmentFromRow), sort)
33
48
  return { items, total, hasMore: query.offset + items.length < total }
34
49
  }
35
50
 
@@ -39,8 +54,15 @@ const TOKENLESS_KINDS = TOKENLESS_MODEL_KINDS.map((kind) => `'${kind}'`).join(",
39
54
  const FILTERED_SQL = `
40
55
  WITH filtered AS (
41
56
  SELECT COALESCE(root_agent.execution_id, usage.execution_id) AS root_execution_id,
57
+ -- What the call is listed under, as aiModelCallGroupKey computes it.
58
+ CASE
59
+ WHEN root_agent.thread_id IS NOT NULL THEN 'thread:' || root_agent.thread_id
60
+ WHEN workflow_node.workflow_run_id IS NOT NULL THEN 'workflowRun:' || workflow_node.workflow_run_id
61
+ ELSE COALESCE(root_agent.execution_id, usage.execution_id)
62
+ END AS group_key,
42
63
  usage.execution_id, usage.provider_id, usage.requested_model_id, usage.occurred_at,
43
- usage.total_tokens, usage.model_kind, usage.output_images, usage.input_characters,
64
+ usage.total_tokens, usage.input_tokens, usage.cache_read_input_tokens,
65
+ usage.model_kind, usage.output_images, usage.input_characters,
44
66
  usage.audio_duration_ms,
45
67
  execution.executor_kind, execution.executor_id, execution.authority_primitive_id AS primitive_id,
46
68
  COALESCE(cost.status, 'unvalued') AS valuation_status,
@@ -53,7 +75,8 @@ WITH filtered AS (
53
75
  workflow_node.node_id AS workflow_agent_step_id,
54
76
  workflow_agent.node_run_id AS workflow_node_run_id,
55
77
  workflow_node.workflow_id AS workflow_id,
56
- workflow_node.workflow_run_id AS workflow_run_id
78
+ workflow_node.workflow_run_id AS workflow_run_id,
79
+ execution.requested_by_user_id, execution.requested_by_service_account_id
57
80
  FROM ai_model_call_usage AS usage
58
81
  JOIN executions AS execution ON execution.project_id = usage.project_id AND execution.id = usage.execution_id
59
82
  LEFT JOIN agent_runs AS direct_agent
@@ -73,23 +96,36 @@ WITH filtered AS (
73
96
  AND (?4 IS NULL OR usage.provider_id = ?4)
74
97
  AND (?5 IS NULL OR usage.requested_model_id = ?5)
75
98
  AND (?6 IS NULL OR COALESCE(cost.status, 'unvalued') = ?6)
99
+ AND ${numbered(
100
+ sqliteAccountingFilter({
101
+ kind: "usage.model_kind",
102
+ sourceKind: SQLITE_SOURCE_KIND,
103
+ sourceWorkflow: SQLITE_SOURCE_WORKFLOW,
104
+ userId: "execution.requested_by_user_id",
105
+ serviceAccountId: "execution.requested_by_service_account_id",
106
+ }),
107
+ 7
108
+ )}
76
109
  ), roots AS (
77
- SELECT root_execution_id, MAX(occurred_at) AS last_call_at
78
- FROM filtered GROUP BY root_execution_id
110
+ SELECT group_key, MAX(occurred_at) AS last_call_at, TOTAL(amount_nanos) AS cost_nanos
111
+ FROM filtered GROUP BY group_key
79
112
  )
80
113
  `
81
114
  const COUNT_SQL = `${FILTERED_SQL} SELECT CAST(COUNT(*) AS TEXT) AS total FROM roots`
82
115
  const PAGE_SQL = `${FILTERED_SQL}, page AS (
83
- SELECT root_execution_id FROM roots
84
- ORDER BY last_call_at DESC, root_execution_id ASC LIMIT ?7 OFFSET ?8
116
+ SELECT group_key FROM roots
117
+ ORDER BY CASE WHEN ?21 = 'cost' THEN cost_nanos END DESC, last_call_at DESC, group_key ASC
118
+ LIMIT ?19 OFFSET ?20
85
119
  )
86
- SELECT filtered.root_execution_id, execution_id, provider_id, requested_model_id,
120
+ SELECT filtered.group_key, root_execution_id, execution_id, provider_id, requested_model_id,
87
121
  MIN(occurred_at) AS first_call_at, MAX(occurred_at) AS last_call_at,
88
122
  CAST(COUNT(*) AS TEXT) AS call_count,
89
123
  CAST(COUNT(total_tokens) AS TEXT) AS token_count,
90
124
  CAST(COUNT(CASE WHEN total_tokens IS NULL AND model_kind IN (${TOKENLESS_KINDS}) THEN 1 END) AS TEXT)
91
125
  AS tokenless_count,
92
126
  CAST(SUM(total_tokens) AS TEXT) AS total_tokens,
127
+ CAST(SUM(input_tokens) AS TEXT) AS input_tokens,
128
+ CAST(SUM(cache_read_input_tokens) AS TEXT) AS cache_read_input_tokens,
93
129
  CAST(SUM(output_images) AS TEXT) AS output_images,
94
130
  CAST(SUM(input_characters) AS TEXT) AS input_characters,
95
131
  CAST(SUM(audio_duration_ms) AS TEXT) AS audio_duration_ms,
@@ -106,8 +142,10 @@ SELECT filtered.root_execution_id, execution_id, provider_id, requested_model_id
106
142
  MAX(workflow_agent_step_id) AS workflow_agent_step_id,
107
143
  MAX(workflow_node_run_id) AS workflow_node_run_id,
108
144
  MAX(workflow_id) AS workflow_id,
109
- MAX(workflow_run_id) AS workflow_run_id
110
- FROM filtered JOIN page ON page.root_execution_id = filtered.root_execution_id
111
- GROUP BY filtered.root_execution_id, execution_id, provider_id, requested_model_id,
145
+ MAX(workflow_run_id) AS workflow_run_id,
146
+ MAX(requested_by_user_id) AS requested_by_user_id,
147
+ MAX(requested_by_service_account_id) AS requested_by_service_account_id
148
+ FROM filtered JOIN page ON page.group_key = filtered.group_key
149
+ GROUP BY filtered.group_key, root_execution_id, execution_id, provider_id, requested_model_id,
112
150
  valuation_status, amount_currency
113
151
  `
@@ -28,6 +28,12 @@ import {
28
28
  type QueryAiAccountingOverviewInput,
29
29
  type SummarizeAiCostExecutionsInput,
30
30
  } from "@sixb/core/storage"
31
+ import {
32
+ SQLITE_SOURCE_KIND,
33
+ SQLITE_SOURCE_WORKFLOW,
34
+ sqliteAccountingFilter,
35
+ sqliteAccountingFilterParameters,
36
+ } from "./ai-accounting-filters"
31
37
  import { listAiModelCallGroups } from "./ai-cost-groups"
32
38
  import { requestedReasoningFromRow } from "./ai-usage-storage"
33
39
  import { isUniqueConstraintError } from "./storage-errors"
@@ -96,6 +102,8 @@ export class SqliteAiCostStorage implements AiCostStorage {
96
102
  query.providerId ?? null,
97
103
  query.modelId ?? null,
98
104
  query.modelId ?? null,
105
+ ...sqliteAccountingFilterParameters(query),
106
+ input.bucket,
99
107
  input.bucket
100
108
  ) as AggregateRow[]
101
109
  return buildAiAccountingOverviewFromFragments(input, rows.map(aggregateFragmentFromRow))
@@ -176,13 +184,34 @@ const SQLITE_ACCOUNTING_LIST_FILTER = `
176
184
  ELSE 'unvalued'
177
185
  END
178
186
  )
187
+ AND ${sqliteAccountingFilter({
188
+ kind: "usage.model_kind",
189
+ sourceKind: SQLITE_SOURCE_KIND,
190
+ sourceWorkflow: SQLITE_SOURCE_WORKFLOW,
191
+ userId: "execution.requested_by_user_id",
192
+ serviceAccountId: "execution.requested_by_service_account_id",
193
+ })}
194
+ `
195
+
196
+ const SQLITE_ACCOUNTING_LIST_JOINS = `
197
+ JOIN executions AS execution ON execution.project_id = usage.project_id AND execution.id = usage.execution_id
198
+ LEFT JOIN agent_runs AS direct_agent
199
+ ON direct_agent.project_id = usage.project_id
200
+ AND direct_agent.execution_id = usage.execution_id
201
+ LEFT JOIN workflow_agent_node_runs AS workflow_agent
202
+ ON workflow_agent.project_id = usage.project_id
203
+ AND workflow_agent.execution_id = usage.execution_id
204
+ LEFT JOIN workflow_node_runs AS workflow_node
205
+ ON workflow_node.project_id = workflow_agent.project_id
206
+ AND workflow_node.id = workflow_agent.node_run_id
207
+ LEFT JOIN ai_model_call_valuations AS cost
208
+ ON cost.project_id = usage.project_id AND cost.usage_record_id = usage.id
179
209
  `
180
210
 
181
211
  const SQLITE_ACCOUNTING_LIST_COUNT_SQL = `
182
212
  SELECT CAST(COUNT(*) AS TEXT) AS total
183
213
  FROM ai_model_call_usage AS usage
184
- LEFT JOIN ai_model_call_valuations AS cost
185
- ON cost.project_id = usage.project_id AND cost.usage_record_id = usage.id
214
+ ${SQLITE_ACCOUNTING_LIST_JOINS}
186
215
  WHERE ${SQLITE_ACCOUNTING_LIST_FILTER}
187
216
  `
188
217
 
@@ -218,18 +247,7 @@ const SQLITE_ACCOUNTING_LIST_PAGE_SQL = `
218
247
  cost.details AS cost_details,
219
248
  cost.rated_at AS cost_rated_at
220
249
  FROM ai_model_call_usage AS usage
221
- JOIN executions AS execution ON execution.project_id = usage.project_id AND execution.id = usage.execution_id
222
- LEFT JOIN agent_runs AS direct_agent
223
- ON direct_agent.project_id = usage.project_id
224
- AND direct_agent.execution_id = usage.execution_id
225
- LEFT JOIN workflow_agent_node_runs AS workflow_agent
226
- ON workflow_agent.project_id = usage.project_id
227
- AND workflow_agent.execution_id = usage.execution_id
228
- LEFT JOIN workflow_node_runs AS workflow_node
229
- ON workflow_node.project_id = workflow_agent.project_id
230
- AND workflow_node.id = workflow_agent.node_run_id
231
- LEFT JOIN ai_model_call_valuations AS cost
232
- ON cost.project_id = usage.project_id AND cost.usage_record_id = usage.id
250
+ ${SQLITE_ACCOUNTING_LIST_JOINS}
233
251
  WHERE ${SQLITE_ACCOUNTING_LIST_FILTER}
234
252
  ORDER BY usage.occurred_at DESC, usage.id ASC
235
253
  LIMIT ? OFFSET ?
@@ -269,7 +287,10 @@ const SQLITE_ACCOUNTING_OVERVIEW_SQL = `
269
287
  ELSE 'unvalued'
270
288
  END AS valuation_status,
271
289
  CASE WHEN cost.status = 'rated' THEN cost.currency ELSE NULL END AS amount_currency,
272
- CASE WHEN cost.status = 'rated' THEN cost.amount_nanos ELSE NULL END AS amount_nanos
290
+ CASE WHEN cost.status = 'rated' THEN cost.amount_nanos ELSE NULL END AS amount_nanos,
291
+ execution.requested_by_user_id AS requester_user_id,
292
+ execution.requested_by_service_account_id AS requester_service_account_id,
293
+ ${SQLITE_SOURCE_KIND} AS source_kind
273
294
  FROM ai_model_call_usage AS usage
274
295
  JOIN executions AS execution ON execution.project_id = usage.project_id AND execution.id = usage.execution_id
275
296
  LEFT JOIN agent_runs AS direct_agent
@@ -286,10 +307,19 @@ const SQLITE_ACCOUNTING_OVERVIEW_SQL = `
286
307
  WHERE usage.project_id = ? AND usage.occurred_at >= ? AND usage.occurred_at < ?
287
308
  AND (? IS NULL OR usage.provider_id = ?)
288
309
  AND (? IS NULL OR usage.requested_model_id = ?)
310
+ ), filtered AS (
311
+ SELECT * FROM classified
312
+ WHERE ${sqliteAccountingFilter({
313
+ kind: "model_kind",
314
+ sourceKind: "source_kind",
315
+ sourceWorkflow: "attribution_workflow_id",
316
+ userId: "requester_user_id",
317
+ serviceAccountId: "requester_service_account_id",
318
+ })}
289
319
  ), expanded AS (
290
320
  SELECT 'totals' AS dimension, NULL AS key_1, NULL AS key_2, NULL AS key_3, NULL AS bucket_start,
291
- classified.*
292
- FROM classified
321
+ filtered.*
322
+ FROM filtered
293
323
  UNION ALL
294
324
  SELECT 'series', NULL, NULL, NULL,
295
325
  CASE ?
@@ -301,18 +331,39 @@ const SQLITE_ACCOUNTING_OVERVIEW_SQL = `
301
331
  printf('-%d days', (CAST(strftime('%w', occurred_at) AS INTEGER) + 6) % 7)
302
332
  )
303
333
  END,
304
- classified.*
305
- FROM classified
334
+ filtered.*
335
+ FROM filtered
306
336
  UNION ALL
307
- SELECT 'model', provider_id, requested_model_id, NULL, NULL, classified.* FROM classified
337
+ SELECT 'modelSeries', provider_id, requested_model_id, NULL,
338
+ CASE ?
339
+ WHEN 'hour' THEN strftime('%Y-%m-%dT%H:00:00.000Z', occurred_at)
340
+ WHEN 'day' THEN strftime('%Y-%m-%dT00:00:00.000Z', occurred_at)
341
+ ELSE strftime(
342
+ '%Y-%m-%dT00:00:00.000Z',
343
+ occurred_at,
344
+ printf('-%d days', (CAST(strftime('%w', occurred_at) AS INTEGER) + 6) % 7)
345
+ )
346
+ END,
347
+ filtered.*
348
+ FROM filtered
308
349
  UNION ALL
309
- SELECT 'kind', model_kind, NULL, NULL, NULL, classified.* FROM classified
350
+ SELECT 'model', provider_id, requested_model_id, NULL, NULL, filtered.* FROM filtered
351
+ UNION ALL
352
+ SELECT 'person',
353
+ CASE
354
+ WHEN requester_user_id IS NOT NULL THEN 'user'
355
+ WHEN requester_service_account_id IS NOT NULL THEN 'serviceAccount'
356
+ END,
357
+ COALESCE(requester_user_id, requester_service_account_id), NULL, NULL, filtered.*
358
+ FROM filtered
310
359
  UNION ALL
311
- SELECT 'agent', attribution_agent_kind, attribution_workflow_id, attribution_agent_step_id, NULL, classified.*
312
- FROM classified WHERE attribution_agent_kind IS NOT NULL
360
+ SELECT 'kind', model_kind, NULL, NULL, NULL, filtered.* FROM filtered
313
361
  UNION ALL
314
- SELECT 'workflow', attribution_workflow_id, NULL, NULL, NULL, classified.*
315
- FROM classified WHERE attribution_workflow_id IS NOT NULL
362
+ SELECT 'agent', attribution_agent_kind, attribution_workflow_id, attribution_agent_step_id, NULL, filtered.*
363
+ FROM filtered WHERE attribution_agent_kind IS NOT NULL
364
+ UNION ALL
365
+ SELECT 'workflow', attribution_workflow_id, NULL, NULL, NULL, filtered.*
366
+ FROM filtered WHERE attribution_workflow_id IS NOT NULL
316
367
  )
317
368
  SELECT dimension,
318
369
  key_1,
@@ -377,6 +428,7 @@ function accountingListFilterParameters(
377
428
  query.executionId ?? null,
378
429
  query.valuationStatus ?? null,
379
430
  query.valuationStatus ?? null,
431
+ ...sqliteAccountingFilterParameters(query),
380
432
  ]
381
433
  }
382
434
 
@@ -505,20 +557,30 @@ function aggregateFragmentFromRow(row: AggregateRow): AiAccountingAggregateFragm
505
557
  const dimensionKeys =
506
558
  row.dimension === "model"
507
559
  ? { providerId: required(row.key_1), modelId: required(row.key_2) }
508
- : row.dimension === "kind"
509
- ? withModelKind(aiAccountingModelKind(row.key_1))
510
- : row.dimension === "agent"
511
- ? {
512
- agentKind: required(row.key_1) as "agent" | "workflowAgent",
513
- ...(row.key_1 === "workflowAgent"
514
- ? { workflowId: required(row.key_2), agentStepId: required(row.key_3) }
515
- : {}),
516
- }
517
- : row.dimension === "workflow"
518
- ? { workflowId: required(row.key_1) }
519
- : row.dimension === "series"
520
- ? { start: new Date(required(row.bucket_start)) }
521
- : {}
560
+ : row.dimension === "modelSeries"
561
+ ? {
562
+ providerId: required(row.key_1),
563
+ modelId: required(row.key_2),
564
+ start: new Date(required(row.bucket_start)),
565
+ }
566
+ : row.dimension === "person"
567
+ ? row.key_1 && row.key_2
568
+ ? { requesterType: row.key_1 as "user" | "serviceAccount", requesterId: row.key_2 }
569
+ : {}
570
+ : row.dimension === "kind"
571
+ ? withModelKind(aiAccountingModelKind(row.key_1))
572
+ : row.dimension === "agent"
573
+ ? {
574
+ agentKind: required(row.key_1) as "agent" | "workflowAgent",
575
+ ...(row.key_1 === "workflowAgent"
576
+ ? { workflowId: required(row.key_2), agentStepId: required(row.key_3) }
577
+ : {}),
578
+ }
579
+ : row.dimension === "workflow"
580
+ ? { workflowId: required(row.key_1) }
581
+ : row.dimension === "series"
582
+ ? { start: new Date(required(row.bucket_start)) }
583
+ : {}
522
584
  return {
523
585
  dimension: row.dimension,
524
586
  ...dimensionKeys,
@@ -0,0 +1,17 @@
1
+ -- What each model's tokenizer measured on a project's agent runs, one row per run: the bytes of a
2
+ -- request's text as the Agent's context estimator weighs them, against the text tokens the
3
+ -- provider counted. A record deletes the model's rows past its newest 20, so the table stays small.
4
+ -- Timestamps are ISO-8601 UTC text and compare lexicographically.
5
+ CREATE TABLE agent_token_rates (
6
+ project_id TEXT NOT NULL CHECK (length(trim(project_id)) > 0),
7
+ run_id TEXT NOT NULL CHECK (length(trim(run_id)) > 0),
8
+ provider_id TEXT NOT NULL CHECK (length(trim(provider_id)) > 0),
9
+ model_id TEXT NOT NULL CHECK (length(trim(model_id)) > 0),
10
+ bytes INTEGER NOT NULL CHECK (bytes > 0),
11
+ tokens INTEGER NOT NULL CHECK (tokens > 0),
12
+ measured_at TEXT NOT NULL,
13
+ PRIMARY KEY (project_id, run_id)
14
+ );
15
+
16
+ CREATE INDEX idx_agent_token_rates_model
17
+ ON agent_token_rates (project_id, provider_id, model_id, measured_at DESC, run_id DESC);
@@ -0,0 +1,27 @@
1
+ -- Where a computer's thread runs: the computer's name and the folder there, beside its id. A
2
+ -- computer kept before folders were has none until its owner moves the thread.
3
+ ALTER TABLE agent_threads ADD COLUMN computer_name TEXT;
4
+ ALTER TABLE agent_threads ADD COLUMN computer_folder TEXT;
5
+ UPDATE agent_threads SET computer_id = NULL WHERE computer_id IS NOT NULL;
6
+ -- When its turns last moved to where they run now: the files earlier turns used are not there.
7
+ ALTER TABLE agent_threads ADD COLUMN moved_at TEXT;
8
+
9
+ -- Each open sixb on its owner's computers, a lease it renews: while one is live, the threads that
10
+ -- run on its computer in its folder are reachable there.
11
+ CREATE TABLE agent_computer_sessions (
12
+ project_id TEXT NOT NULL,
13
+ owner_principal_type TEXT NOT NULL CHECK (owner_principal_type IN ('user', 'serviceAccount', 'system')),
14
+ owner_principal_id TEXT NOT NULL CHECK (length(owner_principal_id) > 0),
15
+ id TEXT NOT NULL CHECK (length(id) > 0),
16
+ computer_id TEXT NOT NULL,
17
+ computer_name TEXT NOT NULL,
18
+ folder TEXT NOT NULL,
19
+ expires_at TEXT NOT NULL,
20
+ PRIMARY KEY (project_id, owner_principal_type, owner_principal_id, id)
21
+ );
22
+ CREATE INDEX agent_computer_sessions_by_expiry ON agent_computer_sessions (project_id, expires_at);
23
+
24
+ -- Each open sixb asks every 20 seconds which of its owner's threads here have a turn active.
25
+ CREATE INDEX agent_threads_active_on_computer
26
+ ON agent_threads (project_id, owner_principal_type, owner_principal_id, computer_id, computer_folder)
27
+ WHERE active_run_id IS NOT NULL;
package/src/migrations.ts CHANGED
@@ -313,6 +313,8 @@ export const sqliteStorageMigrations = defineMigrations({
313
313
  sqliteSql("079-subagent-questions", subagentQuestionsSql),
314
314
  sqliteSql("080-agent-run-cancel-requests", agentRunCancelRequestsSql),
315
315
  sqliteSql("081-agent-thread-runs-on", agentThreadRunsOnSql),
316
+ sqliteSql("082-agent-token-rates", agentTokenRatesSql),
317
+ sqliteSql("083-agent-computer-sessions", agentComputerSessionsSql),
316
318
  ],
317
319
  })
318
320
 
@@ -715,3 +717,7 @@ import agentRunCancelRequestsSql from "./migrations/080-agent-run-cancel-request
715
717
  type: "text",
716
718
  }
717
719
  import agentThreadRunsOnSql from "./migrations/081-agent-thread-runs-on.sql" with { type: "text" }
720
+ import agentTokenRatesSql from "./migrations/082-agent-token-rates.sql" with { type: "text" }
721
+ import agentComputerSessionsSql from "./migrations/083-agent-computer-sessions.sql" with {
722
+ type: "text",
723
+ }
@@ -9,6 +9,7 @@ import {
9
9
  import {
10
10
  hasVectorProfile,
11
11
  isVectorProfileQuery,
12
+ linkEndpointObjectTypeIds,
12
13
  normalizeVector,
13
14
  vectorSearchCandidateLimit,
14
15
  } from "@sixb/core/internal/query"
@@ -190,14 +191,7 @@ function compileObjectQueryInternal(
190
191
  case "page":
191
192
  return compilePage(projectId, query.input, query.pageSize, query.pageToken, ctx)
192
193
  case "traverse":
193
- return compileTraversal(
194
- projectId,
195
- query.input,
196
- query.linkId,
197
- query.direction,
198
- query.sourceObjectTypeId,
199
- ctx
200
- )
194
+ return compileTraversal(projectId, query, ctx)
201
195
  case "set":
202
196
  return compileSet(projectId, query.op, query.inputs, ctx)
203
197
  case "project":
@@ -571,14 +565,13 @@ function compilePage(
571
565
 
572
566
  function compileTraversal(
573
567
  projectId: string,
574
- inputQuery: ObjectQuery,
575
- linkId: string,
576
- direction: "outgoing" | "incoming",
577
- sourceObjectTypeId: string | undefined,
568
+ traversal: Extract<ObjectQuery, { kind: "traverse" }>,
578
569
  ctx: CompileContext
579
570
  ): CompiledObjectQuery {
580
- const input = compileObjectQueryInternal(projectId, inputQuery, exactContext(ctx))
571
+ const { linkId, direction, sourceObjectTypeId } = traversal
572
+ const input = compileObjectQueryInternal(projectId, traversal.input, exactContext(ctx))
581
573
  const outputAlias = direction === "outgoing" ? "target_object" : "source_object"
574
+ const endpointType = compileEndpointTypes(outputAlias, linkEndpointObjectTypeIds(traversal))
582
575
  const joinSql =
583
576
  direction === "outgoing"
584
577
  ? `
@@ -591,6 +584,7 @@ function compileTraversal(
591
584
  ON target_object.project_id = edge.project_id
592
585
  AND target_object.object_type_id = edge.target_type_id
593
586
  AND target_object.primary_id = edge.target_id
587
+ ${endpointType.sql}
594
588
  `
595
589
  : `
596
590
  JOIN ${ctx.source.linksTable} AS edge
@@ -602,6 +596,7 @@ function compileTraversal(
602
596
  ON source_object.project_id = edge.project_id
603
597
  AND source_object.object_type_id = edge.source_type_id
604
598
  AND source_object.primary_id = edge.source_id
599
+ ${endpointType.sql}
605
600
  `
606
601
  const order = compileOrder(identityOrderFields())
607
602
  const qualifiedOrder = compileOrder(identityOrderFields(), outputAlias)
@@ -614,7 +609,8 @@ function compileTraversal(
614
609
  const args = [
615
610
  ...input.args,
616
611
  linkId,
617
- ...(sourceObjectTypeId === undefined ? [] : [sourceObjectTypeId]),
612
+ ...(direction === "incoming" && sourceObjectTypeId !== undefined ? [sourceObjectTypeId] : []),
613
+ ...endpointType.args,
618
614
  ...qualifiedOrder.args,
619
615
  ]
620
616
 
@@ -630,6 +626,19 @@ function compileTraversal(
630
626
  }
631
627
  }
632
628
 
629
+ /** Only the types the ontology says this link can reach. */
630
+ function compileEndpointTypes(
631
+ alias: string,
632
+ objectTypeIds: readonly string[] | undefined
633
+ ): CompiledPredicate {
634
+ if (!objectTypeIds?.length) return { sql: "", args: [] }
635
+
636
+ return {
637
+ sql: `AND ${alias}.object_type_id IN (${objectTypeIds.map(() => "?").join(", ")})`,
638
+ args: [...objectTypeIds],
639
+ }
640
+ }
641
+
633
642
  /** Correlation handle for an expansion: the parent row's identity columns. */
634
643
  interface ExpansionParent {
635
644
  project: string
@@ -730,7 +739,8 @@ function compileExpansionValue(
730
739
  `${parentId} = ${parent.id}`,
731
740
  `${edge}.link_id = ?`,
732
741
  ]
733
- const whereArgs: SqliteValue[] = [expansion.linkId]
742
+ const endpointType = compileEndpointTypes(target, expansion.endpointObjectTypeIds)
743
+ const whereArgs: SqliteValue[] = [...endpointType.args, expansion.linkId]
734
744
  if (incoming && expansion.sourceObjectTypeId !== undefined) {
735
745
  whereParts.push(`${edge}.source_type_id = ?`)
736
746
  whereArgs.push(expansion.sourceObjectTypeId)
@@ -742,6 +752,7 @@ function compileExpansionValue(
742
752
  ON ${target}.project_id = ${edge}.project_id
743
753
  AND ${target}.object_type_id = ${neighborType}
744
754
  AND ${target}.primary_id = ${neighborId}
755
+ ${endpointType.sql}
745
756
  WHERE ${whereParts.join(" AND ")}
746
757
  `
747
758
 
@@ -11,6 +11,7 @@ import type {
11
11
  import {
12
12
  assertNonblank,
13
13
  assertNonnegativeInteger,
14
+ assertTimestamp,
14
15
  commitRecord,
15
16
  originWhere,
16
17
  type SqliteOntologyCommitRow,
@@ -78,6 +79,9 @@ export class SqliteOntologyCommitStorage implements OntologyCommitStorage {
78
79
  const limit = input.limit ?? -1
79
80
  assertNonnegativeInteger(offset, "Ontology commit list offset")
80
81
  if (limit !== -1) assertNonnegativeInteger(limit, "Ontology commit list limit")
82
+ if (input.committedAt !== undefined) {
83
+ assertTimestamp(input.committedAt, "Ontology commit list committedAt")
84
+ }
81
85
  const filters = ["project_id = ?"]
82
86
  const values: (string | number)[] = [input.projectId]
83
87
  if (input.run?.kind === "action") {
@@ -87,6 +91,11 @@ export class SqliteOntologyCommitStorage implements OntologyCommitStorage {
87
91
  filters.push("origin_run_id = ? AND origin_kind IN ('projection', 'telemetry')")
88
92
  values.push(input.run.id)
89
93
  }
94
+ if (input.committedAt !== undefined) {
95
+ // Commit times are stored canonical, so text equality is exact and the index serves it.
96
+ filters.push("committed_at = ?")
97
+ values.push(input.committedAt)
98
+ }
90
99
  const where = filters.join(" AND ")
91
100
  const totalRow = this.db
92
101
  .query(`SELECT COUNT(*) AS total FROM ontology_commits WHERE ${where}`)
@@ -25,6 +25,7 @@ import {
25
25
  invalidCorrelation,
26
26
  materializationOutboxWrite,
27
27
  materializationPlanChunk,
28
+ prepareMaterializationStateRead,
28
29
  sameNonnegativeCounts as sameCounts,
29
30
  uniqueSorted,
30
31
  } from "@sixb/core/internal/ontology-storage-provider"
@@ -36,16 +37,18 @@ import type {
36
37
  MaterializationPlanHeader,
37
38
  MaterializationPlanWorkRecord,
38
39
  MaterializationSession,
40
+ MaterializationState,
39
41
  MaterializationStatePage,
40
42
  MaterializationVectorChangePage,
41
43
  OntologyCommitRecord,
42
44
  OntologyMaterializationStorage,
45
+ ReadMaterializationStateInput,
43
46
  SourceActivationWrite,
44
47
  StageMaterializationWorkInput,
45
48
  StreamMaterializationStateInput,
46
49
  StreamMaterializationVectorChangesInput,
47
50
  } from "@sixb/core/storage"
48
- import { yieldSqliteEventLoop } from "../transactions"
51
+ import { runDeferredReadTransaction, yieldSqliteEventLoop } from "../transactions"
49
52
  import {
50
53
  type SqliteMaterializationSessionState,
51
54
  SqliteMaterializationSessions,
@@ -243,6 +246,20 @@ export class SqliteOntologyMaterializationStorage implements OntologyMaterializa
243
246
  }
244
247
  }
245
248
 
249
+ async readState(input: ReadMaterializationStateInput): Promise<MaterializationState> {
250
+ const request = prepareMaterializationStateRead(input)
251
+
252
+ // One snapshot for every statement, so the read never straddles a commit.
253
+ return runDeferredReadTransaction(this.db, () => {
254
+ const reader = new SqliteMaterializationStateReader(this.db, request.projectId)
255
+ return {
256
+ objects: reader.objectStates(request.objects),
257
+ links: reader.linkStates(request.links),
258
+ linkScopes: reader.linkSlotStates(request.linkScopes),
259
+ }
260
+ })
261
+ }
262
+
246
263
  async stageWork(input: StageMaterializationWorkInput): Promise<void> {
247
264
  this.sessions.stage(input)
248
265
  await yieldSqliteEventLoop()