@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,7 @@
|
|
|
2
2
|
* Tests for the desktop presence policy.
|
|
3
3
|
*
|
|
4
4
|
* The invariant under test is fail-open: only a fresh `active` report from a
|
|
5
|
-
*
|
|
5
|
+
* desktop client answers `true`. Every other shape (stale, idle, away, no
|
|
6
6
|
* presence, wrong interface, no clients, hub throw) answers `false` so the
|
|
7
7
|
* push is sent. Scoping the read to an actor principal narrows what counts as
|
|
8
8
|
* attendance, so it can only ever turn a `true` into a `false`.
|
|
@@ -45,6 +45,15 @@ describe("isDesktopAttended", () => {
|
|
|
45
45
|
expect(isDesktopAttended()).toBe(true);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
test("fresh active windows client is attended", () => {
|
|
49
|
+
registerClient({
|
|
50
|
+
clientId: "windows-1",
|
|
51
|
+
interfaceId: "windows",
|
|
52
|
+
presence: "active",
|
|
53
|
+
});
|
|
54
|
+
expect(isDesktopAttended()).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
48
57
|
test("active report older than the staleness bound is not attended", () => {
|
|
49
58
|
registerClient({ clientId: "mac-1", presence: "active" });
|
|
50
59
|
expect(isDesktopAttended({ now: afterStaleness() })).toBe(false);
|
|
@@ -92,9 +101,13 @@ describe("isDesktopAttended", () => {
|
|
|
92
101
|
}
|
|
93
102
|
});
|
|
94
103
|
|
|
95
|
-
test("one active
|
|
104
|
+
test("one active desktop client among several is enough", () => {
|
|
96
105
|
registerClient({ clientId: "mac-1", presence: "away" });
|
|
97
|
-
registerClient({
|
|
106
|
+
registerClient({
|
|
107
|
+
clientId: "windows-1",
|
|
108
|
+
interfaceId: "windows",
|
|
109
|
+
presence: "active",
|
|
110
|
+
});
|
|
98
111
|
expect(isDesktopAttended()).toBe(true);
|
|
99
112
|
});
|
|
100
113
|
});
|
|
@@ -109,6 +122,16 @@ describe("isDesktopAttended scoped to an actor principal", () => {
|
|
|
109
122
|
expect(isDesktopAttended({ actorPrincipalId: "actor-a" })).toBe(true);
|
|
110
123
|
});
|
|
111
124
|
|
|
125
|
+
test("matching actor principal on windows is attended", () => {
|
|
126
|
+
registerClient({
|
|
127
|
+
clientId: "windows-1",
|
|
128
|
+
interfaceId: "windows",
|
|
129
|
+
presence: "active",
|
|
130
|
+
actorPrincipalId: "actor-a",
|
|
131
|
+
});
|
|
132
|
+
expect(isDesktopAttended({ actorPrincipalId: "actor-a" })).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
112
135
|
test("another actor's attended mac does not count", () => {
|
|
113
136
|
registerClient({
|
|
114
137
|
clientId: "mac-1",
|
|
@@ -123,7 +146,7 @@ describe("isDesktopAttended scoped to an actor principal", () => {
|
|
|
123
146
|
expect(isDesktopAttended({ actorPrincipalId: "actor-a" })).toBe(false);
|
|
124
147
|
});
|
|
125
148
|
|
|
126
|
-
test("omitting the principal counts any attended
|
|
149
|
+
test("omitting the principal counts any attended desktop client", () => {
|
|
127
150
|
registerClient({
|
|
128
151
|
clientId: "mac-1",
|
|
129
152
|
presence: "active",
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `observeHostScreen()`, the conversation-agnostic screen
|
|
3
|
+
* observation helper.
|
|
4
|
+
*
|
|
5
|
+
* Covers:
|
|
6
|
+
* 1. A posted `host-cu-result` resolves the request with the AX tree, with no
|
|
7
|
+
* conversation and no tool call involved.
|
|
8
|
+
* 2. A request that never resolves times out cleanly and returns a failure.
|
|
9
|
+
* 3. `executionError` from the client surfaces as a structured failure.
|
|
10
|
+
* 4. The pending interaction is unregistered on every path.
|
|
11
|
+
* 5. No capable client connected: failure rather than a throw.
|
|
12
|
+
* 6. `includeScreenshot: false` drops the screenshot from the result.
|
|
13
|
+
* 7. Actor binding: another user's client is never selected by default, an
|
|
14
|
+
* explicit clientId owned by another user is rejected, a caller with no
|
|
15
|
+
* actor principal reaches nothing, and two same-user clients are
|
|
16
|
+
* ambiguous rather than fanned out to.
|
|
17
|
+
*/
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
19
|
+
|
|
20
|
+
// Module mocks (must precede the real imports)
|
|
21
|
+
|
|
22
|
+
interface MockClient {
|
|
23
|
+
clientId: string;
|
|
24
|
+
capabilities: string[];
|
|
25
|
+
actorPrincipalId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ACTOR = "principal-a";
|
|
29
|
+
const OTHER_ACTOR = "principal-b";
|
|
30
|
+
|
|
31
|
+
const sentMessages: Record<string, unknown>[] = [];
|
|
32
|
+
let mockClients: MockClient[] = [];
|
|
33
|
+
|
|
34
|
+
mock.module("../assistant-event-hub.js", () => ({
|
|
35
|
+
broadcastMessage: (msg: Record<string, unknown>) => {
|
|
36
|
+
sentMessages.push(msg);
|
|
37
|
+
},
|
|
38
|
+
assistantEventHub: {
|
|
39
|
+
getMostRecentClientByCapability: (cap: string) =>
|
|
40
|
+
mockClients.find((c) => c.capabilities.includes(cap)),
|
|
41
|
+
listClientsByCapability: (cap: string) =>
|
|
42
|
+
mockClients.filter((c) => c.capabilities.includes(cap)),
|
|
43
|
+
getClientById: (id: string) => mockClients.find((c) => c.clientId === id),
|
|
44
|
+
getActorPrincipalIdForClient: (id: string) =>
|
|
45
|
+
mockClients.find((c) => c.clientId === id)?.actorPrincipalId,
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// Real imports (after mocks)
|
|
50
|
+
|
|
51
|
+
const pendingInteractions = await import("../pending-interactions.js");
|
|
52
|
+
const { observeHostScreen } = await import("../host-observe.js");
|
|
53
|
+
const { ROUTES } = await import("../routes/host-cu-routes.js");
|
|
54
|
+
|
|
55
|
+
const hostCuResultRoute = ROUTES.find(
|
|
56
|
+
(r) => r.operationId === "host_cu_result",
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Deliver a client observation through the real `/v1/host-cu-result` route,
|
|
61
|
+
* with the identity headers the targeted client sends.
|
|
62
|
+
*/
|
|
63
|
+
async function postResult(
|
|
64
|
+
body: Record<string, unknown>,
|
|
65
|
+
headers: Record<string, string> = {
|
|
66
|
+
"x-vellum-client-id": "mac-1",
|
|
67
|
+
"x-vellum-actor-principal-id": ACTOR,
|
|
68
|
+
},
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
await hostCuResultRoute?.handler({ body, headers });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The requestId of the single `host_cu_request` broadcast so far. */
|
|
74
|
+
function sentRequestId(): string {
|
|
75
|
+
const request = sentMessages.find((m) => m.type === "host_cu_request");
|
|
76
|
+
expect(request).toBeDefined();
|
|
77
|
+
return request?.requestId as string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Observe as the actor that owns `mac-1` in the default fixture. */
|
|
81
|
+
function observe(
|
|
82
|
+
options: Partial<Parameters<typeof observeHostScreen>[0]> = {},
|
|
83
|
+
) {
|
|
84
|
+
return observeHostScreen({ sourceActorPrincipalId: ACTOR, ...options });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("observeHostScreen", () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
sentMessages.length = 0;
|
|
90
|
+
mockClients = [
|
|
91
|
+
{
|
|
92
|
+
clientId: "mac-1",
|
|
93
|
+
capabilities: ["host_cu"],
|
|
94
|
+
actorPrincipalId: ACTOR,
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
pendingInteractions.clear();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
pendingInteractions.clear();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("resolves with the AX tree from a posted client result", async () => {
|
|
105
|
+
const observation = observe();
|
|
106
|
+
|
|
107
|
+
const request = sentMessages.find((m) => m.type === "host_cu_request");
|
|
108
|
+
expect(request?.toolName).toBe("computer_use_observe");
|
|
109
|
+
expect(request?.input).toEqual({});
|
|
110
|
+
expect(request?.conversationId).toBe("");
|
|
111
|
+
|
|
112
|
+
const requestId = sentRequestId();
|
|
113
|
+
expect(pendingInteractions.get(requestId)?.kind).toBe("host_cu");
|
|
114
|
+
expect(pendingInteractions.get(requestId)?.targetClientId).toBe("mac-1");
|
|
115
|
+
|
|
116
|
+
await postResult({
|
|
117
|
+
requestId,
|
|
118
|
+
axTree: "Window [1]\nButton [2]",
|
|
119
|
+
screenshot: "base64png",
|
|
120
|
+
screenWidthPt: 1440,
|
|
121
|
+
screenHeightPt: 900,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(await observation).toEqual({
|
|
125
|
+
ok: true,
|
|
126
|
+
axTree: "Window [1]\nButton [2]",
|
|
127
|
+
screenshot: "base64png",
|
|
128
|
+
screenWidthPt: 1440,
|
|
129
|
+
screenHeightPt: 900,
|
|
130
|
+
});
|
|
131
|
+
expect(pendingInteractions.getAll()).toHaveLength(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("times out cleanly and unregisters the pending interaction", async () => {
|
|
135
|
+
const result = await observe({ timeoutMs: 20 });
|
|
136
|
+
|
|
137
|
+
expect(result.ok).toBe(false);
|
|
138
|
+
if (!result.ok) {
|
|
139
|
+
expect(result.timedOut).toBe(true);
|
|
140
|
+
expect(result.reason).toContain("Timed out after 20ms");
|
|
141
|
+
}
|
|
142
|
+
expect(pendingInteractions.getAll()).toHaveLength(0);
|
|
143
|
+
expect(sentMessages.some((m) => m.type === "host_cu_cancel")).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("tolerates a late result posted after the timeout", async () => {
|
|
147
|
+
const result = await observe({ timeoutMs: 20 });
|
|
148
|
+
expect(result.ok).toBe(false);
|
|
149
|
+
|
|
150
|
+
// The route 404s on an unknown requestId; a late post must not throw past
|
|
151
|
+
// that, and must not resurrect the already-settled request.
|
|
152
|
+
await expect(postResult({ requestId: sentRequestId() })).rejects.toThrow();
|
|
153
|
+
expect(pendingInteractions.getAll()).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("surfaces executionError as a structured failure", async () => {
|
|
157
|
+
const observation = observe();
|
|
158
|
+
await postResult({
|
|
159
|
+
requestId: sentRequestId(),
|
|
160
|
+
executionError: "Accessibility permission not granted",
|
|
161
|
+
axTree: "Window [1]",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const result = await observation;
|
|
165
|
+
expect(result.ok).toBe(false);
|
|
166
|
+
if (!result.ok) {
|
|
167
|
+
expect(result.reason).toBe("Accessibility permission not granted");
|
|
168
|
+
expect(result.timedOut).toBeUndefined();
|
|
169
|
+
}
|
|
170
|
+
expect(pendingInteractions.getAll()).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("fails without throwing when no capable client is connected", async () => {
|
|
174
|
+
mockClients = [];
|
|
175
|
+
|
|
176
|
+
const result = await observe();
|
|
177
|
+
expect(result.ok).toBe(false);
|
|
178
|
+
if (!result.ok) {
|
|
179
|
+
expect(result.reason).toContain("No connected client");
|
|
180
|
+
}
|
|
181
|
+
expect(sentMessages).toHaveLength(0);
|
|
182
|
+
expect(pendingInteractions.getAll()).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("drops the screenshot when includeScreenshot is false", async () => {
|
|
186
|
+
const observation = observe({ includeScreenshot: false });
|
|
187
|
+
await postResult({
|
|
188
|
+
requestId: sentRequestId(),
|
|
189
|
+
axTree: "Window [1]",
|
|
190
|
+
screenshot: "base64png",
|
|
191
|
+
screenshotWidthPx: 2880,
|
|
192
|
+
screenshotHeightPx: 1800,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(await observation).toEqual({ ok: true, axTree: "Window [1]" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("actor binding", () => {
|
|
199
|
+
test("never selects another actor's client by default", async () => {
|
|
200
|
+
mockClients = [
|
|
201
|
+
{
|
|
202
|
+
clientId: "mac-2",
|
|
203
|
+
capabilities: ["host_cu"],
|
|
204
|
+
actorPrincipalId: OTHER_ACTOR,
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
const result = await observe();
|
|
209
|
+
expect(result.ok).toBe(false);
|
|
210
|
+
if (!result.ok) {
|
|
211
|
+
expect(result.reason).toContain("No connected client");
|
|
212
|
+
}
|
|
213
|
+
expect(sentMessages).toHaveLength(0);
|
|
214
|
+
expect(pendingInteractions.getAll()).toHaveLength(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("rejects an explicit clientId owned by another actor", async () => {
|
|
218
|
+
mockClients.push({
|
|
219
|
+
clientId: "mac-2",
|
|
220
|
+
capabilities: ["host_cu"],
|
|
221
|
+
actorPrincipalId: OTHER_ACTOR,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = await observe({ clientId: "mac-2" });
|
|
225
|
+
expect(result.ok).toBe(false);
|
|
226
|
+
if (!result.ok) {
|
|
227
|
+
expect(result.reason).toContain("different user");
|
|
228
|
+
}
|
|
229
|
+
expect(sentMessages).toHaveLength(0);
|
|
230
|
+
expect(pendingInteractions.getAll()).toHaveLength(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("rejects a caller with no actor principal", async () => {
|
|
234
|
+
const result = await observeHostScreen({
|
|
235
|
+
sourceActorPrincipalId: undefined,
|
|
236
|
+
clientId: "mac-1",
|
|
237
|
+
});
|
|
238
|
+
expect(result.ok).toBe(false);
|
|
239
|
+
if (!result.ok) {
|
|
240
|
+
expect(result.reason).toContain("no authenticated actor");
|
|
241
|
+
}
|
|
242
|
+
expect(sentMessages).toHaveLength(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("rejects a client that registered without an actor principal", async () => {
|
|
246
|
+
mockClients = [{ clientId: "mac-1", capabilities: ["host_cu"] }];
|
|
247
|
+
|
|
248
|
+
const result = await observe({ clientId: "mac-1" });
|
|
249
|
+
expect(result.ok).toBe(false);
|
|
250
|
+
if (!result.ok) {
|
|
251
|
+
expect(result.reason).toContain("without an authenticated user");
|
|
252
|
+
}
|
|
253
|
+
expect(sentMessages).toHaveLength(0);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("refuses to fan out across two clients owned by the actor", async () => {
|
|
257
|
+
mockClients.push({
|
|
258
|
+
clientId: "mac-3",
|
|
259
|
+
capabilities: ["host_cu"],
|
|
260
|
+
actorPrincipalId: ACTOR,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const result = await observe();
|
|
264
|
+
expect(result.ok).toBe(false);
|
|
265
|
+
if (!result.ok) {
|
|
266
|
+
expect(result.reason).toContain("Specify target_client_id");
|
|
267
|
+
}
|
|
268
|
+
expect(sentMessages).toHaveLength(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("succeeds on the same-actor path with an explicit clientId", async () => {
|
|
272
|
+
mockClients.push({
|
|
273
|
+
clientId: "mac-2",
|
|
274
|
+
capabilities: ["host_cu"],
|
|
275
|
+
actorPrincipalId: OTHER_ACTOR,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const observation = observe({ clientId: "mac-1" });
|
|
279
|
+
await postResult({ requestId: sentRequestId(), axTree: "Window [1]" });
|
|
280
|
+
|
|
281
|
+
expect(await observation).toEqual({ ok: true, axTree: "Window [1]" });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("rejects a result submitted by another actor's client", async () => {
|
|
285
|
+
const observation = observe({ timeoutMs: 200 });
|
|
286
|
+
const requestId = sentRequestId();
|
|
287
|
+
|
|
288
|
+
await expect(
|
|
289
|
+
postResult(
|
|
290
|
+
{ requestId, axTree: "Window [1]" },
|
|
291
|
+
{
|
|
292
|
+
"x-vellum-client-id": "mac-1",
|
|
293
|
+
"x-vellum-actor-principal-id": OTHER_ACTOR,
|
|
294
|
+
},
|
|
295
|
+
),
|
|
296
|
+
).rejects.toThrow();
|
|
297
|
+
|
|
298
|
+
const result = await observation;
|
|
299
|
+
expect(result.ok).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -3,12 +3,14 @@ import { z } from "zod";
|
|
|
3
3
|
|
|
4
4
|
import { resolveTwilioPhoneNumber } from "../calls/twilio-config.js";
|
|
5
5
|
import { hasTwilioCredentials } from "../calls/twilio-rest.js";
|
|
6
|
+
import { CHANNEL_METADATA } from "../channels/types.js";
|
|
6
7
|
import { getNestedValue, loadRawConfig } from "../config/loader.js";
|
|
7
8
|
import { hasWebhookRoutingConfigured } from "../config/webhook-routing.js";
|
|
8
9
|
import { credentialKey } from "../security/credential-key.js";
|
|
9
10
|
import { getSecureKeyAsync } from "../security/secure-keys.js";
|
|
10
11
|
import { resolveWhatsAppDisplayNumber } from "./channel-invite-transports/whatsapp.js";
|
|
11
12
|
import type {
|
|
13
|
+
ChannelHealth,
|
|
12
14
|
ChannelId,
|
|
13
15
|
ChannelProbe,
|
|
14
16
|
ChannelProbeContext,
|
|
@@ -247,12 +249,19 @@ const telegramProbe: ChannelProbe = {
|
|
|
247
249
|
health.status === "unknown" ||
|
|
248
250
|
health.status === "unverified";
|
|
249
251
|
const result = check(
|
|
252
|
+
// Published identifier. Installed skill copies and external clients
|
|
253
|
+
// search readiness responses for this name, and they do not upgrade in
|
|
254
|
+
// lockstep with the daemon, so renaming it silently breaks their
|
|
255
|
+
// delivery gate while Telegram is healthy.
|
|
250
256
|
"webhook_delivery",
|
|
251
257
|
health.status === "healthy" || indeterminate,
|
|
252
258
|
"Telegram is delivering to this assistant",
|
|
253
259
|
health.detail,
|
|
254
260
|
);
|
|
255
|
-
|
|
261
|
+
const operational = { ...result, kind: "operational" as const };
|
|
262
|
+
return [
|
|
263
|
+
indeterminate ? { ...operational, indeterminate: true } : operational,
|
|
264
|
+
];
|
|
256
265
|
},
|
|
257
266
|
};
|
|
258
267
|
|
|
@@ -354,6 +363,96 @@ const whatsappProbe: ChannelProbe = {
|
|
|
354
363
|
|
|
355
364
|
// ── Slack Probe ─────────────────────────────────────────────────────────────
|
|
356
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Ask the gateway whether a socket-backed channel's connection is receiving.
|
|
368
|
+
*
|
|
369
|
+
* One function for every such channel, matching the IPC route it reads, which
|
|
370
|
+
* is keyed by channel for the same reason: the question and the answer are the
|
|
371
|
+
* same wherever a socket carries inbound. A channel whose ingress is not a
|
|
372
|
+
* gateway-owned socket answers `unsupported`, which reads as indeterminate
|
|
373
|
+
* rather than a fault, so asking is always safe.
|
|
374
|
+
*
|
|
375
|
+
* Distinct from the credential checks: valid tokens establish that the
|
|
376
|
+
* provider would accept us, not that anything is arriving. A socket can be
|
|
377
|
+
* open at the transport layer and deliver nothing.
|
|
378
|
+
*
|
|
379
|
+
* Three outcomes, matching the Telegram probe:
|
|
380
|
+
*
|
|
381
|
+
* - `connected` is the only verified pass.
|
|
382
|
+
* - `disconnected` is a failure with a concrete cause.
|
|
383
|
+
* - `not_configured`, `unsupported` and an unreachable gateway are
|
|
384
|
+
* indeterminate. None is evidence of a fault, so none may show the channel
|
|
385
|
+
* as broken; none is evidence of delivery either, so none may make it
|
|
386
|
+
* ready.
|
|
387
|
+
*
|
|
388
|
+
* `lastLivenessAt` is reported and never gated on: a connection's first
|
|
389
|
+
* keepalive is a full interval after it opens, so requiring one would call
|
|
390
|
+
* every healthy reconnect broken.
|
|
391
|
+
*
|
|
392
|
+
* Declared `operational`, so it decides the channel's health rather than its
|
|
393
|
+
* setup progress: a dead socket means a configured channel is down, not a
|
|
394
|
+
* half-configured one.
|
|
395
|
+
*
|
|
396
|
+
* It runs among the local checks purely for freshness. It costs one IPC round
|
|
397
|
+
* trip to the gateway beside us rather than a call to Slack, and the remote
|
|
398
|
+
* bucket is cached for {@link REMOTE_TTL_MS}, far longer than a socket takes
|
|
399
|
+
* to die and recover. Placement is a cost decision here; `kind` carries the
|
|
400
|
+
* meaning.
|
|
401
|
+
*/
|
|
402
|
+
async function checkSocketInboundDelivery(
|
|
403
|
+
channel: ChannelId,
|
|
404
|
+
): Promise<ReadinessCheckResult> {
|
|
405
|
+
// The channel's own display name, so a socket-backed channel added later
|
|
406
|
+
// needs no string decision here. Falls back to the id for a channel with no
|
|
407
|
+
// metadata entry, which is every channel clients are not offered.
|
|
408
|
+
const label = CHANNEL_METADATA[channel]?.label ?? channel;
|
|
409
|
+
// Imported here rather than at module scope, matching the Telegram probe:
|
|
410
|
+
// the gateway IPC client pulls in a module graph that unrelated consumers of
|
|
411
|
+
// this service should not have to mock.
|
|
412
|
+
const { readChannelSocketHealth } =
|
|
413
|
+
await import("../channels/gateway-channel-socket-health.js");
|
|
414
|
+
|
|
415
|
+
let health;
|
|
416
|
+
try {
|
|
417
|
+
health = await readChannelSocketHealth(channel);
|
|
418
|
+
} catch {
|
|
419
|
+
return {
|
|
420
|
+
name: "inbound_delivery",
|
|
421
|
+
kind: "operational",
|
|
422
|
+
passed: true,
|
|
423
|
+
message: `Could not reach the gateway to read the ${label} connection state`,
|
|
424
|
+
indeterminate: true,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (health.status === "not_configured" || health.status === "unsupported") {
|
|
429
|
+
return {
|
|
430
|
+
name: "inbound_delivery",
|
|
431
|
+
kind: "operational",
|
|
432
|
+
passed: true,
|
|
433
|
+
message:
|
|
434
|
+
health.status === "not_configured"
|
|
435
|
+
? `${label} is not connected, because its credentials are not configured`
|
|
436
|
+
: `${label} does not report a gateway-owned socket`,
|
|
437
|
+
indeterminate: true,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const lastProof =
|
|
442
|
+
health.lastLivenessAt === undefined
|
|
443
|
+
? "no keepalive answered yet on this connection"
|
|
444
|
+
: `last keepalive ${new Date(health.lastLivenessAt).toISOString()}`;
|
|
445
|
+
return {
|
|
446
|
+
...check(
|
|
447
|
+
"inbound_delivery",
|
|
448
|
+
health.status === "connected",
|
|
449
|
+
`${label} is delivering to this assistant (${lastProof})`,
|
|
450
|
+
`${label} holds no live connection, so inbound messages are not reaching this assistant`,
|
|
451
|
+
),
|
|
452
|
+
kind: "operational",
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
357
456
|
const slackProbe: ChannelProbe = {
|
|
358
457
|
channel: "slack",
|
|
359
458
|
async runLocalChecks(): Promise<ReadinessCheckResult[]> {
|
|
@@ -370,6 +469,7 @@ const slackProbe: ChannelProbe = {
|
|
|
370
469
|
"app_token",
|
|
371
470
|
"Slack app token",
|
|
372
471
|
),
|
|
472
|
+
await checkSocketInboundDelivery("slack"),
|
|
373
473
|
];
|
|
374
474
|
},
|
|
375
475
|
async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
|
|
@@ -448,6 +548,77 @@ const slackProbe: ChannelProbe = {
|
|
|
448
548
|
},
|
|
449
549
|
};
|
|
450
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Whether Discord admits anything in the guilds it has joined.
|
|
553
|
+
*
|
|
554
|
+
* A configuration check rather than an operational one, and the distinction
|
|
555
|
+
* carries the meaning. An empty allow-list is a setup step nobody finished,
|
|
556
|
+
* not a channel that broke: direct messages reach the assistant either way,
|
|
557
|
+
* because a message with no guild never meets this gate. Reporting it as
|
|
558
|
+
* failing health would send an operator to look for an outage that is not
|
|
559
|
+
* there, and reporting it as ready would claim delivery while every guild
|
|
560
|
+
* message is dropped.
|
|
561
|
+
*
|
|
562
|
+
* An unreachable gateway is indeterminate, matching the socket check beside
|
|
563
|
+
* it: nobody looked, so nothing is claimed either way.
|
|
564
|
+
*/
|
|
565
|
+
async function checkDiscordGuildAdmission(): Promise<ReadinessCheckResult> {
|
|
566
|
+
const { readDiscordAdmission } =
|
|
567
|
+
await import("../channels/gateway-discord-admission.js");
|
|
568
|
+
|
|
569
|
+
let admitted;
|
|
570
|
+
try {
|
|
571
|
+
admitted = (await readDiscordAdmission()).admittedChannelCount;
|
|
572
|
+
} catch {
|
|
573
|
+
return {
|
|
574
|
+
name: "guild_admission",
|
|
575
|
+
passed: true,
|
|
576
|
+
message: "Could not reach the gateway to read Discord's allow-list",
|
|
577
|
+
indeterminate: true,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return check(
|
|
582
|
+
"guild_admission",
|
|
583
|
+
admitted > 0,
|
|
584
|
+
`Discord may act in ${admitted} channel${admitted === 1 ? "" : "s"}`,
|
|
585
|
+
"Discord is in no channel's allow-list, so every guild message is dropped. Add channel ids to `discord.allowedChannelIds`",
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── Discord Probe ───────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Discord readiness.
|
|
593
|
+
*
|
|
594
|
+
* One credential and one socket, which is the whole of it. Discord holds a
|
|
595
|
+
* Gateway connection rather than receiving webhooks, so there is no ingress
|
|
596
|
+
* routing to check: nothing has to reach this deployment from outside for
|
|
597
|
+
* inbound to work.
|
|
598
|
+
*
|
|
599
|
+
* No remote check either, and that is a decision rather than an omission. The
|
|
600
|
+
* remote bucket exists to ask a provider whether it would accept us, which for
|
|
601
|
+
* Slack is worth asking separately because its socket authenticates with a
|
|
602
|
+
* different token than its sends. Discord uses one bot token for both, so a
|
|
603
|
+
* live Gateway connection already proves the token the sends will use, and a
|
|
604
|
+
* call to `GET /users/@me` would restate it a cache interval later.
|
|
605
|
+
*/
|
|
606
|
+
const discordProbe: ChannelProbe = {
|
|
607
|
+
channel: "discord",
|
|
608
|
+
async runLocalChecks(): Promise<ReadinessCheckResult[]> {
|
|
609
|
+
return [
|
|
610
|
+
await checkCredential(
|
|
611
|
+
"bot_token",
|
|
612
|
+
"discord_channel",
|
|
613
|
+
"bot_token",
|
|
614
|
+
"Discord bot token",
|
|
615
|
+
),
|
|
616
|
+
await checkSocketInboundDelivery("discord"),
|
|
617
|
+
await checkDiscordGuildAdmission(),
|
|
618
|
+
];
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
|
|
451
622
|
// ── Service ─────────────────────────────────────────────────────────────────
|
|
452
623
|
|
|
453
624
|
export class ChannelReadinessService {
|
|
@@ -519,14 +690,7 @@ export class ChannelReadinessService {
|
|
|
519
690
|
// itself live on the strength of a check that never ran.
|
|
520
691
|
const verified = (c: ReadinessCheckResult): boolean =>
|
|
521
692
|
c.passed && !c.indeterminate;
|
|
522
|
-
|
|
523
|
-
const allRemotePassed =
|
|
524
|
-
remoteChecks && remoteChecksAffectReadiness
|
|
525
|
-
? remoteChecks.every(verified)
|
|
526
|
-
: true;
|
|
527
|
-
const ready = allLocalPassed && allRemotePassed;
|
|
528
|
-
|
|
529
|
-
// setupStatus: considers all checks (credentials + infrastructure)
|
|
693
|
+
|
|
530
694
|
const consideredChecks = [
|
|
531
695
|
...localChecks,
|
|
532
696
|
...(remoteChecks && remoteChecksAffectReadiness ? remoteChecks : []),
|
|
@@ -537,13 +701,38 @@ export class ChannelReadinessService {
|
|
|
537
701
|
// progress reports an untouched workspace as `incomplete`, which is what
|
|
538
702
|
// sends the Channels UI down the "finish setup" path instead of the
|
|
539
703
|
// normal setup flow.
|
|
540
|
-
|
|
541
|
-
|
|
704
|
+
// Two axes, not one ladder. Setup progress derives from configuration
|
|
705
|
+
// checks and health from operational ones, because "is this configured"
|
|
706
|
+
// and "is it working" have different answers and different remedies. A
|
|
707
|
+
// fully configured channel whose delivery is failing is not half set
|
|
708
|
+
// up, and reporting it as incomplete sends its owner to fix the one
|
|
709
|
+
// thing that is not broken.
|
|
710
|
+
const configurationChecks = consideredChecks.filter(
|
|
711
|
+
(c) => (c.kind ?? "configuration") === "configuration",
|
|
712
|
+
);
|
|
713
|
+
const operationalChecks = consideredChecks.filter(
|
|
714
|
+
(c) => c.kind === "operational",
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
const setupStatus: SetupStatus = !configurationChecks.some(verified)
|
|
542
718
|
? "not_configured"
|
|
543
|
-
:
|
|
719
|
+
: configurationChecks.every(verified)
|
|
544
720
|
? "ready"
|
|
545
721
|
: "incomplete";
|
|
546
722
|
|
|
723
|
+
// Absent, not "unknown", when the channel asks no operational question.
|
|
724
|
+
// Nothing was measured, so nothing is claimed, and readiness rests on
|
|
725
|
+
// configuration alone. Collapsing that into `unknown` would report every
|
|
726
|
+
// channel without a delivery check as unverifiable.
|
|
727
|
+
const health: ChannelHealth | undefined =
|
|
728
|
+
operationalChecks.length === 0
|
|
729
|
+
? undefined
|
|
730
|
+
: operationalChecks.some((c) => !c.passed)
|
|
731
|
+
? "failing"
|
|
732
|
+
: operationalChecks.every(verified)
|
|
733
|
+
? "ok"
|
|
734
|
+
: "unknown";
|
|
735
|
+
|
|
547
736
|
const reasons: Array<{ code: string; text: string }> = [];
|
|
548
737
|
for (const check of localChecks) {
|
|
549
738
|
if (!check.passed) {
|
|
@@ -560,8 +749,12 @@ export class ChannelReadinessService {
|
|
|
560
749
|
|
|
561
750
|
const snapshot: ChannelReadinessSnapshot = {
|
|
562
751
|
channel: ch,
|
|
563
|
-
ready
|
|
752
|
+
ready:
|
|
753
|
+
setupStatus === "ready" &&
|
|
754
|
+
health !== "failing" &&
|
|
755
|
+
health !== "unknown",
|
|
564
756
|
setupStatus,
|
|
757
|
+
health,
|
|
565
758
|
checkedAt:
|
|
566
759
|
remoteChecks && cached && !remoteChecksFreshlyFetched
|
|
567
760
|
? cached.checkedAt
|
|
@@ -618,7 +811,13 @@ export class ChannelReadinessService {
|
|
|
618
811
|
|
|
619
812
|
// ── Factory ─────────────────────────────────────────────────────────────────
|
|
620
813
|
|
|
621
|
-
/**
|
|
814
|
+
/**
|
|
815
|
+
* A service with every built-in probe registered.
|
|
816
|
+
*
|
|
817
|
+
* The registrations below are the list; naming them here as well gives the set
|
|
818
|
+
* a second home that drifts the moment a channel is added, which is what it
|
|
819
|
+
* did.
|
|
820
|
+
*/
|
|
622
821
|
export function createReadinessService(): ChannelReadinessService {
|
|
623
822
|
const service = new ChannelReadinessService();
|
|
624
823
|
service.registerProbe(voiceProbe);
|
|
@@ -626,5 +825,6 @@ export function createReadinessService(): ChannelReadinessService {
|
|
|
626
825
|
service.registerProbe(emailProbe);
|
|
627
826
|
service.registerProbe(whatsappProbe);
|
|
628
827
|
service.registerProbe(slackProbe);
|
|
828
|
+
service.registerProbe(discordProbe);
|
|
629
829
|
return service;
|
|
630
830
|
}
|