@vellumai/assistant 0.12.0-staging.1 → 0.12.0
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/Dockerfile +5 -0
- package/docs/architecture/turn-actor.md +9 -0
- package/knip.json +1 -0
- package/node_modules/@vellumai/app-icons/package.json +18 -0
- package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
- package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
- package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/openapi.yaml +628 -0
- package/package.json +3 -1
- package/scripts/smoke-container-workspace-dependencies.ts +19 -0
- package/src/__tests__/app-builder-icon-names.test.ts +29 -0
- package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +2 -0
- package/src/__tests__/conversation-attachments.test.ts +142 -0
- package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
- package/src/__tests__/conversation-event-sink.test.ts +50 -0
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
- package/src/__tests__/conversation-queue.test.ts +297 -0
- package/src/__tests__/credential-routes.test.ts +185 -6
- package/src/__tests__/drain-kick-guard.test.ts +2 -0
- package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
- package/src/__tests__/messaging-send-tool.test.ts +42 -0
- package/src/__tests__/oauth-commands-routes.test.ts +47 -0
- package/src/__tests__/subagent-manager-notify.test.ts +25 -4
- package/src/activation/progress-store.test.ts +1564 -0
- package/src/activation/progress-store.ts +1296 -0
- package/src/activation/turn-hooks.test.ts +246 -0
- package/src/activation/turn-hooks.ts +126 -0
- package/src/api/events/subagent-status-changed.ts +7 -5
- package/src/api/responses/activation.ts +136 -0
- package/src/apps/app-store.ts +8 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
- package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
- package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
- package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
- package/src/cli/commands/channels/index.help.ts +70 -18
- package/src/cli/commands/channels/index.ts +17 -6
- package/src/cli/commands/channels/request.ts +66 -0
- package/src/cli/commands/oauth/request.test.ts +2 -0
- package/src/cli/commands/oauth/request.ts +227 -186
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
- package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
- package/src/config/feature-flag-registry.json +35 -5
- package/src/daemon/assistant-attachments.ts +11 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
- package/src/daemon/conversation-agent-loop.ts +71 -3
- package/src/daemon/conversation-attachments.ts +42 -1
- package/src/daemon/conversation-event-sink.ts +25 -0
- package/src/daemon/conversation-process.ts +69 -21
- package/src/daemon/conversation-store.ts +4 -3
- package/src/daemon/conversation-surfaces.ts +19 -4
- package/src/daemon/message-types/sync.ts +2 -0
- package/src/ipc/assistant-server.ts +2 -0
- package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
- package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
- package/src/notifications/conversation-pairing.ts +47 -5
- package/src/notifications/delivered-post-record.ts +3 -0
- package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
- package/src/persistence/conversation-crud.ts +39 -0
- package/src/persistence/delivery-crud.ts +13 -0
- package/src/plugins/AGENTS.md +1 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
- package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
- package/src/runtime/routes/activation-routes.test.ts +415 -0
- package/src/runtime/routes/activation-routes.ts +172 -0
- package/src/runtime/routes/credential-routes.ts +46 -3
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/oauth-commands-routes.ts +21 -8
- package/src/runtime/routes/platform-managed-credentials.ts +48 -0
- package/src/runtime/routes/secret-routes.ts +3 -12
- package/src/runtime/routes/user-route-resolution.ts +21 -0
- package/src/runtime/routes/user-routes.ts +112 -9
- package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
- package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
- package/src/runtime/sync/resource-sync-events.ts +23 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
- package/src/runtime/sync/worker-daemon-notify.ts +39 -1
- package/src/tools/apps/executors.ts +8 -8
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `notices/` sub-namespace of a plugin's routes belongs to the gateway.
|
|
3
|
+
*
|
|
4
|
+
* The gateway posts its admission-denied notice there and the plugin acts on
|
|
5
|
+
* it with its own vendor credentials, without running a turn. An actor client
|
|
6
|
+
* reaching the same path could hand the plugin a forged notice, so the runtime
|
|
7
|
+
* serves the prefix to the gateway's service principal only, ahead of the
|
|
8
|
+
* `x/:path*` catch-all that every other plugin route is served through. These
|
|
9
|
+
* tests drive the real router with real auth contexts so the ordering, not
|
|
10
|
+
* just the declared policy, is what is proved.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
afterAll,
|
|
17
|
+
afterEach,
|
|
18
|
+
beforeAll,
|
|
19
|
+
beforeEach,
|
|
20
|
+
describe,
|
|
21
|
+
expect,
|
|
22
|
+
mock,
|
|
23
|
+
test,
|
|
24
|
+
} from "bun:test";
|
|
25
|
+
|
|
26
|
+
import { PLUGIN_ADMISSION_DENIED_NOTICE_PATH } from "@vellumai/gateway-client";
|
|
27
|
+
|
|
28
|
+
// Route policies are only evaluated when HTTP auth is on.
|
|
29
|
+
const actualEnv = await import("../../../config/env.js");
|
|
30
|
+
mock.module("../../../config/env.js", () => ({
|
|
31
|
+
...actualEnv,
|
|
32
|
+
isHttpAuthDisabled: () => false,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { getWorkspacePluginsDir } from "../../../util/platform.js";
|
|
36
|
+
import { resolveScopeProfile } from "../../auth/scopes.js";
|
|
37
|
+
import type { AuthContext, ScopeProfile } from "../../auth/types.js";
|
|
38
|
+
import { HttpRouter } from "../../http-router.js";
|
|
39
|
+
import type { RouteHandlerArgs, RouteResponse } from "../types.js";
|
|
40
|
+
import { ROUTES } from "../user-routes.js";
|
|
41
|
+
|
|
42
|
+
const PLUGIN = "echo-plugin";
|
|
43
|
+
const NOTICE_ENDPOINT = `x/plugins/${PLUGIN}/${PLUGIN_ADMISSION_DENIED_NOTICE_PATH}`;
|
|
44
|
+
const NOTICE_ROOT_ENDPOINT = `x/plugins/${PLUGIN}/notices`;
|
|
45
|
+
const SIBLING_ENDPOINT = `x/plugins/${PLUGIN}/status`;
|
|
46
|
+
const METHODS = [
|
|
47
|
+
"GET",
|
|
48
|
+
"POST",
|
|
49
|
+
"PUT",
|
|
50
|
+
"PATCH",
|
|
51
|
+
"DELETE",
|
|
52
|
+
"HEAD",
|
|
53
|
+
"OPTIONS",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
const ECHO_HANDLER = `
|
|
57
|
+
function echo(request) {
|
|
58
|
+
return Response.json({
|
|
59
|
+
pathname: new URL(request.url).pathname,
|
|
60
|
+
principalType: request.headers.get("x-vellum-principal-type"),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export const GET = echo;
|
|
64
|
+
export const POST = echo;
|
|
65
|
+
export const PUT = echo;
|
|
66
|
+
export const PATCH = echo;
|
|
67
|
+
export const DELETE = echo;
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
interface EchoBody {
|
|
71
|
+
pathname: string;
|
|
72
|
+
principalType: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function context(
|
|
76
|
+
principalType: AuthContext["principalType"],
|
|
77
|
+
scopeProfile: ScopeProfile,
|
|
78
|
+
): AuthContext {
|
|
79
|
+
return {
|
|
80
|
+
subject: `${principalType}:self:test`,
|
|
81
|
+
principalType,
|
|
82
|
+
assistantId: "self",
|
|
83
|
+
actorPrincipalId: principalType === "actor" ? "user-123" : undefined,
|
|
84
|
+
scopeProfile,
|
|
85
|
+
scopes: resolveScopeProfile(scopeProfile),
|
|
86
|
+
policyEpoch: 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ACTOR = context("actor", "actor_client_v1");
|
|
91
|
+
const GATEWAY = context("svc_gateway", "gateway_service_v1");
|
|
92
|
+
const LOCAL = context("local", "local_v1");
|
|
93
|
+
|
|
94
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
95
|
+
let router: HttpRouter;
|
|
96
|
+
|
|
97
|
+
beforeAll(() => {
|
|
98
|
+
// The router's dispatch signature carries the Bun server for WebSocket
|
|
99
|
+
// upgrades; user routes never touch it.
|
|
100
|
+
server = Bun.serve({ port: 0, fetch: () => new Response("unused") });
|
|
101
|
+
router = new HttpRouter();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
server.stop(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
const routesDir = join(getWorkspacePluginsDir(), PLUGIN, "routes");
|
|
110
|
+
mkdirSync(join(routesDir, "notices"), { recursive: true });
|
|
111
|
+
writeFileSync(
|
|
112
|
+
join(routesDir, "notices", "admission-denied.ts"),
|
|
113
|
+
ECHO_HANDLER,
|
|
114
|
+
);
|
|
115
|
+
writeFileSync(join(routesDir, "notices", "index.ts"), ECHO_HANDLER);
|
|
116
|
+
writeFileSync(join(routesDir, "status.ts"), ECHO_HANDLER);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
rmSync(getWorkspacePluginsDir(), { recursive: true, force: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
async function dispatch(
|
|
124
|
+
method: string,
|
|
125
|
+
endpoint: string,
|
|
126
|
+
authContext: AuthContext,
|
|
127
|
+
): Promise<Response> {
|
|
128
|
+
const url = new URL(`http://127.0.0.1/v1/${endpoint}`);
|
|
129
|
+
const req = new Request(url, {
|
|
130
|
+
method,
|
|
131
|
+
headers: { "content-type": "application/json" },
|
|
132
|
+
body: method === "GET" || method === "HEAD" ? undefined : "{}",
|
|
133
|
+
});
|
|
134
|
+
const response = await router.dispatch(
|
|
135
|
+
endpoint,
|
|
136
|
+
req,
|
|
137
|
+
url,
|
|
138
|
+
server,
|
|
139
|
+
authContext,
|
|
140
|
+
);
|
|
141
|
+
if (!response) {
|
|
142
|
+
throw new Error(`no route matched ${method} ${endpoint}`);
|
|
143
|
+
}
|
|
144
|
+
return response;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe("plugin notice routes", () => {
|
|
148
|
+
test("an actor client cannot post a notice", async () => {
|
|
149
|
+
const response = await dispatch("POST", NOTICE_ENDPOINT, ACTOR);
|
|
150
|
+
expect(response.status).toBe(403);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a local caller cannot post a notice either", async () => {
|
|
154
|
+
const response = await dispatch("POST", NOTICE_ENDPOINT, LOCAL);
|
|
155
|
+
expect(response.status).toBe(403);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("the reservation covers every method under the prefix", async () => {
|
|
159
|
+
for (const method of METHODS) {
|
|
160
|
+
const response = await dispatch(method, NOTICE_ENDPOINT, ACTOR);
|
|
161
|
+
expect(response.status).toBe(403);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("the namespace root is reserved too", async () => {
|
|
166
|
+
// `routes/notices/index.ts` is a handler at `notices`, which no catch-all
|
|
167
|
+
// param can match, so it has its own definition. The trailing-slash
|
|
168
|
+
// spelling normalizes to the same endpoint.
|
|
169
|
+
expect((await dispatch("POST", NOTICE_ROOT_ENDPOINT, ACTOR)).status).toBe(
|
|
170
|
+
403,
|
|
171
|
+
);
|
|
172
|
+
expect(
|
|
173
|
+
(await dispatch("POST", `${NOTICE_ROOT_ENDPOINT}/`, ACTOR)).status,
|
|
174
|
+
).toBe(403);
|
|
175
|
+
const response = await dispatch("POST", NOTICE_ROOT_ENDPOINT, GATEWAY);
|
|
176
|
+
expect(response.status).toBe(200);
|
|
177
|
+
const body = (await response.json()) as EchoBody;
|
|
178
|
+
expect(body.principalType).toBe("svc_gateway");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a respelled path does not reach the handler through the catch-all", async () => {
|
|
182
|
+
// The notice definitions match literal segments on the wire spelling;
|
|
183
|
+
// the catch-all decodes and the filesystem folds, so each of these would
|
|
184
|
+
// otherwise resolve to `notices/admission-denied.ts` under the actor
|
|
185
|
+
// policy.
|
|
186
|
+
const respellings = [
|
|
187
|
+
`x/plugins/${PLUGIN}/%6eotices/admission-denied`,
|
|
188
|
+
`x/%70lugins/${PLUGIN}/notices/admission-denied`,
|
|
189
|
+
`x/plugins/${PLUGIN}//notices/admission-denied`,
|
|
190
|
+
`x/plugins/${PLUGIN}/./notices/admission-denied`,
|
|
191
|
+
`x/plugins/${PLUGIN}/Notices/admission-denied`,
|
|
192
|
+
`x/plugins/${PLUGIN}/%6eotices`,
|
|
193
|
+
];
|
|
194
|
+
for (const endpoint of respellings) {
|
|
195
|
+
const response = await dispatch("POST", endpoint, ACTOR);
|
|
196
|
+
expect(response.status).toBe(403);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a respelling of an ordinary plugin route still serves", async () => {
|
|
201
|
+
// The refusal is about the namespace, not about encoding in general.
|
|
202
|
+
const response = await dispatch(
|
|
203
|
+
"POST",
|
|
204
|
+
`x/plugins/${PLUGIN}/%73tatus`,
|
|
205
|
+
ACTOR,
|
|
206
|
+
);
|
|
207
|
+
expect(response.status).toBe(200);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("the gateway's service principal reaches the plugin's handler", async () => {
|
|
211
|
+
const response = await dispatch("POST", NOTICE_ENDPOINT, GATEWAY);
|
|
212
|
+
expect(response.status).toBe(200);
|
|
213
|
+
const body = (await response.json()) as EchoBody;
|
|
214
|
+
expect(body.principalType).toBe("svc_gateway");
|
|
215
|
+
expect(body.pathname).toBe(`/v1/${NOTICE_ENDPOINT}`);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("the rest of the plugin's namespace still serves actor clients", async () => {
|
|
219
|
+
const response = await dispatch("POST", SIBLING_ENDPOINT, ACTOR);
|
|
220
|
+
expect(response.status).toBe(200);
|
|
221
|
+
const body = (await response.json()) as EchoBody;
|
|
222
|
+
expect(body.principalType).toBe("actor");
|
|
223
|
+
expect(body.pathname).toBe(`/v1/${SIBLING_ENDPOINT}`);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("served over IPC, the handler sees the full plugin path", async () => {
|
|
227
|
+
// No wire URL on that transport: the route rebuilds it from the params
|
|
228
|
+
// it matched, and the plugin segment has to survive the rebuild.
|
|
229
|
+
const route = ROUTES.find(
|
|
230
|
+
(r) => r.operationId === "plugin_notice_route_post",
|
|
231
|
+
)!;
|
|
232
|
+
const handler = route.handler as (
|
|
233
|
+
args: RouteHandlerArgs,
|
|
234
|
+
) => Promise<RouteResponse>;
|
|
235
|
+
const response = await handler({
|
|
236
|
+
pathParams: {
|
|
237
|
+
plugin: PLUGIN,
|
|
238
|
+
path: PLUGIN_ADMISSION_DENIED_NOTICE_PATH.slice("notices/".length),
|
|
239
|
+
},
|
|
240
|
+
headers: { "x-vellum-principal-type": "svc_gateway" },
|
|
241
|
+
body: {},
|
|
242
|
+
});
|
|
243
|
+
expect(response.status).toBe(200);
|
|
244
|
+
const body = (await new Response(response.body).json()) as EchoBody;
|
|
245
|
+
expect(body.pathname).toBe(`/v1/${NOTICE_ENDPOINT}`);
|
|
246
|
+
expect(body.principalType).toBe("svc_gateway");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the activation route handlers in `activation-routes.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Covers the GET/POST round trip, the request-body validation contract,
|
|
5
|
+
* and the route-definition metadata clients and the OpenAPI generator
|
|
6
|
+
* depend on.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
import type { ActivationProgress } from "../../api/responses/activation.js";
|
|
15
|
+
import type { ConversationRow } from "../../persistence/conversation-crud.js";
|
|
16
|
+
import { RouteError } from "./errors.js";
|
|
17
|
+
import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
|
|
18
|
+
|
|
19
|
+
// Capture invalidations without standing up SSE infrastructure, and read the
|
|
20
|
+
// origin client the handlers thread through from the request headers.
|
|
21
|
+
const publishedOrigins: (string | undefined)[] = [];
|
|
22
|
+
|
|
23
|
+
mock.module("../sync/sync-publisher.js", () => ({
|
|
24
|
+
publishSyncInvalidation: async (tags: string[], originClientId?: string) => {
|
|
25
|
+
publishedOrigins.push(originClientId);
|
|
26
|
+
return { type: "sync_changed", tags };
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
/** The conversations the daemon can resolve, as the start route sees them. */
|
|
31
|
+
const existingConversationIds = new Set<string>();
|
|
32
|
+
|
|
33
|
+
function conversationRow(id: string): ConversationRow {
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
title: null,
|
|
37
|
+
createdAt: 1700000000000,
|
|
38
|
+
updatedAt: 1700000000000,
|
|
39
|
+
totalInputTokens: 0,
|
|
40
|
+
totalOutputTokens: 0,
|
|
41
|
+
totalEstimatedCost: 0,
|
|
42
|
+
contextSummary: null,
|
|
43
|
+
contextCompactedMessageCount: 0,
|
|
44
|
+
contextCompactedAt: null,
|
|
45
|
+
historyStrippedAt: null,
|
|
46
|
+
slackContextCompactionWatermarkTs: null,
|
|
47
|
+
slackContextCompactionWatermarkAt: null,
|
|
48
|
+
conversationType: "standard",
|
|
49
|
+
source: "user",
|
|
50
|
+
originChannel: null,
|
|
51
|
+
originInterface: null,
|
|
52
|
+
forkParentConversationId: null,
|
|
53
|
+
forkParentMessageId: null,
|
|
54
|
+
forkStrategy: null,
|
|
55
|
+
isAutoTitle: 0,
|
|
56
|
+
scheduleJobId: null,
|
|
57
|
+
lastMessageAt: null,
|
|
58
|
+
archivedAt: null,
|
|
59
|
+
surfacedAt: null,
|
|
60
|
+
inferenceProfile: null,
|
|
61
|
+
enabledPlugins: null,
|
|
62
|
+
inferenceProfileSessionId: null,
|
|
63
|
+
inferenceProfileExpiresAt: null,
|
|
64
|
+
lastNotifiedInferenceProfile: null,
|
|
65
|
+
processingStartedAt: null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The start route resolves the conversation the way `POST /v1/messages` does.
|
|
70
|
+
// Stubbed against a set the tests own, so a route test states which
|
|
71
|
+
// conversations exist without standing up a database.
|
|
72
|
+
const realConversationCrud =
|
|
73
|
+
await import("../../persistence/conversation-crud.js");
|
|
74
|
+
mock.module("../../persistence/conversation-crud.js", () => ({
|
|
75
|
+
...realConversationCrud,
|
|
76
|
+
getConversation: (id: string): ConversationRow | null =>
|
|
77
|
+
existingConversationIds.has(id) ? conversationRow(id) : null,
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const { ROUTES: ACTIVATION_ROUTES } = await import("./activation-routes.js");
|
|
81
|
+
const { ROUTES: ALL_ROUTES } = await import("./index.js");
|
|
82
|
+
const { getActivationProgressLockPath, setActivationLockTimingForTesting } =
|
|
83
|
+
await import("../../activation/progress-store.js");
|
|
84
|
+
|
|
85
|
+
function findRoute(operationId: string): RouteDefinition {
|
|
86
|
+
const route = ACTIVATION_ROUTES.find((r) => r.operationId === operationId);
|
|
87
|
+
if (!route) {
|
|
88
|
+
throw new Error(`Route ${operationId} not found`);
|
|
89
|
+
}
|
|
90
|
+
return route;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const getProgressRoute = findRoute("activation_progress_get");
|
|
94
|
+
const startTaskRoute = findRoute("activation_task_start_post");
|
|
95
|
+
const dismissRoute = findRoute("activation_dismiss_post");
|
|
96
|
+
|
|
97
|
+
async function call(
|
|
98
|
+
route: RouteDefinition,
|
|
99
|
+
args: RouteHandlerArgs = {},
|
|
100
|
+
): Promise<ActivationProgress> {
|
|
101
|
+
return (await route.handler(args)) as ActivationProgress;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let workspaceDir: string;
|
|
105
|
+
let origWorkspaceDir: string | undefined;
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
workspaceDir = mkdtempSync(join(tmpdir(), "vellum-activation-routes-"));
|
|
109
|
+
origWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
|
|
110
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
111
|
+
publishedOrigins.length = 0;
|
|
112
|
+
existingConversationIds.clear();
|
|
113
|
+
existingConversationIds.add("conv-1");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
setActivationLockTimingForTesting();
|
|
118
|
+
if (origWorkspaceDir === undefined) {
|
|
119
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
120
|
+
} else {
|
|
121
|
+
process.env.VELLUM_WORKSPACE_DIR = origWorkspaceDir;
|
|
122
|
+
}
|
|
123
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("activation routes", () => {
|
|
127
|
+
test("GET progress returns the empty default before any write", async () => {
|
|
128
|
+
expect(await call(getProgressRoute)).toEqual({
|
|
129
|
+
version: 1,
|
|
130
|
+
listId: null,
|
|
131
|
+
modalDismissedAt: null,
|
|
132
|
+
allDoneShownAt: null,
|
|
133
|
+
tasks: {},
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("POST start links the task and returns the full progress", async () => {
|
|
138
|
+
const result = await call(startTaskRoute, {
|
|
139
|
+
pathParams: { taskId: "draft-email" },
|
|
140
|
+
body: { conversationId: "conv-1", listId: "smb" },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result.listId).toBe("smb");
|
|
144
|
+
expect(result.tasks["draft-email"]).toMatchObject({
|
|
145
|
+
status: "started",
|
|
146
|
+
conversationId: "conv-1",
|
|
147
|
+
});
|
|
148
|
+
expect(await call(getProgressRoute)).toEqual(result);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("POST dismiss records the surface and returns the full progress", async () => {
|
|
152
|
+
const result = await call(dismissRoute, {
|
|
153
|
+
body: { kind: "all-done", listId: "parent" },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(result.allDoneShownAt).not.toBeNull();
|
|
157
|
+
expect(result.modalDismissedAt).toBeNull();
|
|
158
|
+
expect(result.listId).toBe("parent");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("POST start rejects a malformed task id with a 400", async () => {
|
|
162
|
+
const err = await call(startTaskRoute, {
|
|
163
|
+
pathParams: { taskId: "Not A Task" },
|
|
164
|
+
body: { conversationId: "conv-1" },
|
|
165
|
+
}).catch((e: unknown) => e);
|
|
166
|
+
|
|
167
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
168
|
+
expect((err as RouteError).statusCode).toBe(400);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("POST start rejects a missing conversation id with a 400", async () => {
|
|
172
|
+
const err = await call(startTaskRoute, {
|
|
173
|
+
pathParams: { taskId: "draft-email" },
|
|
174
|
+
body: {},
|
|
175
|
+
}).catch((e: unknown) => e);
|
|
176
|
+
|
|
177
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
178
|
+
expect((err as RouteError).statusCode).toBe(400);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("POST start rejects an oversized conversation id with a 400", async () => {
|
|
182
|
+
const err = await call(startTaskRoute, {
|
|
183
|
+
pathParams: { taskId: "draft-email" },
|
|
184
|
+
body: { conversationId: "c".repeat(129) },
|
|
185
|
+
}).catch((e: unknown) => e);
|
|
186
|
+
|
|
187
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
188
|
+
expect((err as RouteError).statusCode).toBe(400);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("a write carries the client that made it, so it can suppress its own echo", async () => {
|
|
192
|
+
await call(startTaskRoute, {
|
|
193
|
+
pathParams: { taskId: "draft-email" },
|
|
194
|
+
body: { conversationId: "conv-1" },
|
|
195
|
+
headers: { "x-vellum-client-id": "client-a" },
|
|
196
|
+
});
|
|
197
|
+
await call(dismissRoute, {
|
|
198
|
+
body: { kind: "modal" },
|
|
199
|
+
headers: { "x-vellum-client-id": "client-b" },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(publishedOrigins).toEqual(["client-a", "client-b"]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a write from a client that named none carries none", async () => {
|
|
206
|
+
await call(startTaskRoute, {
|
|
207
|
+
pathParams: { taskId: "draft-email" },
|
|
208
|
+
body: { conversationId: "conv-1" },
|
|
209
|
+
headers: { "x-vellum-client-id": " " },
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(publishedOrigins).toEqual([undefined]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("POST dismiss rejects an unknown kind with a 400", async () => {
|
|
216
|
+
const err = await call(dismissRoute, { body: { kind: "sideways" } }).catch(
|
|
217
|
+
(e: unknown) => e,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
221
|
+
expect((err as RouteError).statusCode).toBe(400);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("POST start rejects when the write cannot be persisted", async () => {
|
|
225
|
+
// Occupy the data directory's path with a regular file, so the store
|
|
226
|
+
// cannot create it and the write fails.
|
|
227
|
+
writeFileSync(join(workspaceDir, "data"), "not a directory", "utf-8");
|
|
228
|
+
|
|
229
|
+
const err = await call(startTaskRoute, {
|
|
230
|
+
pathParams: { taskId: "draft-email" },
|
|
231
|
+
body: { conversationId: "conv-1" },
|
|
232
|
+
}).catch((e: unknown) => e);
|
|
233
|
+
|
|
234
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
235
|
+
expect((err as RouteError).statusCode).toBe(500);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("POST dismiss rejects when the write cannot be persisted", async () => {
|
|
239
|
+
writeFileSync(join(workspaceDir, "data"), "not a directory", "utf-8");
|
|
240
|
+
|
|
241
|
+
const err = await call(dismissRoute, { body: { kind: "modal" } }).catch(
|
|
242
|
+
(e: unknown) => e,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
246
|
+
expect((err as RouteError).statusCode).toBe(500);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("a progress document from a newer build", () => {
|
|
250
|
+
function writeNewerProgress(): void {
|
|
251
|
+
mkdirSync(join(workspaceDir, "data"), { recursive: true });
|
|
252
|
+
writeFileSync(
|
|
253
|
+
join(workspaceDir, "data", "activation-progress.json"),
|
|
254
|
+
JSON.stringify({
|
|
255
|
+
version: 99,
|
|
256
|
+
listId: "smb",
|
|
257
|
+
modalDismissedAt: null,
|
|
258
|
+
allDoneShownAt: null,
|
|
259
|
+
tasks: {},
|
|
260
|
+
}),
|
|
261
|
+
"utf-8",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
test("GET still serves what this build understands", async () => {
|
|
266
|
+
writeNewerProgress();
|
|
267
|
+
|
|
268
|
+
expect((await call(getProgressRoute)).listId).toBe("smb");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("POST start answers 409 rather than a write that never landed", async () => {
|
|
272
|
+
writeNewerProgress();
|
|
273
|
+
|
|
274
|
+
const err = await call(startTaskRoute, {
|
|
275
|
+
pathParams: { taskId: "draft-email" },
|
|
276
|
+
body: { conversationId: "conv-1" },
|
|
277
|
+
}).catch((e: unknown) => e);
|
|
278
|
+
|
|
279
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
280
|
+
expect((err as RouteError).statusCode).toBe(409);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("POST dismiss answers 409 rather than a write that never landed", async () => {
|
|
284
|
+
writeNewerProgress();
|
|
285
|
+
|
|
286
|
+
const err = await call(dismissRoute, { body: { kind: "modal" } }).catch(
|
|
287
|
+
(e: unknown) => e,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
291
|
+
expect((err as RouteError).statusCode).toBe(409);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("POST start hands the conversation to the latest task", async () => {
|
|
296
|
+
await call(startTaskRoute, {
|
|
297
|
+
pathParams: { taskId: "draft-email" },
|
|
298
|
+
body: { conversationId: "conv-1" },
|
|
299
|
+
});
|
|
300
|
+
const result = await call(startTaskRoute, {
|
|
301
|
+
pathParams: { taskId: "book-travel" },
|
|
302
|
+
body: { conversationId: "conv-1" },
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(Object.keys(result.tasks)).toEqual(["book-travel"]);
|
|
306
|
+
expect(await call(getProgressRoute)).toEqual(result);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("POST start rejects a conversation the daemon cannot resolve with a 404", async () => {
|
|
310
|
+
const err = await call(startTaskRoute, {
|
|
311
|
+
pathParams: { taskId: "draft-email" },
|
|
312
|
+
body: { conversationId: "conv-deleted" },
|
|
313
|
+
}).catch((e: unknown) => e);
|
|
314
|
+
|
|
315
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
316
|
+
expect((err as RouteError).statusCode).toBe(404);
|
|
317
|
+
// Nothing was recorded, so the row stays launchable.
|
|
318
|
+
expect((await call(getProgressRoute)).tasks).toEqual({});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("POST start refuses a conversation deleted while it waited for the lock", async () => {
|
|
322
|
+
setActivationLockTimingForTesting({ waitMs: 1_000 });
|
|
323
|
+
mkdirSync(join(workspaceDir, "data"), { recursive: true });
|
|
324
|
+
const lockPath = getActivationProgressLockPath();
|
|
325
|
+
writeFileSync(
|
|
326
|
+
lockPath,
|
|
327
|
+
JSON.stringify({ pid: process.pid, at: Date.now() }),
|
|
328
|
+
"utf-8",
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// The conversation is there when the call starts, so the fast-path check
|
|
332
|
+
// passes and the mutation queues behind the lock.
|
|
333
|
+
const pending = call(startTaskRoute, {
|
|
334
|
+
pathParams: { taskId: "draft-email" },
|
|
335
|
+
body: { conversationId: "conv-1" },
|
|
336
|
+
}).catch((e: unknown) => e);
|
|
337
|
+
|
|
338
|
+
// Deleted while the mutation waits, the way a delete request landing on
|
|
339
|
+
// another connection would.
|
|
340
|
+
await Bun.sleep(50);
|
|
341
|
+
existingConversationIds.delete("conv-1");
|
|
342
|
+
rmSync(lockPath);
|
|
343
|
+
|
|
344
|
+
const err = await pending;
|
|
345
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
346
|
+
expect((err as RouteError).statusCode).toBe(404);
|
|
347
|
+
// No link was written, so nothing is left pointing at a deleted row.
|
|
348
|
+
expect((await call(getProgressRoute)).tasks).toEqual({});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("POST start answers 503 while another process holds the progress lock", async () => {
|
|
352
|
+
setActivationLockTimingForTesting({ waitMs: 40 });
|
|
353
|
+
mkdirSync(join(workspaceDir, "data"), { recursive: true });
|
|
354
|
+
writeFileSync(
|
|
355
|
+
getActivationProgressLockPath(),
|
|
356
|
+
JSON.stringify({ pid: process.pid, at: Date.now() }),
|
|
357
|
+
"utf-8",
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const err = await call(startTaskRoute, {
|
|
361
|
+
pathParams: { taskId: "draft-email" },
|
|
362
|
+
body: { conversationId: "conv-1" },
|
|
363
|
+
}).catch((e: unknown) => e);
|
|
364
|
+
|
|
365
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
366
|
+
expect((err as RouteError).statusCode).toBe(503);
|
|
367
|
+
expect((await call(getProgressRoute)).tasks).toEqual({});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("POST dismiss answers 503 while another process holds the progress lock", async () => {
|
|
371
|
+
setActivationLockTimingForTesting({ waitMs: 40 });
|
|
372
|
+
mkdirSync(join(workspaceDir, "data"), { recursive: true });
|
|
373
|
+
writeFileSync(
|
|
374
|
+
getActivationProgressLockPath(),
|
|
375
|
+
JSON.stringify({ pid: process.pid, at: Date.now() }),
|
|
376
|
+
"utf-8",
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const err = await call(dismissRoute, { body: { kind: "modal" } }).catch(
|
|
380
|
+
(e: unknown) => e,
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
384
|
+
expect((err as RouteError).statusCode).toBe(503);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("the start route declares the answers it can give", () => {
|
|
388
|
+
expect(
|
|
389
|
+
Object.keys(startTaskRoute.additionalResponses ?? {}).sort(),
|
|
390
|
+
).toEqual(["400", "404", "409", "503"]);
|
|
391
|
+
expect(Object.keys(dismissRoute.additionalResponses ?? {}).sort()).toEqual([
|
|
392
|
+
"400",
|
|
393
|
+
"409",
|
|
394
|
+
"503",
|
|
395
|
+
]);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("routes declare their policy, tags, and response body", () => {
|
|
399
|
+
for (const route of ACTIVATION_ROUTES) {
|
|
400
|
+
expect(route.tags).toEqual(["activation"]);
|
|
401
|
+
expect(route.responseBody).toBeDefined();
|
|
402
|
+
expect(route.policy?.allowedPrincipalTypes.length).toBeGreaterThan(0);
|
|
403
|
+
}
|
|
404
|
+
expect(getProgressRoute.policy?.requiredScopes).toEqual(["chat.read"]);
|
|
405
|
+
expect(startTaskRoute.policy?.requiredScopes).toEqual(["chat.write"]);
|
|
406
|
+
expect(dismissRoute.policy?.requiredScopes).toEqual(["chat.write"]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("routes are registered in the shared route table", () => {
|
|
410
|
+
const registered = new Set(ALL_ROUTES.map((r) => r.operationId));
|
|
411
|
+
for (const route of ACTIVATION_ROUTES) {
|
|
412
|
+
expect(registered.has(route.operationId)).toBe(true);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
});
|