@retinue/agentkit 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- 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/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 +78 -1
- package/dist/agents/agent.js +118 -5
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +20 -0
- package/dist/agents/engine.js +177 -6
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -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/content-parts.d.ts +18 -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 +44 -2
- package/dist/core/events.js +31 -2
- package/dist/core/validation.js +19 -0
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/knowledge.d.ts +8 -0
- package/dist/entries/knowledge.js +8 -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 +3 -1
- package/dist/graphql/resolvers.js +14 -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 +24 -0
- package/dist/knowledge/index.js +36 -1
- package/dist/knowledge/retrieval.d.ts +26 -1
- package/dist/knowledge/retrieval.js +77 -1
- 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 +89 -0
- package/dist/models/streaming.js +179 -2
- 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 +46 -0
- package/dist/server/bin.d.ts +72 -0
- package/dist/server/bin.js +304 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli-worker.js +6 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +10 -5
- package/dist/server/config.d.ts +11 -1
- package/dist/server/config.js +18 -0
- package/dist/server/doctor.d.ts +85 -0
- package/dist/server/doctor.js +280 -0
- package/dist/server/pool.d.ts +47 -0
- package/dist/server/pool.js +73 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +5 -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/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +27 -1
- package/dist/toolkit/index.js +24 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -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/tools/credentials.d.ts +265 -6
- package/dist/tools/credentials.js +318 -3
- package/dist/tools/define.d.ts +14 -0
- package/dist/tools/define.js +3 -0
- package/dist/tools/index.d.ts +21 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/index.d.ts +8 -0
- package/dist/tools/library/index.js +7 -0
- package/dist/tools/registry.d.ts +33 -0
- package/dist/tools/registry.js +72 -2
- 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 +29 -2
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ArtifactStore` conformance (#133) — AC-6.
|
|
3
|
+
*
|
|
4
|
+
* The cases are mostly about **versioning**, because that is the part where two adapters can plausibly
|
|
5
|
+
* disagree and where disagreeing is expensive: an earlier version that stops resolving is a shared link that
|
|
6
|
+
* 404s, and a race that silently collapses two regenerations into one is data loss that looks like success.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { asId } from "../../core/ids.js";
|
|
10
|
+
import { withConversation } from "./parents.js";
|
|
11
|
+
const T1 = asId("conf-artifact-tenant-1");
|
|
12
|
+
const T2 = asId("conf-artifact-tenant-2");
|
|
13
|
+
const C1 = asId("conf-artifact-convo-1");
|
|
14
|
+
const C2 = asId("conf-artifact-convo-2");
|
|
15
|
+
const AT = "2026-08-23T12:00:00.000Z";
|
|
16
|
+
const USER = asId("conf-artifact-user");
|
|
17
|
+
const version = (overrides = {}) => ({
|
|
18
|
+
id: asId(`v-${Math.abs(hash(JSON.stringify(overrides)))}`),
|
|
19
|
+
contentRef: asId("blob-1"),
|
|
20
|
+
byteSize: 128,
|
|
21
|
+
provenance: { producedBy: "create_artifact", inputs: { topic: "q3" } },
|
|
22
|
+
createdBy: USER,
|
|
23
|
+
createdAt: AT,
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
/** Deterministic, because `Math.random` in a fixture makes a failure unreproducible. */
|
|
27
|
+
const hash = (s) => {
|
|
28
|
+
let h = 0;
|
|
29
|
+
for (const ch of s)
|
|
30
|
+
h = (h * 31 + ch.charCodeAt(0)) | 0;
|
|
31
|
+
return h;
|
|
32
|
+
};
|
|
33
|
+
const artifact = (overrides = {}) => ({
|
|
34
|
+
id: asId(overrides.id ?? "a1"),
|
|
35
|
+
conversationId: overrides.conversationId ?? C1,
|
|
36
|
+
kind: "markdown",
|
|
37
|
+
name: overrides.name ?? "Q3 summary",
|
|
38
|
+
createdAt: AT,
|
|
39
|
+
});
|
|
40
|
+
export function artifactStoreConformance(make) {
|
|
41
|
+
describe("ArtifactStore conformance", () => {
|
|
42
|
+
const open = async (conversations = [C1]) => withConversation(await make(), conversations.flatMap((conversationId) => [
|
|
43
|
+
{ tenantId: T1, conversationId },
|
|
44
|
+
{ tenantId: T2, conversationId },
|
|
45
|
+
]));
|
|
46
|
+
it("creates an artifact at version 1 with its content by reference", async () => {
|
|
47
|
+
// AC-1 and AC-5 together: the row carries a ref, and there is no field content could live in.
|
|
48
|
+
const store = await open();
|
|
49
|
+
const created = await store.create({
|
|
50
|
+
tenantId: T1,
|
|
51
|
+
artifact: artifact(),
|
|
52
|
+
version: version({ id: asId("v1"), contentRef: asId("blob-a") }),
|
|
53
|
+
});
|
|
54
|
+
expect(created).toMatchObject({ id: "a1", name: "Q3 summary", kind: "markdown", latestVersion: 1 });
|
|
55
|
+
const v1 = await store.getVersion({ tenantId: T1, id: asId("a1") });
|
|
56
|
+
expect(v1).toMatchObject({ version: 1, contentRef: "blob-a", byteSize: 128 });
|
|
57
|
+
// Nothing resembling content on the version row itself.
|
|
58
|
+
expect(Object.keys(v1 ?? {})).not.toContain("content");
|
|
59
|
+
});
|
|
60
|
+
it("refuses to create the same id twice", async () => {
|
|
61
|
+
// Overwriting would repoint a name at different content while leaving the old versions attached to it.
|
|
62
|
+
const store = await open();
|
|
63
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
64
|
+
await expect(store.create({ tenantId: T1, artifact: artifact(), version: version() })).rejects.toMatchObject({ code: "conflict" });
|
|
65
|
+
});
|
|
66
|
+
it("returns null for another tenant's artifact", async () => {
|
|
67
|
+
// Indistinguishable from absent, so the endpoint cannot be used to probe which ids exist.
|
|
68
|
+
const store = await open();
|
|
69
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
70
|
+
expect(await store.get({ tenantId: T2, id: asId("a1") })).toBeNull();
|
|
71
|
+
expect(await store.getVersion({ tenantId: T2, id: asId("a1") })).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
it("makes a new version the default and keeps the earlier one resolvable", async () => {
|
|
74
|
+
// AC-2, both halves. The second half is the one that breaks quietly: an implementation that overwrote
|
|
75
|
+
// would pass every assertion about the *new* version.
|
|
76
|
+
const store = await open();
|
|
77
|
+
await store.create({
|
|
78
|
+
tenantId: T1,
|
|
79
|
+
artifact: artifact(),
|
|
80
|
+
version: version({ id: asId("v1"), contentRef: asId("blob-1") }),
|
|
81
|
+
});
|
|
82
|
+
const added = await store.addVersion({
|
|
83
|
+
tenantId: T1,
|
|
84
|
+
id: asId("a1"),
|
|
85
|
+
expectedLatestVersion: 1,
|
|
86
|
+
version: version({
|
|
87
|
+
id: asId("v2"),
|
|
88
|
+
contentRef: asId("blob-2"),
|
|
89
|
+
createdAt: "2026-08-23T13:00:00.000Z",
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
expect(added).toEqual({ added: true, version: 2 });
|
|
93
|
+
expect(await store.get({ tenantId: T1, id: asId("a1") })).toMatchObject({ latestVersion: 2 });
|
|
94
|
+
// No version asked for: the current one.
|
|
95
|
+
expect(await store.getVersion({ tenantId: T1, id: asId("a1") })).toMatchObject({
|
|
96
|
+
version: 2,
|
|
97
|
+
contentRef: "blob-2",
|
|
98
|
+
});
|
|
99
|
+
// Asked for by number: still there, still pointing at its own content.
|
|
100
|
+
expect(await store.getVersion({ tenantId: T1, id: asId("a1"), version: 1 })).toMatchObject({
|
|
101
|
+
version: 1,
|
|
102
|
+
contentRef: "blob-1",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
it("lets exactly one of two racing regenerations win", async () => {
|
|
106
|
+
// Both hold `expectedLatestVersion: 1`. Without the compare both become version 2 and one silently
|
|
107
|
+
// replaces the other -- which is AC-2 failing in the way nobody notices.
|
|
108
|
+
const store = await open();
|
|
109
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
110
|
+
const results = await Promise.all([
|
|
111
|
+
store.addVersion({
|
|
112
|
+
tenantId: T1,
|
|
113
|
+
id: asId("a1"),
|
|
114
|
+
expectedLatestVersion: 1,
|
|
115
|
+
version: version({ id: asId("race-a") }),
|
|
116
|
+
}),
|
|
117
|
+
store.addVersion({
|
|
118
|
+
tenantId: T1,
|
|
119
|
+
id: asId("a1"),
|
|
120
|
+
expectedLatestVersion: 1,
|
|
121
|
+
version: version({ id: asId("race-b") }),
|
|
122
|
+
}),
|
|
123
|
+
]);
|
|
124
|
+
expect(results.filter((r) => r.added)).toHaveLength(1);
|
|
125
|
+
expect(await store.get({ tenantId: T1, id: asId("a1") })).toMatchObject({ latestVersion: 2 });
|
|
126
|
+
});
|
|
127
|
+
it("refuses a version whose expected latest is stale", async () => {
|
|
128
|
+
const store = await open();
|
|
129
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
130
|
+
const result = await store.addVersion({
|
|
131
|
+
tenantId: T1,
|
|
132
|
+
id: asId("a1"),
|
|
133
|
+
// Wrong on purpose: a caller working from a read that has since moved on.
|
|
134
|
+
expectedLatestVersion: 7,
|
|
135
|
+
version: version({ id: asId("stale") }),
|
|
136
|
+
});
|
|
137
|
+
expect(result).toEqual({ added: false });
|
|
138
|
+
expect(await store.get({ tenantId: T1, id: asId("a1") })).toMatchObject({ latestVersion: 1 });
|
|
139
|
+
});
|
|
140
|
+
it("does not add a version to another tenant's artifact", async () => {
|
|
141
|
+
const store = await open();
|
|
142
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
143
|
+
const result = await store.addVersion({
|
|
144
|
+
tenantId: T2,
|
|
145
|
+
id: asId("a1"),
|
|
146
|
+
expectedLatestVersion: 1,
|
|
147
|
+
version: version({ id: asId("cross") }),
|
|
148
|
+
});
|
|
149
|
+
expect(result).toEqual({ added: false });
|
|
150
|
+
expect(await store.get({ tenantId: T1, id: asId("a1") })).toMatchObject({ latestVersion: 1 });
|
|
151
|
+
});
|
|
152
|
+
it("records the conversation, run and inputs that produced a version", async () => {
|
|
153
|
+
// AC-3. Every field, because provenance is stored as one JSON value and a mapper that dropped part of
|
|
154
|
+
// it would still return a plausible record.
|
|
155
|
+
const store = await open();
|
|
156
|
+
await store.create({
|
|
157
|
+
tenantId: T1,
|
|
158
|
+
artifact: artifact(),
|
|
159
|
+
version: version({
|
|
160
|
+
id: asId("v1"),
|
|
161
|
+
provenance: {
|
|
162
|
+
runId: asId("run-7"),
|
|
163
|
+
producedBy: "summarize_document",
|
|
164
|
+
inputs: { fileId: "file-3", sections: ["revenue"] },
|
|
165
|
+
sourceFileIds: [asId("file-3")],
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
const v1 = await store.getVersion({ tenantId: T1, id: asId("a1") });
|
|
170
|
+
// The conversation comes from the artifact -- it owns the artifact, so duplicating it per version would
|
|
171
|
+
// be a second place for it to disagree.
|
|
172
|
+
expect(await store.get({ tenantId: T1, id: asId("a1") })).toMatchObject({
|
|
173
|
+
conversationId: C1,
|
|
174
|
+
});
|
|
175
|
+
expect(v1?.provenance).toEqual({
|
|
176
|
+
runId: "run-7",
|
|
177
|
+
producedBy: "summarize_document",
|
|
178
|
+
inputs: { fileId: "file-3", sections: ["revenue"] },
|
|
179
|
+
sourceFileIds: ["file-3"],
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
it("lists a conversation's artifacts, newest cursor last", async () => {
|
|
183
|
+
const store = await open([C1, C2]);
|
|
184
|
+
await store.create({
|
|
185
|
+
tenantId: T1,
|
|
186
|
+
artifact: artifact({ id: "a1", name: "first" }),
|
|
187
|
+
version: version({ id: asId("v-a1") }),
|
|
188
|
+
});
|
|
189
|
+
await store.create({
|
|
190
|
+
tenantId: T1,
|
|
191
|
+
artifact: artifact({ id: "a2", name: "second" }),
|
|
192
|
+
version: version({ id: asId("v-a2"), createdAt: "2026-08-23T13:00:00.000Z" }),
|
|
193
|
+
});
|
|
194
|
+
await store.create({
|
|
195
|
+
tenantId: T1,
|
|
196
|
+
artifact: artifact({ id: "a3", conversationId: C2, name: "elsewhere" }),
|
|
197
|
+
version: version({ id: asId("v-a3") }),
|
|
198
|
+
});
|
|
199
|
+
const page = await store.listByConversation({ tenantId: T1, conversationId: C1, limit: 10 });
|
|
200
|
+
expect(page.items.map((a) => a.id).sort()).toEqual(["a1", "a2"]);
|
|
201
|
+
});
|
|
202
|
+
it("pages a listing on a keyset cursor rather than an offset", async () => {
|
|
203
|
+
// An offset cursor shifts when a row is inserted, so a caller paging while an artifact is created
|
|
204
|
+
// either sees one twice or misses one.
|
|
205
|
+
const store = await open();
|
|
206
|
+
for (const n of [1, 2, 3, 4, 5]) {
|
|
207
|
+
await store.create({
|
|
208
|
+
tenantId: T1,
|
|
209
|
+
artifact: { ...artifact({ id: `a${n}` }), createdAt: `2026-08-23T1${n}:00:00.000Z` },
|
|
210
|
+
version: version({ id: asId(`v-a${n}`), createdAt: `2026-08-23T1${n}:00:00.000Z` }),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const seen = [];
|
|
214
|
+
let cursor;
|
|
215
|
+
do {
|
|
216
|
+
const page = await store.listByConversation({
|
|
217
|
+
tenantId: T1,
|
|
218
|
+
conversationId: C1,
|
|
219
|
+
limit: 2,
|
|
220
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
221
|
+
});
|
|
222
|
+
seen.push(...page.items.map((a) => a.id));
|
|
223
|
+
cursor = page.nextCursor;
|
|
224
|
+
} while (cursor !== undefined);
|
|
225
|
+
expect(seen).toEqual(["a1", "a2", "a3", "a4", "a5"]);
|
|
226
|
+
});
|
|
227
|
+
it("lists every version in order, oldest first", async () => {
|
|
228
|
+
// The history a restore reads. Order matters more here than anywhere else in this port.
|
|
229
|
+
const store = await open();
|
|
230
|
+
await store.create({
|
|
231
|
+
tenantId: T1,
|
|
232
|
+
artifact: artifact(),
|
|
233
|
+
version: version({ id: asId("v1") }),
|
|
234
|
+
});
|
|
235
|
+
for (const n of [2, 3, 4]) {
|
|
236
|
+
await store.addVersion({
|
|
237
|
+
tenantId: T1,
|
|
238
|
+
id: asId("a1"),
|
|
239
|
+
expectedLatestVersion: n - 1,
|
|
240
|
+
// Every version created in the same millisecond: a timestamp keyset would tie, and the version
|
|
241
|
+
// number cannot.
|
|
242
|
+
version: version({ id: asId(`v${n}`), createdAt: AT }),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const seen = [];
|
|
246
|
+
let cursor;
|
|
247
|
+
do {
|
|
248
|
+
const page = await store.listVersions({
|
|
249
|
+
tenantId: T1,
|
|
250
|
+
id: asId("a1"),
|
|
251
|
+
limit: 2,
|
|
252
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
253
|
+
});
|
|
254
|
+
seen.push(...page.items.map((v) => v.version));
|
|
255
|
+
cursor = page.nextCursor;
|
|
256
|
+
} while (cursor !== undefined);
|
|
257
|
+
expect(seen).toEqual([1, 2, 3, 4]);
|
|
258
|
+
});
|
|
259
|
+
it("returns no versions for another tenant's artifact", async () => {
|
|
260
|
+
const store = await open();
|
|
261
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
262
|
+
expect((await store.listVersions({ tenantId: T2, id: asId("a1"), limit: 10 })).items).toEqual([]);
|
|
263
|
+
});
|
|
264
|
+
it("hides a soft-deleted artifact from the conversation listing but keeps it resolvable", async () => {
|
|
265
|
+
// Both halves. The row is kept precisely so a shared link resolves to "deleted" rather than to nothing,
|
|
266
|
+
// and a listing that still showed it would be a deleted document coming back.
|
|
267
|
+
const store = await open();
|
|
268
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
269
|
+
expect(await store.softDelete({ tenantId: T1, id: asId("a1"), at: AT })).toEqual({
|
|
270
|
+
deleted: true,
|
|
271
|
+
});
|
|
272
|
+
expect((await store.listByConversation({ tenantId: T1, conversationId: C1, limit: 10 })).items).toEqual([]);
|
|
273
|
+
expect(await store.get({ tenantId: T1, id: asId("a1") })).toMatchObject({ deletedAt: AT });
|
|
274
|
+
});
|
|
275
|
+
it("keeps the first deletion timestamp when deleted again", async () => {
|
|
276
|
+
// "When was this deleted" must not move every time someone clicks again.
|
|
277
|
+
const store = await open();
|
|
278
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
279
|
+
await store.softDelete({ tenantId: T1, id: asId("a1"), at: AT });
|
|
280
|
+
await store.softDelete({
|
|
281
|
+
tenantId: T1,
|
|
282
|
+
id: asId("a1"),
|
|
283
|
+
at: "2026-08-24T12:00:00.000Z",
|
|
284
|
+
});
|
|
285
|
+
expect(await store.get({ tenantId: T1, id: asId("a1") })).toMatchObject({ deletedAt: AT });
|
|
286
|
+
});
|
|
287
|
+
it("does not delete another tenant's artifact", async () => {
|
|
288
|
+
const store = await open();
|
|
289
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
290
|
+
expect(await store.softDelete({ tenantId: T2, id: asId("a1"), at: AT })).toEqual({
|
|
291
|
+
deleted: false,
|
|
292
|
+
});
|
|
293
|
+
expect((await store.get({ tenantId: T1, id: asId("a1") }))?.deletedAt).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
it("adds no version to a deleted artifact", async () => {
|
|
296
|
+
// A new version would resurrect it in every listing that filters through the artifact row.
|
|
297
|
+
const store = await open();
|
|
298
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
299
|
+
await store.softDelete({ tenantId: T1, id: asId("a1"), at: AT });
|
|
300
|
+
expect(await store.addVersion({
|
|
301
|
+
tenantId: T1,
|
|
302
|
+
id: asId("a1"),
|
|
303
|
+
expectedLatestVersion: 1,
|
|
304
|
+
version: version({ id: asId("after-delete") }),
|
|
305
|
+
})).toEqual({ added: false });
|
|
306
|
+
});
|
|
307
|
+
it("rejects a non-timestamp `at` rather than storing it", async () => {
|
|
308
|
+
// The lesson from #129: a reference adapter laxer than the real one turns a production write failure
|
|
309
|
+
// into a passing test.
|
|
310
|
+
const store = await open();
|
|
311
|
+
await store.create({ tenantId: T1, artifact: artifact(), version: version() });
|
|
312
|
+
await expect(store.softDelete({ tenantId: T1, id: asId("a1"), at: "t" })).rejects.toThrow();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=artifacts.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio provider conformance — REQ-062 (#257), task #258, AC-7.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not in the storage suite
|
|
5
|
+
*
|
|
6
|
+
* AC-7 asks for these ports to be "covered by the conformance suite, consistent with the other 31". The other
|
|
7
|
+
* 31 are the **storage** conformance suite — `conformance/index.ts` says so in its first line, and every
|
|
8
|
+
* harness there is handed a store and asserts persistence semantics: a claim, a lease, a version check, a
|
|
9
|
+
* tenant boundary.
|
|
10
|
+
*
|
|
11
|
+
* A transcription provider is not a store. Adding it to that suite would put a non-storage port in a list whose
|
|
12
|
+
* name and coverage report both say storage, and the next reader would reasonably conclude the audio ports have
|
|
13
|
+
* a durable backing they do not have.
|
|
14
|
+
*
|
|
15
|
+
* So: the same *mechanism* — one shared harness, any adapter must pass it — in its own file, following the
|
|
16
|
+
* precedent `tools-scrape` already set with `CONTRACT_KEYS`. The intent of the AC is that no adapter gets to
|
|
17
|
+
* define its own contract, and that is what this enforces.
|
|
18
|
+
*
|
|
19
|
+
* ## What it can and cannot assert
|
|
20
|
+
*
|
|
21
|
+
* It runs against a **fake** provider by default and against a real one when a caller passes one. That split
|
|
22
|
+
* is deliberate: the contract is about shape and bounds, which a fake can exercise exhaustively and cheaply,
|
|
23
|
+
* while "does Whisper actually transcribe" is a live-call question answered once in `audio-live` rather than on
|
|
24
|
+
* every test run. `#268` is the precedent in the other direction — a *crypto* implementation must be run for
|
|
25
|
+
* real before shipping, because an untested one is worse than none. A transcription adapter that returns the
|
|
26
|
+
* wrong text is visibly wrong; one that silently reports no duration is not, which is why duration is the thing
|
|
27
|
+
* asserted hardest here.
|
|
28
|
+
*/
|
|
29
|
+
import type { SpeechProvider, TranscriptionProvider } from "../../audio/index.js";
|
|
30
|
+
/** Every key a transcription result must carry. A provider cannot quietly return less. */
|
|
31
|
+
export declare const TRANSCRIPTION_CONTRACT_KEYS: readonly ["text", "truncated"];
|
|
32
|
+
/** And a speech result. `durationSeconds` is optional on both — absent is a legitimate answer. */
|
|
33
|
+
export declare const SPEECH_CONTRACT_KEYS: readonly ["audio", "mediaType"];
|
|
34
|
+
export declare function transcriptionProviderConformance(make: () => TranscriptionProvider): void;
|
|
35
|
+
export declare function speechProviderConformance(make: () => SpeechProvider): void;
|
|
36
|
+
/** The transcript bounding, on its own — shared by both suites and worth asserting once. */
|
|
37
|
+
export declare function transcriptBoundingConformance(): void;
|
|
38
|
+
//# sourceMappingURL=audio.d.ts.map
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio provider conformance — REQ-062 (#257), task #258, AC-7.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not in the storage suite
|
|
5
|
+
*
|
|
6
|
+
* AC-7 asks for these ports to be "covered by the conformance suite, consistent with the other 31". The other
|
|
7
|
+
* 31 are the **storage** conformance suite — `conformance/index.ts` says so in its first line, and every
|
|
8
|
+
* harness there is handed a store and asserts persistence semantics: a claim, a lease, a version check, a
|
|
9
|
+
* tenant boundary.
|
|
10
|
+
*
|
|
11
|
+
* A transcription provider is not a store. Adding it to that suite would put a non-storage port in a list whose
|
|
12
|
+
* name and coverage report both say storage, and the next reader would reasonably conclude the audio ports have
|
|
13
|
+
* a durable backing they do not have.
|
|
14
|
+
*
|
|
15
|
+
* So: the same *mechanism* — one shared harness, any adapter must pass it — in its own file, following the
|
|
16
|
+
* precedent `tools-scrape` already set with `CONTRACT_KEYS`. The intent of the AC is that no adapter gets to
|
|
17
|
+
* define its own contract, and that is what this enforces.
|
|
18
|
+
*
|
|
19
|
+
* ## What it can and cannot assert
|
|
20
|
+
*
|
|
21
|
+
* It runs against a **fake** provider by default and against a real one when a caller passes one. That split
|
|
22
|
+
* is deliberate: the contract is about shape and bounds, which a fake can exercise exhaustively and cheaply,
|
|
23
|
+
* while "does Whisper actually transcribe" is a live-call question answered once in `audio-live` rather than on
|
|
24
|
+
* every test run. `#268` is the precedent in the other direction — a *crypto* implementation must be run for
|
|
25
|
+
* real before shipping, because an untested one is worse than none. A transcription adapter that returns the
|
|
26
|
+
* wrong text is visibly wrong; one that silently reports no duration is not, which is why duration is the thing
|
|
27
|
+
* asserted hardest here.
|
|
28
|
+
*/
|
|
29
|
+
import { describe, expect, it } from "vitest";
|
|
30
|
+
import { boundTranscript, MAX_SPEECH_CHARS, MAX_TRANSCRIPT_CHARS } from "../../audio/index.js";
|
|
31
|
+
/** Every key a transcription result must carry. A provider cannot quietly return less. */
|
|
32
|
+
export const TRANSCRIPTION_CONTRACT_KEYS = ["text", "truncated"];
|
|
33
|
+
/** And a speech result. `durationSeconds` is optional on both — absent is a legitimate answer. */
|
|
34
|
+
export const SPEECH_CONTRACT_KEYS = ["audio", "mediaType"];
|
|
35
|
+
/** A tiny valid-looking payload. No provider here decodes it; the fakes do not, and the real one is not run. */
|
|
36
|
+
const SILENCE = new Uint8Array([0xff, 0xfb, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
|
37
|
+
export function transcriptionProviderConformance(make) {
|
|
38
|
+
describe("TranscriptionProvider conformance", () => {
|
|
39
|
+
it("names itself, so a usage record can say which provider ran", () => {
|
|
40
|
+
expect(make().id).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
it("returns every contract key", async () => {
|
|
43
|
+
const result = await make().transcribe({ audio: SILENCE, mediaType: "audio/mpeg" });
|
|
44
|
+
for (const key of TRANSCRIPTION_CONTRACT_KEYS) {
|
|
45
|
+
expect(result, `missing ${key}`).toHaveProperty(key);
|
|
46
|
+
}
|
|
47
|
+
expect(typeof result.text).toBe("string");
|
|
48
|
+
expect(typeof result.truncated).toBe("boolean");
|
|
49
|
+
});
|
|
50
|
+
it("reports a duration, because usage is charged on it", async () => {
|
|
51
|
+
/**
|
|
52
|
+
* The assertion that matters most, and the one a provider is most likely to fail quietly.
|
|
53
|
+
*
|
|
54
|
+
* Audio is billed per second. A provider that omits `durationSeconds` makes every transcription cost
|
|
55
|
+
* **nothing** in the ledger — under-billing that scales with usage and shows up as a revenue shortfall
|
|
56
|
+
* rather than as a bug. The OpenAI adapter asks for `verbose_json` specifically to get this field.
|
|
57
|
+
*
|
|
58
|
+
* `undefined` is permitted by the type, because some providers genuinely do not report it — but a
|
|
59
|
+
* provider claiming conformance has to state which it is rather than leaving a caller to find out from an
|
|
60
|
+
* invoice.
|
|
61
|
+
*/
|
|
62
|
+
const result = await make().transcribe({ audio: SILENCE, mediaType: "audio/mpeg" });
|
|
63
|
+
if (result.durationSeconds === undefined) {
|
|
64
|
+
// Made loud on purpose: this is a costing decision, not a detail.
|
|
65
|
+
throw new Error("this provider reports no durationSeconds, so its transcriptions will be billed as free. If that is " +
|
|
66
|
+
"correct for it, exclude this case explicitly and say why.");
|
|
67
|
+
}
|
|
68
|
+
expect(result.durationSeconds).toBeGreaterThan(0);
|
|
69
|
+
});
|
|
70
|
+
it("refuses a media type it does not accept, rather than sending it", async () => {
|
|
71
|
+
// The useful refusal happens locally: a provider answers an unknown container with a message about the
|
|
72
|
+
// request, not about the format.
|
|
73
|
+
await expect(make().transcribe({ audio: SILENCE, mediaType: "application/zip" })).rejects.toThrow();
|
|
74
|
+
});
|
|
75
|
+
it("bounds the transcript and reports the truncation", async () => {
|
|
76
|
+
const provider = make();
|
|
77
|
+
const result = await provider.transcribe({
|
|
78
|
+
audio: SILENCE,
|
|
79
|
+
mediaType: "audio/mpeg",
|
|
80
|
+
maxTranscriptChars: 20,
|
|
81
|
+
});
|
|
82
|
+
expect(result.text.length).toBeLessThanOrEqual(20);
|
|
83
|
+
// If it was cut, it must say so. A shortened transcript claiming to be whole would have a model
|
|
84
|
+
// summarise a fragment as if it were the entire recording.
|
|
85
|
+
if (result.text.length === 20)
|
|
86
|
+
expect(result.truncated).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export function speechProviderConformance(make) {
|
|
91
|
+
describe("SpeechProvider conformance", () => {
|
|
92
|
+
it("names itself", () => {
|
|
93
|
+
expect(make().id).toBeTruthy();
|
|
94
|
+
});
|
|
95
|
+
it("returns every contract key, with audio as bytes", async () => {
|
|
96
|
+
const result = await make().speak({ text: "hello" });
|
|
97
|
+
for (const key of SPEECH_CONTRACT_KEYS) {
|
|
98
|
+
expect(result, `missing ${key}`).toHaveProperty(key);
|
|
99
|
+
}
|
|
100
|
+
expect(result.audio).toBeInstanceOf(Uint8Array);
|
|
101
|
+
expect(result.audio.byteLength).toBeGreaterThan(0);
|
|
102
|
+
expect(result.mediaType).toMatch(/^audio\//);
|
|
103
|
+
});
|
|
104
|
+
it("refuses empty text rather than producing silence", async () => {
|
|
105
|
+
// A zero-length recording is a bill and a file for nothing, and it reads to a user as a broken player.
|
|
106
|
+
await expect(make().speak({ text: " " })).rejects.toThrow();
|
|
107
|
+
});
|
|
108
|
+
it("refuses text over the ceiling before the call", async () => {
|
|
109
|
+
// Speech is billed per character, so a runaway prompt is a runaway bill. Refused locally, not by the
|
|
110
|
+
// provider, because the provider's refusal costs the round trip.
|
|
111
|
+
await expect(make().speak({ text: "a".repeat(MAX_SPEECH_CHARS + 1) })).rejects.toThrow();
|
|
112
|
+
});
|
|
113
|
+
it("honours the requested format in the media type it reports", async () => {
|
|
114
|
+
const result = await make().speak({ text: "hello", format: "wav" });
|
|
115
|
+
// Reported, not assumed: a caller storing this as an artifact writes the media type into the file record,
|
|
116
|
+
// and a wrong one makes the browser refuse to play a file that is perfectly good.
|
|
117
|
+
expect(result.mediaType).toBe("audio/wav");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** The transcript bounding, on its own — shared by both suites and worth asserting once. */
|
|
122
|
+
export function transcriptBoundingConformance() {
|
|
123
|
+
describe("transcript bounding", () => {
|
|
124
|
+
it("leaves a short transcript alone", () => {
|
|
125
|
+
expect(boundTranscript("short")).toEqual({ text: "short", truncated: false });
|
|
126
|
+
});
|
|
127
|
+
it("cuts at a word boundary when one is near the ceiling", () => {
|
|
128
|
+
const text = `${"word ".repeat(20)}finalword`;
|
|
129
|
+
const bounded = boundTranscript(text, 50);
|
|
130
|
+
expect(bounded.truncated).toBe(true);
|
|
131
|
+
// Not mid-word: a sliced word reads as a transcription error rather than as a truncation.
|
|
132
|
+
expect(bounded.text.endsWith(" ")).toBe(false);
|
|
133
|
+
expect(text.startsWith(bounded.text)).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
it("still cuts when there is no word boundary to use", () => {
|
|
136
|
+
const bounded = boundTranscript("a".repeat(200), 50);
|
|
137
|
+
expect(bounded.text).toHaveLength(50);
|
|
138
|
+
expect(bounded.truncated).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
it("defaults to the documented ceiling", () => {
|
|
141
|
+
expect(boundTranscript("x".repeat(MAX_TRANSCRIPT_CHARS + 10)).text).toHaveLength(MAX_TRANSCRIPT_CHARS);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=audio.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability gating for the conformance suite — `docs/02` → capability declarations.
|
|
3
|
+
*
|
|
4
|
+
* A harness block may be skipped **only** when the adapter declares the capability absent via
|
|
5
|
+
* `AdapterCapability`. An unconditional `it.skip` is not permitted: a silent skip is how a suite
|
|
6
|
+
* comes to look green while verifying nothing, which is exactly the failure #91 exists to close.
|
|
7
|
+
*
|
|
8
|
+
* `gatedIt` therefore always registers a test. When the capability is missing it registers a
|
|
9
|
+
* *passing* test whose name carries the declared reason, so the skip is visible in the report
|
|
10
|
+
* rather than absent from it.
|
|
11
|
+
*/
|
|
12
|
+
import type { AdapterCapability } from "../../persistence/index.js";
|
|
13
|
+
/** What an adapter tells the suite about itself. Absent ⇒ treated as "declares nothing". */
|
|
14
|
+
export type AdapterDeclaration = {
|
|
15
|
+
readonly capabilities?: readonly AdapterCapability[];
|
|
16
|
+
};
|
|
17
|
+
export declare const declares: (declaration: AdapterDeclaration | undefined, capability: AdapterCapability) => boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Register a test that runs only when the adapter declares `capability`. When it does not, the
|
|
20
|
+
* test still appears in the report, named with the reason — a visible, attributable skip.
|
|
21
|
+
*/
|
|
22
|
+
export declare const gatedIt: (declaration: AdapterDeclaration | undefined, capability: AdapterCapability, name: string, fn: () => Promise<void> | void) => void;
|
|
23
|
+
//# sourceMappingURL=capability.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability gating for the conformance suite — `docs/02` → capability declarations.
|
|
3
|
+
*
|
|
4
|
+
* A harness block may be skipped **only** when the adapter declares the capability absent via
|
|
5
|
+
* `AdapterCapability`. An unconditional `it.skip` is not permitted: a silent skip is how a suite
|
|
6
|
+
* comes to look green while verifying nothing, which is exactly the failure #91 exists to close.
|
|
7
|
+
*
|
|
8
|
+
* `gatedIt` therefore always registers a test. When the capability is missing it registers a
|
|
9
|
+
* *passing* test whose name carries the declared reason, so the skip is visible in the report
|
|
10
|
+
* rather than absent from it.
|
|
11
|
+
*/
|
|
12
|
+
import { it } from "vitest";
|
|
13
|
+
export const declares = (declaration, capability) => (declaration?.capabilities ?? []).includes(capability);
|
|
14
|
+
/**
|
|
15
|
+
* Register a test that runs only when the adapter declares `capability`. When it does not, the
|
|
16
|
+
* test still appears in the report, named with the reason — a visible, attributable skip.
|
|
17
|
+
*/
|
|
18
|
+
export const gatedIt = (declaration, capability, name, fn) => {
|
|
19
|
+
if (declares(declaration, capability)) {
|
|
20
|
+
it(name, fn);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
it(`${name} [skipped: adapter does not declare "${capability}"]`, () => {
|
|
24
|
+
// Intentionally empty: the assertion is the printed reason. See the module docstring.
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=capability.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CheckpointStore` conformance — `docs/04` → durable execution. The port documents `save` as
|
|
3
|
+
* **monotonic**: "a save with a lower sequence is ignored". That is what stops a slow in-flight
|
|
4
|
+
* write from rewinding a recovered run, so every adapter must agree on it.
|
|
5
|
+
*
|
|
6
|
+
* **Referential integrity (#95).** A checkpoint belongs to a run, and an adapter is entitled to
|
|
7
|
+
* enforce that — the Postgres schema does, with a foreign key, because an orphan checkpoint is
|
|
8
|
+
* meaningless. The in-memory adapter holds no such constraint. The fixture may therefore supply
|
|
9
|
+
* `seedRun`, via the shared `parents` helper (#96 generalised what started here); adapters without
|
|
10
|
+
* referential integrity omit it and nothing changes for them.
|
|
11
|
+
*/
|
|
12
|
+
import type { CheckpointStore } from "../../persistence/index.js";
|
|
13
|
+
import { type FixtureOrStore } from "./parents.js";
|
|
14
|
+
export declare function checkpointStoreConformance(makeFixture: () => FixtureOrStore<CheckpointStore>): void;
|
|
15
|
+
//# sourceMappingURL=checkpoint-store.d.ts.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CheckpointStore` conformance — `docs/04` → durable execution. The port documents `save` as
|
|
3
|
+
* **monotonic**: "a save with a lower sequence is ignored". That is what stops a slow in-flight
|
|
4
|
+
* write from rewinding a recovered run, so every adapter must agree on it.
|
|
5
|
+
*
|
|
6
|
+
* **Referential integrity (#95).** A checkpoint belongs to a run, and an adapter is entitled to
|
|
7
|
+
* enforce that — the Postgres schema does, with a foreign key, because an orphan checkpoint is
|
|
8
|
+
* meaningless. The in-memory adapter holds no such constraint. The fixture may therefore supply
|
|
9
|
+
* `seedRun`, via the shared `parents` helper (#96 generalised what started here); adapters without
|
|
10
|
+
* referential integrity omit it and nothing changes for them.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { asId } from "../../core/ids.js";
|
|
14
|
+
import { emptyCheckpoint } from "../../runtime/checkpoint.js";
|
|
15
|
+
import { withRun } from "./parents.js";
|
|
16
|
+
const T1 = asId("conf-tenant-1");
|
|
17
|
+
const T2 = asId("conf-tenant-2");
|
|
18
|
+
const RUN = asId("conf-run-1");
|
|
19
|
+
const at = (runId, sequence, step = 0) => ({
|
|
20
|
+
...emptyCheckpoint(runId, `2020-01-01T00:00:${String(sequence).padStart(2, "0")}.000Z`),
|
|
21
|
+
sequence,
|
|
22
|
+
step,
|
|
23
|
+
});
|
|
24
|
+
export function checkpointStoreConformance(makeFixture) {
|
|
25
|
+
const open = (runIds = [{ tenantId: T1, runId: RUN }]) => withRun(makeFixture(), runIds);
|
|
26
|
+
describe("CheckpointStore conformance", () => {
|
|
27
|
+
it("returns null before anything is saved", async () => {
|
|
28
|
+
const store = await open();
|
|
29
|
+
expect(await store.latest({ tenantId: T1, runId: RUN })).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it("saves and returns the checkpoint", async () => {
|
|
32
|
+
const store = await open();
|
|
33
|
+
await store.save({ tenantId: T1, checkpoint: at(RUN, 5, 2) });
|
|
34
|
+
expect(await store.latest({ tenantId: T1, runId: RUN })).toMatchObject({ sequence: 5, step: 2 });
|
|
35
|
+
});
|
|
36
|
+
it("advances on a higher sequence", async () => {
|
|
37
|
+
const store = await open();
|
|
38
|
+
await store.save({ tenantId: T1, checkpoint: at(RUN, 5) });
|
|
39
|
+
await store.save({ tenantId: T1, checkpoint: at(RUN, 9) });
|
|
40
|
+
expect((await store.latest({ tenantId: T1, runId: RUN }))?.sequence).toBe(9);
|
|
41
|
+
});
|
|
42
|
+
it("ignores a save with a lower sequence — monotonic, so recovery never rewinds", async () => {
|
|
43
|
+
const store = await open();
|
|
44
|
+
await store.save({ tenantId: T1, checkpoint: at(RUN, 9) });
|
|
45
|
+
await store.save({ tenantId: T1, checkpoint: at(RUN, 4) });
|
|
46
|
+
expect((await store.latest({ tenantId: T1, runId: RUN }))?.sequence).toBe(9);
|
|
47
|
+
});
|
|
48
|
+
it("treats an equal sequence as a no-op rather than an error", async () => {
|
|
49
|
+
const store = await open();
|
|
50
|
+
await store.save({ tenantId: T1, checkpoint: at(RUN, 7, 1) });
|
|
51
|
+
await store.save({ tenantId: T1, checkpoint: at(RUN, 7, 1) });
|
|
52
|
+
expect((await store.latest({ tenantId: T1, runId: RUN }))?.sequence).toBe(7);
|
|
53
|
+
});
|
|
54
|
+
it("round-trips pendingToolCalls, which recovery reconciles against", async () => {
|
|
55
|
+
const store = await open();
|
|
56
|
+
const checkpoint = {
|
|
57
|
+
...at(RUN, 3),
|
|
58
|
+
pendingToolCalls: [{ toolCallId: asId("tc1"), toolName: "search_web", startedAt: "2020-01-01T00:00:03.000Z" }],
|
|
59
|
+
};
|
|
60
|
+
await store.save({ tenantId: T1, checkpoint });
|
|
61
|
+
const latest = await store.latest({ tenantId: T1, runId: RUN });
|
|
62
|
+
expect(latest?.pendingToolCalls).toHaveLength(1);
|
|
63
|
+
expect(latest?.pendingToolCalls[0]?.toolName).toBe("search_web");
|
|
64
|
+
});
|
|
65
|
+
it("enforces tenant isolation", async () => {
|
|
66
|
+
const store = await open();
|
|
67
|
+
await store.save({ tenantId: T1, checkpoint: at(RUN, 5) });
|
|
68
|
+
expect(await store.latest({ tenantId: T2, runId: RUN })).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=checkpoint-store.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ConnectionStore` conformance — REQ-063 (#259), task #261.
|
|
3
|
+
*
|
|
4
|
+
* Every clause here is a defect somebody would otherwise ship, and two of them are the reason the port exists
|
|
5
|
+
* at all: a store that leaked across tenants would hand one customer another's credential, and a store that
|
|
6
|
+
* kept a revoked connection readable would let a withdrawn credential be used once more.
|
|
7
|
+
*
|
|
8
|
+
* The store never sees plaintext, so this suite never supplies any: it seals nothing and asserts nothing about
|
|
9
|
+
* encryption. That belongs to `SecretCipher`'s own tests and to the raw-row assertion, which needs a real
|
|
10
|
+
* database and lives with the adapter.
|
|
11
|
+
*/
|
|
12
|
+
import type { ConnectionStore } from "../../connections/index.js";
|
|
13
|
+
export declare function connectionStoreConformance(makeStore: () => ConnectionStore | Promise<ConnectionStore>): void;
|
|
14
|
+
//# sourceMappingURL=connections.d.ts.map
|