@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,111 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
decodeJsonSafeOAuthBody,
|
|
5
|
+
decodeOAuthResponseBytes,
|
|
6
|
+
jsonSafeOAuthBody,
|
|
7
|
+
materializeOAuthRequestOutput,
|
|
8
|
+
} from "./connection.js";
|
|
9
|
+
|
|
10
|
+
const PNG_MAGIC = Buffer.from([
|
|
11
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00,
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
describe("decodeOAuthResponseBytes", () => {
|
|
15
|
+
test("parses JSON bodies", () => {
|
|
16
|
+
const raw = Buffer.from(JSON.stringify({ id: "file-123", name: "Doc" }));
|
|
17
|
+
expect(
|
|
18
|
+
decodeOAuthResponseBytes(raw, "application/json; charset=utf-8"),
|
|
19
|
+
).toEqual({ id: "file-123", name: "Doc" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns UTF-8 text for text content types", () => {
|
|
23
|
+
const raw = Buffer.from("hello café", "utf8");
|
|
24
|
+
expect(decodeOAuthResponseBytes(raw, "text/plain; charset=utf-8")).toBe(
|
|
25
|
+
"hello café",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("preserves non-ASCII binary bytes instead of UTF-8 replacement characters", () => {
|
|
30
|
+
const body = decodeOAuthResponseBytes(
|
|
31
|
+
PNG_MAGIC,
|
|
32
|
+
"application/octet-stream",
|
|
33
|
+
);
|
|
34
|
+
expect(Buffer.isBuffer(body)).toBe(true);
|
|
35
|
+
expect(Buffer.from(body as Uint8Array).equals(PNG_MAGIC)).toBe(true);
|
|
36
|
+
expect(Buffer.from(body as Uint8Array).includes(0xff)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("treats Google Drive media downloads as raw bytes", () => {
|
|
40
|
+
const body = decodeOAuthResponseBytes(
|
|
41
|
+
PNG_MAGIC,
|
|
42
|
+
"application/pdf",
|
|
43
|
+
);
|
|
44
|
+
expect(Buffer.from(body as Uint8Array).equals(PNG_MAGIC)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns null for an empty body", () => {
|
|
48
|
+
expect(decodeOAuthResponseBytes(Buffer.alloc(0), "application/json")).toBe(
|
|
49
|
+
null,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("jsonSafeOAuthBody", () => {
|
|
55
|
+
test("leaves JSON and text bodies unchanged", () => {
|
|
56
|
+
expect(jsonSafeOAuthBody({ ok: true })).toEqual({ body: { ok: true } });
|
|
57
|
+
expect(jsonSafeOAuthBody("hello")).toEqual({ body: "hello" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("base64-encodes binary buffers for JSON envelopes", () => {
|
|
61
|
+
expect(jsonSafeOAuthBody(PNG_MAGIC)).toEqual({
|
|
62
|
+
body: PNG_MAGIC.toString("base64"),
|
|
63
|
+
bodyEncoding: "base64",
|
|
64
|
+
});
|
|
65
|
+
expect(Buffer.from(PNG_MAGIC.toString("base64"), "base64").equals(PNG_MAGIC)).toBe(
|
|
66
|
+
true,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("decodeJsonSafeOAuthBody", () => {
|
|
72
|
+
test("round-trips a binary envelope back to the original bytes", () => {
|
|
73
|
+
const encoded = jsonSafeOAuthBody(PNG_MAGIC);
|
|
74
|
+
const decoded = decodeJsonSafeOAuthBody(encoded);
|
|
75
|
+
expect(Buffer.isBuffer(decoded)).toBe(true);
|
|
76
|
+
expect(Buffer.from(decoded as Uint8Array).equals(PNG_MAGIC)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("leaves JSON and text envelopes unchanged", () => {
|
|
80
|
+
expect(decodeJsonSafeOAuthBody({ body: { ok: true } })).toEqual({
|
|
81
|
+
ok: true,
|
|
82
|
+
});
|
|
83
|
+
expect(decodeJsonSafeOAuthBody({ body: "hello" })).toBe("hello");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects a base64 marker whose body is not a string", () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
decodeJsonSafeOAuthBody({ body: { ok: true }, bodyEncoding: "base64" }),
|
|
89
|
+
).toThrow("bodyEncoding=base64");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("materializeOAuthRequestOutput", () => {
|
|
94
|
+
test("writes raw bytes for a base64-marked envelope", () => {
|
|
95
|
+
const output = materializeOAuthRequestOutput(jsonSafeOAuthBody(PNG_MAGIC));
|
|
96
|
+
expect(output?.isBinary).toBe(true);
|
|
97
|
+
expect(output?.bytes.equals(PNG_MAGIC)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("pretty-prints parsed JSON and marks it as text", () => {
|
|
101
|
+
const output = materializeOAuthRequestOutput({ body: { ok: true } });
|
|
102
|
+
expect(output?.isBinary).toBe(false);
|
|
103
|
+
expect(output?.bytes.toString("utf8")).toBe(
|
|
104
|
+
JSON.stringify({ ok: true }, null, 2),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("returns null for a missing body", () => {
|
|
109
|
+
expect(materializeOAuthRequestOutput({ body: null })).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
package/src/oauth/connection.ts
CHANGED
|
@@ -3,7 +3,12 @@ export interface OAuthConnectionRequest {
|
|
|
3
3
|
path: string; // relative, e.g. "/2/tweets"
|
|
4
4
|
query?: Record<string, string | string[]>;
|
|
5
5
|
headers?: Record<string, string>;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* A string is forwarded to the provider verbatim under the caller's own
|
|
8
|
+
* `Content-Type` (multipart, XML, form-encoded). Anything else is
|
|
9
|
+
* JSON-serialized and sent as `application/json`.
|
|
10
|
+
*/
|
|
11
|
+
body?: unknown;
|
|
7
12
|
/**
|
|
8
13
|
* Override the connection's default base URL for this request.
|
|
9
14
|
* Required for providers that span multiple API hosts sharing
|
|
@@ -18,7 +23,143 @@ export interface OAuthConnectionRequest {
|
|
|
18
23
|
export interface OAuthConnectionResponse {
|
|
19
24
|
status: number;
|
|
20
25
|
headers: Record<string, string>;
|
|
26
|
+
/** JSON, UTF-8 text, or a Buffer of raw bytes for binary payloads. */
|
|
27
|
+
body: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TEXT_MEDIA_TYPES = new Set([
|
|
31
|
+
"application/json",
|
|
32
|
+
"application/ld+json",
|
|
33
|
+
"application/xml",
|
|
34
|
+
"text/xml",
|
|
35
|
+
"application/javascript",
|
|
36
|
+
"application/x-www-form-urlencoded",
|
|
37
|
+
"application/problem+json",
|
|
38
|
+
"application/xhtml+xml",
|
|
39
|
+
"application/graphql",
|
|
40
|
+
"application/graphql+json",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function mediaTypeOf(contentType: string): string {
|
|
44
|
+
return contentType.split(";", 1)[0].trim().toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isTextMediaType(mediaType: string): boolean {
|
|
48
|
+
if (!mediaType) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (mediaType.startsWith("text/")) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (TEXT_MEDIA_TYPES.has(mediaType)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return mediaType.endsWith("+json") || mediaType.endsWith("+xml");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decode an HTTP body into JSON, UTF-8 text, or a raw Buffer.
|
|
62
|
+
*
|
|
63
|
+
* Binary payloads (Google Drive `?alt=media`, images, PDFs) stay as bytes.
|
|
64
|
+
* JSON and text content types keep their existing parsed/string behavior.
|
|
65
|
+
*/
|
|
66
|
+
export function decodeOAuthResponseBytes(
|
|
67
|
+
raw: Uint8Array,
|
|
68
|
+
contentType: string,
|
|
69
|
+
): unknown {
|
|
70
|
+
if (raw.byteLength === 0) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const mediaType = mediaTypeOf(contentType);
|
|
75
|
+
let utf8: string | undefined;
|
|
76
|
+
try {
|
|
77
|
+
utf8 = new TextDecoder("utf-8", { fatal: true }).decode(raw);
|
|
78
|
+
} catch {
|
|
79
|
+
utf8 = undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (utf8 !== undefined) {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(utf8) as unknown;
|
|
85
|
+
} catch {
|
|
86
|
+
if (!mediaType || isTextMediaType(mediaType)) {
|
|
87
|
+
return utf8;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (isTextMediaType(mediaType)) {
|
|
93
|
+
return new TextDecoder("utf-8").decode(raw);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Buffer.from(raw);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isBinaryOAuthBody(body: unknown): body is Uint8Array {
|
|
100
|
+
return body instanceof Uint8Array;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* JSON-safe form of an OAuth response body. Binary buffers become a base64
|
|
105
|
+
* string plus `bodyEncoding: "base64"` so CLI/HTTP JSON envelopes can round
|
|
106
|
+
* trip the original bytes.
|
|
107
|
+
*/
|
|
108
|
+
export function jsonSafeOAuthBody(body: unknown): {
|
|
109
|
+
body: unknown;
|
|
110
|
+
bodyEncoding?: "base64";
|
|
111
|
+
} {
|
|
112
|
+
if (isBinaryOAuthBody(body)) {
|
|
113
|
+
return {
|
|
114
|
+
body: Buffer.from(body).toString("base64"),
|
|
115
|
+
bodyEncoding: "base64",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return { body };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Inverse of {@link jsonSafeOAuthBody}. Restores a `bodyEncoding: "base64"`
|
|
123
|
+
* envelope to a Buffer so callers can write the original bytes.
|
|
124
|
+
*/
|
|
125
|
+
export function decodeJsonSafeOAuthBody(envelope: {
|
|
126
|
+
body: unknown;
|
|
127
|
+
bodyEncoding?: string | null;
|
|
128
|
+
}): unknown {
|
|
129
|
+
if (envelope.bodyEncoding === "base64") {
|
|
130
|
+
if (typeof envelope.body !== "string") {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"OAuth response marked bodyEncoding=base64 but body is not a string",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return Buffer.from(envelope.body, "base64");
|
|
136
|
+
}
|
|
137
|
+
return envelope.body;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Bytes to write for a CLI `oauth request` body. Binary envelopes become
|
|
142
|
+
* raw bytes. Text stays UTF-8. Parsed JSON is pretty-printed. Null bodies
|
|
143
|
+
* produce no output.
|
|
144
|
+
*/
|
|
145
|
+
export function materializeOAuthRequestOutput(envelope: {
|
|
21
146
|
body: unknown;
|
|
147
|
+
bodyEncoding?: string | null;
|
|
148
|
+
}): { bytes: Buffer; isBinary: boolean } | null {
|
|
149
|
+
const decoded = decodeJsonSafeOAuthBody(envelope);
|
|
150
|
+
if (decoded == null) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (isBinaryOAuthBody(decoded)) {
|
|
154
|
+
return { bytes: Buffer.from(decoded), isBinary: true };
|
|
155
|
+
}
|
|
156
|
+
if (typeof decoded === "string") {
|
|
157
|
+
return { bytes: Buffer.from(decoded, "utf8"), isBinary: false };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
bytes: Buffer.from(JSON.stringify(decoded, null, 2), "utf8"),
|
|
161
|
+
isBinary: false,
|
|
162
|
+
};
|
|
22
163
|
}
|
|
23
164
|
|
|
24
165
|
export interface OAuthConnection {
|
|
@@ -102,6 +102,40 @@ describe("PlatformOAuthConnection", () => {
|
|
|
102
102
|
expect(result.body).toEqual(upstreamBody);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
test("decodes base64 binary proxy bodies into a Buffer", async () => {
|
|
106
|
+
const binary = Buffer.from([
|
|
107
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00,
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const client = makeMockClient(
|
|
111
|
+
mock(async () => {
|
|
112
|
+
return new Response(
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
status: 200,
|
|
115
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
116
|
+
body: binary.toString("base64"),
|
|
117
|
+
body_encoding: "base64",
|
|
118
|
+
}),
|
|
119
|
+
{ status: 200 },
|
|
120
|
+
);
|
|
121
|
+
}) as unknown as typeof globalThis.fetch,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const conn = new PlatformOAuthConnection({
|
|
125
|
+
...DEFAULT_OPTIONS,
|
|
126
|
+
client,
|
|
127
|
+
});
|
|
128
|
+
const result = await conn.request({
|
|
129
|
+
method: "GET",
|
|
130
|
+
path: "/drive/v3/files/file-123",
|
|
131
|
+
query: { alt: "media" },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.status).toBe(200);
|
|
135
|
+
expect(Buffer.isBuffer(result.body)).toBe(true);
|
|
136
|
+
expect(Buffer.from(result.body as Uint8Array).equals(binary)).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
105
139
|
test("forwards per-request baseUrl when provided", async () => {
|
|
106
140
|
const client = makeMockClient(
|
|
107
141
|
mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
@@ -82,6 +82,10 @@ export class PlatformOAuthConnection implements OAuthConnection {
|
|
|
82
82
|
async request(req: OAuthConnectionRequest): Promise<OAuthConnectionResponse> {
|
|
83
83
|
const proxyPath = `/v1/assistants/${this.client.platformAssistantId}/external-provider-proxy/${this.connectionId}/`;
|
|
84
84
|
|
|
85
|
+
// The envelope carries the caller's headers and body side by side. A
|
|
86
|
+
// string body is placed in the envelope as a string, so the proxy forwards
|
|
87
|
+
// those bytes verbatim under the caller's Content-Type; an object body
|
|
88
|
+
// travels as JSON and the proxy serializes it.
|
|
85
89
|
const body: Record<string, unknown> = {
|
|
86
90
|
request: {
|
|
87
91
|
method: req.method,
|
|
@@ -143,13 +147,10 @@ export class PlatformOAuthConnection implements OAuthConnection {
|
|
|
143
147
|
status: number;
|
|
144
148
|
headers: Record<string, string>;
|
|
145
149
|
body: unknown;
|
|
150
|
+
body_encoding?: string | null;
|
|
146
151
|
};
|
|
147
152
|
|
|
148
|
-
return
|
|
149
|
-
status: json.status,
|
|
150
|
-
headers: json.headers,
|
|
151
|
-
body: json.body,
|
|
152
|
-
};
|
|
153
|
+
return decodePlatformProxyEnvelope(json);
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
throw new BackendError("Platform proxy request failed after retries");
|
|
@@ -161,3 +162,25 @@ export class PlatformOAuthConnection implements OAuthConnection {
|
|
|
161
162
|
);
|
|
162
163
|
}
|
|
163
164
|
}
|
|
165
|
+
|
|
166
|
+
function decodePlatformProxyEnvelope(json: {
|
|
167
|
+
status: number;
|
|
168
|
+
headers: Record<string, string>;
|
|
169
|
+
body: unknown;
|
|
170
|
+
body_encoding?: string | null;
|
|
171
|
+
}): OAuthConnectionResponse {
|
|
172
|
+
let body = json.body;
|
|
173
|
+
if (json.body_encoding === "base64") {
|
|
174
|
+
if (typeof body !== "string") {
|
|
175
|
+
throw new BackendError(
|
|
176
|
+
"Platform proxy returned body_encoding=base64 without a string body",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
body = Buffer.from(body, "base64");
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
status: json.status,
|
|
183
|
+
headers: json.headers ?? {},
|
|
184
|
+
body,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -92,7 +92,7 @@ export const PROVIDER_SEED_DATA: Record<
|
|
|
92
92
|
// base URL override (see GMAIL_API_BASE_URL) for their short paths.
|
|
93
93
|
baseUrl: "https://www.googleapis.com",
|
|
94
94
|
displayLabel: "Google",
|
|
95
|
-
description: "Gmail, Calendar, Drive, and Contacts",
|
|
95
|
+
description: "Gmail, Calendar, Drive, Docs, Sheets, Slides, and Contacts",
|
|
96
96
|
dashboardUrl: "https://console.cloud.google.com/apis/credentials",
|
|
97
97
|
clientIdPlaceholder: "123456789.apps.googleusercontent.com",
|
|
98
98
|
logoUrl: "https://cdn.simpleicons.org/google",
|
|
@@ -137,6 +137,20 @@ export const PROVIDER_SEED_DATA: Record<
|
|
|
137
137
|
headerName: "Authorization",
|
|
138
138
|
valuePrefix: "Bearer ",
|
|
139
139
|
},
|
|
140
|
+
// The Sheets and Slides APIs accept the auth/drive scope, so the
|
|
141
|
+
// existing token covers them; only the hosts need injection entries.
|
|
142
|
+
{
|
|
143
|
+
hostPattern: "sheets.googleapis.com",
|
|
144
|
+
injectionType: "header",
|
|
145
|
+
headerName: "Authorization",
|
|
146
|
+
valuePrefix: "Bearer ",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
hostPattern: "slides.googleapis.com",
|
|
150
|
+
injectionType: "header",
|
|
151
|
+
headerName: "Authorization",
|
|
152
|
+
valuePrefix: "Bearer ",
|
|
153
|
+
},
|
|
140
154
|
{
|
|
141
155
|
hostPattern: "tasks.googleapis.com",
|
|
142
156
|
injectionType: "header",
|
package/src/permissions/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ChannelConversationType } from "@vellumai/gateway-client";
|
|
2
|
+
|
|
1
3
|
export type { AllowlistOption, ScopeOption } from "../tools/tool-types.js";
|
|
2
4
|
export { RiskLevel } from "../tools/tool-types.js";
|
|
3
5
|
|
|
@@ -79,7 +81,7 @@ export interface PolicyContext {
|
|
|
79
81
|
* Conversation type on the permission-matrix axis (dm | private | public),
|
|
80
82
|
* for the channel-type tier of cell resolution.
|
|
81
83
|
*/
|
|
82
|
-
channelConversationType?:
|
|
84
|
+
channelConversationType?: ChannelConversationType;
|
|
83
85
|
/**
|
|
84
86
|
* Whether procedural-memory-as-skills is active for this assistant (memory-v3
|
|
85
87
|
* is live). Precomputed in {@link buildPolicyContext} so the checker can gate
|
|
@@ -50,6 +50,10 @@ import {
|
|
|
50
50
|
isRetryableSqliteError,
|
|
51
51
|
withSqliteRetry,
|
|
52
52
|
} from "../util/sqlite-retry.js";
|
|
53
|
+
import {
|
|
54
|
+
purgeAllWatchTimelines,
|
|
55
|
+
purgeWatchTimelineForConversation,
|
|
56
|
+
} from "../watch/watch-timeline.js";
|
|
53
57
|
import {
|
|
54
58
|
deleteOrphanAttachments,
|
|
55
59
|
linkAttachmentToMessage,
|
|
@@ -191,6 +195,36 @@ function dedicatedTableExists(db: DrizzleDb, table: string): boolean {
|
|
|
191
195
|
* keys on `conversation_id` regardless of event name, so this covers every
|
|
192
196
|
* conversation-scoped pending event.
|
|
193
197
|
*/
|
|
198
|
+
/**
|
|
199
|
+
* Remove a conversation's watch-session timeline: the narration, the screens,
|
|
200
|
+
* and the frames of them the entries carry.
|
|
201
|
+
*
|
|
202
|
+
* The rows are keyed by conversation id with no cascade behind them, so a
|
|
203
|
+
* delete that skipped them would keep pictures of the user's screen for a
|
|
204
|
+
* conversation they asked to remove. It runs after the row deletes, which is
|
|
205
|
+
* the order the watch store expects: once `conversations` no longer has the
|
|
206
|
+
* row, a later append is refused rather than left for a purge that has already
|
|
207
|
+
* been.
|
|
208
|
+
*
|
|
209
|
+
* Best-effort, like the rest of the post-transaction cleanup. The conversation
|
|
210
|
+
* row is already gone by this point, so throwing here would turn a completed
|
|
211
|
+
* delete into an error the caller cannot usefully retry. A swallowed failure
|
|
212
|
+
* is not the end of the story: `sweepOrphanedWatchTimelineEntries` runs from
|
|
213
|
+
* daemon startup and from database maintenance and deletes entries whose
|
|
214
|
+
* conversation no longer exists, so whatever this pass leaves behind is
|
|
215
|
+
* reclaimed on a later one.
|
|
216
|
+
*/
|
|
217
|
+
function purgeWatchTimelineForDeletedConversation(id: string): void {
|
|
218
|
+
try {
|
|
219
|
+
purgeWatchTimelineForConversation(id);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
log.warn(
|
|
222
|
+
{ err, conversationId: id },
|
|
223
|
+
"Failed to purge the watch timeline for a deleted conversation",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
194
228
|
function deletePendingTelemetryEventsForConversation(id: string): void {
|
|
195
229
|
const telemetry = getTelemetryDb({ createIfMissing: false });
|
|
196
230
|
if (!telemetry || !dedicatedTableExists(telemetry, "telemetry_events")) {
|
|
@@ -2109,6 +2143,8 @@ export function deleteConversation(id: string): DeletedMemoryIds {
|
|
|
2109
2143
|
removeConversationDir(id, createdAtForDiskCleanup);
|
|
2110
2144
|
}
|
|
2111
2145
|
|
|
2146
|
+
purgeWatchTimelineForDeletedConversation(id);
|
|
2147
|
+
|
|
2112
2148
|
// Notify `conversation-deleted` hooks (e.g. the memory plugin failing its
|
|
2113
2149
|
// still-pending jobs for this conversation). Fire-and-forget from this
|
|
2114
2150
|
// synchronous primitive — the pipeline contains per-hook failures, and
|
|
@@ -2259,6 +2295,8 @@ export async function deleteConversationGently(
|
|
|
2259
2295
|
removeConversationDir(id, createdAtForDiskCleanup);
|
|
2260
2296
|
}
|
|
2261
2297
|
|
|
2298
|
+
purgeWatchTimelineForDeletedConversation(id);
|
|
2299
|
+
|
|
2262
2300
|
// Notify `conversation-deleted` hooks — fire-and-forget, same contract as
|
|
2263
2301
|
// the synchronous delete primitive.
|
|
2264
2302
|
void runHook(HOOKS.CONVERSATION_DELETED, {
|
|
@@ -3588,6 +3626,14 @@ export async function clearAll(): Promise<{
|
|
|
3588
3626
|
// cascade; wipe them explicitly so labels/objectives don't survive (or
|
|
3589
3627
|
// rehydrate after) a clear-all.
|
|
3590
3628
|
await runOrThrow("DELETE FROM subagents");
|
|
3629
|
+
// Watch-session timelines are conversation-keyed rows the cascade does not
|
|
3630
|
+
// reach. They come after `conversations` so this statement is the last thing
|
|
3631
|
+
// that needs to reach one: an append arriving from here on finds no
|
|
3632
|
+
// conversation to key itself to and is refused, and an append that already
|
|
3633
|
+
// ran is a row this deletes. The bulk deletes above run in a sqlite3
|
|
3634
|
+
// subprocess, so the event loop is free while they work; this one is a
|
|
3635
|
+
// synchronous in-process statement, which nothing can interleave with.
|
|
3636
|
+
purgeAllWatchTimelines();
|
|
3591
3637
|
|
|
3592
3638
|
// The memory feature's relocated conversation-keyed tables lost their main-DB
|
|
3593
3639
|
// cascade. Signal the wipe through the hook rather than reaching into the
|
|
@@ -228,21 +228,22 @@ export function isVoiceSessionUserMessage(
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* True when the row that opened the turn was sent from
|
|
231
|
+
* True when the row that opened the turn was sent from a desktop app, on that
|
|
232
232
|
* row's own evidence.
|
|
233
233
|
*
|
|
234
234
|
* Two markers, both required. The `client` bag's `os` entry is the only
|
|
235
235
|
* per-platform attribution on a message: `userMessageInterface` is `"web"` for
|
|
236
|
-
* the
|
|
237
|
-
* says that `os` was reported by this row's request or
|
|
238
|
-
* inherited from the conversation's live client state,
|
|
239
|
-
* of an earlier turn
|
|
240
|
-
* sent to from
|
|
236
|
+
* the desktop apps, the iOS app, and a desktop browser alike.
|
|
237
|
+
* `clientOsFromRequest` says that `os` was reported by this row's request or
|
|
238
|
+
* transport rather than inherited from the conversation's live client state,
|
|
239
|
+
* which names the surface of an earlier turn. A button tapped on the phone
|
|
240
|
+
* against a conversation last sent to from desktop persists the old OS with no
|
|
241
|
+
* marker.
|
|
241
242
|
*
|
|
242
243
|
* Origin a row did not report itself is origin unknown. Callers gate
|
|
243
|
-
* suppression on this, so unknown has to read as not-
|
|
244
|
+
* suppression on this, so unknown has to read as not-desktop.
|
|
244
245
|
*/
|
|
245
|
-
export function
|
|
246
|
+
export function isDesktopOriginatedUserMessage(
|
|
246
247
|
metadata: Record<string, unknown> | undefined,
|
|
247
248
|
): boolean {
|
|
248
249
|
if (metadata?.clientOsFromRequest !== true) {
|
|
@@ -252,7 +253,8 @@ export function isMacOriginatedUserMessage(
|
|
|
252
253
|
if (typeof client !== "object" || client === null) {
|
|
253
254
|
return false;
|
|
254
255
|
}
|
|
255
|
-
|
|
256
|
+
const clientOs = parseClientOs((client as Record<string, unknown>).os);
|
|
257
|
+
return clientOs === "macos" || clientOs === "windows";
|
|
256
258
|
}
|
|
257
259
|
|
|
258
260
|
/**
|
|
@@ -216,6 +216,7 @@ async function runViaCli(
|
|
|
216
216
|
// never an issue regardless of the statement complexity.
|
|
217
217
|
const proc = Bun.spawn({
|
|
218
218
|
cmd: [sqlite3Path, dbPath],
|
|
219
|
+
windowsHide: true,
|
|
219
220
|
stdin: "pipe",
|
|
220
221
|
stdout: "pipe",
|
|
221
222
|
stderr: "pipe",
|
|
@@ -396,7 +397,7 @@ export function spawnDetachedWalCheckpoint(): boolean {
|
|
|
396
397
|
getDbPath(),
|
|
397
398
|
`PRAGMA busy_timeout=${SQLITE_BUSY_TIMEOUT_MS}; PRAGMA wal_checkpoint(TRUNCATE);`,
|
|
398
399
|
],
|
|
399
|
-
{ detached: true, stdio: "ignore" },
|
|
400
|
+
{ detached: true, stdio: "ignore", windowsHide: true },
|
|
400
401
|
);
|
|
401
402
|
// A post-spawn failure can't be acted on — the daemon is exiting — but an
|
|
402
403
|
// unlistened ChildProcess "error" event would throw as an uncaught
|
|
@@ -3,6 +3,7 @@ import { statSync } from "node:fs";
|
|
|
3
3
|
import { getConfig } from "../config/loader.js";
|
|
4
4
|
import { getLogger } from "../util/logger.js";
|
|
5
5
|
import { getDbPath } from "../util/platform.js";
|
|
6
|
+
import { sweepOrphanedWatchTimelineEntries } from "../watch/watch-timeline.js";
|
|
6
7
|
import { pruneRuns } from "../workflows/journal-store.js";
|
|
7
8
|
import { getMemoryCheckpoint, setMemoryCheckpoint } from "./checkpoints.js";
|
|
8
9
|
import { getLastInteractiveUserMessageTimestamp } from "./conversation-crud.js";
|
|
@@ -81,6 +82,20 @@ async function runDbMaintenance(): Promise<void> {
|
|
|
81
82
|
log.warn({ err }, "Workflow run pruning failed (non-fatal)");
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// Reclaim watch-timeline entries whose conversation is gone. Their purge runs
|
|
86
|
+
// after the conversation row is already deleted and cannot be retried by the
|
|
87
|
+
// caller, and nothing cascades into the table, so a failed purge or a crash
|
|
88
|
+
// between the two writes would otherwise keep screenshots of the user's
|
|
89
|
+
// screen indefinitely. Bounded per pass and best-effort inside the sweep,
|
|
90
|
+
// which reports zero rather than throwing, so it needs no guard here.
|
|
91
|
+
const sweptWatchEntries = sweepOrphanedWatchTimelineEntries();
|
|
92
|
+
if (sweptWatchEntries > 0) {
|
|
93
|
+
log.info(
|
|
94
|
+
{ sweptWatchEntries },
|
|
95
|
+
"Swept watch timeline entries for deleted conversations",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
// Refresh the query planner's statistics (see PLANNER_OPTIMIZE_PRAGMA for
|
|
85
100
|
// what the mask buys here). Routed through the async path to keep it off the
|
|
86
101
|
// main thread when the sqlite3 CLI backend is available.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates `watch_timeline_entries`: the per-session record of what a watch
|
|
5
|
+
* session's user narrated and what was on their screen.
|
|
6
|
+
*
|
|
7
|
+
* The table is the timeline's only home. Entries live outside conversation
|
|
8
|
+
* message history, so nothing here participates in turn alternation or
|
|
9
|
+
* provider-facing sanitization, and only the retrospective's rendered summary
|
|
10
|
+
* reaches a model.
|
|
11
|
+
*
|
|
12
|
+
* `screenshot_attachment_id` holds the id of a row in `attachments`, so the
|
|
13
|
+
* pixels stay on disk behind the attachment store rather than in this table.
|
|
14
|
+
* Timeline rows and the screenshots they own are removed together by the
|
|
15
|
+
* watch store's purge.
|
|
16
|
+
*
|
|
17
|
+
* The two indexes cover the two access paths: reads are scoped to one session
|
|
18
|
+
* and ordered by offset, and purges are scoped to one conversation.
|
|
19
|
+
*
|
|
20
|
+
* Idempotent: `IF NOT EXISTS` on the table and both indexes.
|
|
21
|
+
*/
|
|
22
|
+
export function migrateCreateWatchTimelineEntries(db: DrizzleDb): void {
|
|
23
|
+
const raw = getSqliteFrom(db);
|
|
24
|
+
|
|
25
|
+
raw.exec(/*sql*/ `
|
|
26
|
+
CREATE TABLE IF NOT EXISTS watch_timeline_entries (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
session_id TEXT NOT NULL,
|
|
29
|
+
conversation_id TEXT NOT NULL,
|
|
30
|
+
at_ms INTEGER NOT NULL,
|
|
31
|
+
kind TEXT NOT NULL,
|
|
32
|
+
text TEXT NOT NULL,
|
|
33
|
+
ax_tree TEXT,
|
|
34
|
+
ax_diff TEXT,
|
|
35
|
+
screenshot_attachment_id TEXT,
|
|
36
|
+
created_at INTEGER NOT NULL
|
|
37
|
+
)
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
raw.exec(
|
|
41
|
+
`CREATE INDEX IF NOT EXISTS idx_watch_timeline_entries_session ON watch_timeline_entries(session_id, at_ms)`,
|
|
42
|
+
);
|
|
43
|
+
raw.exec(
|
|
44
|
+
`CREATE INDEX IF NOT EXISTS idx_watch_timeline_entries_conversation ON watch_timeline_entries(conversation_id)`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DrizzleDb } from "../db-connection.js";
|
|
2
|
+
import { tableHasColumn } from "./schema-introspection.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Move a watch timeline entry's screenshot into the row that owns it.
|
|
6
|
+
*
|
|
7
|
+
* `screenshot` holds the frame's JPEG bytes directly, so an entry is one row
|
|
8
|
+
* with one lifetime: a single `DELETE` takes the narration, the screen, and
|
|
9
|
+
* the pixels together, with nothing staged elsewhere to coordinate against.
|
|
10
|
+
* `screenshot_attachment_id`, the id of a separately staged file, goes with
|
|
11
|
+
* it.
|
|
12
|
+
*
|
|
13
|
+
* Idempotent: each `ALTER TABLE` is guarded by a column check, so a crash
|
|
14
|
+
* between the two leaves the next boot able to finish the job.
|
|
15
|
+
*/
|
|
16
|
+
export function migrateWatchTimelineScreenshotBlob(database: DrizzleDb): void {
|
|
17
|
+
if (!tableHasColumn(database, "watch_timeline_entries", "screenshot")) {
|
|
18
|
+
database.run(
|
|
19
|
+
/*sql*/ `ALTER TABLE watch_timeline_entries ADD COLUMN screenshot BLOB`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (
|
|
23
|
+
tableHasColumn(
|
|
24
|
+
database,
|
|
25
|
+
"watch_timeline_entries",
|
|
26
|
+
"screenshot_attachment_id",
|
|
27
|
+
)
|
|
28
|
+
) {
|
|
29
|
+
database.run(
|
|
30
|
+
/*sql*/ `ALTER TABLE watch_timeline_entries DROP COLUMN screenshot_attachment_id`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|