@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +30 -4
- package/dist/frontend/components/AiChatButton.js +3 -2
- package/dist/frontend/components/AiChatButton.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +364 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -7
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +182 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js +316 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +8 -7
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +43 -20
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/settings/route.js +4 -3
- package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/usage/daily/route.js +111 -0
- package/dist/modules/ai_assistant/api/usage/daily/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js +108 -0
- package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/usage/sessions/route.js +153 -0
- package/dist/modules/ai_assistant/api/usage/sessions/route.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +335 -38
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -7
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +44 -35
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js +282 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/cli.js +12 -0
- package/dist/modules/ai_assistant/cli.js.map +2 -2
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +1 -1
- package/dist/modules/ai_assistant/data/entities.js +177 -1
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +104 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js +168 -0
- package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js.map +7 -0
- package/dist/modules/ai_assistant/events.js +8 -0
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +74 -1
- package/dist/modules/ai_assistant/i18n/en.json +74 -1
- package/dist/modules/ai_assistant/i18n/es.json +75 -2
- package/dist/modules/ai_assistant/i18n/pl.json +74 -1
- package/dist/modules/ai_assistant/lib/agent-policy.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-runtime.js +588 -23
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
- package/dist/modules/ai_assistant/lib/agent-tools.js +6 -1
- package/dist/modules/ai_assistant/lib/agent-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-factory.js +63 -22
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/token-usage-recorder.js +78 -0
- package/dist/modules/ai_assistant/lib/token-usage-recorder.js.map +7 -0
- package/dist/modules/ai_assistant/lib/usage-serialization.js +33 -0
- package/dist/modules/ai_assistant/lib/usage-serialization.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js +25 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js +88 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js.map +7 -0
- package/dist/modules/ai_assistant/setup.js +34 -0
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js +114 -0
- package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +7 -0
- package/generated/entities/ai_token_usage_daily/index.ts +16 -0
- package/generated/entities/ai_token_usage_event/index.ts +19 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +47 -1
- package/package.json +15 -7
- package/src/frontend/components/AiChatButton.tsx +3 -2
- package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +521 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -8
- package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +231 -0
- package/src/modules/ai_assistant/__tests__/events.test.ts +4 -3
- package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +5 -5
- package/src/modules/ai_assistant/__tests__/token-usage-recorder.test.ts +109 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.ts +388 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +5 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +8 -7
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +102 -5
- package/src/modules/ai_assistant/api/ai/chat/route.ts +55 -18
- package/src/modules/ai_assistant/api/settings/route.ts +5 -3
- package/src/modules/ai_assistant/api/usage/daily/__tests__/route.test.ts +159 -0
- package/src/modules/ai_assistant/api/usage/daily/route.ts +126 -0
- package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/__tests__/route.test.ts +143 -0
- package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/route.ts +130 -0
- package/src/modules/ai_assistant/api/usage/sessions/__tests__/route.test.ts +123 -0
- package/src/modules/ai_assistant/api/usage/sessions/route.ts +184 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +372 -16
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +1 -4
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +26 -9
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.tsx +469 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.tsx +12 -0
- package/src/modules/ai_assistant/cli.ts +18 -0
- package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +1 -1
- package/src/modules/ai_assistant/data/entities.ts +237 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +135 -3
- package/src/modules/ai_assistant/data/repositories/AiTokenUsageRepository.ts +213 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +223 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiTokenUsageRepository.test.ts +58 -0
- package/src/modules/ai_assistant/events.ts +8 -0
- package/src/modules/ai_assistant/i18n/de.json +74 -1
- package/src/modules/ai_assistant/i18n/en.json +74 -1
- package/src/modules/ai_assistant/i18n/es.json +75 -2
- package/src/modules/ai_assistant/i18n/pl.json +74 -1
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase0.test.ts +439 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase1.test.ts +243 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase2.test.ts +388 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase3.test.ts +359 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +2 -2
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +2 -1
- package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +12 -13
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +77 -14
- package/src/modules/ai_assistant/lib/agent-policy.ts +9 -0
- package/src/modules/ai_assistant/lib/agent-runtime.ts +1148 -43
- package/src/modules/ai_assistant/lib/agent-tools.ts +5 -1
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +289 -2
- package/src/modules/ai_assistant/lib/model-factory.ts +128 -43
- package/src/modules/ai_assistant/lib/token-usage-recorder.ts +122 -0
- package/src/modules/ai_assistant/lib/usage-serialization.ts +29 -0
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +791 -0
- package/src/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.ts +25 -0
- package/src/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.ts +89 -0
- package/src/modules/ai_assistant/setup.ts +49 -0
- package/src/modules/ai_assistant/workers/__tests__/ai-token-usage-prune.test.ts +144 -0
- package/src/modules/ai_assistant/workers/ai-token-usage-prune.ts +188 -0
package/src/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.ts
ADDED
|
@@ -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
|
+
}
|