@vellumai/assistant 0.11.8 → 0.11.9-staging.2
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 +2 -2
- package/Dockerfile +8 -48
- package/docker-entrypoint.sh +5 -1
- package/docker-kata-apt-env.sh +3 -0
- package/docker-kata-apt-shims.sh +127 -0
- package/docker-kata-apt-wrapper.sh +45 -0
- package/docker-kata-chroot-exec.sh +35 -0
- package/docker-kata-pip.sh +8 -2
- package/docs/architecture/memory.md +8 -4
- package/docs/guardian-request-flow.md +18 -14
- package/docs/trusted-contact-access.md +1 -7
- package/node_modules/@vellumai/avatar-catalog/src/catalog.ts +7 -1
- package/node_modules/@vellumai/avatar-catalog/src/index.ts +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -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/platform-credential.ts +41 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -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/platform-credential.ts +41 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
- package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
- package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
- package/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/openapi.yaml +193 -4
- package/package.json +2 -2
- package/src/__tests__/access-request-card-view.test.ts +6 -5
- package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
- package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +43 -82
- package/src/__tests__/always-loaded-tools-guard.test.ts +8 -2
- package/src/__tests__/attachment-stored-path-annotation.test.ts +80 -0
- package/src/__tests__/attachments-store.test.ts +27 -0
- package/src/__tests__/attachments.test.ts +43 -0
- package/src/__tests__/canned-reply-release.test.ts +48 -5
- package/src/__tests__/channel-delivery-store.test.ts +129 -18
- package/src/__tests__/channel-reply-delivery.test.ts +553 -93
- package/src/__tests__/channel-retry-sweep.test.ts +199 -0
- package/src/__tests__/client-os-metadata-persistence.test.ts +32 -3
- package/src/__tests__/conversation-error.test.ts +15 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
- package/src/__tests__/conversation-pairing.test.ts +141 -1
- package/src/__tests__/conversation-routes-disk-view.test.ts +28 -1
- package/src/__tests__/conversation-routes-slash-commands.test.ts +125 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +79 -0
- package/src/__tests__/conversation-store-ephemeral.test.ts +323 -10
- package/src/__tests__/conversation-sync-tags.test.ts +2 -37
- package/src/__tests__/credential-health-service.test.ts +98 -10
- package/src/__tests__/delete-propagation.test.ts +469 -0
- package/src/__tests__/dm-persistence.test.ts +16 -0
- package/src/__tests__/docker-kata-apt-shims.test.ts +135 -0
- package/src/__tests__/forbidden-legacy-symbols.test.ts +12 -0
- package/src/__tests__/gemini-provider.test.ts +46 -0
- package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
- package/src/__tests__/guardian-gateway-sim.ts +0 -23
- package/src/__tests__/guardian-question-mode.test.ts +108 -0
- package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
- package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
- package/src/__tests__/host-proxy-interface.test.ts +10 -0
- package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
- package/src/__tests__/injector-v3-suppression.test.ts +43 -9
- package/src/__tests__/list-messages-page-latest.test.ts +127 -0
- package/src/__tests__/list-messages-system-card.test.ts +103 -0
- package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
- package/src/__tests__/notification-decision-fallback.test.ts +199 -77
- package/src/__tests__/notification-decision-strategy.test.ts +198 -213
- package/src/__tests__/notification-discord-adapter.test.ts +25 -0
- package/src/__tests__/notification-slack-adapter.test.ts +133 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
- package/src/__tests__/openai-provider.test.ts +5 -5
- package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
- package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
- package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
- package/src/__tests__/pricing.test.ts +11 -0
- package/src/__tests__/process-message-display-content.test.ts +20 -9
- package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
- package/src/__tests__/provider-meta-persistence.test.ts +67 -0
- package/src/__tests__/reaction-persistence.test.ts +22 -198
- package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
- package/src/__tests__/skill-load-tool.test.ts +27 -0
- package/src/__tests__/skills.test.ts +34 -1
- package/src/__tests__/strip-memory-injections.test.ts +3 -4
- package/src/__tests__/terminal-tools.test.ts +9 -0
- package/src/__tests__/thread-backfill.test.ts +5 -3
- package/src/__tests__/unified-turn-context-location.test.ts +76 -0
- package/src/__tests__/voice-session-bridge.test.ts +18 -0
- package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
- package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
- package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
- package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
- package/src/agent/attachments.ts +13 -2
- package/src/agent/loop.ts +3 -19
- package/src/api/README.md +9 -5
- package/src/api/index.ts +9 -8
- package/src/api/package.json +1 -0
- package/src/api/responses/conversation-message.ts +8 -0
- package/src/api/responses/home.ts +7 -18
- package/src/api/surfaces.ts +114 -7
- package/src/approvals/AGENTS.md +1 -1
- package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
- package/src/channels/__tests__/types.test.ts +22 -1
- package/src/channels/gateway-guardian-requests.ts +0 -22
- package/src/channels/types.ts +8 -6
- package/src/cli/__tests__/catalog-search-help.test.ts +18 -0
- package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
- package/src/cli/commands/channels/index.help.ts +19 -6
- package/src/cli/commands/channels/index.ts +6 -2
- package/src/cli/commands/db/__tests__/status.test.ts +22 -0
- package/src/cli/commands/db/index.help.ts +1 -1
- package/src/cli/commands/db/status.ts +172 -1
- package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
- package/src/cli/commands/platform/connect.ts +14 -5
- package/src/cli/commands/plugins.help.ts +6 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
- package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
- package/src/cli/lib/bundled-marketplace.json +14 -0
- package/src/cli/lib/install-from-github.ts +9 -5
- package/src/cli/lib/install-from-platform.ts +12 -1
- package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
- package/src/config/assistant-initiated-threads-gate.ts +50 -0
- package/src/config/bundled-skills/acp/SKILL.md +10 -3
- package/src/config/bundled-skills/schedule/SKILL.md +25 -11
- package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
- package/src/config/call-site-defaults.ts +4 -1
- package/src/config/feature-flag-registry.json +21 -4
- package/src/config/schemas/memory-v3.ts +4 -3
- package/src/context/strip-injections.ts +17 -56
- package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
- package/src/conversations/message-consolidation.ts +4 -3
- package/src/credential-health/credential-health-service.ts +130 -0
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
- package/src/daemon/conversation-error.ts +2 -0
- package/src/daemon/conversation-messaging.ts +195 -47
- package/src/daemon/conversation-process.ts +20 -5
- package/src/daemon/conversation-runtime-assembly.ts +48 -39
- package/src/daemon/conversation-store.ts +159 -11
- package/src/daemon/conversation-tool-setup.ts +9 -2
- package/src/daemon/conversation.ts +290 -19
- package/src/daemon/dictation-text-processing.ts +2 -7
- package/src/daemon/handlers/config-channels.ts +33 -3
- package/src/daemon/handlers/config-model.test.ts +1 -0
- package/src/daemon/handlers/shared.ts +9 -0
- package/src/daemon/port-oversized-content.test.ts +117 -0
- package/src/daemon/port-oversized-content.ts +110 -0
- package/src/daemon/process-message.ts +87 -16
- package/src/daemon/reaction-record.test.ts +23 -11
- package/src/daemon/reaction-record.ts +6 -9
- package/src/documents/document-store.ts +1 -5
- package/src/home/conversation-starter-validation.ts +1 -5
- package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
- package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
- package/src/live-voice/live-voice-photo.ts +544 -61
- package/src/live-voice/live-voice-session.ts +6 -2
- package/src/live-voice/protocol.ts +20 -1
- package/src/messaging/provider-message-metadata.ts +40 -1
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
- package/src/messaging/providers/channel-transport.ts +28 -0
- package/src/messaging/providers/discord/send.ts +3 -2
- package/src/messaging/providers/slack/api.ts +11 -1
- package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
- package/src/messaging/providers/slack/message-metadata.ts +141 -1
- package/src/messaging/providers/slack/send.test.ts +58 -0
- package/src/messaging/providers/slack/send.ts +4 -4
- package/src/messaging/providers/slack/transport.ts +28 -2
- package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
- package/src/messaging/providers/telegram-bot/send.ts +93 -0
- package/src/messaging/providers/telegram-bot/transport.ts +71 -0
- package/src/messaging/reaction-envelopes.test.ts +73 -0
- package/src/messaging/reaction-envelopes.ts +33 -5
- package/src/messaging/read-provider-metadata.ts +4 -3
- package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
- package/src/monitoring/recovery/db.ts +35 -0
- package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
- package/src/monitoring/recovery/run-recovery.ts +6 -2
- package/src/monitoring/recovery/stale-processing.ts +3 -20
- package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
- package/src/notifications/AGENTS.md +2 -2
- package/src/notifications/README.md +2 -2
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
- package/src/notifications/__tests__/broadcaster.test.ts +35 -4
- package/src/notifications/__tests__/notification-utils.test.ts +31 -0
- package/src/notifications/access-request-copy.ts +126 -114
- package/src/notifications/adapters/discord.ts +9 -5
- package/src/notifications/adapters/shared.ts +17 -4
- package/src/notifications/adapters/slack.ts +14 -4
- package/src/notifications/adapters/telegram.ts +8 -4
- package/src/notifications/approval-card-data.ts +5 -2
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/broadcaster.ts +57 -25
- package/src/notifications/conversation-pairing.ts +68 -2
- package/src/notifications/copy-composer.ts +13 -32
- package/src/notifications/decision-engine.ts +56 -237
- package/src/notifications/guardian-delivery-recorder.ts +5 -9
- package/src/notifications/guardian-feed-projection.ts +0 -15
- package/src/notifications/guardian-question-mode.ts +147 -6
- package/src/notifications/notification-utils.ts +117 -1
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/prompter.ts +1 -1
- package/src/persistence/conversation-crud.ts +93 -14
- package/src/persistence/conversation-queries.ts +144 -17
- package/src/persistence/conversation-types.ts +39 -5
- package/src/persistence/delivery-crud.ts +224 -28
- package/src/persistence/delivery-status.ts +21 -2
- package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
- package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
- package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
- package/src/persistence/schema/conversations.ts +71 -2
- package/src/persistence/schema-contract.test.ts +78 -0
- package/src/persistence/schema-contract.ts +96 -0
- package/src/persistence/steps.ts +4 -0
- package/src/platform/client.ts +55 -0
- package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
- package/src/plugins/defaults/injector-order.ts +2 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
- package/src/plugins/defaults/memory/injectors.ts +1 -1
- package/src/plugins/defaults/memory/memory-marker.ts +17 -7
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
- package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +17 -0
- package/src/plugins/defaults/memory/substrate/skill-content.ts +7 -0
- package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
- package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +22 -0
- package/src/plugins/defaults/memory/v3/injector.ts +14 -15
- package/src/plugins/defaults/memory/v3/prune.test.ts +20 -2
- package/src/plugins/defaults/memory/v3/render-injection.ts +10 -1
- package/src/plugins/defaults/memory/v3/types.ts +15 -4
- package/src/plugins/defaults/turn-context/injectors.ts +3 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
- package/src/plugins/mcp-servers.ts +14 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
- package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
- package/src/providers/anthropic/client.ts +21 -24
- package/src/providers/call-site-routing.ts +5 -4
- package/src/providers/connection-resolution.ts +29 -1
- package/src/providers/content-block-size.test.ts +82 -0
- package/src/providers/content-block-size.ts +99 -0
- package/src/providers/file-block-text.test.ts +64 -0
- package/src/providers/file-block-text.ts +28 -0
- package/src/providers/gemini/client.ts +15 -9
- package/src/providers/inference/auth.ts +6 -6
- package/src/providers/media-resolve.ts +14 -0
- package/src/providers/model-catalog.ts +47 -34
- package/src/providers/openai/chat-completions-provider.ts +9 -25
- package/src/providers/openai/responses-provider.ts +11 -18
- package/src/providers/registry.ts +10 -7
- package/src/providers/routing-identity.ts +2 -1
- package/src/providers/types.ts +1 -4
- package/src/providers/vellum-model-routing.ts +3 -2
- package/src/runtime/AGENTS.md +40 -15
- package/src/runtime/approval-message-composer.ts +1 -6
- package/src/runtime/assistant-event-hub.ts +2 -39
- package/src/runtime/channel-approval-types.ts +1 -5
- package/src/runtime/channel-reply-delivery.ts +182 -115
- package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
- package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
- package/src/runtime/channel-retry-sweep.ts +102 -1
- package/src/runtime/finalize-event-delivery.ts +4 -4
- package/src/runtime/guardian-action-message-composer.ts +1 -4
- package/src/runtime/guardian-reply-router.ts +4 -75
- package/src/runtime/http-router.ts +1 -5
- package/src/runtime/question-request-guardian-bridge.ts +2 -3
- package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
- package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
- package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
- package/src/runtime/routes/acp-routes.ts +1 -1
- package/src/runtime/routes/canned-reply-release.ts +19 -9
- package/src/runtime/routes/channel-route-shared.ts +0 -39
- package/src/runtime/routes/channel-verification-routes.ts +4 -1
- package/src/runtime/routes/conversation-list-routes.ts +44 -3
- package/src/runtime/routes/conversation-management-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +138 -59
- package/src/runtime/routes/diagnostics-routes.ts +111 -60
- package/src/runtime/routes/guardian-action-routes.ts +10 -14
- package/src/runtime/routes/guardian-approval-interception.ts +0 -5
- package/src/runtime/routes/inbound-message-handler.ts +170 -48
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/platform-routes.ts +99 -10
- package/src/runtime/routes/sight-frame-routes.ts +136 -0
- package/src/runtime/sync/resource-sync-events.ts +14 -37
- package/src/runtime/sync/sync-publisher.test.ts +5 -4
- package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
- package/src/tools/client-os.ts +11 -1
- package/src/tools/host-filesystem/edit.ts +2 -2
- package/src/tools/host-filesystem/read.ts +2 -2
- package/src/tools/host-filesystem/transfer.ts +2 -2
- package/src/tools/host-filesystem/write.ts +2 -2
- package/src/tools/host-terminal/host-shell.ts +2 -2
- package/src/tools/skills/load.ts +1 -1
- package/src/tools/terminal/safe-env.ts +4 -0
- package/src/tools/tool-input-schemas.ts +2 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/tools/ui-surface/definitions.ts +4 -3
- package/src/tools/watch/watch-retro-report.ts +205 -0
- package/src/watch/__tests__/watch-retro.test.ts +268 -40
- package/src/watch/watch-retro.ts +190 -77
- package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
- package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
- package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +4 -0
- package/docker-kata-pip-chroot.sh +0 -22
- package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
- package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
- package/src/api/events/conversation-list-invalidated.ts +0 -38
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Persisting
|
|
2
|
+
* Persisting a camera image on its own, outside any turn.
|
|
3
3
|
*
|
|
4
4
|
* Two kinds arrive this way and both take the same route: a photo the user
|
|
5
|
-
* snapped with the shutter, and an ambient camera frame the client's
|
|
5
|
+
* snapped with the shutter on a call, and an ambient camera frame the client's
|
|
6
|
+
* gate kept, which arrives from a call and from the text-chat composer alike.
|
|
6
7
|
* Each lands in the conversation as its own user message, the moment it
|
|
7
8
|
* arrives, and **runs no turn**. That single choice is what makes the order of
|
|
8
|
-
* image and
|
|
9
|
-
* is answered by a model whose history already contains the picture.
|
|
9
|
+
* image and message irrelevant: whatever the user says or types next, before
|
|
10
|
+
* or after, is answered by a model whose history already contains the picture.
|
|
10
11
|
*
|
|
11
12
|
* A kept frame carries the sight tag as well, which is what lets retention age
|
|
12
13
|
* it out of the model's context while the transcript keeps it. A shutter photo
|
|
@@ -75,13 +76,19 @@ import {
|
|
|
75
76
|
persistQueuedMessageBody,
|
|
76
77
|
} from "../daemon/conversation-messaging.js";
|
|
77
78
|
import { findConversation } from "../daemon/conversation-registry.js";
|
|
78
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
getConversationIfExists,
|
|
81
|
+
isSameIncarnation,
|
|
82
|
+
} from "../daemon/conversation-store.js";
|
|
83
|
+
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
79
84
|
import {
|
|
80
85
|
deleteOrphanAttachments,
|
|
81
86
|
resolveAttachmentsForPersist,
|
|
82
87
|
} from "../persistence/attachments-store.js";
|
|
83
88
|
import {
|
|
89
|
+
getConversation,
|
|
84
90
|
getMessageById,
|
|
91
|
+
MessageInsertPreconditionError,
|
|
85
92
|
recordConversationPersistedSeq,
|
|
86
93
|
} from "../persistence/conversation-crud.js";
|
|
87
94
|
import { broadcastMessage } from "../runtime/assistant-event-hub.js";
|
|
@@ -140,7 +147,11 @@ export interface LiveVoicePhotoResult {
|
|
|
140
147
|
*/
|
|
141
148
|
interface StandaloneImageQueue {
|
|
142
149
|
tail: Promise<void>;
|
|
143
|
-
queuedSightFrame: {
|
|
150
|
+
queuedSightFrame: {
|
|
151
|
+
superseded: boolean;
|
|
152
|
+
attachmentId: string;
|
|
153
|
+
content: string;
|
|
154
|
+
} | null;
|
|
144
155
|
outstanding: number;
|
|
145
156
|
}
|
|
146
157
|
|
|
@@ -163,6 +174,7 @@ async function enqueueStandaloneImagePersist(
|
|
|
163
174
|
conversationId: string,
|
|
164
175
|
attachmentId: string,
|
|
165
176
|
kind: "photo" | "sight_frame",
|
|
177
|
+
content: string,
|
|
166
178
|
job: () => Promise<LiveVoicePhotoResult>,
|
|
167
179
|
): Promise<LiveVoicePhotoResult> {
|
|
168
180
|
let queue = standaloneImageQueues.get(conversationId);
|
|
@@ -176,7 +188,7 @@ async function enqueueStandaloneImagePersist(
|
|
|
176
188
|
}
|
|
177
189
|
const chained = queue;
|
|
178
190
|
|
|
179
|
-
const ticket = { superseded: false, attachmentId };
|
|
191
|
+
const ticket = { superseded: false, attachmentId, content };
|
|
180
192
|
if (kind === "sight_frame") {
|
|
181
193
|
if (chained.queuedSightFrame) {
|
|
182
194
|
chained.queuedSightFrame.superseded = true;
|
|
@@ -195,7 +207,12 @@ async function enqueueStandaloneImagePersist(
|
|
|
195
207
|
{ conversationId, attachmentId: ticket.attachmentId },
|
|
196
208
|
"A newer camera frame replaced one still waiting to persist",
|
|
197
209
|
);
|
|
198
|
-
|
|
210
|
+
reclaimOrDefer(
|
|
211
|
+
conversationId,
|
|
212
|
+
[ticket.attachmentId],
|
|
213
|
+
ticket.content,
|
|
214
|
+
uuidv7(),
|
|
215
|
+
);
|
|
199
216
|
return { ok: false };
|
|
200
217
|
}
|
|
201
218
|
return job();
|
|
@@ -232,10 +249,12 @@ async function enqueueStandaloneImagePersist(
|
|
|
232
249
|
* Each caller owes the invariant in the first line. The supersede and timeout
|
|
233
250
|
* paths have it by construction, neither having reached the persist; the
|
|
234
251
|
* thrown path establishes it by checking that no row was inserted
|
|
235
|
-
* ({@link
|
|
236
|
-
* succeeded
|
|
237
|
-
*
|
|
238
|
-
*
|
|
252
|
+
* ({@link insertedMessageState}), because a persist can fail after `addMessage`
|
|
253
|
+
* succeeded, and a path that cannot read that answer waits for it through
|
|
254
|
+
* {@link deferFrameReclaimDecision} rather than guessing.
|
|
255
|
+
* {@link deleteOrphanAttachments} being link-aware is the second backstop
|
|
256
|
+
* rather than the only one: a failure between the insert and the link leaves a
|
|
257
|
+
* row that references the attachment with no link to protect it.
|
|
239
258
|
*
|
|
240
259
|
* The reclaim names the id the caller handed in, which is not always the id
|
|
241
260
|
* the row ended up referencing: conversation scoping clones an attachment
|
|
@@ -247,17 +266,41 @@ async function enqueueStandaloneImagePersist(
|
|
|
247
266
|
* and one that failed to persist is theirs to retry, not the daemon's to
|
|
248
267
|
* delete.
|
|
249
268
|
*
|
|
250
|
-
*
|
|
269
|
+
* Never throws, and reports whether the store answered. A delete can fail on
|
|
270
|
+
* the same contention the rest of this module waits out, and the caller owes
|
|
271
|
+
* the upload an outcome rather than a log line.
|
|
251
272
|
*/
|
|
252
|
-
function reclaimDroppedFrame(
|
|
273
|
+
function reclaimDroppedFrame(attachmentIds: readonly string[]): boolean {
|
|
253
274
|
try {
|
|
254
|
-
deleteOrphanAttachments([
|
|
275
|
+
deleteOrphanAttachments([...attachmentIds]);
|
|
276
|
+
return true;
|
|
255
277
|
} catch (err) {
|
|
256
278
|
log.warn(
|
|
257
|
-
{ err,
|
|
258
|
-
"Could not reclaim a dropped
|
|
279
|
+
{ err, attachmentIds },
|
|
280
|
+
"Could not reclaim a dropped camera frame",
|
|
259
281
|
);
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Give the upload up, and hand it to the recheck when the store refuses.
|
|
288
|
+
*
|
|
289
|
+
* `messageId` is the id the row would carry, so a later pass asks the right
|
|
290
|
+
* question: a caller that attempted an insert passes the id it used, and one
|
|
291
|
+
* that never reached an insert passes a fresh id, which reads absent as soon as
|
|
292
|
+
* the store is readable and so retries exactly this delete.
|
|
293
|
+
*/
|
|
294
|
+
function reclaimOrDefer(
|
|
295
|
+
conversationId: string,
|
|
296
|
+
attachmentIds: readonly string[],
|
|
297
|
+
content: string,
|
|
298
|
+
messageId: string,
|
|
299
|
+
): void {
|
|
300
|
+
if (reclaimDroppedFrame(attachmentIds)) {
|
|
301
|
+
return;
|
|
260
302
|
}
|
|
303
|
+
deferFrameReclaimDecision(conversationId, messageId, attachmentIds, content);
|
|
261
304
|
}
|
|
262
305
|
|
|
263
306
|
/** Conversations with a standalone-image persist still in flight. */
|
|
@@ -265,6 +308,200 @@ export function _standaloneImageQueueSizeForTests(): number {
|
|
|
265
308
|
return standaloneImageQueues.size;
|
|
266
309
|
}
|
|
267
310
|
|
|
311
|
+
/**
|
|
312
|
+
* How long to wait before asking the store again about a frame whose fate it
|
|
313
|
+
* could not report.
|
|
314
|
+
*
|
|
315
|
+
* The quick cadence covers the ordinary outage, a migration or a writer holding
|
|
316
|
+
* the database, which clears in seconds. One that outlasts those passes steps
|
|
317
|
+
* down to a cadence that can wait all day without filling the log. There is no
|
|
318
|
+
* final pass: giving the record up is the one outcome that loses the bytes for
|
|
319
|
+
* good, nothing else in the daemon collecting an attachment no caller names.
|
|
320
|
+
*/
|
|
321
|
+
const RECLAIM_RECHECK_MS = 30_000;
|
|
322
|
+
const RECLAIM_RECHECK_SLOW_MS = 300_000;
|
|
323
|
+
/** Passes at the quick cadence before a record steps down to the slow one. */
|
|
324
|
+
const RECLAIM_RECHECK_QUICK_PASSES = 10;
|
|
325
|
+
|
|
326
|
+
interface PendingFrameReclaim {
|
|
327
|
+
conversationId: string;
|
|
328
|
+
messageId: string;
|
|
329
|
+
/**
|
|
330
|
+
* Every id this attempt is answerable for: the one the caller handed in, and
|
|
331
|
+
* any the persist materialized for itself. A pre-uploaded frame already
|
|
332
|
+
* linked to another conversation is cloned into this one under a fresh id,
|
|
333
|
+
* and the caller's id reclaims nothing for it, that row still being linked
|
|
334
|
+
* where it came from.
|
|
335
|
+
*/
|
|
336
|
+
attachmentIds: readonly string[];
|
|
337
|
+
/** Row text, so a frame found to have landed can still be announced. */
|
|
338
|
+
content: string;
|
|
339
|
+
attempts: number;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Frames waiting on an answer, held for as long as this process lives.
|
|
344
|
+
*
|
|
345
|
+
* In memory on purpose: the record exists because the store could not be read,
|
|
346
|
+
* so writing it to that store is circular. A process that dies inside the
|
|
347
|
+
* outage therefore loses it, which leaves the upload in the same position as
|
|
348
|
+
* every other attachment nothing links, since the daemon has no orphan sweep.
|
|
349
|
+
*/
|
|
350
|
+
const pendingFrameReclaims: PendingFrameReclaim[] = [];
|
|
351
|
+
let reclaimRecheckTimer: ReturnType<typeof setTimeout> | null = null;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Hold a frame whose outcome the store would not report, and settle it once
|
|
355
|
+
* the store answers.
|
|
356
|
+
*
|
|
357
|
+
* Both the refusal a caller sees and the upload behind it are this module's to
|
|
358
|
+
* settle: a refused frame is reported as handled, and nothing else in the
|
|
359
|
+
* daemon collects an attachment no caller names, collection being
|
|
360
|
+
* candidate-driven with no sweep. Reclaiming on the spot is the other wrong
|
|
361
|
+
* answer, because "could not read" is not "no row", and a row that landed can
|
|
362
|
+
* reference the bytes through content whose link write failed. So the question
|
|
363
|
+
* is asked again later and only an answer decides it.
|
|
364
|
+
*
|
|
365
|
+
* `messageId` is the id the row would carry. A path that never reached an
|
|
366
|
+
* insert passes the id it would have used, which reads absent the moment the
|
|
367
|
+
* store is readable, and readable is the whole of what that path is waiting
|
|
368
|
+
* for.
|
|
369
|
+
*/
|
|
370
|
+
function deferFrameReclaimDecision(
|
|
371
|
+
conversationId: string,
|
|
372
|
+
messageId: string,
|
|
373
|
+
attachmentIds: readonly string[],
|
|
374
|
+
content: string,
|
|
375
|
+
): void {
|
|
376
|
+
pendingFrameReclaims.push({
|
|
377
|
+
conversationId,
|
|
378
|
+
messageId,
|
|
379
|
+
attachmentIds,
|
|
380
|
+
content,
|
|
381
|
+
attempts: 0,
|
|
382
|
+
});
|
|
383
|
+
scheduleFrameReclaimRecheck();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The wait before the next pass: quick while any record is still in its early
|
|
388
|
+
* passes, slow once every one of them has outlasted those.
|
|
389
|
+
*/
|
|
390
|
+
function nextFrameReclaimRecheckDelay(): number {
|
|
391
|
+
return pendingFrameReclaims.some(
|
|
392
|
+
(pending) => pending.attempts < RECLAIM_RECHECK_QUICK_PASSES,
|
|
393
|
+
)
|
|
394
|
+
? RECLAIM_RECHECK_MS
|
|
395
|
+
: RECLAIM_RECHECK_SLOW_MS;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function scheduleFrameReclaimRecheck(): void {
|
|
399
|
+
if (reclaimRecheckTimer || pendingFrameReclaims.length === 0) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
reclaimRecheckTimer = setTimeout(() => {
|
|
403
|
+
reclaimRecheckTimer = null;
|
|
404
|
+
drainFrameReclaimRechecks();
|
|
405
|
+
}, nextFrameReclaimRecheckDelay());
|
|
406
|
+
// A pending reclaim is never a reason to keep the process alive.
|
|
407
|
+
reclaimRecheckTimer.unref?.();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Deliver a row the client was told had not landed.
|
|
412
|
+
*
|
|
413
|
+
* The refusal it got is not destructive: it retracts a frame the client was
|
|
414
|
+
* showing as pending and does nothing else, so this arrives as an ordinary
|
|
415
|
+
* event for a message the client has never seen and the transcript converges
|
|
416
|
+
* without a reload. A row that reads as existing is proof its conversation is
|
|
417
|
+
* there too, the message being a foreign key into it, so what the announce
|
|
418
|
+
* writes has somewhere to go.
|
|
419
|
+
*
|
|
420
|
+
* The persist unwound its own history push before it threw, so the resident
|
|
421
|
+
* history no longer matches the rows and the next turn reloads rather than
|
|
422
|
+
* reusing what it holds.
|
|
423
|
+
*/
|
|
424
|
+
function announceDeferredImage(pending: PendingFrameReclaim): void {
|
|
425
|
+
findConversation(pending.conversationId)?.markHistoryStale();
|
|
426
|
+
try {
|
|
427
|
+
announcePersistedImage(
|
|
428
|
+
pending.conversationId,
|
|
429
|
+
pending.content,
|
|
430
|
+
pending.messageId,
|
|
431
|
+
);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
log.warn(
|
|
434
|
+
{
|
|
435
|
+
err,
|
|
436
|
+
conversationId: pending.conversationId,
|
|
437
|
+
messageId: pending.messageId,
|
|
438
|
+
},
|
|
439
|
+
"Persisted a standalone image but could not announce it",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Ask the store again about every frame waiting on it.
|
|
446
|
+
*
|
|
447
|
+
* A row that exists means the frame reached the transcript, so its bytes are
|
|
448
|
+
* spoken for and the client is told about the row it was never given. Absent
|
|
449
|
+
* means the write never landed and the upload is this module's to give up. A
|
|
450
|
+
* store that still will not answer, and a delete that answers by failing, are
|
|
451
|
+
* the same situation here: ask again, for as long as that takes.
|
|
452
|
+
*/
|
|
453
|
+
function drainFrameReclaimRechecks(): void {
|
|
454
|
+
for (const pending of pendingFrameReclaims.splice(0)) {
|
|
455
|
+
const inserted = insertedMessageState(
|
|
456
|
+
pending.conversationId,
|
|
457
|
+
pending.messageId,
|
|
458
|
+
);
|
|
459
|
+
if (inserted === "exists") {
|
|
460
|
+
announceDeferredImage(pending);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (inserted === "absent" && reclaimDroppedFrame(pending.attachmentIds)) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
keepWaitingForStore(pending);
|
|
467
|
+
}
|
|
468
|
+
scheduleFrameReclaimRecheck();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Put a record back for another pass, counting the attempt so the cadence can
|
|
473
|
+
* step down. The record is never dropped: it is the only handle on an upload
|
|
474
|
+
* nothing else in the daemon would collect.
|
|
475
|
+
*/
|
|
476
|
+
function keepWaitingForStore(pending: PendingFrameReclaim): void {
|
|
477
|
+
pending.attempts += 1;
|
|
478
|
+
if (pending.attempts === RECLAIM_RECHECK_QUICK_PASSES) {
|
|
479
|
+
log.warn(
|
|
480
|
+
{
|
|
481
|
+
conversationId: pending.conversationId,
|
|
482
|
+
attachmentIds: pending.attachmentIds,
|
|
483
|
+
},
|
|
484
|
+
"Still cannot settle a camera frame; asking less often until the store answers",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
pendingFrameReclaims.push(pending);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Frames whose outcome the store has not reported yet. */
|
|
491
|
+
export function _pendingFrameReclaimCountForTests(): number {
|
|
492
|
+
return pendingFrameReclaims.length;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** The wait the next recheck pass would use. */
|
|
496
|
+
export function _nextFrameReclaimRecheckDelayForTests(): number {
|
|
497
|
+
return nextFrameReclaimRecheckDelay();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Ask the store now rather than on the recheck timer. */
|
|
501
|
+
export function _drainFrameReclaimRechecksForTests(): void {
|
|
502
|
+
drainFrameReclaimRechecks();
|
|
503
|
+
}
|
|
504
|
+
|
|
268
505
|
/**
|
|
269
506
|
* Take the conversation's processing flag once it is free, or return false on
|
|
270
507
|
* timeout having taken nothing.
|
|
@@ -280,17 +517,28 @@ export function _standaloneImageQueueSizeForTests(): number {
|
|
|
280
517
|
* means nothing was taken and nothing needs releasing.
|
|
281
518
|
*/
|
|
282
519
|
async function acquireProcessingFlag(conversation: {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}): Promise<boolean> {
|
|
520
|
+
acquireProcessingFenced: () => Promise<number | null>;
|
|
521
|
+
}): Promise<number | null> {
|
|
286
522
|
const deadline = Date.now() + processingWaitMs;
|
|
287
523
|
for (;;) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
524
|
+
// Fenced, so a frame never writes into a turn no reader can see. A marker
|
|
525
|
+
// that refuses to persist gives the hold back and drops the frame; a
|
|
526
|
+
// conversation that belongs to someone else is waited out below.
|
|
527
|
+
let owner: number | null;
|
|
528
|
+
try {
|
|
529
|
+
owner = await conversation.acquireProcessingFenced();
|
|
530
|
+
} catch (err) {
|
|
531
|
+
log.warn(
|
|
532
|
+
{ err },
|
|
533
|
+
"Standalone image gave up its hold: the processing marker would not persist",
|
|
534
|
+
);
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
if (owner !== null) {
|
|
538
|
+
return owner;
|
|
291
539
|
}
|
|
292
540
|
if (Date.now() >= deadline) {
|
|
293
|
-
return
|
|
541
|
+
return null;
|
|
294
542
|
}
|
|
295
543
|
await new Promise((resolve) => setTimeout(resolve, PROCESSING_POLL_MS));
|
|
296
544
|
}
|
|
@@ -309,76 +557,260 @@ async function acquireProcessingFlag(conversation: {
|
|
|
309
557
|
* them ever holds the flag. Never throws. An image that cannot be stored must
|
|
310
558
|
* not take the call down with it, and the caller reports the failure to the
|
|
311
559
|
* user instead.
|
|
560
|
+
*
|
|
561
|
+
* Every later check answers for the one incarnation the image was accepted
|
|
562
|
+
* for, which is read before anything is queued. Reading it inside the job
|
|
563
|
+
* instead would read whatever holds the id by the time the job runs: the chain
|
|
564
|
+
* can hold a frame behind another image for as long as a turn runs, and a
|
|
565
|
+
* conversation deleted and recreated in that time would be captured as the
|
|
566
|
+
* incarnation the frame was taken in, leaving every check comparing the
|
|
567
|
+
* replacement against itself.
|
|
568
|
+
*
|
|
569
|
+
* `acceptedIncarnation` is that value read by the caller, for one whose own
|
|
570
|
+
* acceptance is earlier than this call: the HTTP door answers 404 from a row
|
|
571
|
+
* it reads before resolving the request's actor, so the row it accepted is the
|
|
572
|
+
* one it read there rather than whatever survives that resolution. A caller
|
|
573
|
+
* with nothing between its acceptance and this call omits it, and the read
|
|
574
|
+
* below is that same moment.
|
|
312
575
|
*/
|
|
313
576
|
function persistStandaloneImage(
|
|
314
577
|
conversationId: string,
|
|
315
578
|
attachmentId: string,
|
|
316
579
|
kind: "photo" | "sight_frame",
|
|
317
|
-
persistOptions: Omit<
|
|
580
|
+
persistOptions: Omit<
|
|
581
|
+
PersistMessageOptions,
|
|
582
|
+
| "attachments"
|
|
583
|
+
| "requestId"
|
|
584
|
+
| "insertPrecondition"
|
|
585
|
+
| "onUndiscardedAttachments"
|
|
586
|
+
>,
|
|
587
|
+
acceptedIncarnation?: number,
|
|
318
588
|
): Promise<LiveVoicePhotoResult> {
|
|
319
|
-
|
|
320
|
-
|
|
589
|
+
let incarnation: number | null;
|
|
590
|
+
try {
|
|
591
|
+
incarnation =
|
|
592
|
+
acceptedIncarnation ?? getConversation(conversationId)?.createdAt ?? null;
|
|
593
|
+
} catch (err) {
|
|
594
|
+
// This read is the one thing here that runs outside the job's own catch,
|
|
595
|
+
// and the contract above is that an image never takes its caller down: the
|
|
596
|
+
// socket attaches no handler for a rejection. Reported as the refusal it
|
|
597
|
+
// is, and the upload is held rather than deleted, because a store that
|
|
598
|
+
// cannot be read is not evidence the conversation is gone. Nothing was
|
|
599
|
+
// inserted, so the recheck reclaims as soon as the store answers at all.
|
|
600
|
+
log.warn(
|
|
601
|
+
{ err, conversationId, attachmentId, kind },
|
|
602
|
+
"Could not read the conversation a standalone image names",
|
|
603
|
+
);
|
|
604
|
+
if (kind === "sight_frame") {
|
|
605
|
+
deferFrameReclaimDecision(
|
|
606
|
+
conversationId,
|
|
607
|
+
uuidv7(),
|
|
608
|
+
[attachmentId],
|
|
609
|
+
persistOptions.content,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
return Promise.resolve({ ok: false });
|
|
613
|
+
}
|
|
614
|
+
if (incarnation === null) {
|
|
615
|
+
log.warn(
|
|
616
|
+
{ conversationId, attachmentId, kind },
|
|
617
|
+
"Standalone image dropped: it names no conversation",
|
|
618
|
+
);
|
|
619
|
+
if (kind === "sight_frame") {
|
|
620
|
+
reclaimOrDefer(
|
|
621
|
+
conversationId,
|
|
622
|
+
[attachmentId],
|
|
623
|
+
persistOptions.content,
|
|
624
|
+
uuidv7(),
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
return Promise.resolve({ ok: false });
|
|
628
|
+
}
|
|
629
|
+
return enqueueStandaloneImagePersist(
|
|
630
|
+
conversationId,
|
|
631
|
+
attachmentId,
|
|
632
|
+
kind,
|
|
633
|
+
persistOptions.content,
|
|
634
|
+
() =>
|
|
635
|
+
writeStandaloneImage(
|
|
636
|
+
conversationId,
|
|
637
|
+
attachmentId,
|
|
638
|
+
kind,
|
|
639
|
+
incarnation,
|
|
640
|
+
persistOptions,
|
|
641
|
+
),
|
|
321
642
|
);
|
|
322
643
|
}
|
|
323
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Report and give up on an image whose conversation is no longer the
|
|
647
|
+
* incarnation it was accepted for.
|
|
648
|
+
*
|
|
649
|
+
* Keeps only, per {@link reclaimDroppedFrame}: a photo that failed to land is
|
|
650
|
+
* the user's to retry rather than the daemon's to delete.
|
|
651
|
+
*/
|
|
652
|
+
function dropReplacedImage(
|
|
653
|
+
conversationId: string,
|
|
654
|
+
attachmentId: string,
|
|
655
|
+
kind: "photo" | "sight_frame",
|
|
656
|
+
content: string,
|
|
657
|
+
): LiveVoicePhotoResult {
|
|
658
|
+
log.warn(
|
|
659
|
+
{ conversationId, attachmentId, kind },
|
|
660
|
+
"Standalone image dropped: its conversation was replaced before the write",
|
|
661
|
+
);
|
|
662
|
+
if (kind === "sight_frame") {
|
|
663
|
+
reclaimOrDefer(conversationId, [attachmentId], content, uuidv7());
|
|
664
|
+
}
|
|
665
|
+
return { ok: false };
|
|
666
|
+
}
|
|
667
|
+
|
|
324
668
|
async function writeStandaloneImage(
|
|
325
669
|
conversationId: string,
|
|
326
670
|
attachmentId: string,
|
|
327
671
|
kind: "photo" | "sight_frame",
|
|
328
|
-
|
|
672
|
+
incarnation: number,
|
|
673
|
+
persistOptions: Omit<
|
|
674
|
+
PersistMessageOptions,
|
|
675
|
+
| "attachments"
|
|
676
|
+
| "requestId"
|
|
677
|
+
| "insertPrecondition"
|
|
678
|
+
| "onUndiscardedAttachments"
|
|
679
|
+
>,
|
|
329
680
|
): Promise<LiveVoicePhotoResult> {
|
|
330
681
|
const { content } = persistOptions;
|
|
331
682
|
// The id the row is inserted under, so a failure can ask whether the insert
|
|
332
683
|
// landed before deciding the frame is safe to reclaim.
|
|
333
684
|
const requestId = uuidv7();
|
|
685
|
+
// Ids the persist materialized for this attempt and then could not delete.
|
|
686
|
+
// A frame already linked elsewhere is cloned into this conversation under a
|
|
687
|
+
// fresh id, and nothing but the persist knows it: reclaiming under the id
|
|
688
|
+
// this module holds would leave the clone behind for good.
|
|
689
|
+
const strandedClones: string[] = [];
|
|
334
690
|
try {
|
|
335
691
|
const attachments = resolveAttachmentsForPersist([attachmentId]);
|
|
336
692
|
if (attachments.length === 0) {
|
|
337
693
|
log.warn(
|
|
338
694
|
{ attachmentId, kind },
|
|
339
|
-
"
|
|
695
|
+
"Standalone image attachment did not resolve",
|
|
340
696
|
);
|
|
341
697
|
return { ok: false };
|
|
342
698
|
}
|
|
343
699
|
|
|
344
|
-
|
|
700
|
+
// A queued job starts long after its caller checked, so the conversation
|
|
701
|
+
// can be gone by now. The acquire creates nothing, which is what keeps a
|
|
702
|
+
// delete final for everything already queued: the creating acquire would
|
|
703
|
+
// write the row back and bring the conversation home carrying nothing but
|
|
704
|
+
// camera frames. A delete that lands after the acquire is fenced by the
|
|
705
|
+
// messages foreign key instead, and the failure path below reclaims the
|
|
706
|
+
// upload once it reads that no row was inserted.
|
|
707
|
+
const conversation = await getConversationIfExists(conversationId);
|
|
708
|
+
if (!conversation) {
|
|
709
|
+
log.warn(
|
|
710
|
+
{ conversationId, attachmentId, kind },
|
|
711
|
+
"Standalone image dropped: its conversation was deleted while it waited",
|
|
712
|
+
);
|
|
713
|
+
if (kind === "sight_frame") {
|
|
714
|
+
reclaimOrDefer(conversationId, [attachmentId], content, uuidv7());
|
|
715
|
+
}
|
|
716
|
+
return { ok: false };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// The acquire answers for the row it found, which for a job the chain held
|
|
720
|
+
// behind another image can be a conversation created under this id since.
|
|
721
|
+
// Asked before the idle wait so a replacement is answered now rather than
|
|
722
|
+
// after holding a stranger's lock for the length of a turn.
|
|
723
|
+
if (!isSameIncarnation(conversationId, incarnation)) {
|
|
724
|
+
return dropReplacedImage(conversationId, attachmentId, kind, content);
|
|
725
|
+
}
|
|
345
726
|
|
|
346
727
|
// A turn holds the lock for its whole run. Waiting rather than queueing:
|
|
347
728
|
// the conversation's queue drains into a turn, which is the one thing this
|
|
348
729
|
// must not cause.
|
|
349
|
-
|
|
730
|
+
const owner = await acquireProcessingFlag(conversation);
|
|
731
|
+
if (owner === null) {
|
|
350
732
|
log.warn(
|
|
351
733
|
{ conversationId, attachmentId, kind },
|
|
352
|
-
"
|
|
734
|
+
"Standalone image timed out waiting for the conversation to go idle",
|
|
353
735
|
);
|
|
354
736
|
if (kind === "sight_frame") {
|
|
355
|
-
|
|
737
|
+
reclaimOrDefer(conversationId, [attachmentId], content, uuidv7());
|
|
356
738
|
}
|
|
357
739
|
return { ok: false };
|
|
358
740
|
}
|
|
359
741
|
|
|
360
742
|
try {
|
|
743
|
+
// The wait can outlast the conversation, and this job holds an instance
|
|
744
|
+
// rather than re-reading, so a delete alone would be caught only by the
|
|
745
|
+
// messages foreign key. A delete followed by a recreate under the same
|
|
746
|
+
// id restores that foreign key's target, and the row would land in a
|
|
747
|
+
// conversation created after the deletion. Both kinds check: a photo
|
|
748
|
+
// persisted into a stranger that inherited the name is the same wrong.
|
|
749
|
+
// The same question rides the persist below as its insert precondition,
|
|
750
|
+
// which is what covers a replacement landing while the write runs.
|
|
751
|
+
if (!isSameIncarnation(conversationId, incarnation)) {
|
|
752
|
+
return dropReplacedImage(conversationId, attachmentId, kind, content);
|
|
753
|
+
}
|
|
754
|
+
|
|
361
755
|
const persisted = await persistQueuedMessageBody(conversation, {
|
|
362
756
|
...persistOptions,
|
|
363
757
|
attachments,
|
|
364
758
|
requestId,
|
|
759
|
+
// Asked again in the insert's own tick, about both things that can
|
|
760
|
+
// stop being true across the awaits the persist takes to materialize
|
|
761
|
+
// the attachment and build its content.
|
|
762
|
+
//
|
|
763
|
+
// A delete and recreate under the same id leaves a valid foreign-key
|
|
764
|
+
// target, so without the first term the frame joins a conversation it
|
|
765
|
+
// was never taken in. A Stop on this hold force-clears the flag, since
|
|
766
|
+
// no turn owns it, and the next request acquires, so without the
|
|
767
|
+
// second the frame writes under a dead claim alongside that turn. The
|
|
768
|
+
// release afterwards is refused either way, but a refused release
|
|
769
|
+
// cannot undo a row.
|
|
770
|
+
//
|
|
771
|
+
// Refusing is also the right reading of the Stop: the user asked the
|
|
772
|
+
// conversation to stop, and this frame is the camera's, not theirs.
|
|
773
|
+
insertPrecondition: () =>
|
|
774
|
+
isSameIncarnation(conversationId, incarnation) &&
|
|
775
|
+
conversation.holdsProcessingClaim(owner),
|
|
776
|
+
onUndiscardedAttachments: (ids) => {
|
|
777
|
+
strandedClones.push(...ids);
|
|
778
|
+
},
|
|
365
779
|
});
|
|
366
780
|
|
|
781
|
+
// The row just joined the resident history, and this write ran outside
|
|
782
|
+
// any turn, so nothing scoped that history for the actor it names. A
|
|
783
|
+
// frame posted by one actor into a conversation resident under another
|
|
784
|
+
// would otherwise reach the model inside a scope a reload filters it out
|
|
785
|
+
// of.
|
|
786
|
+
conversation.markHistoryStaleForForeignScope(persistOptions.trustContext);
|
|
787
|
+
|
|
367
788
|
announcePersistedImage(conversationId, content, persisted.id);
|
|
368
789
|
|
|
369
790
|
return { ok: true, messageId: persisted.id };
|
|
370
791
|
} finally {
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
792
|
+
// Only this job's own hold is released. A turn that claimed the flag
|
|
793
|
+
// away mid-write owns it now, and clearing there would free a turn that
|
|
794
|
+
// is still running.
|
|
795
|
+
if (conversation.releaseProcessing(owner)) {
|
|
796
|
+
// Anything queued behind the lock we just held still has to run.
|
|
797
|
+
// Without this a message queued during the image's write sits until
|
|
798
|
+
// the next turn ends.
|
|
799
|
+
void conversation.kickDrainQueue("loop_complete", `standalone_${kind}`);
|
|
800
|
+
}
|
|
376
801
|
}
|
|
377
802
|
} catch (err) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
803
|
+
if (err instanceof MessageInsertPreconditionError) {
|
|
804
|
+
log.warn(
|
|
805
|
+
{ conversationId, attachmentId, kind },
|
|
806
|
+
"Standalone image dropped: its conversation was replaced during the write",
|
|
807
|
+
);
|
|
808
|
+
} else {
|
|
809
|
+
log.warn(
|
|
810
|
+
{ err, conversationId, attachmentId, kind },
|
|
811
|
+
"Failed to persist a standalone image",
|
|
812
|
+
);
|
|
813
|
+
}
|
|
382
814
|
const inserted = insertedMessageState(conversationId, requestId);
|
|
383
815
|
if (inserted === "exists") {
|
|
384
816
|
// The row is in the transcript, so the result has to say so whatever
|
|
@@ -397,13 +829,31 @@ async function writeStandaloneImage(
|
|
|
397
829
|
} catch (announceErr) {
|
|
398
830
|
log.warn(
|
|
399
831
|
{ err: announceErr, conversationId, messageId: requestId },
|
|
400
|
-
"Persisted a
|
|
832
|
+
"Persisted a standalone image but could not announce it",
|
|
401
833
|
);
|
|
402
834
|
}
|
|
403
835
|
return { ok: true, messageId: requestId };
|
|
404
836
|
}
|
|
405
|
-
if (kind === "sight_frame"
|
|
406
|
-
|
|
837
|
+
if (kind === "sight_frame") {
|
|
838
|
+
if (inserted === "absent") {
|
|
839
|
+
reclaimOrDefer(
|
|
840
|
+
conversationId,
|
|
841
|
+
[attachmentId, ...strandedClones],
|
|
842
|
+
content,
|
|
843
|
+
requestId,
|
|
844
|
+
);
|
|
845
|
+
} else {
|
|
846
|
+
// The store would not say whether the row landed. The refusal below
|
|
847
|
+
// reports the frame as handled, so the upload cannot simply be left,
|
|
848
|
+
// and a row that turns out to have landed was never announced. Both
|
|
849
|
+
// wait on the same answer.
|
|
850
|
+
deferFrameReclaimDecision(
|
|
851
|
+
conversationId,
|
|
852
|
+
requestId,
|
|
853
|
+
[attachmentId, ...strandedClones],
|
|
854
|
+
content,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
407
857
|
}
|
|
408
858
|
return { ok: false };
|
|
409
859
|
}
|
|
@@ -461,7 +911,7 @@ function insertedMessageState(
|
|
|
461
911
|
} catch (err) {
|
|
462
912
|
log.warn(
|
|
463
913
|
{ err, conversationId, messageId },
|
|
464
|
-
"Could not tell whether a
|
|
914
|
+
"Could not tell whether a standalone image persisted; keeping its attachment and reporting failure",
|
|
465
915
|
);
|
|
466
916
|
return "unknown";
|
|
467
917
|
}
|
|
@@ -489,6 +939,9 @@ export async function persistLiveVoicePhoto(
|
|
|
489
939
|
});
|
|
490
940
|
}
|
|
491
941
|
|
|
942
|
+
/** Which client surface the camera's gate was running on. */
|
|
943
|
+
export type SightFrameSurface = "voice" | "chat";
|
|
944
|
+
|
|
492
945
|
/**
|
|
493
946
|
* Persist an ambient camera frame the client's gate kept.
|
|
494
947
|
*
|
|
@@ -501,23 +954,53 @@ export async function persistLiveVoicePhoto(
|
|
|
501
954
|
*
|
|
502
955
|
* `scripted` because the camera's gate sent this, not the user: a keep every
|
|
503
956
|
* few seconds would otherwise read downstream as that many turns the user
|
|
504
|
-
* took, and activation counts turns that claim they were typed.
|
|
957
|
+
* took, and activation counts turns that claim they were typed. The pair of
|
|
958
|
+
* `scripted` and the tag is also the signature the memory-privacy guard
|
|
959
|
+
* (`messageMetadataIsAmbientSightKeep`) reads, so both surfaces stamp both.
|
|
960
|
+
*
|
|
961
|
+
* The surface decides one key and nothing else. `voiceSessionTurn` says a
|
|
962
|
+
* reply to this row is spoken back over a session that is still open, which is
|
|
963
|
+
* true of a keep taken on a call and false of one taken beside the composer,
|
|
964
|
+
* so only the voice caller stamps it.
|
|
965
|
+
*
|
|
966
|
+
* `trustContext` is the requester's own trust, for a caller that resolved one
|
|
967
|
+
* from the actor it verified. The row's provenance is stamped from it, so a
|
|
968
|
+
* conversation whose resting trust names an earlier actor cannot claim this
|
|
969
|
+
* frame. A caller that holds no per-request actor omits it and the persist
|
|
970
|
+
* attributes the row to the conversation, which is the right answer for a
|
|
971
|
+
* session that owns the conversation's trust for its whole life.
|
|
972
|
+
*
|
|
973
|
+
* `acceptedIncarnation` is the `created_at` of the conversation the caller
|
|
974
|
+
* accepted the frame for, for one that resolved that trust between reading the
|
|
975
|
+
* row and calling here: the frame belongs to the row the caller answered on,
|
|
976
|
+
* not to whatever holds the id once the resolution returns. A caller with no
|
|
977
|
+
* such gap omits it. See {@link persistStandaloneImage}.
|
|
505
978
|
*/
|
|
506
|
-
export async function
|
|
979
|
+
export async function persistAmbientSightFrame(
|
|
507
980
|
conversationId: string,
|
|
508
981
|
attachmentId: string,
|
|
982
|
+
surface: SightFrameSurface,
|
|
983
|
+
trustContext?: TrustContext,
|
|
984
|
+
acceptedIncarnation?: number,
|
|
509
985
|
): Promise<LiveVoicePhotoResult> {
|
|
510
|
-
return persistStandaloneImage(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
986
|
+
return persistStandaloneImage(
|
|
987
|
+
conversationId,
|
|
988
|
+
attachmentId,
|
|
989
|
+
"sight_frame",
|
|
990
|
+
{
|
|
991
|
+
content: SIGHT_FRAME_MESSAGE_CONTENT,
|
|
992
|
+
metadata: surface === "voice" ? { voiceSessionTurn: true } : {},
|
|
993
|
+
...(trustContext ? { trustContext } : {}),
|
|
994
|
+
scripted: true,
|
|
995
|
+
// The camera sampled this, nobody sent it. Indexing it would feed
|
|
996
|
+
// extraction a frame every few seconds of whatever the room happens to
|
|
997
|
+
// contain, and commit those visuals to long-term memory with no consent
|
|
998
|
+
// surface: the design puts keeps in the TRANSCRIPT, which the user can
|
|
999
|
+
// see and delete, and says nothing about memory. The text half is
|
|
1000
|
+
// worthless to search anyway, every row reading "(camera frame)".
|
|
1001
|
+
skipIndexing: true,
|
|
1002
|
+
sightFrameAttachmentIds: [attachmentId],
|
|
1003
|
+
},
|
|
1004
|
+
acceptedIncarnation,
|
|
1005
|
+
);
|
|
523
1006
|
}
|