@vellumai/assistant 0.12.5 → 0.12.6-dev.202609292116.41f7caa
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/.env.example +2 -0
- package/ARCHITECTURE.md +26 -3
- package/docs/automatic-done.md +32 -0
- package/docs/desktop-browser-cli.md +3 -2
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
- package/node_modules/@vellumai/environments/src/shell.test.ts +13 -1
- package/node_modules/@vellumai/environments/src/shell.ts +22 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/status-contracts.test.ts +39 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +25 -0
- package/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
- package/openapi.yaml +270 -10
- package/package.json +1 -1
- package/src/__tests__/agent-loop.test.ts +76 -0
- package/src/__tests__/anthropic-provider.test.ts +2 -0
- package/src/__tests__/byok-default-profile-ensure.test.ts +7 -0
- package/src/__tests__/config-get-vision-flag.test.ts +1 -1
- package/src/__tests__/config-loader-validation-reset-notice.test.ts +2 -2
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +160 -154
- package/src/__tests__/conversation-evictor.test.ts +54 -0
- package/src/__tests__/conversation-pairing.test.ts +112 -0
- package/src/__tests__/document-append-idempotency.test.ts +13 -0
- package/src/__tests__/document-create-dedupe.test.ts +13 -0
- package/src/__tests__/document-find-replace.test.ts +13 -0
- package/src/__tests__/document-revision-history.test.ts +390 -0
- package/src/__tests__/document-revision.test.ts +425 -0
- package/src/__tests__/document-tool-security.test.ts +13 -0
- package/src/__tests__/document-update-default-surface.test.ts +13 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +270 -0
- package/src/__tests__/gateway-flag-listener.test.ts +59 -6
- package/src/__tests__/headless-browser-navigate.test.ts +20 -30
- package/src/__tests__/host-cu-proxy.test.ts +79 -0
- package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
- package/src/__tests__/host-shell-tool.test.ts +6 -2
- package/src/__tests__/identity-routes.test.ts +4 -3
- package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -4
- package/src/__tests__/llm-schema.test.ts +1 -1
- package/src/__tests__/notification-platform-adapter.test.ts +83 -1
- package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
- package/src/__tests__/openai-provider.test.ts +38 -11
- package/src/__tests__/pricing.test.ts +11 -0
- package/src/__tests__/public-ingress-urls.test.ts +26 -0
- package/src/__tests__/retry-thinking-tool-choice.test.ts +47 -0
- package/src/__tests__/skill-execute-input.test.ts +11 -0
- package/src/__tests__/tool-approval-handler.test.ts +4 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
- package/src/__tests__/usage-attribution.test.ts +31 -0
- package/src/__tests__/workspace-migration-133-collapse-provider-connections.test.ts +3 -3
- package/src/__tests__/workspace-migration-160-repair-retired-fireworks-model-ids.test.ts +262 -0
- package/src/__tests__/workspace-migration-161-repair-retired-fireworks-glm-5p2-model-id.test.ts +233 -0
- package/src/acp/agent-process.ts +6 -1
- package/src/acp/client-handler.ts +6 -1
- package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
- package/src/agent/loop.ts +161 -17
- package/src/api/events/document-editor-show.ts +5 -0
- package/src/api/events/document-editor-update.ts +10 -0
- package/src/api/events/tool-result.ts +1 -0
- package/src/calls/__tests__/call-controller.test.ts +273 -1
- package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
- package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +54 -0
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-controller.ts +359 -25
- package/src/calls/channel-admission-reader.ts +15 -9
- package/src/calls/media-stream-server.ts +18 -0
- package/src/calls/media-stream-stt-session.ts +30 -9
- package/src/calls/twilio-provider.ts +14 -31
- package/src/calls/voice-session-bridge.ts +18 -1
- package/src/cli/commands/browser.ts +1 -2
- package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
- package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +2 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
- package/src/cli/lib/bundled-marketplace.json +54 -2
- package/src/cli/lib/inspect-plugin.ts +15 -15
- package/src/cli/lib/list-installed-plugins.ts +29 -28
- package/src/cli/lib/plugin-artifact.ts +74 -63
- package/src/cli/lib/plugin-catalog-visibility.ts +1 -0
- package/src/cli/lib/plugin-details.ts +27 -42
- package/src/cli/lib/plugin-marketplace.ts +6 -8
- package/src/cli/lib/plugin-surfaces.ts +8 -7
- package/src/cli.ts +1 -3
- package/src/config/__tests__/chat-settings.test.ts +121 -0
- package/src/config/assistant-feature-flags.ts +28 -23
- package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
- package/src/config/chat-settings.ts +100 -0
- package/src/config/default-profile-catalog.ts +1 -1
- package/src/config/feature-flag-cache.ts +7 -25
- package/src/config/feature-flag-registry.json +9 -9
- package/src/config/loader.ts +5 -1
- package/src/config/profile-text-generation.ts +25 -21
- package/src/config/schemas/conversations.ts +14 -0
- package/src/config/schemas/llm.ts +1 -0
- package/src/config/schemas/notifications.ts +8 -1
- package/src/contacts/gateway-channel-read.ts +1 -1
- package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
- package/src/conversations/__tests__/auto-archive.test.ts +304 -0
- package/src/conversations/auto-archive-activity.ts +61 -0
- package/src/conversations/auto-archive.ts +201 -0
- package/src/daemon/computer-use-observation.test.ts +81 -0
- package/src/daemon/computer-use-observation.ts +23 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
- package/src/daemon/conversation-agent-loop.ts +14 -6
- package/src/daemon/conversation-evictor.ts +50 -16
- package/src/daemon/conversation-messaging.ts +4 -0
- package/src/daemon/conversation-process.ts +15 -0
- package/src/daemon/conversation-queue-manager.ts +2 -0
- package/src/daemon/conversation.ts +2 -0
- package/src/daemon/doordash-steps.ts +1 -3
- package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
- package/src/daemon/handlers/__tests__/config-channels.test.ts +1 -1
- package/src/daemon/handlers/config-a2a.ts +75 -2
- package/src/daemon/handlers/shared.ts +13 -2
- package/src/daemon/host-cu-proxy.ts +34 -3
- package/src/daemon/lifecycle.ts +15 -2
- package/src/daemon/process-message.ts +5 -0
- package/src/daemon/shutdown-handlers.ts +3 -0
- package/src/daemon/turn-finalization.ts +4 -0
- package/src/desktop/desktop-browser-endpoint.ts +11 -2
- package/src/desktop/desktop-chrome-policy.test.ts +1 -0
- package/src/desktop/desktop-chrome-policy.ts +7 -1
- package/src/desktop/desktop-computer-use.test.ts +14 -4
- package/src/desktop/desktop-session-manager.ts +5 -3
- package/src/documents/document-revisions-store.ts +153 -0
- package/src/documents/document-store.ts +471 -133
- package/src/followups/followup-store.ts +0 -4
- package/src/i18n/__tests__/i18n.test.ts +25 -0
- package/src/i18n/assistant-text.ts +18 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/messages.ts +11 -0
- package/src/inbound/__tests__/oauth-callback-url.test.ts +25 -2
- package/src/inbound/oauth-callback-url.ts +30 -1
- package/src/inbound/public-ingress-urls.ts +12 -2
- package/src/ipc/gateway-client.test.ts +40 -0
- package/src/ipc/gateway-client.ts +12 -29
- package/src/ipc/gateway-flag-listener.ts +7 -10
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +71 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
- package/src/live-voice/__tests__/protocol.test.ts +77 -0
- package/src/live-voice/__tests__/share-targets.test.ts +163 -0
- package/src/live-voice/live-voice-session.ts +23 -6
- package/src/live-voice/protocol.ts +22 -0
- package/src/live-voice/share-targets.ts +225 -0
- package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +144 -3
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -0
- package/src/mcp/client.ts +1 -19
- package/src/mcp/mcp-oauth-provider.ts +66 -49
- package/src/media/openrouter-image-service.ts +39 -22
- package/src/monitoring/__tests__/oom-inheritance-sweep.test.ts +86 -0
- package/src/monitoring/__tests__/oom-kill-reporter.test.ts +374 -0
- package/src/monitoring/control.ts +2 -1
- package/src/monitoring/oom-inheritance-sweep.ts +96 -0
- package/src/monitoring/oom-kill-reporter.ts +469 -0
- package/src/monitoring/resource-sampler.ts +30 -0
- package/src/notifications/README.md +53 -4
- package/src/notifications/__tests__/assistant-initiated-thread.test.ts +254 -0
- package/src/notifications/__tests__/broadcaster.test.ts +558 -6
- package/src/notifications/__tests__/completion-policy.test.ts +37 -0
- package/src/notifications/__tests__/from-me-alert-integration.test.ts +192 -0
- package/src/notifications/adapters/platform.ts +14 -0
- package/src/notifications/assistant-initiated-thread.ts +101 -0
- package/src/notifications/broadcaster.ts +94 -16
- package/src/notifications/chat-reply-policy.ts +11 -0
- package/src/notifications/completion-policy.ts +12 -2
- package/src/notifications/conversation-pairing.ts +15 -70
- package/src/notifications/emit-signal.ts +34 -3
- package/src/notifications/types.ts +2 -0
- package/src/oauth/connect-orchestrator.ts +1 -2
- package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
- package/src/persistence/conversation-auto-archive.ts +170 -0
- package/src/persistence/conversation-crud.ts +2 -1
- package/src/persistence/conversation-queries.ts +15 -8
- package/src/persistence/embeddings/embed.test.ts +96 -0
- package/src/persistence/embeddings/embed.ts +20 -2
- package/src/persistence/embeddings/embedding-gemini.test.ts +39 -3
- package/src/persistence/embeddings/embedding-gemini.ts +39 -1
- package/src/persistence/embeddings/embedding-local.ts +6 -0
- package/src/persistence/embeddings/qdrant-manager.ts +5 -0
- package/src/persistence/job-utils.ts +10 -0
- package/src/persistence/migrations/384-add-document-revision.test.ts +83 -0
- package/src/persistence/migrations/384-add-document-revision.ts +21 -0
- package/src/persistence/migrations/385-create-document-revisions.test.ts +67 -0
- package/src/persistence/migrations/385-create-document-revisions.ts +28 -0
- package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
- package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
- package/src/persistence/schema/conversations.ts +1 -0
- package/src/persistence/schema/documents.ts +20 -0
- package/src/persistence/steps.ts +6 -0
- package/src/platform/managed-speech.ts +42 -46
- package/src/plugin-api/vision-support.test.ts +21 -21
- package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +11 -6
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +1 -1
- package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
- package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
- package/src/providers/anthropic/client.ts +1 -1
- package/src/providers/fireworks/client.test.ts +168 -0
- package/src/providers/fireworks/client.ts +24 -0
- package/src/providers/inference/adapter-factory.ts +6 -0
- package/src/providers/ionet/client.test.ts +143 -0
- package/src/providers/ionet/client.ts +37 -0
- package/src/providers/model-catalog.ts +141 -84
- package/src/providers/model-intents.ts +4 -4
- package/src/providers/openai/chat-completions-provider.ts +33 -2
- package/src/providers/retry.ts +56 -4
- package/src/providers/search-provider-catalog.ts +10 -0
- package/src/providers/types.ts +10 -2
- package/src/providers/unparseable-tool-args.ts +29 -5
- package/src/providers/usage-tracking.ts +5 -3
- package/src/runtime/assistant-event-hub.ts +23 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
- package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
- package/src/runtime/channel-reply-delivery.ts +2 -0
- package/src/runtime/channel-retry-sweep.ts +1 -2
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
- package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
- package/src/runtime/migrations/vbundle-builder.ts +19 -3
- package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
- package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
- package/src/runtime/migrations/vbundle-validator.ts +54 -1
- package/src/runtime/pending-interactions.ts +20 -2
- package/src/runtime/routes/__tests__/contact-routes.test.ts +5 -3
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +36 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
- package/src/runtime/routes/__tests__/inference-send-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +12 -0
- package/src/runtime/routes/contact-routes.ts +2 -2
- package/src/runtime/routes/conversation-list-routes.ts +10 -2
- package/src/runtime/routes/conversation-query-routes.ts +48 -10
- package/src/runtime/routes/conversation-routes.ts +7 -0
- package/src/runtime/routes/documents-routes.ts +185 -47
- package/src/runtime/routes/events-routes.ts +1 -3
- package/src/runtime/routes/host-app-control-routes.ts +1 -0
- package/src/runtime/routes/host-bash-routes.ts +1 -0
- package/src/runtime/routes/host-browser-routes.ts +15 -0
- package/src/runtime/routes/host-cu-routes.ts +4 -1
- package/src/runtime/routes/host-file-routes.ts +1 -0
- package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
- package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
- package/src/runtime/routes/host-transfer-routes.ts +1 -0
- package/src/runtime/routes/identity-routes.ts +3 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
- package/src/runtime/routes/integrations/a2a.ts +4 -1
- package/src/runtime/routes/migration-routes.ts +3 -1
- package/src/runtime/routes/notification-routes.ts +1 -2
- package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
- package/src/runtime/routes/user-route-dispatcher.ts +5 -1
- package/src/runtime/verification-templates.ts +1 -2
- package/src/sequence/analytics.ts +0 -4
- package/src/sequence/engine.ts +1 -11
- package/src/sequence/guardrails.ts +1 -6
- package/src/sequence/importer.ts +0 -8
- package/src/sequence/store.ts +0 -10
- package/src/signals/user-message.ts +0 -6
- package/src/skills/catalog-install.ts +52 -23
- package/src/tools/browser/browser-execution.ts +20 -23
- package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +1 -3
- package/src/tools/document/document-tool.ts +13 -11
- package/src/tools/executor.ts +1 -2
- package/src/tools/host-terminal/host-shell.ts +4 -2
- package/src/tools/network/__tests__/web-search.test.ts +105 -0
- package/src/tools/network/web-fetch.ts +2 -4
- package/src/tools/network/web-search.ts +239 -12
- package/src/tools/shared/filesystem/format-diff.ts +1 -2
- package/src/tools/shared/shell-output.test.ts +42 -0
- package/src/tools/shared/shell-output.ts +27 -4
- package/src/tools/skills/execute.ts +10 -7
- package/src/tools/terminal/shell.ts +3 -3
- package/src/tools/tool-approval-handler.ts +9 -1
- package/src/tools/ui-surface/visual-validation.ts +1 -2
- package/src/usage/attribution.ts +18 -1
- package/src/util/__tests__/oom-priority.test.ts +55 -0
- package/src/util/__tests__/worker-memory.test.ts +16 -0
- package/src/util/debounce.ts +0 -4
- package/src/util/oom-priority.ts +79 -0
- package/src/util/platform.ts +0 -2
- package/src/util/plugin-manifest.ts +5 -7
- package/src/util/pricing.ts +25 -0
- package/src/util/worker-memory.ts +9 -3
- package/src/util/worker-process.ts +8 -0
- package/src/workspace/byok-default-profile-ensure.ts +11 -0
- package/src/workspace/hatched-date.ts +11 -4
- package/src/workspace/migrations/160-repair-retired-fireworks-model-ids.ts +209 -0
- package/src/workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.ts +197 -0
- package/src/workspace/migrations/registry.ts +4 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
and,
|
|
3
|
+
asc,
|
|
4
|
+
eq,
|
|
5
|
+
gt,
|
|
6
|
+
inArray,
|
|
7
|
+
isNull,
|
|
8
|
+
lte,
|
|
9
|
+
notExists,
|
|
10
|
+
notInArray,
|
|
11
|
+
or,
|
|
12
|
+
sql,
|
|
13
|
+
} from "drizzle-orm";
|
|
14
|
+
|
|
15
|
+
import { ACP_LIVE_STATUSES } from "../acp/types.js";
|
|
16
|
+
import { TERMINAL_STATUSES } from "../subagent/types.js";
|
|
17
|
+
import { unseenAttentionStateConditions } from "./conversation-attention-store.js";
|
|
18
|
+
import { ensureGroupMigration } from "./conversation-group-migration.js";
|
|
19
|
+
import {
|
|
20
|
+
MEMORY_V2_CONSOLIDATION_SOURCE,
|
|
21
|
+
NATIVE_ORIGIN_CHANNEL,
|
|
22
|
+
PINNED_GROUP_ID,
|
|
23
|
+
} from "./conversation-types.js";
|
|
24
|
+
import { getDb } from "./db-connection.js";
|
|
25
|
+
import { rawChanges } from "./raw-query.js";
|
|
26
|
+
import {
|
|
27
|
+
acpSessionHistory,
|
|
28
|
+
conversationAssistantAttentionState,
|
|
29
|
+
conversationModeSessions,
|
|
30
|
+
conversations,
|
|
31
|
+
externalConversationBindings,
|
|
32
|
+
workflowRuns,
|
|
33
|
+
} from "./schema/index.js";
|
|
34
|
+
|
|
35
|
+
const activityAt = sql<number>`max(coalesce(${conversations.lastMessageAt}, ${conversations.createdAt}), coalesce(${conversations.lastReopenedAt}, ${conversations.createdAt}))`;
|
|
36
|
+
|
|
37
|
+
export interface AutoArchiveCandidate {
|
|
38
|
+
id: string;
|
|
39
|
+
createdAt: number;
|
|
40
|
+
lastMessageAt: number | null;
|
|
41
|
+
lastReopenedAt: number | null;
|
|
42
|
+
activityAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function eligibleForAutoArchive(cutoff: number) {
|
|
46
|
+
const db = getDb();
|
|
47
|
+
const origin = sql`coalesce((select ${externalConversationBindings.sourceChannel} from ${externalConversationBindings} where ${externalConversationBindings.conversationId} = ${conversations.id}), ${conversations.originChannel}, ${NATIVE_ORIGIN_CHANNEL})`;
|
|
48
|
+
return and(
|
|
49
|
+
isNull(conversations.archivedAt),
|
|
50
|
+
isNull(conversations.processingStartedAt),
|
|
51
|
+
eq(conversations.conversationType, "standard"),
|
|
52
|
+
notInArray(conversations.source, [
|
|
53
|
+
"subagent",
|
|
54
|
+
"system",
|
|
55
|
+
MEMORY_V2_CONSOLIDATION_SOURCE,
|
|
56
|
+
]),
|
|
57
|
+
isNull(conversations.parentConversationId),
|
|
58
|
+
isNull(conversations.scheduleJobId),
|
|
59
|
+
sql`coalesce(group_id, 'system:all') not in (${PINNED_GROUP_ID}, 'system:background', 'system:scheduled')`,
|
|
60
|
+
or(eq(origin, NATIVE_ORIGIN_CHANNEL), sql`${origin} like 'notification:%'`),
|
|
61
|
+
lte(activityAt, cutoff),
|
|
62
|
+
notExists(
|
|
63
|
+
db
|
|
64
|
+
.select({ id: conversationAssistantAttentionState.conversationId })
|
|
65
|
+
.from(conversationAssistantAttentionState)
|
|
66
|
+
.where(
|
|
67
|
+
and(
|
|
68
|
+
eq(
|
|
69
|
+
conversationAssistantAttentionState.conversationId,
|
|
70
|
+
conversations.id,
|
|
71
|
+
),
|
|
72
|
+
...unseenAttentionStateConditions(),
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
notExists(
|
|
77
|
+
db
|
|
78
|
+
.select({ id: acpSessionHistory.id })
|
|
79
|
+
.from(acpSessionHistory)
|
|
80
|
+
.where(
|
|
81
|
+
and(
|
|
82
|
+
eq(acpSessionHistory.parentConversationId, conversations.id),
|
|
83
|
+
inArray(acpSessionHistory.status, [...ACP_LIVE_STATUSES]),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
notExists(
|
|
88
|
+
db
|
|
89
|
+
.select({ id: workflowRuns.id })
|
|
90
|
+
.from(workflowRuns)
|
|
91
|
+
.where(
|
|
92
|
+
and(
|
|
93
|
+
eq(workflowRuns.conversationId, conversations.id),
|
|
94
|
+
eq(workflowRuns.status, "running"),
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
notExists(
|
|
99
|
+
db
|
|
100
|
+
.select({ id: conversationModeSessions.id })
|
|
101
|
+
.from(conversationModeSessions)
|
|
102
|
+
.where(
|
|
103
|
+
and(
|
|
104
|
+
eq(conversationModeSessions.conversationId, conversations.id),
|
|
105
|
+
eq(conversationModeSessions.status, "active"),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
sql`not exists (select 1 from subagents where parent_conversation_id = ${conversations.id} and status not in (${sql.join(
|
|
110
|
+
[...TERMINAL_STATUSES].map((status) => sql`${status}`),
|
|
111
|
+
sql`, `,
|
|
112
|
+
)}))`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function listAutoArchiveCandidates(options: {
|
|
117
|
+
cutoff: number;
|
|
118
|
+
limit: number;
|
|
119
|
+
after?: Pick<AutoArchiveCandidate, "activityAt" | "id">;
|
|
120
|
+
}): AutoArchiveCandidate[] {
|
|
121
|
+
ensureGroupMigration();
|
|
122
|
+
return getDb()
|
|
123
|
+
.select({
|
|
124
|
+
id: conversations.id,
|
|
125
|
+
createdAt: conversations.createdAt,
|
|
126
|
+
lastMessageAt: conversations.lastMessageAt,
|
|
127
|
+
lastReopenedAt: conversations.lastReopenedAt,
|
|
128
|
+
activityAt,
|
|
129
|
+
})
|
|
130
|
+
.from(conversations)
|
|
131
|
+
.where(
|
|
132
|
+
and(
|
|
133
|
+
eligibleForAutoArchive(options.cutoff),
|
|
134
|
+
options.after
|
|
135
|
+
? or(
|
|
136
|
+
gt(activityAt, options.after.activityAt),
|
|
137
|
+
and(
|
|
138
|
+
eq(activityAt, options.after.activityAt),
|
|
139
|
+
gt(conversations.id, options.after.id),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
: undefined,
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
.orderBy(asc(activityAt), asc(conversations.id))
|
|
146
|
+
.limit(options.limit)
|
|
147
|
+
.all();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The sampled cursors and current eligibility are checked by the write itself. */
|
|
151
|
+
export function archiveInactiveConversation(
|
|
152
|
+
candidate: AutoArchiveCandidate,
|
|
153
|
+
cutoff: number,
|
|
154
|
+
archivedAt: number,
|
|
155
|
+
): boolean {
|
|
156
|
+
getDb()
|
|
157
|
+
.update(conversations)
|
|
158
|
+
.set({ archivedAt, updatedAt: archivedAt })
|
|
159
|
+
.where(
|
|
160
|
+
and(
|
|
161
|
+
eq(conversations.id, candidate.id),
|
|
162
|
+
eq(conversations.createdAt, candidate.createdAt),
|
|
163
|
+
sql`${conversations.lastMessageAt} is ${candidate.lastMessageAt}`,
|
|
164
|
+
sql`${conversations.lastReopenedAt} is ${candidate.lastReopenedAt}`,
|
|
165
|
+
eligibleForAutoArchive(cutoff),
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
.run();
|
|
169
|
+
return rawChanges() > 0;
|
|
170
|
+
}
|
|
@@ -3594,7 +3594,8 @@ export function unarchiveConversation(id: string): boolean {
|
|
|
3594
3594
|
const now = Date.now();
|
|
3595
3595
|
rawRun(
|
|
3596
3596
|
"conversation:unarchive",
|
|
3597
|
-
"UPDATE conversations SET archived_at = NULL, updated_at = ? WHERE id = ?",
|
|
3597
|
+
"UPDATE conversations SET last_reopened_at = CASE WHEN archived_at IS NOT NULL THEN ? ELSE last_reopened_at END, archived_at = NULL, updated_at = ? WHERE id = ?",
|
|
3598
|
+
now,
|
|
3598
3599
|
now,
|
|
3599
3600
|
id,
|
|
3600
3601
|
);
|
|
@@ -137,6 +137,19 @@ function archiveStatusClause(status: ArchiveStatusFilter) {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* A conversation's last activity, the key every list read is ordered on: its
|
|
142
|
+
* latest message (its last update, for a row with no messages yet) or the
|
|
143
|
+
* moment it was marked done, whichever is later. Marking a chat done is
|
|
144
|
+
* something the user did to it, so a done chat files under the day it was
|
|
145
|
+
* marked done rather than the day it was last written to. An active row has
|
|
146
|
+
* no `archived_at`, so for every active listing this is plain message recency.
|
|
147
|
+
*
|
|
148
|
+
* SQLite's multi-argument `MAX` is NULL when any argument is, hence the
|
|
149
|
+
* `COALESCE` on each side.
|
|
150
|
+
*/
|
|
151
|
+
const lastActivitySql = sql`MAX(COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt}), COALESCE(${conversations.archivedAt}, 0))`;
|
|
152
|
+
|
|
140
153
|
/**
|
|
141
154
|
* Raw SQL predicate for "visible in the standard (Recents) listing".
|
|
142
155
|
*
|
|
@@ -532,9 +545,7 @@ export function listConversations(
|
|
|
532
545
|
ensureGroupMigration();
|
|
533
546
|
const db = getDb();
|
|
534
547
|
const { limit, offset = 0, ...filter } = query;
|
|
535
|
-
const recency = desc(
|
|
536
|
-
sql`COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt})`,
|
|
537
|
-
);
|
|
548
|
+
const recency = desc(lastActivitySql);
|
|
538
549
|
// `id` closes the ordering so the sort is a total order. Rows tied on every
|
|
539
550
|
// preceding key would otherwise have no defined relative order, which
|
|
540
551
|
// becomes duplicated and skipped rows once a cursor pages across a tie
|
|
@@ -654,11 +665,7 @@ export function listPinnedConversations(
|
|
|
654
665
|
...(archiveCond ? [archiveCond] : []),
|
|
655
666
|
),
|
|
656
667
|
)
|
|
657
|
-
.orderBy(
|
|
658
|
-
desc(
|
|
659
|
-
sql`COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt})`,
|
|
660
|
-
),
|
|
661
|
-
);
|
|
668
|
+
.orderBy(desc(lastActivitySql));
|
|
662
669
|
return query.all().map(parseConversation);
|
|
663
670
|
}
|
|
664
671
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { GeminiEmbedError } from "./embedding-gemini.js";
|
|
4
|
+
|
|
5
|
+
// Capture delays passed to abortableSleep so tests can assert retry behavior
|
|
6
|
+
// without depending on real clock.
|
|
7
|
+
const sleepCalls: number[] = [];
|
|
8
|
+
|
|
9
|
+
mock.module("../../util/retry.js", () => ({
|
|
10
|
+
abortableSleep: async (ms: number) => {
|
|
11
|
+
sleepCalls.push(ms);
|
|
12
|
+
},
|
|
13
|
+
computeRetryDelay: (attempt: number, base: number) =>
|
|
14
|
+
base * Math.pow(2, attempt),
|
|
15
|
+
isRetryableNetworkError: () => false,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const mockEmbedWithBackend = mock();
|
|
19
|
+
mock.module("./embedding-backend.js", () => ({
|
|
20
|
+
embedWithBackend: mockEmbedWithBackend,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { EMBED_BASE_DELAY_MS, embedWithRetry } from "./embed.js";
|
|
24
|
+
|
|
25
|
+
const STUB_CONFIG = {} as Parameters<typeof embedWithRetry>[0];
|
|
26
|
+
|
|
27
|
+
describe("embedWithRetry: Retry-After header", () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
sleepCalls.length = 0;
|
|
30
|
+
mockEmbedWithBackend.mockClear();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
mockEmbedWithBackend.mockRestore();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("uses retryAfterMs from error as the sleep delay when present", async () => {
|
|
38
|
+
const retryAfterMs = 5000;
|
|
39
|
+
mockEmbedWithBackend
|
|
40
|
+
.mockRejectedValueOnce(
|
|
41
|
+
new GeminiEmbedError("rate limited", 429, retryAfterMs),
|
|
42
|
+
)
|
|
43
|
+
.mockResolvedValueOnce({
|
|
44
|
+
provider: "gemini",
|
|
45
|
+
model: "m",
|
|
46
|
+
vectors: [[0.1, 0.2]],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await embedWithRetry(STUB_CONFIG, ["hello"]);
|
|
50
|
+
|
|
51
|
+
expect(sleepCalls).toHaveLength(1);
|
|
52
|
+
expect(sleepCalls[0]).toBe(retryAfterMs);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("falls back to exponential backoff when retryAfterMs is absent", async () => {
|
|
56
|
+
mockEmbedWithBackend
|
|
57
|
+
.mockRejectedValueOnce(new GeminiEmbedError("rate limited", 429))
|
|
58
|
+
.mockResolvedValueOnce({
|
|
59
|
+
provider: "gemini",
|
|
60
|
+
model: "m",
|
|
61
|
+
vectors: [[0.1, 0.2]],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await embedWithRetry(STUB_CONFIG, ["hello"]);
|
|
65
|
+
|
|
66
|
+
expect(sleepCalls).toHaveLength(1);
|
|
67
|
+
expect(sleepCalls[0]).toBe(EMBED_BASE_DELAY_MS);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("retries up to EMBED_MAX_RETRIES times before throwing", async () => {
|
|
71
|
+
const error = new GeminiEmbedError("rate limited", 429, 1);
|
|
72
|
+
mockEmbedWithBackend.mockRejectedValue(error);
|
|
73
|
+
|
|
74
|
+
await expect(embedWithRetry(STUB_CONFIG, ["hello"])).rejects.toThrow(
|
|
75
|
+
"rate limited",
|
|
76
|
+
);
|
|
77
|
+
expect(sleepCalls).toHaveLength(3);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("returns result without retrying on success", async () => {
|
|
81
|
+
mockEmbedWithBackend.mockResolvedValueOnce({
|
|
82
|
+
provider: "gemini",
|
|
83
|
+
model: "m",
|
|
84
|
+
vectors: [[0.5, 0.6]],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const result = await embedWithRetry(STUB_CONFIG, ["hello"]);
|
|
88
|
+
|
|
89
|
+
expect(result).toEqual({
|
|
90
|
+
provider: "gemini",
|
|
91
|
+
model: "m",
|
|
92
|
+
vectors: [[0.5, 0.6]],
|
|
93
|
+
});
|
|
94
|
+
expect(sleepCalls).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -38,9 +38,16 @@ export async function embedWithRetry(
|
|
|
38
38
|
if (!isTransient || attempt === EMBED_MAX_RETRIES) {
|
|
39
39
|
throw err;
|
|
40
40
|
}
|
|
41
|
-
const
|
|
41
|
+
const retryAfterMs = getErrorRetryAfterMs(err);
|
|
42
|
+
const delay =
|
|
43
|
+
retryAfterMs ?? computeRetryDelay(attempt, EMBED_BASE_DELAY_MS);
|
|
42
44
|
log.warn(
|
|
43
|
-
{
|
|
45
|
+
{
|
|
46
|
+
err,
|
|
47
|
+
attempt: attempt + 1,
|
|
48
|
+
delayMs: Math.round(delay),
|
|
49
|
+
retryAfterHeader: retryAfterMs !== undefined,
|
|
50
|
+
},
|
|
44
51
|
"Transient embedding failure, retrying",
|
|
45
52
|
);
|
|
46
53
|
await abortableSleep(delay, opts?.signal);
|
|
@@ -87,6 +94,17 @@ function getErrorStatusCode(err: Error): unknown {
|
|
|
87
94
|
return undefined;
|
|
88
95
|
}
|
|
89
96
|
|
|
97
|
+
function getErrorRetryAfterMs(err: unknown): number | undefined {
|
|
98
|
+
if (!(err instanceof Error)) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const candidate = (err as { retryAfterMs?: unknown }).retryAfterMs;
|
|
102
|
+
if (typeof candidate === "number" && candidate >= 0) {
|
|
103
|
+
return candidate;
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
function isHttpStatusError(err: unknown): boolean {
|
|
91
109
|
if (!(err instanceof Error)) {
|
|
92
110
|
return false;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
GeminiEmbeddingBackend,
|
|
5
|
+
GeminiEmbedError,
|
|
6
|
+
} from "./embedding-gemini.js";
|
|
4
7
|
|
|
5
8
|
function makeSuccessResponse(values: number[]) {
|
|
6
9
|
return new Response(JSON.stringify({ embedding: { values } }), {
|
|
@@ -186,18 +189,51 @@ describe("GeminiEmbeddingBackend", () => {
|
|
|
186
189
|
});
|
|
187
190
|
|
|
188
191
|
describe("error handling", () => {
|
|
189
|
-
test("throws on non-OK response", async () => {
|
|
192
|
+
test("throws GeminiEmbedError on non-OK response", async () => {
|
|
190
193
|
mockFetch = mock(() =>
|
|
191
194
|
Promise.resolve(new Response("Internal Server Error", { status: 500 })),
|
|
192
195
|
);
|
|
193
196
|
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
|
194
197
|
|
|
195
198
|
const backend = new GeminiEmbeddingBackend("test-key", "test-model");
|
|
196
|
-
await
|
|
199
|
+
const err = await backend.embed(["hello"]).catch((e: unknown) => e);
|
|
200
|
+
expect(err).toBeInstanceOf(GeminiEmbedError);
|
|
201
|
+
expect((err as GeminiEmbedError).status).toBe(500);
|
|
202
|
+
expect((err as GeminiEmbedError).message).toContain(
|
|
197
203
|
"Gemini embeddings request failed (500): Internal Server Error",
|
|
198
204
|
);
|
|
199
205
|
});
|
|
200
206
|
|
|
207
|
+
test("attaches retryAfterMs from Retry-After delta-seconds header on 429", async () => {
|
|
208
|
+
mockFetch = mock(() =>
|
|
209
|
+
Promise.resolve(
|
|
210
|
+
new Response("rate limited", {
|
|
211
|
+
status: 429,
|
|
212
|
+
headers: { "Retry-After": "30" },
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
|
217
|
+
|
|
218
|
+
const backend = new GeminiEmbeddingBackend("test-key", "test-model");
|
|
219
|
+
const err = await backend.embed(["hello"]).catch((e: unknown) => e);
|
|
220
|
+
expect(err).toBeInstanceOf(GeminiEmbedError);
|
|
221
|
+
expect((err as GeminiEmbedError).status).toBe(429);
|
|
222
|
+
expect((err as GeminiEmbedError).retryAfterMs).toBe(30000);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("retryAfterMs is undefined on 429 without Retry-After header", async () => {
|
|
226
|
+
mockFetch = mock(() =>
|
|
227
|
+
Promise.resolve(new Response("rate limited", { status: 429 })),
|
|
228
|
+
);
|
|
229
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
|
230
|
+
|
|
231
|
+
const backend = new GeminiEmbeddingBackend("test-key", "test-model");
|
|
232
|
+
const err = await backend.embed(["hello"]).catch((e: unknown) => e);
|
|
233
|
+
expect(err).toBeInstanceOf(GeminiEmbedError);
|
|
234
|
+
expect((err as GeminiEmbedError).retryAfterMs).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
201
237
|
test("throws when response is missing embedding values", async () => {
|
|
202
238
|
mockFetch = mock(() =>
|
|
203
239
|
Promise.resolve(
|
|
@@ -10,6 +10,17 @@ import { normalizeEmbeddingInput } from "./embedding-types.js";
|
|
|
10
10
|
|
|
11
11
|
const log = getLogger("memory-embeddings");
|
|
12
12
|
|
|
13
|
+
export class GeminiEmbedError extends Error {
|
|
14
|
+
readonly status: number;
|
|
15
|
+
readonly retryAfterMs?: number;
|
|
16
|
+
constructor(message: string, status: number, retryAfterMs?: number) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "GeminiEmbedError";
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.retryAfterMs = retryAfterMs;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
interface GeminiEmbedResponse {
|
|
14
25
|
embedding?: {
|
|
15
26
|
values?: number[];
|
|
@@ -277,8 +288,13 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
|
|
|
277
288
|
});
|
|
278
289
|
if (!response.ok) {
|
|
279
290
|
const responseBody = await response.text();
|
|
280
|
-
|
|
291
|
+
const retryAfterMs = parseRetryAfterHeader(
|
|
292
|
+
response.headers.get("Retry-After"),
|
|
293
|
+
);
|
|
294
|
+
throw new GeminiEmbedError(
|
|
281
295
|
`Gemini embeddings request failed (${response.status}): ${responseBody}`,
|
|
296
|
+
response.status,
|
|
297
|
+
retryAfterMs,
|
|
282
298
|
);
|
|
283
299
|
}
|
|
284
300
|
const payload = (await response.json()) as GeminiEmbedResponse;
|
|
@@ -322,3 +338,25 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
|
|
|
322
338
|
];
|
|
323
339
|
}
|
|
324
340
|
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Parse the value of a `Retry-After` response header into milliseconds.
|
|
344
|
+
* Accepts a delta-seconds integer ("30") or an HTTP date string.
|
|
345
|
+
* Returns undefined when the value is absent, unparseable, or in the past.
|
|
346
|
+
*/
|
|
347
|
+
function parseRetryAfterHeader(value: string | null): number | undefined {
|
|
348
|
+
if (!value) {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
const trimmed = value.trim();
|
|
352
|
+
const seconds = Number(trimmed);
|
|
353
|
+
if (!Number.isNaN(seconds) && seconds >= 0) {
|
|
354
|
+
return Math.ceil(seconds * 1000);
|
|
355
|
+
}
|
|
356
|
+
const date = new Date(trimmed);
|
|
357
|
+
if (!isNaN(date.getTime())) {
|
|
358
|
+
const ms = date.getTime() - Date.now();
|
|
359
|
+
return ms > 0 ? ms : undefined;
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
@@ -11,6 +11,10 @@ import type { Subprocess } from "bun";
|
|
|
11
11
|
|
|
12
12
|
import { getIsContainerized } from "../../config/env-registry.js";
|
|
13
13
|
import { getLogger } from "../../util/logger.js";
|
|
14
|
+
import {
|
|
15
|
+
setOomScoreAdj,
|
|
16
|
+
WORKER_OOM_SCORE_ADJ,
|
|
17
|
+
} from "../../util/oom-priority.js";
|
|
14
18
|
import {
|
|
15
19
|
getEmbeddingModelsDir,
|
|
16
20
|
getEmbedWorkerPidPath,
|
|
@@ -395,6 +399,8 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
395
399
|
cwd: embeddingModelsDir,
|
|
396
400
|
});
|
|
397
401
|
|
|
402
|
+
setOomScoreAdj(WORKER_OOM_SCORE_ADJ, proc.pid);
|
|
403
|
+
|
|
398
404
|
// Type-compatible assignment
|
|
399
405
|
this.workerProc = proc;
|
|
400
406
|
|
|
@@ -17,6 +17,10 @@ import type { Subprocess } from "bun";
|
|
|
17
17
|
|
|
18
18
|
import { getQdrantReadyzTimeoutMs, getQdrantUrlEnv } from "../../config/env.js";
|
|
19
19
|
import { getLogger } from "../../util/logger.js";
|
|
20
|
+
import {
|
|
21
|
+
setOomScoreAdj,
|
|
22
|
+
WORKER_OOM_SCORE_ADJ,
|
|
23
|
+
} from "../../util/oom-priority.js";
|
|
20
24
|
import { getDataDir } from "../../util/platform.js";
|
|
21
25
|
|
|
22
26
|
const log = getLogger("qdrant-manager");
|
|
@@ -288,6 +292,7 @@ export class QdrantManager {
|
|
|
288
292
|
} catch (err) {
|
|
289
293
|
throw this.startError(err, "spawn");
|
|
290
294
|
}
|
|
295
|
+
setOomScoreAdj(WORKER_OOM_SCORE_ADJ, proc.pid);
|
|
291
296
|
this.process = proc;
|
|
292
297
|
this.drainOutputFrom(proc.stdout, proc.stderr);
|
|
293
298
|
|
|
@@ -54,6 +54,16 @@ export const RETRY_MAX_ATTEMPTS = 8;
|
|
|
54
54
|
* Fatal: 400 bad request, 401 auth, 403 permission, other 4xx client errors.
|
|
55
55
|
*/
|
|
56
56
|
export function classifyError(err: unknown): ErrorCategory {
|
|
57
|
+
// LOCAL PATCH (juno, 2026-09-26): SQLite write contention is transient, retry it.
|
|
58
|
+
if (
|
|
59
|
+
err instanceof Error &&
|
|
60
|
+
(/database is locked|SQLITE_BUSY/i.test(err.message) ||
|
|
61
|
+
/^SQLITE_(BUSY|IOERR)/.test(
|
|
62
|
+
String((err as Error & { code?: string }).code ?? ""),
|
|
63
|
+
))
|
|
64
|
+
) {
|
|
65
|
+
return "retryable";
|
|
66
|
+
}
|
|
57
67
|
// Timeout errors from our own Promise.race wrappers
|
|
58
68
|
if (err instanceof Error && err.message.includes("timeout")) {
|
|
59
69
|
return "retryable";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
5
|
+
|
|
6
|
+
import * as schema from "../schema.js";
|
|
7
|
+
import { migrateAddDocumentWorkspacePath } from "./360-add-document-workspace-path.js";
|
|
8
|
+
import { migrateAddDocumentRevision } from "./384-add-document-revision.js";
|
|
9
|
+
|
|
10
|
+
/** Pre-384 shape: the documents table as core tables and migration 360 left it. */
|
|
11
|
+
function createTestDb() {
|
|
12
|
+
const sqlite = new Database(":memory:");
|
|
13
|
+
sqlite.exec(/*sql*/ `
|
|
14
|
+
CREATE TABLE conversations (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
created_at INTEGER NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE documents (
|
|
20
|
+
surface_id TEXT PRIMARY KEY,
|
|
21
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
22
|
+
title TEXT NOT NULL,
|
|
23
|
+
content TEXT NOT NULL,
|
|
24
|
+
word_count INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
created_at INTEGER NOT NULL,
|
|
26
|
+
updated_at INTEGER NOT NULL
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
INSERT INTO conversations (id, created_at) VALUES ('conv-1', 1000);
|
|
30
|
+
INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at)
|
|
31
|
+
VALUES ('doc-1', 'conv-1', 'Doc', 'body', 1, 1000, 1000);
|
|
32
|
+
`);
|
|
33
|
+
const db = drizzle(sqlite, { schema });
|
|
34
|
+
migrateAddDocumentWorkspacePath(db);
|
|
35
|
+
return { sqlite, db };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function revisionColumn(sqlite: Database) {
|
|
39
|
+
return (
|
|
40
|
+
sqlite.query("PRAGMA table_info(documents)").all() as Array<{
|
|
41
|
+
name: string;
|
|
42
|
+
notnull: number;
|
|
43
|
+
dflt_value: string | null;
|
|
44
|
+
}>
|
|
45
|
+
).find((c) => c.name === "revision");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("migration 384: documents.revision", () => {
|
|
49
|
+
test("adds a NOT NULL column defaulting to 0", () => {
|
|
50
|
+
const { sqlite, db } = createTestDb();
|
|
51
|
+
expect(revisionColumn(sqlite)).toBeUndefined();
|
|
52
|
+
|
|
53
|
+
migrateAddDocumentRevision(db);
|
|
54
|
+
|
|
55
|
+
const column = revisionColumn(sqlite);
|
|
56
|
+
expect(column?.notnull).toBe(1);
|
|
57
|
+
expect(column?.dflt_value).toBe("0");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("existing rows read revision 0", () => {
|
|
61
|
+
const { sqlite, db } = createTestDb();
|
|
62
|
+
|
|
63
|
+
migrateAddDocumentRevision(db);
|
|
64
|
+
|
|
65
|
+
const row = sqlite
|
|
66
|
+
.query("SELECT revision FROM documents WHERE surface_id = 'doc-1'")
|
|
67
|
+
.get() as { revision: number };
|
|
68
|
+
expect(row.revision).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("is idempotent", () => {
|
|
72
|
+
const { sqlite, db } = createTestDb();
|
|
73
|
+
|
|
74
|
+
migrateAddDocumentRevision(db);
|
|
75
|
+
sqlite.run("UPDATE documents SET revision = 3 WHERE surface_id = 'doc-1'");
|
|
76
|
+
expect(() => migrateAddDocumentRevision(db)).not.toThrow();
|
|
77
|
+
|
|
78
|
+
const row = sqlite
|
|
79
|
+
.query("SELECT revision FROM documents WHERE surface_id = 'doc-1'")
|
|
80
|
+
.get() as { revision: number };
|
|
81
|
+
expect(row.revision).toBe(3);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DrizzleDb } from "../db-connection.js";
|
|
2
|
+
import { tableHasColumn } from "./schema-introspection.js";
|
|
3
|
+
|
|
4
|
+
const COLUMN_NAME = "revision";
|
|
5
|
+
const COLUMN_DEFINITION = "revision INTEGER NOT NULL DEFAULT 0";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Add `revision` to `documents`: a counter every write bumps by one, which
|
|
9
|
+
* lets a client make its save conditional on the revision it last saw.
|
|
10
|
+
*
|
|
11
|
+
* Existing rows start at 0 with no backfill; their next write moves them to 1.
|
|
12
|
+
*
|
|
13
|
+
* Idempotent: guarded with `tableHasColumn` so a crash between the `ALTER
|
|
14
|
+
* TABLE` and the checkpoint write does not raise a duplicate-column error on
|
|
15
|
+
* the next boot.
|
|
16
|
+
*/
|
|
17
|
+
export function migrateAddDocumentRevision(database: DrizzleDb): void {
|
|
18
|
+
if (!tableHasColumn(database, "documents", COLUMN_NAME)) {
|
|
19
|
+
database.run(`ALTER TABLE documents ADD COLUMN ${COLUMN_DEFINITION}`);
|
|
20
|
+
}
|
|
21
|
+
}
|