@vellumai/assistant 0.3.4 → 0.3.6
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/Dockerfile +2 -0
- package/README.md +88 -2
- package/eslint.config.mjs +31 -0
- package/package.json +1 -1
- package/scripts/ipc/check-swift-decoder-drift.ts +4 -1
- package/scripts/ipc/generate-swift.ts +31 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +438 -1
- package/src/__tests__/approval-conversation-turn.test.ts +214 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/browser-manager.test.ts +1 -0
- package/src/__tests__/call-conversation-messages.test.ts +130 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +799 -249
- package/src/__tests__/call-pointer-messages.test.ts +148 -0
- package/src/__tests__/call-recovery.test.ts +3 -0
- package/src/__tests__/call-routes-http.test.ts +32 -2
- package/src/__tests__/call-store.test.ts +3 -0
- package/src/__tests__/channel-approval-routes.test.ts +1277 -98
- package/src/__tests__/channel-approval.test.ts +37 -0
- package/src/__tests__/channel-approvals.test.ts +36 -50
- package/src/__tests__/channel-guardian.test.ts +630 -22
- package/src/__tests__/channel-readiness-service.test.ts +324 -0
- package/src/__tests__/checker.test.ts +14 -7
- package/src/__tests__/clarification-resolver.test.ts +44 -24
- package/src/__tests__/commit-message-enrichment-service.test.ts +9 -4
- package/src/__tests__/computer-use-session-working-dir.test.ts +8 -0
- package/src/__tests__/config-schema.test.ts +14 -8
- package/src/__tests__/context-window-manager.test.ts +30 -2
- package/src/__tests__/contradiction-checker.test.ts +20 -5
- package/src/__tests__/credential-security-invariants.test.ts +7 -2
- package/src/__tests__/daemon-lifecycle.test.ts +13 -12
- package/src/__tests__/db-migration-rollback.test.ts +752 -0
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +2 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/fuzzy-match-property.test.ts +5 -5
- package/src/__tests__/guardian-action-store.test.ts +123 -0
- package/src/__tests__/guardian-action-sweep.test.ts +277 -0
- package/src/__tests__/guardian-dispatch.test.ts +389 -0
- package/src/__tests__/guardian-question-copy.test.ts +47 -0
- package/src/__tests__/handlers-telegram-config.test.ts +4 -2
- package/src/__tests__/handlers-twilio-config.test.ts +533 -0
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +291 -1
- package/src/__tests__/memory-upsert-concurrency.test.ts +828 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/model-intents.test.ts +96 -0
- package/src/__tests__/no-direct-anthropic-sdk-imports.test.ts +42 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +130 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +2 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +89 -13
- package/src/__tests__/provider-error-scenarios.test.ts +621 -0
- package/src/__tests__/provider-fail-open-selection.test.ts +119 -0
- package/src/__tests__/qdrant-manager.test.ts +27 -20
- package/src/__tests__/relay-server.test.ts +779 -40
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +6 -0
- package/src/__tests__/run-orchestrator.test.ts +42 -4
- package/src/__tests__/runtime-runs-http.test.ts +17 -1
- package/src/__tests__/runtime-runs.test.ts +16 -0
- package/src/__tests__/schedule-store.test.ts +18 -4
- package/src/__tests__/scheduler-recurrence.test.ts +13 -4
- package/src/__tests__/session-abort-tool-results.test.ts +6 -0
- package/src/__tests__/session-agent-loop.test.ts +857 -0
- package/src/__tests__/session-conflict-gate.test.ts +6 -0
- package/src/__tests__/session-pre-run-repair.test.ts +6 -0
- package/src/__tests__/session-profile-injection.test.ts +6 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +6 -0
- package/src/__tests__/session-queue.test.ts +6 -0
- package/src/__tests__/session-runtime-assembly.test.ts +321 -13
- package/src/__tests__/session-slash-known.test.ts +6 -0
- package/src/__tests__/session-slash-queue.test.ts +6 -0
- package/src/__tests__/session-slash-unknown.test.ts +6 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +2 -0
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
- package/src/__tests__/session-workspace-injection.test.ts +6 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +6 -0
- package/src/__tests__/skills.test.ts +2 -0
- package/src/__tests__/sms-messaging-provider.test.ts +126 -0
- package/src/__tests__/starter-task-flow.test.ts +2 -0
- package/src/__tests__/swarm-dag-pathological.test.ts +535 -0
- package/src/__tests__/system-prompt.test.ts +2 -0
- package/src/__tests__/task-management-tools.test.ts +2 -2
- package/src/__tests__/task-runner.test.ts +14 -4
- package/src/__tests__/terminal-tools.test.ts +25 -19
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +545 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +11 -11
- package/src/__tests__/tool-executor.test.ts +23 -24
- package/src/__tests__/trust-store.test.ts +3 -3
- package/src/__tests__/twilio-rest.test.ts +29 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +3 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +11 -0
- package/src/__tests__/twilio-routes.test.ts +167 -11
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +2 -0
- package/src/__tests__/voice-quality.test.ts +222 -0
- package/src/__tests__/web-search.test.ts +46 -30
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/agent/loop.ts +1 -1
- package/src/agent-heartbeat/agent-heartbeat-service.ts +2 -10
- package/src/amazon/client.ts +1418 -0
- package/src/amazon/request-extractor.ts +135 -0
- package/src/amazon/session.ts +109 -0
- package/src/autonomy/autonomy-store.ts +5 -5
- package/src/browser-extension-relay/client.ts +124 -0
- package/src/browser-extension-relay/protocol.ts +63 -0
- package/src/browser-extension-relay/server.ts +177 -0
- package/src/bundler/app-bundler.ts +3 -3
- package/src/bundler/bundle-signer.ts +1 -1
- package/src/bundler/signature-verifier.ts +1 -1
- package/src/calls/call-conversation-messages.ts +33 -0
- package/src/calls/call-domain.ts +114 -10
- package/src/calls/call-orchestrator.ts +268 -59
- package/src/calls/call-pointer-messages.ts +53 -0
- package/src/calls/call-recovery.ts +3 -8
- package/src/calls/call-store.ts +69 -87
- package/src/calls/elevenlabs-config.ts +3 -2
- package/src/calls/guardian-action-sweep.ts +105 -0
- package/src/calls/guardian-dispatch.ts +203 -0
- package/src/calls/guardian-question-copy.ts +133 -0
- package/src/calls/relay-server.ts +466 -8
- package/src/calls/speaker-identification.ts +1 -1
- package/src/calls/twilio-config.ts +22 -14
- package/src/calls/twilio-provider.ts +6 -4
- package/src/calls/twilio-rest.ts +308 -7
- package/src/calls/twilio-routes.ts +65 -12
- package/src/calls/types.ts +3 -1
- package/src/channels/types.ts +25 -0
- package/src/cli/amazon.ts +815 -0
- package/src/cli/config-commands.ts +2 -2
- package/src/cli/core-commands.ts +4 -3
- package/src/cli/influencer.ts +244 -0
- package/src/cli/map.ts +89 -6
- package/src/cli.ts +1 -1
- package/src/config/agent-schema.ts +171 -0
- package/src/config/bundled-skills/amazon/SKILL.md +127 -0
- package/src/config/bundled-skills/amazon/icon.svg +13 -0
- package/src/config/bundled-skills/api-mapping/SKILL.md +78 -0
- package/src/config/bundled-skills/browser/SKILL.md +1 -0
- package/src/config/bundled-skills/browser/TOOLS.json +17 -0
- package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +25 -0
- package/src/config/bundled-skills/doordash/SKILL.md +51 -51
- package/src/config/bundled-skills/email-setup/SKILL.md +14 -5
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +183 -0
- package/src/config/bundled-skills/influencer/SKILL.md +144 -0
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/macos-automation/icon.svg +12 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +176 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +230 -0
- package/src/config/bundled-skills/media-processing/__tests__/concurrency-pool.test.ts +77 -0
- package/src/config/bundled-skills/media-processing/__tests__/cost-tracker.test.ts +69 -0
- package/src/config/bundled-skills/media-processing/__tests__/preprocess.test.ts +303 -0
- package/src/config/bundled-skills/media-processing/services/concurrency-pool.ts +55 -0
- package/src/config/bundled-skills/media-processing/services/cost-tracker.ts +86 -0
- package/src/config/bundled-skills/media-processing/services/gemini-map.ts +339 -0
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +551 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +259 -0
- package/src/config/bundled-skills/media-processing/services/reduce.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +136 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +59 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +143 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +65 -0
- package/src/config/bundled-skills/messaging/SKILL.md +33 -8
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +4 -7
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +2 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +88 -23
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/bundled-skills/twitter/icon.svg +14 -0
- package/src/config/bundled-tool-registry.ts +310 -0
- package/src/config/calls-schema.ts +181 -0
- package/src/config/core-schema.ts +309 -0
- package/src/config/defaults.ts +28 -3
- package/src/config/env-registry.ts +162 -0
- package/src/config/env.ts +175 -0
- package/src/config/loader.ts +6 -6
- package/src/config/memory-schema.ts +528 -0
- package/src/config/sandbox-schema.ts +55 -0
- package/src/config/schema.ts +158 -1133
- package/src/config/skill-state.ts +1 -1
- package/src/config/skills-schema.ts +32 -0
- package/src/config/skills.ts +35 -24
- package/src/config/system-prompt.ts +131 -56
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/user-reference.ts +4 -9
- package/src/config/vellum-skills/catalog.json +6 -7
- package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +5 -1
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +216 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
- package/src/context/window-manager.ts +27 -7
- package/src/daemon/approval-generators.ts +186 -0
- package/src/daemon/approved-devices-store.ts +140 -0
- package/src/daemon/assistant-attachments.ts +1 -1
- package/src/daemon/classifier.ts +35 -32
- package/src/daemon/config-watcher.ts +1 -1
- package/src/daemon/daemon-control.ts +217 -0
- package/src/daemon/handlers/apps.ts +2 -3
- package/src/daemon/handlers/config-channels.ts +158 -0
- package/src/daemon/handlers/config-inbox.ts +540 -0
- package/src/daemon/handlers/config-ingress.ts +231 -0
- package/src/daemon/handlers/config-integrations.ts +258 -0
- package/src/daemon/handlers/config-model.ts +143 -0
- package/src/daemon/handlers/config-parental.ts +163 -0
- package/src/daemon/handlers/config-scheduling.ts +172 -0
- package/src/daemon/handlers/config-slack.ts +92 -0
- package/src/daemon/handlers/config-telegram.ts +301 -0
- package/src/daemon/handlers/config-tools.ts +177 -0
- package/src/daemon/handlers/config-trust.ts +104 -0
- package/src/daemon/handlers/config-twilio.ts +1080 -0
- package/src/daemon/handlers/config.ts +53 -1689
- package/src/daemon/handlers/diagnostics.ts +1 -1
- package/src/daemon/handlers/dictation.ts +180 -0
- package/src/daemon/handlers/documents.ts +18 -32
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +11 -0
- package/src/daemon/handlers/misc.ts +3 -5
- package/src/daemon/handlers/pairing.ts +98 -0
- package/src/daemon/handlers/sessions.ts +56 -5
- package/src/daemon/handlers/shared.ts +6 -1
- package/src/daemon/handlers/skills.ts +1 -1
- package/src/daemon/handlers/twitter-auth.ts +2 -0
- package/src/daemon/handlers/work-items.ts +17 -9
- package/src/daemon/handlers/workspace-files.ts +4 -3
- package/src/daemon/install-cli-launchers.ts +113 -0
- package/src/daemon/ipc-contract/apps.ts +356 -0
- package/src/daemon/ipc-contract/browser.ts +74 -0
- package/src/daemon/ipc-contract/computer-use.ts +151 -0
- package/src/daemon/ipc-contract/diagnostics.ts +56 -0
- package/src/daemon/ipc-contract/documents.ts +74 -0
- package/src/daemon/ipc-contract/inbox.ts +209 -0
- package/src/daemon/ipc-contract/integrations.ts +284 -0
- package/src/daemon/ipc-contract/memory.ts +48 -0
- package/src/daemon/ipc-contract/messages.ts +211 -0
- package/src/daemon/ipc-contract/pairing.ts +45 -0
- package/src/daemon/ipc-contract/parental-control.ts +95 -0
- package/src/daemon/ipc-contract/schedules.ts +97 -0
- package/src/daemon/ipc-contract/sessions.ts +315 -0
- package/src/daemon/ipc-contract/shared.ts +42 -0
- package/src/daemon/ipc-contract/skills.ts +120 -0
- package/src/daemon/ipc-contract/subagents.ts +58 -0
- package/src/daemon/ipc-contract/surfaces.ts +250 -0
- package/src/daemon/ipc-contract/trust.ts +60 -0
- package/src/daemon/ipc-contract/work-items.ts +225 -0
- package/src/daemon/ipc-contract/workspace.ts +113 -0
- package/src/daemon/ipc-contract-inventory.json +70 -0
- package/src/daemon/ipc-contract-inventory.ts +55 -29
- package/src/daemon/ipc-contract.ts +229 -2426
- package/src/daemon/ipc-protocol.ts +1 -1
- package/src/daemon/ipc-validate.ts +7 -0
- package/src/daemon/lifecycle.ts +97 -377
- package/src/daemon/pairing-store.ts +177 -0
- package/src/daemon/providers-setup.ts +43 -0
- package/src/daemon/ride-shotgun-handler.ts +68 -3
- package/src/daemon/server.ts +66 -46
- package/src/daemon/session-agent-loop-handlers.ts +421 -0
- package/src/daemon/session-agent-loop.ts +117 -275
- package/src/daemon/session-dynamic-profile.ts +1 -1
- package/src/daemon/session-history.ts +1 -1
- package/src/daemon/session-media-retry.ts +1 -1
- package/src/daemon/session-messaging.ts +37 -2
- package/src/daemon/session-notifiers.ts +5 -25
- package/src/daemon/session-process.ts +99 -59
- package/src/daemon/session-queue-manager.ts +96 -4
- package/src/daemon/session-runtime-assembly.ts +199 -10
- package/src/daemon/session-surfaces.ts +19 -4
- package/src/daemon/session-tool-setup.ts +30 -30
- package/src/daemon/session-workspace.ts +1 -1
- package/src/daemon/session.ts +35 -2
- package/src/daemon/shutdown-handlers.ts +122 -0
- package/src/daemon/trace-emitter.ts +1 -1
- package/src/daemon/watch-handler.ts +36 -33
- package/src/doordash/cart-queries.ts +787 -0
- package/src/doordash/client.ts +144 -127
- package/src/doordash/order-queries.ts +85 -0
- package/src/doordash/queries.ts +10 -1308
- package/src/doordash/search-queries.ts +203 -0
- package/src/doordash/session.ts +3 -2
- package/src/doordash/store-queries.ts +246 -0
- package/src/doordash/types.ts +367 -0
- package/src/email/providers/agentmail.ts +2 -1
- package/src/email/providers/index.ts +3 -2
- package/src/email/service.ts +3 -2
- package/src/errors.ts +43 -0
- package/src/home-base/prebuilt/seed.ts +1 -1
- package/src/hooks/cli.ts +6 -5
- package/src/hooks/config.ts +6 -8
- package/src/hooks/discovery.ts +6 -5
- package/src/hooks/manager.ts +4 -3
- package/src/hooks/runner.ts +2 -2
- package/src/hooks/templates.ts +5 -5
- package/src/inbound/public-ingress-urls.ts +6 -4
- package/src/index.ts +4 -2
- package/src/influencer/client.ts +1104 -0
- package/src/instrument.ts +4 -3
- package/src/logfire.ts +4 -3
- package/src/memory/admin.ts +25 -35
- package/src/memory/attachments-store.ts +4 -7
- package/src/memory/channel-delivery-store.ts +30 -1
- package/src/memory/channel-guardian-store.ts +202 -2
- package/src/memory/clarification-resolver.ts +37 -33
- package/src/memory/conflict-store.ts +67 -61
- package/src/memory/contradiction-checker.ts +141 -117
- package/src/memory/conversation-store.ts +335 -51
- package/src/memory/db-connection.ts +27 -4
- package/src/memory/db-init.ts +265 -4
- package/src/memory/db.ts +14 -1
- package/src/memory/embedding-backend.ts +27 -5
- package/src/memory/embedding-ollama.ts +2 -1
- package/src/memory/entity-extractor.ts +38 -35
- package/src/memory/guardian-action-store.ts +430 -0
- package/src/memory/inbox-escalation-projection.ts +59 -0
- package/src/memory/inbox-thread-store.ts +218 -0
- package/src/memory/ingress-invite-store.ts +338 -0
- package/src/memory/ingress-member-store.ts +350 -0
- package/src/memory/items-extractor.ts +91 -97
- package/src/memory/job-handlers/index-maintenance.ts +3 -3
- package/src/memory/job-handlers/media-processing.ts +69 -0
- package/src/memory/job-handlers/summarization.ts +32 -26
- package/src/memory/job-utils.ts +3 -10
- package/src/memory/jobs-store.ts +8 -10
- package/src/memory/jobs-worker.ts +55 -36
- package/src/memory/media-store.ts +759 -0
- package/src/memory/migrations/001-job-deferrals.ts +45 -0
- package/src/memory/migrations/002-tool-invocations-fk.ts +43 -0
- package/src/memory/migrations/003-memory-fts-backfill.ts +24 -0
- package/src/memory/migrations/004-entity-relation-dedup.ts +87 -0
- package/src/memory/migrations/005-fingerprint-scope-unique.ts +80 -0
- package/src/memory/migrations/006-scope-salted-fingerprints.ts +62 -0
- package/src/memory/migrations/007-assistant-id-to-self.ts +254 -0
- package/src/memory/migrations/008-remove-assistant-id-columns.ts +208 -0
- package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +83 -0
- package/src/memory/migrations/010-ext-conv-bindings-channel-chat-unique.ts +56 -0
- package/src/memory/migrations/011-call-sessions-provider-sid-dedup.ts +63 -0
- package/src/memory/migrations/012-call-sessions-add-initiated-from.ts +19 -0
- package/src/memory/migrations/013-guardian-action-tables.ts +68 -0
- package/src/memory/migrations/014-backfill-inbox-thread-state.ts +76 -0
- package/src/memory/migrations/015-drop-active-search-index.ts +27 -0
- package/src/memory/migrations/016-memory-segments-indexes.ts +11 -0
- package/src/memory/migrations/017-memory-items-indexes.ts +10 -0
- package/src/memory/migrations/018-remaining-table-indexes.ts +13 -0
- package/src/memory/migrations/index.ts +24 -0
- package/src/memory/migrations/registry.ts +79 -0
- package/src/memory/migrations/validate-migration-state.ts +69 -0
- package/src/memory/qdrant-manager.ts +49 -8
- package/src/memory/query-builder.ts +1 -1
- package/src/memory/raw-query.ts +119 -0
- package/src/memory/recall-cache.ts +4 -1
- package/src/memory/retriever.ts +165 -47
- package/src/memory/schema-migration.ts +25 -984
- package/src/memory/schema.ts +228 -7
- package/src/memory/search/entity.ts +205 -31
- package/src/memory/search/lexical.ts +81 -52
- package/src/memory/search/ranking.ts +27 -23
- package/src/memory/search/semantic.ts +157 -19
- package/src/memory/search/types.ts +24 -0
- package/src/memory/shared-app-links-store.ts +4 -5
- package/src/memory/validation.ts +19 -0
- package/src/messaging/draft-store.ts +5 -6
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +201 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +2 -5
- package/src/messaging/providers/whatsapp/adapter.ts +136 -0
- package/src/messaging/providers/whatsapp/client.ts +67 -0
- package/src/messaging/style-analyzer.ts +5 -4
- package/src/messaging/thread-summarizer.ts +61 -69
- package/src/messaging/triage-engine.ts +62 -71
- package/src/migrations/config-merge.ts +53 -0
- package/src/migrations/data-layout.ts +68 -0
- package/src/migrations/data-merge.ts +33 -0
- package/src/migrations/hooks-merge.ts +90 -0
- package/src/migrations/index.ts +6 -0
- package/src/migrations/log.ts +23 -0
- package/src/migrations/skills-merge.ts +33 -0
- package/src/migrations/workspace-layout.ts +79 -0
- package/src/permissions/checker.ts +133 -11
- package/src/permissions/prompter.ts +14 -0
- package/src/permissions/shell-identity.ts +31 -1
- package/src/permissions/trust-store.ts +21 -1
- package/src/providers/anthropic/client.ts +4 -4
- package/src/providers/failover.ts +2 -2
- package/src/providers/model-intents.ts +70 -0
- package/src/providers/ollama/client.ts +2 -1
- package/src/providers/provider-send-message.ts +176 -0
- package/src/providers/registry.ts +71 -30
- package/src/providers/retry.ts +35 -1
- package/src/providers/types.ts +12 -1
- package/src/runtime/approval-conversation-turn.ts +97 -0
- package/src/runtime/approval-message-composer.ts +253 -0
- package/src/runtime/channel-approval-parser.ts +36 -2
- package/src/runtime/channel-approvals.ts +11 -24
- package/src/runtime/channel-guardian-service.ts +88 -21
- package/src/runtime/channel-readiness-service.ts +418 -0
- package/src/runtime/channel-readiness-types.ts +35 -0
- package/src/runtime/channel-retry-sweep.ts +184 -0
- package/src/runtime/guardian-context-resolver.ts +108 -0
- package/src/runtime/http-server.ts +275 -717
- package/src/runtime/http-types.ts +59 -3
- package/src/runtime/middleware/auth.ts +116 -0
- package/src/runtime/middleware/error-handler.ts +33 -0
- package/src/runtime/middleware/twilio-validation.ts +127 -0
- package/src/runtime/routes/app-routes.ts +1 -1
- package/src/runtime/routes/call-routes.ts +51 -7
- package/src/runtime/routes/channel-delivery-routes.ts +170 -0
- package/src/runtime/routes/channel-guardian-routes.ts +1191 -0
- package/src/runtime/routes/channel-inbound-routes.ts +1152 -0
- package/src/runtime/routes/channel-route-shared.ts +144 -0
- package/src/runtime/routes/channel-routes.ts +32 -1588
- package/src/runtime/routes/conversation-routes.ts +50 -7
- package/src/runtime/routes/events-routes.ts +2 -2
- package/src/runtime/routes/identity-routes.ts +126 -0
- package/src/runtime/routes/pairing-routes.ts +143 -0
- package/src/runtime/routes/run-routes.ts +15 -1
- package/src/runtime/run-orchestrator.ts +86 -35
- package/src/schedule/schedule-store.ts +36 -32
- package/src/schedule/scheduler.ts +3 -3
- package/src/security/encrypted-store.ts +5 -7
- package/src/security/oauth2.ts +45 -15
- package/src/security/parental-control-store.ts +183 -0
- package/src/security/secret-allowlist.ts +4 -3
- package/src/security/secret-scanner.ts +5 -5
- package/src/security/secure-keys.ts +1 -1
- package/src/security/token-manager.ts +3 -2
- package/src/services/vercel-deploy.ts +6 -2
- package/src/skills/tool-manifest.ts +3 -3
- package/src/skills/vellum-catalog-remote.ts +75 -16
- package/src/slack/slack-webhook.ts +2 -1
- package/src/swarm/orchestrator.ts +92 -1
- package/src/swarm/router-planner.ts +6 -9
- package/src/swarm/worker-prompts.ts +9 -12
- package/src/tasks/task-compiler.ts +19 -28
- package/src/tasks/task-runner.ts +1 -1
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/assets/search.ts +15 -14
- package/src/tools/browser/__tests__/auth-detector.test.ts +1 -0
- package/src/tools/browser/auto-navigate.ts +1 -0
- package/src/tools/browser/browser-execution.ts +10 -1
- package/src/tools/browser/browser-manager.ts +119 -4
- package/src/tools/browser/network-recorder.ts +5 -0
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/credentials/broker.ts +11 -2
- package/src/tools/credentials/metadata-store.ts +18 -14
- package/src/tools/credentials/post-connect-hooks.ts +61 -0
- package/src/tools/credentials/vault.ts +49 -23
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/executor.ts +68 -9
- package/src/tools/host-terminal/cli-discover.ts +1 -1
- package/src/tools/network/script-proxy/http-forwarder.ts +1 -1
- package/src/tools/network/script-proxy/mitm-handler.ts +1 -1
- package/src/tools/network/script-proxy/server.ts +1 -1
- package/src/tools/network/script-proxy/session-manager.ts +6 -5
- package/src/tools/network/web-fetch.ts +18 -2
- package/src/tools/network/web-search.ts +8 -4
- package/src/tools/reminder/reminder-store.ts +14 -15
- package/src/tools/schedule/create.ts +1 -0
- package/src/tools/schedule/list.ts +2 -1
- package/src/tools/shared/filesystem/file-ops-service.ts +5 -7
- package/src/tools/skills/skill-script-runner.ts +24 -9
- package/src/tools/skills/skill-tool-factory.ts +1 -0
- package/src/tools/tasks/work-item-enqueue.ts +2 -2
- package/src/tools/terminal/evaluate-typescript.ts +21 -12
- package/src/tools/terminal/parser.ts +50 -0
- package/src/tools/types.ts +2 -0
- package/src/tools/watcher/delete.ts +6 -0
- package/src/tools/weather/service.ts +1 -1
- package/src/twitter/client.ts +190 -24
- package/src/twitter/router.ts +1 -1
- package/src/twitter/session.ts +4 -3
- package/src/util/clipboard.ts +1 -1
- package/src/util/errors.ts +65 -8
- package/src/util/fs.ts +40 -0
- package/src/util/json.ts +10 -0
- package/src/util/log-redact.ts +189 -0
- package/src/util/logger.ts +19 -17
- package/src/util/object.ts +3 -0
- package/src/util/platform.ts +105 -363
- package/src/util/pricing.ts +1 -1
- package/src/util/promise-guard.ts +1 -1
- package/src/util/retry.ts +19 -0
- package/src/util/row-mapper.ts +79 -0
- package/src/util/silently.ts +21 -0
- package/src/watcher/engine.ts +5 -1
- package/src/watcher/provider-types.ts +20 -0
- package/src/watcher/providers/github.ts +156 -0
- package/src/watcher/providers/gmail.ts +1 -0
- package/src/watcher/providers/google-calendar.ts +1 -0
- package/src/watcher/providers/linear.ts +460 -0
- package/src/watcher/providers/slack.ts +1 -0
- package/src/work-items/work-item-runner.ts +1 -1
- package/src/workspace/git-service.ts +1 -1
- package/src/workspace/provider-commit-message-generator.ts +51 -22
- package/src/__tests__/call-bridge.test.ts +0 -517
- package/src/__tests__/session-process-bridge.test.ts +0 -244
- package/src/calls/call-bridge.ts +0 -168
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +0 -199
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomicity tests for memory UPSERT paths.
|
|
3
|
+
*
|
|
4
|
+
* SQLite is single-writer, and indexMessageNow / createOrUpdatePendingConflict
|
|
5
|
+
* are synchronous functions. Because every call runs to completion before the
|
|
6
|
+
* next microtask starts, the Promise.all / Promise.resolve().then() pattern
|
|
7
|
+
* used here does NOT create true concurrent execution — calls still run
|
|
8
|
+
* sequentially.
|
|
9
|
+
*
|
|
10
|
+
* What these tests DO verify is the correctness of the ON CONFLICT /
|
|
11
|
+
* IMMEDIATE-transaction logic when the same logical operation is repeated many
|
|
12
|
+
* times (e.g. duplicate indexer runs for the same messageId). That covers the
|
|
13
|
+
* most common real-world correctness problem: a retry or a duplicate dispatch
|
|
14
|
+
* reaching the same code path more than once.
|
|
15
|
+
*
|
|
16
|
+
* True OS-level thread concurrency would require spawning separate worker
|
|
17
|
+
* processes and is not tested here.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
21
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { eq } from 'drizzle-orm';
|
|
25
|
+
|
|
26
|
+
const testDir = mkdtempSync(join(tmpdir(), 'memory-upsert-concurrency-'));
|
|
27
|
+
|
|
28
|
+
mock.module('../util/platform.js', () => ({
|
|
29
|
+
getDataDir: () => testDir,
|
|
30
|
+
isMacOS: () => process.platform === 'darwin',
|
|
31
|
+
isLinux: () => process.platform === 'linux',
|
|
32
|
+
isWindows: () => process.platform === 'win32',
|
|
33
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
34
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
35
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
36
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
37
|
+
ensureDataDir: () => {},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
mock.module('../util/logger.js', () => ({
|
|
41
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
42
|
+
get: () => () => {},
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
mock.module('../memory/qdrant-client.js', () => ({
|
|
47
|
+
getQdrantClient: () => ({
|
|
48
|
+
searchWithFilter: async () => [],
|
|
49
|
+
upsertPoints: async () => {},
|
|
50
|
+
deletePoints: async () => {},
|
|
51
|
+
}),
|
|
52
|
+
initQdrantClient: () => {},
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
import { DEFAULT_CONFIG } from '../config/defaults.js';
|
|
56
|
+
|
|
57
|
+
const TEST_CONFIG = {
|
|
58
|
+
...DEFAULT_CONFIG,
|
|
59
|
+
memory: {
|
|
60
|
+
...DEFAULT_CONFIG.memory,
|
|
61
|
+
enabled: true,
|
|
62
|
+
extraction: {
|
|
63
|
+
...DEFAULT_CONFIG.memory.extraction,
|
|
64
|
+
useLLM: false,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
mock.module('../config/loader.js', () => ({
|
|
70
|
+
loadConfig: () => TEST_CONFIG,
|
|
71
|
+
getConfig: () => TEST_CONFIG,
|
|
72
|
+
loadRawConfig: () => ({}),
|
|
73
|
+
saveRawConfig: () => {},
|
|
74
|
+
invalidateConfigCache: () => {},
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
78
|
+
import {
|
|
79
|
+
conversations,
|
|
80
|
+
memoryItems,
|
|
81
|
+
memorySegments,
|
|
82
|
+
messages,
|
|
83
|
+
} from '../memory/schema.js';
|
|
84
|
+
import { indexMessageNow } from '../memory/indexer.js';
|
|
85
|
+
import { createOrUpdatePendingConflict, listPendingConflicts } from '../memory/conflict-store.js';
|
|
86
|
+
|
|
87
|
+
// Initialize DB once for the entire file. Each test cleans its own tables.
|
|
88
|
+
initializeDb();
|
|
89
|
+
|
|
90
|
+
afterAll(() => {
|
|
91
|
+
resetDb();
|
|
92
|
+
try {
|
|
93
|
+
rmSync(testDir, { recursive: true });
|
|
94
|
+
} catch {
|
|
95
|
+
// best effort cleanup
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function resetTables() {
|
|
100
|
+
const db = getDb();
|
|
101
|
+
db.run('DELETE FROM memory_item_conflicts');
|
|
102
|
+
db.run('DELETE FROM memory_item_entities');
|
|
103
|
+
db.run('DELETE FROM memory_entity_relations');
|
|
104
|
+
db.run('DELETE FROM memory_entities');
|
|
105
|
+
db.run('DELETE FROM memory_item_sources');
|
|
106
|
+
db.run('DELETE FROM memory_embeddings');
|
|
107
|
+
db.run('DELETE FROM memory_summaries');
|
|
108
|
+
db.run('DELETE FROM memory_items');
|
|
109
|
+
db.run('DELETE FROM memory_segment_fts');
|
|
110
|
+
db.run('DELETE FROM memory_segments');
|
|
111
|
+
db.run('DELETE FROM memory_jobs');
|
|
112
|
+
db.run('DELETE FROM messages');
|
|
113
|
+
db.run('DELETE FROM conversations');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Insert a minimal conversation + message row for FK references. */
|
|
117
|
+
function seedConversationAndMessage(
|
|
118
|
+
conversationId: string,
|
|
119
|
+
messageId: string,
|
|
120
|
+
text: string,
|
|
121
|
+
): void {
|
|
122
|
+
const db = getDb();
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
db.insert(conversations).values({
|
|
125
|
+
id: conversationId,
|
|
126
|
+
title: null,
|
|
127
|
+
createdAt: now,
|
|
128
|
+
updatedAt: now,
|
|
129
|
+
totalInputTokens: 0,
|
|
130
|
+
totalOutputTokens: 0,
|
|
131
|
+
totalEstimatedCost: 0,
|
|
132
|
+
contextSummary: null,
|
|
133
|
+
contextCompactedMessageCount: 0,
|
|
134
|
+
contextCompactedAt: null,
|
|
135
|
+
}).run();
|
|
136
|
+
|
|
137
|
+
db.insert(messages).values({
|
|
138
|
+
id: messageId,
|
|
139
|
+
conversationId,
|
|
140
|
+
role: 'user',
|
|
141
|
+
content: JSON.stringify([{ type: 'text', text }]),
|
|
142
|
+
createdAt: now,
|
|
143
|
+
}).run();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Insert a pair of memory items that can serve as conflict participants. */
|
|
147
|
+
function seedItemPair(suffix: string, scopeId = 'default'): { existingItemId: string; candidateItemId: string } {
|
|
148
|
+
const db = getDb();
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const existingItemId = `existing-${suffix}`;
|
|
151
|
+
const candidateItemId = `candidate-${suffix}`;
|
|
152
|
+
db.insert(memoryItems).values([
|
|
153
|
+
{
|
|
154
|
+
id: existingItemId,
|
|
155
|
+
kind: 'preference',
|
|
156
|
+
subject: 'framework preference',
|
|
157
|
+
statement: `Existing statement ${suffix}`,
|
|
158
|
+
status: 'active',
|
|
159
|
+
confidence: 0.8,
|
|
160
|
+
importance: 0.7,
|
|
161
|
+
fingerprint: `fp-existing-${suffix}`,
|
|
162
|
+
verificationState: 'assistant_inferred',
|
|
163
|
+
scopeId,
|
|
164
|
+
firstSeenAt: now,
|
|
165
|
+
lastSeenAt: now,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: candidateItemId,
|
|
169
|
+
kind: 'preference',
|
|
170
|
+
subject: 'framework preference',
|
|
171
|
+
statement: `Candidate statement ${suffix}`,
|
|
172
|
+
status: 'pending_clarification',
|
|
173
|
+
confidence: 0.8,
|
|
174
|
+
importance: 0.7,
|
|
175
|
+
fingerprint: `fp-candidate-${suffix}`,
|
|
176
|
+
verificationState: 'assistant_inferred',
|
|
177
|
+
scopeId,
|
|
178
|
+
firstSeenAt: now,
|
|
179
|
+
lastSeenAt: now,
|
|
180
|
+
},
|
|
181
|
+
]).run();
|
|
182
|
+
return { existingItemId, candidateItemId };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
// Test suite: segment UPSERT atomicity under parallel indexer load
|
|
187
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
describe('segment UPSERT atomicity under repeated indexer invocations', () => {
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
resetTables();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('repeated indexing of the same message does not create duplicate segments', async () => {
|
|
195
|
+
// Index the same messageId multiple times (simulating duplicate indexer
|
|
196
|
+
// dispatches, retries, or a race at the call site). The ON CONFLICT DO
|
|
197
|
+
// UPDATE on memorySegments.id must absorb every duplicate call.
|
|
198
|
+
const conversationId = 'conv-parallel-segment-dedup';
|
|
199
|
+
const messageId = 'msg-parallel-segment-dedup';
|
|
200
|
+
const text = 'I prefer TypeScript over plain JavaScript for large projects.';
|
|
201
|
+
|
|
202
|
+
seedConversationAndMessage(conversationId, messageId, text);
|
|
203
|
+
|
|
204
|
+
const db = getDb();
|
|
205
|
+
const config = TEST_CONFIG.memory;
|
|
206
|
+
|
|
207
|
+
// Call indexMessageNow N times for the same messageId. Even though we use
|
|
208
|
+
// Promise.all, these synchronous calls still run sequentially — the point is
|
|
209
|
+
// to verify that repeated indexer runs for the same messageId do not produce
|
|
210
|
+
// duplicate segment rows (i.e. the ON CONFLICT DO UPDATE absorbs them).
|
|
211
|
+
const WORKERS = 8;
|
|
212
|
+
await Promise.all(
|
|
213
|
+
Array.from({ length: WORKERS }, () =>
|
|
214
|
+
Promise.resolve().then(() =>
|
|
215
|
+
indexMessageNow(
|
|
216
|
+
{
|
|
217
|
+
messageId,
|
|
218
|
+
conversationId,
|
|
219
|
+
role: 'user',
|
|
220
|
+
content: JSON.stringify([{ type: 'text', text }]),
|
|
221
|
+
createdAt: Date.now(),
|
|
222
|
+
},
|
|
223
|
+
config,
|
|
224
|
+
),
|
|
225
|
+
),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const segments = db
|
|
230
|
+
.select()
|
|
231
|
+
.from(memorySegments)
|
|
232
|
+
.where(eq(memorySegments.messageId, messageId))
|
|
233
|
+
.all();
|
|
234
|
+
|
|
235
|
+
// Each physical segment (identified by segmentId = messageId + segmentIndex)
|
|
236
|
+
// must appear exactly once regardless of how many indexer calls ran.
|
|
237
|
+
const idCounts = new Map<string, number>();
|
|
238
|
+
for (const seg of segments) {
|
|
239
|
+
idCounts.set(seg.id, (idCounts.get(seg.id) ?? 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
for (const [segId, count] of idCounts) {
|
|
242
|
+
expect(count).toBe(1);
|
|
243
|
+
expect(segId.startsWith(messageId)).toBe(true);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('indexing distinct messages produces independent segment sets', async () => {
|
|
248
|
+
// Different messages indexed in the same batch must each produce their own
|
|
249
|
+
// non-overlapping segments with correct messageId back-references.
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
const conversationId = 'conv-parallel-distinct';
|
|
252
|
+
const db = getDb();
|
|
253
|
+
|
|
254
|
+
db.insert(conversations).values({
|
|
255
|
+
id: conversationId,
|
|
256
|
+
title: null,
|
|
257
|
+
createdAt: now,
|
|
258
|
+
updatedAt: now,
|
|
259
|
+
totalInputTokens: 0,
|
|
260
|
+
totalOutputTokens: 0,
|
|
261
|
+
totalEstimatedCost: 0,
|
|
262
|
+
contextSummary: null,
|
|
263
|
+
contextCompactedMessageCount: 0,
|
|
264
|
+
contextCompactedAt: null,
|
|
265
|
+
}).run();
|
|
266
|
+
|
|
267
|
+
const MSG_COUNT = 6;
|
|
268
|
+
for (let i = 0; i < MSG_COUNT; i++) {
|
|
269
|
+
db.insert(messages).values({
|
|
270
|
+
id: `msg-distinct-${i}`,
|
|
271
|
+
conversationId,
|
|
272
|
+
role: 'user',
|
|
273
|
+
content: JSON.stringify([{ type: 'text', text: `Distinct message content for worker ${i}, covering a unique topic that should be stored separately.` }]),
|
|
274
|
+
createdAt: now + i,
|
|
275
|
+
}).run();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const config = TEST_CONFIG.memory;
|
|
279
|
+
|
|
280
|
+
// Call indexMessageNow once per distinct messageId. The calls run
|
|
281
|
+
// sequentially (synchronous functions), but grouping them here mirrors
|
|
282
|
+
// how a batch indexer would dispatch multiple messages and lets us assert
|
|
283
|
+
// that each message produces its own non-overlapping segment set.
|
|
284
|
+
await Promise.all(
|
|
285
|
+
Array.from({ length: MSG_COUNT }, (_, i) => {
|
|
286
|
+
const msgId = `msg-distinct-${i}`;
|
|
287
|
+
return Promise.resolve().then(() =>
|
|
288
|
+
indexMessageNow(
|
|
289
|
+
{
|
|
290
|
+
messageId: msgId,
|
|
291
|
+
conversationId,
|
|
292
|
+
role: 'user',
|
|
293
|
+
content: JSON.stringify([{ type: 'text', text: `Distinct message content for worker ${i}, covering a unique topic that should be stored separately.` }]),
|
|
294
|
+
createdAt: now + i,
|
|
295
|
+
},
|
|
296
|
+
config,
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// Every segment must reference its own message and no segment may appear
|
|
303
|
+
// for the wrong messageId.
|
|
304
|
+
for (let i = 0; i < MSG_COUNT; i++) {
|
|
305
|
+
const msgId = `msg-distinct-${i}`;
|
|
306
|
+
const segs = db
|
|
307
|
+
.select()
|
|
308
|
+
.from(memorySegments)
|
|
309
|
+
.where(eq(memorySegments.messageId, msgId))
|
|
310
|
+
.all();
|
|
311
|
+
|
|
312
|
+
// At least one segment must have been written.
|
|
313
|
+
expect(segs.length).toBeGreaterThanOrEqual(1);
|
|
314
|
+
|
|
315
|
+
// Segment IDs must be of the form `${msgId}:${index}`.
|
|
316
|
+
for (const seg of segs) {
|
|
317
|
+
expect(seg.id.startsWith(msgId + ':')).toBe(true);
|
|
318
|
+
expect(seg.messageId).toBe(msgId);
|
|
319
|
+
expect(seg.conversationId).toBe(conversationId);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('re-indexing with identical content does not change the stored segment', () => {
|
|
325
|
+
// When an indexer re-processes an already-indexed segment (same id + same
|
|
326
|
+
// content hash), the ON CONFLICT DO UPDATE path must run but the row must
|
|
327
|
+
// remain semantically equivalent to the original.
|
|
328
|
+
const conversationId = 'conv-stable-rehash';
|
|
329
|
+
const messageId = 'msg-stable-rehash';
|
|
330
|
+
const text = 'My preferred timezone is America/Los_Angeles and I work remotely.';
|
|
331
|
+
|
|
332
|
+
seedConversationAndMessage(conversationId, messageId, text);
|
|
333
|
+
|
|
334
|
+
const config = TEST_CONFIG.memory;
|
|
335
|
+
|
|
336
|
+
const firstResult = indexMessageNow(
|
|
337
|
+
{ messageId, conversationId, role: 'user', content: JSON.stringify([{ type: 'text', text }]), createdAt: Date.now() },
|
|
338
|
+
config,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const db = getDb();
|
|
342
|
+
const segmentsAfterFirst = db
|
|
343
|
+
.select()
|
|
344
|
+
.from(memorySegments)
|
|
345
|
+
.where(eq(memorySegments.messageId, messageId))
|
|
346
|
+
.all();
|
|
347
|
+
|
|
348
|
+
// Re-index twice more with the same payload.
|
|
349
|
+
indexMessageNow(
|
|
350
|
+
{ messageId, conversationId, role: 'user', content: JSON.stringify([{ type: 'text', text }]), createdAt: Date.now() },
|
|
351
|
+
config,
|
|
352
|
+
);
|
|
353
|
+
indexMessageNow(
|
|
354
|
+
{ messageId, conversationId, role: 'user', content: JSON.stringify([{ type: 'text', text }]), createdAt: Date.now() },
|
|
355
|
+
config,
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const segmentsAfterRehash = db
|
|
359
|
+
.select()
|
|
360
|
+
.from(memorySegments)
|
|
361
|
+
.where(eq(memorySegments.messageId, messageId))
|
|
362
|
+
.all();
|
|
363
|
+
|
|
364
|
+
// Segment count must not have grown.
|
|
365
|
+
expect(segmentsAfterRehash.length).toBe(segmentsAfterFirst.length);
|
|
366
|
+
|
|
367
|
+
// Content hashes must match between first and subsequent indexings.
|
|
368
|
+
const firstById = new Map(segmentsAfterFirst.map((s) => [s.id, s]));
|
|
369
|
+
for (const seg of segmentsAfterRehash) {
|
|
370
|
+
const original = firstById.get(seg.id);
|
|
371
|
+
expect(original).toBeDefined();
|
|
372
|
+
expect(seg.contentHash).toBe(original!.contentHash);
|
|
373
|
+
expect(seg.text).toBe(original!.text);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The indexer must have reported the correct segment count both times.
|
|
377
|
+
expect(firstResult.indexedSegments).toBeGreaterThanOrEqual(1);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('re-indexing same message with different content applies last-write semantics', async () => {
|
|
381
|
+
// When indexMessageNow is called twice for the same messageId with different
|
|
382
|
+
// content (simulating an edit followed by a re-index), the ON CONFLICT DO
|
|
383
|
+
// UPDATE must store one row per segmentId. We cannot assert which text
|
|
384
|
+
// "wins" — only that no duplicate rows exist.
|
|
385
|
+
const conversationId = 'conv-edit-race';
|
|
386
|
+
const messageId = 'msg-edit-race';
|
|
387
|
+
const textV1 = 'I prefer React for frontend development work on large projects.';
|
|
388
|
+
const textV2 = 'I prefer Vue for frontend development work on large projects instead.';
|
|
389
|
+
|
|
390
|
+
seedConversationAndMessage(conversationId, messageId, textV1);
|
|
391
|
+
|
|
392
|
+
const config = TEST_CONFIG.memory;
|
|
393
|
+
|
|
394
|
+
// Call indexMessageNow twice with different content for the same messageId,
|
|
395
|
+
// running sequentially. The ON CONFLICT DO UPDATE must absorb both calls
|
|
396
|
+
// and leave exactly one row per segmentId regardless of which content wins.
|
|
397
|
+
await Promise.all([
|
|
398
|
+
Promise.resolve().then(() =>
|
|
399
|
+
indexMessageNow(
|
|
400
|
+
{ messageId, conversationId, role: 'user', content: JSON.stringify([{ type: 'text', text: textV1 }]), createdAt: Date.now() },
|
|
401
|
+
config,
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
Promise.resolve().then(() =>
|
|
405
|
+
indexMessageNow(
|
|
406
|
+
{ messageId, conversationId, role: 'user', content: JSON.stringify([{ type: 'text', text: textV2 }]), createdAt: Date.now() },
|
|
407
|
+
config,
|
|
408
|
+
),
|
|
409
|
+
),
|
|
410
|
+
]);
|
|
411
|
+
|
|
412
|
+
const db = getDb();
|
|
413
|
+
const segments = db
|
|
414
|
+
.select()
|
|
415
|
+
.from(memorySegments)
|
|
416
|
+
.where(eq(memorySegments.messageId, messageId))
|
|
417
|
+
.all();
|
|
418
|
+
|
|
419
|
+
// No duplicate segment IDs — each logical segment must appear at most once.
|
|
420
|
+
const ids = segments.map((s) => s.id);
|
|
421
|
+
const uniqueIds = new Set(ids);
|
|
422
|
+
expect(uniqueIds.size).toBe(ids.length);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
427
|
+
// Test suite: conflict creation UPSERT atomicity
|
|
428
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
describe('conflict creation UPSERT atomicity', () => {
|
|
431
|
+
beforeEach(() => {
|
|
432
|
+
resetTables();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('repeated createOrUpdatePendingConflict calls for the same pair produce exactly one conflict row', async () => {
|
|
436
|
+
// Critical UPSERT path: the same conflict pair inserted multiple times
|
|
437
|
+
// (e.g. duplicate worker dispatches, retries). The IMMEDIATE transaction
|
|
438
|
+
// guard in createOrUpdatePendingConflict must ensure only one row exists.
|
|
439
|
+
const pair = seedItemPair('parallel-create');
|
|
440
|
+
|
|
441
|
+
// Call createOrUpdatePendingConflict N times for the same pair. Calls run
|
|
442
|
+
// sequentially (synchronous); the test verifies that repeated calls produce
|
|
443
|
+
// exactly one conflict row — the IMMEDIATE transaction deduplication path.
|
|
444
|
+
const WORKERS = 10;
|
|
445
|
+
const results = await Promise.all(
|
|
446
|
+
Array.from({ length: WORKERS }, (_, i) =>
|
|
447
|
+
Promise.resolve().then(() =>
|
|
448
|
+
createOrUpdatePendingConflict({
|
|
449
|
+
scopeId: 'default',
|
|
450
|
+
existingItemId: pair.existingItemId,
|
|
451
|
+
candidateItemId: pair.candidateItemId,
|
|
452
|
+
relationship: 'ambiguous_contradiction',
|
|
453
|
+
clarificationQuestion: `Worker ${i} discovered a contradiction`,
|
|
454
|
+
}),
|
|
455
|
+
),
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// All callers must receive the same conflict ID — the deduplication path
|
|
460
|
+
// returns the existing row on the second and subsequent calls.
|
|
461
|
+
const firstId = results[0].id;
|
|
462
|
+
for (const result of results) {
|
|
463
|
+
expect(result.id).toBe(firstId);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Exactly one pending conflict row in the DB.
|
|
467
|
+
const pending = listPendingConflicts('default');
|
|
468
|
+
expect(pending).toHaveLength(1);
|
|
469
|
+
expect(pending[0].id).toBe(firstId);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('conflict creation for different pairs produces distinct rows without cross-contamination', async () => {
|
|
473
|
+
// Each unique item pair must get its own conflict row — deduplication must
|
|
474
|
+
// be scoped to the pair, not global. Also exercises the idempotent
|
|
475
|
+
// insert-then-update path within each pair.
|
|
476
|
+
const PAIR_COUNT = 6;
|
|
477
|
+
const pairs = Array.from({ length: PAIR_COUNT }, (_, i) => seedItemPair(`multi-pair-${i}`));
|
|
478
|
+
|
|
479
|
+
// For each pair, make two calls: one insert and one update. All calls run
|
|
480
|
+
// sequentially. The test verifies that each pair ends up with exactly one
|
|
481
|
+
// conflict row (no cross-pair contamination, idempotent update path works).
|
|
482
|
+
await Promise.all(
|
|
483
|
+
pairs.flatMap((pair) => [
|
|
484
|
+
// First call: insert with 'contradiction'.
|
|
485
|
+
Promise.resolve().then(() =>
|
|
486
|
+
createOrUpdatePendingConflict({
|
|
487
|
+
scopeId: 'default',
|
|
488
|
+
existingItemId: pair.existingItemId,
|
|
489
|
+
candidateItemId: pair.candidateItemId,
|
|
490
|
+
relationship: 'contradiction',
|
|
491
|
+
}),
|
|
492
|
+
),
|
|
493
|
+
// Second call: update to 'ambiguous_contradiction' — tests the idempotent update path.
|
|
494
|
+
Promise.resolve().then(() =>
|
|
495
|
+
createOrUpdatePendingConflict({
|
|
496
|
+
scopeId: 'default',
|
|
497
|
+
existingItemId: pair.existingItemId,
|
|
498
|
+
candidateItemId: pair.candidateItemId,
|
|
499
|
+
relationship: 'ambiguous_contradiction',
|
|
500
|
+
}),
|
|
501
|
+
),
|
|
502
|
+
]),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
// Each pair must have produced exactly one pending conflict.
|
|
506
|
+
const pending = listPendingConflicts('default');
|
|
507
|
+
expect(pending).toHaveLength(PAIR_COUNT);
|
|
508
|
+
|
|
509
|
+
// All conflict IDs must be unique.
|
|
510
|
+
const ids = pending.map((c) => c.id);
|
|
511
|
+
expect(new Set(ids).size).toBe(PAIR_COUNT);
|
|
512
|
+
|
|
513
|
+
// Each returned conflict must reference the correct item pair.
|
|
514
|
+
for (let i = 0; i < PAIR_COUNT; i++) {
|
|
515
|
+
const pair = pairs[i];
|
|
516
|
+
const found = pending.find(
|
|
517
|
+
(c) => c.existingItemId === pair.existingItemId && c.candidateItemId === pair.candidateItemId,
|
|
518
|
+
);
|
|
519
|
+
expect(found).toBeDefined();
|
|
520
|
+
// The update call ran after the insert, so relationship is ambiguous_contradiction.
|
|
521
|
+
expect(found!.relationship).toBe('ambiguous_contradiction');
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('repeated updates to the same conflict row converge to a consistent state', async () => {
|
|
526
|
+
// Multiple update calls for the same conflict (e.g. repeated worker runs).
|
|
527
|
+
// All updates must succeed (last writer wins is acceptable) and the row
|
|
528
|
+
// must remain internally consistent.
|
|
529
|
+
const pair = seedItemPair('concurrent-update');
|
|
530
|
+
const first = createOrUpdatePendingConflict({
|
|
531
|
+
scopeId: 'default',
|
|
532
|
+
existingItemId: pair.existingItemId,
|
|
533
|
+
candidateItemId: pair.candidateItemId,
|
|
534
|
+
relationship: 'contradiction',
|
|
535
|
+
clarificationQuestion: 'Initial question',
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Call createOrUpdatePendingConflict N times against the same existing row.
|
|
539
|
+
// Calls are sequential; the test verifies the row stays consistent (one row,
|
|
540
|
+
// valid status/relationship) after repeated updates — last writer wins.
|
|
541
|
+
const UPDATES = 8;
|
|
542
|
+
const results = await Promise.all(
|
|
543
|
+
Array.from({ length: UPDATES }, (_, i) =>
|
|
544
|
+
Promise.resolve().then(() =>
|
|
545
|
+
createOrUpdatePendingConflict({
|
|
546
|
+
scopeId: 'default',
|
|
547
|
+
existingItemId: pair.existingItemId,
|
|
548
|
+
candidateItemId: pair.candidateItemId,
|
|
549
|
+
relationship: 'ambiguous_contradiction',
|
|
550
|
+
clarificationQuestion: `Updated question from worker ${i}`,
|
|
551
|
+
}),
|
|
552
|
+
),
|
|
553
|
+
),
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
// All calls must return the same conflict ID.
|
|
557
|
+
for (const result of results) {
|
|
558
|
+
expect(result.id).toBe(first.id);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Still exactly one row in the DB.
|
|
562
|
+
const pending = listPendingConflicts('default');
|
|
563
|
+
expect(pending).toHaveLength(1);
|
|
564
|
+
|
|
565
|
+
// The row must be consistent: valid status, valid relationship.
|
|
566
|
+
const conflict = pending[0];
|
|
567
|
+
expect(conflict.status).toBe('pending_clarification');
|
|
568
|
+
expect(conflict.relationship).toBe('ambiguous_contradiction');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('scope isolation ensures conflicts in different scopes do not interfere', async () => {
|
|
572
|
+
// Conflicts created in different scopes must not cross-contaminate each
|
|
573
|
+
// other's conflict sets — scopeId must be part of the deduplication key.
|
|
574
|
+
const SCOPES = ['scope-alpha', 'scope-beta', 'scope-gamma'];
|
|
575
|
+
const scopePairs = SCOPES.map((scope) => ({ scope, pair: seedItemPair(`scope-${scope}`, scope) }));
|
|
576
|
+
|
|
577
|
+
// Make 3 calls per scope for all scopes. Calls run sequentially; the test
|
|
578
|
+
// verifies that each scope produces exactly one conflict row and that there
|
|
579
|
+
// is no cross-scope contamination from repeated same-scope calls.
|
|
580
|
+
await Promise.all(
|
|
581
|
+
scopePairs.flatMap(({ scope, pair }) =>
|
|
582
|
+
Array.from({ length: 3 }, () =>
|
|
583
|
+
Promise.resolve().then(() =>
|
|
584
|
+
createOrUpdatePendingConflict({
|
|
585
|
+
scopeId: scope,
|
|
586
|
+
existingItemId: pair.existingItemId,
|
|
587
|
+
candidateItemId: pair.candidateItemId,
|
|
588
|
+
relationship: 'contradiction',
|
|
589
|
+
}),
|
|
590
|
+
),
|
|
591
|
+
),
|
|
592
|
+
),
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
for (const scope of SCOPES) {
|
|
596
|
+
const pending = listPendingConflicts(scope);
|
|
597
|
+
// Exactly one conflict per scope, no cross-scope leakage.
|
|
598
|
+
expect(pending).toHaveLength(1);
|
|
599
|
+
expect(pending[0].scopeId).toBe(scope);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
605
|
+
// Test suite: memory segment job atomicity
|
|
606
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
describe('memory segment job atomicity under repeated indexer invocations', () => {
|
|
609
|
+
beforeEach(() => {
|
|
610
|
+
resetTables();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test('each unique (messageId, segmentIndex) pair generates at most one segment row', async () => {
|
|
614
|
+
// Re-index the same messages multiple times to verify that the job+segment
|
|
615
|
+
// transaction boundary is respected and no duplicate segment rows appear for
|
|
616
|
+
// the same logical (messageId, segmentIndex) identity.
|
|
617
|
+
const conversationId = 'conv-job-atomicity';
|
|
618
|
+
const now = Date.now();
|
|
619
|
+
const db = getDb();
|
|
620
|
+
|
|
621
|
+
db.insert(conversations).values({
|
|
622
|
+
id: conversationId,
|
|
623
|
+
title: null,
|
|
624
|
+
createdAt: now,
|
|
625
|
+
updatedAt: now,
|
|
626
|
+
totalInputTokens: 0,
|
|
627
|
+
totalOutputTokens: 0,
|
|
628
|
+
totalEstimatedCost: 0,
|
|
629
|
+
contextSummary: null,
|
|
630
|
+
contextCompactedMessageCount: 0,
|
|
631
|
+
contextCompactedAt: null,
|
|
632
|
+
}).run();
|
|
633
|
+
|
|
634
|
+
const MSG_COUNT = 5;
|
|
635
|
+
const REPEATS = 4; // how many times each message is re-indexed
|
|
636
|
+
for (let i = 0; i < MSG_COUNT; i++) {
|
|
637
|
+
db.insert(messages).values({
|
|
638
|
+
id: `msg-atomicity-${i}`,
|
|
639
|
+
conversationId,
|
|
640
|
+
role: 'user',
|
|
641
|
+
content: JSON.stringify([{ type: 'text', text: `Message ${i}: I prefer TypeScript and always follow functional programming patterns in my projects.` }]),
|
|
642
|
+
createdAt: now + i,
|
|
643
|
+
}).run();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const config = TEST_CONFIG.memory;
|
|
647
|
+
|
|
648
|
+
// Repeat indexMessageNow REPEATS times for each of MSG_COUNT messages. All
|
|
649
|
+
// calls run sequentially; the test verifies that repeated indexing of the
|
|
650
|
+
// same (messageId, segmentIndex) never produces duplicate segment rows.
|
|
651
|
+
await Promise.all(
|
|
652
|
+
Array.from({ length: REPEATS }, () =>
|
|
653
|
+
Array.from({ length: MSG_COUNT }, (_, i) => {
|
|
654
|
+
const msgId = `msg-atomicity-${i}`;
|
|
655
|
+
return Promise.resolve().then(() =>
|
|
656
|
+
indexMessageNow(
|
|
657
|
+
{
|
|
658
|
+
messageId: msgId,
|
|
659
|
+
conversationId,
|
|
660
|
+
role: 'user',
|
|
661
|
+
content: JSON.stringify([{ type: 'text', text: `Message ${i}: I prefer TypeScript and always follow functional programming patterns in my projects.` }]),
|
|
662
|
+
createdAt: now + i,
|
|
663
|
+
},
|
|
664
|
+
config,
|
|
665
|
+
),
|
|
666
|
+
);
|
|
667
|
+
}),
|
|
668
|
+
).flat(),
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
// For every message, count distinct segment IDs — there must be no
|
|
672
|
+
// duplicates regardless of how many indexer calls ran.
|
|
673
|
+
for (let i = 0; i < MSG_COUNT; i++) {
|
|
674
|
+
const msgId = `msg-atomicity-${i}`;
|
|
675
|
+
const segs = db
|
|
676
|
+
.select()
|
|
677
|
+
.from(memorySegments)
|
|
678
|
+
.where(eq(memorySegments.messageId, msgId))
|
|
679
|
+
.all();
|
|
680
|
+
|
|
681
|
+
const segIds = segs.map((s) => s.id);
|
|
682
|
+
const uniqueSegIds = new Set(segIds);
|
|
683
|
+
expect(uniqueSegIds.size).toBe(segIds.length);
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test('indexer result counts are consistent with actual stored segment counts', async () => {
|
|
688
|
+
// The IndexMessageResult.indexedSegments value returned by indexMessageNow
|
|
689
|
+
// must always match the number of rows stored in memory_segments for that
|
|
690
|
+
// message. Under repeated indexing the stored count stays stable while
|
|
691
|
+
// every result reports the same logical segment count.
|
|
692
|
+
const conversationId = 'conv-count-consistency';
|
|
693
|
+
const messageId = 'msg-count-consistency';
|
|
694
|
+
const text = 'I always prefer concise code reviews and I work in a distributed team across multiple timezones.';
|
|
695
|
+
|
|
696
|
+
seedConversationAndMessage(conversationId, messageId, text);
|
|
697
|
+
|
|
698
|
+
const config = TEST_CONFIG.memory;
|
|
699
|
+
|
|
700
|
+
// Index the same message RUNS times sequentially. The test verifies that
|
|
701
|
+
// the returned indexedSegments count is stable across all runs and matches
|
|
702
|
+
// the number of rows actually stored in the DB.
|
|
703
|
+
const RUNS = 5;
|
|
704
|
+
const results = await Promise.all(
|
|
705
|
+
Array.from({ length: RUNS }, () =>
|
|
706
|
+
Promise.resolve().then(() =>
|
|
707
|
+
indexMessageNow(
|
|
708
|
+
{ messageId, conversationId, role: 'user', content: JSON.stringify([{ type: 'text', text }]), createdAt: Date.now() },
|
|
709
|
+
config,
|
|
710
|
+
),
|
|
711
|
+
),
|
|
712
|
+
),
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
const db = getDb();
|
|
716
|
+
const storedSegments = db
|
|
717
|
+
.select()
|
|
718
|
+
.from(memorySegments)
|
|
719
|
+
.where(eq(memorySegments.messageId, messageId))
|
|
720
|
+
.all();
|
|
721
|
+
|
|
722
|
+
// All runs must agree on the segment count.
|
|
723
|
+
const firstCount = results[0].indexedSegments;
|
|
724
|
+
for (const result of results) {
|
|
725
|
+
expect(result.indexedSegments).toBe(firstCount);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Stored count must equal the reported logical count.
|
|
729
|
+
expect(storedSegments.length).toBe(firstCount);
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
734
|
+
// Test suite: memory_items fingerprint uniqueness under race conditions
|
|
735
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
describe('memory_items fingerprint uniqueness under race conditions', () => {
|
|
738
|
+
beforeEach(() => {
|
|
739
|
+
resetTables();
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('duplicate inserts with identical fingerprints produce exactly one row', () => {
|
|
743
|
+
// The memory_items table has a unique constraint on (fingerprint, scope_id).
|
|
744
|
+
// Two sequential inserts for the same fingerprint simulate duplicate extractor
|
|
745
|
+
// runs. Only one INSERT must land; the second must be absorbed by ON CONFLICT.
|
|
746
|
+
const db = getDb();
|
|
747
|
+
const now = Date.now();
|
|
748
|
+
const fingerprint = 'fp-race-unique-test-concurrency';
|
|
749
|
+
const scopeId = 'default';
|
|
750
|
+
|
|
751
|
+
// Use raw SQL to replicate what the items-extractor would do when a second
|
|
752
|
+
// run tries to INSERT the same fingerprint that already exists.
|
|
753
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
754
|
+
|
|
755
|
+
raw.run(`
|
|
756
|
+
INSERT INTO memory_items (
|
|
757
|
+
id, kind, subject, statement, status, confidence, importance,
|
|
758
|
+
fingerprint, verification_state, scope_id, first_seen_at, last_seen_at
|
|
759
|
+
) VALUES (
|
|
760
|
+
'item-race-1', 'preference', 'code style', 'I prefer tabs over spaces.',
|
|
761
|
+
'active', 0.8, 0.6, '${fingerprint}', 'user_reported', '${scopeId}',
|
|
762
|
+
${now}, ${now}
|
|
763
|
+
)
|
|
764
|
+
`);
|
|
765
|
+
|
|
766
|
+
// Second "worker" tries to insert the same fingerprint — must not create a
|
|
767
|
+
// duplicate. INSERT OR IGNORE / ON CONFLICT DO NOTHING is the expected
|
|
768
|
+
// behavior for the unique constraint.
|
|
769
|
+
expect(() => {
|
|
770
|
+
raw.run(`
|
|
771
|
+
INSERT OR IGNORE INTO memory_items (
|
|
772
|
+
id, kind, subject, statement, status, confidence, importance,
|
|
773
|
+
fingerprint, verification_state, scope_id, first_seen_at, last_seen_at
|
|
774
|
+
) VALUES (
|
|
775
|
+
'item-race-2', 'preference', 'code style', 'I prefer tabs over spaces.',
|
|
776
|
+
'active', 0.8, 0.6, '${fingerprint}', 'user_reported', '${scopeId}',
|
|
777
|
+
${now + 1}, ${now + 1}
|
|
778
|
+
)
|
|
779
|
+
`);
|
|
780
|
+
}).not.toThrow();
|
|
781
|
+
|
|
782
|
+
const rows = db
|
|
783
|
+
.select()
|
|
784
|
+
.from(memoryItems)
|
|
785
|
+
.all()
|
|
786
|
+
.filter((r) => r.fingerprint === fingerprint);
|
|
787
|
+
|
|
788
|
+
// Only the first insert must have landed.
|
|
789
|
+
expect(rows).toHaveLength(1);
|
|
790
|
+
expect(rows[0].id).toBe('item-race-1');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test('bare INSERT without IGNORE throws on duplicate fingerprint+scopeId', () => {
|
|
794
|
+
// Verify the DB-level unique constraint is actually enforced so that any code
|
|
795
|
+
// path that accidentally omits ON CONFLICT will fail loudly rather than silently
|
|
796
|
+
// producing inconsistent state.
|
|
797
|
+
const db = getDb();
|
|
798
|
+
const now = Date.now();
|
|
799
|
+
const fingerprint = 'fp-constraint-enforcement-test';
|
|
800
|
+
|
|
801
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
802
|
+
|
|
803
|
+
raw.run(`
|
|
804
|
+
INSERT INTO memory_items (
|
|
805
|
+
id, kind, subject, statement, status, confidence, importance,
|
|
806
|
+
fingerprint, verification_state, scope_id, first_seen_at, last_seen_at
|
|
807
|
+
) VALUES (
|
|
808
|
+
'item-constraint-a', 'preference', 'editor', 'I use VS Code.',
|
|
809
|
+
'active', 0.9, 0.7, '${fingerprint}', 'user_reported', 'default',
|
|
810
|
+
${now}, ${now}
|
|
811
|
+
)
|
|
812
|
+
`);
|
|
813
|
+
|
|
814
|
+
// A bare INSERT (no ON CONFLICT) for the same fingerprint+scope_id must throw.
|
|
815
|
+
expect(() => {
|
|
816
|
+
raw.run(`
|
|
817
|
+
INSERT INTO memory_items (
|
|
818
|
+
id, kind, subject, statement, status, confidence, importance,
|
|
819
|
+
fingerprint, verification_state, scope_id, first_seen_at, last_seen_at
|
|
820
|
+
) VALUES (
|
|
821
|
+
'item-constraint-b', 'preference', 'editor', 'I use VS Code.',
|
|
822
|
+
'active', 0.9, 0.7, '${fingerprint}', 'user_reported', 'default',
|
|
823
|
+
${now + 1}, ${now + 1}
|
|
824
|
+
)
|
|
825
|
+
`);
|
|
826
|
+
}).toThrow();
|
|
827
|
+
});
|
|
828
|
+
});
|