@vellumai/assistant 0.11.5 → 0.11.6-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 +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- package/src/watch/watch-timeline.ts +848 -0
|
@@ -2,7 +2,11 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
import type { Command } from "commander";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { exitFromIpcResult } from "../../../ipc/cli-client.js";
|
|
6
|
+
import {
|
|
7
|
+
findContentTypeHeader,
|
|
8
|
+
parseRequestBodyData,
|
|
9
|
+
} from "../../../util/oauth-request-body.js";
|
|
6
10
|
import { readStdinSync } from "../../../util/read-stdin.js";
|
|
7
11
|
import { subcommand } from "../../lib/cli-command-help.js";
|
|
8
12
|
import { shouldOutputJson, writeOutput } from "../../output.js";
|
|
@@ -33,18 +37,6 @@ function parseHeader(raw: string): [string, string] {
|
|
|
33
37
|
return [raw.slice(0, idx).trim(), raw.slice(idx + 1).trim()];
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
/**
|
|
37
|
-
* Attempt to JSON-parse a string. Returns the parsed value on success,
|
|
38
|
-
* or the original string on failure.
|
|
39
|
-
*/
|
|
40
|
-
function tryJsonParse(raw: string): unknown {
|
|
41
|
-
try {
|
|
42
|
-
return JSON.parse(raw);
|
|
43
|
-
} catch {
|
|
44
|
-
return raw;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
40
|
/**
|
|
49
41
|
* Read body data from the `-d` flag value. Supports:
|
|
50
42
|
* - `@-` reads stdin
|
|
@@ -54,20 +46,28 @@ function tryJsonParse(raw: string): unknown {
|
|
|
54
46
|
* File/stdin reading must happen on the CLI side (not the daemon)
|
|
55
47
|
* since stdin is attached to the CLI process and file paths are
|
|
56
48
|
* relative to the user's cwd.
|
|
49
|
+
*
|
|
50
|
+
* A non-JSON `Content-Type` keeps the payload as the exact string the caller
|
|
51
|
+
* gave, so multipart, XML, and form-encoded bodies reach the provider
|
|
52
|
+
* unchanged. Files are read as UTF-8 text, so a body that is not valid UTF-8
|
|
53
|
+
* (a raw image, an archive) cannot travel through this flag.
|
|
57
54
|
*/
|
|
58
|
-
function readBodyData(
|
|
55
|
+
export function readBodyData(
|
|
56
|
+
data: string,
|
|
57
|
+
headers: Record<string, string>,
|
|
58
|
+
): unknown {
|
|
59
|
+
const contentType = findContentTypeHeader(headers);
|
|
60
|
+
|
|
59
61
|
if (data === "@-") {
|
|
60
|
-
|
|
61
|
-
return tryJsonParse(raw);
|
|
62
|
+
return parseRequestBodyData(readStdinSync(), contentType);
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
if (data.startsWith("@")) {
|
|
65
66
|
const filePath = data.slice(1);
|
|
66
|
-
|
|
67
|
-
return tryJsonParse(raw);
|
|
67
|
+
return parseRequestBodyData(readFileSync(filePath, "utf-8"), contentType);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
return
|
|
70
|
+
return parseRequestBodyData(data, contentType);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
@@ -90,7 +90,7 @@ export function registerRequestCommand(oauth: Command): void {
|
|
|
90
90
|
)
|
|
91
91
|
.option(
|
|
92
92
|
"-d, --data <data>",
|
|
93
|
-
"Request body: inline JSON, @filename, or @- for stdin",
|
|
93
|
+
"Request body: inline JSON, @filename, or @- for stdin. Sent raw when Content-Type is not JSON",
|
|
94
94
|
)
|
|
95
95
|
.option("-G, --get", "Force GET; body data becomes query params")
|
|
96
96
|
.option("-I, --head", "Send a HEAD request")
|
|
@@ -172,10 +172,9 @@ export function registerRequestCommand(oauth: Command): void {
|
|
|
172
172
|
// Read body data on the CLI side (file/stdin reading must happen here)
|
|
173
173
|
let parsedData: unknown;
|
|
174
174
|
if (opts.data !== undefined) {
|
|
175
|
-
parsedData = readBodyData(opts.data);
|
|
175
|
+
parsedData = readBodyData(opts.data, parsedHeaders);
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
// Build IPC request body
|
|
179
178
|
const body: Record<string, unknown> = {
|
|
180
179
|
provider: opts.provider,
|
|
181
180
|
url,
|
|
@@ -202,22 +201,36 @@ export function registerRequestCommand(oauth: Command): void {
|
|
|
202
201
|
body.client_id = opts.clientId;
|
|
203
202
|
}
|
|
204
203
|
|
|
205
|
-
|
|
204
|
+
// Run the route handler in this process so Gmail-sized fetch and
|
|
205
|
+
// JSON parse stay off the assistant event loop.
|
|
206
|
+
const { handleRequest } =
|
|
207
|
+
await import("../../../runtime/routes/oauth-commands-routes.js");
|
|
208
|
+
const { RouteError } =
|
|
209
|
+
await import("../../../runtime/routes/errors.js");
|
|
210
|
+
|
|
211
|
+
let result: {
|
|
206
212
|
ok: boolean;
|
|
207
213
|
status: number;
|
|
208
214
|
headers: Record<string, string>;
|
|
209
215
|
body: unknown;
|
|
216
|
+
bodyEncoding?: "base64";
|
|
210
217
|
hint?: string;
|
|
211
218
|
account?: string | null;
|
|
212
219
|
accountWarning?: string;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
220
|
+
};
|
|
221
|
+
try {
|
|
222
|
+
result = (await handleRequest({ body })) as typeof result;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (err instanceof RouteError) {
|
|
225
|
+
return exitFromIpcResult({
|
|
226
|
+
ok: false,
|
|
227
|
+
error: err.message,
|
|
228
|
+
statusCode: err.statusCode,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
throw err;
|
|
217
232
|
}
|
|
218
233
|
|
|
219
|
-
const result = r.result!;
|
|
220
|
-
|
|
221
234
|
// Non-2xx exit code
|
|
222
235
|
if (result.status < 200 || result.status >= 300) {
|
|
223
236
|
process.exitCode = 1;
|
|
@@ -251,20 +264,23 @@ export function registerRequestCommand(oauth: Command): void {
|
|
|
251
264
|
writeInfo(`<`);
|
|
252
265
|
}
|
|
253
266
|
|
|
254
|
-
// Body output (skip for null bodies
|
|
255
|
-
if (result.body != null) {
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
267
|
+
// Body output (skip for null bodies: HEAD requests, 204, etc.)
|
|
268
|
+
if (result.body != null || result.bodyEncoding === "base64") {
|
|
269
|
+
const { materializeOAuthRequestOutput } =
|
|
270
|
+
await import("../../../oauth/connection.js");
|
|
271
|
+
const output = materializeOAuthRequestOutput(result);
|
|
272
|
+
if (output) {
|
|
273
|
+
if (opts.output) {
|
|
274
|
+
writeFileSync(opts.output, output.bytes);
|
|
275
|
+
} else {
|
|
276
|
+
process.stdout.write(output.bytes);
|
|
277
|
+
if (!output.isBinary) {
|
|
278
|
+
process.stdout.write("\n");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
265
281
|
}
|
|
266
282
|
} else if (opts.output) {
|
|
267
|
-
writeFileSync(opts.output,
|
|
283
|
+
writeFileSync(opts.output, Buffer.alloc(0));
|
|
268
284
|
}
|
|
269
285
|
} catch (err) {
|
|
270
286
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -214,6 +214,19 @@
|
|
|
214
214
|
"license": "MIT",
|
|
215
215
|
"icon": "📣"
|
|
216
216
|
},
|
|
217
|
+
{
|
|
218
|
+
"name": "imessage",
|
|
219
|
+
"source": {
|
|
220
|
+
"source": "github",
|
|
221
|
+
"repo": "vellum-ai/imessage",
|
|
222
|
+
"ref": "069a8375e2a6ed069550866114a653218864bc91"
|
|
223
|
+
},
|
|
224
|
+
"description": "iMessage and SMS channel for the Vellum assistant, backed by a Photon line.",
|
|
225
|
+
"category": "productivity",
|
|
226
|
+
"homepage": "https://github.com/vellum-ai/imessage",
|
|
227
|
+
"license": "MIT",
|
|
228
|
+
"icon": "📲"
|
|
229
|
+
},
|
|
217
230
|
{
|
|
218
231
|
"name": "kitchen-companion",
|
|
219
232
|
"source": {
|
|
@@ -297,7 +310,7 @@
|
|
|
297
310
|
"source": {
|
|
298
311
|
"source": "github",
|
|
299
312
|
"repo": "vellum-ai/natural",
|
|
300
|
-
"ref": "
|
|
313
|
+
"ref": "e71880d144337d1b8b6d3e65ab98006b7d015ef6"
|
|
301
314
|
},
|
|
302
315
|
"description": "Hosted MCP access to Natural payment operations for Vellum assistants.",
|
|
303
316
|
"category": "finance",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import { openInHostBrowser } from "./open-browser.js";
|
|
9
|
+
|
|
10
|
+
describe("openInHostBrowser", () => {
|
|
11
|
+
test("launches the default browser directly on macOS", () => {
|
|
12
|
+
const calls: Array<{
|
|
13
|
+
command: string;
|
|
14
|
+
args: readonly string[];
|
|
15
|
+
options: unknown;
|
|
16
|
+
}> = [];
|
|
17
|
+
let unrefCalled = false;
|
|
18
|
+
const child = new EventEmitter() as ChildProcess;
|
|
19
|
+
child.unref = () => {
|
|
20
|
+
unrefCalled = true;
|
|
21
|
+
return child;
|
|
22
|
+
};
|
|
23
|
+
const spawnImpl = ((
|
|
24
|
+
command: string,
|
|
25
|
+
args: readonly string[],
|
|
26
|
+
options: unknown,
|
|
27
|
+
) => {
|
|
28
|
+
calls.push({ command, args, options });
|
|
29
|
+
return child;
|
|
30
|
+
}) as typeof spawn;
|
|
31
|
+
|
|
32
|
+
openInHostBrowser("https://example.com/auth", {
|
|
33
|
+
platform: "darwin",
|
|
34
|
+
spawnImpl,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(calls).toEqual([
|
|
38
|
+
{
|
|
39
|
+
command: "open",
|
|
40
|
+
args: ["https://example.com/auth"],
|
|
41
|
+
options: { detached: true, stdio: "ignore" },
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
expect(unrefCalled).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("emits a host-client signal on Windows", () => {
|
|
48
|
+
const workspaceDir = mkdtempSync(join(tmpdir(), "open-browser-test-"));
|
|
49
|
+
try {
|
|
50
|
+
openInHostBrowser("https://example.com/auth", {
|
|
51
|
+
platform: "win32",
|
|
52
|
+
workspaceDir,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
JSON.parse(
|
|
57
|
+
readFileSync(join(workspaceDir, "signals", "emit-event"), "utf8"),
|
|
58
|
+
),
|
|
59
|
+
).toEqual({
|
|
60
|
+
type: "open_url",
|
|
61
|
+
url: "https://example.com/auth",
|
|
62
|
+
});
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI-side helper that opens a URL on the user's host machine.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* clients (e.g. the Swift macOS app) via the assistant event hub.
|
|
4
|
+
* On macOS, launches the default browser directly. Other platforms write an
|
|
5
|
+
* `open_url` event so a connected host client can open the URL.
|
|
7
6
|
*
|
|
8
7
|
* CLI-initiated emit — no conversation context available, so the inner
|
|
9
8
|
* message has no `conversationId`. That's fine: `OpenUrlEventSchema`
|
|
@@ -13,6 +12,7 @@
|
|
|
13
12
|
* Uses only `node:` imports so it's safe for `ipc`-tagged CLI commands.
|
|
14
13
|
*/
|
|
15
14
|
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
16
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
|
|
@@ -27,9 +27,28 @@ function getWorkspaceDir(): string {
|
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export function openInHostBrowser(
|
|
30
|
+
export function openInHostBrowser(
|
|
31
|
+
url: string,
|
|
32
|
+
deps: {
|
|
33
|
+
platform?: NodeJS.Platform;
|
|
34
|
+
spawnImpl?: typeof spawn;
|
|
35
|
+
workspaceDir?: string;
|
|
36
|
+
} = {},
|
|
37
|
+
): void {
|
|
38
|
+
if ((deps.platform ?? process.platform) === "darwin") {
|
|
39
|
+
const child = (deps.spawnImpl ?? spawn)("open", [url], {
|
|
40
|
+
detached: true,
|
|
41
|
+
stdio: "ignore",
|
|
42
|
+
});
|
|
43
|
+
child.on("error", () => {
|
|
44
|
+
// The caller prints a fallback URL.
|
|
45
|
+
});
|
|
46
|
+
child.unref();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
try {
|
|
32
|
-
const signalsDir = join(getWorkspaceDir(), "signals");
|
|
51
|
+
const signalsDir = join(deps.workspaceDir ?? getWorkspaceDir(), "signals");
|
|
33
52
|
mkdirSync(signalsDir, { recursive: true });
|
|
34
53
|
writeFileSync(
|
|
35
54
|
join(signalsDir, "emit-event"),
|
|
@@ -4,6 +4,7 @@ import { describe, expect, mock, test } from "bun:test";
|
|
|
4
4
|
// kind, so the tests control the row store directly.
|
|
5
5
|
const connectionRows = new Map<string, { name: string; provider: string }>([
|
|
6
6
|
["anthropic-personal", { name: "anthropic-personal", provider: "anthropic" }],
|
|
7
|
+
["local-ollama", { name: "local-ollama", provider: "ollama" }],
|
|
7
8
|
]);
|
|
8
9
|
mock.module("../../persistence/db-connection.js", () => ({
|
|
9
10
|
getDb: () => ({}),
|
|
@@ -36,6 +37,31 @@ const fullDefault = LLMConfigBase.parse({
|
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
describe("completeCustomProfile", () => {
|
|
40
|
+
test("clamps a filled maxTokens to the model's catalog output cap", () => {
|
|
41
|
+
const completed = completeCustomProfile(fullDefault, {
|
|
42
|
+
provider: "ollama",
|
|
43
|
+
model: "llama3.2",
|
|
44
|
+
});
|
|
45
|
+
expect(completed.maxTokens).toBe(4096);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("clamps a filled maxTokens for an entry-name provider via its row's kind", () => {
|
|
49
|
+
const completed = completeCustomProfile(fullDefault, {
|
|
50
|
+
provider: "local-ollama",
|
|
51
|
+
model: "llama3.2",
|
|
52
|
+
});
|
|
53
|
+
expect(completed.maxTokens).toBe(4096);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("never clamps an explicit maxTokens", () => {
|
|
57
|
+
const completed = completeCustomProfile(fullDefault, {
|
|
58
|
+
provider: "ollama",
|
|
59
|
+
model: "llama3.2",
|
|
60
|
+
maxTokens: 64000,
|
|
61
|
+
});
|
|
62
|
+
expect(completed.maxTokens).toBe(64000);
|
|
63
|
+
});
|
|
64
|
+
|
|
39
65
|
test("inherits omitted scalar fields from the default", () => {
|
|
40
66
|
const completed = completeCustomProfile(fullDefault, {
|
|
41
67
|
model: "claude-fable-5",
|
|
@@ -80,6 +80,12 @@ The system has a 30-second silence timeout. If nobody speaks for 30 seconds duri
|
|
|
80
80
|
- Verify `services.tts.providers.elevenlabs.voiceId` is set to a valid ElevenLabs voice ID
|
|
81
81
|
- Ask for the desired voice style again and try a different voice selection
|
|
82
82
|
|
|
83
|
+
## Inbound call asks for a name or says "I don't recognize this number"
|
|
84
|
+
|
|
85
|
+
This is the Phone channel trust floor, not a Twilio or webhook failure. The call reached the assistant. Phone defaults to **Verified contacts**, so unknown callers hear that greeting and you get an access request instead of a normal conversation.
|
|
86
|
+
|
|
87
|
+
Load `twilio-setup` and follow its Channel Trust Floors section. Change **Who can message** on **Channels → Phone**, or verify the caller, rather than retuning ingress.
|
|
88
|
+
|
|
83
89
|
## Twilio says "application error" right after answer
|
|
84
90
|
|
|
85
91
|
- This often means the voice webhook failed or returned invalid TwiML, or the media-stream WebSocket could not connect
|
|
@@ -158,7 +158,7 @@ Optionally pass `routing_hints` (a JSON object) to influence routing decisions (
|
|
|
158
158
|
2. **`interface` fallback** — if `source_channel` is absent (common for guardian/direct users), map the `interface` value to a channel name:
|
|
159
159
|
| `interface` value | Channel name |
|
|
160
160
|
| --- | --- |
|
|
161
|
-
| `macos`, `ios` | `vellum` |
|
|
161
|
+
| `macos`, `windows`, `ios` | `vellum` |
|
|
162
162
|
| `telegram` | `telegram` |
|
|
163
163
|
| `slack` | `slack` |
|
|
164
164
|
| `cli` | _(omit — no routable channel)_ |
|
|
@@ -92,6 +92,14 @@
|
|
|
92
92
|
"description": "Enable the Quick Input popover on right-click of the menu bar icon",
|
|
93
93
|
"defaultEnabled": false
|
|
94
94
|
},
|
|
95
|
+
{
|
|
96
|
+
"id": "teach",
|
|
97
|
+
"scope": "client",
|
|
98
|
+
"key": "teach",
|
|
99
|
+
"label": "Teach",
|
|
100
|
+
"description": "Offer Teach, the session that reads the user's screen while they narrate what they are doing. Gates the companion surface's Teach control and the toggleWatch command that starts a session; evaluated in the app's window and read again in the Electron main process, which pushes it to the floating surface",
|
|
101
|
+
"defaultEnabled": false
|
|
102
|
+
},
|
|
95
103
|
{
|
|
96
104
|
"id": "expand-completed-steps",
|
|
97
105
|
"scope": "client",
|
|
@@ -100,6 +108,14 @@
|
|
|
100
108
|
"description": "Auto-expand completed tool call step groups instead of showing them collapsed",
|
|
101
109
|
"defaultEnabled": false
|
|
102
110
|
},
|
|
111
|
+
{
|
|
112
|
+
"id": "inline-assistant-intermediates",
|
|
113
|
+
"scope": "client",
|
|
114
|
+
"key": "inline-assistant-intermediates",
|
|
115
|
+
"label": "Inline Assistant Intermediates",
|
|
116
|
+
"description": "Render a completed response's intermediate activity inline at full size instead of collapsing it behind the Earlier activity disclosure",
|
|
117
|
+
"defaultEnabled": false
|
|
118
|
+
},
|
|
103
119
|
{
|
|
104
120
|
"id": "backward-releases",
|
|
105
121
|
"scope": "assistant",
|
|
@@ -145,7 +161,7 @@
|
|
|
145
161
|
"scope": "client",
|
|
146
162
|
"key": "internal-thread-actions",
|
|
147
163
|
"label": "Internal Thread Actions",
|
|
148
|
-
"description": "Show the internal thread affordances: 'Fork from here' on messages, 'Fork Conversation', 'Analyze Conversation', and 'Copy conversation ID' in the thread menu, plus the LLM inspector surfaces. The flag is the sole gate: any session that enables it gets the affordances, including local sessions with no platform identity.",
|
|
164
|
+
"description": "Show the internal thread affordances: 'Fork from here' and 'Summarize up to here' on messages, 'Fork Conversation', 'Analyze Conversation', and 'Copy conversation ID' in the thread menu, plus the LLM inspector surfaces. The flag is the sole gate: any session that enables it gets the affordances, including local sessions with no platform identity.",
|
|
149
165
|
"defaultEnabled": false
|
|
150
166
|
},
|
|
151
167
|
{
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { getDb } from "../persistence/db-connection.js";
|
|
2
|
+
import { catalogProviderForProfile } from "../providers/connection-resolution.js";
|
|
2
3
|
import { ROUTING_IDENTITY_PROVIDERS } from "../providers/inference/auth.js";
|
|
3
4
|
import { getConnection } from "../providers/inference/connections.js";
|
|
4
5
|
import {
|
|
6
|
+
catalogMaxOutputTokens,
|
|
5
7
|
getCatalogProviderForModel,
|
|
6
8
|
isModelInCatalog,
|
|
7
9
|
} from "../providers/model-catalog.js";
|
|
@@ -100,6 +102,33 @@ export function completeCustomProfile(
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
// A FILLED maxTokens expresses no user preference, so it is clamped to the
|
|
106
|
+
// model's catalog output cap — an over-cap fill (e.g. 64000 onto a
|
|
107
|
+
// 4096-cap model) would make every request fail upstream. Runs after the
|
|
108
|
+
// provider implication above so the cap is read from the provider the
|
|
109
|
+
// profile actually dispatches with. An explicit profile value is never
|
|
110
|
+
// touched, and models the catalog does not know keep the default (the
|
|
111
|
+
// save-time probe covers those). Mirrors `clampMaxTokensToModelCap` for
|
|
112
|
+
// the code-owned default profiles.
|
|
113
|
+
if (
|
|
114
|
+
profile.maxTokens === undefined &&
|
|
115
|
+
completed.maxTokens !== undefined &&
|
|
116
|
+
completed.provider !== undefined &&
|
|
117
|
+
completed.model !== undefined
|
|
118
|
+
) {
|
|
119
|
+
const catalogProvider = catalogProviderForProfile(
|
|
120
|
+
completed.provider,
|
|
121
|
+
completed.model,
|
|
122
|
+
);
|
|
123
|
+
const cap =
|
|
124
|
+
catalogProvider !== null
|
|
125
|
+
? catalogMaxOutputTokens(catalogProvider, completed.model)
|
|
126
|
+
: undefined;
|
|
127
|
+
if (cap !== undefined && completed.maxTokens > cap) {
|
|
128
|
+
completed.maxTokens = cap;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
103
132
|
// Completion never stamps a `provider_connection`; an inherited binding
|
|
104
133
|
// would only re-introduce the collapsed field on disk. The default's
|
|
105
134
|
// explicit binding is instead inherited IN the provider value, the
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
INGRESS_ASSISTANT_ID_KEY,
|
|
3
|
+
INGRESS_LAST_TUNNEL_KEY,
|
|
4
|
+
INGRESS_PAIRING_TUNNEL_KEY,
|
|
5
|
+
} from "@vellumai/service-contracts/ingress";
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* Strips environment-specific fields from config JSON before transferring
|
|
3
9
|
* between local and platform environments (teleport/restore).
|
|
@@ -6,6 +12,9 @@
|
|
|
6
12
|
* - `ingress.publicBaseUrl` → set to `""`
|
|
7
13
|
* - `ingress.enabled` → deleted
|
|
8
14
|
* - `ingress.publicBaseUrlManagedBy` → deleted
|
|
15
|
+
* - `ingress.lastTunnel` → deleted
|
|
16
|
+
* - `ingress.pairingTunnel` → deleted
|
|
17
|
+
* - `ingress.assistantId` → deleted
|
|
9
18
|
* - `telegram.registeredWebhookUrl` → deleted
|
|
10
19
|
* - `daemon` → deleted entirely
|
|
11
20
|
* - `skills.load.extraDirs` → set to `[]`
|
|
@@ -44,6 +53,13 @@ export function sanitizeConfigForTransfer(configJson: string): string {
|
|
|
44
53
|
ingress.publicBaseUrl = "";
|
|
45
54
|
delete ingress.enabled;
|
|
46
55
|
delete ingress.publicBaseUrlManagedBy;
|
|
56
|
+
// The remembered tunnels and the assistant they fronted belong to the
|
|
57
|
+
// source host; carrying them over makes tunnel-status UIs name the old
|
|
58
|
+
// deployment, and the pairing record would have the destination advertise
|
|
59
|
+
// an address on the source's tailnet.
|
|
60
|
+
delete ingress[INGRESS_LAST_TUNNEL_KEY];
|
|
61
|
+
delete ingress[INGRESS_PAIRING_TUNNEL_KEY];
|
|
62
|
+
delete ingress[INGRESS_ASSISTANT_ID_KEY];
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
// Strip the recorded Telegram webhook URL. It records where *this*
|
|
@@ -604,6 +604,13 @@ export const ProfileEntry = LLMConfigFragment.extend({
|
|
|
604
604
|
* not yet backfilled by the boot-time migration.
|
|
605
605
|
*/
|
|
606
606
|
provider_connection: z.string().min(1).optional(),
|
|
607
|
+
/**
|
|
608
|
+
* The profile was deliberately created for a model the catalog does not
|
|
609
|
+
* list (the write routes' allowUnlisted escape hatch). Stamped at write
|
|
610
|
+
* time so listings do not flag the row as misconfigured on every read;
|
|
611
|
+
* a model the checks can vouch for never needs it.
|
|
612
|
+
*/
|
|
613
|
+
allowUnlisted: z.boolean().optional(),
|
|
607
614
|
/**
|
|
608
615
|
* Absent means active. `.nullable()` matches `label` so the PUT route's
|
|
609
616
|
* "send `null` to clear" sentinel works for status too — a managed
|
|
@@ -76,6 +76,9 @@ const WebSearchServiceSchema = z.object({
|
|
|
76
76
|
provider: z
|
|
77
77
|
.enum(VALID_WEB_SEARCH_PROVIDERS)
|
|
78
78
|
.default("inference-provider-native"),
|
|
79
|
+
// Origin for providers that support a custom API base (e.g. fastCRW).
|
|
80
|
+
// Empty / omitted uses the provider's cloud default.
|
|
81
|
+
apiBase: z.string().optional(),
|
|
79
82
|
});
|
|
80
83
|
|
|
81
84
|
/**
|
|
@@ -88,6 +91,9 @@ const WebFetchServiceSchema = z.object({
|
|
|
88
91
|
// `firecrawl`) scrape via their hosted API and reuse the same stored key as
|
|
89
92
|
// their web-search counterpart.
|
|
90
93
|
provider: z.enum(VALID_WEB_FETCH_PROVIDERS).default("default"),
|
|
94
|
+
// Origin for providers that support a custom API base (e.g. fastCRW).
|
|
95
|
+
// Empty / omitted uses the provider's cloud default.
|
|
96
|
+
apiBase: z.string().optional(),
|
|
91
97
|
});
|
|
92
98
|
|
|
93
99
|
const GoogleOAuthServiceSchema = BaseServiceSchema.extend({
|
|
@@ -26,6 +26,12 @@ const AX_TREE_PLACEHOLDER = "<ax_tree_omitted />";
|
|
|
26
26
|
* prematurely when the user happens to be viewing XML/HTML source that
|
|
27
27
|
* contains the closing tag. The escaped content does not need to be
|
|
28
28
|
* unescaped because compaction replaces the entire block with a placeholder.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately narrower than `escapeTagBoundaries` in
|
|
31
|
+
* `security/untrusted-content.ts`, which is the general defense for fencing
|
|
32
|
+
* untrusted text: this one leaves the opening tag intact because
|
|
33
|
+
* {@link AX_TREE_PATTERN} needs it to find whole blocks. Do not fold the two
|
|
34
|
+
* together.
|
|
29
35
|
*/
|
|
30
36
|
export function escapeAxTreeContent(content: string): string {
|
|
31
37
|
return content.replace(/<\/ax-tree>/gi, "</ax-tree>");
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The watch-timeline sweep's startup contract.
|
|
7
|
+
*
|
|
8
|
+
* `drainOrphanedWatchTimelineEntries` is the recovery path for a purge that
|
|
9
|
+
* did not happen, and the rows it reclaims hold narration, AX trees, and
|
|
10
|
+
* screenshots of the user's screen. Database maintenance runs it on a periodic
|
|
11
|
+
* cadence from the memory plugin's jobs worker, so an install with that plugin
|
|
12
|
+
* disabled or `memory.enabled: false` reaches it never. Daemon startup runs it
|
|
13
|
+
* on every install and closes that gap.
|
|
14
|
+
*
|
|
15
|
+
* Two properties carry the contract, and each is checked the way it can be:
|
|
16
|
+
*
|
|
17
|
+
* 1. lifecycle calls the sweep on its DB-ready startup path, after migrations
|
|
18
|
+
* have settled. Mounting the full lifecycle import graph to observe a real
|
|
19
|
+
* boot is disproportionately heavy, so this is a source guard over the call
|
|
20
|
+
* site.
|
|
21
|
+
* 2. a throwing sweep does not abort startup. The wiring below mirrors the
|
|
22
|
+
* lifecycle call site verbatim.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const LIFECYCLE_PATH = join(import.meta.dir, "..", "lifecycle.ts");
|
|
26
|
+
|
|
27
|
+
describe("lifecycle watch-timeline sweep wiring", () => {
|
|
28
|
+
test("lifecycle sweeps orphaned entries on the DB-ready startup path", () => {
|
|
29
|
+
const source = readFileSync(LIFECYCLE_PATH, "utf8");
|
|
30
|
+
|
|
31
|
+
const sweepIndex = source.indexOf("drainOrphanedWatchTimelineEntries()");
|
|
32
|
+
expect(sweepIndex).toBeGreaterThan(-1);
|
|
33
|
+
|
|
34
|
+
// The drain yields between pages, so the call has to be awaited. Without
|
|
35
|
+
// the await the count is a pending promise, which compares false against
|
|
36
|
+
// zero, and the pass reports nothing swept however much it removed.
|
|
37
|
+
expect(source).toContain("await drainOrphanedWatchTimelineEntries()");
|
|
38
|
+
|
|
39
|
+
// `startRuntimeHttpServerBackgroundSweeps()` is the point where lifecycle
|
|
40
|
+
// has established that migrations settled, so a call after it runs against
|
|
41
|
+
// a database whose tables exist.
|
|
42
|
+
const migrationsSettledIndex = source.indexOf(
|
|
43
|
+
"startRuntimeHttpServerBackgroundSweeps();",
|
|
44
|
+
);
|
|
45
|
+
expect(migrationsSettledIndex).toBeGreaterThan(-1);
|
|
46
|
+
expect(sweepIndex).toBeGreaterThan(migrationsSettledIndex);
|
|
47
|
+
|
|
48
|
+
// The call sits under a `dbReady` guard, so a boot whose DB never opened
|
|
49
|
+
// does not reach it.
|
|
50
|
+
const guardIndex = source.lastIndexOf("if (dbReady) {", sweepIndex);
|
|
51
|
+
expect(guardIndex).toBeGreaterThan(migrationsSettledIndex);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("invokes the sweep once and logs only when it reclaimed rows", () => {
|
|
55
|
+
const sweep = mock(() => 0);
|
|
56
|
+
|
|
57
|
+
const quiet = runStartupSweepWiring(sweep);
|
|
58
|
+
expect(sweep).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(quiet.logged).toBe(false);
|
|
60
|
+
expect(quiet.reachedNextStep).toBe(true);
|
|
61
|
+
|
|
62
|
+
const noisy = runStartupSweepWiring(mock(() => 3));
|
|
63
|
+
expect(noisy.logged).toBe(true);
|
|
64
|
+
expect(noisy.reachedNextStep).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a throwing sweep does NOT abort startup", () => {
|
|
68
|
+
const sweep = mock(() => {
|
|
69
|
+
throw new Error("sweep exploded");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const { reachedNextStep, logged } = runStartupSweepWiring(sweep);
|
|
73
|
+
|
|
74
|
+
expect(sweep).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(logged).toBe(false);
|
|
76
|
+
expect(reachedNextStep).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Replicates the lifecycle startup wiring: sweep, log only a non-zero count,
|
|
82
|
+
* swallow any throw, then continue to the next startup step.
|
|
83
|
+
*/
|
|
84
|
+
function runStartupSweepWiring(sweep: () => number): {
|
|
85
|
+
reachedNextStep: boolean;
|
|
86
|
+
logged: boolean;
|
|
87
|
+
} {
|
|
88
|
+
let logged = false;
|
|
89
|
+
try {
|
|
90
|
+
const sweptWatchEntries = sweep();
|
|
91
|
+
if (sweptWatchEntries > 0) {
|
|
92
|
+
logged = true;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Startup must never block on a subsystem failure.
|
|
96
|
+
}
|
|
97
|
+
return { reachedNextStep: true, logged };
|
|
98
|
+
}
|