@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,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `UsageRollupStore` conformance (#139).
|
|
3
|
+
*
|
|
4
|
+
* Almost every case is about a property that only shows up under repetition or concurrency: idempotency, no
|
|
5
|
+
* double counting, no lost records, no cross-tenant aggregate. Those are the ones a spend figure has to get
|
|
6
|
+
* right, and the ones a single-write test cannot see.
|
|
7
|
+
*
|
|
8
|
+
* The fixture pairs the rollup store with the ledger it derives from, because a rollup computed from a
|
|
9
|
+
* *different* set of events than the ledger holds is the exact bug this port exists to prevent — and a suite
|
|
10
|
+
* that opened them separately could not notice.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { asId } from "../../core/ids.js";
|
|
14
|
+
const T1 = asId("conf-rollup-tenant-1");
|
|
15
|
+
const T2 = asId("conf-rollup-tenant-2");
|
|
16
|
+
const HOUR = "2026-08-23T10:00:00.000Z";
|
|
17
|
+
const NEXT_HOUR = "2026-08-23T11:00:00.000Z";
|
|
18
|
+
const DAY = "2026-08-23T00:00:00.000Z";
|
|
19
|
+
/** Two people in one tenant — the case every earlier rollup test lacked, which is how #175 hid. */
|
|
20
|
+
const P1 = asId("conf-principal-1");
|
|
21
|
+
const P2 = asId("conf-principal-2");
|
|
22
|
+
const event = (overrides) => ({
|
|
23
|
+
id: overrides.id ?? `e${overrides.n}`,
|
|
24
|
+
tenantId: overrides.tenantId ?? T1,
|
|
25
|
+
runId: overrides.runId ?? asId(`run-${overrides.n}`),
|
|
26
|
+
modelId: "m1",
|
|
27
|
+
inputTokens: overrides.inputTokens ?? 100,
|
|
28
|
+
outputTokens: overrides.outputTokens ?? 20,
|
|
29
|
+
cachedInputTokens: overrides.cachedInputTokens ?? 5,
|
|
30
|
+
// Non-zero by default, so every case exercises it. Found by sabotage: with it unset, making
|
|
31
|
+
// `reasoning_tokens` accumulate passed every double-count test — 0 + 0 is 0.
|
|
32
|
+
reasoningTokens: overrides.reasoningTokens ?? 3,
|
|
33
|
+
costMinorUnits: overrides.costMinorUnits ?? 7,
|
|
34
|
+
currency: overrides.currency ?? "EUR",
|
|
35
|
+
occurredAt: overrides.occurredAt ?? "2026-08-23T10:30:00.000Z",
|
|
36
|
+
...(overrides.stepId === undefined ? {} : { stepId: overrides.stepId }),
|
|
37
|
+
// #175. Absent by default so the pre-existing cases keep testing the tenant grain alone, and a case that wants
|
|
38
|
+
// two people in one tenant says so — which is the case every earlier test lacked.
|
|
39
|
+
...(overrides.principalId === undefined ? {} : { principalId: overrides.principalId }),
|
|
40
|
+
});
|
|
41
|
+
export function usageRollupStoreConformance(make) {
|
|
42
|
+
describe("UsageRollupStore conformance", () => {
|
|
43
|
+
const seeded = async (events) => {
|
|
44
|
+
const fixture = await make();
|
|
45
|
+
// Parents first, and de-duplicated: several events share a run in the concurrency cases, and creating a
|
|
46
|
+
// run twice is a conflict rather than a no-op.
|
|
47
|
+
const runs = new Map();
|
|
48
|
+
for (const e of events)
|
|
49
|
+
runs.set(`${e.tenantId} ${e.runId}`, { tenantId: e.tenantId, runId: e.runId });
|
|
50
|
+
for (const run of runs.values())
|
|
51
|
+
await fixture.seedRun?.(run);
|
|
52
|
+
for (const e of events)
|
|
53
|
+
await fixture.usage.append({ tenantId: e.tenantId, event: e });
|
|
54
|
+
return fixture;
|
|
55
|
+
};
|
|
56
|
+
/** Append after seeding, creating the event's run parent first. Two cases need it and both did without. */
|
|
57
|
+
const appendLater = async (fixture, e) => {
|
|
58
|
+
await fixture.seedRun?.({ tenantId: e.tenantId, runId: e.runId });
|
|
59
|
+
await fixture.usage.append({ tenantId: e.tenantId, event: e });
|
|
60
|
+
};
|
|
61
|
+
it("sums a bucket's events exactly", async () => {
|
|
62
|
+
const { rollups } = await seeded([
|
|
63
|
+
event({ n: 1, costMinorUnits: 7 }),
|
|
64
|
+
event({ n: 2, costMinorUnits: 11 }),
|
|
65
|
+
event({ n: 3, costMinorUnits: 13 }),
|
|
66
|
+
]);
|
|
67
|
+
const row = await rollups.rebuild({
|
|
68
|
+
tenantId: T1,
|
|
69
|
+
period: "hour",
|
|
70
|
+
bucketStart: HOUR,
|
|
71
|
+
});
|
|
72
|
+
expect(row).toMatchObject({
|
|
73
|
+
period: "hour",
|
|
74
|
+
bucketStart: HOUR,
|
|
75
|
+
costMinorUnits: 31,
|
|
76
|
+
inputTokens: 300,
|
|
77
|
+
outputTokens: 60,
|
|
78
|
+
eventCount: 3,
|
|
79
|
+
currency: "EUR",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
it("matches a direct sum of the ledger", async () => {
|
|
83
|
+
// The comparison the test steps ask for. A rollup that disagreed with the ledger would be a plausible
|
|
84
|
+
// wrong number, which is the worst kind for an invoice.
|
|
85
|
+
const events = Array.from({ length: 50 }, (_, i) => event({ n: i, costMinorUnits: i + 1, inputTokens: i * 3, outputTokens: i }));
|
|
86
|
+
const { usage, rollups } = await seeded(events);
|
|
87
|
+
const row = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
88
|
+
const direct = await usage.totals({ tenantId: T1 });
|
|
89
|
+
expect(row.costMinorUnits).toBe(direct.costMinorUnits);
|
|
90
|
+
expect(row.inputTokens).toBe(direct.inputTokens);
|
|
91
|
+
expect(row.eventCount).toBe(direct.eventCount);
|
|
92
|
+
});
|
|
93
|
+
it("does not double count when rebuilt twice", async () => {
|
|
94
|
+
// AC-4. A rebuild is a *recomputation*, so this is structural rather than bookkept — but it is the one
|
|
95
|
+
// property whose failure is invisible until an invoice is wrong.
|
|
96
|
+
const { rollups } = await seeded([event({ n: 1 }), event({ n: 2 })]);
|
|
97
|
+
const first = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
98
|
+
const second = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
99
|
+
// *Every* numeric field, not only cost and count. Found by sabotage: making just `input_tokens`
|
|
100
|
+
// accumulate passed a test that checked the other two, and a rollup with correct money and wrong tokens
|
|
101
|
+
// is the kind of wrong that survives review.
|
|
102
|
+
expect({ ...second, computedAt: "" }).toEqual({ ...first, computedAt: "" });
|
|
103
|
+
// `computedAt` is stamped by the store and moves forward (or stays, within a clock tick) — never
|
|
104
|
+
// backwards, which is what would make a fresh bucket look stale forever.
|
|
105
|
+
expect(second.computedAt >= first.computedAt).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
it("does not double count when two writers race the same bucket", async () => {
|
|
108
|
+
const { rollups } = await seeded([event({ n: 1 }), event({ n: 2 }), event({ n: 3 })]);
|
|
109
|
+
const [a, b] = await Promise.all([
|
|
110
|
+
rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR }),
|
|
111
|
+
rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR }),
|
|
112
|
+
]);
|
|
113
|
+
// Every field, for the same reason as above: two racing writers agreeing on the money and disagreeing on
|
|
114
|
+
// the tokens is still a corrupt rollup.
|
|
115
|
+
expect({ ...a, computedAt: "" }).toEqual({ ...b, computedAt: "" });
|
|
116
|
+
expect(await rollups.get({ tenantId: T1, period: "hour", bucketStart: HOUR })).toMatchObject({
|
|
117
|
+
costMinorUnits: 21,
|
|
118
|
+
eventCount: 3,
|
|
119
|
+
inputTokens: 300,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
it("loses nothing when events land between two rebuilds", async () => {
|
|
123
|
+
// The "no lost records" half of AC-4: the aggregation and the write are one statement, so an event
|
|
124
|
+
// arriving cannot fall between a read and a write and be dropped from the rollup while sitting in the
|
|
125
|
+
// ledger.
|
|
126
|
+
const fixture = await seeded([event({ n: 1 })]);
|
|
127
|
+
await fixture.rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
128
|
+
await appendLater(fixture, event({ n: 2 }));
|
|
129
|
+
const row = await fixture.rollups.rebuild({
|
|
130
|
+
tenantId: T1,
|
|
131
|
+
period: "hour",
|
|
132
|
+
bucketStart: HOUR,
|
|
133
|
+
});
|
|
134
|
+
expect(row.eventCount).toBe(2);
|
|
135
|
+
});
|
|
136
|
+
it("excludes an event from the next bucket", async () => {
|
|
137
|
+
// An off-by-one on the upper bound would put an event in two buckets, so a day total would exceed the
|
|
138
|
+
// sum of its hours.
|
|
139
|
+
const { rollups } = await seeded([
|
|
140
|
+
event({ n: 1, occurredAt: "2026-08-23T10:59:59.999Z" }),
|
|
141
|
+
event({ n: 2, occurredAt: NEXT_HOUR }),
|
|
142
|
+
]);
|
|
143
|
+
const first = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
144
|
+
const second = await rollups.rebuild({
|
|
145
|
+
tenantId: T1,
|
|
146
|
+
period: "hour",
|
|
147
|
+
bucketStart: NEXT_HOUR,
|
|
148
|
+
});
|
|
149
|
+
expect(first.eventCount).toBe(1);
|
|
150
|
+
expect(second.eventCount).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
it("makes a day's total the sum of its hours", async () => {
|
|
153
|
+
// The invariant that catches a boundary error in either period: if an event is double-counted or dropped
|
|
154
|
+
// by one of them, the two totals stop agreeing.
|
|
155
|
+
const { rollups } = await seeded([
|
|
156
|
+
event({ n: 1, occurredAt: "2026-08-23T00:15:00.000Z", costMinorUnits: 5 }),
|
|
157
|
+
event({ n: 2, occurredAt: "2026-08-23T10:30:00.000Z", costMinorUnits: 7 }),
|
|
158
|
+
event({ n: 3, occurredAt: "2026-08-23T23:59:00.000Z", costMinorUnits: 11 }),
|
|
159
|
+
]);
|
|
160
|
+
for (const bucketStart of ["2026-08-23T00:00:00.000Z", HOUR, "2026-08-23T23:00:00.000Z"]) {
|
|
161
|
+
await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart });
|
|
162
|
+
}
|
|
163
|
+
const day = await rollups.rebuild({ tenantId: T1, period: "day", bucketStart: DAY });
|
|
164
|
+
const hours = await rollups.sum({
|
|
165
|
+
tenantId: T1,
|
|
166
|
+
period: "hour",
|
|
167
|
+
from: DAY,
|
|
168
|
+
to: "2026-08-24T00:00:00.000Z",
|
|
169
|
+
});
|
|
170
|
+
expect(day.costMinorUnits).toBe(hours.costMinorUnits);
|
|
171
|
+
expect(day.eventCount).toBe(hours.eventCount);
|
|
172
|
+
});
|
|
173
|
+
it("returns null for a bucket nothing has computed", async () => {
|
|
174
|
+
// Distinct from a zero bucket: "never computed" and "computed and empty" want different responses from a
|
|
175
|
+
// job, and conflating them would either re-run everything forever or never run anything.
|
|
176
|
+
const { rollups } = await seeded([event({ n: 1 })]);
|
|
177
|
+
expect(await rollups.get({ tenantId: T1, period: "hour", bucketStart: NEXT_HOUR })).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
it("computes an empty bucket as zero rather than refusing", async () => {
|
|
180
|
+
const { rollups } = await seeded([]);
|
|
181
|
+
const row = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
182
|
+
expect(row).toMatchObject({ costMinorUnits: 0, eventCount: 0, inputTokens: 0 });
|
|
183
|
+
});
|
|
184
|
+
it("never aggregates across tenants", async () => {
|
|
185
|
+
// AC-6, and the case a `WHERE` clause someone forgot would fail: both tenants have events in the same
|
|
186
|
+
// bucket, so a missing predicate produces a plausible larger number rather than an error.
|
|
187
|
+
const { rollups } = await seeded([
|
|
188
|
+
event({ n: 1, costMinorUnits: 7 }),
|
|
189
|
+
event({ n: 2, tenantId: T2, costMinorUnits: 1000 }),
|
|
190
|
+
]);
|
|
191
|
+
const mine = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
192
|
+
expect(mine.costMinorUnits).toBe(7);
|
|
193
|
+
const theirs = await rollups.rebuild({ tenantId: T2, period: "hour", bucketStart: HOUR });
|
|
194
|
+
expect(theirs.costMinorUnits).toBe(1000);
|
|
195
|
+
// And neither can read the other's bucket.
|
|
196
|
+
expect(await rollups.get({ tenantId: T2, period: "hour", bucketStart: HOUR })).toMatchObject({
|
|
197
|
+
costMinorUnits: 1000,
|
|
198
|
+
});
|
|
199
|
+
expect(await rollups.sum({ tenantId: T1, period: "hour", from: HOUR, to: NEXT_HOUR })).toMatchObject({
|
|
200
|
+
costMinorUnits: 7,
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
it("lists a range in order, exclusive at the top", async () => {
|
|
204
|
+
// Exclusive so adjacent ranges tile: a caller asking for two ranges must not get a boundary bucket twice.
|
|
205
|
+
const { rollups } = await seeded([]);
|
|
206
|
+
for (const h of [10, 11, 12]) {
|
|
207
|
+
await rollups.rebuild({
|
|
208
|
+
tenantId: T1,
|
|
209
|
+
period: "hour",
|
|
210
|
+
bucketStart: `2026-08-23T${h}:00:00.000Z`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const page = await rollups.list({
|
|
214
|
+
tenantId: T1,
|
|
215
|
+
period: "hour",
|
|
216
|
+
from: HOUR,
|
|
217
|
+
to: "2026-08-23T12:00:00.000Z",
|
|
218
|
+
limit: 10,
|
|
219
|
+
});
|
|
220
|
+
expect(page.items.map((r) => r.bucketStart)).toEqual([HOUR, NEXT_HOUR]);
|
|
221
|
+
});
|
|
222
|
+
it("pages a range without repeating or skipping a bucket", async () => {
|
|
223
|
+
const { rollups } = await seeded([]);
|
|
224
|
+
for (const h of [10, 11, 12, 13, 14]) {
|
|
225
|
+
await rollups.rebuild({
|
|
226
|
+
tenantId: T1,
|
|
227
|
+
period: "hour",
|
|
228
|
+
bucketStart: `2026-08-23T${h}:00:00.000Z`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const seen = [];
|
|
232
|
+
let cursor;
|
|
233
|
+
do {
|
|
234
|
+
const page = await rollups.list({
|
|
235
|
+
tenantId: T1,
|
|
236
|
+
period: "hour",
|
|
237
|
+
from: HOUR,
|
|
238
|
+
to: "2026-08-23T15:00:00.000Z",
|
|
239
|
+
limit: 2,
|
|
240
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
241
|
+
});
|
|
242
|
+
seen.push(...page.items.map((r) => r.bucketStart));
|
|
243
|
+
cursor = page.nextCursor;
|
|
244
|
+
} while (cursor !== undefined);
|
|
245
|
+
expect(seen).toHaveLength(5);
|
|
246
|
+
expect(new Set(seen).size).toBe(5);
|
|
247
|
+
});
|
|
248
|
+
it("keeps the hour and day series apart", async () => {
|
|
249
|
+
// Same tenant, same instant, two periods. A key missing `period` would have one overwrite the other and a
|
|
250
|
+
// day total would silently become an hour's.
|
|
251
|
+
const { rollups } = await seeded([event({ n: 1, occurredAt: "2026-08-23T10:30:00.000Z" })]);
|
|
252
|
+
await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
253
|
+
await rollups.rebuild({ tenantId: T1, period: "day", bucketStart: DAY });
|
|
254
|
+
expect(await rollups.get({ tenantId: T1, period: "hour", bucketStart: HOUR })).not.toBeNull();
|
|
255
|
+
expect(await rollups.get({ tenantId: T1, period: "day", bucketStart: DAY })).not.toBeNull();
|
|
256
|
+
expect(await rollups.get({ tenantId: T1, period: "day", bucketStart: HOUR })).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
/**
|
|
259
|
+
* Per-principal rollups — #175.
|
|
260
|
+
*
|
|
261
|
+
* The tenant row and a person's row are the same measurement at two grains, stored as a nullable dimension
|
|
262
|
+
* on one table. The failure to guard against is them bleeding into each other: a tenant chart that summed its
|
|
263
|
+
* own row *and* every principal's would double every figure, and a per-person quota reading the tenant row
|
|
264
|
+
* would refuse someone for a colleague's spending.
|
|
265
|
+
*/
|
|
266
|
+
it("keeps a principal's bucket separate from the tenant's", async () => {
|
|
267
|
+
const { rollups } = await seeded([
|
|
268
|
+
event({ n: 1, principalId: P1, costMinorUnits: 10 }),
|
|
269
|
+
event({ n: 2, principalId: P2, costMinorUnits: 90 }),
|
|
270
|
+
]);
|
|
271
|
+
await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
272
|
+
await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P1 });
|
|
273
|
+
await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P2 });
|
|
274
|
+
// The tenant row is the whole bucket.
|
|
275
|
+
expect((await rollups.get({ tenantId: T1, period: "hour", bucketStart: HOUR }))?.costMinorUnits).toBe(100);
|
|
276
|
+
// Each principal row is that person's slice, and they do not see each other's.
|
|
277
|
+
expect((await rollups.get({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P1 }))?.costMinorUnits).toBe(10);
|
|
278
|
+
expect((await rollups.get({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P2 }))?.costMinorUnits).toBe(90);
|
|
279
|
+
});
|
|
280
|
+
it("does not add principal rows into a tenant sum", async () => {
|
|
281
|
+
// The double-count this dimension invites. Without a grain predicate, summing the range picks up the
|
|
282
|
+
// tenant row *and* both principal rows and reports 200 for 100 spent.
|
|
283
|
+
const { rollups } = await seeded([
|
|
284
|
+
event({ n: 1, principalId: P1, costMinorUnits: 10 }),
|
|
285
|
+
event({ n: 2, principalId: P2, costMinorUnits: 90 }),
|
|
286
|
+
]);
|
|
287
|
+
for (const principalId of [undefined, P1, P2]) {
|
|
288
|
+
await rollups.rebuild({
|
|
289
|
+
tenantId: T1,
|
|
290
|
+
period: "hour",
|
|
291
|
+
bucketStart: HOUR,
|
|
292
|
+
...(principalId === undefined ? {} : { principalId }),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const summed = await rollups.sum({ tenantId: T1, period: "hour", from: HOUR, to: NEXT_HOUR });
|
|
296
|
+
expect(summed.costMinorUnits).toBe(100);
|
|
297
|
+
// And a chart of the tenant lists one bucket, not three.
|
|
298
|
+
const listed = await rollups.list({ tenantId: T1, period: "hour", from: HOUR, to: NEXT_HOUR, limit: 10 });
|
|
299
|
+
expect(listed.items).toHaveLength(1);
|
|
300
|
+
expect(listed.items[0]?.principalId).toBeUndefined();
|
|
301
|
+
});
|
|
302
|
+
/**
|
|
303
|
+
* Rebuilding a **principal** bucket twice, which is the test that was missing — #175.
|
|
304
|
+
*
|
|
305
|
+
* Every other case here rebuilt each grain once, and the tenant grain had its own idempotency test. So a
|
|
306
|
+
* per-person bucket that inserted the first time and violated its unique index the second time passed the
|
|
307
|
+
* whole suite: `/api/usage` worked once after a fresh deploy and then returned
|
|
308
|
+
* duplicate-key errors forever.
|
|
309
|
+
*
|
|
310
|
+
* Uniqueness is two *partial* indexes because a NULL cannot participate in a normal unique constraint, and a
|
|
311
|
+
* single `ON CONFLICT` clause cannot serve both — naming the tenant index while inserting a principal row
|
|
312
|
+
* finds no arbiter at all.
|
|
313
|
+
*/
|
|
314
|
+
it("rebuilds a principal's bucket idempotently, not just the tenant's", async () => {
|
|
315
|
+
const { rollups } = await seeded([event({ n: 1, principalId: P1, costMinorUnits: 11 })]);
|
|
316
|
+
const first = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P1 });
|
|
317
|
+
// The second call is the one that used to throw.
|
|
318
|
+
const second = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P1 });
|
|
319
|
+
expect(second.costMinorUnits).toBe(first.costMinorUnits);
|
|
320
|
+
expect(second.costMinorUnits).toBe(11);
|
|
321
|
+
// A recomputation, not an accumulation: two rebuilds must not double the figure either.
|
|
322
|
+
expect((await rollups.get({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P1 }))?.costMinorUnits).toBe(11);
|
|
323
|
+
});
|
|
324
|
+
it("rebuilds both grains repeatedly without either disturbing the other", async () => {
|
|
325
|
+
// Interleaved, because the two writes share a table and the arbiter is chosen per call — an arbiter picked
|
|
326
|
+
// from the wrong grain corrupts or rejects depending on the order.
|
|
327
|
+
const { rollups } = await seeded([
|
|
328
|
+
event({ n: 1, principalId: P1, costMinorUnits: 3 }),
|
|
329
|
+
event({ n: 2, principalId: P2, costMinorUnits: 4 }),
|
|
330
|
+
]);
|
|
331
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
332
|
+
await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
333
|
+
await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P1 });
|
|
334
|
+
await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P2 });
|
|
335
|
+
}
|
|
336
|
+
expect((await rollups.get({ tenantId: T1, period: "hour", bucketStart: HOUR }))?.costMinorUnits).toBe(7);
|
|
337
|
+
expect((await rollups.get({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P1 }))?.costMinorUnits).toBe(3);
|
|
338
|
+
expect((await rollups.get({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P2 }))?.costMinorUnits).toBe(4);
|
|
339
|
+
});
|
|
340
|
+
it("carries the grain on the row, so a caller can tell them apart", async () => {
|
|
341
|
+
// Without this a caller holding a mixed list has no way to know which row is whose, and adding them up is
|
|
342
|
+
// the obvious next mistake.
|
|
343
|
+
const { rollups } = await seeded([event({ n: 1, principalId: P1 })]);
|
|
344
|
+
const own = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR, principalId: P1 });
|
|
345
|
+
const tenant = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
346
|
+
expect(own.principalId).toBe(P1);
|
|
347
|
+
expect(tenant.principalId).toBeUndefined();
|
|
348
|
+
});
|
|
349
|
+
it("reports a stale bucket at both grains, so the job rebuilds both", async () => {
|
|
350
|
+
/**
|
|
351
|
+
* A per-principal rollup is only useful if something rebuilds it, and the job cannot enumerate which
|
|
352
|
+
* principals were active without reading the ledger — the scan a rollup exists to avoid. The store already
|
|
353
|
+
* reads it to decide staleness, so it is the only place that knows cheaply.
|
|
354
|
+
*/
|
|
355
|
+
const { rollups } = await seeded([event({ n: 1, principalId: P1 })]);
|
|
356
|
+
const stale = await rollups.listStaleBuckets({ tenantId: T1, period: "hour", since: DAY, limit: 10 });
|
|
357
|
+
expect(stale.items).toHaveLength(2);
|
|
358
|
+
// The tenant row first, so a job that stops mid-bucket has at least the total.
|
|
359
|
+
expect(stale.items[0]?.principalId).toBeUndefined();
|
|
360
|
+
expect(stale.items[1]?.principalId).toBe(P1);
|
|
361
|
+
});
|
|
362
|
+
it("reports only the tenant grain when no event names a principal", async () => {
|
|
363
|
+
// The other half, and the one that caught a real bug: with a NULL principal both grouping sets produce the
|
|
364
|
+
// same row, so the tenant bucket was listed twice and the job rebuilt it twice.
|
|
365
|
+
const { rollups } = await seeded([event({ n: 1 })]);
|
|
366
|
+
const stale = await rollups.listStaleBuckets({ tenantId: T1, period: "hour", since: DAY, limit: 10 });
|
|
367
|
+
expect(stale.items).toEqual([{ period: "hour", bucketStart: HOUR }]);
|
|
368
|
+
});
|
|
369
|
+
it("lists a bucket with events but no rollup as stale", async () => {
|
|
370
|
+
const { rollups } = await seeded([event({ n: 1 })]);
|
|
371
|
+
const page = await rollups.listStaleBuckets({ tenantId: T1, period: "hour", since: DAY, limit: 10 });
|
|
372
|
+
expect(page.items).toEqual([{ period: "hour", bucketStart: HOUR }]);
|
|
373
|
+
});
|
|
374
|
+
it("stops listing a bucket once it is computed", async () => {
|
|
375
|
+
// Otherwise the job never drains and rebuilds the same bucket forever.
|
|
376
|
+
const { rollups } = await seeded([event({ n: 1 })]);
|
|
377
|
+
await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
378
|
+
expect((await rollups.listStaleBuckets({ tenantId: T1, period: "hour", since: DAY, limit: 10 })).items).toEqual([]);
|
|
379
|
+
});
|
|
380
|
+
it("lists a bucket again when a newer event lands in it", async () => {
|
|
381
|
+
// The other direction: a rollup computed before an event is wrong, and nothing else would notice.
|
|
382
|
+
const fixture = await seeded([event({ n: 1 })]);
|
|
383
|
+
await fixture.rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
384
|
+
await appendLater(fixture, event({ n: 2, occurredAt: "2026-08-23T12:30:00.000Z" }));
|
|
385
|
+
const page = await fixture.rollups.listStaleBuckets({
|
|
386
|
+
tenantId: T1,
|
|
387
|
+
period: "hour",
|
|
388
|
+
since: DAY,
|
|
389
|
+
limit: 10,
|
|
390
|
+
});
|
|
391
|
+
expect(page.items.map((b) => b.bucketStart)).toContain("2026-08-23T12:00:00.000Z");
|
|
392
|
+
});
|
|
393
|
+
it("lists a bucket again when a *late* event lands in it", async () => {
|
|
394
|
+
// The case that judging staleness by `occurredAt` misses entirely: an event recorded now but stamped in
|
|
395
|
+
// the past — a delayed provider report, or a recovered run replaying its steps. Its bucket looks already
|
|
396
|
+
// computed, so the event is never rolled up and the total is silently short. Found by writing this test
|
|
397
|
+
// and watching the rollup job report nothing to do.
|
|
398
|
+
const fixture = await seeded([event({ n: 1, occurredAt: "2026-08-23T10:50:00.000Z" })]);
|
|
399
|
+
await fixture.rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
400
|
+
// Recorded *after* the rollup, occurring *before* it — the whole point of the case.
|
|
401
|
+
await appendLater(fixture, event({ n: 2, occurredAt: "2026-08-23T10:05:00.000Z" }));
|
|
402
|
+
expect((await fixture.rollups.listStaleBuckets({ tenantId: T1, period: "hour", since: DAY, limit: 10 })).items).toEqual([{ period: "hour", bucketStart: HOUR }]);
|
|
403
|
+
// And the rebuild picks the late event up, which is the consequence that matters.
|
|
404
|
+
expect((await fixture.rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR })).eventCount).toBe(2);
|
|
405
|
+
// And the bucket is fresh again once rebuilt. Asserted exactly, which only became possible when staleness
|
|
406
|
+
// moved from a clock to a monotonic sequence: two timestamps can tie, and a tie had to be read as
|
|
407
|
+
// "stale" to stay safe — which made a genuinely drained bucket indistinguishable from one that was not.
|
|
408
|
+
expect((await fixture.rollups.listStaleBuckets({ tenantId: T1, period: "hour", since: DAY, limit: 10 })).items).toEqual([]);
|
|
409
|
+
});
|
|
410
|
+
it("lists no stale bucket for another tenant", async () => {
|
|
411
|
+
const { rollups } = await seeded([event({ n: 1 })]);
|
|
412
|
+
expect((await rollups.listStaleBuckets({ tenantId: T2, period: "hour", since: DAY, limit: 10 })).items).toEqual([]);
|
|
413
|
+
});
|
|
414
|
+
it("rolls up reasoning tokens the ledger recorded", async () => {
|
|
415
|
+
// The ledger has the column, so a rollup that ignored it would drop a figure that is on the invoice.
|
|
416
|
+
const { rollups } = await seeded([
|
|
417
|
+
event({ n: 1, reasoningTokens: 40 }),
|
|
418
|
+
event({ n: 2, reasoningTokens: 60 }),
|
|
419
|
+
]);
|
|
420
|
+
const row = await rollups.rebuild({ tenantId: T1, period: "hour", bucketStart: HOUR });
|
|
421
|
+
expect(row.reasoningTokens).toBe(100);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
//# sourceMappingURL=rollups.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ConversationRunCoordinator` conformance — `docs/13` → run ordering. The port's own docstrings
|
|
3
|
+
* are emphatic that both methods MUST be atomic: `claimOrEnqueue` with "no claim→enqueue gap" so a
|
|
4
|
+
* run cannot strand itself in an idle-but-unclaimed slot, and `releaseAndPromote` with "no
|
|
5
|
+
* release→dequeue→claim gap" so two runs can never both become active.
|
|
6
|
+
*
|
|
7
|
+
* Atomicity itself is not observable from a single-threaded caller, so these tests pin the
|
|
8
|
+
* *consequences* an adapter must produce: single-flight, FIFO order, and a promotion that hands
|
|
9
|
+
* back exactly one run.
|
|
10
|
+
*/
|
|
11
|
+
import type { ConversationRunCoordinator } from "../../persistence/index.js";
|
|
12
|
+
import { type AdapterDeclaration } from "./capability.js";
|
|
13
|
+
import { type Fixture, type FixtureOrStore } from "./parents.js";
|
|
14
|
+
/**
|
|
15
|
+
* A coordinator, plus — for adapters whose slot state lives outside the process — a way to build a
|
|
16
|
+
* *second* coordinator over the same backend.
|
|
17
|
+
*
|
|
18
|
+
* `sibling` is what makes the `distributed-locking` case below able to discriminate at all. Two
|
|
19
|
+
* independently constructed coordinators either share a slot (state is in the database) or they do
|
|
20
|
+
* not (state is in the object). That is the difference between distributed and in-process, and it is
|
|
21
|
+
* the most an in-process test can honestly check.
|
|
22
|
+
*/
|
|
23
|
+
export type RunCoordinatorFixture = Fixture<ConversationRunCoordinator> & {
|
|
24
|
+
readonly sibling?: () => ConversationRunCoordinator;
|
|
25
|
+
};
|
|
26
|
+
export declare function conversationRunCoordinatorConformance(makeFixture: () => FixtureOrStore<ConversationRunCoordinator> | RunCoordinatorFixture, declaration?: AdapterDeclaration): void;
|
|
27
|
+
//# sourceMappingURL=run-coordinator.d.ts.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ConversationRunCoordinator` conformance — `docs/13` → run ordering. The port's own docstrings
|
|
3
|
+
* are emphatic that both methods MUST be atomic: `claimOrEnqueue` with "no claim→enqueue gap" so a
|
|
4
|
+
* run cannot strand itself in an idle-but-unclaimed slot, and `releaseAndPromote` with "no
|
|
5
|
+
* release→dequeue→claim gap" so two runs can never both become active.
|
|
6
|
+
*
|
|
7
|
+
* Atomicity itself is not observable from a single-threaded caller, so these tests pin the
|
|
8
|
+
* *consequences* an adapter must produce: single-flight, FIFO order, and a promotion that hands
|
|
9
|
+
* back exactly one run.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { asId } from "../../core/ids.js";
|
|
13
|
+
import { gatedIt } from "./capability.js";
|
|
14
|
+
import { openFixture } from "./parents.js";
|
|
15
|
+
const T1 = asId("conf-tenant-1");
|
|
16
|
+
const T2 = asId("conf-tenant-2");
|
|
17
|
+
const C1 = asId("conf-convo-1");
|
|
18
|
+
const C2 = asId("conf-convo-2");
|
|
19
|
+
const r = (s) => asId(s);
|
|
20
|
+
/** Every conversation the harness touches, so an adapter with a foreign key can seed them all. */
|
|
21
|
+
const CONVERSATIONS = [
|
|
22
|
+
{ tenantId: T1, conversationId: C1 },
|
|
23
|
+
{ tenantId: T1, conversationId: C2 },
|
|
24
|
+
{ tenantId: T2, conversationId: C1 },
|
|
25
|
+
];
|
|
26
|
+
export function conversationRunCoordinatorConformance(makeFixture, declaration) {
|
|
27
|
+
/** Seed the conversations an adapter's foreign keys require, then hand back the coordinator. */
|
|
28
|
+
const open = async () => {
|
|
29
|
+
const fixture = openFixture(makeFixture());
|
|
30
|
+
if (fixture.seedConversation)
|
|
31
|
+
for (const c of CONVERSATIONS)
|
|
32
|
+
await fixture.seedConversation(c);
|
|
33
|
+
// `openFixture` is generic over both fixture shapes, so `store` widens to the union. The narrowing is safe
|
|
34
|
+
// by construction — every branch of `FixtureOrStore<T>` yields a `T` — and was simply never checked, this
|
|
35
|
+
// directory having been excluded from `tsconfig` until #253.
|
|
36
|
+
return fixture.store;
|
|
37
|
+
};
|
|
38
|
+
describe("ConversationRunCoordinator conformance", () => {
|
|
39
|
+
it("starts the first run and queues the rest", async () => {
|
|
40
|
+
const co = await open();
|
|
41
|
+
expect(await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("a") })).toMatchObject({
|
|
42
|
+
status: "started",
|
|
43
|
+
position: 0,
|
|
44
|
+
});
|
|
45
|
+
expect(await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("b") })).toMatchObject({
|
|
46
|
+
status: "queued",
|
|
47
|
+
position: 1,
|
|
48
|
+
});
|
|
49
|
+
expect(await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("c") })).toMatchObject({
|
|
50
|
+
status: "queued",
|
|
51
|
+
position: 2,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
it("reports the active run and the backlog depth", async () => {
|
|
55
|
+
const co = await open();
|
|
56
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("a") });
|
|
57
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("b") });
|
|
58
|
+
expect(await co.active({ tenantId: T1, conversationId: C1 })).toBe("a");
|
|
59
|
+
expect(await co.depth({ tenantId: T1, conversationId: C1 })).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
it("is idempotent for the run that already holds the slot", async () => {
|
|
62
|
+
const co = await open();
|
|
63
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("a") });
|
|
64
|
+
expect(await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("a") })).toMatchObject({
|
|
65
|
+
status: "started",
|
|
66
|
+
});
|
|
67
|
+
expect(await co.depth({ tenantId: T1, conversationId: C1 })).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
it("does not enqueue the same run twice", async () => {
|
|
70
|
+
const co = await open();
|
|
71
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("a") });
|
|
72
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("b") });
|
|
73
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("b") });
|
|
74
|
+
expect(await co.depth({ tenantId: T1, conversationId: C1 })).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
it("promotes the backlog in FIFO order, one run per release", async () => {
|
|
77
|
+
const co = await open();
|
|
78
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("a") });
|
|
79
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("b") });
|
|
80
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("c") });
|
|
81
|
+
expect(await co.releaseAndPromote({ tenantId: T1, conversationId: C1, runId: r("a") })).toBe("b");
|
|
82
|
+
expect(await co.active({ tenantId: T1, conversationId: C1 })).toBe("b");
|
|
83
|
+
expect(await co.releaseAndPromote({ tenantId: T1, conversationId: C1, runId: r("b") })).toBe("c");
|
|
84
|
+
expect(await co.releaseAndPromote({ tenantId: T1, conversationId: C1, runId: r("c") })).toBeNull();
|
|
85
|
+
expect(await co.active({ tenantId: T1, conversationId: C1 })).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
it("ignores a release from a run that does not hold the slot", async () => {
|
|
88
|
+
const co = await open();
|
|
89
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("a") });
|
|
90
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("b") });
|
|
91
|
+
// "b" is queued, not active — releasing it must not promote anything or unseat "a".
|
|
92
|
+
await co.releaseAndPromote({ tenantId: T1, conversationId: C1, runId: r("b") });
|
|
93
|
+
expect(await co.active({ tenantId: T1, conversationId: C1 })).toBe("a");
|
|
94
|
+
});
|
|
95
|
+
it("keeps conversations independent", async () => {
|
|
96
|
+
const co = await open();
|
|
97
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("a") });
|
|
98
|
+
expect(await co.claimOrEnqueue({ tenantId: T1, conversationId: C2, runId: r("b") })).toMatchObject({
|
|
99
|
+
status: "started",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
it("enforces tenant isolation — the same conversation id in another tenant is a separate slot", async () => {
|
|
103
|
+
const co = await open();
|
|
104
|
+
await co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("a") });
|
|
105
|
+
expect(await co.claimOrEnqueue({ tenantId: T2, conversationId: C1, runId: r("b") })).toMatchObject({
|
|
106
|
+
status: "started",
|
|
107
|
+
});
|
|
108
|
+
expect(await co.active({ tenantId: T2, conversationId: C1 })).toBe("b");
|
|
109
|
+
});
|
|
110
|
+
it("admits exactly one starter under concurrent claims — single-flight", async () => {
|
|
111
|
+
const co = await open();
|
|
112
|
+
const results = await Promise.all(["a", "b", "c", "d"].map((id) => co.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r(id) })));
|
|
113
|
+
expect(results.filter((x) => x.status === "started")).toHaveLength(1);
|
|
114
|
+
expect(await co.depth({ tenantId: T1, conversationId: C1 })).toBe(3);
|
|
115
|
+
});
|
|
116
|
+
/**
|
|
117
|
+
* The first case in this suite that can actually tell a distributed coordinator from an
|
|
118
|
+
* in-process one.
|
|
119
|
+
*
|
|
120
|
+
* Every test above passes for an adapter whose entire state is a `Map` in one object — which is
|
|
121
|
+
* correct for the reference adapter and useless as a claim about `distributed-locking`. Real
|
|
122
|
+
* distribution means the slot lives in the *backend*, so two coordinators built independently
|
|
123
|
+
* contend for the same slot. The reference adapter fails this by construction (two calls to its
|
|
124
|
+
* factory make two Maps), which is why it declares no capabilities and this case stands down for
|
|
125
|
+
* it with a printed reason.
|
|
126
|
+
*
|
|
127
|
+
* What it still cannot prove: that the lock holds across *processes*. No in-process test can —
|
|
128
|
+
* the property is about other processes. That half is covered by the two-connection test in
|
|
129
|
+
* `postgres-run-coordination.test.ts`, which only runs against a real server. AC-5 of #98 is
|
|
130
|
+
* therefore backed by these two together, and by neither alone.
|
|
131
|
+
*/
|
|
132
|
+
gatedIt(declaration, "distributed-locking", "shares one slot between two independently constructed coordinators", async () => {
|
|
133
|
+
const fixture = openFixture(makeFixture());
|
|
134
|
+
if (fixture.seedConversation)
|
|
135
|
+
for (const c of CONVERSATIONS)
|
|
136
|
+
await fixture.seedConversation(c);
|
|
137
|
+
const sibling = fixture.sibling?.();
|
|
138
|
+
expect(sibling, "an adapter declaring distributed-locking must supply a sibling coordinator, or this case proves nothing").toBeDefined();
|
|
139
|
+
if (!sibling)
|
|
140
|
+
return;
|
|
141
|
+
expect(await fixture.store.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("a") })).toMatchObject({ status: "started" });
|
|
142
|
+
// A different coordinator object over the same database must see the slot as taken.
|
|
143
|
+
expect(await sibling.claimOrEnqueue({ tenantId: T1, conversationId: C1, runId: r("b") })).toMatchObject({ status: "queued", position: 1 });
|
|
144
|
+
// And a promotion driven through one is visible through the other.
|
|
145
|
+
expect(await sibling.releaseAndPromote({ tenantId: T1, conversationId: C1, runId: r("a") })).toBe("b");
|
|
146
|
+
expect(await fixture.store.active({ tenantId: T1, conversationId: C1 })).toBe("b");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=run-coordinator.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RunEventLog` conformance — `docs/04` → reconnect. This log is the catch-up half of streaming and
|
|
3
|
+
* the input worker recovery reconciles against, so ordering must be exact and gap-free on every
|
|
4
|
+
* adapter. A missing or reordered event here becomes a client that renders duplicated or lost
|
|
5
|
+
* output after a refresh.
|
|
6
|
+
*/
|
|
7
|
+
import type { RunEventLog } from "../../core/events.js";
|
|
8
|
+
export declare function runEventLogConformance(makeLog: () => RunEventLog): void;
|
|
9
|
+
//# sourceMappingURL=run-event-log.d.ts.map
|