@open-mercato/ai-assistant 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1

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 (135) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +30 -4
  3. package/dist/frontend/components/AiChatButton.js +3 -2
  4. package/dist/frontend/components/AiChatButton.js.map +2 -2
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +364 -0
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +7 -0
  7. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -7
  8. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
  9. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +182 -0
  10. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +7 -0
  11. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js +316 -0
  12. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js.map +7 -0
  13. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +8 -7
  14. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +2 -2
  15. package/dist/modules/ai_assistant/api/ai/chat/route.js +43 -20
  16. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  17. package/dist/modules/ai_assistant/api/settings/route.js +4 -3
  18. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  19. package/dist/modules/ai_assistant/api/usage/daily/route.js +111 -0
  20. package/dist/modules/ai_assistant/api/usage/daily/route.js.map +7 -0
  21. package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js +108 -0
  22. package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js.map +7 -0
  23. package/dist/modules/ai_assistant/api/usage/sessions/route.js +153 -0
  24. package/dist/modules/ai_assistant/api/usage/sessions/route.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +335 -38
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -7
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +44 -35
  30. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  31. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js +282 -0
  32. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js.map +7 -0
  33. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js +10 -0
  34. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js.map +7 -0
  35. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js +25 -0
  36. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js.map +7 -0
  37. package/dist/modules/ai_assistant/cli.js +12 -0
  38. package/dist/modules/ai_assistant/cli.js.map +2 -2
  39. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +1 -1
  40. package/dist/modules/ai_assistant/data/entities.js +177 -1
  41. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  42. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +104 -2
  43. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  44. package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js +168 -0
  45. package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js.map +7 -0
  46. package/dist/modules/ai_assistant/events.js +8 -0
  47. package/dist/modules/ai_assistant/events.js.map +2 -2
  48. package/dist/modules/ai_assistant/i18n/de.json +74 -1
  49. package/dist/modules/ai_assistant/i18n/en.json +74 -1
  50. package/dist/modules/ai_assistant/i18n/es.json +75 -2
  51. package/dist/modules/ai_assistant/i18n/pl.json +74 -1
  52. package/dist/modules/ai_assistant/lib/agent-policy.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/agent-runtime.js +588 -23
  54. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
  55. package/dist/modules/ai_assistant/lib/agent-tools.js +6 -1
  56. package/dist/modules/ai_assistant/lib/agent-tools.js.map +2 -2
  57. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  58. package/dist/modules/ai_assistant/lib/model-factory.js +63 -22
  59. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  60. package/dist/modules/ai_assistant/lib/token-usage-recorder.js +78 -0
  61. package/dist/modules/ai_assistant/lib/token-usage-recorder.js.map +7 -0
  62. package/dist/modules/ai_assistant/lib/usage-serialization.js +33 -0
  63. package/dist/modules/ai_assistant/lib/usage-serialization.js.map +7 -0
  64. package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js +25 -0
  65. package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js.map +7 -0
  66. package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js +88 -0
  67. package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js.map +7 -0
  68. package/dist/modules/ai_assistant/setup.js +34 -0
  69. package/dist/modules/ai_assistant/setup.js.map +2 -2
  70. package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js +114 -0
  71. package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js.map +7 -0
  72. package/generated/entities/ai_agent_runtime_override/index.ts +7 -0
  73. package/generated/entities/ai_token_usage_daily/index.ts +16 -0
  74. package/generated/entities/ai_token_usage_event/index.ts +19 -0
  75. package/generated/entities.ids.generated.ts +2 -0
  76. package/generated/entity-fields-registry.ts +47 -1
  77. package/package.json +15 -7
  78. package/src/frontend/components/AiChatButton.tsx +3 -2
  79. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +521 -0
  80. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -8
  81. package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +231 -0
  82. package/src/modules/ai_assistant/__tests__/events.test.ts +4 -3
  83. package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +5 -5
  84. package/src/modules/ai_assistant/__tests__/token-usage-recorder.test.ts +109 -0
  85. package/src/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.ts +388 -0
  86. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +5 -0
  87. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +8 -7
  88. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +102 -5
  89. package/src/modules/ai_assistant/api/ai/chat/route.ts +55 -18
  90. package/src/modules/ai_assistant/api/settings/route.ts +5 -3
  91. package/src/modules/ai_assistant/api/usage/daily/__tests__/route.test.ts +159 -0
  92. package/src/modules/ai_assistant/api/usage/daily/route.ts +126 -0
  93. package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/__tests__/route.test.ts +143 -0
  94. package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/route.ts +130 -0
  95. package/src/modules/ai_assistant/api/usage/sessions/__tests__/route.test.ts +123 -0
  96. package/src/modules/ai_assistant/api/usage/sessions/route.ts +184 -0
  97. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +372 -16
  98. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +1 -4
  99. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +26 -9
  100. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.tsx +469 -0
  101. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.ts +23 -0
  102. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.tsx +12 -0
  103. package/src/modules/ai_assistant/cli.ts +18 -0
  104. package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +1 -1
  105. package/src/modules/ai_assistant/data/entities.ts +237 -0
  106. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +135 -3
  107. package/src/modules/ai_assistant/data/repositories/AiTokenUsageRepository.ts +213 -0
  108. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +223 -0
  109. package/src/modules/ai_assistant/data/repositories/__tests__/AiTokenUsageRepository.test.ts +58 -0
  110. package/src/modules/ai_assistant/events.ts +8 -0
  111. package/src/modules/ai_assistant/i18n/de.json +74 -1
  112. package/src/modules/ai_assistant/i18n/en.json +74 -1
  113. package/src/modules/ai_assistant/i18n/es.json +75 -2
  114. package/src/modules/ai_assistant/i18n/pl.json +74 -1
  115. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase0.test.ts +439 -0
  116. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase1.test.ts +243 -0
  117. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase2.test.ts +388 -0
  118. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase3.test.ts +359 -0
  119. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +2 -2
  120. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +2 -1
  121. package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +12 -13
  122. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +77 -14
  123. package/src/modules/ai_assistant/lib/agent-policy.ts +9 -0
  124. package/src/modules/ai_assistant/lib/agent-runtime.ts +1148 -43
  125. package/src/modules/ai_assistant/lib/agent-tools.ts +5 -1
  126. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +289 -2
  127. package/src/modules/ai_assistant/lib/model-factory.ts +128 -43
  128. package/src/modules/ai_assistant/lib/token-usage-recorder.ts +122 -0
  129. package/src/modules/ai_assistant/lib/usage-serialization.ts +29 -0
  130. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +791 -0
  131. package/src/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.ts +25 -0
  132. package/src/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.ts +89 -0
  133. package/src/modules/ai_assistant/setup.ts +49 -0
  134. package/src/modules/ai_assistant/workers/__tests__/ai-token-usage-prune.test.ts +144 -0
  135. package/src/modules/ai_assistant/workers/ai-token-usage-prune.ts +188 -0
