@vellumai/assistant 0.11.10 → 0.11.11-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/AGENTS.md +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -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/plan-credit.ts +66 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -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/plan-credit.ts +66 -0
- package/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/openapi.yaml +152 -55
- package/package.json +1 -1
- package/src/__tests__/anthropic-provider.test.ts +60 -0
- package/src/__tests__/config-get-vision-flag.test.ts +36 -0
- package/src/__tests__/conversation-error.test.ts +38 -0
- package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
- package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
- package/src/__tests__/headless-browser-mode.test.ts +63 -13
- package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
- package/src/__tests__/heartbeat-service.test.ts +118 -27
- package/src/__tests__/host-app-control-proxy.test.ts +2 -2
- package/src/__tests__/host-app-control-routes.test.ts +41 -0
- package/src/__tests__/host-bash-routes.test.ts +30 -1
- package/src/__tests__/host-browser-routes.test.ts +34 -0
- package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
- package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
- package/src/__tests__/host-proxy-base.test.ts +1 -0
- package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
- package/src/__tests__/llm-schema.test.ts +32 -0
- package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
- package/src/__tests__/memory-config-file-parity.test.ts +18 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
- package/src/__tests__/messaging-send-tool.test.ts +159 -13
- package/src/__tests__/messaging-shared.test.ts +19 -0
- package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
- package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
- package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
- package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
- package/src/__tests__/schedule-routes.test.ts +30 -1
- package/src/__tests__/scheduler-result-notification.test.ts +209 -0
- package/src/__tests__/skills-install-extract.test.ts +26 -0
- package/src/__tests__/skills-install-staging.test.ts +11 -2
- package/src/api/constants/personality-sliders.test.ts +21 -0
- package/src/api/constants/personality-sliders.ts +64 -0
- package/src/api/index.ts +8 -0
- package/src/archive/ustar-size.test.ts +32 -0
- package/src/archive/ustar-size.ts +23 -0
- package/src/background-wake/next-wake.test.ts +13 -0
- package/src/background-wake/next-wake.ts +4 -2
- package/src/calls/voice-session-bridge.ts +26 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
- package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
- package/src/cli/commands/config.help.ts +3 -1
- package/src/cli/commands/memory/worker.ts +1 -1
- package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
- package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
- package/src/cli/commands/platform/credits-format.ts +57 -0
- package/src/cli/commands/platform/index.help.ts +19 -3
- package/src/cli/commands/plugins.help.ts +4 -1
- package/src/cli/commands/plugins.ts +2 -1
- package/src/cli/lib/bundled-marketplace.json +1 -1
- package/src/cli/lib/install-from-platform.ts +8 -10
- package/src/config/bundled-skills/messaging/SKILL.md +2 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +9 -1
- package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
- package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
- package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/input-modalities.test.ts +71 -0
- package/src/config/input-modalities.ts +95 -0
- package/src/config/schemas/heartbeat.ts +3 -3
- package/src/config/schemas/llm.ts +13 -3
- package/src/config/schemas/memory-storage.test.ts +39 -0
- package/src/config/schemas/memory-storage.ts +21 -0
- package/src/context/token-estimator.ts +2 -2
- package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
- package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
- package/src/daemon/conversation-error.ts +20 -7
- package/src/daemon/embedding-reconcile.ts +64 -1
- package/src/daemon/handlers/config-embeddings.ts +31 -0
- package/src/daemon/host-app-control-proxy.ts +2 -0
- package/src/daemon/host-bash-proxy.ts +1 -0
- package/src/daemon/host-cu-proxy.ts +6 -6
- package/src/daemon/host-file-proxy.ts +6 -6
- package/src/daemon/host-proxy-base.ts +10 -9
- package/src/daemon/host-transfer-proxy.ts +21 -24
- package/src/daemon/mcp-reload-service.ts +6 -0
- package/src/daemon/providers-setup.ts +5 -26
- package/src/heartbeat/heartbeat-service.ts +72 -13
- package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
- package/src/live-voice/live-voice-photo.ts +68 -4
- package/src/live-voice/live-voice-session.ts +36 -2
- package/src/live-voice/protocol.ts +98 -1
- package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
- package/src/mcp/__tests__/reload-signal.test.ts +57 -0
- package/src/mcp/__tests__/startup.test.ts +150 -0
- package/src/mcp/reload-signal.ts +50 -0
- package/src/mcp/startup.ts +101 -0
- package/src/messaging/providers/outlook/adapter.ts +2 -13
- package/src/messaging/providers/outlook/client.ts +13 -1
- package/src/messaging/providers/outlook/types.ts +3 -0
- package/src/notifications/__tests__/decision-engine.test.ts +79 -6
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
- package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
- package/src/notifications/copy-composer.ts +33 -11
- package/src/notifications/decision-engine.ts +48 -11
- package/src/notifications/events-store.ts +68 -2
- package/src/notifications/home-feed-side-effect.ts +15 -3
- package/src/notifications/schedule-result-producer.ts +275 -0
- package/src/notifications/signal.ts +5 -0
- package/src/oauth/seed-providers.ts +3 -2
- package/src/persistence/conversation-crud.ts +38 -0
- package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
- package/src/persistence/embeddings/embedding-backend.ts +148 -11
- package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
- package/src/persistence/embeddings/embedding-openai.ts +55 -4
- package/src/persistence/embeddings/embedding-types.test.ts +24 -0
- package/src/persistence/embeddings/embedding-types.ts +25 -1
- package/src/persistence/job-utils.ts +6 -2
- package/src/plugin-api/index.ts +2 -1
- package/src/plugin-api/vision-support.test.ts +81 -0
- package/src/plugin-api/vision-support.ts +57 -10
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
- package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
- package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
- package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
- package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
- package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
- package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
- package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
- package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
- package/src/providers/anthropic/client.ts +9 -4
- package/src/providers/inline-audio-support.test.ts +34 -0
- package/src/providers/inline-audio-support.ts +27 -0
- package/src/providers/model-catalog.ts +22 -2
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
- package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
- package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
- package/src/providers/openai/api-error-normalization.ts +18 -3
- package/src/providers/openai/chat-completions-provider.ts +113 -13
- package/src/providers/openai/responses-provider.ts +10 -8
- package/src/providers/provider-secret-catalog.ts +11 -1
- package/src/providers/vellum/client.ts +14 -1
- package/src/providers/vellum/personality-directions.test.ts +163 -0
- package/src/providers/vellum/personality-directions.ts +120 -0
- package/src/providers/xml-tool-call-salvage.ts +212 -0
- package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
- package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
- package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
- package/src/runtime/auth/same-actor.ts +29 -5
- package/src/runtime/background-job-runner.ts +38 -29
- package/src/runtime/guardian-action-service.ts +20 -1
- package/src/runtime/migrations/vbundle-validator.ts +3 -3
- package/src/runtime/pending-interactions.ts +4 -5
- package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
- package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
- package/src/runtime/routes/conversation-query-routes.ts +53 -11
- package/src/runtime/routes/document-comments-routes.ts +8 -2
- package/src/runtime/routes/guardian-action-routes.ts +7 -0
- package/src/runtime/routes/heartbeat-routes.ts +41 -53
- package/src/runtime/routes/host-app-control-routes.ts +10 -36
- package/src/runtime/routes/host-bash-routes.ts +11 -45
- package/src/runtime/routes/host-browser-routes.ts +24 -0
- package/src/runtime/routes/host-cu-routes.ts +11 -45
- package/src/runtime/routes/host-file-routes.ts +11 -44
- package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
- package/src/runtime/routes/host-transfer-routes.ts +33 -25
- package/src/runtime/routes/index.ts +0 -2
- package/src/runtime/routes/integrations/a2a.ts +12 -3
- package/src/runtime/routes/integrations/vercel.ts +13 -3
- package/src/runtime/routes/platform-routes.ts +121 -24
- package/src/runtime/routes/schedule-routes.ts +15 -1
- package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
- package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
- package/src/schedule/schedule-timezone.ts +25 -0
- package/src/schedule/scheduler.ts +19 -0
- package/src/schedule/worker.ts +119 -2
- package/src/skills/catalog-install.ts +40 -5
- package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
- package/src/tools/browser/browser-execution.ts +117 -53
- package/src/tools/browser/browser-status-constants.ts +6 -6
- package/src/tools/capability-offer.test.ts +87 -0
- package/src/tools/capability-offer.ts +93 -0
- package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
- package/src/tools/terminal/safe-env.ts +3 -2
- package/src/util/provider-error-patterns.ts +34 -0
- package/src/watcher/__tests__/engine.test.ts +5 -2
- package/src/watcher/engine.ts +7 -4
- package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
- package/src/runtime/routes/sight-frame-routes.ts +0 -136
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ambiguousSameUserError,
|
|
10
10
|
enforceSameActorOrErrorResult,
|
|
11
11
|
pickSameUserAutoResolve,
|
|
12
|
+
snapshotHostProxyActorPrincipalId,
|
|
12
13
|
} from "../runtime/auth/same-actor.js";
|
|
13
14
|
import * as pendingInteractions from "../runtime/pending-interactions.js";
|
|
14
15
|
import { readAudioBase64 } from "../tools/shared/filesystem/audio-read.js";
|
|
@@ -183,12 +184,11 @@ export class HostFileProxy {
|
|
|
183
184
|
conversationId,
|
|
184
185
|
kind: "host_file",
|
|
185
186
|
targetClientId: resolvedTargetClientId,
|
|
186
|
-
targetActorPrincipalId:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
: undefined,
|
|
187
|
+
targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
|
|
188
|
+
hub: assistantEventHub,
|
|
189
|
+
targetClientId: resolvedTargetClientId,
|
|
190
|
+
sourceActorPrincipalId,
|
|
191
|
+
}),
|
|
192
192
|
rpcResolve: resolve as (v: unknown) => void,
|
|
193
193
|
rpcReject: reject,
|
|
194
194
|
timer,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
assistantEventHub,
|
|
22
22
|
broadcastMessage,
|
|
23
23
|
} from "../runtime/assistant-event-hub.js";
|
|
24
|
+
import { snapshotHostProxyActorPrincipalId } from "../runtime/auth/same-actor.js";
|
|
24
25
|
import type { PendingInteraction } from "../runtime/pending-interactions.js";
|
|
25
26
|
import * as pendingInteractions from "../runtime/pending-interactions.js";
|
|
26
27
|
import { AssistantError, ErrorCode } from "../util/errors.js";
|
|
@@ -142,6 +143,7 @@ export abstract class HostProxyBase<TRequest, TResultPayload> {
|
|
|
142
143
|
extraFields?: Record<string, unknown>,
|
|
143
144
|
targetClientId?: string,
|
|
144
145
|
timeoutMsOverride?: number,
|
|
146
|
+
sourceActorPrincipalId?: string,
|
|
145
147
|
): Promise<TResultPayload> {
|
|
146
148
|
const requestId = uuid();
|
|
147
149
|
const effectiveTimeoutMs = timeoutMsOverride ?? this.timeoutMs;
|
|
@@ -206,15 +208,14 @@ export abstract class HostProxyBase<TRequest, TResultPayload> {
|
|
|
206
208
|
// (HostCuProxy bypasses dispatchRequest entirely with its own inline
|
|
207
209
|
// request method that registers directly, which is why CU works
|
|
208
210
|
// without this base-level fix.)
|
|
209
|
-
// Snapshot the
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
: undefined;
|
|
211
|
+
// Snapshot the actor principal at registration so the result-route
|
|
212
|
+
// same-actor check has a stable value. Targeted dispatch uses the
|
|
213
|
+
// target client; untargeted dispatch uses the turn's source actor.
|
|
214
|
+
const targetActorPrincipalId = snapshotHostProxyActorPrincipalId({
|
|
215
|
+
hub: assistantEventHub,
|
|
216
|
+
targetClientId,
|
|
217
|
+
sourceActorPrincipalId,
|
|
218
|
+
});
|
|
218
219
|
pendingInteractions.register(requestId, {
|
|
219
220
|
conversationId,
|
|
220
221
|
kind: this.resultPendingKind,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
ambiguousSameUserError,
|
|
14
14
|
enforceSameActorOrErrorResult,
|
|
15
15
|
pickSameUserAutoResolve,
|
|
16
|
+
snapshotHostProxyActorPrincipalId,
|
|
16
17
|
} from "../runtime/auth/same-actor.js";
|
|
17
18
|
import * as pendingInteractions from "../runtime/pending-interactions.js";
|
|
18
19
|
import type { ToolExecutionResult } from "../tools/types.js";
|
|
@@ -259,24 +260,22 @@ export class HostTransferProxy {
|
|
|
259
260
|
sha256,
|
|
260
261
|
fileBuffer,
|
|
261
262
|
targetClientId: resolvedTargetClientId,
|
|
262
|
-
targetActorPrincipalId:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
: undefined,
|
|
263
|
+
targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
|
|
264
|
+
hub: assistantEventHub,
|
|
265
|
+
targetClientId: resolvedTargetClientId,
|
|
266
|
+
sourceActorPrincipalId,
|
|
267
|
+
}),
|
|
268
268
|
});
|
|
269
269
|
|
|
270
270
|
pendingInteractions.register(requestId, {
|
|
271
271
|
conversationId: input.conversationId,
|
|
272
272
|
kind: "host_transfer",
|
|
273
273
|
targetClientId: resolvedTargetClientId,
|
|
274
|
-
targetActorPrincipalId:
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
: undefined,
|
|
274
|
+
targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
|
|
275
|
+
hub: assistantEventHub,
|
|
276
|
+
targetClientId: resolvedTargetClientId,
|
|
277
|
+
sourceActorPrincipalId,
|
|
278
|
+
}),
|
|
280
279
|
rpcResolve: resolve as (v: unknown) => void,
|
|
281
280
|
rpcReject: reject,
|
|
282
281
|
timer,
|
|
@@ -450,24 +449,22 @@ export class HostTransferProxy {
|
|
|
450
449
|
filePath: input.destPath,
|
|
451
450
|
overwrite: input.overwrite,
|
|
452
451
|
targetClientId: resolvedTargetClientId,
|
|
453
|
-
targetActorPrincipalId:
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
: undefined,
|
|
452
|
+
targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
|
|
453
|
+
hub: assistantEventHub,
|
|
454
|
+
targetClientId: resolvedTargetClientId,
|
|
455
|
+
sourceActorPrincipalId,
|
|
456
|
+
}),
|
|
459
457
|
});
|
|
460
458
|
|
|
461
459
|
pendingInteractions.register(requestId, {
|
|
462
460
|
conversationId: input.conversationId,
|
|
463
461
|
kind: "host_transfer",
|
|
464
462
|
targetClientId: resolvedTargetClientId,
|
|
465
|
-
targetActorPrincipalId:
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
: undefined,
|
|
463
|
+
targetActorPrincipalId: snapshotHostProxyActorPrincipalId({
|
|
464
|
+
hub: assistantEventHub,
|
|
465
|
+
targetClientId: resolvedTargetClientId,
|
|
466
|
+
sourceActorPrincipalId,
|
|
467
|
+
}),
|
|
471
468
|
rpcResolve: resolve as (v: unknown) => void,
|
|
472
469
|
rpcReject: reject,
|
|
473
470
|
timer,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "../mcp/effective-config.js";
|
|
13
13
|
import { getMcpServerManager } from "../mcp/manager.js";
|
|
14
14
|
import { migrateLegacyMcpHeaders } from "../mcp/mcp-header-store.js";
|
|
15
|
+
import { signalMcpReloaded } from "../mcp/reload-signal.js";
|
|
15
16
|
import { createMcpToolsFromServer } from "../tools/mcp/mcp-tool-factory.js";
|
|
16
17
|
import { registerMcpTools, unregisterAllMcpTools } from "../tools/registry.js";
|
|
17
18
|
import { getLogger } from "../util/logger.js";
|
|
@@ -154,6 +155,11 @@ async function doReload(): Promise<McpReloadResult> {
|
|
|
154
155
|
// to evict sessions.
|
|
155
156
|
|
|
156
157
|
log.info({ serverCount, toolCount }, "MCP servers reloaded");
|
|
158
|
+
// Other processes hold their own connections to the same servers and have
|
|
159
|
+
// no watcher of their own; this is how they learn the set moved. Only on
|
|
160
|
+
// success: a reload that failed before the teardown left this process on
|
|
161
|
+
// its existing servers, so there is nothing for anyone to mirror.
|
|
162
|
+
signalMcpReloaded();
|
|
157
163
|
return { success: true, serverCount, toolCount, servers };
|
|
158
164
|
} catch (err) {
|
|
159
165
|
const error = err instanceof Error ? err.message : String(err);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { maybeDefaultSpeechToManaged } from "../config/managed-speech-defaults.js";
|
|
2
2
|
import { rehydratePlatformCredentials } from "../config/platform-rehydration.js";
|
|
3
3
|
import type { AssistantConfig } from "../config/types.js";
|
|
4
|
-
import {
|
|
5
|
-
import { getMcpServerManager } from "../mcp/manager.js";
|
|
4
|
+
import { startConfiguredMcpServers } from "../mcp/startup.js";
|
|
6
5
|
import { gmailMessagingProvider } from "../messaging/providers/gmail/adapter.js";
|
|
7
6
|
import { outlookMessagingProvider } from "../messaging/providers/outlook/adapter.js";
|
|
8
7
|
import { slackProvider as slackMessagingProvider } from "../messaging/providers/slack/adapter.js";
|
|
@@ -11,8 +10,7 @@ import { whatsappMessagingProvider } from "../messaging/providers/whatsapp/adapt
|
|
|
11
10
|
import { registerMessagingProvider } from "../messaging/registry.js";
|
|
12
11
|
import { initializeProviders } from "../providers/registry.js";
|
|
13
12
|
import { validateSubagentRoleAllowlists } from "../subagent/validate-allowlists.js";
|
|
14
|
-
import {
|
|
15
|
-
import { initializeTools, registerMcpTools } from "../tools/registry.js";
|
|
13
|
+
import { initializeTools } from "../tools/registry.js";
|
|
16
14
|
import { getLogger } from "../util/logger.js";
|
|
17
15
|
import { initWatcherEngine } from "../watcher/engine.js";
|
|
18
16
|
import { registerWatcherProvider } from "../watcher/provider-registry.js";
|
|
@@ -63,28 +61,9 @@ export async function initializeProvidersAndTools(
|
|
|
63
61
|
}
|
|
64
62
|
|
|
65
63
|
// Start MCP servers — workspace-configured and plugin-declared alike —
|
|
66
|
-
// and register their tools.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const manager = getMcpServerManager();
|
|
70
|
-
try {
|
|
71
|
-
const serverToolInfos = await manager.start(mcpConfig);
|
|
72
|
-
for (const { serverId, serverConfig, tools } of serverToolInfos) {
|
|
73
|
-
const mcpTools = createMcpToolsFromServer(
|
|
74
|
-
tools,
|
|
75
|
-
serverId,
|
|
76
|
-
serverConfig,
|
|
77
|
-
manager,
|
|
78
|
-
);
|
|
79
|
-
registerMcpTools(serverId, mcpTools);
|
|
80
|
-
}
|
|
81
|
-
} catch (err) {
|
|
82
|
-
log.error(
|
|
83
|
-
{ err },
|
|
84
|
-
"MCP server initialization failed — continuing without MCP tools",
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
64
|
+
// and register their tools. Shared with the schedule worker, which hosts
|
|
65
|
+
// agent turns in its own process and so needs its own connections.
|
|
66
|
+
await startConfiguredMcpServers(config.mcp);
|
|
88
67
|
|
|
89
68
|
log.info("Daemon startup: providers and tools initialized");
|
|
90
69
|
}
|
|
@@ -21,6 +21,10 @@ import { broadcastMessage } from "../runtime/assistant-event-hub.js";
|
|
|
21
21
|
import { runBackgroundJob } from "../runtime/background-job-runner.js";
|
|
22
22
|
import { hasReceivedUserMessage } from "../runtime/pre-first-message-gate.js";
|
|
23
23
|
import { computeNextRunAt } from "../schedule/recurrence-engine.js";
|
|
24
|
+
import {
|
|
25
|
+
hourInTimeZone,
|
|
26
|
+
resolveScheduleTimezone,
|
|
27
|
+
} from "../schedule/schedule-timezone.js";
|
|
24
28
|
import { readTextFileSync } from "../util/fs.js";
|
|
25
29
|
import { getLogger } from "../util/logger.js";
|
|
26
30
|
import { getWorkspaceDir, getWorkspacePromptPath } from "../util/platform.js";
|
|
@@ -117,8 +121,10 @@ function refreshBackgroundWakeIntentSoon(reason: string): void {
|
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
export interface HeartbeatDeps {
|
|
120
|
-
/** Override for current hour (0-23), for testing. */
|
|
124
|
+
/** Override for current hour (0-23), for testing. Used when no timezone is resolved. */
|
|
121
125
|
getCurrentHour?: () => number;
|
|
126
|
+
/** Override for "now", for testing timezone-aware active hours. */
|
|
127
|
+
now?: () => Date;
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
export interface ManagedWakeHeartbeatRunOptions {
|
|
@@ -351,7 +357,7 @@ export class HeartbeatService {
|
|
|
351
357
|
const nextRunAt = computeNextRunAt({
|
|
352
358
|
syntax: "cron",
|
|
353
359
|
expression: config.cronExpression!,
|
|
354
|
-
timezone: config.timezone,
|
|
360
|
+
timezone: resolveScheduleTimezone(config.timezone),
|
|
355
361
|
});
|
|
356
362
|
this._nextRunAt = nextRunAt;
|
|
357
363
|
if (this.timer) {
|
|
@@ -543,22 +549,15 @@ export class HeartbeatService {
|
|
|
543
549
|
|
|
544
550
|
// Active hours guard — only applied when both bounds are set.
|
|
545
551
|
// The schema rejects configs where only one bound is provided.
|
|
552
|
+
// Hours are wall-clock in the heartbeat timezone (explicit, then the
|
|
553
|
+
// user's configured/detected zone). Host-local is the last resort so a
|
|
554
|
+
// managed container whose clock is UTC does not treat 8:00-22:00 as UTC.
|
|
546
555
|
if (
|
|
547
556
|
!force &&
|
|
548
557
|
config.activeHoursStart != null &&
|
|
549
558
|
config.activeHoursEnd != null
|
|
550
559
|
) {
|
|
551
|
-
|
|
552
|
-
if (this.cronMode && config.timezone) {
|
|
553
|
-
const parts = new Intl.DateTimeFormat("en-US", {
|
|
554
|
-
timeZone: config.timezone,
|
|
555
|
-
hourCycle: "h23",
|
|
556
|
-
hour: "numeric",
|
|
557
|
-
}).formatToParts(new Date());
|
|
558
|
-
hour = Number(parts.find((p) => p.type === "hour")!.value);
|
|
559
|
-
} else {
|
|
560
|
-
hour = this.deps.getCurrentHour?.() ?? new Date().getHours();
|
|
561
|
-
}
|
|
560
|
+
const hour = currentHourForHeartbeat(config, this.deps);
|
|
562
561
|
if (
|
|
563
562
|
!isWithinActiveHours(
|
|
564
563
|
hour,
|
|
@@ -569,6 +568,7 @@ export class HeartbeatService {
|
|
|
569
568
|
log.debug(
|
|
570
569
|
{
|
|
571
570
|
hour,
|
|
571
|
+
timezone: resolveScheduleTimezone(config.timezone),
|
|
572
572
|
activeHoursStart: config.activeHoursStart,
|
|
573
573
|
activeHoursEnd: config.activeHoursEnd,
|
|
574
574
|
},
|
|
@@ -1011,6 +1011,18 @@ function isDiskPressureBackgroundLocked(logKey: string): boolean {
|
|
|
1011
1011
|
return true;
|
|
1012
1012
|
}
|
|
1013
1013
|
|
|
1014
|
+
function currentHourForHeartbeat(
|
|
1015
|
+
config: HeartbeatConfig,
|
|
1016
|
+
deps: HeartbeatDeps,
|
|
1017
|
+
): number {
|
|
1018
|
+
const now = deps.now?.() ?? new Date();
|
|
1019
|
+
const timezone = resolveScheduleTimezone(config.timezone);
|
|
1020
|
+
if (timezone) {
|
|
1021
|
+
return hourInTimeZone(timezone, now);
|
|
1022
|
+
}
|
|
1023
|
+
return deps.getCurrentHour?.() ?? now.getHours();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1014
1026
|
/**
|
|
1015
1027
|
* Check if the given hour falls within the active window.
|
|
1016
1028
|
* Handles overnight windows (e.g. start=22, end=6).
|
|
@@ -1026,3 +1038,50 @@ function isWithinActiveHours(
|
|
|
1026
1038
|
// Overnight window: e.g. 22-6 means 22,23,0,1,2,3,4,5
|
|
1027
1039
|
return hour >= start || hour < end;
|
|
1028
1040
|
}
|
|
1041
|
+
|
|
1042
|
+
function stringOrNull(value: unknown): string | null {
|
|
1043
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function rawObject(value: unknown): Record<string, unknown> | null {
|
|
1047
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
return value as Record<string, unknown>;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Cron next-run is computed in the resolved timezone at schedule time. Interval
|
|
1055
|
+
* active hours are evaluated live, so only cron needs a reschedule when the
|
|
1056
|
+
* fallback zone changes and heartbeat.timezone is unset.
|
|
1057
|
+
*/
|
|
1058
|
+
export function shouldRescheduleHeartbeatForTimezoneChange(
|
|
1059
|
+
previousRaw: Record<string, unknown>,
|
|
1060
|
+
nextRaw: Record<string, unknown>,
|
|
1061
|
+
): boolean {
|
|
1062
|
+
const nextHeartbeat = rawObject(nextRaw.heartbeat);
|
|
1063
|
+
if (stringOrNull(nextHeartbeat?.timezone)) {
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
if (!stringOrNull(nextHeartbeat?.cronExpression)) {
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
1069
|
+
const previousUi = rawObject(previousRaw.ui);
|
|
1070
|
+
const nextUi = rawObject(nextRaw.ui);
|
|
1071
|
+
return (
|
|
1072
|
+
stringOrNull(previousUi?.userTimezone) !==
|
|
1073
|
+
stringOrNull(nextUi?.userTimezone) ||
|
|
1074
|
+
stringOrNull(previousUi?.detectedTimezone) !==
|
|
1075
|
+
stringOrNull(nextUi?.detectedTimezone)
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
export function rescheduleHeartbeatIfTimezoneChanged(
|
|
1080
|
+
previousRaw: Record<string, unknown>,
|
|
1081
|
+
nextRaw: Record<string, unknown>,
|
|
1082
|
+
): void {
|
|
1083
|
+
if (!shouldRescheduleHeartbeatForTimezoneChange(previousRaw, nextRaw)) {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
HeartbeatService.getInstance()?.reconfigure();
|
|
1087
|
+
}
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
_pendingFrameReclaimCountForTests,
|
|
54
54
|
_setProcessingWaitMsForTests,
|
|
55
55
|
_standaloneImageQueueSizeForTests,
|
|
56
|
+
newestPersistedSightFrame,
|
|
56
57
|
pendingStandaloneImagePersist,
|
|
57
58
|
persistAmbientSightFrame,
|
|
58
59
|
persistLiveVoicePhoto,
|
|
@@ -462,6 +463,39 @@ describe("persistLiveVoicePhoto", () => {
|
|
|
462
463
|
});
|
|
463
464
|
|
|
464
465
|
describe("persistAmbientSightFrame", () => {
|
|
466
|
+
test("reports where the daemon's half of the persist went", async () => {
|
|
467
|
+
const live = liveConversation("Live voice sight frame timing");
|
|
468
|
+
try {
|
|
469
|
+
const attachment = await uploadAttachment(
|
|
470
|
+
"frame.png",
|
|
471
|
+
"image/png",
|
|
472
|
+
IMAGE_BASE64,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
const result = await persistAmbientSightFrame(
|
|
476
|
+
live.id,
|
|
477
|
+
attachment.id,
|
|
478
|
+
"voice",
|
|
479
|
+
);
|
|
480
|
+
expect(result.ok).toBe(true);
|
|
481
|
+
// Three legs, each a whole non-negative number of milliseconds: nothing
|
|
482
|
+
// was queued ahead of it and no turn held the flag, so the first two are
|
|
483
|
+
// near zero, and the write is whatever the store took.
|
|
484
|
+
expect(result.timing).toBeDefined();
|
|
485
|
+
for (const leg of Object.values(result.timing!)) {
|
|
486
|
+
expect(Number.isInteger(leg)).toBe(true);
|
|
487
|
+
expect(leg).toBeGreaterThanOrEqual(0);
|
|
488
|
+
}
|
|
489
|
+
expect(Object.keys(result.timing!).sort()).toEqual([
|
|
490
|
+
"flagWaitMs",
|
|
491
|
+
"queueWaitMs",
|
|
492
|
+
"writeMs",
|
|
493
|
+
]);
|
|
494
|
+
} finally {
|
|
495
|
+
live.dispose();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
465
499
|
test("tags the row with the attachment it carries", async () => {
|
|
466
500
|
const live = liveConversation("Live voice sight frame");
|
|
467
501
|
try {
|
|
@@ -1596,6 +1630,44 @@ describe("standalone image persists are serialized per conversation", () => {
|
|
|
1596
1630
|
});
|
|
1597
1631
|
});
|
|
1598
1632
|
|
|
1633
|
+
describe("newestPersistedSightFrame", () => {
|
|
1634
|
+
test("is null for a conversation that carries no frame", () => {
|
|
1635
|
+
const live = liveConversation("Sight newest none");
|
|
1636
|
+
try {
|
|
1637
|
+
expect(newestPersistedSightFrame(live.id)).toBeNull();
|
|
1638
|
+
} finally {
|
|
1639
|
+
live.dispose();
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
test("names the last frame written, by the id the row carries", async () => {
|
|
1644
|
+
const live = liveConversation("Sight newest");
|
|
1645
|
+
try {
|
|
1646
|
+
const first = await uploadFrame("first.png");
|
|
1647
|
+
const second = await uploadFrame("second.png");
|
|
1648
|
+
expect((await persistAmbientSightFrame(live.id, first, "voice")).ok).toBe(
|
|
1649
|
+
true,
|
|
1650
|
+
);
|
|
1651
|
+
expect(
|
|
1652
|
+
(await persistAmbientSightFrame(live.id, second, "voice")).ok,
|
|
1653
|
+
).toBe(true);
|
|
1654
|
+
|
|
1655
|
+
// A frame already linked elsewhere is stored under a fresh id, so the
|
|
1656
|
+
// answer is read from the rows rather than assumed from the argument.
|
|
1657
|
+
const rows = getMessages(live.id);
|
|
1658
|
+
const storedSecond = sightFrameAttachmentIdsFromMetadata(
|
|
1659
|
+
metadataOf(rows[1]),
|
|
1660
|
+
)[0];
|
|
1661
|
+
expect(newestPersistedSightFrame(live.id)).toEqual({
|
|
1662
|
+
attachmentId: storedSecond,
|
|
1663
|
+
capturedAt: rows[1].createdAt,
|
|
1664
|
+
});
|
|
1665
|
+
} finally {
|
|
1666
|
+
live.dispose();
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1599
1671
|
describe("pendingStandaloneImagePersist", () => {
|
|
1600
1672
|
test("reports nothing for a conversation with no image queued", async () => {
|
|
1601
1673
|
const live = liveConversation("Sight pending idle");
|
|
@@ -212,6 +212,87 @@ describe("live-voice sight_frame frame", () => {
|
|
|
212
212
|
expect(result.error.frameType).toBe("sight_frame");
|
|
213
213
|
}
|
|
214
214
|
});
|
|
215
|
+
|
|
216
|
+
test("carries the client's timing through as sent", () => {
|
|
217
|
+
const timing = {
|
|
218
|
+
reason: "forced",
|
|
219
|
+
armToKeepMs: 40,
|
|
220
|
+
keepToEncodedMs: 30,
|
|
221
|
+
encodedToUploadedMs: 200,
|
|
222
|
+
uploadedToSentMs: 0,
|
|
223
|
+
bytes: 12345,
|
|
224
|
+
};
|
|
225
|
+
const result = validateLiveVoiceClientFrame({
|
|
226
|
+
type: "sight_frame",
|
|
227
|
+
attachmentId: "att-1",
|
|
228
|
+
timing,
|
|
229
|
+
});
|
|
230
|
+
expect(result).toEqual({
|
|
231
|
+
ok: true,
|
|
232
|
+
frame: { type: "sight_frame", attachmentId: "att-1", timing },
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("an ambient keep's timing has no arm", () => {
|
|
237
|
+
const timing = {
|
|
238
|
+
reason: "novel",
|
|
239
|
+
keepToEncodedMs: 30,
|
|
240
|
+
encodedToUploadedMs: 200,
|
|
241
|
+
uploadedToSentMs: 12,
|
|
242
|
+
bytes: 12345,
|
|
243
|
+
};
|
|
244
|
+
const result = validateLiveVoiceClientFrame({
|
|
245
|
+
type: "sight_frame",
|
|
246
|
+
attachmentId: "att-1",
|
|
247
|
+
timing,
|
|
248
|
+
});
|
|
249
|
+
expect(result).toEqual({
|
|
250
|
+
ok: true,
|
|
251
|
+
frame: { type: "sight_frame", attachmentId: "att-1", timing },
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("rejects a timing with a negative or fractional duration, naming the field", () => {
|
|
256
|
+
for (const bad of [
|
|
257
|
+
{ keepToEncodedMs: -1 },
|
|
258
|
+
{ encodedToUploadedMs: 1.5 },
|
|
259
|
+
{ uploadedToSentMs: "0" },
|
|
260
|
+
{ armToKeepMs: -5 },
|
|
261
|
+
{ bytes: null },
|
|
262
|
+
{ reason: "" },
|
|
263
|
+
]) {
|
|
264
|
+
const result = validateLiveVoiceClientFrame({
|
|
265
|
+
type: "sight_frame",
|
|
266
|
+
attachmentId: "att-1",
|
|
267
|
+
timing: {
|
|
268
|
+
reason: "novel",
|
|
269
|
+
keepToEncodedMs: 30,
|
|
270
|
+
encodedToUploadedMs: 200,
|
|
271
|
+
uploadedToSentMs: 0,
|
|
272
|
+
bytes: 1,
|
|
273
|
+
...bad,
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
expect(result.ok).toBe(false);
|
|
277
|
+
if (!result.ok) {
|
|
278
|
+
expect(result.error.code).toBe("invalid_field");
|
|
279
|
+
expect(result.error.field).toBe("timing");
|
|
280
|
+
expect(result.error.frameType).toBe("sight_frame");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("rejects a timing that is not an object", () => {
|
|
286
|
+
const result = validateLiveVoiceClientFrame({
|
|
287
|
+
type: "sight_frame",
|
|
288
|
+
attachmentId: "att-1",
|
|
289
|
+
timing: "fast",
|
|
290
|
+
});
|
|
291
|
+
expect(result.ok).toBe(false);
|
|
292
|
+
if (!result.ok) {
|
|
293
|
+
expect(result.error.field).toBe("timing");
|
|
294
|
+
}
|
|
295
|
+
});
|
|
215
296
|
});
|
|
216
297
|
|
|
217
298
|
describe("live-voice camera frames kept mid-call", () => {
|
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
getMessageById,
|
|
91
91
|
MessageInsertPreconditionError,
|
|
92
92
|
recordConversationPersistedSeq,
|
|
93
|
+
selectNewestSightFrameCapture,
|
|
93
94
|
} from "../persistence/conversation-crud.js";
|
|
94
95
|
import { broadcastMessage } from "../runtime/assistant-event-hub.js";
|
|
95
96
|
import { getCurrentSeq } from "../runtime/assistant-stream-state.js";
|
|
@@ -134,6 +135,29 @@ const SIGHT_FRAME_MESSAGE_CONTENT = "(camera frame)";
|
|
|
134
135
|
export interface LiveVoicePhotoResult {
|
|
135
136
|
readonly ok: boolean;
|
|
136
137
|
readonly messageId?: string;
|
|
138
|
+
/**
|
|
139
|
+
* Where the daemon's half of a persist went, on the ordinary success path.
|
|
140
|
+
* Absent on failure and on the recovery path that reports a row the write
|
|
141
|
+
* threw after landing, where the marks say nothing about the row.
|
|
142
|
+
*/
|
|
143
|
+
readonly timing?: LiveVoicePhotoPersistTiming;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The daemon leg of one standalone image, as durations between its marks.
|
|
148
|
+
*
|
|
149
|
+
* Together with the client leg a `sight_frame` can carry (see
|
|
150
|
+
* `LiveVoiceSightFrameTiming` in `protocol.ts`) and the session's own
|
|
151
|
+
* distance-from-speech-onset, this is what says which leg a frame that missed
|
|
152
|
+
* its turn was lost in.
|
|
153
|
+
*/
|
|
154
|
+
export interface LiveVoicePhotoPersistTiming {
|
|
155
|
+
/** Behind this conversation's earlier standalone images. */
|
|
156
|
+
readonly queueWaitMs: number;
|
|
157
|
+
/** Polling for the processing flag, which a running turn holds. */
|
|
158
|
+
readonly flagWaitMs: number;
|
|
159
|
+
/** Materializing the attachment and inserting the row. */
|
|
160
|
+
readonly writeMs: number;
|
|
137
161
|
}
|
|
138
162
|
|
|
139
163
|
/**
|
|
@@ -175,8 +199,9 @@ async function enqueueStandaloneImagePersist(
|
|
|
175
199
|
attachmentId: string,
|
|
176
200
|
kind: "photo" | "sight_frame",
|
|
177
201
|
content: string,
|
|
178
|
-
job: () => Promise<LiveVoicePhotoResult>,
|
|
202
|
+
job: (queueWaitMs: number) => Promise<LiveVoicePhotoResult>,
|
|
179
203
|
): Promise<LiveVoicePhotoResult> {
|
|
204
|
+
const queuedAtMs = Date.now();
|
|
180
205
|
let queue = standaloneImageQueues.get(conversationId);
|
|
181
206
|
if (!queue) {
|
|
182
207
|
queue = {
|
|
@@ -215,7 +240,7 @@ async function enqueueStandaloneImagePersist(
|
|
|
215
240
|
);
|
|
216
241
|
return { ok: false };
|
|
217
242
|
}
|
|
218
|
-
return job();
|
|
243
|
+
return job(Date.now() - queuedAtMs);
|
|
219
244
|
});
|
|
220
245
|
// The tail must survive a failed job, or one rejection would strand every
|
|
221
246
|
// image queued behind it.
|
|
@@ -265,6 +290,33 @@ export const SIGHT_FRAME_TURN_HOLD_MS = 800;
|
|
|
265
290
|
* Settles, never rejects: the caller is choosing how long to wait, not whether
|
|
266
291
|
* the image landed.
|
|
267
292
|
*/
|
|
293
|
+
/** The newest camera frame a conversation's rows carry. */
|
|
294
|
+
export interface NewestSightFrame {
|
|
295
|
+
readonly attachmentId: string;
|
|
296
|
+
/** When the row carrying it was written, in wall-clock milliseconds. */
|
|
297
|
+
readonly capturedAt: number;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The camera frame a turn launching now would read as the current view, or
|
|
302
|
+
* null when the conversation carries none.
|
|
303
|
+
*
|
|
304
|
+
* For the turn's own log: which frame the answer is about is otherwise
|
|
305
|
+
* invisible, and the failure this instruments is exactly a turn reading the
|
|
306
|
+
* frame before the one the question was asked about. Read from the rows, the
|
|
307
|
+
* same source retention ranks frames by, so a frame that landed is counted
|
|
308
|
+
* and a frame still in flight is not. One indexed row, since it runs on every
|
|
309
|
+
* voice turn.
|
|
310
|
+
*/
|
|
311
|
+
export function newestPersistedSightFrame(
|
|
312
|
+
conversationId: string,
|
|
313
|
+
): NewestSightFrame | null {
|
|
314
|
+
const newest = selectNewestSightFrameCapture(conversationId);
|
|
315
|
+
return newest === null
|
|
316
|
+
? null
|
|
317
|
+
: { attachmentId: newest.attachmentId, capturedAt: newest.createdAt };
|
|
318
|
+
}
|
|
319
|
+
|
|
268
320
|
export function pendingStandaloneImagePersist(
|
|
269
321
|
conversationId: string,
|
|
270
322
|
): Promise<void> | null {
|
|
@@ -672,13 +724,14 @@ function persistStandaloneImage(
|
|
|
672
724
|
attachmentId,
|
|
673
725
|
kind,
|
|
674
726
|
persistOptions.content,
|
|
675
|
-
() =>
|
|
727
|
+
(queueWaitMs) =>
|
|
676
728
|
writeStandaloneImage(
|
|
677
729
|
conversationId,
|
|
678
730
|
attachmentId,
|
|
679
731
|
kind,
|
|
680
732
|
incarnation,
|
|
681
733
|
persistOptions,
|
|
734
|
+
queueWaitMs,
|
|
682
735
|
),
|
|
683
736
|
);
|
|
684
737
|
}
|
|
@@ -718,6 +771,7 @@ async function writeStandaloneImage(
|
|
|
718
771
|
| "insertPrecondition"
|
|
719
772
|
| "onUndiscardedAttachments"
|
|
720
773
|
>,
|
|
774
|
+
queueWaitMs: number,
|
|
721
775
|
): Promise<LiveVoicePhotoResult> {
|
|
722
776
|
const { content } = persistOptions;
|
|
723
777
|
// The id the row is inserted under, so a failure can ask whether the insert
|
|
@@ -768,7 +822,9 @@ async function writeStandaloneImage(
|
|
|
768
822
|
// A turn holds the lock for its whole run. Waiting rather than queueing:
|
|
769
823
|
// the conversation's queue drains into a turn, which is the one thing this
|
|
770
824
|
// must not cause.
|
|
825
|
+
const flagWaitStartedAtMs = Date.now();
|
|
771
826
|
const owner = await acquireProcessingFlag(conversation);
|
|
827
|
+
const flagAcquiredAtMs = Date.now();
|
|
772
828
|
if (owner === null) {
|
|
773
829
|
log.warn(
|
|
774
830
|
{ conversationId, attachmentId, kind },
|
|
@@ -828,7 +884,15 @@ async function writeStandaloneImage(
|
|
|
828
884
|
|
|
829
885
|
announcePersistedImage(conversationId, content, persisted.id);
|
|
830
886
|
|
|
831
|
-
return {
|
|
887
|
+
return {
|
|
888
|
+
ok: true,
|
|
889
|
+
messageId: persisted.id,
|
|
890
|
+
timing: {
|
|
891
|
+
queueWaitMs,
|
|
892
|
+
flagWaitMs: flagAcquiredAtMs - flagWaitStartedAtMs,
|
|
893
|
+
writeMs: Date.now() - flagAcquiredAtMs,
|
|
894
|
+
},
|
|
895
|
+
};
|
|
832
896
|
} finally {
|
|
833
897
|
// Only this job's own hold is released. A turn that claimed the flag
|
|
834
898
|
// away mid-write owns it now, and clearing there would free a turn that
|