@retinue/agentkit 0.2.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 +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 +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/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,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `InteractionStore`, `ApprovalGrantStore` and `UsageStore` conformance.
|
|
3
|
+
*
|
|
4
|
+
* The two properties here are the ones that would cost real money or cause a real duplicate
|
|
5
|
+
* publish if an adapter got them wrong, so they are asserted rather than trusted:
|
|
6
|
+
*
|
|
7
|
+
* - **Idempotent resolution** (`docs/04`): "the first call resolves the interaction and reports
|
|
8
|
+
* `alreadyResolved: false`; a duplicate reports `true` and changes nothing, so a continuation is
|
|
9
|
+
* queued exactly once." A store that reports `false` twice queues the continuation twice.
|
|
10
|
+
* - **Grant scoping** (`docs/04`): a `conversation`-scoped grant "never leaks to another
|
|
11
|
+
* conversation or tenant-wide". A store that ignores `conversationId` silently converts a
|
|
12
|
+
* one-conversation approval into a standing one.
|
|
13
|
+
* - **Append-only usage** (`docs/12`): events are never edited; appends are idempotent on
|
|
14
|
+
* `(runId, stepId)` so a recovered run never double-counts.
|
|
15
|
+
* - **The approval claim** (`docs/04` → How the loop closes): an approval's single execution is
|
|
16
|
+
* claimed exactly once, and only after a decision. This is where `allow-once` gets its "once" —
|
|
17
|
+
* it issues no grant, so an adapter that lost the claim would let one decision publish twice, and
|
|
18
|
+
* one that let an undecided interaction be claimed would create permission out of nothing.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
21
|
+
import { asId } from "../../core/ids.js";
|
|
22
|
+
import { withRun } from "./parents.js";
|
|
23
|
+
const T1 = asId("conf-tenant-1");
|
|
24
|
+
const T2 = asId("conf-tenant-2");
|
|
25
|
+
const RUN = asId("conf-run-1");
|
|
26
|
+
const C1 = "conf-convo-1";
|
|
27
|
+
const C2 = "conf-convo-2";
|
|
28
|
+
const NOW = "2020-01-01T00:00:00.000Z";
|
|
29
|
+
const LATER = "2020-01-02T00:00:00.000Z";
|
|
30
|
+
const question = (id) => ({
|
|
31
|
+
id: asId(id),
|
|
32
|
+
tenantId: T1,
|
|
33
|
+
runId: RUN,
|
|
34
|
+
questions: [{ key: "channel", prompt: "Which channel?", options: ["linkedin", "meta"] }],
|
|
35
|
+
createdAt: NOW,
|
|
36
|
+
});
|
|
37
|
+
const approval = (id) => ({
|
|
38
|
+
id: asId(id),
|
|
39
|
+
tenantId: T1,
|
|
40
|
+
runId: RUN,
|
|
41
|
+
toolName: "publish_post",
|
|
42
|
+
normalizedInput: { draftId: "d1" },
|
|
43
|
+
riskCategory: "external-write",
|
|
44
|
+
summary: "Publish draft d1 to LinkedIn",
|
|
45
|
+
expiresAt: LATER,
|
|
46
|
+
idempotencyKey: "idem-1",
|
|
47
|
+
});
|
|
48
|
+
export function interactionStoreConformance(makeFixture) {
|
|
49
|
+
// A question and an approval both belong to a run, and the Postgres schema enforces it with a
|
|
50
|
+
// foreign key (#99) — an orphan approval would be an authorisation with nothing to authorise. The
|
|
51
|
+
// in-memory adapter has no such constraint and passes no seeder; see ./parents.ts.
|
|
52
|
+
const open = () => withRun(makeFixture(), [{ tenantId: T1, runId: RUN }]);
|
|
53
|
+
describe("InteractionStore conformance", () => {
|
|
54
|
+
it("finds a pending question by run", async () => {
|
|
55
|
+
const store = await open();
|
|
56
|
+
await store.createQuestion({ tenantId: T1, question: question("q1") });
|
|
57
|
+
expect(await store.findPendingQuestion({ tenantId: T1, runId: RUN })).toMatchObject({ id: "q1" });
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* Every field of the spec survives, not just the ones the first version had — #163.
|
|
61
|
+
*
|
|
62
|
+
* `multiple` and `allowOther` are what let a client tell "pick one" from "pick several" from "or write your
|
|
63
|
+
* own". An adapter that silently dropped them would render a multi-select as a radio group, and the person
|
|
64
|
+
* would answer a different question from the one the agent asked. The fixture above deliberately does not
|
|
65
|
+
* set them, so this covers the case the shared fixture cannot.
|
|
66
|
+
*/
|
|
67
|
+
it("round-trips every field of a question spec, including multiple and allowOther", async () => {
|
|
68
|
+
const store = await open();
|
|
69
|
+
await store.createQuestion({
|
|
70
|
+
tenantId: T1,
|
|
71
|
+
question: {
|
|
72
|
+
...question("q-full"),
|
|
73
|
+
questions: [
|
|
74
|
+
{ key: "keep", prompt: "Which notes?", options: ["a", "b", "c"], multiple: true, allowOther: true },
|
|
75
|
+
{ key: "why", prompt: "Why?" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const found = await store.findPendingQuestion({ tenantId: T1, runId: RUN });
|
|
80
|
+
expect(found?.questions).toEqual([
|
|
81
|
+
{ key: "keep", prompt: "Which notes?", options: ["a", "b", "c"], multiple: true, allowOther: true },
|
|
82
|
+
{ key: "why", prompt: "Why?" },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* The answered-question read a resumed run needs — #163.
|
|
87
|
+
*
|
|
88
|
+
* The approval side has had `findDecidedApproval` from the start; questions had no equivalent, so an
|
|
89
|
+
* answered question was invisible to the run that asked it and the model asked again. An adapter that
|
|
90
|
+
* returned pending questions here, or nothing, reproduces that loop.
|
|
91
|
+
*/
|
|
92
|
+
it("finds the run's answered question, and does not confuse it with a pending one", async () => {
|
|
93
|
+
const store = await open();
|
|
94
|
+
await store.createQuestion({ tenantId: T1, question: question("q1") });
|
|
95
|
+
// Nothing answered yet: an answer that does not exist must not be invented.
|
|
96
|
+
expect(await store.findAnsweredQuestion({ tenantId: T1, runId: RUN })).toBeNull();
|
|
97
|
+
await store.answerQuestion({ tenantId: T1, interactionId: asId("q1"), answers: { channel: "meta" }, at: LATER });
|
|
98
|
+
const found = await store.findAnsweredQuestion({ tenantId: T1, runId: RUN });
|
|
99
|
+
expect(found?.id).toBe("q1");
|
|
100
|
+
expect(found?.answers).toEqual({ channel: "meta" });
|
|
101
|
+
// And it is no longer pending, or a client would keep showing the picker.
|
|
102
|
+
expect(await store.findPendingQuestion({ tenantId: T1, runId: RUN })).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
/** A run may ask twice. The latest answer is the news; the earlier one is already in the history. */
|
|
105
|
+
it("returns the most recently answered question when a run asked more than once", async () => {
|
|
106
|
+
const store = await open();
|
|
107
|
+
await store.createQuestion({ tenantId: T1, question: question("q1") });
|
|
108
|
+
await store.answerQuestion({ tenantId: T1, interactionId: asId("q1"), answers: { channel: "first" }, at: NOW });
|
|
109
|
+
await store.createQuestion({ tenantId: T1, question: question("q2") });
|
|
110
|
+
await store.answerQuestion({ tenantId: T1, interactionId: asId("q2"), answers: { channel: "second" }, at: LATER });
|
|
111
|
+
expect((await store.findAnsweredQuestion({ tenantId: T1, runId: RUN }))?.answers).toEqual({ channel: "second" });
|
|
112
|
+
});
|
|
113
|
+
it("does not serve one tenant's answered question to another", async () => {
|
|
114
|
+
const store = await open();
|
|
115
|
+
await store.createQuestion({ tenantId: T1, question: question("q1") });
|
|
116
|
+
await store.answerQuestion({ tenantId: T1, interactionId: asId("q1"), answers: { channel: "meta" }, at: LATER });
|
|
117
|
+
expect(await store.findAnsweredQuestion({ tenantId: T2, runId: RUN })).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
it("answering reports alreadyResolved false the first time", async () => {
|
|
120
|
+
const store = await open();
|
|
121
|
+
await store.createQuestion({ tenantId: T1, question: question("q1") });
|
|
122
|
+
const first = await store.answerQuestion({
|
|
123
|
+
tenantId: T1,
|
|
124
|
+
interactionId: asId("q1"),
|
|
125
|
+
answers: { channel: "linkedin" },
|
|
126
|
+
at: LATER,
|
|
127
|
+
});
|
|
128
|
+
expect(first.alreadyResolved).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
it("a duplicate answer reports alreadyResolved true and changes nothing", async () => {
|
|
131
|
+
const store = await open();
|
|
132
|
+
await store.createQuestion({ tenantId: T1, question: question("q1") });
|
|
133
|
+
await store.answerQuestion({
|
|
134
|
+
tenantId: T1,
|
|
135
|
+
interactionId: asId("q1"),
|
|
136
|
+
answers: { channel: "linkedin" },
|
|
137
|
+
at: LATER,
|
|
138
|
+
});
|
|
139
|
+
const second = await store.answerQuestion({
|
|
140
|
+
tenantId: T1,
|
|
141
|
+
interactionId: asId("q1"),
|
|
142
|
+
answers: { channel: "meta" },
|
|
143
|
+
at: LATER,
|
|
144
|
+
});
|
|
145
|
+
expect(second.alreadyResolved).toBe(true);
|
|
146
|
+
// The stored answer must be the first one — a duplicate must not overwrite the decision.
|
|
147
|
+
expect(second.question.answers).toEqual({ channel: "linkedin" });
|
|
148
|
+
});
|
|
149
|
+
it("an answered question is no longer pending", async () => {
|
|
150
|
+
const store = await open();
|
|
151
|
+
await store.createQuestion({ tenantId: T1, question: question("q1") });
|
|
152
|
+
await store.answerQuestion({
|
|
153
|
+
tenantId: T1,
|
|
154
|
+
interactionId: asId("q1"),
|
|
155
|
+
answers: { channel: "linkedin" },
|
|
156
|
+
at: LATER,
|
|
157
|
+
});
|
|
158
|
+
expect(await store.findPendingQuestion({ tenantId: T1, runId: RUN })).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
it("finds a pending approval by run", async () => {
|
|
161
|
+
const store = await open();
|
|
162
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
163
|
+
expect(await store.findPendingApproval({ tenantId: T1, runId: RUN })).toMatchObject({
|
|
164
|
+
id: "a1",
|
|
165
|
+
toolName: "publish_post",
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
it("deciding is idempotent — a duplicate decision cannot flip the outcome", async () => {
|
|
169
|
+
const store = await open();
|
|
170
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
171
|
+
const first = await store.decideApproval({
|
|
172
|
+
tenantId: T1,
|
|
173
|
+
interactionId: asId("a1"),
|
|
174
|
+
decision: "deny",
|
|
175
|
+
at: LATER,
|
|
176
|
+
});
|
|
177
|
+
expect(first.alreadyResolved).toBe(false);
|
|
178
|
+
const second = await store.decideApproval({
|
|
179
|
+
tenantId: T1,
|
|
180
|
+
interactionId: asId("a1"),
|
|
181
|
+
decision: "allow-once",
|
|
182
|
+
at: LATER,
|
|
183
|
+
});
|
|
184
|
+
expect(second.alreadyResolved).toBe(true);
|
|
185
|
+
expect(second.approval.decision).toBe("deny");
|
|
186
|
+
});
|
|
187
|
+
it("preserves the stored normalized input, so resumption never runs regenerated arguments", async () => {
|
|
188
|
+
const store = await open();
|
|
189
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
190
|
+
const pending = await store.findPendingApproval({ tenantId: T1, runId: RUN });
|
|
191
|
+
expect(pending?.normalizedInput).toEqual({ draftId: "d1" });
|
|
192
|
+
});
|
|
193
|
+
/**
|
|
194
|
+
* The resumption half of the loop. A decision that is recorded but that nothing can look
|
|
195
|
+
* up again is a decision the run cannot act on — which is the state the platform was in: the
|
|
196
|
+
* decision was stored, the run was re-enqueued, and the resumed run had no way to find the
|
|
197
|
+
* approval whose stored input it was supposed to execute.
|
|
198
|
+
*/
|
|
199
|
+
it("finds the decided approval a resumption must execute", async () => {
|
|
200
|
+
const store = await open();
|
|
201
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
202
|
+
await store.decideApproval({
|
|
203
|
+
tenantId: T1,
|
|
204
|
+
interactionId: asId("a1"),
|
|
205
|
+
decision: "allow-once",
|
|
206
|
+
at: LATER,
|
|
207
|
+
});
|
|
208
|
+
expect(await store.findDecidedApproval({ tenantId: T1, runId: RUN })).toMatchObject({
|
|
209
|
+
id: "a1",
|
|
210
|
+
decision: "allow-once",
|
|
211
|
+
normalizedInput: { draftId: "d1" },
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
it("an undecided approval is not resumable", async () => {
|
|
215
|
+
const store = await open();
|
|
216
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
217
|
+
expect(await store.findDecidedApproval({ tenantId: T1, runId: RUN })).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
/**
|
|
220
|
+
* Where `allow-once` gets its "once" from. It is deliberately *not* a grant — a grant is standing
|
|
221
|
+
* by definition — so the single execution has to be claimed from the interaction itself, and the
|
|
222
|
+
* store is the only place that holds across processes. Two workers racing a resumed run must see
|
|
223
|
+
* exactly one `claimed: true`.
|
|
224
|
+
*/
|
|
225
|
+
it("claims the single execution an approval authorizes exactly once", async () => {
|
|
226
|
+
const store = await open();
|
|
227
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
228
|
+
await store.decideApproval({
|
|
229
|
+
tenantId: T1,
|
|
230
|
+
interactionId: asId("a1"),
|
|
231
|
+
decision: "allow-once",
|
|
232
|
+
at: LATER,
|
|
233
|
+
});
|
|
234
|
+
const first = await store.claimApproval({ tenantId: T1, interactionId: asId("a1"), at: LATER });
|
|
235
|
+
const second = await store.claimApproval({ tenantId: T1, interactionId: asId("a1"), at: LATER });
|
|
236
|
+
expect(first.claimed).toBe(true);
|
|
237
|
+
expect(second.claimed).toBe(false);
|
|
238
|
+
expect(second.approval.consumedAt).toBe(first.approval.consumedAt);
|
|
239
|
+
});
|
|
240
|
+
it("refuses to claim an approval nobody has decided", async () => {
|
|
241
|
+
const store = await open();
|
|
242
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
243
|
+
const claim = await store.claimApproval({ tenantId: T1, interactionId: asId("a1"), at: LATER });
|
|
244
|
+
expect(claim.claimed).toBe(false);
|
|
245
|
+
expect(claim.approval.consumedAt).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
it("a claimed approval is no longer the run's resumable one", async () => {
|
|
248
|
+
const store = await open();
|
|
249
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
250
|
+
await store.decideApproval({
|
|
251
|
+
tenantId: T1,
|
|
252
|
+
interactionId: asId("a1"),
|
|
253
|
+
decision: "allow-once",
|
|
254
|
+
at: LATER,
|
|
255
|
+
});
|
|
256
|
+
await store.claimApproval({ tenantId: T1, interactionId: asId("a1"), at: LATER });
|
|
257
|
+
expect(await store.findDecidedApproval({ tenantId: T1, runId: RUN })).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
it("reads one approval back by id, so a one-time authorization can be verified", async () => {
|
|
260
|
+
const store = await open();
|
|
261
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
262
|
+
expect(await store.findApproval({ tenantId: T1, interactionId: asId("a1") })).toMatchObject({
|
|
263
|
+
id: "a1",
|
|
264
|
+
toolName: "publish_post",
|
|
265
|
+
});
|
|
266
|
+
expect(await store.findApproval({ tenantId: T1, interactionId: asId("nope") })).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
it("enforces tenant isolation", async () => {
|
|
269
|
+
const store = await open();
|
|
270
|
+
await store.createQuestion({ tenantId: T1, question: question("q1") });
|
|
271
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
272
|
+
expect(await store.findPendingQuestion({ tenantId: T2, runId: RUN })).toBeNull();
|
|
273
|
+
expect(await store.findPendingApproval({ tenantId: T2, runId: RUN })).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
it("isolates the resumption lookups by tenant too", async () => {
|
|
276
|
+
const store = await open();
|
|
277
|
+
await store.createApproval({ tenantId: T1, approval: approval("a1") });
|
|
278
|
+
await store.decideApproval({
|
|
279
|
+
tenantId: T1,
|
|
280
|
+
interactionId: asId("a1"),
|
|
281
|
+
decision: "allow-once",
|
|
282
|
+
at: LATER,
|
|
283
|
+
});
|
|
284
|
+
expect(await store.findDecidedApproval({ tenantId: T2, runId: RUN })).toBeNull();
|
|
285
|
+
expect(await store.findApproval({ tenantId: T2, interactionId: asId("a1") })).toBeNull();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
export function approvalGrantStoreConformance(makeFixture) {
|
|
290
|
+
// Grants are standing tenant-level permissions with no run and no required conversation row, so
|
|
291
|
+
// there is no parent to seed — the fixture form is accepted only for symmetry with its sibling.
|
|
292
|
+
const open = () => withRun(makeFixture(), []);
|
|
293
|
+
const grant = (id, over = {}) => ({
|
|
294
|
+
id: asId(id),
|
|
295
|
+
tenantId: T1,
|
|
296
|
+
scope: "tenant",
|
|
297
|
+
toolNameOrCategory: "publish_post",
|
|
298
|
+
grantedAt: NOW,
|
|
299
|
+
...over,
|
|
300
|
+
});
|
|
301
|
+
describe("ApprovalGrantStore conformance", () => {
|
|
302
|
+
it("returns null when no grant exists", async () => {
|
|
303
|
+
expect(await (await open()).findActive({ tenantId: T1, toolNameOrCategory: "publish_post", now: NOW })).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
it("finds an active tenant-wide grant", async () => {
|
|
306
|
+
const store = await open();
|
|
307
|
+
await store.grant({ tenantId: T1, grant: grant("g1") });
|
|
308
|
+
expect(await store.findActive({ tenantId: T1, toolNameOrCategory: "publish_post", now: NOW })).toMatchObject({ id: "g1" });
|
|
309
|
+
});
|
|
310
|
+
it("a conversation-scoped grant matches only its own conversation", async () => {
|
|
311
|
+
const store = await open();
|
|
312
|
+
await store.grant({
|
|
313
|
+
tenantId: T1,
|
|
314
|
+
grant: grant("g1", { scope: "conversation", conversationId: C1 }),
|
|
315
|
+
});
|
|
316
|
+
expect(await store.findActive({
|
|
317
|
+
tenantId: T1,
|
|
318
|
+
toolNameOrCategory: "publish_post",
|
|
319
|
+
now: NOW,
|
|
320
|
+
conversationId: C1,
|
|
321
|
+
})).toMatchObject({ id: "g1" });
|
|
322
|
+
expect(await store.findActive({
|
|
323
|
+
tenantId: T1,
|
|
324
|
+
toolNameOrCategory: "publish_post",
|
|
325
|
+
now: NOW,
|
|
326
|
+
conversationId: C2,
|
|
327
|
+
})).toBeNull();
|
|
328
|
+
});
|
|
329
|
+
it("a conversation-scoped grant never leaks tenant-wide when no conversation is supplied", async () => {
|
|
330
|
+
const store = await open();
|
|
331
|
+
await store.grant({
|
|
332
|
+
tenantId: T1,
|
|
333
|
+
grant: grant("g1", { scope: "conversation", conversationId: C1 }),
|
|
334
|
+
});
|
|
335
|
+
expect(await store.findActive({ tenantId: T1, toolNameOrCategory: "publish_post", now: NOW })).toBeNull();
|
|
336
|
+
});
|
|
337
|
+
it("an expired grant is not active", async () => {
|
|
338
|
+
const store = await open();
|
|
339
|
+
await store.grant({ tenantId: T1, grant: grant("g1", { expiresAt: NOW }) });
|
|
340
|
+
expect(await store.findActive({ tenantId: T1, toolNameOrCategory: "publish_post", now: LATER })).toBeNull();
|
|
341
|
+
});
|
|
342
|
+
it("a revoked grant is not active", async () => {
|
|
343
|
+
const store = await open();
|
|
344
|
+
await store.grant({ tenantId: T1, grant: grant("g1") });
|
|
345
|
+
await store.revoke({ tenantId: T1, grantId: asId("g1"), at: NOW });
|
|
346
|
+
expect(await store.findActive({ tenantId: T1, toolNameOrCategory: "publish_post", now: LATER })).toBeNull();
|
|
347
|
+
});
|
|
348
|
+
it("does not match a different tool", async () => {
|
|
349
|
+
const store = await open();
|
|
350
|
+
await store.grant({ tenantId: T1, grant: grant("g1") });
|
|
351
|
+
expect(await store.findActive({ tenantId: T1, toolNameOrCategory: "delete_everything", now: NOW })).toBeNull();
|
|
352
|
+
});
|
|
353
|
+
it("enforces tenant isolation", async () => {
|
|
354
|
+
const store = await open();
|
|
355
|
+
await store.grant({ tenantId: T1, grant: grant("g1") });
|
|
356
|
+
expect(await store.findActive({ tenantId: T2, toolNameOrCategory: "publish_post", now: NOW })).toBeNull();
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
export function usageStoreConformance(makeFixture) {
|
|
361
|
+
// A usage record belongs to a run, and the Postgres schema enforces it (#100) — an orphan cost
|
|
362
|
+
// record is a charge attributable to nothing. The harness uses two runs, so both are seeded.
|
|
363
|
+
const open = () => withRun(makeFixture(), [
|
|
364
|
+
{ tenantId: T1, runId: RUN },
|
|
365
|
+
{ tenantId: T1, runId: asId("conf-run-2") },
|
|
366
|
+
]);
|
|
367
|
+
const event = (id, over = {}) => ({
|
|
368
|
+
id,
|
|
369
|
+
tenantId: T1,
|
|
370
|
+
runId: RUN,
|
|
371
|
+
conversationId: asId(C1),
|
|
372
|
+
stepId: "step-1",
|
|
373
|
+
modelId: "claude-opus-5",
|
|
374
|
+
inputTokens: 100,
|
|
375
|
+
outputTokens: 50,
|
|
376
|
+
cachedInputTokens: 10,
|
|
377
|
+
reasoningTokens: 5,
|
|
378
|
+
costMinorUnits: 250,
|
|
379
|
+
currency: "EUR",
|
|
380
|
+
occurredAt: NOW,
|
|
381
|
+
...over,
|
|
382
|
+
});
|
|
383
|
+
describe("UsageStore conformance", () => {
|
|
384
|
+
it("appends an event and lists it by run", async () => {
|
|
385
|
+
const store = await open();
|
|
386
|
+
await store.append({ tenantId: T1, event: event("u1") });
|
|
387
|
+
const page = await store.listByRun({ tenantId: T1, runId: RUN, limit: 10 });
|
|
388
|
+
expect(page.items).toHaveLength(1);
|
|
389
|
+
});
|
|
390
|
+
/**
|
|
391
|
+
* `totalsBetween` — #181. A rolling window's total comes from here, so every one of these is a number that
|
|
392
|
+
* decides whether somebody is refused.
|
|
393
|
+
*/
|
|
394
|
+
it("totals an exact interval, half-open at both ends", async () => {
|
|
395
|
+
const store = await open();
|
|
396
|
+
// 00:00, 01:00, 02:00. A window of [01:00, 02:00) must contain exactly the middle one: the lower bound is
|
|
397
|
+
// inclusive and the upper exclusive, so two adjacent windows tile without a record being counted twice.
|
|
398
|
+
await store.append({ tenantId: T1, event: event("u1", { occurredAt: "2020-01-01T00:00:00.000Z" }) });
|
|
399
|
+
await store.append({ tenantId: T1, event: event("u2", { stepId: "s2", occurredAt: "2020-01-01T01:00:00.000Z" }) });
|
|
400
|
+
await store.append({ tenantId: T1, event: event("u3", { stepId: "s3", occurredAt: "2020-01-01T02:00:00.000Z" }) });
|
|
401
|
+
const middle = await store.totalsBetween({
|
|
402
|
+
tenantId: T1,
|
|
403
|
+
from: "2020-01-01T01:00:00.000Z",
|
|
404
|
+
to: "2020-01-01T02:00:00.000Z",
|
|
405
|
+
});
|
|
406
|
+
expect(middle.totals.eventCount).toBe(1);
|
|
407
|
+
expect(middle.totals.costMinorUnits).toBe(250);
|
|
408
|
+
expect(middle.earliestAt).toBe("2020-01-01T01:00:00.000Z");
|
|
409
|
+
});
|
|
410
|
+
it("reports the earliest record in the window, and null for an empty one", async () => {
|
|
411
|
+
const store = await open();
|
|
412
|
+
await store.append({ tenantId: T1, event: event("u1", { occurredAt: "2020-01-01T05:00:00.000Z" }) });
|
|
413
|
+
await store.append({ tenantId: T1, event: event("u2", { stepId: "s2", occurredAt: "2020-01-01T03:00:00.000Z" }) });
|
|
414
|
+
const window = await store.totalsBetween({
|
|
415
|
+
tenantId: T1,
|
|
416
|
+
from: "2020-01-01T00:00:00.000Z",
|
|
417
|
+
to: "2020-01-01T06:00:00.000Z",
|
|
418
|
+
});
|
|
419
|
+
// The earliest, not the first appended — a rolling window's headroom returns when the *oldest* record
|
|
420
|
+
// leaves it, so an insertion-ordered answer would name the wrong time.
|
|
421
|
+
expect(window.earliestAt).toBe("2020-01-01T03:00:00.000Z");
|
|
422
|
+
expect(window.totals.eventCount).toBe(2);
|
|
423
|
+
// Null rather than a substituted date: a window nothing was spent in has no reset time, and inventing one
|
|
424
|
+
// would put a false promise in a refusal message.
|
|
425
|
+
const empty = await store.totalsBetween({
|
|
426
|
+
tenantId: T1,
|
|
427
|
+
from: "2020-01-02T00:00:00.000Z",
|
|
428
|
+
to: "2020-01-02T06:00:00.000Z",
|
|
429
|
+
});
|
|
430
|
+
expect(empty.earliestAt).toBeNull();
|
|
431
|
+
expect(empty.totals.eventCount).toBe(0);
|
|
432
|
+
expect(empty.totals.costMinorUnits).toBe(0);
|
|
433
|
+
});
|
|
434
|
+
it("treats an absent principal as every principal, not as a null principal", async () => {
|
|
435
|
+
const store = await open();
|
|
436
|
+
const alice = asId("alice");
|
|
437
|
+
const bob = asId("bob");
|
|
438
|
+
await store.append({ tenantId: T1, event: event("u1", { principalId: alice }) });
|
|
439
|
+
await store.append({ tenantId: T1, event: event("u2", { stepId: "s2", principalId: bob }) });
|
|
440
|
+
// No principal at all — a background job's record. It is part of the tenant's spend.
|
|
441
|
+
await store.append({ tenantId: T1, event: event("u3", { stepId: "s3" }) });
|
|
442
|
+
const range = { tenantId: T1, from: "2019-12-31T00:00:00.000Z", to: "2020-01-02T00:00:00.000Z" };
|
|
443
|
+
// The trap this exists to catch: implemented as `principal_id = $n` with a null parameter, or as
|
|
444
|
+
// `principalId === event.principalId` with both undefined, "no principal given" selects only the rows
|
|
445
|
+
// that *have* no principal — so a tenant-wide rolling limit would see one record out of three and refuse
|
|
446
|
+
// nobody.
|
|
447
|
+
const everyone = await store.totalsBetween(range);
|
|
448
|
+
expect(everyone.totals.eventCount).toBe(3);
|
|
449
|
+
const justAlice = await store.totalsBetween({ ...range, principalId: alice });
|
|
450
|
+
expect(justAlice.totals.eventCount).toBe(1);
|
|
451
|
+
expect(justAlice.totals.costMinorUnits).toBe(250);
|
|
452
|
+
});
|
|
453
|
+
it("scopes a window to one model, leaving the others untouched", async () => {
|
|
454
|
+
const store = await open();
|
|
455
|
+
await store.append({ tenantId: T1, event: event("u1", { modelId: "claude-opus-5" }) });
|
|
456
|
+
await store.append({ tenantId: T1, event: event("u2", { stepId: "s2", modelId: "claude-opus-5" }) });
|
|
457
|
+
await store.append({ tenantId: T1, event: event("u3", { stepId: "s3", modelId: "claude-haiku-4-5" }) });
|
|
458
|
+
const range = { tenantId: T1, from: "2019-12-31T00:00:00.000Z", to: "2020-01-02T00:00:00.000Z" };
|
|
459
|
+
// #182 depends on this: an expensive model's allowance must not be consumed by a cheap model's traffic.
|
|
460
|
+
expect((await store.totalsBetween({ ...range, modelId: "claude-opus-5" })).totals.eventCount).toBe(2);
|
|
461
|
+
expect((await store.totalsBetween({ ...range, modelId: "claude-haiku-4-5" })).totals.eventCount).toBe(1);
|
|
462
|
+
expect((await store.totalsBetween(range)).totals.eventCount).toBe(3);
|
|
463
|
+
});
|
|
464
|
+
it("does not total another tenant's records", async () => {
|
|
465
|
+
const store = await open();
|
|
466
|
+
await store.append({ tenantId: T1, event: event("u1") });
|
|
467
|
+
const range = { from: "2019-12-31T00:00:00.000Z", to: "2020-01-02T00:00:00.000Z" };
|
|
468
|
+
const other = await store.totalsBetween({ tenantId: asId("conf-tenant-other"), ...range });
|
|
469
|
+
expect(other.totals.eventCount).toBe(0);
|
|
470
|
+
expect(other.earliestAt).toBeNull();
|
|
471
|
+
});
|
|
472
|
+
it("totals sum exactly, with integer minor units", async () => {
|
|
473
|
+
const store = await open();
|
|
474
|
+
await store.append({ tenantId: T1, event: event("u1") });
|
|
475
|
+
await store.append({ tenantId: T1, event: event("u2", { stepId: "step-2" }) });
|
|
476
|
+
const totals = await store.totals({ tenantId: T1, runId: RUN });
|
|
477
|
+
expect(totals.inputTokens).toBe(200);
|
|
478
|
+
expect(totals.outputTokens).toBe(100);
|
|
479
|
+
expect(totals.costMinorUnits).toBe(500);
|
|
480
|
+
expect(totals.eventCount).toBe(2);
|
|
481
|
+
expect(Number.isInteger(totals.costMinorUnits)).toBe(true);
|
|
482
|
+
});
|
|
483
|
+
it("is idempotent on a repeated append, so a recovered run never double-counts", async () => {
|
|
484
|
+
const store = await open();
|
|
485
|
+
await store.append({ tenantId: T1, event: event("u1") });
|
|
486
|
+
await store.append({ tenantId: T1, event: event("u1") });
|
|
487
|
+
const totals = await store.totals({ tenantId: T1, runId: RUN });
|
|
488
|
+
expect(totals.eventCount).toBe(1);
|
|
489
|
+
expect(totals.costMinorUnits).toBe(250);
|
|
490
|
+
});
|
|
491
|
+
it("scopes totals to a run", async () => {
|
|
492
|
+
const store = await open();
|
|
493
|
+
const otherRun = asId("conf-run-2");
|
|
494
|
+
await store.append({ tenantId: T1, event: event("u1") });
|
|
495
|
+
await store.append({ tenantId: T1, event: event("u2", { runId: otherRun }) });
|
|
496
|
+
expect((await store.totals({ tenantId: T1, runId: RUN })).eventCount).toBe(1);
|
|
497
|
+
expect((await store.totals({ tenantId: T1, runId: otherRun })).eventCount).toBe(1);
|
|
498
|
+
});
|
|
499
|
+
it("pages listByRun by stable cursor", async () => {
|
|
500
|
+
const store = await open();
|
|
501
|
+
for (const n of [1, 2, 3, 4, 5]) {
|
|
502
|
+
await store.append({ tenantId: T1, event: event(`u${n}`, { stepId: `step-${n}` }) });
|
|
503
|
+
}
|
|
504
|
+
const first = await store.listByRun({ tenantId: T1, runId: RUN, limit: 2 });
|
|
505
|
+
expect(first.items).toHaveLength(2);
|
|
506
|
+
const second = await store.listByRun({
|
|
507
|
+
tenantId: T1,
|
|
508
|
+
runId: RUN,
|
|
509
|
+
limit: 2,
|
|
510
|
+
cursor: first.nextCursor,
|
|
511
|
+
});
|
|
512
|
+
const ids = new Set(first.items.map((e) => e.id));
|
|
513
|
+
expect(second.items.some((e) => ids.has(e.id))).toBe(false);
|
|
514
|
+
});
|
|
515
|
+
it("enforces tenant isolation", async () => {
|
|
516
|
+
const store = await open();
|
|
517
|
+
await store.append({ tenantId: T1, event: event("u1") });
|
|
518
|
+
expect((await store.listByRun({ tenantId: T2, runId: RUN, limit: 10 })).items).toHaveLength(0);
|
|
519
|
+
expect((await store.totals({ tenantId: T2, runId: RUN })).eventCount).toBe(0);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
//# sourceMappingURL=hitl.js.map
|