@@ -0,0 +1,25 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260508160000_ai_agent_loop_overrides extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`alter table "ai_agent_runtime_overrides" add column "loop_disabled" boolean null;`);
7
+ this.addSql(`alter table "ai_agent_runtime_overrides" add column "loop_max_steps" int null;`);
8
+ this.addSql(`alter table "ai_agent_runtime_overrides" add column "loop_max_tool_calls" int null;`);
9
+ this.addSql(`alter table "ai_agent_runtime_overrides" add column "loop_max_wall_clock_ms" int null;`);
10
+ this.addSql(`alter table "ai_agent_runtime_overrides" add column "loop_max_tokens" int null;`);
11
+ this.addSql(`alter table "ai_agent_runtime_overrides" add column "loop_stop_when_json" jsonb null;`);
12
+ this.addSql(`alter table "ai_agent_runtime_overrides" add column "loop_active_tools_json" jsonb null;`);
13
+ }
14
+
15
+ override async down(): Promise<void> {
16
+ this.addSql(`alter table "ai_agent_runtime_overrides" drop column "loop_disabled";`);
17
+ this.addSql(`alter table "ai_agent_runtime_overrides" drop column "loop_max_steps";`);
18
+ this.addSql(`alter table "ai_agent_runtime_overrides" drop column "loop_max_tool_calls";`);
19
+ this.addSql(`alter table "ai_agent_runtime_overrides" drop column "loop_max_wall_clock_ms";`);
20
+ this.addSql(`alter table "ai_agent_runtime_overrides" drop column "loop_max_tokens";`);
21
+ this.addSql(`alter table "ai_agent_runtime_overrides" drop column "loop_stop_when_json";`);
22
+ this.addSql(`alter table "ai_agent_runtime_overrides" drop column "loop_active_tools_json";`);
23
+ }
24
+
25
+ }
@@ -0,0 +1,89 @@
1
+ import { Migration } from '@mikro-orm/migrations'
2
+
3
+ export class Migration20260508170000_ai_token_usage extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`
7
+ create table "ai_token_usage_events" (
8
+ "id" uuid not null default gen_random_uuid(),
9
+ "tenant_id" uuid not null,
10
+ "organization_id" uuid null,
11
+ "user_id" uuid not null,
12
+ "agent_id" text not null,
13
+ "module_id" text not null,
14
+ "session_id" uuid not null,
15
+ "turn_id" uuid not null,
16
+ "step_index" int not null,
17
+ "provider_id" text not null,
18
+ "model_id" text not null,
19
+ "input_tokens" int not null,
20
+ "output_tokens" int not null,
21
+ "cached_input_tokens" int null,
22
+ "reasoning_tokens" int null,
23
+ "finish_reason" text null,
24
+ "loop_abort_reason" text null,
25
+ "created_at" timestamptz not null,
26
+ "updated_at" timestamptz not null,
27
+ constraint "ai_token_usage_events_pkey" primary key ("id")
28
+ );
29
+ `)
30
+ this.addSql(`
31
+ create index "ai_token_usage_events_tenant_created_idx"
32
+ on "ai_token_usage_events" ("tenant_id", "created_at" desc);
33
+ `)
34
+ this.addSql(`
35
+ create index "ai_token_usage_events_tenant_agent_created_idx"
36
+ on "ai_token_usage_events" ("tenant_id", "agent_id", "created_at" desc);
37
+ `)
38
+ this.addSql(`
39
+ create index "ai_token_usage_events_tenant_model_created_idx"
40
+ on "ai_token_usage_events" ("tenant_id", "model_id", "created_at" desc);
41
+ `)
42
+ this.addSql(`
43
+ create index "ai_token_usage_events_tenant_session_turn_step_idx"
44
+ on "ai_token_usage_events" ("tenant_id", "session_id", "turn_id", "step_index");
45
+ `)
46
+
47
+ this.addSql(`
48
+ create table "ai_token_usage_daily" (
49
+ "id" uuid not null default gen_random_uuid(),
50
+ "tenant_id" uuid not null,
51
+ "organization_id" uuid null,
52
+ "day" date not null,
53
+ "agent_id" text not null,
54
+ "model_id" text not null,
55
+ "provider_id" text not null,
56
+ "input_tokens" bigint not null default 0,
57
+ "output_tokens" bigint not null default 0,
58
+ "cached_input_tokens" bigint not null default 0,
59
+ "reasoning_tokens" bigint not null default 0,
60
+ "step_count" bigint not null default 0,
61
+ "turn_count" bigint not null default 0,
62
+ "session_count" bigint not null default 0,
63
+ "created_at" timestamptz not null,
64
+ "updated_at" timestamptz not null,
65
+ constraint "ai_token_usage_daily_pkey" primary key ("id")
66
+ );
67
+ `)
68
+ this.addSql(`
69
+ create unique index "ai_token_usage_daily_tenant_day_agent_model_org_uq"
70
+ on "ai_token_usage_daily" ("tenant_id", "day", "agent_id", "model_id", "organization_id")
71
+ where "organization_id" is not null;
72
+ `)
73
+ this.addSql(`
74
+ create unique index "ai_token_usage_daily_tenant_day_agent_model_null_org_uq"
75
+ on "ai_token_usage_daily" ("tenant_id", "day", "agent_id", "model_id")
76
+ where "organization_id" is null;
77
+ `)
78
+ this.addSql(`
79
+ create index "ai_token_usage_daily_tenant_day_idx"
80
+ on "ai_token_usage_daily" ("tenant_id", "day");
81
+ `)
82
+ }
83
+
84
+ override async down(): Promise<void> {
85
+ this.addSql(`drop table if exists "ai_token_usage_events" cascade;`)
86
+ this.addSql(`drop table if exists "ai_token_usage_daily" cascade;`)
87
+ }
88
+
89
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
2
 
