@vellumai/vellum-gateway 0.8.6 → 0.8.7
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/ARCHITECTURE.md +2 -2
- package/package.json +1 -1
- package/src/__tests__/feature-flag-resolver.test.ts +91 -0
- package/src/__tests__/feature-flags-route.test.ts +50 -50
- package/src/__tests__/ipc-feature-flag-routes.test.ts +13 -13
- package/src/__tests__/ipc-slack-thread-routes.test.ts +47 -0
- package/src/__tests__/remote-feature-flag-sync.test.ts +109 -8
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +222 -0
- package/src/__tests__/trust-rules-route-auth.test.ts +36 -0
- package/src/db/slack-store.ts +1 -1
- package/src/feature-flag-defaults.ts +2 -2
- package/src/feature-flag-registry.json +25 -9
- package/src/feature-flag-remote-store.ts +0 -4
- package/src/feature-flag-resolver.ts +11 -11
- package/src/http/routes/feature-flags.ts +4 -9
- package/src/http/routes/ipc-runtime-proxy.test.ts +36 -24
- package/src/http/routes/ipc-runtime-proxy.ts +35 -9
- package/src/index.ts +51 -12
- package/src/ipc/feature-flag-handlers.ts +4 -8
- package/src/ipc/route-schema-cache.test.ts +196 -0
- package/src/ipc/route-schema-cache.ts +100 -24
- package/src/remote-feature-flag-sync.ts +47 -23
- package/src/risk/bash-risk-classifier.test.ts +2 -2
- package/src/risk/command-registry/commands/assistant.ts +14 -15
- package/src/risk/command-registry.test.ts +1 -1
- package/src/slack/socket-mode.ts +97 -0
- package/src/webhook-copy.ts +5 -0
- package/src/__tests__/ipc-route-policy-coverage.test.ts +0 -297
- package/src/__tests__/ipc-route-policy.test.ts +0 -160
- package/src/auth/ipc-route-policy.ts +0 -375
package/ARCHITECTURE.md
CHANGED
|
@@ -95,11 +95,11 @@ The gateway exposes a REST API for reading and mutating assistant feature flags.
|
|
|
95
95
|
| GET | `/v1/feature-flags` | List all declared assistant feature flags from the defaults registry, merged with persisted values from the feature flag store. Returns `{ flags: FeatureFlagEntry[] }` where each entry has `key`, `enabled`, `defaultEnabled`, and `description`. |
|
|
96
96
|
| PATCH | `/v1/feature-flags/:key` | Set a single assistant feature flag. Body: `{ "enabled": true\|false }`. Key must be a simple kebab-case flag key declared in the defaults registry. Writes to `~/.vellum/protected/feature-flags.json`. |
|
|
97
97
|
|
|
98
|
-
**Unified registry:** All declared feature flags and their default values are defined in the unified registry at `meta/feature-flags/feature-flag-registry.json` (bundled copy at `gateway/src/feature-flag-registry.json`). The gateway loads this registry on startup via `gateway/src/feature-flag-defaults.ts`, filtering to `scope: "assistant"` flags. Labels come from the registry. The GET endpoint merges persisted overrides with remote values and registry defaults to produce the full flag list.
|
|
98
|
+
**Unified registry:** All declared feature flags and their default values are defined in the unified registry at `meta/feature-flags/feature-flag-registry.json` (bundled copy at `gateway/src/feature-flag-registry.json`). The gateway loads this registry on startup via `gateway/src/feature-flag-defaults.ts`, filtering to `scope: "assistant"` flags. Labels come from the registry. The GET endpoint merges persisted overrides with explicit remote values and registry defaults to produce the full flag list. Declared flags missing from a remote snapshot fall back to their registry `defaultEnabled`; undeclared keys are rejected by writes and fail closed in direct resolver calls. The PATCH endpoint validates that the target flag key exists in the registry before accepting a write. Only declared keys are exposed by this API.
|
|
99
99
|
|
|
100
100
|
**Flag key format:** The canonical key format is simple kebab-case (e.g., `browser`, `ces-tools`). Only keys matching this pattern and declared in the registry are accepted by the PATCH endpoint; other patterns are rejected with 400. All writes use the canonical format and are stored in the protected feature flag store (`~/.vellum/protected/feature-flags.json`).
|
|
101
101
|
|
|
102
|
-
**Storage:** Flag overrides are persisted in `~/.vellum/protected/feature-flags.json` (local) or `GATEWAY_SECURITY_DIR/feature-flags.json` (Docker). The store uses a versioned JSON format (`{ version: 1, values: Record<string, boolean> }`). Remote platform snapshots are persisted separately in `feature-flags-remote.json`; local overrides win over remote values, remote values win over registry defaults, and missing remote values
|
|
102
|
+
**Storage:** Flag overrides are persisted in `~/.vellum/protected/feature-flags.json` (local) or `GATEWAY_SECURITY_DIR/feature-flags.json` (Docker). The store uses a versioned JSON format (`{ version: 1, values: Record<string, boolean> }`). Remote platform snapshots are persisted separately in `feature-flags-remote.json`; local overrides win over remote values, explicit remote values win over registry defaults, and missing remote values fall back to registry defaults. The gateway writes atomically (temp file + rename, 0o600 permissions). The daemon's config watcher monitors the protected directory and hot-reloads flag changes, so flag mutations take effect on the next session or tool resolution without a restart.
|
|
103
103
|
|
|
104
104
|
**Token separation (authentication boundary):**
|
|
105
105
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { testSecurityDir } from "./test-preload.js";
|
|
5
|
+
|
|
6
|
+
const protectedDir = testSecurityDir;
|
|
7
|
+
const defaultsPath = join(protectedDir, "feature-flag-registry.json");
|
|
8
|
+
|
|
9
|
+
const TEST_REGISTRY = {
|
|
10
|
+
version: 1,
|
|
11
|
+
flags: [
|
|
12
|
+
{
|
|
13
|
+
id: "browser",
|
|
14
|
+
scope: "assistant",
|
|
15
|
+
key: "browser",
|
|
16
|
+
label: "Browser",
|
|
17
|
+
description: "Browser skill",
|
|
18
|
+
defaultEnabled: true,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "a2a-channel",
|
|
22
|
+
scope: "assistant",
|
|
23
|
+
key: "a2a-channel",
|
|
24
|
+
label: "A2A Channel",
|
|
25
|
+
description: "A2A channel integration",
|
|
26
|
+
defaultEnabled: false,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
mkdirSync(protectedDir, { recursive: true });
|
|
33
|
+
writeFileSync(defaultsPath, JSON.stringify(TEST_REGISTRY, null, 2));
|
|
34
|
+
_setRegistryCandidateOverrides([defaultsPath]);
|
|
35
|
+
resetFeatureFlagDefaultsCache();
|
|
36
|
+
clearFeatureFlagStoreCache();
|
|
37
|
+
clearRemoteFeatureFlagStoreCache();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
try {
|
|
42
|
+
rmSync(protectedDir, { recursive: true, force: true });
|
|
43
|
+
mkdirSync(protectedDir, { recursive: true });
|
|
44
|
+
} catch {
|
|
45
|
+
// best effort cleanup
|
|
46
|
+
}
|
|
47
|
+
_setRegistryCandidateOverrides(null);
|
|
48
|
+
resetFeatureFlagDefaultsCache();
|
|
49
|
+
clearFeatureFlagStoreCache();
|
|
50
|
+
clearRemoteFeatureFlagStoreCache();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const { isFeatureFlagEnabled } = await import("../feature-flag-resolver.js");
|
|
54
|
+
const { resetFeatureFlagDefaultsCache, _setRegistryCandidateOverrides } =
|
|
55
|
+
await import("../feature-flag-defaults.js");
|
|
56
|
+
const { clearFeatureFlagStoreCache, writeFeatureFlag } =
|
|
57
|
+
await import("../feature-flag-store.js");
|
|
58
|
+
const { clearRemoteFeatureFlagStoreCache, writeRemoteFeatureFlags } =
|
|
59
|
+
await import("../feature-flag-remote-store.js");
|
|
60
|
+
|
|
61
|
+
describe("isFeatureFlagEnabled", () => {
|
|
62
|
+
test("uses registry defaults when no override exists", () => {
|
|
63
|
+
expect(isFeatureFlagEnabled("browser")).toBe(true);
|
|
64
|
+
expect(isFeatureFlagEnabled("a2a-channel")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("uses persisted overrides for declared flags", () => {
|
|
68
|
+
writeFeatureFlag("browser", false);
|
|
69
|
+
writeFeatureFlag("a2a-channel", true);
|
|
70
|
+
|
|
71
|
+
expect(isFeatureFlagEnabled("browser")).toBe(false);
|
|
72
|
+
expect(isFeatureFlagEnabled("a2a-channel")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("uses explicit remote values for declared flags", () => {
|
|
76
|
+
writeRemoteFeatureFlags({
|
|
77
|
+
browser: false,
|
|
78
|
+
"a2a-channel": true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(isFeatureFlagEnabled("browser")).toBe(false);
|
|
82
|
+
expect(isFeatureFlagEnabled("a2a-channel")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("ignores persisted and remote values for undeclared flags", () => {
|
|
86
|
+
writeFeatureFlag("unknown", true);
|
|
87
|
+
writeRemoteFeatureFlags({ unknown: true });
|
|
88
|
+
|
|
89
|
+
expect(isFeatureFlagEnabled("unknown")).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -32,11 +32,11 @@ const TEST_REGISTRY = {
|
|
|
32
32
|
defaultEnabled: true,
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
|
-
id: "
|
|
35
|
+
id: "a2a-channel",
|
|
36
36
|
scope: "assistant",
|
|
37
|
-
key: "
|
|
38
|
-
label: "
|
|
39
|
-
description: "
|
|
37
|
+
key: "a2a-channel",
|
|
38
|
+
label: "A2A Channel",
|
|
39
|
+
description: "A2A channel integration",
|
|
40
40
|
defaultEnabled: false,
|
|
41
41
|
},
|
|
42
42
|
{
|
|
@@ -248,19 +248,19 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
248
248
|
});
|
|
249
249
|
|
|
250
250
|
test("remote values fill in when no local override exists", async () => {
|
|
251
|
-
// Write a remote store with
|
|
251
|
+
// Write a remote store with a2a-channel enabled (overriding registry default of false)
|
|
252
252
|
writeFileSync(
|
|
253
253
|
remoteFeatureFlagStorePath,
|
|
254
254
|
JSON.stringify({
|
|
255
255
|
version: 1,
|
|
256
256
|
values: {
|
|
257
|
-
"
|
|
257
|
+
"a2a-channel": true,
|
|
258
258
|
},
|
|
259
259
|
}),
|
|
260
260
|
);
|
|
261
261
|
clearRemoteFeatureFlagStoreCache();
|
|
262
262
|
|
|
263
|
-
// No local override for
|
|
263
|
+
// No local override for a2a-channel
|
|
264
264
|
if (existsSync(featureFlagStorePath)) {
|
|
265
265
|
rmSync(featureFlagStorePath);
|
|
266
266
|
}
|
|
@@ -274,12 +274,12 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
274
274
|
expect(res.status).toBe(200);
|
|
275
275
|
const body = await res.json();
|
|
276
276
|
|
|
277
|
-
const
|
|
278
|
-
(f: { key: string }) => f.key === "
|
|
277
|
+
const a2aFlag = body.flags.find(
|
|
278
|
+
(f: { key: string }) => f.key === "a2a-channel",
|
|
279
279
|
);
|
|
280
|
-
expect(
|
|
280
|
+
expect(a2aFlag).toBeDefined();
|
|
281
281
|
// Remote value (true) overrides registry default (false)
|
|
282
|
-
expect(
|
|
282
|
+
expect(a2aFlag.enabled).toBe(true);
|
|
283
283
|
});
|
|
284
284
|
|
|
285
285
|
test("local overrides take precedence over remote values", async () => {
|
|
@@ -289,7 +289,7 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
289
289
|
JSON.stringify({
|
|
290
290
|
version: 1,
|
|
291
291
|
values: {
|
|
292
|
-
"
|
|
292
|
+
"a2a-channel": true,
|
|
293
293
|
},
|
|
294
294
|
}),
|
|
295
295
|
);
|
|
@@ -301,7 +301,7 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
301
301
|
JSON.stringify({
|
|
302
302
|
version: 1,
|
|
303
303
|
values: {
|
|
304
|
-
"
|
|
304
|
+
"a2a-channel": false,
|
|
305
305
|
},
|
|
306
306
|
}),
|
|
307
307
|
);
|
|
@@ -315,38 +315,38 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
315
315
|
expect(res.status).toBe(200);
|
|
316
316
|
const body = await res.json();
|
|
317
317
|
|
|
318
|
-
const
|
|
319
|
-
(f: { key: string }) => f.key === "
|
|
318
|
+
const a2aFlag = body.flags.find(
|
|
319
|
+
(f: { key: string }) => f.key === "a2a-channel",
|
|
320
320
|
);
|
|
321
|
-
expect(
|
|
321
|
+
expect(a2aFlag).toBeDefined();
|
|
322
322
|
// Local override (false) takes precedence over remote (true)
|
|
323
|
-
expect(
|
|
323
|
+
expect(a2aFlag.enabled).toBe(false);
|
|
324
324
|
});
|
|
325
325
|
|
|
326
326
|
test("reflects updated flags after remote sync writes new values (stale cache regression)", async () => {
|
|
327
327
|
// Scenario: the remote poller (RemoteFeatureFlagSync) writes
|
|
328
|
-
//
|
|
329
|
-
// poll writes
|
|
328
|
+
// a2a-channel: false, the gateway caches it, then a subsequent
|
|
329
|
+
// poll writes a2a-channel: true. The GET handler should return
|
|
330
330
|
// the updated value because writeRemoteFeatureFlags() updates
|
|
331
331
|
// both disk and the in-memory cache.
|
|
332
332
|
|
|
333
|
-
// Step 1: First poll writes
|
|
333
|
+
// Step 1: First poll writes a2a-channel: false (simulated via
|
|
334
334
|
// writeRemoteFeatureFlags, which is what the poller calls internally).
|
|
335
|
-
writeRemoteFeatureFlags({ "
|
|
335
|
+
writeRemoteFeatureFlags({ "a2a-channel": false });
|
|
336
336
|
|
|
337
337
|
const handler = createFeatureFlagsGetHandler();
|
|
338
338
|
const res1 = await handler(
|
|
339
339
|
new Request("http://gateway.test/v1/feature-flags"),
|
|
340
340
|
);
|
|
341
341
|
const body1 = await res1.json();
|
|
342
|
-
const
|
|
343
|
-
(f: { key: string }) => f.key === "
|
|
342
|
+
const a2aFlag1 = body1.flags.find(
|
|
343
|
+
(f: { key: string }) => f.key === "a2a-channel",
|
|
344
344
|
);
|
|
345
|
-
expect(
|
|
345
|
+
expect(a2aFlag1.enabled).toBe(false);
|
|
346
346
|
|
|
347
|
-
// Step 2: Second poll writes
|
|
347
|
+
// Step 2: Second poll writes a2a-channel: true — the poller
|
|
348
348
|
// calls writeRemoteFeatureFlags which updates file + cache.
|
|
349
|
-
writeRemoteFeatureFlags({ "
|
|
349
|
+
writeRemoteFeatureFlags({ "a2a-channel": true });
|
|
350
350
|
|
|
351
351
|
// Step 3: The GET handler should immediately reflect the update
|
|
352
352
|
// without needing a file-watcher round-trip.
|
|
@@ -354,10 +354,10 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
354
354
|
new Request("http://gateway.test/v1/feature-flags"),
|
|
355
355
|
);
|
|
356
356
|
const body2 = await res2.json();
|
|
357
|
-
const
|
|
358
|
-
(f: { key: string }) => f.key === "
|
|
357
|
+
const a2aFlag2 = body2.flags.find(
|
|
358
|
+
(f: { key: string }) => f.key === "a2a-channel",
|
|
359
359
|
);
|
|
360
|
-
expect(
|
|
360
|
+
expect(a2aFlag2.enabled).toBe(true);
|
|
361
361
|
});
|
|
362
362
|
|
|
363
363
|
test("registry default used when neither local nor remote is set", async () => {
|
|
@@ -381,13 +381,13 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
381
381
|
expect(res.status).toBe(200);
|
|
382
382
|
const body = await res.json();
|
|
383
383
|
|
|
384
|
-
//
|
|
385
|
-
const
|
|
386
|
-
(f: { key: string }) => f.key === "
|
|
384
|
+
// a2a-channel has defaultEnabled: false in registry
|
|
385
|
+
const a2aFlag = body.flags.find(
|
|
386
|
+
(f: { key: string }) => f.key === "a2a-channel",
|
|
387
387
|
);
|
|
388
|
-
expect(
|
|
389
|
-
expect(
|
|
390
|
-
expect(
|
|
388
|
+
expect(a2aFlag).toBeDefined();
|
|
389
|
+
expect(a2aFlag.enabled).toBe(false);
|
|
390
|
+
expect(a2aFlag.defaultEnabled).toBe(false);
|
|
391
391
|
|
|
392
392
|
// browser has defaultEnabled: true in registry
|
|
393
393
|
const browserFlag = body.flags.find(
|
|
@@ -398,7 +398,7 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
398
398
|
expect(browserFlag.defaultEnabled).toBe(true);
|
|
399
399
|
});
|
|
400
400
|
|
|
401
|
-
test("declared flags missing from a remote snapshot
|
|
401
|
+
test("declared flags missing from a remote snapshot use their registry defaults", async () => {
|
|
402
402
|
// No local override
|
|
403
403
|
if (existsSync(featureFlagStorePath)) {
|
|
404
404
|
rmSync(featureFlagStorePath);
|
|
@@ -411,7 +411,7 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
411
411
|
remoteFeatureFlagStorePath,
|
|
412
412
|
JSON.stringify({
|
|
413
413
|
version: 1,
|
|
414
|
-
values: { "
|
|
414
|
+
values: { "a2a-channel": true },
|
|
415
415
|
}),
|
|
416
416
|
);
|
|
417
417
|
clearRemoteFeatureFlagStoreCache();
|
|
@@ -424,16 +424,16 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
424
424
|
expect(res.status).toBe(200);
|
|
425
425
|
const body = await res.json();
|
|
426
426
|
|
|
427
|
-
const
|
|
428
|
-
(f: { key: string }) => f.key === "
|
|
427
|
+
const a2aFlag = body.flags.find(
|
|
428
|
+
(f: { key: string }) => f.key === "a2a-channel",
|
|
429
429
|
);
|
|
430
|
-
expect(
|
|
430
|
+
expect(a2aFlag.enabled).toBe(true);
|
|
431
431
|
|
|
432
432
|
const browserFlag = body.flags.find(
|
|
433
433
|
(f: { key: string }) => f.key === "browser",
|
|
434
434
|
);
|
|
435
435
|
expect(browserFlag).toBeDefined();
|
|
436
|
-
expect(browserFlag.enabled).toBe(
|
|
436
|
+
expect(browserFlag.enabled).toBe(true);
|
|
437
437
|
expect(browserFlag.defaultEnabled).toBe(true);
|
|
438
438
|
});
|
|
439
439
|
|
|
@@ -498,7 +498,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
498
498
|
JSON.stringify({
|
|
499
499
|
version: 1,
|
|
500
500
|
values: {
|
|
501
|
-
"
|
|
501
|
+
"a2a-channel": true,
|
|
502
502
|
},
|
|
503
503
|
}),
|
|
504
504
|
);
|
|
@@ -517,7 +517,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
517
517
|
// Both old and new values should be persisted
|
|
518
518
|
clearFeatureFlagStoreCache();
|
|
519
519
|
const persisted = readPersistedFeatureFlags();
|
|
520
|
-
expect(persisted["
|
|
520
|
+
expect(persisted["a2a-channel"]).toBe(true);
|
|
521
521
|
expect(persisted["browser"]).toBe(true);
|
|
522
522
|
});
|
|
523
523
|
|
|
@@ -527,12 +527,12 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
527
527
|
|
|
528
528
|
const handler = createFeatureFlagsPatchHandler();
|
|
529
529
|
const res = await handler(
|
|
530
|
-
new Request("http://gateway.test/v1/feature-flags/
|
|
530
|
+
new Request("http://gateway.test/v1/feature-flags/a2a-channel", {
|
|
531
531
|
method: "PATCH",
|
|
532
532
|
headers: { "content-type": "application/json" },
|
|
533
533
|
body: JSON.stringify({ enabled: true }),
|
|
534
534
|
}),
|
|
535
|
-
"
|
|
535
|
+
"a2a-channel",
|
|
536
536
|
);
|
|
537
537
|
|
|
538
538
|
expect(res.status).toBe(200);
|
|
@@ -540,7 +540,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
540
540
|
|
|
541
541
|
clearFeatureFlagStoreCache();
|
|
542
542
|
const persisted = readPersistedFeatureFlags();
|
|
543
|
-
expect(persisted["
|
|
543
|
+
expect(persisted["a2a-channel"]).toBe(true);
|
|
544
544
|
});
|
|
545
545
|
|
|
546
546
|
// Validation tests
|
|
@@ -633,7 +633,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
633
633
|
test("accepts valid declared kebab-case key formats", async () => {
|
|
634
634
|
const handler = createFeatureFlagsPatchHandler();
|
|
635
635
|
|
|
636
|
-
const validKeys = ["browser", "
|
|
636
|
+
const validKeys = ["browser", "a2a-channel"];
|
|
637
637
|
|
|
638
638
|
for (const key of validKeys) {
|
|
639
639
|
clearFeatureFlagStoreCache();
|
|
@@ -704,7 +704,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
704
704
|
featureFlagStorePath,
|
|
705
705
|
JSON.stringify({
|
|
706
706
|
version: 1,
|
|
707
|
-
values: { "
|
|
707
|
+
values: { "a2a-channel": true },
|
|
708
708
|
}),
|
|
709
709
|
);
|
|
710
710
|
clearFeatureFlagStoreCache();
|
|
@@ -723,7 +723,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
723
723
|
const raw = readFileSync(featureFlagStorePath, "utf-8");
|
|
724
724
|
const data = JSON.parse(raw);
|
|
725
725
|
expect(data.version).toBe(1);
|
|
726
|
-
expect(data.values["
|
|
726
|
+
expect(data.values["a2a-channel"]).toBe(true);
|
|
727
727
|
expect(data.values["browser"]).toBe(false);
|
|
728
728
|
|
|
729
729
|
// Verify no temp files left behind
|
|
@@ -737,7 +737,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
737
737
|
const handler = createFeatureFlagsPatchHandler();
|
|
738
738
|
|
|
739
739
|
// Fire multiple concurrent PATCH requests at the same time
|
|
740
|
-
const flagKeys = ["browser", "
|
|
740
|
+
const flagKeys = ["browser", "a2a-channel"];
|
|
741
741
|
|
|
742
742
|
const results = await Promise.all(
|
|
743
743
|
flagKeys.map((key) =>
|
|
@@ -30,11 +30,11 @@ const TEST_REGISTRY = {
|
|
|
30
30
|
defaultEnabled: true,
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
|
-
id: "
|
|
33
|
+
id: "a2a-channel",
|
|
34
34
|
scope: "assistant",
|
|
35
|
-
key: "
|
|
36
|
-
label: "
|
|
37
|
-
description: "
|
|
35
|
+
key: "a2a-channel",
|
|
36
|
+
label: "A2A Channel",
|
|
37
|
+
description: "A2A channel integration",
|
|
38
38
|
defaultEnabled: false,
|
|
39
39
|
},
|
|
40
40
|
{
|
|
@@ -167,9 +167,9 @@ describe("IPC feature flag routes", () => {
|
|
|
167
167
|
expect(res.result).toBeDefined();
|
|
168
168
|
|
|
169
169
|
const flags = res.result as Record<string, boolean>;
|
|
170
|
-
// browser defaults to true,
|
|
170
|
+
// browser defaults to true, a2a-channel defaults to false
|
|
171
171
|
expect(flags["browser"]).toBe(true);
|
|
172
|
-
expect(flags["
|
|
172
|
+
expect(flags["a2a-channel"]).toBe(false);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
test("get_feature_flags merges persisted values over defaults", async () => {
|
|
@@ -188,7 +188,7 @@ describe("IPC feature flag routes", () => {
|
|
|
188
188
|
expect(res.error).toBeUndefined();
|
|
189
189
|
const flags = res.result as Record<string, boolean>;
|
|
190
190
|
expect(flags["browser"]).toBe(false); // overridden from default true
|
|
191
|
-
expect(flags["
|
|
191
|
+
expect(flags["a2a-channel"]).toBe(false); // still default
|
|
192
192
|
});
|
|
193
193
|
|
|
194
194
|
test("get_feature_flags merges remote values when no local override", async () => {
|
|
@@ -196,7 +196,7 @@ describe("IPC feature flag routes", () => {
|
|
|
196
196
|
remoteFeatureFlagStorePath,
|
|
197
197
|
JSON.stringify({
|
|
198
198
|
version: 1,
|
|
199
|
-
values: { "
|
|
199
|
+
values: { "a2a-channel": true },
|
|
200
200
|
}),
|
|
201
201
|
);
|
|
202
202
|
clearRemoteFeatureFlagStoreCache();
|
|
@@ -212,15 +212,15 @@ describe("IPC feature flag routes", () => {
|
|
|
212
212
|
|
|
213
213
|
expect(res.error).toBeUndefined();
|
|
214
214
|
const flags = res.result as Record<string, boolean>;
|
|
215
|
-
expect(flags["
|
|
215
|
+
expect(flags["a2a-channel"]).toBe(true); // remote overrides default
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
test("get_feature_flags
|
|
218
|
+
test("get_feature_flags falls back to registry defaults for flags missing from a remote snapshot", async () => {
|
|
219
219
|
writeFileSync(
|
|
220
220
|
remoteFeatureFlagStorePath,
|
|
221
221
|
JSON.stringify({
|
|
222
222
|
version: 1,
|
|
223
|
-
values: { "
|
|
223
|
+
values: { "a2a-channel": true },
|
|
224
224
|
}),
|
|
225
225
|
);
|
|
226
226
|
clearRemoteFeatureFlagStoreCache();
|
|
@@ -235,8 +235,8 @@ describe("IPC feature flag routes", () => {
|
|
|
235
235
|
|
|
236
236
|
expect(res.error).toBeUndefined();
|
|
237
237
|
const flags = res.result as Record<string, boolean>;
|
|
238
|
-
expect(flags["
|
|
239
|
-
expect(flags["browser"]).toBe(
|
|
238
|
+
expect(flags["a2a-channel"]).toBe(true);
|
|
239
|
+
expect(flags["browser"]).toBe(true);
|
|
240
240
|
});
|
|
241
241
|
|
|
242
242
|
test("get_feature_flag returns value for a known flag", async () => {
|
|
@@ -77,10 +77,39 @@ function activeThreadRows(): Array<{ threadTs: string; channelId: string }> {
|
|
|
77
77
|
return new SlackStore(getGatewayDb()).listActiveThreadsWithChannel();
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function rawActiveThreadRows(): Array<{
|
|
81
|
+
threadTs: string;
|
|
82
|
+
channelId: string | null;
|
|
83
|
+
}> {
|
|
84
|
+
const rawDb = (getGatewayDb() as unknown as { $client: unknown }).$client as {
|
|
85
|
+
prepare: (sql: string) => {
|
|
86
|
+
all: () => Array<{ thread_ts: string; channel_id: string | null }>;
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
return rawDb
|
|
90
|
+
.prepare("SELECT thread_ts, channel_id FROM slack_active_threads")
|
|
91
|
+
.all()
|
|
92
|
+
.map((row) => ({
|
|
93
|
+
threadTs: row.thread_ts,
|
|
94
|
+
channelId: row.channel_id,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
function trackThread(): void {
|
|
81
99
|
new SlackStore(getGatewayDb()).trackThread(THREAD_TS, CHANNEL_ID, 60_000);
|
|
82
100
|
}
|
|
83
101
|
|
|
102
|
+
function trackLegacyThreadWithoutChannel(): void {
|
|
103
|
+
const rawDb = (getGatewayDb() as unknown as { $client: unknown }).$client as {
|
|
104
|
+
prepare: (sql: string) => { run: (...params: unknown[]) => void };
|
|
105
|
+
};
|
|
106
|
+
rawDb
|
|
107
|
+
.prepare(
|
|
108
|
+
"INSERT INTO slack_active_threads (thread_ts, channel_id, tracked_at, expires_at) VALUES (?, NULL, ?, ?)",
|
|
109
|
+
)
|
|
110
|
+
.run(THREAD_TS, Date.now(), Date.now() + 60_000);
|
|
111
|
+
}
|
|
112
|
+
|
|
84
113
|
describe("IPC Slack thread routes", () => {
|
|
85
114
|
let server: InstanceType<typeof GatewayIpcServer>;
|
|
86
115
|
let client: Socket;
|
|
@@ -135,6 +164,24 @@ describe("IPC Slack thread routes", () => {
|
|
|
135
164
|
]);
|
|
136
165
|
});
|
|
137
166
|
|
|
167
|
+
test("detach_slack_active_thread removes a legacy active thread without channel", async () => {
|
|
168
|
+
trackLegacyThreadWithoutChannel();
|
|
169
|
+
|
|
170
|
+
await startServerAndConnect();
|
|
171
|
+
const res = await sendRequest(client, "detach_slack_active_thread", {
|
|
172
|
+
channelId: CHANNEL_ID,
|
|
173
|
+
threadTs: THREAD_TS,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(res.error).toBeUndefined();
|
|
177
|
+
expect(res.result).toEqual({
|
|
178
|
+
detached: true,
|
|
179
|
+
channelId: CHANNEL_ID,
|
|
180
|
+
threadTs: THREAD_TS,
|
|
181
|
+
});
|
|
182
|
+
expect(rawActiveThreadRows()).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
138
185
|
test("detach_slack_active_thread does not remove channel mismatches", async () => {
|
|
139
186
|
trackThread();
|
|
140
187
|
|