@vellumai/assistant 0.11.7 → 0.11.8-staging.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +10 -10
- package/Dockerfile +1 -0
- package/docs/architecture/security.md +1 -1
- package/docs/error-handling.md +1 -2
- package/docs/guardian-request-flow.md +51 -5
- package/docs/trusted-contact-access.md +13 -12
- package/knip.json +1 -0
- package/node_modules/@vellumai/avatar-catalog/package.json +19 -0
- package/{src/avatar/character-components.ts → node_modules/@vellumai/avatar-catalog/src/catalog.ts} +12 -44
- package/node_modules/@vellumai/avatar-catalog/src/colors.ts +19 -0
- package/node_modules/@vellumai/avatar-catalog/src/index.ts +25 -0
- package/node_modules/@vellumai/avatar-catalog/src/types.ts +47 -0
- package/node_modules/@vellumai/avatar-catalog/tsconfig.json +20 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -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/no-response.ts +84 -0
- package/node_modules/@vellumai/credential-storage/src/static-credentials.ts +43 -12
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
- package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -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/no-response.ts +84 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +8 -18
- package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +46 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +46 -0
- package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +62 -21
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +14 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +53 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +11 -5
- package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +1 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
- package/openapi.yaml +481 -9
- package/package.json +3 -1
- package/scripts/sync-llm-catalog.ts +3 -0
- package/src/__tests__/agent-loop-callsite-precedence.test.ts +16 -0
- package/src/__tests__/agent-loop-compacted-history-transform.test.ts +304 -0
- package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +3 -0
- package/src/__tests__/agent-wake-override-profile.test.ts +3 -0
- package/src/__tests__/anthropic-provider.test.ts +56 -0
- package/src/__tests__/approval-cascade.test.ts +0 -13
- package/src/__tests__/attachments-store.test.ts +117 -6
- package/src/__tests__/attachments.test.ts +122 -0
- package/src/__tests__/bundled-asset.test.ts +22 -1
- package/src/__tests__/channel-approval.test.ts +14 -7
- package/src/__tests__/channel-approvals.test.ts +3 -3
- package/src/__tests__/channel-delivery-store.test.ts +58 -0
- package/src/__tests__/channel-inbound-disk-pressure.test.ts +1 -0
- package/src/__tests__/channel-invite-transport.test.ts +1 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +3 -3
- package/src/__tests__/compaction-events.test.ts +0 -12
- package/src/__tests__/compactor-call-site-logging.test.ts +7 -1
- package/src/__tests__/compactor-retained-image-validation.test.ts +15 -0
- package/src/__tests__/config-schema.test.ts +35 -0
- package/src/__tests__/contacts-write.test.ts +141 -3
- package/src/__tests__/conversation-abort-tool-results.test.ts +0 -13
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +0 -12
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -13
- package/src/__tests__/conversation-agent-loop.test.ts +95 -13
- package/src/__tests__/conversation-confirmation-signals.test.ts +0 -13
- package/src/__tests__/conversation-load-history-repair.test.ts +418 -0
- package/src/__tests__/conversation-media-retry.test.ts +33 -2
- package/src/__tests__/conversation-pairing.test.ts +54 -0
- package/src/__tests__/conversation-process-callsite.test.ts +0 -12
- package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -13
- package/src/__tests__/conversation-queue.test.ts +4 -13
- package/src/__tests__/conversation-retry-route.test.ts +5 -0
- package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
- package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
- package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
- package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
- package/src/__tests__/conversation-sight-frames.test.ts +615 -0
- package/src/__tests__/conversation-slash-queue.test.ts +0 -13
- package/src/__tests__/conversation-slash-unknown.test.ts +0 -13
- package/src/__tests__/conversation-speed-override.test.ts +0 -12
- package/src/__tests__/conversation-store-ephemeral.test.ts +0 -12
- package/src/__tests__/conversation-summarize-route.test.ts +5 -0
- package/src/__tests__/conversation-summarize-up-to.test.ts +102 -0
- package/src/__tests__/conversation-wait-for-idle.test.ts +0 -12
- package/src/__tests__/conversation-workspace-cache-state.test.ts +0 -5
- package/src/__tests__/conversation-workspace-injection.test.ts +0 -17
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -17
- package/src/__tests__/credential-health-service.test.ts +45 -0
- package/src/__tests__/credential-record-retire.test.ts +256 -0
- package/src/__tests__/credential-record-write-through.test.ts +8 -4
- package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +0 -4
- package/src/__tests__/db-conversation-inference-profile-migration.test.ts +0 -4
- package/src/__tests__/db-llm-request-log-provider-migration.test.ts +0 -8
- package/src/__tests__/delete-propagation.test.ts +121 -3
- package/src/__tests__/evict-conversations-for-reload.test.ts +46 -6
- package/src/__tests__/file-ops-service.test.ts +8 -3
- package/src/__tests__/gemini-provider.test.ts +64 -0
- package/src/__tests__/guardian-action-service-scope.test.ts +118 -0
- package/src/__tests__/guardian-card-withdrawal.test.ts +299 -5
- package/src/__tests__/guardian-expiry-notifier.test.ts +117 -13
- package/src/__tests__/guardian-gateway-sim.test.ts +119 -0
- package/src/__tests__/guardian-gateway-sim.ts +56 -20
- package/src/__tests__/guardian-reply-router-answer-mode.test.ts +6 -0
- package/src/__tests__/guardian-request-status-sync.test.ts +162 -0
- package/src/__tests__/guardian-wait-controller.test.ts +6 -0
- package/src/__tests__/headless-browser-mode.test.ts +27 -0
- package/src/__tests__/helpers/mock-conversation.ts +2 -0
- package/src/__tests__/list-messages-system-card.test.ts +70 -0
- package/src/__tests__/llm-catalog-parity.test.ts +2 -0
- package/src/__tests__/managed-profile-guard.test.ts +2 -2
- package/src/__tests__/media-resolve-image-validation.test.ts +106 -0
- package/src/__tests__/media-stream-server-integration.test.ts +6 -0
- package/src/__tests__/non-member-access-request.test.ts +12 -0
- package/src/__tests__/notification-discord-adapter.test.ts +76 -4
- package/src/__tests__/oauth-commands-routes.test.ts +21 -0
- package/src/__tests__/openai-provider.test.ts +63 -0
- package/src/__tests__/outbound-slack-persistence.test.ts +114 -2
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +63 -1
- package/src/__tests__/persist-unsendable-image.test.ts +19 -0
- package/src/__tests__/plugin-app-serve-routes.test.ts +2 -2
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
- package/src/__tests__/process-message-background-slack.test.ts +1 -1
- package/src/__tests__/process-message-display-content.test.ts +1 -1
- package/src/__tests__/processing-flag-persist-failure.test.ts +0 -5
- package/src/__tests__/prompt-cache-cross-turn-stability.test.ts +0 -17
- package/src/__tests__/qdrant-manager.test.ts +6 -0
- package/src/__tests__/reaction-persistence.test.ts +58 -62
- package/src/__tests__/retry-thinking-adaptive-only.test.ts +67 -0
- package/src/__tests__/server-history-render.test.ts +22 -0
- package/src/__tests__/sight-frame-retention.test.ts +362 -0
- package/src/__tests__/slack-reaction-approvals.test.ts +44 -24
- package/src/__tests__/slack-reaction-guardian-approval.test.ts +29 -1
- package/src/__tests__/subagent-fork-notifications.test.ts +2 -0
- package/src/__tests__/subagent-manager-notify.test.ts +2 -0
- package/src/__tests__/subagent-notify-parent.test.ts +2 -0
- package/src/__tests__/subagent-notify-stale-rebuild.test.ts +85 -0
- package/src/__tests__/subagent-tool-gate-mode.test.ts +4 -4
- package/src/__tests__/terminal-tools.test.ts +9 -9
- package/src/__tests__/tool-result-spool.test.ts +5 -8
- package/src/__tests__/usage-attribution.test.ts +26 -0
- package/src/acp/__tests__/acp-auth-marker-store.test.ts +191 -0
- package/src/acp/__tests__/acp-claude-oauth.test.ts +262 -0
- package/src/acp/__tests__/acp-connect-card-state.test.ts +17 -0
- package/src/acp/__tests__/helpers/acp-history-db.ts +10 -3
- package/src/acp/acp-auth-marker-store.ts +196 -0
- package/src/acp/acp-claude-oauth.ts +154 -4
- package/src/acp/acp-connect-card-state.ts +49 -8
- package/src/acp/agent-process.ts +1 -0
- package/src/acp/auto-install.ts +1 -0
- package/src/acp/client-handler.ts +1 -0
- package/src/acp/prepare-agent-env.ts +79 -1
- package/src/acp/resolve-agent.ts +12 -0
- package/src/acp/session-manager.test.ts +183 -0
- package/src/acp/session-manager.ts +110 -6
- package/src/acp/types.ts +17 -0
- package/src/agent/attachments.ts +7 -2
- package/src/agent/loop.ts +59 -1
- package/src/api/events/contact-form-closed.ts +32 -0
- package/src/api/events/contact-record-request.ts +63 -0
- package/src/api/events/contact-request.ts +10 -3
- package/src/api/index.ts +20 -0
- package/src/api/responses/conversation-message.ts +20 -0
- package/src/api/responses/home.ts +101 -0
- package/src/approvals/AGENTS.md +1 -1
- package/src/approvals/guardian-card-withdrawal.ts +127 -30
- package/src/approvals/guardian-decision-primitive.ts +9 -0
- package/src/approvals/guardian-expiry-notifier.ts +22 -16
- package/src/approvals/guardian-request-status-sync.ts +98 -0
- package/src/avatar/svg-compositor.ts +1 -1
- package/src/avatar/traits-png-sync.ts +1 -1
- package/src/bundler/app-compiler.ts +1 -0
- package/src/bundler/compiler-tools.ts +1 -0
- package/src/bundler/package-resolver.ts +1 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +183 -3
- package/src/calls/guardian-action-sweep.ts +6 -1
- package/src/calls/voice-session-bridge.ts +46 -1
- package/src/channels/__tests__/gateway-guardian-requests.test.ts +13 -18
- package/src/channels/gateway-guardian-requests.ts +37 -18
- package/src/cli/__tests__/contacts-record-prompt-cli.test.ts +329 -0
- package/src/cli/commands/__tests__/browser.test.ts +65 -1
- package/src/cli/commands/browser.ts +30 -10
- package/src/cli/commands/contacts.help.ts +173 -11
- package/src/cli/commands/contacts.ts +343 -4
- package/src/cli/commands/db/refresh.ts +1 -0
- package/src/cli/commands/oauth/index.help.ts +6 -3
- package/src/cli/commands/oauth/request.test.ts +37 -0
- package/src/cli/commands/oauth/request.ts +7 -6
- package/src/cli/lib/bundled-marketplace.json +1 -1
- package/src/cli/lib/install-from-github.ts +20 -19
- package/src/cli/lib/install-plugin-dependencies.ts +3 -5
- package/src/cli/lib/merge-plugin-tree.ts +1 -0
- package/src/cli/lib/publish-plugin.ts +4 -1
- package/src/config/__tests__/balanced-model-experiment.test.ts +63 -31
- package/src/config/__tests__/default-profile-catalog.test.ts +2 -2
- package/src/config/balanced-model-experiment.ts +15 -9
- package/src/config/bundled-skills/contacts/SKILL.md +61 -18
- package/src/config/default-profile-catalog.ts +12 -11
- package/src/config/feature-flag-registry.json +22 -13
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/__tests__/live-voice.test.ts +13 -0
- package/src/config/schemas/live-voice.ts +7 -0
- package/src/config/schemas/sight.ts +45 -0
- package/src/config/skills.ts +2 -1
- package/src/contacts/__tests__/mirror-reconciler.test.ts +352 -0
- package/src/contacts/contact-store.ts +47 -4
- package/src/contacts/contacts-write.ts +5 -2
- package/src/contacts/mirror-reconciler.ts +210 -0
- package/src/contacts/mirror-tombstones.ts +42 -0
- package/src/contacts/notify-contacts-changed.ts +11 -2
- package/src/context/compactor.ts +47 -19
- package/src/context/tool-result-spool.ts +21 -14
- package/src/conversations/message-consolidation.ts +2 -2
- package/src/credential-health/credential-health-service.ts +37 -6
- package/src/daemon/conversation-agent-loop-handlers.ts +19 -2
- package/src/daemon/conversation-agent-loop.ts +100 -1
- package/src/daemon/conversation-media-retry.ts +11 -6
- package/src/daemon/conversation-messaging.ts +293 -30
- package/src/daemon/conversation-runtime-assembly.ts +67 -0
- package/src/daemon/conversation-sight-frames.ts +187 -0
- package/src/daemon/conversation-store.ts +18 -28
- package/src/daemon/conversation-surfaces.ts +12 -2
- package/src/daemon/conversation-tool-setup.ts +39 -10
- package/src/daemon/conversation.ts +159 -16
- package/src/daemon/daemon-control.ts +6 -17
- package/src/daemon/handlers/config-model.test.ts +1 -0
- package/src/daemon/handlers/conversations.ts +10 -11
- package/src/daemon/install-assistant-command.ts +1 -0
- package/src/daemon/lifecycle.ts +10 -13
- package/src/daemon/message-types/sync.ts +4 -0
- package/src/daemon/process-message.ts +2 -17
- package/src/daemon/reaction-history-render.test.ts +162 -0
- package/src/daemon/reaction-history-render.ts +115 -0
- package/src/daemon/reaction-record.test.ts +162 -0
- package/src/daemon/reaction-record.ts +83 -0
- package/src/daemon/shutdown-handlers.ts +0 -2
- package/src/daemon/summarize-boundary.ts +12 -0
- package/src/daemon/video-thumbnail.ts +1 -1
- package/src/home/feed-types.ts +4 -0
- package/src/home/feed-writer.ts +25 -0
- package/src/live-voice/__tests__/live-voice-attach-frame.test.ts +679 -0
- package/src/live-voice/__tests__/live-voice-attach-image.test.ts +8 -5
- package/src/live-voice/__tests__/live-voice-photo.test.ts +637 -1
- package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +787 -0
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +347 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +109 -0
- package/src/live-voice/live-voice-photo.ts +420 -65
- package/src/live-voice/live-voice-session.ts +311 -27
- package/src/live-voice/protocol.ts +147 -11
- package/src/messaging/provider-message-metadata.ts +7 -0
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +113 -1
- package/src/messaging/providers/channel-transport.ts +37 -0
- package/src/messaging/providers/discord/api.ts +4 -4
- package/src/messaging/providers/discord/send.test.ts +177 -2
- package/src/messaging/providers/discord/send.ts +197 -10
- package/src/messaging/providers/discord/transport.ts +10 -0
- package/src/messaging/providers/index.ts +42 -0
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +129 -3
- package/src/messaging/providers/slack/__tests__/auth.test.ts +35 -0
- package/src/messaging/providers/slack/__tests__/message-content.test.ts +152 -0
- package/src/messaging/providers/slack/adapter.ts +112 -27
- package/src/messaging/providers/slack/api.test.ts +10 -0
- package/src/messaging/providers/slack/auth.ts +32 -6
- package/src/messaging/providers/slack/client.ts +0 -15
- package/src/messaging/providers/slack/message-content.ts +137 -0
- package/src/messaging/providers/slack/send.test.ts +43 -2
- package/src/messaging/providers/slack/send.ts +18 -5
- package/src/messaging/providers/slack/transport.ts +10 -0
- package/src/messaging/providers/slack/types.ts +21 -2
- package/src/messaging/providers/telegram-bot/adapter.ts +5 -1
- package/src/messaging/providers/telegram-bot/send.test.ts +49 -2
- package/src/messaging/providers/telegram-bot/send.ts +41 -1
- package/src/messaging/providers/telegram-bot/transport.ts +10 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +13 -5
- package/src/messaging/reaction-envelopes.ts +79 -0
- package/src/monitoring/control.ts +2 -2
- package/src/monitoring/page-cache.ts +5 -1
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/__tests__/guardian-feed-projection.test.ts +360 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +8 -0
- package/src/notifications/adapters/discord.ts +62 -9
- package/src/notifications/conversation-pairing.ts +31 -4
- package/src/notifications/emit-signal.ts +8 -5
- package/src/notifications/guardian-delivery-recorder.ts +47 -6
- package/src/notifications/guardian-feed-projection.ts +492 -0
- package/src/notifications/home-feed-side-effect.ts +59 -11
- package/src/oauth/byo-connection.test.ts +22 -0
- package/src/oauth/byo-connection.ts +11 -5
- package/src/oauth/connection.ts +3 -2
- package/src/oauth/platform-connection.test.ts +35 -0
- package/src/oauth/platform-connection.ts +18 -13
- package/src/oauth/scope-utils.ts +48 -0
- package/src/oauth/seed-providers.ts +12 -0
- package/src/outbound-proxy/certs.ts +5 -4
- package/src/permissions/question-guardian-request.test.ts +7 -1
- package/src/persistence/attachments-store.ts +134 -31
- package/src/persistence/conversation-crud.ts +137 -4
- package/src/persistence/conversation-types.ts +109 -0
- package/src/persistence/db-connection.ts +3 -21
- package/src/persistence/delivery-crud.ts +42 -12
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +15 -2
- package/src/persistence/embeddings/embedding-local.ts +7 -112
- package/src/persistence/embeddings/qdrant-manager.ts +4 -1
- package/src/persistence/llm-request-log-store.ts +1 -1
- package/src/persistence/migrations/370-acp-session-history-auth-error-code.ts +36 -0
- package/src/persistence/migrations/371-acp-session-history-auth-error-credential.ts +40 -0
- package/src/persistence/migrations/372-create-acp-refused-credentials.ts +34 -0
- package/src/persistence/migrations/373-acp-auth-marker-index.ts +27 -0
- package/src/persistence/migrations/__tests__/370-acp-session-history-auth-error-code.test.ts +65 -0
- package/src/persistence/migrations/__tests__/371-acp-session-history-auth-error-credential.test.ts +68 -0
- package/src/persistence/migrations/__tests__/372-create-acp-refused-credentials.test.ts +64 -0
- package/src/persistence/migrations/__tests__/373-acp-auth-marker-index.test.ts +82 -0
- package/src/persistence/schema/acp.ts +25 -0
- package/src/persistence/steps.ts +8 -0
- package/src/platform/platform-patch-queue.test.ts +43 -41
- package/src/plugin-api/index.ts +12 -0
- package/src/plugins/defaults/compaction/window-manager.ts +2 -0
- package/src/plugins/defaults/image-recovery/recover.ts +12 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +166 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +3 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +3 -0
- package/src/plugins/defaults/memory/jobs-worker.ts +1 -2
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +53 -9
- package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +4 -7
- package/src/plugins/defaults/memory/src/memory-worker-routes.ts +1 -1
- package/src/plugins/defaults/memory/v1/job-handlers/index-maintenance.ts +15 -1
- package/src/plugins/defaults/memory/worker-control.ts +1 -2
- package/src/providers/content-block-schema.ts +1 -0
- package/src/providers/fireworks/client.ts +9 -1
- package/src/providers/gemini/client.ts +22 -15
- package/src/providers/media-resolve.ts +24 -10
- package/src/providers/model-catalog.ts +91 -0
- package/src/providers/openai/chat-completions-provider.ts +44 -5
- package/src/providers/retry.ts +16 -11
- package/src/providers/types.ts +56 -0
- package/src/routes/__tests__/control.test.ts +4 -3
- package/src/routes/control.ts +1 -1
- package/src/routes/route-host-client.ts +1 -1
- package/src/runtime/AGENTS.md +30 -12
- package/src/runtime/__tests__/agent-wake.test.ts +71 -6
- package/src/runtime/__tests__/guardian-form-registry.test.ts +242 -0
- package/src/runtime/access-request-helper.ts +15 -7
- package/src/runtime/agent-wake.ts +16 -4
- package/src/runtime/channel-approval-types.ts +8 -5
- package/src/runtime/channel-invite-transport.ts +2 -0
- package/src/runtime/channel-invite-transports/discord.ts +27 -0
- package/src/runtime/channel-readiness-service.ts +4 -4
- package/src/runtime/channel-reply-delivery.ts +72 -28
- package/src/runtime/guardian-action-service.ts +9 -5
- package/src/runtime/guardian-form-registry.ts +256 -0
- package/src/runtime/guardian-reply-router.ts +33 -14
- package/src/runtime/invite-instruction-generator.ts +14 -2
- package/src/runtime/migrations/vbundle-metadata-merge.ts +4 -7
- package/src/runtime/no-response.ts +13 -74
- package/src/runtime/routes/__tests__/acp-routes.test.ts +527 -0
- package/src/runtime/routes/__tests__/app-management-shared-apps.test.ts +74 -0
- package/src/runtime/routes/__tests__/contact-form-kinds.test.ts +22 -0
- package/src/runtime/routes/__tests__/contact-record-prompt-route.test.ts +303 -0
- package/src/runtime/routes/__tests__/monitoring-routes.test.ts +3 -4
- package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +6 -0
- package/src/runtime/routes/__tests__/route-host-worker-routes.test.ts +3 -4
- package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +5 -6
- package/src/runtime/routes/acp-routes-list.test.ts +58 -0
- package/src/runtime/routes/acp-routes.ts +253 -47
- package/src/runtime/routes/app-management-routes.ts +80 -47
- package/src/runtime/routes/approval-routes.ts +9 -0
- package/src/runtime/routes/archive-utils.ts +6 -1
- package/src/runtime/routes/avatar-routes.ts +1 -1
- package/src/runtime/routes/channel-route-shared.ts +18 -20
- package/src/runtime/routes/contact-prompt-routes.ts +271 -105
- package/src/runtime/routes/conversation-routes.ts +41 -32
- package/src/runtime/routes/credential-prompt-routes.ts +10 -1
- package/src/runtime/routes/debug-bash-routes.ts +1 -0
- package/src/runtime/routes/guardian-expiry-sweep.ts +89 -31
- package/src/runtime/routes/guardian-form-routes.ts +115 -0
- package/src/runtime/routes/inbound-message-handler.ts +82 -29
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -3
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +6 -0
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +6 -0
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +6 -1
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +50 -17
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +97 -94
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/integrations/slack/channel.ts +39 -18
- package/src/runtime/routes/migration-routes.ts +1 -3
- package/src/runtime/routes/monitoring-routes.ts +0 -3
- package/src/runtime/routes/oauth-commands-routes.ts +18 -5
- package/src/runtime/routes/route-host-worker-routes.ts +1 -1
- package/src/runtime/routes/schedule-worker-routes.ts +1 -1
- package/src/runtime/tool-grant-request-helper.ts +4 -0
- package/src/schedule/run-script.ts +1 -0
- package/src/schedule/scheduler.ts +1 -1
- package/src/schedule/worker-control.ts +2 -2
- package/src/security/oauth2.ts +2 -11
- package/src/security/secure-keys.ts +55 -8
- package/src/skills/catalog-install.ts +7 -4
- package/src/skills/clawhub.ts +1 -0
- package/src/skills/inline-command-runner.ts +1 -0
- package/src/stt/__tests__/room-noise-floor.test.ts +108 -0
- package/src/stt/room-noise-floor.ts +140 -0
- package/src/subagent/notify.ts +49 -3
- package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
- package/src/tools/acp/spawn.ts +2 -2
- package/src/tools/browser/__tests__/browser-status.test.ts +9 -0
- package/src/tools/browser/browser-execution.ts +10 -43
- package/src/tools/browser/browser-manager.ts +1 -0
- package/src/tools/browser/browser-status-constants.ts +6 -5
- package/src/tools/browser/runtime-check.ts +2 -0
- package/src/tools/channel/react-to-message.test.ts +193 -0
- package/src/tools/channel/react-to-message.ts +159 -0
- package/src/tools/credentials/metadata-store.ts +115 -19
- package/src/tools/credentials/store.ts +0 -2
- package/src/tools/filesystem/read.ts +2 -2
- package/src/tools/host-filesystem/read.ts +2 -2
- package/src/tools/host-terminal/host-shell.test.ts +24 -0
- package/src/tools/host-terminal/host-shell.ts +11 -38
- package/src/tools/shared/filesystem/file-ops-service.ts +10 -7
- package/src/tools/shared/shell-output.test.ts +104 -0
- package/src/tools/shared/shell-output.ts +112 -39
- package/src/tools/skills/sandbox-runner.ts +1 -0
- package/src/tools/system/request-permission.test.ts +34 -0
- package/src/tools/system/request-permission.ts +29 -5
- package/src/tools/terminal/shell.test.ts +63 -0
- package/src/tools/terminal/shell.ts +12 -44
- package/src/tools/tool-input-schemas.ts +2 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/usage/attribution.ts +12 -4
- package/src/util/__tests__/worker-ownership.test.ts +77 -0
- package/src/util/__tests__/worker-process-command.test.ts +246 -1
- package/src/util/bun-runtime.ts +44 -13
- package/src/util/bundled-asset.ts +9 -1
- package/src/util/disk-usage.ts +1 -0
- package/src/util/file-use.ts +4 -1
- package/src/util/guardian-form-timeouts.ts +50 -0
- package/src/util/image-conversion.ts +1 -0
- package/src/util/oauth-request-body.test.ts +59 -0
- package/src/util/oauth-request-body.ts +56 -5
- package/src/util/platform.ts +69 -73
- package/src/util/process-table.ts +16 -5
- package/src/util/read-stdin.ts +5 -0
- package/src/util/spawn.ts +1 -0
- package/src/util/sqlite3-runtime.ts +25 -5
- package/src/util/worker-ownership.ts +193 -0
- package/src/util/worker-process.ts +375 -37
- package/src/workspace/git-service.ts +4 -12
- package/src/workspace/migrations/010-app-dir-rename.ts +1 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the pull-based contact-mirror reconciler.
|
|
3
|
+
*
|
|
4
|
+
* The reconciler converges the assistant identity mirror onto the gateway's
|
|
5
|
+
* identity snapshot: it must heal missing rows under the gateway's own ids,
|
|
6
|
+
* hand a divergent channel to the contact the gateway says owns it, and
|
|
7
|
+
* write NOTHING else. Assistant-authored data (notes, classification,
|
|
8
|
+
* curated names) and mirror-only rows (a2a peers) are exactly what a pull
|
|
9
|
+
* from the gateway cannot reconstruct, so preserving them is as load-bearing
|
|
10
|
+
* as the healing.
|
|
11
|
+
*
|
|
12
|
+
* The daemon DB is real; the gateway snapshot arrives through a mocked
|
|
13
|
+
* `ipcCallPersistent`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
import type { ContactsIdentitySnapshotIpcResponse } from "@vellumai/gateway-client/gateway-ipc-contracts";
|
|
19
|
+
|
|
20
|
+
let snapshot: ContactsIdentitySnapshotIpcResponse = { ok: true, contacts: [] };
|
|
21
|
+
const ipcCallPersistentMock = mock(async (method: string) => {
|
|
22
|
+
if (method !== "contacts_identity_snapshot") {
|
|
23
|
+
throw new Error(`unexpected IPC method: ${method}`);
|
|
24
|
+
}
|
|
25
|
+
return snapshot;
|
|
26
|
+
});
|
|
27
|
+
mock.module("../../ipc/gateway-client.js", () => ({
|
|
28
|
+
ipcCallPersistent: ipcCallPersistentMock,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import { getSqlite } from "../../persistence/db-connection.js";
|
|
32
|
+
import { initializeDb } from "../../persistence/db-init.js";
|
|
33
|
+
import { deleteContact, upsertContact } from "../contact-store.js";
|
|
34
|
+
import { upsertContactChannel } from "../contacts-write.js";
|
|
35
|
+
import { runMirrorReconcile } from "../mirror-reconciler.js";
|
|
36
|
+
await initializeDb();
|
|
37
|
+
|
|
38
|
+
function resetContactTables(): void {
|
|
39
|
+
const sqlite = getSqlite();
|
|
40
|
+
sqlite.run("DELETE FROM contact_channels");
|
|
41
|
+
sqlite.run("DELETE FROM contacts");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function contactRow(
|
|
45
|
+
id: string,
|
|
46
|
+
): { displayName: string; notes: string | null } | null {
|
|
47
|
+
return getSqlite()
|
|
48
|
+
.query(
|
|
49
|
+
"SELECT display_name AS displayName, notes FROM contacts WHERE id = ?",
|
|
50
|
+
)
|
|
51
|
+
.get(id) as { displayName: string; notes: string | null } | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function channelRow(
|
|
55
|
+
type: string,
|
|
56
|
+
address: string,
|
|
57
|
+
): { id: string; contactId: string; externalChatId: string | null } | null {
|
|
58
|
+
return getSqlite()
|
|
59
|
+
.query(
|
|
60
|
+
"SELECT id, contact_id AS contactId, external_chat_id AS externalChatId FROM contact_channels WHERE type = ? AND address = ? COLLATE NOCASE",
|
|
61
|
+
)
|
|
62
|
+
.get(type, address) as {
|
|
63
|
+
id: string;
|
|
64
|
+
contactId: string;
|
|
65
|
+
externalChatId: string | null;
|
|
66
|
+
} | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function gatewayContact(opts: {
|
|
70
|
+
id: string;
|
|
71
|
+
displayName: string;
|
|
72
|
+
channels?: Array<{
|
|
73
|
+
id: string;
|
|
74
|
+
type: string;
|
|
75
|
+
address: string;
|
|
76
|
+
externalChatId?: string | null;
|
|
77
|
+
isPrimary?: boolean;
|
|
78
|
+
}>;
|
|
79
|
+
}): ContactsIdentitySnapshotIpcResponse["contacts"][number] {
|
|
80
|
+
return {
|
|
81
|
+
id: opts.id,
|
|
82
|
+
displayName: opts.displayName,
|
|
83
|
+
channels: (opts.channels ?? []).map((ch) => ({
|
|
84
|
+
id: ch.id,
|
|
85
|
+
contactId: opts.id,
|
|
86
|
+
type: ch.type,
|
|
87
|
+
address: ch.address,
|
|
88
|
+
externalChatId: ch.externalChatId ?? null,
|
|
89
|
+
isPrimary: ch.isPrimary ?? false,
|
|
90
|
+
})),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
resetContactTables();
|
|
96
|
+
snapshot = { ok: true, contacts: [] };
|
|
97
|
+
ipcCallPersistentMock.mockClear();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("healing toward the gateway snapshot", () => {
|
|
101
|
+
test("recreates a missing contact + channel under the gateway ids", async () => {
|
|
102
|
+
snapshot = {
|
|
103
|
+
ok: true,
|
|
104
|
+
contacts: [
|
|
105
|
+
gatewayContact({
|
|
106
|
+
id: "co-gw",
|
|
107
|
+
displayName: "Alice Example",
|
|
108
|
+
channels: [
|
|
109
|
+
{
|
|
110
|
+
id: "ch-gw",
|
|
111
|
+
type: "slack",
|
|
112
|
+
address: "U123",
|
|
113
|
+
externalChatId: "D123",
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
}),
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await runMirrorReconcile();
|
|
121
|
+
|
|
122
|
+
expect(channelRow("slack", "U123")).toEqual({
|
|
123
|
+
id: "ch-gw",
|
|
124
|
+
contactId: "co-gw",
|
|
125
|
+
externalChatId: "D123",
|
|
126
|
+
});
|
|
127
|
+
expect(contactRow("co-gw")?.displayName).toBe("Alice Example");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("hands a divergent channel to the contact the gateway says owns it", async () => {
|
|
131
|
+
// The mirror believes the channel belongs to a stale owner.
|
|
132
|
+
upsertContactChannel({
|
|
133
|
+
sourceChannel: "slack",
|
|
134
|
+
externalUserId: "U123",
|
|
135
|
+
displayName: "Stale Owner",
|
|
136
|
+
contactId: "co-stale",
|
|
137
|
+
channelId: "ch-stale",
|
|
138
|
+
refreshDisplayName: true,
|
|
139
|
+
userFileOnCreate: null,
|
|
140
|
+
reassignConflictingChannels: false,
|
|
141
|
+
});
|
|
142
|
+
snapshot = {
|
|
143
|
+
ok: true,
|
|
144
|
+
contacts: [
|
|
145
|
+
gatewayContact({
|
|
146
|
+
id: "co-truth",
|
|
147
|
+
displayName: "Alice Example",
|
|
148
|
+
channels: [{ id: "ch-gw", type: "slack", address: "U123" }],
|
|
149
|
+
}),
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
await runMirrorReconcile();
|
|
154
|
+
|
|
155
|
+
expect(channelRow("slack", "U123")?.contactId).toBe("co-truth");
|
|
156
|
+
// The heal adopts the gateway id in the same write.
|
|
157
|
+
expect(channelRow("slack", "U123")?.id).toBe("ch-gw");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("adopts the gateway channel id onto a same-owner legacy row, then reaches a fixed point", async () => {
|
|
161
|
+
upsertContactChannel({
|
|
162
|
+
sourceChannel: "slack",
|
|
163
|
+
externalUserId: "U123",
|
|
164
|
+
displayName: "Alice Example",
|
|
165
|
+
contactId: "co-1",
|
|
166
|
+
channelId: "legacy-ch",
|
|
167
|
+
refreshDisplayName: true,
|
|
168
|
+
userFileOnCreate: null,
|
|
169
|
+
reassignConflictingChannels: false,
|
|
170
|
+
});
|
|
171
|
+
snapshot = {
|
|
172
|
+
ok: true,
|
|
173
|
+
contacts: [
|
|
174
|
+
gatewayContact({
|
|
175
|
+
id: "co-1",
|
|
176
|
+
displayName: "Alice Example",
|
|
177
|
+
channels: [{ id: "ch-gw", type: "slack", address: "U123" }],
|
|
178
|
+
}),
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
await runMirrorReconcile();
|
|
183
|
+
expect(channelRow("slack", "U123")?.id).toBe("ch-gw");
|
|
184
|
+
|
|
185
|
+
// Converged: a follow-up pass writes nothing.
|
|
186
|
+
const before = getSqlite()
|
|
187
|
+
.query("SELECT updated_at AS u FROM contact_channels WHERE id = 'ch-gw'")
|
|
188
|
+
.get() as { u: number };
|
|
189
|
+
await runMirrorReconcile();
|
|
190
|
+
const after = getSqlite()
|
|
191
|
+
.query("SELECT updated_at AS u FROM contact_channels WHERE id = 'ch-gw'")
|
|
192
|
+
.get() as { u: number };
|
|
193
|
+
expect(after.u).toBe(before.u);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("recreates a channel-less gateway contact as an identity stub", async () => {
|
|
197
|
+
snapshot = {
|
|
198
|
+
ok: true,
|
|
199
|
+
contacts: [gatewayContact({ id: "co-record", displayName: "Mom" })],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
await runMirrorReconcile();
|
|
203
|
+
|
|
204
|
+
expect(contactRow("co-record")?.displayName).toBe("Mom");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("what a pull must never touch", () => {
|
|
209
|
+
test("mirror-only rows and assistant-authored fields survive untouched", async () => {
|
|
210
|
+
// An a2a peer exists only in the mirror; the seed contact carries
|
|
211
|
+
// guardian-authored notes and a curated name.
|
|
212
|
+
upsertContact({
|
|
213
|
+
id: "co-a2a",
|
|
214
|
+
displayName: "Peer Assistant",
|
|
215
|
+
contactType: "assistant",
|
|
216
|
+
channels: [{ type: "a2a", address: "peer-assistant-id" }],
|
|
217
|
+
});
|
|
218
|
+
upsertContactChannel({
|
|
219
|
+
sourceChannel: "slack",
|
|
220
|
+
externalUserId: "U123",
|
|
221
|
+
displayName: "Platform Name",
|
|
222
|
+
contactId: "co-known",
|
|
223
|
+
channelId: "ch-known",
|
|
224
|
+
refreshDisplayName: true,
|
|
225
|
+
userFileOnCreate: null,
|
|
226
|
+
reassignConflictingChannels: false,
|
|
227
|
+
});
|
|
228
|
+
upsertContact({
|
|
229
|
+
id: "co-known",
|
|
230
|
+
displayName: "Curated Name",
|
|
231
|
+
notes: "vip",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// The gateway snapshot knows co-known (with a different display name,
|
|
235
|
+
// the curated gateway one) and has never heard of the a2a peer.
|
|
236
|
+
snapshot = {
|
|
237
|
+
ok: true,
|
|
238
|
+
contacts: [
|
|
239
|
+
gatewayContact({
|
|
240
|
+
id: "co-known",
|
|
241
|
+
displayName: "Gateway Curated",
|
|
242
|
+
channels: [{ id: "ch-known", type: "slack", address: "U123" }],
|
|
243
|
+
}),
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
await runMirrorReconcile();
|
|
248
|
+
|
|
249
|
+
expect(channelRow("a2a", "peer-assistant-id")?.contactId).toBe("co-a2a");
|
|
250
|
+
expect(contactRow("co-a2a")?.displayName).toBe("Peer Assistant");
|
|
251
|
+
expect(contactRow("co-known")).toEqual({
|
|
252
|
+
displayName: "Curated Name",
|
|
253
|
+
notes: "vip",
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a converged mirror takes no writes (fixed point)", async () => {
|
|
258
|
+
upsertContactChannel({
|
|
259
|
+
sourceChannel: "slack",
|
|
260
|
+
externalUserId: "U123",
|
|
261
|
+
externalChatId: "D123",
|
|
262
|
+
displayName: "Alice Example",
|
|
263
|
+
contactId: "co-1",
|
|
264
|
+
channelId: "ch-1",
|
|
265
|
+
refreshDisplayName: true,
|
|
266
|
+
userFileOnCreate: null,
|
|
267
|
+
reassignConflictingChannels: false,
|
|
268
|
+
});
|
|
269
|
+
snapshot = {
|
|
270
|
+
ok: true,
|
|
271
|
+
contacts: [
|
|
272
|
+
gatewayContact({
|
|
273
|
+
id: "co-1",
|
|
274
|
+
displayName: "Gateway Name",
|
|
275
|
+
channels: [
|
|
276
|
+
{
|
|
277
|
+
id: "ch-1",
|
|
278
|
+
type: "slack",
|
|
279
|
+
address: "U123",
|
|
280
|
+
externalChatId: "D123",
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
}),
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
const before = getSqlite()
|
|
287
|
+
.query("SELECT updated_at AS u FROM contact_channels WHERE id = 'ch-1'")
|
|
288
|
+
.get() as { u: number };
|
|
289
|
+
|
|
290
|
+
await runMirrorReconcile();
|
|
291
|
+
|
|
292
|
+
const after = getSqlite()
|
|
293
|
+
.query("SELECT updated_at AS u FROM contact_channels WHERE id = 'ch-1'")
|
|
294
|
+
.get() as { u: number };
|
|
295
|
+
expect(after.u).toBe(before.u);
|
|
296
|
+
// Display name still the mirror's own (a no-op pass rewrites nothing).
|
|
297
|
+
expect(contactRow("co-1")?.displayName).toBe("Alice Example");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("a snapshot pulled before a delete cannot resurrect the deleted contact", async () => {
|
|
301
|
+
// The gateway deleted co-gone (and the explicit mirror delete op landed)
|
|
302
|
+
// while a snapshot still listing it was in flight. Healing from that
|
|
303
|
+
// stale snapshot would bring the ghost back, and the additive contract
|
|
304
|
+
// would then keep it forever.
|
|
305
|
+
upsertContactChannel({
|
|
306
|
+
sourceChannel: "slack",
|
|
307
|
+
externalUserId: "UGONE",
|
|
308
|
+
displayName: "Alice Example",
|
|
309
|
+
contactId: "co-gone",
|
|
310
|
+
channelId: "ch-gone",
|
|
311
|
+
refreshDisplayName: true,
|
|
312
|
+
userFileOnCreate: null,
|
|
313
|
+
reassignConflictingChannels: false,
|
|
314
|
+
});
|
|
315
|
+
deleteContact("co-gone");
|
|
316
|
+
snapshot = {
|
|
317
|
+
ok: true,
|
|
318
|
+
contacts: [
|
|
319
|
+
gatewayContact({
|
|
320
|
+
id: "co-gone",
|
|
321
|
+
displayName: "Alice Example",
|
|
322
|
+
channels: [{ id: "ch-gone", type: "slack", address: "UGONE" }],
|
|
323
|
+
}),
|
|
324
|
+
],
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
await runMirrorReconcile();
|
|
328
|
+
|
|
329
|
+
expect(contactRow("co-gone")).toBeNull();
|
|
330
|
+
expect(channelRow("slack", "UGONE")).toBeNull();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("a failed snapshot pull heals nothing and throws nothing", async () => {
|
|
334
|
+
upsertContactChannel({
|
|
335
|
+
sourceChannel: "slack",
|
|
336
|
+
externalUserId: "U123",
|
|
337
|
+
displayName: "Alice Example",
|
|
338
|
+
contactId: "co-1",
|
|
339
|
+
channelId: "ch-1",
|
|
340
|
+
refreshDisplayName: true,
|
|
341
|
+
userFileOnCreate: null,
|
|
342
|
+
reassignConflictingChannels: false,
|
|
343
|
+
});
|
|
344
|
+
ipcCallPersistentMock.mockImplementationOnce(async () => {
|
|
345
|
+
throw new Error("gateway unreachable");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await runMirrorReconcile();
|
|
349
|
+
|
|
350
|
+
expect(channelRow("slack", "U123")?.contactId).toBe("co-1");
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
contacts,
|
|
10
10
|
} from "../persistence/schema/index.js";
|
|
11
11
|
import { canonicalizeInboundIdentity } from "../util/canonicalize-identity.js";
|
|
12
|
+
import { recordContactTombstone } from "./mirror-tombstones.js";
|
|
12
13
|
import { notifyContactsChanged } from "./notify-contacts-changed.js";
|
|
13
14
|
import type {
|
|
14
15
|
AssistantContactMetadata,
|
|
@@ -256,6 +257,16 @@ export function upsertContact(params: {
|
|
|
256
257
|
* mirror to create faithful null-user_file stubs. `userFile` takes
|
|
257
258
|
* precedence when both are supplied. */
|
|
258
259
|
userFileOnCreate?: string | null;
|
|
260
|
+
/** contactType to seed ONLY when inserting a new contact; ignored on update
|
|
261
|
+
* (and on channel-identity adoption) so a guardian-curated classification
|
|
262
|
+
* is never clobbered by an inbound seed. `contactType` takes precedence
|
|
263
|
+
* when both are supplied. */
|
|
264
|
+
contactTypeOnCreate?: ContactType;
|
|
265
|
+
/** notes to seed ONLY when inserting a new contact; ignored on update (and
|
|
266
|
+
* on channel-identity adoption) so guardian-authored notes are never
|
|
267
|
+
* clobbered by an inbound seed. `notes` takes precedence when both are
|
|
268
|
+
* supplied. */
|
|
269
|
+
notesOnCreate?: string | null;
|
|
259
270
|
channels?: SyncChannelData[];
|
|
260
271
|
/** When true, conflicting channels on other contacts are reassigned to this
|
|
261
272
|
* contact instead of being skipped. Used by invite redemption to bind a
|
|
@@ -317,8 +328,20 @@ export function upsertContact(params: {
|
|
|
317
328
|
}
|
|
318
329
|
}
|
|
319
330
|
|
|
320
|
-
// Try to find by channel canonical identity to avoid duplicates
|
|
321
|
-
|
|
331
|
+
// Try to find by channel canonical identity to avoid duplicates. This also
|
|
332
|
+
// covers an explicit-id CREATE (the id was not found above) when the caller
|
|
333
|
+
// did not opt into reassignment: syncChannels would skip a channel owned by
|
|
334
|
+
// another contact, so inserting the supplied id would mint a channel-less
|
|
335
|
+
// duplicate of that contact. Adopting the existing owner instead keeps one
|
|
336
|
+
// contact per channel identity; the supplied id is dropped, and the
|
|
337
|
+
// *OnCreate record fields never apply here. Reassigning callers (invite
|
|
338
|
+
// binding, the guardian bootstrap mirror) skip this and keep their
|
|
339
|
+
// bind-to-target semantics.
|
|
340
|
+
if (
|
|
341
|
+
canonicalChannels &&
|
|
342
|
+
canonicalChannels.length > 0 &&
|
|
343
|
+
(!contactId || !params.reassignConflictingChannels)
|
|
344
|
+
) {
|
|
322
345
|
for (const ch of canonicalChannels) {
|
|
323
346
|
const existingChannel = findConflictingChannel(db, ch.type, ch.address);
|
|
324
347
|
|
|
@@ -362,8 +385,8 @@ export function upsertContact(params: {
|
|
|
362
385
|
.values({
|
|
363
386
|
id: contactId,
|
|
364
387
|
displayName: params.displayName,
|
|
365
|
-
notes: params.notes ?? null,
|
|
366
|
-
contactType: params.contactType ?? "human",
|
|
388
|
+
notes: params.notes ?? params.notesOnCreate ?? null,
|
|
389
|
+
contactType: params.contactType ?? params.contactTypeOnCreate ?? "human",
|
|
367
390
|
userFile: resolvedUserFile,
|
|
368
391
|
createdAt: now,
|
|
369
392
|
updatedAt: now,
|
|
@@ -390,6 +413,9 @@ export function upsertContact(params: {
|
|
|
390
413
|
*/
|
|
391
414
|
export function deleteContact(id: string): void {
|
|
392
415
|
getDb().delete(contacts).where(eq(contacts.id, id)).run();
|
|
416
|
+
// The mirror reconciler must not resurrect this id from a gateway snapshot
|
|
417
|
+
// pulled before the delete.
|
|
418
|
+
recordContactTombstone(id);
|
|
393
419
|
notifyContactsChanged();
|
|
394
420
|
}
|
|
395
421
|
|
|
@@ -482,6 +508,16 @@ function syncChannels(
|
|
|
482
508
|
|
|
483
509
|
if (existing) {
|
|
484
510
|
const updateSet: Record<string, unknown> = {};
|
|
511
|
+
// Adopt a caller-supplied (gateway-minted) id onto a row still keyed
|
|
512
|
+
// by a divergent legacy id: the byId lookup above missed, so the id is
|
|
513
|
+
// free, and aligning it converges the two stores' keys (id-keyed
|
|
514
|
+
// gateway read-backs and client PATCHes then resolve directly).
|
|
515
|
+
// Nothing daemon-side persists a mirror channel id elsewhere, and
|
|
516
|
+
// readers holding the old id already fall back to the (type, address)
|
|
517
|
+
// logical key, so the swap only removes divergence.
|
|
518
|
+
if (ch.id && ch.id !== existing.id) {
|
|
519
|
+
updateSet.id = ch.id;
|
|
520
|
+
}
|
|
485
521
|
// Self-heal legacy lowercased addresses to canonical form.
|
|
486
522
|
if (existing.address !== ch.address) {
|
|
487
523
|
updateSet.address = ch.address;
|
|
@@ -514,6 +550,12 @@ function syncChannels(
|
|
|
514
550
|
contactId,
|
|
515
551
|
updatedAt: now,
|
|
516
552
|
};
|
|
553
|
+
// Adopt the caller-supplied (gateway-minted) id in the same write:
|
|
554
|
+
// the byId lookup above missed, so the id is free (same rationale as
|
|
555
|
+
// the same-contact adopt branch).
|
|
556
|
+
if (ch.id && ch.id !== conflicting.id) {
|
|
557
|
+
reassignSet.id = ch.id;
|
|
558
|
+
}
|
|
517
559
|
if (ch.externalChatId !== undefined) {
|
|
518
560
|
reassignSet.externalChatId = ch.externalChatId;
|
|
519
561
|
}
|
|
@@ -773,6 +815,7 @@ export function mergeContactMirror(params: {
|
|
|
773
815
|
|
|
774
816
|
// Delete the donor (cascade removes remaining duplicate channels).
|
|
775
817
|
tx.delete(contacts).where(eq(contacts.id, params.mergeContactId)).run();
|
|
818
|
+
recordContactTombstone(params.mergeContactId);
|
|
776
819
|
return true;
|
|
777
820
|
});
|
|
778
821
|
|
|
@@ -82,8 +82,11 @@ export function upsertContactChannel(params: {
|
|
|
82
82
|
upsertContact({
|
|
83
83
|
id: params.contactId,
|
|
84
84
|
displayName,
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// Classification and notes are create-only on this path (see the param
|
|
86
|
+
// docs above): applying them on update or on channel-identity adoption
|
|
87
|
+
// would let an inbound seed clobber guardian-authored record fields.
|
|
88
|
+
contactTypeOnCreate: params.contactType,
|
|
89
|
+
notesOnCreate: params.notes,
|
|
87
90
|
userFileOnCreate: params.userFileOnCreate,
|
|
88
91
|
channels: [
|
|
89
92
|
{
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull-based identity reconciliation of the assistant contact mirror against
|
|
3
|
+
* the gateway DB (the source of truth for contact identity and channel
|
|
4
|
+
* ownership).
|
|
5
|
+
*
|
|
6
|
+
* The gateway's per-write mirror ops are best-effort: a failed or lost op
|
|
7
|
+
* leaves the mirror missing a row, or holding a channel under the wrong
|
|
8
|
+
* contact, until something happens to write that identity again. This module
|
|
9
|
+
* converges the drift instead: on the debounced `contacts_changed` signal it
|
|
10
|
+
* pulls the gateway's identity snapshot and heals the mirror toward it.
|
|
11
|
+
*
|
|
12
|
+
* Additive-corrective ONLY, because the mirror holds data the gateway cannot
|
|
13
|
+
* reconstruct:
|
|
14
|
+
*
|
|
15
|
+
* - It never deletes a mirror row. Mirror-only contacts and channels are
|
|
16
|
+
* legitimate (a2a peers are written daemon-side only), and absence from
|
|
17
|
+
* the gateway is indistinguishable from "the gateway never had it".
|
|
18
|
+
* Delete propagation stays on the explicit delete ops.
|
|
19
|
+
* - It never touches assistant-authored fields (notes, contactType,
|
|
20
|
+
* userFile) or an existing contact's displayName (the mirror name tracks
|
|
21
|
+
* the live platform profile, not the gateway's curated name).
|
|
22
|
+
*
|
|
23
|
+
* Convergence is a fixed point: a pass only writes rows that diverge from
|
|
24
|
+
* the snapshot, so the follow-up pass its own writes trigger (every mirror
|
|
25
|
+
* write broadcasts `contacts_changed`) finds nothing to do and stops.
|
|
26
|
+
*
|
|
27
|
+
* TRANSITIONAL: this module exists only while the assistant DB keeps its own
|
|
28
|
+
* copy of identity columns. When identity reads move to the gateway
|
|
29
|
+
* wholesale, the mirror's identity columns and this reconciler go with them;
|
|
30
|
+
* do not grow it into a general sync framework.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { ContactsIdentitySnapshotIpcResponseSchema } from "@vellumai/gateway-client/gateway-ipc-contracts";
|
|
34
|
+
|
|
35
|
+
import { getDbMigrationReadiness } from "../daemon/daemon-readiness.js";
|
|
36
|
+
import { ipcCallPersistent } from "../ipc/gateway-client.js";
|
|
37
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
38
|
+
import { contactChannels, contacts } from "../persistence/schema/index.js";
|
|
39
|
+
import { getLogger } from "../util/logger.js";
|
|
40
|
+
import { upsertContact } from "./contact-store.js";
|
|
41
|
+
import { upsertContactChannel } from "./contacts-write.js";
|
|
42
|
+
import { isContactTombstoned } from "./mirror-tombstones.js";
|
|
43
|
+
|
|
44
|
+
const log = getLogger("mirror-reconciler");
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Collapse bursts of contacts_changed into one pull. Every inbound message
|
|
48
|
+
* from a known sender refreshes its channel row and notifies, so under
|
|
49
|
+
* continuous traffic this is the steady-state floor between snapshot pulls;
|
|
50
|
+
* healing latency is deliberately loose (the next quiet moment is fine).
|
|
51
|
+
*/
|
|
52
|
+
const DEBOUNCE_MS = 15_000;
|
|
53
|
+
|
|
54
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
55
|
+
let running = false;
|
|
56
|
+
let rerunRequested = false;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ask for a reconcile pass soon. Called (via dynamic import) from
|
|
60
|
+
* `notifyContactsChanged`, so every contact write, gateway-emitted or
|
|
61
|
+
* daemon-local, requests convergence. Coalesces bursts; a request landing
|
|
62
|
+
* while a pass is running (including the pass's own writes re-notifying)
|
|
63
|
+
* runs one follow-up pass after it, whose no-op result ends the cycle.
|
|
64
|
+
*/
|
|
65
|
+
export function scheduleMirrorReconcile(): void {
|
|
66
|
+
if (running) {
|
|
67
|
+
rerunRequested = true;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (timer) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
timer = setTimeout(() => {
|
|
74
|
+
timer = null;
|
|
75
|
+
void runMirrorReconcile();
|
|
76
|
+
}, DEBOUNCE_MS);
|
|
77
|
+
// A pending debounce must never hold the process open (daemon shutdown,
|
|
78
|
+
// test teardown); a lost pass is re-requested by the next contact write.
|
|
79
|
+
timer.unref?.();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Key channels by (type, lower(address)), matching the gateway's
|
|
84
|
+
* UNIQUE(type, address) NOCASE collation. The NUL delimiter cannot appear in
|
|
85
|
+
* either field, so keys never collide.
|
|
86
|
+
*/
|
|
87
|
+
function channelKey(type: string, address: string): string {
|
|
88
|
+
return `${type}\u0000${address.toLowerCase()}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One reconcile pass: pull the gateway identity snapshot and heal every
|
|
93
|
+
* diverging mirror row through the identity-mirror primitives (which own the
|
|
94
|
+
* conflict semantics: gateway ids adopted on create, ownership reassigned to
|
|
95
|
+
* the gateway's contact, curated fields preserved). Best-effort: errors are
|
|
96
|
+
* logged, never thrown.
|
|
97
|
+
*/
|
|
98
|
+
export async function runMirrorReconcile(): Promise<void> {
|
|
99
|
+
if (running) {
|
|
100
|
+
rerunRequested = true;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!getDbMigrationReadiness().ready) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
running = true;
|
|
107
|
+
try {
|
|
108
|
+
const result = await ipcCallPersistent("contacts_identity_snapshot", {});
|
|
109
|
+
const snapshot = ContactsIdentitySnapshotIpcResponseSchema.parse(result);
|
|
110
|
+
|
|
111
|
+
const db = getDb();
|
|
112
|
+
const localContactIds = new Set(
|
|
113
|
+
db
|
|
114
|
+
.select({ id: contacts.id })
|
|
115
|
+
.from(contacts)
|
|
116
|
+
.all()
|
|
117
|
+
.map((r) => r.id),
|
|
118
|
+
);
|
|
119
|
+
const localByTypeAddress = new Map(
|
|
120
|
+
db
|
|
121
|
+
.select({
|
|
122
|
+
id: contactChannels.id,
|
|
123
|
+
contactId: contactChannels.contactId,
|
|
124
|
+
type: contactChannels.type,
|
|
125
|
+
address: contactChannels.address,
|
|
126
|
+
externalChatId: contactChannels.externalChatId,
|
|
127
|
+
isPrimary: contactChannels.isPrimary,
|
|
128
|
+
})
|
|
129
|
+
.from(contactChannels)
|
|
130
|
+
.all()
|
|
131
|
+
.map((row) => [channelKey(row.type, row.address), row]),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
let healed = 0;
|
|
135
|
+
for (const contact of snapshot.contacts) {
|
|
136
|
+
// A contact deleted (or merged away) after this snapshot was pulled
|
|
137
|
+
// must not be resurrected from it; the additive contract would keep
|
|
138
|
+
// the ghost forever.
|
|
139
|
+
if (isContactTombstoned(contact.id)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (contact.channels.length === 0) {
|
|
143
|
+
// A channel-less gateway contact (a guardian-authored record) whose
|
|
144
|
+
// mirror row is missing: recreate the identity stub.
|
|
145
|
+
if (!localContactIds.has(contact.id)) {
|
|
146
|
+
upsertContact({
|
|
147
|
+
id: contact.id,
|
|
148
|
+
displayName: contact.displayName,
|
|
149
|
+
userFileOnCreate: null,
|
|
150
|
+
});
|
|
151
|
+
healed += 1;
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const channel of contact.channels) {
|
|
157
|
+
const local = localByTypeAddress.get(
|
|
158
|
+
channelKey(channel.type, channel.address),
|
|
159
|
+
);
|
|
160
|
+
// A gateway-null externalChatId means the delivery chat id was
|
|
161
|
+
// never learned (clearing one is off the upsert contract), so the
|
|
162
|
+
// mirror's value is the richer of the two and is not divergence. A
|
|
163
|
+
// divergent channel id IS: the heal adopts the gateway id (see
|
|
164
|
+
// syncChannels), so id-keyed read-backs and client PATCHes resolve
|
|
165
|
+
// directly instead of through the logical-key fallback forever.
|
|
166
|
+
const diverges =
|
|
167
|
+
!local ||
|
|
168
|
+
local.id !== channel.id ||
|
|
169
|
+
local.contactId !== contact.id ||
|
|
170
|
+
(channel.externalChatId != null &&
|
|
171
|
+
local.externalChatId !== channel.externalChatId) ||
|
|
172
|
+
local.isPrimary !== channel.isPrimary;
|
|
173
|
+
if (!diverges) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// The identity-mirror primitive adopts the gateway ids on create,
|
|
177
|
+
// reassigns a divergent owner (the gateway owns channel ownership),
|
|
178
|
+
// and preserves an existing contact's curated name and record fields.
|
|
179
|
+
upsertContactChannel({
|
|
180
|
+
sourceChannel: channel.type,
|
|
181
|
+
externalUserId: channel.address,
|
|
182
|
+
externalChatId: channel.externalChatId ?? undefined,
|
|
183
|
+
displayName: contact.displayName,
|
|
184
|
+
contactId: contact.id,
|
|
185
|
+
channelId: channel.id,
|
|
186
|
+
refreshDisplayName: false,
|
|
187
|
+
reassignConflictingChannels: true,
|
|
188
|
+
isPrimary: channel.isPrimary,
|
|
189
|
+
userFileOnCreate: null,
|
|
190
|
+
});
|
|
191
|
+
healed += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (healed > 0) {
|
|
196
|
+
log.info(
|
|
197
|
+
{ healed },
|
|
198
|
+
"mirror reconciler converged identity rows onto the gateway snapshot",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
log.warn({ err }, "mirror reconcile failed (best-effort)");
|
|
203
|
+
} finally {
|
|
204
|
+
running = false;
|
|
205
|
+
if (rerunRequested) {
|
|
206
|
+
rerunRequested = false;
|
|
207
|
+
scheduleMirrorReconcile();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|