3
3
  const PENDING_ACTION_CLEANUP_SCHEDULE_ID = 'ai_assistant:pending-action-cleanup'
4
+ const TOKEN_USAGE_PRUNE_SCHEDULE_ID = 'ai_assistant:token-usage-prune'
4
5
 
5
6
  /**
6
7
  * System-scoped recurring schedule: every 5 minutes, enqueue a job to the
@@ -49,6 +50,53 @@ async function ensurePendingActionCleanupSchedule(
49
50
  }
50
51
  }
51
52
 
53
+ /**
54
+ * System-scoped daily schedule: enqueue a job to the `ai-token-usage-prune`
55
+ * queue to prune events older than the retention window and reconcile the
56
+ * daily rollup session counts.
57
+ *
58
+ * Phase 6.4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
59
+ */
60
+ async function ensureTokenUsagePruneSchedule(
61
+ container: import('awilix').AwilixContainer | undefined,
62
+ ): Promise<void> {
63
+ if (!container) return
64
+ let schedulerService:
65
+ | {
66
+ register: (registration: Record<string, unknown>) => Promise<void>
67
+ }
68
+ | undefined
69
+ try {
70
+ schedulerService = container.resolve('schedulerService')
71
+ } catch {
72
+ schedulerService = undefined
73
+ }
74
+ if (!schedulerService) return
75
+ try {
76
+ await schedulerService.register({
77
+ id: TOKEN_USAGE_PRUNE_SCHEDULE_ID,
78
+ name: 'AI token-usage prune',
79
+ description:
80
+ 'Delete ai_token_usage_events rows older than AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS (default 90) and reconcile session_count on the daily rollup.',
81
+ scopeType: 'system',
82
+ scheduleType: 'interval',
83
+ scheduleValue: '24h',
84
+ timezone: 'UTC',
85
+ targetType: 'queue',
86
+ targetQueue: 'ai-token-usage-prune',
87
+ targetPayload: {},
88
+ sourceType: 'module',
89
+ sourceModule: 'ai_assistant',
90
+ isEnabled: true,
91
+ })
92
+ } catch (error) {
93
+ console.warn(
94
+ '[ai_assistant] Failed to register token-usage prune schedule:',
95
+ error instanceof Error ? error.message : error,
96
+ )
97
+ }
98
+ }
99
+
52
100
  export const setup: ModuleSetupConfig = {
53
101
  defaultRoleFeatures: {
54
102
  admin: [
@@ -64,6 +112,7 @@ export const setup: ModuleSetupConfig = {
64
112
 
65
113
  async seedDefaults({ container }) {
66
114
  await ensurePendingActionCleanupSchedule(container)
115
+ await ensureTokenUsagePruneSchedule(container)
67
116
  },
68
117
  }
69
118
 
@@ -0,0 +1,144 @@
1
+ import { runTokenUsagePrune } from '../ai-token-usage-prune'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+
4
+ type ExecuteResult = { affectedRows?: number; rowCount?: number }
5
+ type ConnectionExecuteSpy = jest.Mock<Promise<unknown[]> | Promise<ExecuteResult>>
6
+
7
+ interface ConnectionStub {
8
+ execute: ConnectionExecuteSpy
9
+ }
10
+
11
+ function makeEmStub(connectionStub: ConnectionStub) {
12
+ return {
13
+ getConnection: () => connectionStub,
14
+ } as unknown as EntityManager
15
+ }
16
+
17
+ function makeConnectionStub(options: {
18
+ deleteBatches?: number[]
19
+ dailyRows?: Array<Record<string, unknown>>
20
+ }): ConnectionStub {
21
+ const { deleteBatches = [], dailyRows = [] } = options
22
+ let deleteCallIndex = 0
23
+
24
+ const execute: ConnectionExecuteSpy = jest.fn(async (sql: string) => {
25
+ const normalized = sql.replace(/\s+/g, ' ').trim()
26
+
27
+ if (normalized.includes('delete from ai_token_usage_events')) {
28
+ const affected = deleteBatches[deleteCallIndex] ?? 0
29
+ deleteCallIndex++
30
+ return { affectedRows: affected } as unknown as ExecuteResult
31
+ }
32
+
33
+ if (normalized.includes('from ai_token_usage_daily')) {
34
+ return dailyRows
35
+ }
36
+
37
+ if (normalized.includes('update ai_token_usage_daily')) {
38
+ return [] as unknown[]
39
+ }
40
+
41
+ return [] as unknown[]
42
+ })
43
+
44
+ return { execute }
45
+ }
46
+
47
+ describe('runTokenUsagePrune', () => {
48
+ beforeEach(() => {
49
+ jest.clearAllMocks()
50
+ })
51
+
52
+ it('returns zero counts when there are no events or daily rows', async () => {
53
+ const connection = makeConnectionStub({ deleteBatches: [0], dailyRows: [] })
54
+ const em = makeEmStub(connection)
55
+ const summary = await runTokenUsagePrune({ em, now: new Date('2026-06-01T00:00:00Z'), retentionDays: 90 })
56
+ expect(summary.eventsDeleted).toBe(0)
57
+ expect(summary.dailyRowsReconciled).toBe(0)
58
+ })
59
+
60
+ it('deletes a single partial batch and stops looping', async () => {
61
+ const connection = makeConnectionStub({ deleteBatches: [42], dailyRows: [] })
62
+ const em = makeEmStub(connection)
63
+ const summary = await runTokenUsagePrune({ em, now: new Date('2026-06-01'), retentionDays: 90, batchSize: 5000 })
64
+ expect(summary.eventsDeleted).toBe(42)
65
+ const deleteCalls = (connection.execute as jest.Mock).mock.calls.filter(([sql]: [string]) =>
66
+ sql.includes('delete from ai_token_usage_events'),
67
+ )
68
+ expect(deleteCalls).toHaveLength(1)
69
+ })
70
+
71
+ it('loops until a partial batch completes multi-batch pruning', async () => {
72
+ const connection = makeConnectionStub({ deleteBatches: [100, 100, 50], dailyRows: [] })
73
+ const em = makeEmStub(connection)
74
+ const summary = await runTokenUsagePrune({ em, now: new Date('2026-06-01'), retentionDays: 90, batchSize: 100 })
75
+ expect(summary.eventsDeleted).toBe(250)
76
+ const deleteCalls = (connection.execute as jest.Mock).mock.calls.filter(([sql]: [string]) =>
77
+ sql.includes('delete from ai_token_usage_events'),
78
+ )
79
+ expect(deleteCalls).toHaveLength(3)
80
+ })
81
+
82
+ it('reconciles daily rows that are returned by the select', async () => {
83
+ const dailyRows = [
84
+ { id: 'row-1', computed_session_count: '3' },
85
+ { id: 'row-2', computed_session_count: 7 },
86
+ ]
87
+ const connection = makeConnectionStub({ deleteBatches: [0], dailyRows })
88
+ const em = makeEmStub(connection)
89
+ const summary = await runTokenUsagePrune({ em, now: new Date('2026-06-01'), retentionDays: 90 })
90
+ expect(summary.dailyRowsReconciled).toBe(2)
91
+ const updateCalls = (connection.execute as jest.Mock).mock.calls.filter(([sql]: [string]) =>
92
+ sql.includes('update ai_token_usage_daily'),
93
+ )
94
+ expect(updateCalls).toHaveLength(2)
95
+ })
96
+
97
+ it('does not throw when the delete query fails — returns zero deleted', async () => {
98
+ const badConnection: ConnectionStub = {
99
+ execute: jest.fn(async (sql: string) => {
100
+ if (sql.includes('delete from ai_token_usage_events')) {
101
+ throw new Error('DB connection error')
102
+ }
103
+ return []
104
+ }) as unknown as ConnectionExecuteSpy,
105
+ }
106
+ const em = makeEmStub(badConnection)
107
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
108
+ const summary = await runTokenUsagePrune({ em, now: new Date('2026-06-01'), retentionDays: 90 })
109
+ expect(summary.eventsDeleted).toBe(0)
110
+ consoleSpy.mockRestore()
111
+ })
112
+
113
+ it('does not throw when the reconcile query fails — returns zero reconciled', async () => {
114
+ const badConnection: ConnectionStub = {
115
+ execute: jest.fn(async (sql: string) => {
116
+ if (sql.includes('delete from ai_token_usage_events')) {
117
+ return [{ affectedRows: 0 }]
118
+ }
119
+ throw new Error('Reconcile error')
120
+ }) as unknown as ConnectionExecuteSpy,
121
+ }
122
+ const em = makeEmStub(badConnection)
123
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
124
+ const summary = await runTokenUsagePrune({ em, now: new Date('2026-06-01'), retentionDays: 90 })
125
+ expect(summary.dailyRowsReconciled).toBe(0)
126
+ consoleSpy.mockRestore()
127
+ })
128
+
129
+ it('uses AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS env var when retentionDays is not supplied', async () => {
130
+ const original = process.env.AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS
131
+ process.env.AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS = '30'
132
+ const connection = makeConnectionStub({ deleteBatches: [0], dailyRows: [] })
133
+ const em = makeEmStub(connection)
134
+ await runTokenUsagePrune({ em, now: new Date('2026-06-01') })
135
+ const deleteCall = (connection.execute as jest.Mock).mock.calls.find(([sql]: [string]) =>
136
+ sql.includes('delete from ai_token_usage_events'),
137
+ )
138
+ expect(deleteCall).toBeDefined()
139
+ const cutoffParam = deleteCall![1][0] as Date
140
+ const cutoffIso = cutoffParam.toISOString().slice(0, 10)
141
+ expect(cutoffIso).toBe('2026-05-02')
142
+ process.env.AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS = original
143
+ })
144
+ })
@@ -0,0 +1,188 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
3
+
4
+ /** Default retention window for the ai_token_usage_events table (days). */
5
+ const DEFAULT_RETENTION_DAYS = 90
6
+ /** Batch size for delete operations to avoid long locks. */
7
+ const DELETE_BATCH_SIZE = 5_000
8
+ /** Number of trailing days to reconcile session_count against events table. */
9
+ const RECONCILE_TRAILING_DAYS = 7
10
+
11
+ export const metadata: WorkerMeta = {
12
+ queue: 'ai-token-usage-prune',
13
+ id: 'ai_assistant:token-usage-prune',
14
+ concurrency: 1,
15
+ }
16
+
17
+ export interface TokenUsagePruneRunOptions {
18
+ em: EntityManager
19
+ /** Override for deterministic tests. Defaults to `new Date()`. */
20
+ now?: Date
21
+ /** Override retention days (default from env). */
22
+ retentionDays?: number
23
+ /** Override batch size (default 5000). */
24
+ batchSize?: number
25
+ }
26
+
27
+ export interface TokenUsagePruneSummary {
28
+ eventsDeleted: number
29
+ dailyRowsReconciled: number
30
+ }
31
+
32
+ /**
33
+ * Reads `AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS` env var (default 90).
34
+ */
35
+ function resolveRetentionDays(): number {
36
+ const raw = process.env.AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS
37
+ if (!raw) return DEFAULT_RETENTION_DAYS
38
+ const parsed = parseInt(raw.trim(), 10)
39
+ return !isNaN(parsed) && parsed > 0 ? parsed : DEFAULT_RETENTION_DAYS
40
+ }
41
+
42
+ /**
43
+ * Deletes events older than the retention cutoff in batches of `batchSize` to
44
+ * avoid long table locks. Returns total rows deleted.
45
+ */
46
+ async function pruneOldEvents(
47
+ connection: ReturnType<EntityManager['getConnection']>,
48
+ cutoff: Date,
49
+ batchSize: number,
50
+ ): Promise<number> {
51
+ let totalDeleted = 0
52
+ for (;;) {
53
+ const result = await connection.execute(
54
+ `
55
+ delete from ai_token_usage_events
56
+ where id in (
57
+ select id from ai_token_usage_events
58
+ where created_at < ?
59
+ limit ?
60
+ )
61
+ `,
62
+ [cutoff, batchSize],
63
+ 'run',
64
+ ) as { affectedRows?: number; rowCount?: number } | undefined
65
+ const deleted = result?.affectedRows ?? result?.rowCount ?? 0
66
+ totalDeleted += deleted
67
+ if (deleted < batchSize) break
68
+ }
69
+ return totalDeleted
70
+ }
71
+
72
+ /**
73
+ * Reconciles `session_count` on daily rollup rows for the trailing N days by
74
+ * recomputing it directly from the events table. This corrects any drift caused
75
+ * by out-of-order event delivery, retention pruning, or failed incremental
76
+ * writes.
77
+ */
78
+ async function reconcileSessionCounts(
79
+ connection: ReturnType<EntityManager['getConnection']>,
80
+ now: Date,
81
+ trailingDays: number,
82
+ ): Promise<number> {
83
+ const trailingStart = new Date(now)
84
+ trailingStart.setUTCDate(trailingStart.getUTCDate() - trailingDays)
85
+ const from = trailingStart.toISOString().slice(0, 10)
86
+
87
+ // Recompute session_count for each (tenant_id, day, agent_id, model_id, org)
88
+ // combination by counting distinct session_ids from the events table.
89
+ const rows = await connection.execute(
90
+ `
91
+ select
92
+ d.id,
93
+ count(distinct e.session_id)::bigint as computed_session_count
94
+ from ai_token_usage_daily d
95
+ left join ai_token_usage_events e
96
+ on e.tenant_id = d.tenant_id
97
+ and e.agent_id = d.agent_id
98
+ and e.model_id = d.model_id
99
+ and date_trunc('day', e.created_at)::date = d.day
100
+ and (
101
+ (d.organization_id is null and e.organization_id is null)
102
+ or (d.organization_id is not null and e.organization_id = d.organization_id)
103
+ )
104
+ where d.day >= ?::date
105
+ group by d.id
106
+ `,
107
+ [from],
108
+ 'all',
109
+ )
110
+
111
+ if (!Array.isArray(rows) || rows.length === 0) return 0
112
+
113
+ let reconciled = 0
114
+ for (const row of rows as Array<Record<string, unknown>>) {
115
+ const rowId = row.id as string
116
+ const computed = typeof row.computed_session_count === 'string'
117
+ ? parseInt(row.computed_session_count, 10)
118
+ : (row.computed_session_count as number) ?? 0
119
+ await connection.execute(
120
+ `update ai_token_usage_daily set session_count = ?, updated_at = now() where id = ?`,
121
+ [computed, rowId],
122
+ 'run',
123
+ )
124
+ reconciled += 1
125
+ }
126
+
127
+ return reconciled
128
+ }
129
+
130
+ /**
131
+ * Core logic for the token-usage prune worker. Exported for unit testing.
132
+ *
133
+ * 1. Resolves the retention cutoff from `AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS`.
134
+ * 2. Deletes events older than the cutoff in batches of 5_000.
135
+ * 3. Reconciles `session_count` on the daily rollup for trailing 7 days.
136
+ *
137
+ * Phase 6.4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
138
+ */
139
+ export async function runTokenUsagePrune(
140
+ options: TokenUsagePruneRunOptions,
141
+ ): Promise<TokenUsagePruneSummary> {
142
+ const now = options.now ?? new Date()
143
+ const retentionDays = options.retentionDays ?? resolveRetentionDays()
144
+ const batchSize = options.batchSize ?? DELETE_BATCH_SIZE
145
+
146
+ const cutoff = new Date(now)
147
+ cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays)
148
+
149
+ const connection = options.em.getConnection()
150
+
151
+ let eventsDeleted = 0
152
+ try {
153
+ eventsDeleted = await pruneOldEvents(connection, cutoff, batchSize)
154
+ } catch (error) {
155
+ console.error(
156
+ '[ai-token-usage-prune] Failed to prune old events:',
157
+ error instanceof Error ? error.message : error,
158
+ )
159
+ }
160
+
161
+ let dailyRowsReconciled = 0
162
+ try {
163
+ dailyRowsReconciled = await reconcileSessionCounts(connection, now, RECONCILE_TRAILING_DAYS)
164
+ } catch (error) {
165
+ console.error(
166
+ '[ai-token-usage-prune] Failed to reconcile session counts:',
167
+ error instanceof Error ? error.message : error,
168
+ )
169
+ }
170
+
171
+ console.info(
172
+ `[ai-token-usage-prune] Done. eventsDeleted=${eventsDeleted}, dailyRowsReconciled=${dailyRowsReconciled}, retentionDays=${retentionDays}.`,
173
+ )
174
+
175
+ return { eventsDeleted, dailyRowsReconciled }
176
+ }
177
+
178
+ type HandlerContext = JobContext & {
179
+ resolve: <T = unknown>(name: string) => T
180
+ }
181
+
182
+ export default async function handle(
183
+ _job: QueuedJob,
184
+ ctx: HandlerContext,
185
+ ): Promise<void> {
186
+ const em = ctx.resolve<EntityManager>('em')
187
+ await runTokenUsagePrune({ em })
188
+ }