@circuitwall/jarela 2.3.0 → 2.5.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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +5 -5
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +4 -4
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +14 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/v1/access/[identity]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/access/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/display-filters/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/thread/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/agents/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/allowed-sites/[hostname]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/allowed-sites/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/artifacts/lifecycle/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/[remote_jid]/route.js +18 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/route.js +18 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +27 -33
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/[route_id]/route.js +28 -12
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/route.js +28 -12
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js +9 -2
- package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/route.js +19 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/dashboard/pricing/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/documents/search/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/documents/settings/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/env-sync/allowlist/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/env-sync/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/events/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/browser/activate/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/foreground/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/history/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/poll/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/progress/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/result/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/retry/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/tabs/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/harnesses/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/harnesses/default/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/harnesses/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/health/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/integrations/[name]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/callback/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/start/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/callback/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/start/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/lifecycle/adoption/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/maps/embed/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/mcp-servers/[name]/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/mcp-servers/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/memory/[namespace]/[key]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/policy/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/search/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/[name]/compact-threads/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
- package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
- package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
- package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/pending-actions/[id]/approve/route.js +27 -12
- package/.next/standalone/.next/server/app/api/v1/profile/location/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/profile/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +585 -58
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/github-copilot/auth/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/proxy-config/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/redaction/init/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/redaction/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/idle-timeout/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/pin/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/state/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/unlock/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/verify-pin/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/repos/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/repos/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/tasks/[agent_id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/tasks/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/activity/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/context-pin/route.js +12 -6
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +125 -26
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -2
- package/.next/standalone/.next/server/app/api/v1/threads/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/voice/transcribe/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/voice/tts/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/watchers/[id]/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
- package/.next/standalone/.next/server/app/page.js +27323 -27034
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page.js +8 -0
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +5 -5
- package/.next/standalone/.next/server/chunks/1388.js +106 -36
- package/.next/standalone/.next/server/chunks/2068.js +47 -10
- package/.next/standalone/.next/server/chunks/210.js +6 -6
- package/.next/standalone/.next/server/chunks/2447.js +10 -5
- package/.next/standalone/.next/server/chunks/2653.js +80 -1
- package/.next/standalone/.next/server/chunks/3042.js +10 -8
- package/.next/standalone/.next/server/chunks/{4540.js → 309.js} +104 -38
- package/.next/standalone/.next/server/chunks/{2350.js → 3272.js} +104 -37
- package/.next/standalone/.next/server/chunks/3524.js +150 -67
- package/.next/standalone/.next/server/chunks/3566.js +1 -1
- package/.next/standalone/.next/server/chunks/4189.js +1 -1
- package/.next/standalone/.next/server/chunks/4596.js +28 -4
- package/.next/standalone/.next/server/chunks/46.js +91 -31
- package/.next/standalone/.next/server/chunks/466.js +53 -7
- package/.next/standalone/.next/server/chunks/4955.js +30 -3
- package/.next/standalone/.next/server/chunks/5585.js +28 -4
- package/.next/standalone/.next/server/chunks/5908.js +63 -42
- package/.next/standalone/.next/server/chunks/6142.js +10 -2
- package/.next/standalone/.next/server/chunks/8461.js +10 -8
- package/.next/standalone/.next/server/chunks/9032.js +5 -5
- package/.next/standalone/.next/server/chunks/9164.js +487 -177
- package/.next/standalone/.next/server/chunks/9304.js +51 -12
- package/.next/standalone/.next/server/chunks/{423.js → 9590.js} +84 -24
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/middleware.js +89 -21
- package/.next/standalone/.next/server/pages/404.html +14 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/preview-props.json +3 -3
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
- package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
- package/.next/standalone/.next/static/chunks/app/{page-213a623b37226452.js → page-2ef8d75d40eb4311.js} +10643 -10295
- package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
- package/.next/standalone/docs/adoption/changesets.json +2805 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +52 -0
- package/api/client.models.test.ts +10 -0
- package/api/client.startup.test.ts +129 -0
- package/api/client.ts +51 -9
- package/api/thread-metadata.test.ts +72 -0
- package/api/types.ts +6 -3
- package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
- package/app/api/v1/providers/[provider]/models/route.ts +259 -40
- package/app/api/v1/threads/[thread_id]/route.ts +26 -23
- package/app/api/v1/threads/[thread_id]/run/route.ts +0 -1
- package/app/globals.css +119 -29
- package/app/layout.tsx +12 -0
- package/components/agents/AgentsPanel.tsx +5 -13
- package/components/chat/ChatView.tsx +9 -18
- package/components/chat/InputBar.tsx +1 -1
- package/components/chat/MessageBubble.tsx +13 -1
- package/components/chat/MessageList.test.tsx +45 -4
- package/components/chat/MessageList.tsx +123 -89
- package/components/chat/ToolList.tsx +88 -84
- package/components/chat/useThreadActivity.test.tsx +2 -2
- package/components/chat/useThreadActivity.ts +2 -2
- package/components/chat/useThreadData.ts +1 -30
- package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
- package/components/chat/useThreadMetadataPolling.ts +50 -0
- package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
- package/components/chat/useUserProfileAndAgent.ts +13 -2
- package/components/dashboard/DashboardPanel.tsx +1 -1
- package/components/dashboard/Series.tsx +34 -21
- package/components/dashboard/SummaryCards.tsx +1 -1
- package/components/integrations/NetworkSection.tsx +2 -2
- package/components/layout/AppShell.tsx +22 -7
- package/components/layout/MenuPanel.tsx +14 -24
- package/components/profile/ProfilePanel.tsx +2 -2
- package/components/settings/AppearancePanel.tsx +70 -3
- package/components/settings/SettingsPanel.tsx +2 -2
- package/components/tools/ToolsPanel.tsx +2 -7
- package/components/ui/AppearancePreferences.test.tsx +68 -0
- package/components/ui/BootScreen.test.tsx +44 -0
- package/components/ui/BootScreen.tsx +24 -11
- package/components/ui/NotificationStatus.test.tsx +2 -2
- package/components/ui/NotificationStatus.tsx +5 -5
- package/components/ui/SubTabBar.tsx +2 -3
- package/contexts/ThemeContext.tsx +84 -1
- package/hooks/useAgentSession.test.ts +12 -1
- package/hooks/useAgentSession.ts +6 -4
- package/hooks/useUrlSync.test.ts +13 -0
- package/hooks/useUrlSync.ts +0 -4
- package/lib/agents/citation-checker.test.ts +40 -2
- package/lib/agents/citation-checker.ts +56 -11
- package/lib/agents/context-boundary.ts +5 -6
- package/lib/agents/instruction-edits.test.ts +120 -0
- package/lib/agents/instruction-edits.ts +65 -42
- package/lib/agents/prepare/request.ts +0 -2
- package/lib/agents/prepare/system-prompt.test.ts +10 -0
- package/lib/agents/prepare/system-prompt.ts +8 -2
- package/lib/agents/proposals.test.ts +76 -0
- package/lib/agents/proposals.ts +13 -5
- package/lib/agents/run-thread.auto-boundary.test.ts +125 -5
- package/lib/agents/run-thread.ts +24 -9
- package/lib/agents/sqlite-checkpoint-saver.test.ts +91 -0
- package/lib/agents/warm-summary-background.ts +22 -2
- package/lib/agents/warm-topics.test.ts +155 -1
- package/lib/agents/warm-topics.ts +27 -7
- package/lib/bridges/dispatcher.ts +1 -0
- package/lib/bridges/message-role.test.ts +81 -0
- package/lib/bridges/message-role.ts +23 -10
- package/lib/bridges/types.ts +6 -0
- package/lib/bridges/whatsapp-link.test.ts +32 -0
- package/lib/bridges/whatsapp-link.ts +21 -0
- package/lib/bridges/whatsapp.inbound.test.ts +239 -0
- package/lib/bridges/whatsapp.sender.test.ts +80 -1
- package/lib/bridges/whatsapp.ts +76 -38
- package/lib/db/migrations.test.ts +61 -0
- package/lib/db/migrations.ts +13 -0
- package/lib/embeddings/performance.test.ts +114 -0
- package/lib/pricing/snapshot.test.ts +70 -0
- package/lib/pricing/snapshot.ts +10 -2
- package/lib/proxy/dispatcher.test.ts +119 -1
- package/lib/proxy/dispatcher.ts +22 -9
- package/lib/stores/agent-configs.ts +10 -5
- package/lib/stores/bridges.test.ts +81 -0
- package/lib/stores/bridges.ts +9 -8
- package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
- package/lib/stores/dashboard-metrics.ts +30 -4
- package/lib/stores/message-embedding-jobs.test.ts +27 -0
- package/lib/stores/message-embedding-jobs.ts +5 -3
- package/lib/stores/message-usage.test.ts +21 -1
- package/lib/stores/pricing-cache.test.ts +31 -0
- package/lib/stores/pricing.ts +3 -0
- package/lib/stores/proxy-config.test.ts +120 -0
- package/lib/stores/proxy-config.ts +22 -4
- package/lib/stores/threads.ts +12 -8
- package/lib/tools/communications/calendar.test.ts +43 -2
- package/lib/tools/communications/calendar.ts +2 -0
- package/lib/tools/communications/gmail.test.ts +51 -1
- package/lib/tools/communications/gmail.ts +44 -2
- package/lib/tools/communications/outlook-calendar.ts +2 -0
- package/lib/tools/communications/outlook.test.ts +33 -2
- package/lib/tools/communications/outlook.ts +2 -0
- package/lib/tools/filesystem/edit-match.test.ts +133 -0
- package/lib/tools/filesystem/edit-match.ts +7 -2
- package/lib/tools/packages/default-packages.test.ts +21 -0
- package/lib/tools/packages/optional-package.test.ts +72 -0
- package/lib/tools/packages/optional-package.ts +33 -4
- package/lib/tools/support/result-refs.test.ts +131 -0
- package/lib/tools/support/result-refs.ts +41 -9
- package/lib/ui/navigate.test.ts +13 -0
- package/lib/ui/navigate.ts +10 -2
- package/lib/utils/source-link.test.ts +51 -0
- package/lib/utils/source-link.ts +45 -0
- package/package.json +1 -1
- package/.next/standalone/.next/static/css/312542a5725ba0d5.css +0 -5
- /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → 544OFJV-q0RC6Gjgje4LT}/_ssgManifest.js +0 -0
|
@@ -31,7 +31,7 @@ vi.mock("node:child_process", async () => {
|
|
|
31
31
|
|
|
32
32
|
import { exec } from "node:child_process";
|
|
33
33
|
import { readFileSync } from "node:fs";
|
|
34
|
-
import { getProxyConfigRaw } from "@/lib/stores/proxy-config";
|
|
34
|
+
import { getProxyConfigRaw, type ProxyConfigRaw } from "@/lib/stores/proxy-config";
|
|
35
35
|
import { extractSystemKeychainCAs } from "@/lib/proxy/keychain";
|
|
36
36
|
|
|
37
37
|
const mockedGetProxyConfigRaw = vi.mocked(getProxyConfigRaw);
|
|
@@ -262,3 +262,121 @@ describe("applyProxyConfigFromDb — env override", () => {
|
|
|
262
262
|
expect(mockedExtract).not.toHaveBeenCalled();
|
|
263
263
|
});
|
|
264
264
|
});
|
|
265
|
+
|
|
266
|
+
describe("applyProxyConfigFromDb — manual mode", () => {
|
|
267
|
+
const originalEnv = { ...process.env };
|
|
268
|
+
|
|
269
|
+
function manual(overrides: Partial<ProxyConfigRaw> = {}): ProxyConfigRaw {
|
|
270
|
+
return {
|
|
271
|
+
mode: "manual",
|
|
272
|
+
scheme: "http",
|
|
273
|
+
host: "proxy.corp",
|
|
274
|
+
port: 3128,
|
|
275
|
+
username: null,
|
|
276
|
+
password: null,
|
|
277
|
+
no_proxy: null,
|
|
278
|
+
ca_bundle: null,
|
|
279
|
+
updated_at: "2026-05-22T00:00:00.000Z",
|
|
280
|
+
...overrides,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
beforeEach(() => {
|
|
285
|
+
for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "NO_PROXY", "no_proxy"]) delete process.env[key];
|
|
286
|
+
vi.resetModules();
|
|
287
|
+
vi.clearAllMocks();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
afterEach(async () => {
|
|
291
|
+
mockedGetProxyConfigRaw.mockReturnValue(null);
|
|
292
|
+
const { applyProxyConfigFromDb } = await import("./dispatcher");
|
|
293
|
+
await applyProxyConfigFromDb();
|
|
294
|
+
process.env = { ...originalEnv };
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("percent-encodes credentials in the proxy URL and redacts the password in the result", async () => {
|
|
298
|
+
mockedGetProxyConfigRaw.mockReturnValue(manual({ username: "dom\\user", password: "p@ss:w/rd" }));
|
|
299
|
+
const { applyProxyConfigFromDb } = await import("./dispatcher");
|
|
300
|
+
const r = await applyProxyConfigFromDb();
|
|
301
|
+
expect(r.source).toBe("manual");
|
|
302
|
+
expect(process.env.HTTPS_PROXY).toBe("http://dom%5Cuser:p%40ss%3Aw%2Frd@proxy.corp:3128");
|
|
303
|
+
expect(r.proxyUrl).toBe("http://dom%5Cuser:***@proxy.corp:3128/");
|
|
304
|
+
expect(r.proxyUrl).not.toContain("p%40ss");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("brackets an IPv6 proxy host so the URL is parseable", async () => {
|
|
308
|
+
mockedGetProxyConfigRaw.mockReturnValue(manual({ host: "fd00::1" }));
|
|
309
|
+
const { applyProxyConfigFromDb } = await import("./dispatcher");
|
|
310
|
+
const r = await applyProxyConfigFromDb();
|
|
311
|
+
expect(process.env.HTTPS_PROXY).toBe("http://[fd00::1]:3128");
|
|
312
|
+
expect(r.proxyUrl).toBe("http://[fd00::1]:3128/");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("always bypasses loopback, merged with the user's no_proxy list", async () => {
|
|
316
|
+
mockedGetProxyConfigRaw.mockReturnValue(manual({ no_proxy: " corp.internal, .svc " }));
|
|
317
|
+
const { applyProxyConfigFromDb } = await import("./dispatcher");
|
|
318
|
+
await applyProxyConfigFromDb();
|
|
319
|
+
const entries = (process.env.NO_PROXY ?? "").split(",");
|
|
320
|
+
expect(entries).toEqual(expect.arrayContaining(["corp.internal", ".svc", "localhost", "127.0.0.1", "::1"]));
|
|
321
|
+
expect(new Set(entries).size).toBe(entries.length);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("still reaches a local server (e.g. Ollama) directly when the proxy is unreachable", async () => {
|
|
325
|
+
const { createServer } = await import("node:http");
|
|
326
|
+
const server = createServer((_req, res) => res.end("local-ok"));
|
|
327
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
328
|
+
const { port } = server.address() as { port: number };
|
|
329
|
+
try {
|
|
330
|
+
// Port 9 (discard) on loopback refuses connections, so any request
|
|
331
|
+
// routed through this "proxy" fails.
|
|
332
|
+
mockedGetProxyConfigRaw.mockReturnValue(manual({ host: "127.0.0.1", port: 9 }));
|
|
333
|
+
const { applyProxyConfigFromDb } = await import("./dispatcher");
|
|
334
|
+
await applyProxyConfigFromDb();
|
|
335
|
+
for (const host of ["127.0.0.1", "localhost"]) {
|
|
336
|
+
const res = await fetch(`http://${host}:${port}/`);
|
|
337
|
+
expect(await res.text()).toBe("local-ok");
|
|
338
|
+
}
|
|
339
|
+
} finally {
|
|
340
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("keeps loopback direct even when a lowercase no_proxy is inherited", async () => {
|
|
345
|
+
// undici reads lowercase no_proxy before NO_PROXY.
|
|
346
|
+
process.env.no_proxy = "corp.internal";
|
|
347
|
+
const { createServer } = await import("node:http");
|
|
348
|
+
const server = createServer((_req, res) => res.end("local-ok"));
|
|
349
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
350
|
+
const { port } = server.address() as { port: number };
|
|
351
|
+
try {
|
|
352
|
+
mockedGetProxyConfigRaw.mockReturnValue(manual({ host: "127.0.0.1", port: 9 }));
|
|
353
|
+
const { applyProxyConfigFromDb } = await import("./dispatcher");
|
|
354
|
+
await applyProxyConfigFromDb();
|
|
355
|
+
const res = await fetch(`http://127.0.0.1:${port}/`);
|
|
356
|
+
expect(await res.text()).toBe("local-ok");
|
|
357
|
+
} finally {
|
|
358
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reverts to no proxy when host or port is missing", async () => {
|
|
363
|
+
mockedGetProxyConfigRaw.mockReturnValue(manual({ port: null }));
|
|
364
|
+
const { applyProxyConfigFromDb } = await import("./dispatcher");
|
|
365
|
+
const r = await applyProxyConfigFromDb();
|
|
366
|
+
expect(r.source).toBe("off");
|
|
367
|
+
expect(r.note).toMatch(/missing host\/port/);
|
|
368
|
+
expect(process.env.HTTPS_PROXY).toBeUndefined();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("clears the proxy env when switched back to off", async () => {
|
|
372
|
+
mockedGetProxyConfigRaw.mockReturnValue(manual());
|
|
373
|
+
const { applyProxyConfigFromDb } = await import("./dispatcher");
|
|
374
|
+
await applyProxyConfigFromDb();
|
|
375
|
+
expect(process.env.HTTPS_PROXY).toBeDefined();
|
|
376
|
+
mockedGetProxyConfigRaw.mockReturnValue({ ...manual(), mode: "off" });
|
|
377
|
+
const r = await applyProxyConfigFromDb();
|
|
378
|
+
expect(r.source).toBe("off");
|
|
379
|
+
expect(process.env.HTTPS_PROXY).toBeUndefined();
|
|
380
|
+
expect(process.env.NO_PROXY).toBeUndefined();
|
|
381
|
+
});
|
|
382
|
+
});
|
package/lib/proxy/dispatcher.ts
CHANGED
|
@@ -215,7 +215,18 @@ function buildProxyUrl(
|
|
|
215
215
|
const auth = username
|
|
216
216
|
? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ""}@`
|
|
217
217
|
: "";
|
|
218
|
-
|
|
218
|
+
const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
219
|
+
return `${scheme}://${auth}${urlHost}:${port}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// undici's EnvHttpProxyAgent has no implicit loopback exemption, so without
|
|
223
|
+
// these a configured proxy would also receive local traffic (Ollama, the
|
|
224
|
+
// WhatsApp bridge sidecar, the app's own loopback API).
|
|
225
|
+
const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"];
|
|
226
|
+
|
|
227
|
+
function mergeNoProxy(noProxy: string | null): string {
|
|
228
|
+
const entries = (noProxy ?? "").split(/[,\s]+/).map((entry) => entry.trim()).filter(Boolean);
|
|
229
|
+
return [...new Set([...entries, ...LOOPBACK_NO_PROXY])].join(",");
|
|
219
230
|
}
|
|
220
231
|
|
|
221
232
|
function installProxy(url: string, noProxy: string | null, caBundle: string | null): void {
|
|
@@ -224,22 +235,24 @@ function installProxy(url: string, noProxy: string | null, caBundle: string | nu
|
|
|
224
235
|
// We still treat the boot-time env snapshot as authoritative for the
|
|
225
236
|
// "env beats DB" rule — this mutation only happens after we've
|
|
226
237
|
// confirmed the user did NOT set HTTP_PROXY at launch.
|
|
238
|
+
const merged = mergeNoProxy(noProxy);
|
|
227
239
|
process.env.HTTP_PROXY = url;
|
|
228
240
|
process.env.HTTPS_PROXY = url;
|
|
229
|
-
|
|
230
|
-
process.env.NO_PROXY = noProxy.trim();
|
|
231
|
-
} else {
|
|
232
|
-
delete process.env.NO_PROXY;
|
|
233
|
-
}
|
|
241
|
+
process.env.NO_PROXY = merged;
|
|
234
242
|
// ADR-0012: when the user pasted a corporate root CA, inject it on
|
|
235
243
|
// both TLS legs — the proxy hop (proxyTls) for https-scheme proxies
|
|
236
244
|
// signed by the corporate root, and the upstream tunnel (requestTls)
|
|
237
245
|
// for proxies that MITM outbound HTTPS with the same internal CA.
|
|
238
246
|
// Same blob is correct: the corporate trust chain typically signs
|
|
239
247
|
// both ends.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
248
|
+
// Pass the values explicitly too: undici prefers lowercase http_proxy /
|
|
249
|
+
// no_proxy from the environment over the uppercase ones set above.
|
|
250
|
+
const opts = {
|
|
251
|
+
httpProxy: url,
|
|
252
|
+
httpsProxy: url,
|
|
253
|
+
noProxy: merged,
|
|
254
|
+
...(caBundle ? { requestTls: { ca: caBundle }, proxyTls: { ca: caBundle } } : {}),
|
|
255
|
+
};
|
|
243
256
|
setGlobalDispatcher(new EnvHttpProxyAgent(opts));
|
|
244
257
|
}
|
|
245
258
|
|
|
@@ -253,12 +253,17 @@ function upsertAgentConfigTransaction(input: UpsertAgentInput): AgentConfigRow {
|
|
|
253
253
|
const db = getDb();
|
|
254
254
|
const existing = getAgentConfig(input.id);
|
|
255
255
|
const created_at = existing?.created_at ?? t;
|
|
256
|
-
const
|
|
256
|
+
const existingMbti = toMbti(existing?.adaptive_mbti);
|
|
257
|
+
const mbti = input.adaptive_mbti ?? existingMbti ?? "INTJ";
|
|
257
258
|
const preset = MBTI_PRESETS[mbti];
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
259
|
+
// Omitted sliders keep their tuned values; they only fall back to the
|
|
260
|
+
// preset when the type changes or the agent is new. Otherwise every
|
|
261
|
+
// partial update (approved proposals, tool edits) reset them.
|
|
262
|
+
const keepTuned = existing !== null && existing !== undefined && mbti === existingMbti;
|
|
263
|
+
const strength = input.adaptive_persona_strength ?? (keepTuned ? existing.adaptive_persona_strength : preset.strength);
|
|
264
|
+
const empathy = input.adaptive_empathy ?? (keepTuned ? existing.adaptive_empathy : preset.empathy);
|
|
265
|
+
const expressiveness = input.adaptive_expressiveness ?? (keepTuned ? existing.adaptive_expressiveness : preset.expressiveness);
|
|
266
|
+
const verbosity = input.adaptive_verbosity ?? (keepTuned ? existing.adaptive_verbosity : preset.verbosity);
|
|
262
267
|
if (input.is_default) db.prepare("UPDATE agent_configs SET is_default=0").run();
|
|
263
268
|
// harness_id: explicit `undefined` means "keep existing"; explicit `null`
|
|
264
269
|
// means "use the global default". Empty string is normalised to null too,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-bridges-"));
|
|
7
|
+
process.env.JARELA_DB_DIR = tmpRoot;
|
|
8
|
+
|
|
9
|
+
const store = await import("./bridges");
|
|
10
|
+
const { getDb } = await import("@/lib/db");
|
|
11
|
+
|
|
12
|
+
afterAll(() => {
|
|
13
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* */ }
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("bridges store", () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
const db = getDb();
|
|
19
|
+
db.exec("DELETE FROM bridge_routes; DELETE FROM bridge_ignores; DELETE FROM bridges;");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("keeps each chat's watermark monotonic and isolated from other chats", () => {
|
|
23
|
+
const b = store.createBridge({ kind: "whatsapp", name: "WA" });
|
|
24
|
+
store.createRoute({ bridge_id: b.id, remote_jid: "a@s.whatsapp.net", agent_id: "agent-1" });
|
|
25
|
+
store.createRoute({ bridge_id: b.id, remote_jid: "b@s.whatsapp.net", agent_id: "agent-2" });
|
|
26
|
+
|
|
27
|
+
store.bumpRouteLastSeenTs(b.id, "a@s.whatsapp.net", 2_000);
|
|
28
|
+
store.bumpRouteLastSeenTs(b.id, "a@s.whatsapp.net", 1_000);
|
|
29
|
+
store.bumpRouteLastSeenTs(b.id, "a@s.whatsapp.net", Number.NaN);
|
|
30
|
+
store.bumpRouteLastSeenTs(b.id, "a@s.whatsapp.net", -5);
|
|
31
|
+
store.bumpRouteLastSeenTs(b.id, "unrouted@s.whatsapp.net", 9_000);
|
|
32
|
+
|
|
33
|
+
expect(store.getRouteLastSeenTsMap(b.id)).toEqual(new Map([["a@s.whatsapp.net", 2_000]]));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("scopes watermarks to their bridge", () => {
|
|
37
|
+
const one = store.createBridge({ kind: "whatsapp", name: "one" });
|
|
38
|
+
const two = store.createBridge({ kind: "whatsapp", name: "two" });
|
|
39
|
+
store.createRoute({ bridge_id: one.id, remote_jid: "x@s.whatsapp.net", agent_id: "agent-3" });
|
|
40
|
+
store.createRoute({ bridge_id: two.id, remote_jid: "x@s.whatsapp.net", agent_id: "agent-4" });
|
|
41
|
+
store.bumpRouteLastSeenTs(one.id, "x@s.whatsapp.net", 5_000);
|
|
42
|
+
expect(store.getRouteLastSeenTsMap(two.id).size).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("deletes a bridge together with its routes and ignores", () => {
|
|
46
|
+
const b = store.createBridge({ kind: "whatsapp", name: "WA" });
|
|
47
|
+
const route = store.createRoute({ bridge_id: b.id, remote_jid: "*", agent_id: "agent-5" });
|
|
48
|
+
store.addIgnore({ bridge_id: b.id, remote_jid: "spam@s.whatsapp.net" });
|
|
49
|
+
|
|
50
|
+
expect(store.deleteBridge(b.id)).toBe(true);
|
|
51
|
+
expect(store.getRoute(route.id)).toBeNull();
|
|
52
|
+
expect(store.isIgnored(b.id, "spam@s.whatsapp.net")).toBe(false);
|
|
53
|
+
expect(store.deleteBridge(b.id)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects a duplicate ignore entry and removes only the matching chat", () => {
|
|
57
|
+
const b = store.createBridge({ kind: "whatsapp", name: "WA" });
|
|
58
|
+
store.addIgnore({ bridge_id: b.id, remote_jid: "a@s.whatsapp.net", label: "A" });
|
|
59
|
+
store.addIgnore({ bridge_id: b.id, remote_jid: "b@s.whatsapp.net" });
|
|
60
|
+
expect(() => store.addIgnore({ bridge_id: b.id, remote_jid: "a@s.whatsapp.net" })).toThrow(/UNIQUE/);
|
|
61
|
+
|
|
62
|
+
expect(store.removeIgnore(b.id, "a@s.whatsapp.net")).toBe(true);
|
|
63
|
+
expect(store.removeIgnore(b.id, "a@s.whatsapp.net")).toBe(false);
|
|
64
|
+
expect(store.listIgnores(b.id).map((i) => i.remote_jid)).toEqual(["b@s.whatsapp.net"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("applies partial updates without clobbering nullable fields", () => {
|
|
68
|
+
const b = store.createBridge({ kind: "whatsapp", name: "WA" });
|
|
69
|
+
store.updateBridge(b.id, { last_error: "boom", paired_id: "me" });
|
|
70
|
+
const updated = store.updateBridge(b.id, { status: "connected", forward_group_profile_updates: 0 });
|
|
71
|
+
expect(updated).toMatchObject({ last_error: "boom", paired_id: "me", status: "connected", forward_group_profile_updates: 0 });
|
|
72
|
+
expect(store.updateBridge(b.id, { last_error: null })?.last_error).toBeNull();
|
|
73
|
+
expect(store.getBridge(b.id)).toMatchObject({ last_error: null, paired_id: "me", forward_group_participants_updates: 1 });
|
|
74
|
+
expect(store.updateBridge("missing", { name: "x" })).toBeNull();
|
|
75
|
+
|
|
76
|
+
const route = store.createRoute({ bridge_id: b.id, remote_jid: "a@s.whatsapp.net", agent_id: "agent-6", label: "A", silent_mode: true });
|
|
77
|
+
expect(store.updateRoute(route.id, { respond_to: "user" })).toMatchObject({ label: "A", silent_mode: 1, respond_to: "user" });
|
|
78
|
+
expect(store.updateRoute(route.id, { silent_mode: false, label: null })).toMatchObject({ label: null, silent_mode: 0 });
|
|
79
|
+
expect(store.findRoute(b.id, "a@s.whatsapp.net")).toMatchObject({ silent_mode: 0, respond_to: "user", label: null });
|
|
80
|
+
});
|
|
81
|
+
});
|
package/lib/stores/bridges.ts
CHANGED
|
@@ -263,14 +263,15 @@ export function bumpRouteLastSeenTs(bridgeId: string, remoteJid: string, ts: num
|
|
|
263
263
|
.run(ts, bridgeId, remoteJid, ts);
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
.
|
|
273
|
-
|
|
266
|
+
// Per-chat watermarks for every explicitly routed chat on a bridge, loaded
|
|
267
|
+
// once per upsert batch. Each chat is gated only by its own progress: a
|
|
268
|
+
// bridge-wide maximum would drop chat B's offline backlog as soon as a newer
|
|
269
|
+
// message from chat A had been delivered.
|
|
270
|
+
export function getRouteLastSeenTsMap(bridgeId: string): Map<string, number> {
|
|
271
|
+
const rows = getDb()
|
|
272
|
+
.prepare("SELECT remote_jid, last_seen_ts FROM bridge_routes WHERE bridge_id=? AND last_seen_ts IS NOT NULL")
|
|
273
|
+
.all(bridgeId) as Array<{ remote_jid: string; last_seen_ts: number }>;
|
|
274
|
+
return new Map(rows.map((r) => [r.remote_jid, r.last_seen_ts]));
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-dashboard-cache-"));
|
|
8
|
+
process.env.JARELA_DB_DIR = tmpRoot;
|
|
9
|
+
const { getDb, closeDb, DB_PATH } = await import("@/lib/db");
|
|
10
|
+
const { getDashboardMetrics } = await import("./dashboard-metrics");
|
|
11
|
+
const { recordMessageUsage } = await import("./message-usage");
|
|
12
|
+
const { getPricingTables } = await import("./pricing");
|
|
13
|
+
const pricing = await import("./pricing");
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
const db = getDb();
|
|
17
|
+
db.exec("DELETE FROM message_usage; DELETE FROM messages; DELETE FROM threads;");
|
|
18
|
+
const now = new Date().toISOString();
|
|
19
|
+
db.prepare("INSERT INTO threads(thread_id, agent_id, created_at, updated_at) VALUES (?,?,?,?)")
|
|
20
|
+
.run("t1", "a1", now, now);
|
|
21
|
+
db.prepare("INSERT INTO messages(msg_id, thread_id, role, content, created_at) VALUES (?,?,?,?,?)")
|
|
22
|
+
.run("m1", "t1", "assistant", " legacy estimate ", now);
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.useRealTimers();
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
afterAll(() => {
|
|
29
|
+
closeDb();
|
|
30
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("dashboard metrics cache", () => {
|
|
34
|
+
it("reuses a result only while the data, pricing, and window are unchanged", async () => {
|
|
35
|
+
const first = await getDashboardMetrics(30);
|
|
36
|
+
expect(await getDashboardMetrics(30)).toBe(first);
|
|
37
|
+
expect(first.summary.output_tokens_est).toBe(4);
|
|
38
|
+
expect(await getDashboardMetrics(7)).not.toBe(first);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("invalidates immediately after local writes", async () => {
|
|
42
|
+
const first = await getDashboardMetrics();
|
|
43
|
+
getDb().prepare("UPDATE messages SET content=? WHERE msg_id=?").run("a".repeat(100), "m1");
|
|
44
|
+
const next = await getDashboardMetrics();
|
|
45
|
+
expect(next).not.toBe(first);
|
|
46
|
+
expect(next.summary.output_tokens_est).toBe(25);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("invalidates for writes through another connection", async () => {
|
|
50
|
+
const first = await getDashboardMetrics();
|
|
51
|
+
const writer = new DatabaseSync(DB_PATH);
|
|
52
|
+
try {
|
|
53
|
+
writer.prepare("UPDATE messages SET content=? WHERE msg_id=?").run("b".repeat(80), "m1");
|
|
54
|
+
const next = await getDashboardMetrics();
|
|
55
|
+
expect(next).not.toBe(first);
|
|
56
|
+
expect(next.summary.output_tokens_est).toBe(20);
|
|
57
|
+
} finally {
|
|
58
|
+
writer.close();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("expires after five seconds and across UTC day boundaries", async () => {
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
vi.setSystemTime(new Date("2026-10-18T23:59:59.000Z"));
|
|
65
|
+
const first = await getDashboardMetrics();
|
|
66
|
+
vi.setSystemTime(new Date("2026-10-19T00:00:00.000Z"));
|
|
67
|
+
const nextDay = await getDashboardMetrics();
|
|
68
|
+
expect(nextDay).not.toBe(first);
|
|
69
|
+
vi.advanceTimersByTime(5000);
|
|
70
|
+
expect(await getDashboardMetrics()).not.toBe(nextDay);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("invalidates when pricing tables change", async () => {
|
|
74
|
+
const first = await getDashboardMetrics();
|
|
75
|
+
const tables = getPricingTables();
|
|
76
|
+
vi.spyOn(pricing, "getPricingTables").mockReturnValue({ ...tables });
|
|
77
|
+
expect(await getDashboardMetrics()).not.toBe(first);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("preserves snapshot tokens, costs, and tool counts without needing the body", async () => {
|
|
81
|
+
getDb().prepare("UPDATE messages SET content=?, tool_events=? WHERE msg_id='m1'")
|
|
82
|
+
.run("x".repeat(100_000), JSON.stringify([
|
|
83
|
+
{ id: "call1", phase: "call", name: "test", payload: {} },
|
|
84
|
+
{ id: "call1", phase: "result", name: "test", payload: { ok: true } },
|
|
85
|
+
]));
|
|
86
|
+
recordMessageUsage({
|
|
87
|
+
message_id: "m1", thread_id: "t1", agent_id: "a1", agent_name: "Synthetic",
|
|
88
|
+
provider: "synthetic", model_id: "synthetic", model_config_name: null,
|
|
89
|
+
input_tokens: 100, output_tokens: 20, input_rate_usd_per_mtok: null,
|
|
90
|
+
output_rate_usd_per_mtok: null, cost_usd: 0.5,
|
|
91
|
+
});
|
|
92
|
+
const data = await getDashboardMetrics();
|
|
93
|
+
expect(data.summary).toMatchObject({
|
|
94
|
+
input_tokens_est: 100, output_tokens_est: 20,
|
|
95
|
+
recorded_cost_usd: 0.5, tool_calls: 1, tool_successes: 1,
|
|
96
|
+
});
|
|
97
|
+
expect(data.summary.data_quality.measured_messages).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -12,6 +12,16 @@ import {
|
|
|
12
12
|
|
|
13
13
|
const CHARS_PER_TOKEN = 4;
|
|
14
14
|
const DEFAULT_WINDOW_DAYS = 30;
|
|
15
|
+
const METRICS_CACHE_MS = 5_000;
|
|
16
|
+
|
|
17
|
+
let metricsCache: {
|
|
18
|
+
db: ReturnType<typeof getDb>;
|
|
19
|
+
changes: number;
|
|
20
|
+
dataVersion: number;
|
|
21
|
+
tables: PricingTables;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
metrics: DashboardMetrics;
|
|
24
|
+
} | null = null;
|
|
15
25
|
|
|
16
26
|
export interface DashboardTierTokens {
|
|
17
27
|
hot_tokens: number;
|
|
@@ -256,10 +266,25 @@ export async function getDashboardMetrics(days = DEFAULT_WINDOW_DAYS): Promise<D
|
|
|
256
266
|
const now = new Date();
|
|
257
267
|
const boundedDays = Number.isFinite(days) ? Math.min(120, Math.max(7, Math.floor(days))) : DEFAULT_WINDOW_DAYS;
|
|
258
268
|
const since = new Date(now.getTime() - (boundedDays * 24 * 60 * 60 * 1000)).toISOString();
|
|
269
|
+
const db = getDb();
|
|
270
|
+
const tables = getPricingTables();
|
|
271
|
+
// total_changes covers this connection; data_version covers other writers.
|
|
272
|
+
const { changes } = db.prepare("SELECT total_changes() AS changes").get() as { changes: number };
|
|
273
|
+
const { data_version: dataVersion } = db.prepare("PRAGMA data_version").get() as { data_version: number };
|
|
274
|
+
if (metricsCache?.db === db && metricsCache.changes === changes
|
|
275
|
+
&& metricsCache.dataVersion === dataVersion && metricsCache.tables === tables
|
|
276
|
+
&& metricsCache.metrics.days === boundedDays
|
|
277
|
+
&& metricsCache.metrics.generated_at.slice(0, 10) === now.toISOString().slice(0, 10)
|
|
278
|
+
&& now.getTime() - metricsCache.createdAt >= 0
|
|
279
|
+
&& now.getTime() - metricsCache.createdAt < METRICS_CACHE_MS) {
|
|
280
|
+
return metricsCache.metrics;
|
|
281
|
+
}
|
|
259
282
|
|
|
260
|
-
const usageRows =
|
|
283
|
+
const usageRows = db
|
|
261
284
|
.prepare(
|
|
262
|
-
`SELECT m.created_at, m.role,
|
|
285
|
+
`SELECT m.created_at, m.role,
|
|
286
|
+
CASE WHEN mu.input_tokens IS NULL THEN m.content ELSE '' END AS content,
|
|
287
|
+
m.tool_events, m.thread_id, t.agent_id,
|
|
263
288
|
a.name AS agent_name, mc.provider AS provider,
|
|
264
289
|
mc.model_id AS model_id,
|
|
265
290
|
COALESCE(ta.model_config_name, a.model_config_name, dmc.name) AS model_config_name,
|
|
@@ -313,7 +338,6 @@ export async function getDashboardMetrics(days = DEFAULT_WINDOW_DAYS): Promise<D
|
|
|
313
338
|
}
|
|
314
339
|
}
|
|
315
340
|
|
|
316
|
-
const tables = getPricingTables();
|
|
317
341
|
const { byProvider, generatedAt } = tables;
|
|
318
342
|
const dayMap = seedDayBuckets(now, boundedDays);
|
|
319
343
|
const agentMap = new Map<string, AgentBucket>();
|
|
@@ -645,7 +669,7 @@ export async function getDashboardMetrics(days = DEFAULT_WINDOW_DAYS): Promise<D
|
|
|
645
669
|
};
|
|
646
670
|
}
|
|
647
671
|
|
|
648
|
-
|
|
672
|
+
const metrics: DashboardMetrics = {
|
|
649
673
|
generated_at: now.toISOString(),
|
|
650
674
|
days: boundedDays,
|
|
651
675
|
summary: {
|
|
@@ -701,6 +725,8 @@ export async function getDashboardMetrics(days = DEFAULT_WINDOW_DAYS): Promise<D
|
|
|
701
725
|
notes: "Dashboard cost is recalculated from captured provider token usage and the latest matching rates. The original per-turn cost snapshots remain unchanged; legacy rows use content-length token estimates.",
|
|
702
726
|
},
|
|
703
727
|
};
|
|
728
|
+
metricsCache = { db, changes, dataVersion, tables, createdAt: now.getTime(), metrics };
|
|
729
|
+
return metrics;
|
|
704
730
|
}
|
|
705
731
|
|
|
706
732
|
function seedDayBuckets(now: Date, days: number): Map<string, DayBucket> {
|
|
@@ -87,6 +87,33 @@ describe("message embedding jobs", () => {
|
|
|
87
87
|
expect(nextMessageEmbeddingDelayMs(currentTime)).toBeGreaterThan(0);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
it("finds the earliest pending deadline or processing lease without including terminal jobs", () => {
|
|
91
|
+
const nowMs = Date.now();
|
|
92
|
+
expect(nextMessageEmbeddingDelayMs(nowMs)).toBeNull();
|
|
93
|
+
for (const id of ["pending", "processing", "done", "no-lease"]) {
|
|
94
|
+
addMessage(id, "synthetic embedding input");
|
|
95
|
+
enqueueMessageEmbeddingJob(id, "synthetic embedding input");
|
|
96
|
+
}
|
|
97
|
+
const db = getDb();
|
|
98
|
+
db.prepare("UPDATE message_embedding_jobs SET available_at_ms=? WHERE message_id='pending'").run(nowMs + 3000);
|
|
99
|
+
db.prepare("UPDATE message_embedding_jobs SET state='processing',lease_until_ms=? WHERE message_id='processing'")
|
|
100
|
+
.run(nowMs + 1000);
|
|
101
|
+
db.exec("UPDATE message_embedding_jobs SET state='done',available_at_ms=0 WHERE message_id='done'");
|
|
102
|
+
db.exec("UPDATE message_embedding_jobs SET state='processing',lease_until_ms=NULL WHERE message_id='no-lease'");
|
|
103
|
+
expect(nextMessageEmbeddingDelayMs(nowMs)).toBe(1000);
|
|
104
|
+
db.exec("DELETE FROM message_embedding_jobs WHERE message_id='processing'");
|
|
105
|
+
expect(nextMessageEmbeddingDelayMs(nowMs)).toBe(3000);
|
|
106
|
+
db.exec("DELETE FROM message_embedding_jobs WHERE message_id='pending'");
|
|
107
|
+
expect(nextMessageEmbeddingDelayMs(nowMs)).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns zero for an already-expired processing lease", () => {
|
|
111
|
+
addMessage("expired", "synthetic expired embedding input");
|
|
112
|
+
enqueueMessageEmbeddingJob("expired", "synthetic expired embedding input");
|
|
113
|
+
getDb().exec("UPDATE message_embedding_jobs SET state='processing',lease_until_ms=1 WHERE message_id='expired'");
|
|
114
|
+
expect(nextMessageEmbeddingDelayMs(100)).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
90
117
|
it("cascades pending work when its source message is deleted", () => {
|
|
91
118
|
addMessage("msg-4", "message pending embedding");
|
|
92
119
|
enqueueMessageEmbeddingJob("msg-4", "message pending embedding");
|
|
@@ -102,9 +102,11 @@ export function claimMessageEmbeddingJobs(
|
|
|
102
102
|
|
|
103
103
|
export function nextMessageEmbeddingDelayMs(nowMs = Date.now()): number | null {
|
|
104
104
|
const row = getDb().prepare(
|
|
105
|
-
`SELECT MIN(
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
`SELECT MIN(next_at_ms) AS next_at_ms FROM (
|
|
106
|
+
SELECT MIN(available_at_ms) AS next_at_ms FROM message_embedding_jobs WHERE state='pending'
|
|
107
|
+
UNION ALL
|
|
108
|
+
SELECT MIN(lease_until_ms) AS next_at_ms FROM message_embedding_jobs WHERE state='processing'
|
|
109
|
+
)`,
|
|
108
110
|
).get() as { next_at_ms?: number | null } | undefined;
|
|
109
111
|
return typeof row?.next_at_ms === "number" ? Math.max(0, row.next_at_ms - nowMs) : null;
|
|
110
112
|
}
|
|
@@ -6,7 +6,8 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-message-usage-"));
|
|
7
7
|
process.env.JARELA_DB_DIR = tmpRoot;
|
|
8
8
|
|
|
9
|
-
const { recordMessageUsage, getMessageUsageByIds } = await import("./message-usage");
|
|
9
|
+
const { recordMessageUsage, getMessageUsageByIds, getLatestMessageUsageForThread } = await import("./message-usage");
|
|
10
|
+
const { getDb } = await import("@/lib/db");
|
|
10
11
|
const getMessageUsage = (id: string) => getMessageUsageByIds([id]).get(id) ?? null;
|
|
11
12
|
|
|
12
13
|
afterAll(() => {
|
|
@@ -14,6 +15,25 @@ afterAll(() => {
|
|
|
14
15
|
});
|
|
15
16
|
|
|
16
17
|
describe("message_usage snapshot store (ADR-0041)", () => {
|
|
18
|
+
it("returns the latest usage for only the requested thread", () => {
|
|
19
|
+
const db = getDb();
|
|
20
|
+
for (const [id, thread, timestamp] of [
|
|
21
|
+
["ordered-old", "ordered-thread", "2026-10-01T00:00:00.000Z"],
|
|
22
|
+
["ordered-other", "other-thread", "2026-10-18T00:00:00.000Z"],
|
|
23
|
+
["ordered-new", "ordered-thread", "2026-10-10T00:00:00.000Z"],
|
|
24
|
+
]) {
|
|
25
|
+
recordMessageUsage({
|
|
26
|
+
message_id: id, thread_id: thread, agent_id: "synthetic", agent_name: "Synthetic",
|
|
27
|
+
provider: "synthetic", model_id: "synthetic", model_config_name: null,
|
|
28
|
+
input_tokens: 1, output_tokens: 1, input_rate_usd_per_mtok: null,
|
|
29
|
+
output_rate_usd_per_mtok: null, cost_usd: 0,
|
|
30
|
+
});
|
|
31
|
+
db.prepare("UPDATE message_usage SET created_at=? WHERE message_id=?").run(timestamp, id);
|
|
32
|
+
}
|
|
33
|
+
expect(getLatestMessageUsageForThread("ordered-thread")?.message_id).toBe("ordered-new");
|
|
34
|
+
expect(getLatestMessageUsageForThread("missing-thread")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
17
37
|
it("records and reads back a usage snapshot", () => {
|
|
18
38
|
recordMessageUsage({
|
|
19
39
|
message_id: "m1",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({ statSync: vi.fn(), readFileSync: vi.fn() }));
|
|
4
|
+
vi.mock("node:fs", () => mocks);
|
|
5
|
+
vi.mock("./known-rates", () => ({ knownRateModelIds: () => [], knownRateFor: () => null }));
|
|
6
|
+
const { getPricingTables } = await import("./pricing");
|
|
7
|
+
|
|
8
|
+
describe("pricing snapshot read cache", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("does not read or parse the snapshot again when its mtime is unchanged", () => {
|
|
14
|
+
mocks.statSync.mockReturnValue({ mtimeMs: 100 });
|
|
15
|
+
mocks.readFileSync.mockReturnValue(JSON.stringify({ generated_at: "first", sources: [] }));
|
|
16
|
+
const first = getPricingTables();
|
|
17
|
+
expect(getPricingTables()).toBe(first);
|
|
18
|
+
expect(mocks.statSync).toHaveBeenCalledTimes(2);
|
|
19
|
+
expect(mocks.readFileSync).toHaveBeenCalledTimes(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("reloads rates when the snapshot changes", () => {
|
|
23
|
+
mocks.statSync.mockReturnValue({ mtimeMs: 200 });
|
|
24
|
+
mocks.readFileSync.mockReturnValue(JSON.stringify({ generated_at: "second", sources: [] }));
|
|
25
|
+
expect(getPricingTables().generatedAt).toBe("second");
|
|
26
|
+
mocks.statSync.mockReturnValue({ mtimeMs: 300 });
|
|
27
|
+
mocks.readFileSync.mockReturnValue(JSON.stringify({ generated_at: "third", sources: [] }));
|
|
28
|
+
expect(getPricingTables().generatedAt).toBe("third");
|
|
29
|
+
expect(mocks.readFileSync).toHaveBeenCalledTimes(2);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/lib/stores/pricing.ts
CHANGED
|
@@ -112,6 +112,9 @@ function readSnapshotSync(): { path: string; snapshot: PricingSnapshot | null; m
|
|
|
112
112
|
try {
|
|
113
113
|
const p = snapshotPath();
|
|
114
114
|
const stat = statSync(p);
|
|
115
|
+
if (cached?.path === p && cached.mtimeMs === stat.mtimeMs) {
|
|
116
|
+
return { path: p, snapshot: null, mtimeMs: stat.mtimeMs };
|
|
117
|
+
}
|
|
115
118
|
const raw = readFileSync(p, "utf8");
|
|
116
119
|
return { path: p, snapshot: JSON.parse(raw) as PricingSnapshot, mtimeMs: stat.mtimeMs };
|
|
117
120
|
} catch {
|