@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
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ingress for a watch session: `/v1/watch/stream`, a WebSocket carrying
|
|
3
|
+
* the user's narration while they work.
|
|
4
|
+
*
|
|
5
|
+
* The transport is the one `/v1/stt/stream` already established, and
|
|
6
|
+
* deliberately not a second story: binary frames or base64 `audio` events in,
|
|
7
|
+
* a `{ type: "stop" }` text frame to flush, and a `StreamingTranscriber`
|
|
8
|
+
* resolved by `resolveStreamingTranscriber()` so provider selection,
|
|
9
|
+
* credentials, and language live in exactly one place.
|
|
10
|
+
*
|
|
11
|
+
* What differs is everything downstream of a transcript. Dictation hands its
|
|
12
|
+
* text back to the client; a watch session hands each final to
|
|
13
|
+
* {@link WatchSessionManager}, which files it on the timeline and decides
|
|
14
|
+
* whether the screen is worth reading. So the frames going the other way are
|
|
15
|
+
* lifecycle only: `ready`, `entry`, `observation`, `error`, `closed`. No
|
|
16
|
+
* `partial`, no transcript text, no assistant reply. What the client draws
|
|
17
|
+
* during a session is that the session is running and that its screen was
|
|
18
|
+
* read, never what was said or seen, and the assistant stays silent until the
|
|
19
|
+
* retrospective, which is a conversational turn that happens after the socket
|
|
20
|
+
* is gone.
|
|
21
|
+
*
|
|
22
|
+
* Route policy: the upgrade is gated exactly as `/v1/stt/stream` is, in
|
|
23
|
+
* `http-server.ts`: private-network peer and origin, then an `svc_gateway`
|
|
24
|
+
* service token. A WebSocket upgrade never reaches the shared `ROUTES` array,
|
|
25
|
+
* whose `policy` block the HTTP adapter evaluates per JSON request, so the
|
|
26
|
+
* gate is the upgrade handler's rather than a `RoutePolicy` value. The gateway
|
|
27
|
+
* authenticates the downstream actor before it dials upstream
|
|
28
|
+
* (`gateway/src/http/routes/stt-stream-websocket.ts` requires an actor
|
|
29
|
+
* principal and refuses service tokens on the client-facing half).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type {
|
|
33
|
+
StreamingTranscriber,
|
|
34
|
+
SttErrorCategory,
|
|
35
|
+
SttStreamServerEvent,
|
|
36
|
+
} from "../../stt/types.js";
|
|
37
|
+
import { getLogger } from "../../util/logger.js";
|
|
38
|
+
import { runWatchRetro } from "../../watch/watch-retro.js";
|
|
39
|
+
import {
|
|
40
|
+
WatchSessionManager,
|
|
41
|
+
type WatchSessionSummary,
|
|
42
|
+
} from "../../watch/watch-session-manager.js";
|
|
43
|
+
|
|
44
|
+
const log = getLogger("watch-stream");
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How long a socket may go without an inbound frame before the session is torn
|
|
48
|
+
* down, matching `/v1/stt/stream`. A watch client streams capture continuously,
|
|
49
|
+
* so silence on the socket means the client is gone rather than that the user
|
|
50
|
+
* stopped talking, and a leaked session would hold the single manager slot
|
|
51
|
+
* against the next press of Watch.
|
|
52
|
+
*/
|
|
53
|
+
const IDLE_TIMEOUT_MS = 60_000;
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Frames
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Why a session ended badly. Provider failures keep the category
|
|
61
|
+
* `resolveStreamingTranscriber`'s stack already assigns them; `session-error`
|
|
62
|
+
* covers the reasons that are the watch session's own, such as a second socket
|
|
63
|
+
* arriving while one is running.
|
|
64
|
+
*/
|
|
65
|
+
export type WatchStreamErrorCategory = SttErrorCategory | "session-error";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What the daemon sends back. Lifecycle only.
|
|
69
|
+
*
|
|
70
|
+
* `entry` is an acknowledgement that narration reached the session, carrying
|
|
71
|
+
* no text: it is what lets a client show that capture is live without drawing
|
|
72
|
+
* a transcript nobody is meant to read mid-session.
|
|
73
|
+
*
|
|
74
|
+
* `observation` is the same acknowledgement for the other half of a session,
|
|
75
|
+
* the screen reads the runtime takes around what the user says. It is a frame
|
|
76
|
+
* of its own rather than a discriminator on `entry` because the two report
|
|
77
|
+
* different facts with different failure modes: an `entry` is the narration
|
|
78
|
+
* the client itself just streamed coming back confirmed, while an
|
|
79
|
+
* `observation` is the only word a client ever gets that its screen was read
|
|
80
|
+
* at all. A client that treated them as one kind would have to re-derive that
|
|
81
|
+
* distinction from a field, and a client that knows nothing of the new frame
|
|
82
|
+
* ignores it, which is what makes this additive.
|
|
83
|
+
*
|
|
84
|
+
* Both are discrete events rather than states, and neither is emitted on a
|
|
85
|
+
* timer. A client can honestly draw the moment one arrives and nothing in
|
|
86
|
+
* between, which is the whole of what a watch session gives it to draw: the
|
|
87
|
+
* cadence is roughly three or four reads a minute (`MIN_OBSERVE_INTERVAL_MS`
|
|
88
|
+
* to `MAX_OBSERVE_INTERVAL_MS` in `watch-session-manager.ts`), so a
|
|
89
|
+
* client-side approximation of it would spend most of a session claiming a
|
|
90
|
+
* capture that is not happening.
|
|
91
|
+
*/
|
|
92
|
+
export type WatchStreamServerFrame =
|
|
93
|
+
| { readonly type: "ready"; sessionId: string; conversationId: string }
|
|
94
|
+
| { readonly type: "entry" }
|
|
95
|
+
| { readonly type: "observation" }
|
|
96
|
+
| {
|
|
97
|
+
readonly type: "error";
|
|
98
|
+
category: WatchStreamErrorCategory;
|
|
99
|
+
message: string;
|
|
100
|
+
}
|
|
101
|
+
| { readonly type: "closed" };
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Minimal socket surface, so the session can be driven by a test double
|
|
105
|
+
* instead of Bun's `ServerWebSocket`.
|
|
106
|
+
*/
|
|
107
|
+
export interface WatchStreamSocket {
|
|
108
|
+
send(data: string): void;
|
|
109
|
+
close(code?: number, reason?: string): void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Manager singleton
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
let sharedManager: WatchSessionManager | null = null;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The process-wide watch session manager.
|
|
120
|
+
*
|
|
121
|
+
* One instance because the manager owns one slot: it is driven by the one
|
|
122
|
+
* microphone the machine has, and a second manager would let two sessions
|
|
123
|
+
* interleave unrelated timelines. Lazily created so importing this module
|
|
124
|
+
* costs nothing until a socket arrives.
|
|
125
|
+
*/
|
|
126
|
+
export function getWatchSessionManager(): WatchSessionManager {
|
|
127
|
+
sharedManager ??= new WatchSessionManager();
|
|
128
|
+
return sharedManager;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Session
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
type SessionState =
|
|
136
|
+
/** Constructed, waiting for the transcriber and the session slot. */
|
|
137
|
+
| "initializing"
|
|
138
|
+
/** Recording: audio frames are accepted and finals become narration. */
|
|
139
|
+
| "active"
|
|
140
|
+
/** The client sent `stop`; the provider is flushing its last finals. */
|
|
141
|
+
| "stopping"
|
|
142
|
+
/** Terminal. */
|
|
143
|
+
| "closed";
|
|
144
|
+
|
|
145
|
+
export interface WatchStreamSessionOptions {
|
|
146
|
+
/** MIME type of the audio the client streams. */
|
|
147
|
+
readonly mimeType: string;
|
|
148
|
+
/** Sample rate in Hz, threaded to the provider that wants one. */
|
|
149
|
+
readonly sampleRate?: number;
|
|
150
|
+
/** Adopt an existing conversation rather than minting one for the session. */
|
|
151
|
+
readonly conversationId?: string;
|
|
152
|
+
/** The desktop client to observe, when the actor has more than one. */
|
|
153
|
+
readonly clientId?: string;
|
|
154
|
+
/** Override the idle window for testing. */
|
|
155
|
+
readonly idleTimeoutMs?: number;
|
|
156
|
+
/** The manager the session drives. Defaults to the process-wide one. */
|
|
157
|
+
readonly manager?: WatchSessionManager;
|
|
158
|
+
/**
|
|
159
|
+
* Opens the provider stream. Defaults to `resolveStreamingTranscriber`,
|
|
160
|
+
* imported lazily so a caller that injects its own never pulls the provider
|
|
161
|
+
* stack into the module graph.
|
|
162
|
+
*/
|
|
163
|
+
readonly resolveTranscriber?: () => Promise<StreamingTranscriber | null>;
|
|
164
|
+
/** Resolves the actor the session observes for. */
|
|
165
|
+
readonly resolveActorPrincipalId?: () => Promise<string | undefined>;
|
|
166
|
+
/**
|
|
167
|
+
* Runs the end-of-session retrospective. Defaults to {@link runWatchRetro}.
|
|
168
|
+
*/
|
|
169
|
+
readonly runRetro?: (summary: WatchSessionSummary) => Promise<unknown>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* One watch session, from socket open to teardown.
|
|
174
|
+
*
|
|
175
|
+
* Created by the WebSocket `open` handler in `http-server.ts` and destroyed on
|
|
176
|
+
* `stop`, client disconnect, idle timeout, or runtime shutdown. Whichever of
|
|
177
|
+
* those arrives first, the manager slot is released exactly once.
|
|
178
|
+
*/
|
|
179
|
+
export class WatchStreamSession {
|
|
180
|
+
private state: SessionState = "initializing";
|
|
181
|
+
private transcriber: StreamingTranscriber | null = null;
|
|
182
|
+
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
183
|
+
/**
|
|
184
|
+
* Whether this socket is the one holding the manager's slot. A socket that
|
|
185
|
+
* was turned away as busy must never stop the session that turned it away.
|
|
186
|
+
*/
|
|
187
|
+
private ownsManagerSession = false;
|
|
188
|
+
|
|
189
|
+
private readonly ws: WatchStreamSocket;
|
|
190
|
+
private readonly options: WatchStreamSessionOptions;
|
|
191
|
+
private readonly manager: WatchSessionManager;
|
|
192
|
+
private readonly idleTimeoutMs: number;
|
|
193
|
+
|
|
194
|
+
constructor(ws: WatchStreamSocket, options: WatchStreamSessionOptions) {
|
|
195
|
+
this.ws = ws;
|
|
196
|
+
this.options = options;
|
|
197
|
+
this.manager = options.manager ?? getWatchSessionManager();
|
|
198
|
+
this.idleTimeoutMs = options.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Whether the session has reached its terminal state. */
|
|
202
|
+
get isClosed(): boolean {
|
|
203
|
+
return this.state === "closed";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Startup ────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve the actor, open the provider stream, and claim the session slot.
|
|
210
|
+
*
|
|
211
|
+
* The actor comes first because it is the binding everything else depends
|
|
212
|
+
* on: `observeHostScreen` reaches only that actor's own desktop clients, and
|
|
213
|
+
* a session started without one could record nothing but failures. Failing
|
|
214
|
+
* here sends `error` then `closed` rather than opening a session that cannot
|
|
215
|
+
* see anything.
|
|
216
|
+
*/
|
|
217
|
+
async start(): Promise<void> {
|
|
218
|
+
if (this.state !== "initializing") {
|
|
219
|
+
log.warn(
|
|
220
|
+
{ state: this.state },
|
|
221
|
+
"Watch stream start in non-initial state",
|
|
222
|
+
);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (watchIngressClosed) {
|
|
227
|
+
this.failStart(
|
|
228
|
+
"session-error",
|
|
229
|
+
"The assistant is shutting down and is not starting new watch sessions.",
|
|
230
|
+
1001,
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const sourceActorPrincipalId = await this.resolveActorPrincipalId();
|
|
237
|
+
if (this.isClosed) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!sourceActorPrincipalId) {
|
|
241
|
+
this.failStart(
|
|
242
|
+
"session-error",
|
|
243
|
+
"Watch could not resolve the actor to observe for. Sign in on this device and try again.",
|
|
244
|
+
1008,
|
|
245
|
+
);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const transcriber = await this.resolveTranscriber();
|
|
250
|
+
|
|
251
|
+
// The socket can close while either resolution is in flight. Read the
|
|
252
|
+
// terminal state through the getter so the compiler does not narrow it
|
|
253
|
+
// to the value it held before the await.
|
|
254
|
+
if (this.isClosed) {
|
|
255
|
+
stopQuietly(transcriber);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!transcriber) {
|
|
260
|
+
this.failStart(
|
|
261
|
+
"provider-error",
|
|
262
|
+
"Watch needs a speech provider that supports streaming transcription.",
|
|
263
|
+
1000,
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.transcriber = transcriber;
|
|
269
|
+
await transcriber.start((event) => {
|
|
270
|
+
this.handleTranscriberEvent(event);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (this.isClosed) {
|
|
274
|
+
stopQuietly(transcriber);
|
|
275
|
+
this.transcriber = null;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const started = this.manager.start({
|
|
280
|
+
sourceActorPrincipalId,
|
|
281
|
+
onObservation: () => {
|
|
282
|
+
this.handleObservation();
|
|
283
|
+
},
|
|
284
|
+
...(this.options.conversationId
|
|
285
|
+
? { conversationId: this.options.conversationId }
|
|
286
|
+
: {}),
|
|
287
|
+
...(this.options.clientId ? { clientId: this.options.clientId } : {}),
|
|
288
|
+
});
|
|
289
|
+
if (started.status !== "started") {
|
|
290
|
+
this.failStart(
|
|
291
|
+
"session-error",
|
|
292
|
+
started.status === "busy"
|
|
293
|
+
? "A watch session is already running."
|
|
294
|
+
: started.reason,
|
|
295
|
+
1000,
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.ownsManagerSession = true;
|
|
300
|
+
|
|
301
|
+
this.state = "active";
|
|
302
|
+
this.resetIdleTimer();
|
|
303
|
+
this.sendFrame({
|
|
304
|
+
type: "ready",
|
|
305
|
+
sessionId: started.sessionId,
|
|
306
|
+
conversationId: started.conversationId,
|
|
307
|
+
});
|
|
308
|
+
log.info(
|
|
309
|
+
{
|
|
310
|
+
sessionId: started.sessionId,
|
|
311
|
+
conversationId: started.conversationId,
|
|
312
|
+
provider: transcriber.providerId,
|
|
313
|
+
},
|
|
314
|
+
"Watch stream session started",
|
|
315
|
+
);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
318
|
+
log.error({ error: message }, "Failed to start watch stream session");
|
|
319
|
+
this.failStart("provider-error", message, 1011);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Inbound frames ─────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/** Handle a text frame: a base64 `audio` event or `stop`. */
|
|
326
|
+
handleMessage(raw: string): void {
|
|
327
|
+
if (this.state === "closed") {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.resetIdleTimer();
|
|
331
|
+
|
|
332
|
+
let parsed: unknown;
|
|
333
|
+
try {
|
|
334
|
+
parsed = JSON.parse(raw);
|
|
335
|
+
} catch {
|
|
336
|
+
log.debug("Watch stream: dropped non-JSON text frame");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!parsed || typeof parsed !== "object") {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const event = parsed as {
|
|
344
|
+
type?: string;
|
|
345
|
+
audio?: string;
|
|
346
|
+
mimeType?: string;
|
|
347
|
+
};
|
|
348
|
+
switch (event.type) {
|
|
349
|
+
case "audio": {
|
|
350
|
+
if (this.state !== "active" || typeof event.audio !== "string") {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
this.transcriber?.sendAudio(
|
|
354
|
+
Buffer.from(event.audio, "base64"),
|
|
355
|
+
event.mimeType ?? this.options.mimeType,
|
|
356
|
+
);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
case "stop": {
|
|
360
|
+
this.handleStop();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
default: {
|
|
364
|
+
log.debug({ type: event.type }, "Watch stream: dropped unknown event");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Handle a binary frame: raw audio bytes. */
|
|
371
|
+
handleBinaryAudio(data: Buffer | ArrayBuffer | Uint8Array): void {
|
|
372
|
+
if (this.state !== "active") {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
this.resetIdleTimer();
|
|
376
|
+
|
|
377
|
+
const buffer = Buffer.isBuffer(data)
|
|
378
|
+
? data
|
|
379
|
+
: Buffer.from(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
|
|
380
|
+
this.transcriber?.sendAudio(buffer, this.options.mimeType);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The client disconnected, or the transport failed. */
|
|
384
|
+
handleClose(code: number, reason?: string): void {
|
|
385
|
+
if (this.state === "closed") {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
log.info({ code, reason }, "Watch stream WebSocket closed");
|
|
389
|
+
this.teardown();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Forcible teardown, for runtime shutdown.
|
|
394
|
+
*
|
|
395
|
+
* No retrospective. A retro is a full agent turn that runs for as long as the
|
|
396
|
+
* model takes, and the process behind it is on its way out: started here it
|
|
397
|
+
* would be killed partway through, leaving a half-written report in the
|
|
398
|
+
* thread. Skipping keeps the timeline, which is the whole of what the
|
|
399
|
+
* session recorded and outlives the daemon.
|
|
400
|
+
*/
|
|
401
|
+
destroy(): void {
|
|
402
|
+
if (this.state === "closed") {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
log.info("Watch stream session destroyed");
|
|
406
|
+
this.teardown({ retrospective: false });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Internals ──────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
private async resolveActorPrincipalId(): Promise<string | undefined> {
|
|
412
|
+
if (this.options.resolveActorPrincipalId) {
|
|
413
|
+
return this.options.resolveActorPrincipalId();
|
|
414
|
+
}
|
|
415
|
+
return resolveWatchActorPrincipalId();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private async resolveTranscriber(): Promise<StreamingTranscriber | null> {
|
|
419
|
+
if (this.options.resolveTranscriber) {
|
|
420
|
+
return this.options.resolveTranscriber();
|
|
421
|
+
}
|
|
422
|
+
const { resolveStreamingTranscriber } =
|
|
423
|
+
await import("../../providers/speech-to-text/resolve.js");
|
|
424
|
+
return resolveStreamingTranscriber(
|
|
425
|
+
this.options.sampleRate !== undefined
|
|
426
|
+
? { sampleRate: this.options.sampleRate }
|
|
427
|
+
: {},
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Report why the session never opened, then close. The teardown between the
|
|
433
|
+
* two stops a transcriber that opened before the failing step, and emits the
|
|
434
|
+
* terminal `closed` frame.
|
|
435
|
+
*/
|
|
436
|
+
private failStart(
|
|
437
|
+
category: WatchStreamErrorCategory,
|
|
438
|
+
message: string,
|
|
439
|
+
closeCode: number,
|
|
440
|
+
): void {
|
|
441
|
+
this.sendFrame({ type: "error", category, message });
|
|
442
|
+
this.teardown();
|
|
443
|
+
this.closeSocket(closeCode, "watch session start failed");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The client finished narrating. The provider may still emit finals after
|
|
448
|
+
* `stop()`, so the session waits for the provider's `closed` rather than
|
|
449
|
+
* tearing down here.
|
|
450
|
+
*/
|
|
451
|
+
private handleStop(): void {
|
|
452
|
+
if (this.state !== "active") {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
this.state = "stopping";
|
|
456
|
+
this.clearIdleTimer();
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
this.transcriber?.stop();
|
|
460
|
+
} catch (err) {
|
|
461
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
462
|
+
log.error({ error: message }, "Error stopping the watch transcriber");
|
|
463
|
+
this.sendFrame({ type: "error", category: "provider-error", message });
|
|
464
|
+
this.teardown();
|
|
465
|
+
this.closeSocket(1011, "stop failed");
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* A screen read landed on the timeline, so tell the client.
|
|
471
|
+
*
|
|
472
|
+
* The manager only calls this for a read that came back and was kept, so
|
|
473
|
+
* everything the session does with the news is send it: a failed, timed-out,
|
|
474
|
+
* or cancelled read never reaches here (see `WatchSessionStartOptions`).
|
|
475
|
+
*
|
|
476
|
+
* The terminal check is the socket's own. A read dispatched moments before
|
|
477
|
+
* teardown is dropped by the manager's `stopped` guard, so this is guarding
|
|
478
|
+
* the narrower case of a listener that outlived the session it was passed
|
|
479
|
+
* with, and it keeps the frame order the client's contract: nothing after
|
|
480
|
+
* `closed`.
|
|
481
|
+
*/
|
|
482
|
+
private handleObservation(): void {
|
|
483
|
+
if (this.state === "closed") {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
this.sendFrame({ type: "observation" });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private handleTranscriberEvent(event: SttStreamServerEvent): void {
|
|
490
|
+
if (this.state === "closed") {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (event.type === "turn-start") {
|
|
495
|
+
// Onset, not text. Observing here catches the screen the user is about
|
|
496
|
+
// to describe rather than the one their sentence left behind; the
|
|
497
|
+
// narration itself is filed by the `final` below. Fire and forget for
|
|
498
|
+
// the same reason as that one, and no `entry` frame is sent because no
|
|
499
|
+
// narration was appended; the read this triggers announces itself
|
|
500
|
+
// through `handleObservation` if it lands.
|
|
501
|
+
void this.manager.handleNarrationStart().catch((err: unknown) => {
|
|
502
|
+
log.warn({ err }, "Watch narration-start observation threw");
|
|
503
|
+
});
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (event.type === "final") {
|
|
508
|
+
const text = event.text.trim();
|
|
509
|
+
if (!text) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
// Fire and forget: the narration is filed synchronously inside
|
|
513
|
+
// `handleNarrationFinal`, and what remains is the screen read, which the
|
|
514
|
+
// manager already owns the failure handling for.
|
|
515
|
+
void this.manager.handleNarrationFinal(text).catch((err: unknown) => {
|
|
516
|
+
log.warn({ err }, "Watch narration append threw");
|
|
517
|
+
});
|
|
518
|
+
this.sendFrame({ type: "entry" });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (event.type === "error") {
|
|
523
|
+
this.sendFrame({
|
|
524
|
+
type: "error",
|
|
525
|
+
category: event.category,
|
|
526
|
+
message: event.message,
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (event.type === "closed") {
|
|
532
|
+
this.teardown();
|
|
533
|
+
this.closeSocket(1000, "session complete");
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── Idle timer ─────────────────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
private resetIdleTimer(): void {
|
|
540
|
+
this.clearIdleTimer();
|
|
541
|
+
if (this.state === "closed" || this.state === "stopping") {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
this.idleTimer = setTimeout(() => {
|
|
546
|
+
if (this.state === "closed") {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
log.warn("Watch stream session idle timeout");
|
|
550
|
+
this.sendFrame({
|
|
551
|
+
type: "error",
|
|
552
|
+
category: "timeout",
|
|
553
|
+
message: "The watch session timed out because the client went quiet.",
|
|
554
|
+
});
|
|
555
|
+
this.teardown();
|
|
556
|
+
this.closeSocket(1000, "idle timeout");
|
|
557
|
+
}, this.idleTimeoutMs);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private clearIdleTimer(): void {
|
|
561
|
+
if (this.idleTimer !== null) {
|
|
562
|
+
clearTimeout(this.idleTimer);
|
|
563
|
+
this.idleTimer = null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── Teardown ───────────────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Release everything the session holds and send the terminal `closed` frame.
|
|
571
|
+
* Idempotent: the close handler, the idle timer, and the provider's own
|
|
572
|
+
* `closed` all land here, and only the first one does any work.
|
|
573
|
+
*/
|
|
574
|
+
private teardown(
|
|
575
|
+
options: { retrospective: boolean } = { retrospective: true },
|
|
576
|
+
): void {
|
|
577
|
+
if (this.state === "closed") {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
this.state = "closed";
|
|
581
|
+
this.clearIdleTimer();
|
|
582
|
+
|
|
583
|
+
if (this.transcriber) {
|
|
584
|
+
stopQuietly(this.transcriber);
|
|
585
|
+
this.transcriber = null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (this.ownsManagerSession) {
|
|
589
|
+
this.ownsManagerSession = false;
|
|
590
|
+
// A screen read still in flight is dropped by the manager's `stopped`
|
|
591
|
+
// guard rather than awaited. Narration itself survives, because
|
|
592
|
+
// `handleNarrationFinal` files it synchronously before it observes, so
|
|
593
|
+
// what is lost is at most one trailing frame of a session that is over.
|
|
594
|
+
const summary = this.manager.stop();
|
|
595
|
+
if (summary) {
|
|
596
|
+
log.info(
|
|
597
|
+
{
|
|
598
|
+
sessionId: summary.sessionId,
|
|
599
|
+
conversationId: summary.conversationId,
|
|
600
|
+
entryCount: summary.entryCount,
|
|
601
|
+
durationMs: summary.durationMs,
|
|
602
|
+
},
|
|
603
|
+
"Watch session ended",
|
|
604
|
+
);
|
|
605
|
+
if (options.retrospective) {
|
|
606
|
+
this.startRetrospective(summary);
|
|
607
|
+
} else {
|
|
608
|
+
log.info(
|
|
609
|
+
{ sessionId: summary.sessionId },
|
|
610
|
+
"Watch session ended during shutdown; skipping the retrospective",
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
this.sendFrame({ type: "closed" });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Start the retrospective and register it so shutdown can wait on it.
|
|
621
|
+
*
|
|
622
|
+
* The turn is a conversation that outlives the socket by minutes, so
|
|
623
|
+
* teardown starts it rather than blocking on it, and it owns its own
|
|
624
|
+
* failures. Registration is what stops a stop-then-quit from killing a turn
|
|
625
|
+
* mid-generation: {@link drainWatchRetros} gives one already in flight a
|
|
626
|
+
* bounded chance to finish.
|
|
627
|
+
*/
|
|
628
|
+
private startRetrospective(summary: WatchSessionSummary): void {
|
|
629
|
+
const runRetro = this.options.runRetro ?? runWatchRetro;
|
|
630
|
+
const pending = runRetro(summary).catch((err: unknown) => {
|
|
631
|
+
log.warn(
|
|
632
|
+
{ err, sessionId: summary.sessionId },
|
|
633
|
+
"Watch retrospective threw",
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
inFlightWatchRetros.add(pending);
|
|
637
|
+
void pending.finally(() => {
|
|
638
|
+
inFlightWatchRetros.delete(pending);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private sendFrame(frame: WatchStreamServerFrame): void {
|
|
643
|
+
try {
|
|
644
|
+
this.ws.send(JSON.stringify(frame));
|
|
645
|
+
} catch (err) {
|
|
646
|
+
log.debug({ err }, "Watch stream: failed to send a frame");
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private closeSocket(code: number, reason: string): void {
|
|
651
|
+
try {
|
|
652
|
+
this.ws.close(code, reason);
|
|
653
|
+
} catch {
|
|
654
|
+
// Already closed.
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Stop a transcriber that may already be gone. */
|
|
660
|
+
function stopQuietly(transcriber: StreamingTranscriber | null): void {
|
|
661
|
+
if (!transcriber) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
transcriber.stop();
|
|
666
|
+
} catch {
|
|
667
|
+
// Best effort.
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* The actor a watch session observes for: the vellum guardian bound to this
|
|
673
|
+
* daemon, read from the gateway-owned binding.
|
|
674
|
+
*
|
|
675
|
+
* The principal comes from that binding rather than from anything the request
|
|
676
|
+
* carries, because the request cannot carry a trustworthy one. The gateway
|
|
677
|
+
* authenticates the downstream client's edge JWT and then dials the runtime on
|
|
678
|
+
* a fresh socket bearing only its own service token
|
|
679
|
+
* (`gateway/src/http/routes/stt-stream-websocket.ts`), so an actor claim on
|
|
680
|
+
* the upgrade is never the gateway's word about who the client is. Honouring
|
|
681
|
+
* one would let any caller holding the service token bind a session, and the
|
|
682
|
+
* screen reads it drives, to somebody else's principal.
|
|
683
|
+
*
|
|
684
|
+
* It is the same binding `resolveActorPrincipalIdForLocalGuardian` falls
|
|
685
|
+
* through to and the same one `live-voice-session.ts` stamps its turns with,
|
|
686
|
+
* so a watch session observes the principal the host-proxy result routes match
|
|
687
|
+
* a desktop client against.
|
|
688
|
+
*
|
|
689
|
+
* The read deliberately bypasses the guardian-delivery cache. That cache keeps
|
|
690
|
+
* a successful read that found no binding, and a gateway-side binding write
|
|
691
|
+
* does not invalidate it, so a guardian bound after the daemon cached an empty
|
|
692
|
+
* answer would leave every Watch press failing the unresolvable-principal path
|
|
693
|
+
* until the TTL lapsed. That is the order of events on a first run, and it
|
|
694
|
+
* fails looking like a broken feature rather than one that is not ready yet. A
|
|
695
|
+
* session starts only when a person asks for one, so a fresh read costs
|
|
696
|
+
* nothing worth weighing against that.
|
|
697
|
+
*/
|
|
698
|
+
export async function resolveWatchActorPrincipalId(): Promise<
|
|
699
|
+
string | undefined
|
|
700
|
+
> {
|
|
701
|
+
const { findLocalGuardianPrincipalId } =
|
|
702
|
+
await import("../local-actor-identity.js");
|
|
703
|
+
return findLocalGuardianPrincipalId({ forceRefresh: true });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
// Active session registry
|
|
708
|
+
// ---------------------------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Open watch sessions keyed by socket session id, so runtime shutdown can tear
|
|
712
|
+
* them all down deterministically. Mirrors `activeSttStreamSessions`.
|
|
713
|
+
*/
|
|
714
|
+
export const activeWatchStreamSessions = new Map<string, WatchStreamSession>();
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Retrospectives that have started and not yet settled.
|
|
718
|
+
*
|
|
719
|
+
* A retro is dispatched from a teardown that cannot wait on it, so without a
|
|
720
|
+
* handle a socket closing seconds before shutdown leaves a turn running
|
|
721
|
+
* against a database that is about to be closed underneath it.
|
|
722
|
+
*/
|
|
723
|
+
const inFlightWatchRetros = new Set<Promise<unknown>>();
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Whether new watch sessions are being refused.
|
|
727
|
+
*
|
|
728
|
+
* Shutdown tears down the sessions it can see and then waits on the
|
|
729
|
+
* retrospectives they left running, and the Bun server keeps accepting
|
|
730
|
+
* connections until well after both. Without this latch a socket that opens
|
|
731
|
+
* and closes inside that window registers a retrospective nobody is waiting
|
|
732
|
+
* on, which is the turn the drain exists to protect.
|
|
733
|
+
*
|
|
734
|
+
* A latch here rather than a shared one because the daemon has no shutdown
|
|
735
|
+
* state a route can read: `shutdown-handlers.ts` keeps its flag module-private
|
|
736
|
+
* and process-wide, and the readiness module tracks migrations rather than
|
|
737
|
+
* teardown.
|
|
738
|
+
*/
|
|
739
|
+
let watchIngressClosed = false;
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Refuse new watch sessions, the first step of shutting the surface down.
|
|
743
|
+
*
|
|
744
|
+
* Separate from tearing the open sessions down so the order can be ingress
|
|
745
|
+
* first, sessions second, retrospectives last. A session that arrives after
|
|
746
|
+
* this fails its start with a clean error frame rather than opening and being
|
|
747
|
+
* killed moments later.
|
|
748
|
+
*/
|
|
749
|
+
export function closeWatchIngress(): void {
|
|
750
|
+
watchIngressClosed = true;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Accept watch sessions again. For tests, which share a module instance. */
|
|
754
|
+
export function reopenWatchIngressForTest(): void {
|
|
755
|
+
watchIngressClosed = false;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Longest shutdown waits for retrospectives already under way.
|
|
760
|
+
*
|
|
761
|
+
* Shutdown's other awaited step is releasing the live-voice session, which is
|
|
762
|
+
* a handful of socket closes, so there is no established budget to borrow. A
|
|
763
|
+
* retro is a model call and can legitimately take longer than any shutdown
|
|
764
|
+
* should, which is why one is never started during shutdown; this bound is for
|
|
765
|
+
* the turn that was already running when the user quit, and it is short enough
|
|
766
|
+
* that quitting stays a quick action.
|
|
767
|
+
*/
|
|
768
|
+
const RETRO_DRAIN_TIMEOUT_MS = 5_000;
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Wait for in-flight retrospectives, up to {@link RETRO_DRAIN_TIMEOUT_MS}, and
|
|
772
|
+
* report how many were still running when the wait ended.
|
|
773
|
+
*
|
|
774
|
+
* Resolves rather than rejects on the timeout: a retro that is still going is
|
|
775
|
+
* a turn that will be cut off, which is worth a log line and never a reason to
|
|
776
|
+
* fail the shutdown that is cutting it off. A settled one is forgotten, so the
|
|
777
|
+
* registry tracks what is running rather than everything that ever ran.
|
|
778
|
+
*/
|
|
779
|
+
export async function drainWatchRetros(
|
|
780
|
+
timeoutMs: number = RETRO_DRAIN_TIMEOUT_MS,
|
|
781
|
+
): Promise<number> {
|
|
782
|
+
if (inFlightWatchRetros.size === 0) {
|
|
783
|
+
return 0;
|
|
784
|
+
}
|
|
785
|
+
log.info(
|
|
786
|
+
{ count: inFlightWatchRetros.size },
|
|
787
|
+
"Waiting for watch retrospectives to settle",
|
|
788
|
+
);
|
|
789
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
790
|
+
const deadline = new Promise<void>((resolve) => {
|
|
791
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
792
|
+
timer.unref?.();
|
|
793
|
+
});
|
|
794
|
+
await Promise.race([
|
|
795
|
+
Promise.allSettled([...inFlightWatchRetros]).then(() => undefined),
|
|
796
|
+
deadline,
|
|
797
|
+
]);
|
|
798
|
+
clearTimeout(timer);
|
|
799
|
+
const unsettled = inFlightWatchRetros.size;
|
|
800
|
+
if (unsettled > 0) {
|
|
801
|
+
log.warn(
|
|
802
|
+
{ count: unsettled },
|
|
803
|
+
"Shutting down with watch retrospectives still running",
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
return unsettled;
|
|
807
|
+
}
|