@retinue/agentkit 0.1.0 → 0.3.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/README.md +59 -277
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/embeddings/openai.d.ts +45 -0
- package/dist/adapters/embeddings/openai.js +109 -0
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +99 -1
- package/dist/agents/agent.js +215 -16
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +48 -0
- package/dist/agents/engine.js +370 -13
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/capabilities/index.d.ts +5 -1
- package/dist/capabilities/index.js +23 -0
- package/dist/capabilities/runtime.d.ts +8 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/budget.d.ts +55 -0
- package/dist/core/budget.js +56 -0
- package/dist/core/content-parts.d.ts +26 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +110 -2
- package/dist/core/events.js +33 -2
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/validation.js +19 -0
- package/dist/documents/index.d.ts +14 -0
- package/dist/documents/parsers/text.d.ts +16 -0
- package/dist/documents/parsers/text.js +54 -2
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/guardrails.d.ts +14 -0
- package/dist/entries/guardrails.js +14 -0
- package/dist/entries/knowledge.d.ts +17 -0
- package/dist/entries/knowledge.js +16 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +7 -1
- package/dist/graphql/resolvers.js +20 -0
- package/dist/graphql/schema.d.ts +1 -1
- package/dist/graphql/schema.js +44 -0
- package/dist/guardrails/index.d.ts +115 -0
- package/dist/guardrails/index.js +108 -0
- package/dist/guardrails/moderation.d.ts +53 -0
- package/dist/guardrails/moderation.js +75 -0
- package/dist/guardrails/pii.d.ts +75 -0
- package/dist/guardrails/pii.js +193 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +25 -0
- package/dist/knowledge/index.js +37 -1
- package/dist/knowledge/navigate.d.ts +89 -0
- package/dist/knowledge/navigate.js +107 -0
- package/dist/knowledge/retrieval.d.ts +98 -5
- package/dist/knowledge/retrieval.js +158 -28
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +111 -1
- package/dist/models/streaming.js +184 -3
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +55 -0
- package/dist/security/findings.js +18 -9
- package/dist/server/bin.d.ts +19 -0
- package/dist/server/bin.js +226 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +7 -4
- package/dist/server/doctor.d.ts +74 -0
- package/dist/server/doctor.js +280 -0
- package/dist/skills/catalogue.d.ts +49 -0
- package/dist/skills/catalogue.js +61 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +2 -0
- package/dist/skills/index.js +2 -0
- package/dist/telemetry/spans.js +17 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/files.d.ts +125 -0
- package/dist/toolkit/files.js +320 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +31 -1
- package/dist/toolkit/index.js +26 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/sandbox.d.ts +119 -0
- package/dist/toolkit/sandbox.js +239 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/toolkit/web.d.ts +13 -0
- package/dist/toolkit/web.js +7 -1
- package/dist/tools/budget.d.ts +28 -0
- package/dist/tools/budget.js +35 -0
- package/dist/tools/credentials.d.ts +316 -0
- package/dist/tools/credentials.js +369 -0
- package/dist/tools/define.d.ts +45 -0
- package/dist/tools/define.js +26 -0
- package/dist/tools/find.d.ts +109 -0
- package/dist/tools/find.js +210 -0
- package/dist/tools/index.d.ts +35 -2
- package/dist/tools/index.js +11 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/fs.d.ts +24 -0
- package/dist/tools/library/fs.js +102 -0
- package/dist/tools/library/index.d.ts +37 -2
- package/dist/tools/library/index.js +47 -0
- package/dist/tools/library/shell.d.ts +45 -0
- package/dist/tools/library/shell.js +70 -0
- package/dist/tools/meta-tools.js +8 -0
- package/dist/tools/registry.d.ts +146 -0
- package/dist/tools/registry.js +250 -4
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- package/package.json +33 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SessionStateStore`, `ConversationBindingStore`, `ThreadSummaryStore` and `UnitOfWork`
|
|
3
|
+
* conformance — `docs/13-sessions-and-threads.md`.
|
|
4
|
+
*
|
|
5
|
+
* Session state is the deliberate replacement for Agno's fixed 20-turn re-injection (see the
|
|
6
|
+
* extraction inventory), so its optimistic concurrency and size ceiling are load-bearing: they are
|
|
7
|
+
* what stop two runs on one conversation from clobbering each other and what keep working memory
|
|
8
|
+
* from growing into a document store.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { asId } from "../../core/ids.js";
|
|
12
|
+
import { gatedIt } from "./capability.js";
|
|
13
|
+
import { withConversation } from "./parents.js";
|
|
14
|
+
const T1 = asId("conf-tenant-1");
|
|
15
|
+
const T2 = asId("conf-tenant-2");
|
|
16
|
+
const C1 = asId("conf-convo-1");
|
|
17
|
+
const AGENT = asId("conf-agent-1");
|
|
18
|
+
const MSG = asId("conf-msg-1");
|
|
19
|
+
export function sessionStateStoreConformance(makeFixture, options = {}) {
|
|
20
|
+
// Session state references a conversation, and Postgres enforces it with a foreign key (#97).
|
|
21
|
+
const open = () => withConversation(makeFixture(), [{ tenantId: T1, conversationId: C1 }]);
|
|
22
|
+
describe("SessionStateStore conformance", () => {
|
|
23
|
+
it("returns null before anything is written", async () => {
|
|
24
|
+
const store = await open();
|
|
25
|
+
expect(await store.get({ tenantId: T1, conversationId: C1 })).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
it("writes at expectedVersion 0 and reads back version 1", async () => {
|
|
28
|
+
const store = await open();
|
|
29
|
+
const put = await store.put({ tenantId: T1, conversationId: C1, expectedVersion: 0, data: { a: 1 } });
|
|
30
|
+
expect(put).toMatchObject({ version: 1, data: { a: 1 } });
|
|
31
|
+
expect(await store.get({ tenantId: T1, conversationId: C1 })).toMatchObject({ version: 1 });
|
|
32
|
+
});
|
|
33
|
+
it("increments the version on each write", async () => {
|
|
34
|
+
const store = await open();
|
|
35
|
+
await store.put({ tenantId: T1, conversationId: C1, expectedVersion: 0, data: { n: 1 } });
|
|
36
|
+
const second = await store.put({ tenantId: T1, conversationId: C1, expectedVersion: 1, data: { n: 2 } });
|
|
37
|
+
expect(second.version).toBe(2);
|
|
38
|
+
});
|
|
39
|
+
it("rejects a stale expectedVersion, so concurrent runs cannot clobber each other", async () => {
|
|
40
|
+
const store = await open();
|
|
41
|
+
await store.put({ tenantId: T1, conversationId: C1, expectedVersion: 0, data: { n: 1 } });
|
|
42
|
+
await expect(store.put({ tenantId: T1, conversationId: C1, expectedVersion: 0, data: { n: 99 } })).rejects.toThrow();
|
|
43
|
+
expect((await store.get({ tenantId: T1, conversationId: C1 }))?.data).toEqual({ n: 1 });
|
|
44
|
+
});
|
|
45
|
+
it("rejects a write beyond the size ceiling — working memory, not a document store", async () => {
|
|
46
|
+
const store = await open();
|
|
47
|
+
const maxBytes = options.maxBytes ?? 64 * 1024;
|
|
48
|
+
const oversized = { blob: "x".repeat(maxBytes + 1_000) };
|
|
49
|
+
await expect(store.put({ tenantId: T1, conversationId: C1, expectedVersion: 0, data: oversized })).rejects.toThrow();
|
|
50
|
+
});
|
|
51
|
+
it("enforces tenant isolation", async () => {
|
|
52
|
+
const store = await open();
|
|
53
|
+
await store.put({ tenantId: T1, conversationId: C1, expectedVersion: 0, data: { a: 1 } });
|
|
54
|
+
expect(await store.get({ tenantId: T2, conversationId: C1 })).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function conversationBindingStoreConformance(makeFixture) {
|
|
59
|
+
// A binding belongs to a conversation, and Postgres enforces that with a foreign key (#96). The
|
|
60
|
+
// in-memory adapter has no such constraint and passes no seeder — see ./parents.ts.
|
|
61
|
+
const open = () => withConversation(makeFixture(), [{ tenantId: T1, conversationId: C1 }]);
|
|
62
|
+
describe("ConversationBindingStore conformance", () => {
|
|
63
|
+
it("returns null for an unbound conversation rather than a default", async () => {
|
|
64
|
+
const store = await open();
|
|
65
|
+
expect(await store.get({ tenantId: T1, conversationId: C1 })).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
it("round-trips a pinned binding including the version", async () => {
|
|
68
|
+
const store = await open();
|
|
69
|
+
await store.bind({
|
|
70
|
+
tenantId: T1,
|
|
71
|
+
conversationId: C1,
|
|
72
|
+
agentId: AGENT,
|
|
73
|
+
agentVersionPolicy: "pinned",
|
|
74
|
+
agentVersion: 3,
|
|
75
|
+
});
|
|
76
|
+
expect(await store.get({ tenantId: T1, conversationId: C1 })).toMatchObject({
|
|
77
|
+
agentId: AGENT,
|
|
78
|
+
agentVersionPolicy: "pinned",
|
|
79
|
+
agentVersion: 3,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
it("round-trips a latest-policy binding", async () => {
|
|
83
|
+
const store = await open();
|
|
84
|
+
await store.bind({ tenantId: T1, conversationId: C1, agentId: AGENT, agentVersionPolicy: "latest" });
|
|
85
|
+
expect(await store.get({ tenantId: T1, conversationId: C1 })).toMatchObject({
|
|
86
|
+
agentVersionPolicy: "latest",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
it("re-binding is idempotent — the last write wins, without error", async () => {
|
|
90
|
+
const store = await open();
|
|
91
|
+
await store.bind({ tenantId: T1, conversationId: C1, agentId: AGENT, agentVersionPolicy: "latest" });
|
|
92
|
+
await store.bind({
|
|
93
|
+
tenantId: T1,
|
|
94
|
+
conversationId: C1,
|
|
95
|
+
agentId: AGENT,
|
|
96
|
+
agentVersionPolicy: "pinned",
|
|
97
|
+
agentVersion: 2,
|
|
98
|
+
});
|
|
99
|
+
expect(await store.get({ tenantId: T1, conversationId: C1 })).toMatchObject({ agentVersion: 2 });
|
|
100
|
+
});
|
|
101
|
+
it("enforces tenant isolation", async () => {
|
|
102
|
+
const store = await open();
|
|
103
|
+
await store.bind({ tenantId: T1, conversationId: C1, agentId: AGENT, agentVersionPolicy: "latest" });
|
|
104
|
+
expect(await store.get({ tenantId: T2, conversationId: C1 })).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export function threadSummaryStoreConformance(makeFixture) {
|
|
109
|
+
// Same foreign key as session state (#97).
|
|
110
|
+
const open = () => withConversation(makeFixture(), [{ tenantId: T1, conversationId: C1 }]);
|
|
111
|
+
describe("ThreadSummaryStore conformance", () => {
|
|
112
|
+
it("returns null before any summary exists", async () => {
|
|
113
|
+
const store = await open();
|
|
114
|
+
expect(await store.latest({ tenantId: T1, conversationId: C1 })).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
it("appends version 1 first", async () => {
|
|
117
|
+
const store = await open();
|
|
118
|
+
const s = await store.append({
|
|
119
|
+
tenantId: T1,
|
|
120
|
+
conversationId: C1,
|
|
121
|
+
summary: "first",
|
|
122
|
+
coversUpToMessageId: MSG,
|
|
123
|
+
});
|
|
124
|
+
expect(s).toMatchObject({ version: 1, summary: "first" });
|
|
125
|
+
});
|
|
126
|
+
it("versions successive summaries rather than overwriting", async () => {
|
|
127
|
+
const store = await open();
|
|
128
|
+
await store.append({ tenantId: T1, conversationId: C1, summary: "first", coversUpToMessageId: MSG });
|
|
129
|
+
const second = await store.append({
|
|
130
|
+
tenantId: T1,
|
|
131
|
+
conversationId: C1,
|
|
132
|
+
summary: "second",
|
|
133
|
+
coversUpToMessageId: MSG,
|
|
134
|
+
});
|
|
135
|
+
expect(second.version).toBe(2);
|
|
136
|
+
expect((await store.latest({ tenantId: T1, conversationId: C1 }))?.summary).toBe("second");
|
|
137
|
+
});
|
|
138
|
+
it("records how far the summary covers, so recent turns stay verbatim", async () => {
|
|
139
|
+
const store = await open();
|
|
140
|
+
await store.append({ tenantId: T1, conversationId: C1, summary: "s", coversUpToMessageId: MSG });
|
|
141
|
+
expect((await store.latest({ tenantId: T1, conversationId: C1 }))?.coversUpToMessageId).toBe(MSG);
|
|
142
|
+
});
|
|
143
|
+
it("enforces tenant isolation", async () => {
|
|
144
|
+
const store = await open();
|
|
145
|
+
await store.append({ tenantId: T1, conversationId: C1, summary: "s", coversUpToMessageId: MSG });
|
|
146
|
+
expect(await store.latest({ tenantId: T2, conversationId: C1 })).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
export function unitOfWorkConformance(makeFixture, declaration) {
|
|
151
|
+
/** A fresh backend per test, with its parent row seeded — so no case depends on another's order. */
|
|
152
|
+
const open = async () => {
|
|
153
|
+
const fixture = makeFixture();
|
|
154
|
+
// Committed before any transaction opens, so a rolled-back unit does not take the parent with it.
|
|
155
|
+
await fixture.seedConversation?.({ tenantId: T1, conversationId: C1 });
|
|
156
|
+
return fixture;
|
|
157
|
+
};
|
|
158
|
+
describe("UnitOfWork conformance", () => {
|
|
159
|
+
it("returns the callback's value on success", async () => {
|
|
160
|
+
const { unitOfWork } = await open();
|
|
161
|
+
expect(await unitOfWork.run(async () => 42)).toBe(42);
|
|
162
|
+
});
|
|
163
|
+
it("propagates the callback's error", async () => {
|
|
164
|
+
const { unitOfWork } = await open();
|
|
165
|
+
await expect(unitOfWork.run(async () => {
|
|
166
|
+
throw new Error("boom");
|
|
167
|
+
})).rejects.toThrow("boom");
|
|
168
|
+
});
|
|
169
|
+
/**
|
|
170
|
+
* Gated on `transactions` deliberately, and this gate documents a real divergence rather than
|
|
171
|
+
* hiding one. An adapter backed by a database transaction rolls a store write back with no
|
|
172
|
+
* cooperation from the caller. The in-memory reference adapter instead offers compensations via
|
|
173
|
+
* its own `runTx(tx => tx.onRollback(...))`, which the bare port cannot express — so through
|
|
174
|
+
* `run()` alone it rolls back nothing. Callers that rely on automatic rollback are therefore
|
|
175
|
+
* only portable to adapters declaring `transactions`. See the PR discussion on #91.
|
|
176
|
+
*
|
|
177
|
+
* As of #98 Postgres is the first adapter for which this gate opens, so the case finally runs
|
|
178
|
+
* somewhere instead of standing down everywhere — which is what AC-5 of #98 is really about.
|
|
179
|
+
*/
|
|
180
|
+
gatedIt(declaration, "transactions", "leaves no partial write behind when the unit fails", async () => {
|
|
181
|
+
const { unitOfWork, sessions } = await open();
|
|
182
|
+
await expect(unitOfWork.run(async () => {
|
|
183
|
+
await sessions.put({ tenantId: T1, conversationId: C1, expectedVersion: 0, data: { staged: true } });
|
|
184
|
+
throw new Error("fail after the write");
|
|
185
|
+
})).rejects.toThrow("fail after the write");
|
|
186
|
+
// The all-or-nothing guarantee: usage and session state commit together or not at all.
|
|
187
|
+
expect(await sessions.get({ tenantId: T1, conversationId: C1 })).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
it("commits the write when the unit succeeds", async () => {
|
|
190
|
+
const { unitOfWork, sessions } = await open();
|
|
191
|
+
await unitOfWork.run(async () => {
|
|
192
|
+
await sessions.put({ tenantId: T1, conversationId: C1, expectedVersion: 0, data: { committed: true } });
|
|
193
|
+
});
|
|
194
|
+
expect((await sessions.get({ tenantId: T1, conversationId: C1 }))?.data).toEqual({ committed: true });
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=session-state.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `UsageLimitStore` conformance — admin-configured spend limits (#175).
|
|
3
|
+
*
|
|
4
|
+
* The rule under test is **most specific wins, as a fallback and never a merge**. That distinction is the whole
|
|
5
|
+
* reason this harness exists: an adapter that merged a principal's cost limit with the tenant's token limit would
|
|
6
|
+
* produce a third set of limits nobody configured, and every individual assertion about either would still pass.
|
|
7
|
+
*/
|
|
8
|
+
import type { UsageLimitStore } from "../../persistence/index.js";
|
|
9
|
+
export declare function usageLimitStoreConformance(makeStore: () => UsageLimitStore): void;
|
|
10
|
+
//# sourceMappingURL=usage-limits.d.ts.map
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `UsageLimitStore` conformance — admin-configured spend limits (#175).
|
|
3
|
+
*
|
|
4
|
+
* The rule under test is **most specific wins, as a fallback and never a merge**. That distinction is the whole
|
|
5
|
+
* reason this harness exists: an adapter that merged a principal's cost limit with the tenant's token limit would
|
|
6
|
+
* produce a third set of limits nobody configured, and every individual assertion about either would still pass.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { asId } from "../../core/ids.js";
|
|
10
|
+
import { windowKey } from "../../persistence/index.js";
|
|
11
|
+
/**
|
|
12
|
+
* The window every pre-existing case in here uses — #181 widened `period` into a union, and spelling it out at
|
|
13
|
+
* thirty-odd sites would bury what each case is about.
|
|
14
|
+
*/
|
|
15
|
+
const MONTH = { kind: "calendar", period: "month" };
|
|
16
|
+
/** Five hours, the case a workspace admin actually asked for. */
|
|
17
|
+
const FIVE_HOURS = { kind: "rolling", minutes: 300 };
|
|
18
|
+
/** Two models, so a per-model limit can be shown *not* to bind on the other one — #182. */
|
|
19
|
+
const OPUS = "claude-opus-5";
|
|
20
|
+
const HAIKU = "claude-haiku-4-5";
|
|
21
|
+
const T1 = asId("conf-tenant-1");
|
|
22
|
+
const T2 = asId("conf-tenant-2");
|
|
23
|
+
const P1 = asId("conf-principal-1");
|
|
24
|
+
const P2 = asId("conf-principal-2");
|
|
25
|
+
export function usageLimitStoreConformance(makeStore) {
|
|
26
|
+
describe("UsageLimitStore conformance", () => {
|
|
27
|
+
it("returns null when nothing is configured, which means unbounded", async () => {
|
|
28
|
+
// Not zero. A store that returned a zeroed limit would refuse every run, and an outage is only visible to
|
|
29
|
+
// the customer it is happening to — where an unbounded quota is visible in the rollups.
|
|
30
|
+
expect(await makeStore().resolve({ tenantId: T1, principalId: P1, window: MONTH })).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
it("falls back to the tenant default when a principal has no override", async () => {
|
|
33
|
+
const store = makeStore();
|
|
34
|
+
await store.put({
|
|
35
|
+
tenantId: T1,
|
|
36
|
+
limit: { tenantId: T1, window: MONTH, costMinorUnits: 5000, updatedAt: "t" },
|
|
37
|
+
});
|
|
38
|
+
const resolved = await store.resolve({ tenantId: T1, principalId: P1, window: MONTH });
|
|
39
|
+
expect(resolved?.costMinorUnits).toBe(5000);
|
|
40
|
+
expect(resolved?.principalId).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
it("prefers a principal's own limit over the tenant default", async () => {
|
|
43
|
+
const store = makeStore();
|
|
44
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, costMinorUnits: 5000, updatedAt: "t" } });
|
|
45
|
+
await store.put({
|
|
46
|
+
tenantId: T1,
|
|
47
|
+
limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 100, updatedAt: "t" },
|
|
48
|
+
});
|
|
49
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH }))?.costMinorUnits).toBe(100);
|
|
50
|
+
// And leaves everyone else on the default.
|
|
51
|
+
expect((await store.resolve({ tenantId: T1, principalId: P2, window: MONTH }))?.costMinorUnits).toBe(5000);
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* A fallback, never a merge.
|
|
55
|
+
*
|
|
56
|
+
* The principal's row sets a cost limit and no token limit. Resolution must return *that row* — so the token
|
|
57
|
+
* limit is unbounded for them — rather than filling the gap from the tenant default. Merging would invent a
|
|
58
|
+
* limit nobody configured, and it would look correct in every test that checked one field at a time.
|
|
59
|
+
*/
|
|
60
|
+
it("does not merge a partial override with the tenant default", async () => {
|
|
61
|
+
const store = makeStore();
|
|
62
|
+
await store.put({
|
|
63
|
+
tenantId: T1,
|
|
64
|
+
limit: { tenantId: T1, window: MONTH, costMinorUnits: 5000, inputTokens: 1_000_000, updatedAt: "t" },
|
|
65
|
+
});
|
|
66
|
+
await store.put({
|
|
67
|
+
tenantId: T1,
|
|
68
|
+
limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 100, updatedAt: "t" },
|
|
69
|
+
});
|
|
70
|
+
const resolved = await store.resolve({ tenantId: T1, principalId: P1, window: MONTH });
|
|
71
|
+
expect(resolved?.costMinorUnits).toBe(100);
|
|
72
|
+
expect(resolved?.inputTokens).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
/**
|
|
75
|
+
* An omitted limit round-trips as **absent**, never as zero.
|
|
76
|
+
*
|
|
77
|
+
* The most dangerous direction in this whole feature, and the one my first pass at this harness missed: a
|
|
78
|
+
* store writing `0` for an unset limit refuses every run, and the quota code cannot tell that apart from an
|
|
79
|
+
* administrator setting a limit of zero on purpose. An outage is only visible to the customer it is happening
|
|
80
|
+
* to, where an unbounded quota is visible in the rollups.
|
|
81
|
+
*
|
|
82
|
+
* Caught by sabotage — replacing `?? null` with `?? 0` in the adapter passed every other test here.
|
|
83
|
+
*/
|
|
84
|
+
it("round-trips an omitted limit as absent, not as zero", async () => {
|
|
85
|
+
const store = makeStore();
|
|
86
|
+
await store.put({
|
|
87
|
+
tenantId: T1,
|
|
88
|
+
// Cost only. Tokens are deliberately unset, which means unbounded.
|
|
89
|
+
limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 100, updatedAt: "t" },
|
|
90
|
+
});
|
|
91
|
+
const resolved = await store.resolve({ tenantId: T1, principalId: P1, window: MONTH });
|
|
92
|
+
expect(resolved?.costMinorUnits).toBe(100);
|
|
93
|
+
expect(resolved?.inputTokens).toBeUndefined();
|
|
94
|
+
expect(resolved?.outputTokens).toBeUndefined();
|
|
95
|
+
expect(resolved?.warnAt).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
it("stores a deliberate zero as zero, which is a different statement from absent", async () => {
|
|
98
|
+
// An administrator setting a limit of zero is saying "this person may spend nothing", and that has to be
|
|
99
|
+
// distinguishable from "no limit configured" — otherwise the two collapse and one of them is wrong.
|
|
100
|
+
const store = makeStore();
|
|
101
|
+
await store.put({
|
|
102
|
+
tenantId: T1,
|
|
103
|
+
limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 0, updatedAt: "t" },
|
|
104
|
+
});
|
|
105
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH }))?.costMinorUnits).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
it("keeps periods independent", async () => {
|
|
108
|
+
// A monthly allowance says nothing about a weekly one. Resolving across periods would apply a month's
|
|
109
|
+
// budget to a week.
|
|
110
|
+
const store = makeStore();
|
|
111
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, costMinorUnits: 5000, updatedAt: "t" } });
|
|
112
|
+
expect(await store.resolve({ tenantId: T1, principalId: P1, window: { kind: "calendar", period: "week" } })).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
it("replaces rather than accumulates when a limit is edited", async () => {
|
|
115
|
+
// Otherwise `resolve` would have to disambiguate between rows, and "which one wins" becomes an accident of
|
|
116
|
+
// insertion order.
|
|
117
|
+
const store = makeStore();
|
|
118
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 100, updatedAt: "t" } });
|
|
119
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 250, updatedAt: "t" } });
|
|
120
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH }))?.costMinorUnits).toBe(250);
|
|
121
|
+
expect(await store.list({ tenantId: T1 })).toHaveLength(1);
|
|
122
|
+
});
|
|
123
|
+
it("removing an override falls back to the default rather than to zero", async () => {
|
|
124
|
+
const store = makeStore();
|
|
125
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, costMinorUnits: 5000, updatedAt: "t" } });
|
|
126
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 100, updatedAt: "t" } });
|
|
127
|
+
await store.remove({ tenantId: T1, principalId: P1, window: MONTH });
|
|
128
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH }))?.costMinorUnits).toBe(5000);
|
|
129
|
+
});
|
|
130
|
+
it("removing the tenant default leaves an override in place", async () => {
|
|
131
|
+
const store = makeStore();
|
|
132
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, costMinorUnits: 5000, updatedAt: "t" } });
|
|
133
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 100, updatedAt: "t" } });
|
|
134
|
+
await store.remove({ tenantId: T1, window: MONTH });
|
|
135
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH }))?.costMinorUnits).toBe(100);
|
|
136
|
+
expect(await store.resolve({ tenantId: T1, principalId: P2, window: MONTH })).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
it("records who changed a limit", async () => {
|
|
139
|
+
// A spend limit is the kind of setting somebody eventually has to explain.
|
|
140
|
+
const store = makeStore();
|
|
141
|
+
await store.put({
|
|
142
|
+
tenantId: T1,
|
|
143
|
+
limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 100, updatedAt: "t", updatedBy: "admin@example.com" },
|
|
144
|
+
});
|
|
145
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH }))?.updatedBy).toBe("admin@example.com");
|
|
146
|
+
});
|
|
147
|
+
it("enforces tenant isolation", async () => {
|
|
148
|
+
const store = makeStore();
|
|
149
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, costMinorUnits: 5000, updatedAt: "t" } });
|
|
150
|
+
expect(await store.resolve({ tenantId: T2, principalId: P1, window: MONTH })).toBeNull();
|
|
151
|
+
expect(await store.list({ tenantId: T2 })).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
/** The rolling window an admin can now configure — #181. */
|
|
154
|
+
it("stores and resolves a rolling window", async () => {
|
|
155
|
+
const store = makeStore();
|
|
156
|
+
await store.put({
|
|
157
|
+
tenantId: T1,
|
|
158
|
+
limit: { tenantId: T1, principalId: P1, window: FIVE_HOURS, costMinorUnits: 500, updatedAt: "t" },
|
|
159
|
+
});
|
|
160
|
+
const resolved = await store.resolve({ tenantId: T1, principalId: P1, window: FIVE_HOURS });
|
|
161
|
+
// Round-tripped as the union, not as whatever string the adapter happens to store — the guard branches on
|
|
162
|
+
// `kind`, so a rolling row coming back as a calendar one would enforce the wrong span silently.
|
|
163
|
+
expect(resolved?.window).toEqual(FIVE_HOURS);
|
|
164
|
+
expect(resolved?.costMinorUnits).toBe(500);
|
|
165
|
+
});
|
|
166
|
+
it("keeps a rolling window and a calendar one as separate limits", async () => {
|
|
167
|
+
const store = makeStore();
|
|
168
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 10_000, updatedAt: "t" } });
|
|
169
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: FIVE_HOURS, costMinorUnits: 500, updatedAt: "t" } });
|
|
170
|
+
// Both, because they are both real: "500 in any five hours **and** 10,000 a month" is the normal shape of
|
|
171
|
+
// a plan. If the window were not part of the key, the second `put` would have overwritten the first.
|
|
172
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH }))?.costMinorUnits).toBe(10_000);
|
|
173
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: FIVE_HOURS }))?.costMinorUnits).toBe(500);
|
|
174
|
+
expect(await store.list({ tenantId: T1 })).toHaveLength(2);
|
|
175
|
+
});
|
|
176
|
+
it("keeps two rolling windows of different lengths apart", async () => {
|
|
177
|
+
const store = makeStore();
|
|
178
|
+
const ninety = { kind: "rolling", minutes: 90 };
|
|
179
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: FIVE_HOURS, costMinorUnits: 500, updatedAt: "t" } });
|
|
180
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: ninety, costMinorUnits: 200, updatedAt: "t" } });
|
|
181
|
+
// The length is part of the identity. A key that dropped it would make every rolling limit the same row.
|
|
182
|
+
expect((await store.resolve({ tenantId: T1, window: FIVE_HOURS }))?.costMinorUnits).toBe(500);
|
|
183
|
+
expect((await store.resolve({ tenantId: T1, window: ninety }))?.costMinorUnits).toBe(200);
|
|
184
|
+
});
|
|
185
|
+
it("falls back from a principal's rolling window to the tenant's, and never merges them", async () => {
|
|
186
|
+
const store = makeStore();
|
|
187
|
+
await store.put({
|
|
188
|
+
tenantId: T1,
|
|
189
|
+
limit: { tenantId: T1, window: FIVE_HOURS, costMinorUnits: 5000, inputTokens: 1_000_000, updatedAt: "t" },
|
|
190
|
+
});
|
|
191
|
+
await store.put({
|
|
192
|
+
tenantId: T1,
|
|
193
|
+
limit: { tenantId: T1, principalId: P1, window: FIVE_HOURS, costMinorUnits: 100, updatedAt: "t" },
|
|
194
|
+
});
|
|
195
|
+
const resolved = await store.resolve({ tenantId: T1, principalId: P1, window: FIVE_HOURS });
|
|
196
|
+
expect(resolved?.costMinorUnits).toBe(100);
|
|
197
|
+
// The rule #175 established, restated for the new window kind: a fallback, never a merge. Inheriting the
|
|
198
|
+
// tenant's token limit alongside the principal's cost limit would be a third set nobody configured.
|
|
199
|
+
expect(resolved?.inputTokens).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
it("removes a rolling window without touching the calendar one beside it", async () => {
|
|
202
|
+
const store = makeStore();
|
|
203
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 10_000, updatedAt: "t" } });
|
|
204
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: FIVE_HOURS, costMinorUnits: 500, updatedAt: "t" } });
|
|
205
|
+
await store.remove({ tenantId: T1, principalId: P1, window: FIVE_HOURS });
|
|
206
|
+
expect(await store.resolve({ tenantId: T1, principalId: P1, window: FIVE_HOURS })).toBeNull();
|
|
207
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH }))?.costMinorUnits).toBe(10_000);
|
|
208
|
+
});
|
|
209
|
+
/**
|
|
210
|
+
* Per-model limits, and the rule that makes several limits coexist — #182.
|
|
211
|
+
*
|
|
212
|
+
* `applicable` is the surface the guard actually uses, so these are the cases that decide whether a
|
|
213
|
+
* configured limit is enforced at all.
|
|
214
|
+
*/
|
|
215
|
+
it("returns every limit in force, not the most specific one", async () => {
|
|
216
|
+
const store = makeStore();
|
|
217
|
+
// A personal five-hour cap, a workspace monthly cap, and a workspace cap on one model.
|
|
218
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: FIVE_HOURS, costMinorUnits: 500, updatedAt: "t" } });
|
|
219
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, costMinorUnits: 100_000, updatedAt: "t" } });
|
|
220
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, modelId: OPUS, costMinorUnits: 2_000, updatedAt: "t" } });
|
|
221
|
+
const applicable = await store.applicable({ tenantId: T1, principalId: P1, modelId: OPUS });
|
|
222
|
+
// All three. Returning only the "most specific" was how a workspace-wide Opus cap went unenforced for
|
|
223
|
+
// anybody who also had a personal limit — configured, visible, and never read.
|
|
224
|
+
expect(applicable).toHaveLength(3);
|
|
225
|
+
expect(applicable.map((r) => r.costMinorUnits).sort((a, b) => a - b)).toEqual([500, 2_000, 100_000]);
|
|
226
|
+
});
|
|
227
|
+
it("lets a principal's row override the tenant's within a scope, and only within it", async () => {
|
|
228
|
+
const store = makeStore();
|
|
229
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, costMinorUnits: 100_000, updatedAt: "t" } });
|
|
230
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 5_000, updatedAt: "t" } });
|
|
231
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: FIVE_HOURS, costMinorUnits: 900, updatedAt: "t" } });
|
|
232
|
+
const applicable = await store.applicable({ tenantId: T1, principalId: P1 });
|
|
233
|
+
// Two scopes, two rows: the month is Alice's 5,000 (hers replaces the workspace's), and the five-hour is
|
|
234
|
+
// still the workspace's 900 — an override in one scope must not silence a different scope.
|
|
235
|
+
expect(applicable).toHaveLength(2);
|
|
236
|
+
const month = applicable.find((r) => windowKey(r.window) === "month");
|
|
237
|
+
const rolling = applicable.find((r) => windowKey(r.window) === "rolling:300");
|
|
238
|
+
expect(month?.costMinorUnits).toBe(5_000);
|
|
239
|
+
expect(month?.principalId).toBe(P1);
|
|
240
|
+
expect(rolling?.costMinorUnits).toBe(900);
|
|
241
|
+
expect(rolling?.principalId).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
it("applies a model-scoped limit only to that model", async () => {
|
|
244
|
+
const store = makeStore();
|
|
245
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, modelId: OPUS, costMinorUnits: 2_000, updatedAt: "t" } });
|
|
246
|
+
expect(await store.applicable({ tenantId: T1, principalId: P1, modelId: OPUS })).toHaveLength(1);
|
|
247
|
+
// A cheap model is not subject to the expensive model's allowance, which is the entire point of scoping.
|
|
248
|
+
expect(await store.applicable({ tenantId: T1, principalId: P1, modelId: HAIKU })).toHaveLength(0);
|
|
249
|
+
});
|
|
250
|
+
it("does not apply a model-scoped limit when no model is given", async () => {
|
|
251
|
+
const store = makeStore();
|
|
252
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, modelId: OPUS, costMinorUnits: 2_000, updatedAt: "t" } });
|
|
253
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, costMinorUnits: 100_000, updatedAt: "t" } });
|
|
254
|
+
const applicable = await store.applicable({ tenantId: T1, principalId: P1 });
|
|
255
|
+
// Only the unscoped one. An unknown model cannot be checked against a per-model allowance, and applying it
|
|
256
|
+
// anyway would refuse work on a model the limit was never about.
|
|
257
|
+
expect(applicable).toHaveLength(1);
|
|
258
|
+
expect(applicable[0]?.modelId).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
it("keeps a model-scoped limit and an unscoped one as separate rows", async () => {
|
|
261
|
+
const store = makeStore();
|
|
262
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 100_000, updatedAt: "t" } });
|
|
263
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, modelId: OPUS, costMinorUnits: 2_000, updatedAt: "t" } });
|
|
264
|
+
// If the model were not part of the key, the second `put` would have overwritten the first — the person's
|
|
265
|
+
// overall monthly allowance would silently become 2,000.
|
|
266
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH }))?.costMinorUnits).toBe(100_000);
|
|
267
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH, modelId: OPUS }))?.costMinorUnits).toBe(2_000);
|
|
268
|
+
expect(await store.list({ tenantId: T1 })).toHaveLength(2);
|
|
269
|
+
});
|
|
270
|
+
it("removes a model-scoped limit without touching the unscoped one", async () => {
|
|
271
|
+
const store = makeStore();
|
|
272
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, costMinorUnits: 100_000, updatedAt: "t" } });
|
|
273
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: MONTH, modelId: OPUS, costMinorUnits: 2_000, updatedAt: "t" } });
|
|
274
|
+
await store.remove({ tenantId: T1, principalId: P1, window: MONTH, modelId: OPUS });
|
|
275
|
+
expect(await store.resolve({ tenantId: T1, principalId: P1, window: MONTH, modelId: OPUS })).toBeNull();
|
|
276
|
+
expect((await store.resolve({ tenantId: T1, principalId: P1, window: MONTH }))?.costMinorUnits).toBe(100_000);
|
|
277
|
+
});
|
|
278
|
+
it("does not return another principal's limit", async () => {
|
|
279
|
+
const store = makeStore();
|
|
280
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P2, window: MONTH, costMinorUnits: 1, updatedAt: "t" } });
|
|
281
|
+
// P2's row is not P1's business, and a guard that saw it would refuse the wrong person.
|
|
282
|
+
expect(await store.applicable({ tenantId: T1, principalId: P1 })).toHaveLength(0);
|
|
283
|
+
});
|
|
284
|
+
it("does not return another tenant's limits", async () => {
|
|
285
|
+
const store = makeStore();
|
|
286
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, costMinorUnits: 5, updatedAt: "t" } });
|
|
287
|
+
expect(await store.applicable({ tenantId: T2, principalId: P1 })).toHaveLength(0);
|
|
288
|
+
});
|
|
289
|
+
it("keys a window the same way in every adapter", () => {
|
|
290
|
+
// Not a store test — a guard on the codec both adapters key on. The memory store builds its map key from
|
|
291
|
+
// `windowKey` and the SQL store writes the same string to `window_key`; if the two ever spelled a window
|
|
292
|
+
// differently, both would stay self-consistent and neither could read the other's rows, and every case
|
|
293
|
+
// above would still pass.
|
|
294
|
+
expect(windowKey(MONTH)).toBe("month");
|
|
295
|
+
expect(windowKey(FIVE_HOURS)).toBe("rolling:300");
|
|
296
|
+
});
|
|
297
|
+
it("lists every configured limit for an admin screen, in a stable order", async () => {
|
|
298
|
+
const store = makeStore();
|
|
299
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P2, window: MONTH, costMinorUnits: 1, updatedAt: "t" } });
|
|
300
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, window: MONTH, costMinorUnits: 2, updatedAt: "t" } });
|
|
301
|
+
await store.put({ tenantId: T1, limit: { tenantId: T1, principalId: P1, window: { kind: "calendar", period: "week" }, costMinorUnits: 3, updatedAt: "t" } });
|
|
302
|
+
const listed = await store.list({ tenantId: T1 });
|
|
303
|
+
expect(listed).toHaveLength(3);
|
|
304
|
+
// The tenant default first, then principals — so a screen does not reshuffle between refreshes.
|
|
305
|
+
expect(listed[0]?.principalId).toBeUndefined();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
//# sourceMappingURL=usage-limits.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export shim. The suite grew from one harness into `./conformance/` (see #91); this file keeps
|
|
3
|
+
* the original import path working so existing adapter tests did not have to change in the same PR.
|
|
4
|
+
*
|
|
5
|
+
* Prefer importing from `../testing/conformance/index.js` in new code.
|
|
6
|
+
*/
|
|
7
|
+
export * from "./conformance/index.js";
|
|
8
|
+
//# sourceMappingURL=conformance.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export shim. The suite grew from one harness into `./conformance/` (see #91); this file keeps
|
|
3
|
+
* the original import path working so existing adapter tests did not have to change in the same PR.
|
|
4
|
+
*
|
|
5
|
+
* Prefer importing from `../testing/conformance/index.js` in new code.
|
|
6
|
+
*/
|
|
7
|
+
export * from "./conformance/index.js";
|
|
8
|
+
//# sourceMappingURL=conformance.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every in-memory store, assembled — task #253 AC-5.
|
|
3
|
+
*
|
|
4
|
+
* Fifteen `createMemory*` calls is the current price of a test that needs a working backend, and the reference
|
|
5
|
+
* host pays it in `examples/src/memory-app.ts`. Every consumer testing an agent would rebuild the same list,
|
|
6
|
+
* and the sixteenth store to land would be missing from all of them — silently, because a missing store is a
|
|
7
|
+
* missing dependency and a caller that does not use it never notices.
|
|
8
|
+
*
|
|
9
|
+
* ## Called, not shared
|
|
10
|
+
*
|
|
11
|
+
* A factory per assembly, because here the factory **is** the state. `examples/src/memory-app.ts` records what
|
|
12
|
+
* calling it twice does: two empty worlds, and a message that vanishes between being written and being read.
|
|
13
|
+
* The Postgres adapters take an executor and hold nothing, which is why they are safe to construct per call and
|
|
14
|
+
* these are not.
|
|
15
|
+
*/
|
|
16
|
+
export declare const createMemoryStores: () => {
|
|
17
|
+
conversations: import("../index.js").ConversationStore;
|
|
18
|
+
messages: import("../index.js").MessageStore;
|
|
19
|
+
runs: import("../index.js").RunStore;
|
|
20
|
+
checkpoints: import("../index.js").CheckpointStore;
|
|
21
|
+
eventLog: import("../index.js").RunEventLog;
|
|
22
|
+
interactions: import("../index.js").InteractionStore;
|
|
23
|
+
grants: import("../index.js").ApprovalGrantStore;
|
|
24
|
+
sessions: import("../index.js").SessionStateStore;
|
|
25
|
+
summaries: import("../index.js").ThreadSummaryStore;
|
|
26
|
+
idempotency: import("../index.js").IdempotencyStore;
|
|
27
|
+
principalMemory: import("../index.js").PrincipalMemoryStore;
|
|
28
|
+
skills: import("../index.js").SkillStore & {
|
|
29
|
+
add(tenantId: string, skill: import("../index.js").SkillVersion): void;
|
|
30
|
+
};
|
|
31
|
+
usage: import("../index.js").UsageStore;
|
|
32
|
+
rollups: import("../index.js").UsageRollupStore;
|
|
33
|
+
limits: import("../index.js").UsageLimitStore;
|
|
34
|
+
coordinator: import("../index.js").ConversationRunCoordinator;
|
|
35
|
+
/** #248. Single-process by construction — see the adapter's own header before using it anywhere real. */
|
|
36
|
+
rateLimit: import("../index.js").RateLimitStore;
|
|
37
|
+
};
|
|
38
|
+
export type MemoryStores = ReturnType<typeof createMemoryStores>;
|
|
39
|
+
//# sourceMappingURL=memory-backend.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every in-memory store, assembled — task #253 AC-5.
|
|
3
|
+
*
|
|
4
|
+
* Fifteen `createMemory*` calls is the current price of a test that needs a working backend, and the reference
|
|
5
|
+
* host pays it in `examples/src/memory-app.ts`. Every consumer testing an agent would rebuild the same list,
|
|
6
|
+
* and the sixteenth store to land would be missing from all of them — silently, because a missing store is a
|
|
7
|
+
* missing dependency and a caller that does not use it never notices.
|
|
8
|
+
*
|
|
9
|
+
* ## Called, not shared
|
|
10
|
+
*
|
|
11
|
+
* A factory per assembly, because here the factory **is** the state. `examples/src/memory-app.ts` records what
|
|
12
|
+
* calling it twice does: two empty worlds, and a message that vanishes between being written and being read.
|
|
13
|
+
* The Postgres adapters take an executor and hold nothing, which is why they are safe to construct per call and
|
|
14
|
+
* these are not.
|
|
15
|
+
*/
|
|
16
|
+
import { createMemoryApprovalGrantStore, createMemoryCheckpointStore, createMemoryConversationRunCoordinator, createMemoryConversationStore, createMemoryIdempotencyStore, createMemoryInteractionStore, createMemoryMessageStore, createMemoryPrincipalMemoryStore, createMemoryRunEventLog, createMemoryRunStore, createMemorySessionStateStore, createMemorySkillStore, createMemoryThreadSummaryStore, createMemoryUsageBackend, createMemoryUsageLimitStore, } from "../adapters/memory/index.js";
|
|
17
|
+
import { createMemoryRateLimitStore } from "../adapters/memory/rate-limit.js";
|
|
18
|
+
export const createMemoryStores = () => {
|
|
19
|
+
const usage = createMemoryUsageBackend();
|
|
20
|
+
return {
|
|
21
|
+
conversations: createMemoryConversationStore(),
|
|
22
|
+
messages: createMemoryMessageStore(),
|
|
23
|
+
runs: createMemoryRunStore(),
|
|
24
|
+
checkpoints: createMemoryCheckpointStore(),
|
|
25
|
+
eventLog: createMemoryRunEventLog(),
|
|
26
|
+
interactions: createMemoryInteractionStore(),
|
|
27
|
+
grants: createMemoryApprovalGrantStore(),
|
|
28
|
+
sessions: createMemorySessionStateStore(),
|
|
29
|
+
summaries: createMemoryThreadSummaryStore(),
|
|
30
|
+
idempotency: createMemoryIdempotencyStore(),
|
|
31
|
+
principalMemory: createMemoryPrincipalMemoryStore(),
|
|
32
|
+
skills: createMemorySkillStore(),
|
|
33
|
+
usage: usage.usage,
|
|
34
|
+
rollups: usage.rollups,
|
|
35
|
+
limits: createMemoryUsageLimitStore(),
|
|
36
|
+
coordinator: createMemoryConversationRunCoordinator(),
|
|
37
|
+
/** #248. Single-process by construction — see the adapter's own header before using it anywhere real. */
|
|
38
|
+
rateLimit: createMemoryRateLimitStore(),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=memory-backend.js.map
|