@victframework/runtime 0.1.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/dist/agent-governance.d.ts +285 -0
- package/dist/agent-governance.js +438 -0
- package/dist/agent-governance.js.map +1 -0
- package/dist/agent-registry.d.ts +103 -0
- package/dist/agent-registry.js +758 -0
- package/dist/agent-registry.js.map +1 -0
- package/dist/agent-types.d.ts +433 -0
- package/dist/agent-types.js +480 -0
- package/dist/agent-types.js.map +1 -0
- package/dist/authority.d.ts +83 -0
- package/dist/authority.js +170 -0
- package/dist/authority.js.map +1 -0
- package/dist/boundary-conformance.d.ts +51 -0
- package/dist/boundary-conformance.js +364 -0
- package/dist/boundary-conformance.js.map +1 -0
- package/dist/control-conformance.d.ts +68 -0
- package/dist/control-conformance.js +1131 -0
- package/dist/control-conformance.js.map +1 -0
- package/dist/control-in-memory.d.ts +257 -0
- package/dist/control-in-memory.js +886 -0
- package/dist/control-in-memory.js.map +1 -0
- package/dist/control-types.d.ts +1017 -0
- package/dist/control-types.js +779 -0
- package/dist/control-types.js.map +1 -0
- package/dist/durable-run.d.ts +73 -0
- package/dist/durable-run.js +262 -0
- package/dist/durable-run.js.map +1 -0
- package/dist/effect-policy.d.ts +22 -0
- package/dist/effect-policy.js +45 -0
- package/dist/effect-policy.js.map +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +71 -0
- package/dist/errors.js.map +1 -0
- package/dist/in-memory-stores.d.ts +16 -0
- package/dist/in-memory-stores.js +522 -0
- package/dist/in-memory-stores.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/operator-config.d.ts +141 -0
- package/dist/operator-config.js +289 -0
- package/dist/operator-config.js.map +1 -0
- package/dist/orchestration-activation.d.ts +116 -0
- package/dist/orchestration-activation.js +223 -0
- package/dist/orchestration-activation.js.map +1 -0
- package/dist/orchestration-canary-conformance.d.ts +10 -0
- package/dist/orchestration-canary-conformance.js +726 -0
- package/dist/orchestration-canary-conformance.js.map +1 -0
- package/dist/orchestration-commands.d.ts +47 -0
- package/dist/orchestration-commands.js +538 -0
- package/dist/orchestration-commands.js.map +1 -0
- package/dist/orchestration-conformance.d.ts +59 -0
- package/dist/orchestration-conformance.js +594 -0
- package/dist/orchestration-conformance.js.map +1 -0
- package/dist/orchestration-driver-types.d.ts +163 -0
- package/dist/orchestration-driver-types.js +21 -0
- package/dist/orchestration-driver-types.js.map +1 -0
- package/dist/orchestration-driver.d.ts +48 -0
- package/dist/orchestration-driver.js +1030 -0
- package/dist/orchestration-driver.js.map +1 -0
- package/dist/orchestration-in-memory.d.ts +18 -0
- package/dist/orchestration-in-memory.js +1313 -0
- package/dist/orchestration-in-memory.js.map +1 -0
- package/dist/orchestration-join-conformance.d.ts +20 -0
- package/dist/orchestration-join-conformance.js +542 -0
- package/dist/orchestration-join-conformance.js.map +1 -0
- package/dist/orchestration-plan.d.ts +52 -0
- package/dist/orchestration-plan.js +265 -0
- package/dist/orchestration-plan.js.map +1 -0
- package/dist/orchestration-race-conformance.d.ts +35 -0
- package/dist/orchestration-race-conformance.js +1265 -0
- package/dist/orchestration-race-conformance.js.map +1 -0
- package/dist/orchestration-remediation-conformance.d.ts +54 -0
- package/dist/orchestration-remediation-conformance.js +632 -0
- package/dist/orchestration-remediation-conformance.js.map +1 -0
- package/dist/orchestration-store-types.d.ts +568 -0
- package/dist/orchestration-store-types.js +2 -0
- package/dist/orchestration-store-types.js.map +1 -0
- package/dist/pack-conformance.d.ts +59 -0
- package/dist/pack-conformance.js +222 -0
- package/dist/pack-conformance.js.map +1 -0
- package/dist/pack-install.d.ts +44 -0
- package/dist/pack-install.js +126 -0
- package/dist/pack-install.js.map +1 -0
- package/dist/registry.d.ts +107 -0
- package/dist/registry.js +481 -0
- package/dist/registry.js.map +1 -0
- package/dist/runtime.d.ts +158 -0
- package/dist/runtime.js +1341 -0
- package/dist/runtime.js.map +1 -0
- package/dist/serialization.d.ts +26 -0
- package/dist/serialization.js +162 -0
- package/dist/serialization.js.map +1 -0
- package/dist/store-conformance.d.ts +45 -0
- package/dist/store-conformance.js +876 -0
- package/dist/store-conformance.js.map +1 -0
- package/dist/store-errors.d.ts +49 -0
- package/dist/store-errors.js +48 -0
- package/dist/store-errors.js.map +1 -0
- package/dist/store-types.d.ts +331 -0
- package/dist/store-types.js +14 -0
- package/dist/store-types.js.map +1 -0
- package/dist/store-validation.d.ts +63 -0
- package/dist/store-validation.js +164 -0
- package/dist/store-validation.js.map +1 -0
- package/dist/stream-hub.d.ts +151 -0
- package/dist/stream-hub.js +402 -0
- package/dist/stream-hub.js.map +1 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +19 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
import { createInMemoryAgentControlStores } from './control-in-memory.js';
|
|
2
|
+
import { controlContentHash, validateChangeSetContent, VICT_IDEMPOTENCY_FENCE_CONFLICT, } from './control-types.js';
|
|
3
|
+
/** Valid ChangeSet content fixture. */
|
|
4
|
+
function changesetContentFixture(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
changesetId: 'changeset-cf-1',
|
|
7
|
+
authorActorId: 'actor-author',
|
|
8
|
+
createdAt: 1000,
|
|
9
|
+
base: { kind: 'release', subjectId: 'app.cf', expectedVersion: 'release-cf-1' },
|
|
10
|
+
operations: [
|
|
11
|
+
{
|
|
12
|
+
kind: 'select-activation',
|
|
13
|
+
graphId: 'graph.cf',
|
|
14
|
+
activationVersion: 'v2_cfactivation',
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
rationale: 'Conformance proposal',
|
|
18
|
+
riskClass: 'low',
|
|
19
|
+
requiredApproverCount: 1,
|
|
20
|
+
expiresAt: 9000,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function makeChangeSetRecord(overrides = {}, contentOverrides = {}) {
|
|
25
|
+
const content = changesetContentFixture(contentOverrides);
|
|
26
|
+
const validated = validateChangeSetContent(content);
|
|
27
|
+
return {
|
|
28
|
+
changesetId: content.changesetId,
|
|
29
|
+
schema: 'vict.changeset@1',
|
|
30
|
+
authorActorId: content.authorActorId,
|
|
31
|
+
createdAt: content.createdAt,
|
|
32
|
+
base: validated.base,
|
|
33
|
+
operations: validated.operations,
|
|
34
|
+
rationale: validated.rationale,
|
|
35
|
+
riskClass: validated.riskClass,
|
|
36
|
+
requiredApproverCount: validated.requiredApproverCount,
|
|
37
|
+
expiresAt: validated.expiresAt,
|
|
38
|
+
validation: undefined,
|
|
39
|
+
simulation: undefined,
|
|
40
|
+
contentHash: validated.contentHash,
|
|
41
|
+
status: 'draft',
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function makeReleaseFixture(releaseVersion) {
|
|
46
|
+
return {
|
|
47
|
+
releaseVersion,
|
|
48
|
+
applicationId: 'app.cf',
|
|
49
|
+
applicationVersion: 'appver-cf-1',
|
|
50
|
+
rendererIdentity: 'renderer-svelte@1',
|
|
51
|
+
componentRegistryIdentity: 'registry@1',
|
|
52
|
+
dataAdapterIdentity: 'appdata-sqlite@1',
|
|
53
|
+
activationBinding: 'activation-cf-1',
|
|
54
|
+
publishedByActorId: 'actor-author',
|
|
55
|
+
publishedAt: 1000,
|
|
56
|
+
contentHash: controlContentHash({ releaseVersion, applicationId: 'app.cf' }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function makeTurnFixture(turnId) {
|
|
60
|
+
return {
|
|
61
|
+
turnId,
|
|
62
|
+
streamId: `stream-${turnId}`,
|
|
63
|
+
threadId: 'thread-cf-1',
|
|
64
|
+
actorId: 'actor-turn',
|
|
65
|
+
agentProfileVersion: 'v1_turncf',
|
|
66
|
+
activationVersion: 'v1_actcf',
|
|
67
|
+
applicationReleaseVersion: undefined,
|
|
68
|
+
inputSummary: 'input-summary-only',
|
|
69
|
+
status: 'intent',
|
|
70
|
+
createdAt: 1000,
|
|
71
|
+
updatedAt: 1000,
|
|
72
|
+
terminalAt: undefined,
|
|
73
|
+
errorCode: undefined,
|
|
74
|
+
traceId: undefined,
|
|
75
|
+
victRunId: undefined,
|
|
76
|
+
mastraRunId: undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function makeInvocationFixture(invocationId, turnId) {
|
|
80
|
+
return {
|
|
81
|
+
invocationId,
|
|
82
|
+
turnId,
|
|
83
|
+
toolCallId: `call-${invocationId}`,
|
|
84
|
+
toolName: 'cap.one',
|
|
85
|
+
capabilityId: 'cap.one',
|
|
86
|
+
capabilityRevision: '1',
|
|
87
|
+
effect: 'write',
|
|
88
|
+
idempotencyKey: `key-${invocationId}`,
|
|
89
|
+
actorId: 'actor-turn',
|
|
90
|
+
argDigest: 'digest-cf',
|
|
91
|
+
argumentSummary: 'safe-summary',
|
|
92
|
+
status: 'intent',
|
|
93
|
+
createdAt: 1000,
|
|
94
|
+
updatedAt: 1000,
|
|
95
|
+
completedAt: undefined,
|
|
96
|
+
resultSummary: undefined,
|
|
97
|
+
errorCode: undefined,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function makeApprovalFixture(approvalId, invocationId, turnId = 'turn-cf') {
|
|
101
|
+
return {
|
|
102
|
+
approvalId,
|
|
103
|
+
kind: 'tool-invocation',
|
|
104
|
+
turnId,
|
|
105
|
+
invocationId,
|
|
106
|
+
toolCallId: `call-${invocationId}`,
|
|
107
|
+
toolName: 'cap.one',
|
|
108
|
+
capabilityId: 'cap.one',
|
|
109
|
+
capabilityRevision: '1',
|
|
110
|
+
effect: 'write',
|
|
111
|
+
actorId: 'actor-requester',
|
|
112
|
+
agentProfileVersion: 'v1_turncf',
|
|
113
|
+
argDigest: 'digest-cf',
|
|
114
|
+
environment: 'local',
|
|
115
|
+
requiredApproverRole: 'approver',
|
|
116
|
+
status: 'pending',
|
|
117
|
+
createdAt: 1000,
|
|
118
|
+
expiresAt: 9000,
|
|
119
|
+
decidedAt: undefined,
|
|
120
|
+
approverActorId: undefined,
|
|
121
|
+
decisionReason: undefined,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Build and run the conformance suite against one store factory. */
|
|
125
|
+
export function runAgentControlConformanceSuite(factory, runner) {
|
|
126
|
+
const { it: t, expect } = runner;
|
|
127
|
+
// ---- Corrective-finalization stores: runs, receipts, CAS, idempotency ----
|
|
128
|
+
t(`[${factory.name}] control runs: idempotent record, content collision, exact read`, async () => {
|
|
129
|
+
const stores = await factory.create();
|
|
130
|
+
try {
|
|
131
|
+
const record = makeChangeSetRecord();
|
|
132
|
+
await stores.control.saveChangeSet(record);
|
|
133
|
+
const run = {
|
|
134
|
+
runId: 'run-cf-1',
|
|
135
|
+
kind: 'validation',
|
|
136
|
+
changesetId: record.changesetId,
|
|
137
|
+
contentHash: record.contentHash,
|
|
138
|
+
base: record.base,
|
|
139
|
+
operations: record.operations,
|
|
140
|
+
runnerProfile: 'vict.control-plane@1',
|
|
141
|
+
actorId: 'actor-author',
|
|
142
|
+
outcome: 'passed',
|
|
143
|
+
createdAt: 5000,
|
|
144
|
+
};
|
|
145
|
+
await stores.control.recordControlRun(run);
|
|
146
|
+
await stores.control.recordControlRun(run); // idempotent re-record
|
|
147
|
+
const stored = await stores.control.getControlRun('run-cf-1');
|
|
148
|
+
expect(stored?.outcome).toBe('passed');
|
|
149
|
+
expect(stored?.runnerProfile).toBe('vict.control-plane@1');
|
|
150
|
+
await expect(stores.control.recordControlRun({ ...run, outcome: 'failed' })).rejects.toThrow(/different content/);
|
|
151
|
+
expect(await stores.control.getControlRun('run-ghost')).toEqual(undefined);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await stores.dispose();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
t(`[${factory.name}] operation receipts: idempotent, ordered, collision-guarded`, async () => {
|
|
158
|
+
const stores = await factory.create();
|
|
159
|
+
try {
|
|
160
|
+
const receipt = (index, effectRef) => ({
|
|
161
|
+
changesetId: 'changeset-cf-1',
|
|
162
|
+
operationIndex: index,
|
|
163
|
+
operationKind: 'select-release',
|
|
164
|
+
operationDigest: `digest-${index}`,
|
|
165
|
+
effectRef,
|
|
166
|
+
actorId: 'actor-author',
|
|
167
|
+
appliedAt: 6000 + index,
|
|
168
|
+
state: 'applied',
|
|
169
|
+
guardJson: undefined,
|
|
170
|
+
});
|
|
171
|
+
// The two-state operation protocol: a PREPARED intent is recorded
|
|
172
|
+
// first; conflicting content under the SAME identity fails closed;
|
|
173
|
+
// the applied state settles it idempotently.
|
|
174
|
+
const intent = {
|
|
175
|
+
...receipt(0, 'op:digest-0'),
|
|
176
|
+
state: 'prepared',
|
|
177
|
+
guardJson: '{"expectedBaseVersion":"release-cf-1"}',
|
|
178
|
+
};
|
|
179
|
+
expect(await stores.control.recordOperationIntent(intent)).toBe('recorded');
|
|
180
|
+
expect(await stores.control.recordOperationIntent(intent)).toBe('exists'); // idempotent
|
|
181
|
+
await expect(stores.control.recordOperationIntent({
|
|
182
|
+
...intent,
|
|
183
|
+
operationDigest: 'digest-DIFFERENT',
|
|
184
|
+
})).rejects.toThrow(/conflicting content fails closed|different content/i);
|
|
185
|
+
const marked = await stores.control.markOperationApplied({
|
|
186
|
+
changesetId: 'changeset-cf-1',
|
|
187
|
+
operationIndex: 0,
|
|
188
|
+
at: 6100,
|
|
189
|
+
});
|
|
190
|
+
expect(marked.state).toBe('applied');
|
|
191
|
+
await stores.control.recordOperationReceipt(receipt(1, 'effect:b'));
|
|
192
|
+
const list = await stores.control.listOperationReceipts('changeset-cf-1');
|
|
193
|
+
expect(list.map((entry) => entry.operationIndex)).toEqual([0, 1]);
|
|
194
|
+
await expect(stores.control.recordOperationReceipt({ ...receipt(0, 'effect:a'), appliedAt: 9999 })).rejects.toThrow(/different content/);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
await stores.dispose();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
t(`[${factory.name}] compare-and-set status: exactly one winner, conflict otherwise`, async () => {
|
|
201
|
+
const stores = await factory.create();
|
|
202
|
+
try {
|
|
203
|
+
const record = makeChangeSetRecord();
|
|
204
|
+
await stores.control.saveChangeSet(record);
|
|
205
|
+
const first = await stores.control.compareAndSetChangeSetStatus({
|
|
206
|
+
changesetId: record.changesetId,
|
|
207
|
+
expectedStatus: 'draft',
|
|
208
|
+
nextStatus: 'applying',
|
|
209
|
+
});
|
|
210
|
+
expect(first.status).toBe('applying');
|
|
211
|
+
await expect(stores.control.compareAndSetChangeSetStatus({
|
|
212
|
+
changesetId: record.changesetId,
|
|
213
|
+
expectedStatus: 'draft',
|
|
214
|
+
nextStatus: 'committed',
|
|
215
|
+
})).rejects.toThrow(/not in the expected status/);
|
|
216
|
+
const second = await stores.control.compareAndSetChangeSetStatus({
|
|
217
|
+
changesetId: record.changesetId,
|
|
218
|
+
expectedStatus: 'applying',
|
|
219
|
+
nextStatus: 'committed',
|
|
220
|
+
});
|
|
221
|
+
expect(second.status).toBe('committed');
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await stores.dispose();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
t(`[${factory.name}] command idempotency: namespaced claim/complete/fail with one winner`, async () => {
|
|
228
|
+
const stores = await factory.create();
|
|
229
|
+
try {
|
|
230
|
+
const receipt = {
|
|
231
|
+
idempotencyKey: 'key-cf-1',
|
|
232
|
+
actorId: 'actor-author',
|
|
233
|
+
command: 'changeset.propose',
|
|
234
|
+
requestDigest: 'digest-cf-1',
|
|
235
|
+
status: 'pending',
|
|
236
|
+
responseCode: undefined,
|
|
237
|
+
resultJson: undefined,
|
|
238
|
+
createdAt: 7000,
|
|
239
|
+
settledAt: undefined,
|
|
240
|
+
owner: 'svc-cf-1',
|
|
241
|
+
leaseUntil: 8000,
|
|
242
|
+
attempts: 1,
|
|
243
|
+
fenceToken: 'fence-cf-1',
|
|
244
|
+
};
|
|
245
|
+
expect(await stores.commandIdempotency.claimReceipt(receipt)).toBe('claimed');
|
|
246
|
+
expect(await stores.commandIdempotency.claimReceipt(receipt)).toBe('exists');
|
|
247
|
+
await stores.commandIdempotency.completeReceipt({
|
|
248
|
+
actorId: 'actor-author',
|
|
249
|
+
command: 'changeset.propose',
|
|
250
|
+
idempotencyKey: 'key-cf-1',
|
|
251
|
+
resultJson: '{"changesetId":"changeset-cf-1","status":"draft"}',
|
|
252
|
+
at: 7001,
|
|
253
|
+
fenceToken: 'fence-cf-1',
|
|
254
|
+
});
|
|
255
|
+
const completed = await stores.commandIdempotency.getReceipt({
|
|
256
|
+
actorId: 'actor-author',
|
|
257
|
+
command: 'changeset.propose',
|
|
258
|
+
idempotencyKey: 'key-cf-1',
|
|
259
|
+
});
|
|
260
|
+
expect(completed?.status).toBe('completed');
|
|
261
|
+
expect(completed?.resultJson).toBe('{"changesetId":"changeset-cf-1","status":"draft"}');
|
|
262
|
+
// A settled receipt is never overwritten (and the fence no longer
|
|
263
|
+
// owns a pending claim).
|
|
264
|
+
await expect(stores.commandIdempotency.failReceipt({
|
|
265
|
+
actorId: 'actor-author',
|
|
266
|
+
command: 'changeset.propose',
|
|
267
|
+
idempotencyKey: 'key-cf-1',
|
|
268
|
+
responseCode: 'VICT_X',
|
|
269
|
+
at: 7002,
|
|
270
|
+
fenceToken: 'fence-cf-1',
|
|
271
|
+
})).rejects.toThrow(VICT_IDEMPOTENCY_FENCE_CONFLICT);
|
|
272
|
+
expect((await stores.commandIdempotency.getReceipt({
|
|
273
|
+
actorId: 'actor-author',
|
|
274
|
+
command: 'changeset.propose',
|
|
275
|
+
idempotencyKey: 'key-cf-1',
|
|
276
|
+
}))?.status).toBe('completed');
|
|
277
|
+
// A failure disposition round-trips.
|
|
278
|
+
await stores.commandIdempotency.claimReceipt({
|
|
279
|
+
...receipt,
|
|
280
|
+
idempotencyKey: 'key-cf-2',
|
|
281
|
+
fenceToken: 'fence-cf-2',
|
|
282
|
+
});
|
|
283
|
+
await stores.commandIdempotency.failReceipt({
|
|
284
|
+
actorId: 'actor-author',
|
|
285
|
+
command: 'changeset.propose',
|
|
286
|
+
idempotencyKey: 'key-cf-2',
|
|
287
|
+
responseCode: 'VICT_CONTROL_CHANGESET_EXISTS',
|
|
288
|
+
at: 7003,
|
|
289
|
+
fenceToken: 'fence-cf-2',
|
|
290
|
+
});
|
|
291
|
+
const failed = await stores.commandIdempotency.getReceipt({
|
|
292
|
+
actorId: 'actor-author',
|
|
293
|
+
command: 'changeset.propose',
|
|
294
|
+
idempotencyKey: 'key-cf-2',
|
|
295
|
+
});
|
|
296
|
+
expect(failed?.status).toBe('failed');
|
|
297
|
+
expect(failed?.responseCode).toBe('VICT_CONTROL_CHANGESET_EXISTS');
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
await stores.dispose();
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
t(`[${factory.name}] command idempotency: NAMESPACED by actor+command — the same key never interferes across actors or commands`, async () => {
|
|
304
|
+
const stores = await factory.create();
|
|
305
|
+
try {
|
|
306
|
+
const base = {
|
|
307
|
+
idempotencyKey: 'shared-key',
|
|
308
|
+
actorId: 'actor-a',
|
|
309
|
+
command: 'changeset.propose',
|
|
310
|
+
requestDigest: 'digest-a',
|
|
311
|
+
status: 'pending',
|
|
312
|
+
responseCode: undefined,
|
|
313
|
+
resultJson: undefined,
|
|
314
|
+
createdAt: 7000,
|
|
315
|
+
settledAt: undefined,
|
|
316
|
+
owner: 'svc-a',
|
|
317
|
+
leaseUntil: 9000,
|
|
318
|
+
attempts: 1,
|
|
319
|
+
fenceToken: 'fence-a',
|
|
320
|
+
};
|
|
321
|
+
expect(await stores.commandIdempotency.claimReceipt(base)).toBe('claimed');
|
|
322
|
+
// A second actor claiming the SAME client-generated key is a
|
|
323
|
+
// DIFFERENT namespace: claimed, never conflicts, never sees a's
|
|
324
|
+
// receipt.
|
|
325
|
+
expect(await stores.commandIdempotency.claimReceipt({
|
|
326
|
+
...base,
|
|
327
|
+
actorId: 'actor-b',
|
|
328
|
+
requestDigest: 'digest-b',
|
|
329
|
+
fenceToken: 'fence-b',
|
|
330
|
+
})).toBe('claimed');
|
|
331
|
+
// The same key under a different command is also a different
|
|
332
|
+
// namespace.
|
|
333
|
+
expect(await stores.commandIdempotency.claimReceipt({
|
|
334
|
+
...base,
|
|
335
|
+
command: 'release.select',
|
|
336
|
+
fenceToken: 'fence-c',
|
|
337
|
+
})).toBe('claimed');
|
|
338
|
+
await stores.commandIdempotency.completeReceipt({
|
|
339
|
+
actorId: 'actor-a',
|
|
340
|
+
command: 'changeset.propose',
|
|
341
|
+
idempotencyKey: 'shared-key',
|
|
342
|
+
resultJson: '{"changesetId":"cs-a"}',
|
|
343
|
+
at: 7001,
|
|
344
|
+
fenceToken: 'fence-a',
|
|
345
|
+
});
|
|
346
|
+
const a = await stores.commandIdempotency.getReceipt({
|
|
347
|
+
actorId: 'actor-a',
|
|
348
|
+
command: 'changeset.propose',
|
|
349
|
+
idempotencyKey: 'shared-key',
|
|
350
|
+
});
|
|
351
|
+
const b = await stores.commandIdempotency.getReceipt({
|
|
352
|
+
actorId: 'actor-b',
|
|
353
|
+
command: 'changeset.propose',
|
|
354
|
+
idempotencyKey: 'shared-key',
|
|
355
|
+
});
|
|
356
|
+
expect(a?.status).toBe('completed');
|
|
357
|
+
expect(b?.status).toBe('pending');
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
await stores.dispose();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
t(`[${factory.name}] command idempotency: pending leases recover — expired claims are taken over, live claims are protected, retryable failures release`, async () => {
|
|
364
|
+
const stores = await factory.create();
|
|
365
|
+
try {
|
|
366
|
+
await stores.commandIdempotency.claimReceipt({
|
|
367
|
+
idempotencyKey: 'lease-key',
|
|
368
|
+
actorId: 'actor-author',
|
|
369
|
+
command: 'changeset.commit',
|
|
370
|
+
requestDigest: 'digest-lease',
|
|
371
|
+
status: 'pending',
|
|
372
|
+
responseCode: undefined,
|
|
373
|
+
resultJson: undefined,
|
|
374
|
+
createdAt: 7000,
|
|
375
|
+
settledAt: undefined,
|
|
376
|
+
owner: 'svc-crashed',
|
|
377
|
+
leaseUntil: 7050,
|
|
378
|
+
attempts: 1,
|
|
379
|
+
fenceToken: 'fence-crashed',
|
|
380
|
+
});
|
|
381
|
+
// A live lease is protected.
|
|
382
|
+
expect(await stores.commandIdempotency.takeOverExpiredLease({
|
|
383
|
+
actorId: 'actor-author',
|
|
384
|
+
command: 'changeset.commit',
|
|
385
|
+
idempotencyKey: 'lease-key',
|
|
386
|
+
owner: 'svc-retry',
|
|
387
|
+
leaseUntil: 7100,
|
|
388
|
+
at: 7049,
|
|
389
|
+
})).toEqual({ outcome: 'not-expired' });
|
|
390
|
+
// After expiry the crashed claim is taken over (attempt counter
|
|
391
|
+
// incremented, NEW fence token) — the key is never stuck as
|
|
392
|
+
// in-progress forever.
|
|
393
|
+
const takeover = await stores.commandIdempotency.takeOverExpiredLease({
|
|
394
|
+
actorId: 'actor-author',
|
|
395
|
+
command: 'changeset.commit',
|
|
396
|
+
idempotencyKey: 'lease-key',
|
|
397
|
+
owner: 'svc-retry',
|
|
398
|
+
leaseUntil: 7150,
|
|
399
|
+
at: 7051,
|
|
400
|
+
});
|
|
401
|
+
expect(takeover.outcome).toBe('taken');
|
|
402
|
+
if (takeover.outcome !== 'taken')
|
|
403
|
+
throw new Error('unreachable');
|
|
404
|
+
expect(takeover.fenceToken === undefined).toBe(false);
|
|
405
|
+
expect(takeover.fenceToken).not.toBe('fence-crashed');
|
|
406
|
+
const taken = await stores.commandIdempotency.getReceipt({
|
|
407
|
+
actorId: 'actor-author',
|
|
408
|
+
command: 'changeset.commit',
|
|
409
|
+
idempotencyKey: 'lease-key',
|
|
410
|
+
});
|
|
411
|
+
expect(taken?.owner).toBe('svc-retry');
|
|
412
|
+
expect(taken?.attempts).toBe(2);
|
|
413
|
+
expect(taken?.fenceToken).toBe(takeover.fenceToken);
|
|
414
|
+
// A STALE owner (the crashed claimer's old fence token) can neither
|
|
415
|
+
// settle nor release: stable conflict, receipt byte-identical.
|
|
416
|
+
const beforeStale = JSON.stringify(await stores.commandIdempotency.getReceipt({
|
|
417
|
+
actorId: 'actor-author',
|
|
418
|
+
command: 'changeset.commit',
|
|
419
|
+
idempotencyKey: 'lease-key',
|
|
420
|
+
}));
|
|
421
|
+
await expect(stores.commandIdempotency.completeReceipt({
|
|
422
|
+
actorId: 'actor-author',
|
|
423
|
+
command: 'changeset.commit',
|
|
424
|
+
idempotencyKey: 'lease-key',
|
|
425
|
+
resultJson: '{"stale":true}',
|
|
426
|
+
at: 7052,
|
|
427
|
+
fenceToken: 'fence-crashed',
|
|
428
|
+
})).rejects.toThrow(VICT_IDEMPOTENCY_FENCE_CONFLICT);
|
|
429
|
+
await expect(stores.commandIdempotency.releaseReceipt({
|
|
430
|
+
actorId: 'actor-author',
|
|
431
|
+
command: 'changeset.commit',
|
|
432
|
+
idempotencyKey: 'lease-key',
|
|
433
|
+
at: 7052,
|
|
434
|
+
fenceToken: 'fence-crashed',
|
|
435
|
+
})).rejects.toThrow(VICT_IDEMPOTENCY_FENCE_CONFLICT);
|
|
436
|
+
expect(JSON.stringify(await stores.commandIdempotency.getReceipt({
|
|
437
|
+
actorId: 'actor-author',
|
|
438
|
+
command: 'changeset.commit',
|
|
439
|
+
idempotencyKey: 'lease-key',
|
|
440
|
+
}))).toBe(beforeStale);
|
|
441
|
+
// A retryable infrastructure failure RELEASES the claim (fenced).
|
|
442
|
+
await stores.commandIdempotency.releaseReceipt({
|
|
443
|
+
actorId: 'actor-author',
|
|
444
|
+
command: 'changeset.commit',
|
|
445
|
+
idempotencyKey: 'lease-key',
|
|
446
|
+
at: 7052,
|
|
447
|
+
fenceToken: takeover.fenceToken,
|
|
448
|
+
});
|
|
449
|
+
expect(await stores.commandIdempotency.getReceipt({
|
|
450
|
+
actorId: 'actor-author',
|
|
451
|
+
command: 'changeset.commit',
|
|
452
|
+
idempotencyKey: 'lease-key',
|
|
453
|
+
})).toBeUndefined();
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
await stores.dispose();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
t(`[${factory.name}] release selection: operation-identity idempotency and the SUBJECT-LEVEL base CAS`, async () => {
|
|
460
|
+
const stores = await factory.create();
|
|
461
|
+
try {
|
|
462
|
+
await stores.control.publishRelease(makeReleaseFixture('release-sel-1'));
|
|
463
|
+
await stores.control.publishRelease(makeReleaseFixture('release-sel-2'));
|
|
464
|
+
await stores.control.publishRelease(makeReleaseFixture('release-sel-3'));
|
|
465
|
+
await stores.control.selectRelease({
|
|
466
|
+
applicationId: 'app.cf',
|
|
467
|
+
releaseVersion: 'release-sel-1',
|
|
468
|
+
actorId: 'actor-author',
|
|
469
|
+
at: 100,
|
|
470
|
+
reason: 'select',
|
|
471
|
+
});
|
|
472
|
+
// Operation identity: re-application returns the ORIGINAL revision
|
|
473
|
+
// without adding another selection; conflicting content under the
|
|
474
|
+
// same identity fails closed.
|
|
475
|
+
const first = await stores.control.selectRelease({
|
|
476
|
+
applicationId: 'app.cf',
|
|
477
|
+
releaseVersion: 'release-sel-2',
|
|
478
|
+
actorId: 'actor-author',
|
|
479
|
+
at: 200,
|
|
480
|
+
reason: 'select',
|
|
481
|
+
operationId: 'op-sel-1',
|
|
482
|
+
expectedBaseVersion: 'release-sel-1',
|
|
483
|
+
});
|
|
484
|
+
const again = await stores.control.selectRelease({
|
|
485
|
+
applicationId: 'app.cf',
|
|
486
|
+
releaseVersion: 'release-sel-2',
|
|
487
|
+
actorId: 'actor-author',
|
|
488
|
+
at: 300,
|
|
489
|
+
reason: 'select',
|
|
490
|
+
operationId: 'op-sel-1',
|
|
491
|
+
expectedBaseVersion: 'release-sel-2',
|
|
492
|
+
});
|
|
493
|
+
expect(again.selectionRevision).toBe(first.selectionRevision);
|
|
494
|
+
const selections = await stores.control.listReleaseSelections('app.cf');
|
|
495
|
+
expect(selections).toHaveLength(2); // seed + exactly ONE operation selection
|
|
496
|
+
expect(selections.at(-1)?.operationId).toBe('op-sel-1');
|
|
497
|
+
await expect(stores.control.selectRelease({
|
|
498
|
+
applicationId: 'app.cf',
|
|
499
|
+
releaseVersion: 'release-sel-3',
|
|
500
|
+
actorId: 'actor-author',
|
|
501
|
+
at: 400,
|
|
502
|
+
reason: 'select',
|
|
503
|
+
operationId: 'op-sel-1',
|
|
504
|
+
})).rejects.toThrow(/conflicting content fails closed/i);
|
|
505
|
+
// Base CAS: a second writer fencing on the SAME base loses with the
|
|
506
|
+
// stable stale-base conflict and NO effect.
|
|
507
|
+
await expect(stores.control.selectRelease({
|
|
508
|
+
applicationId: 'app.cf',
|
|
509
|
+
releaseVersion: 'release-sel-3',
|
|
510
|
+
actorId: 'actor-2',
|
|
511
|
+
at: 500,
|
|
512
|
+
reason: 'select',
|
|
513
|
+
operationId: 'op-sel-2',
|
|
514
|
+
expectedBaseVersion: 'release-sel-1',
|
|
515
|
+
})).rejects.toThrow(/base changed|exactly one concurrent/i);
|
|
516
|
+
expect(await stores.control.listReleaseSelections('app.cf')).toHaveLength(2);
|
|
517
|
+
// The winner fencing on the CURRENT base succeeds.
|
|
518
|
+
await stores.control.selectRelease({
|
|
519
|
+
applicationId: 'app.cf',
|
|
520
|
+
releaseVersion: 'release-sel-3',
|
|
521
|
+
actorId: 'actor-2',
|
|
522
|
+
at: 600,
|
|
523
|
+
reason: 'select',
|
|
524
|
+
operationId: 'op-sel-3',
|
|
525
|
+
expectedBaseVersion: 'release-sel-2',
|
|
526
|
+
});
|
|
527
|
+
expect((await stores.control.getSelectedRelease('app.cf'))?.releaseVersion).toBe('release-sel-3');
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
await stores.dispose();
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
t(`[${factory.name}] stream ledger STORE-BOUNDARY validation: hostile writes are rejected without mutating sequence state`, async () => {
|
|
534
|
+
const stores = await factory.create();
|
|
535
|
+
try {
|
|
536
|
+
const context = '"actorId":"actor-author","agentProfileVersion":"p","threadId":"thread-gate","turnId":"turn-gate"';
|
|
537
|
+
// Seed one valid event so a rejected append must leave seq at 1.
|
|
538
|
+
await stores.streamLedger.appendEvent({
|
|
539
|
+
streamId: 'stream-gate',
|
|
540
|
+
kind: 'response.started',
|
|
541
|
+
payload: `{"actorId":"actor-author","agentProfileVersion":"p","kind":"response.started","threadId":"thread-gate","turnId":"turn-gate"}`,
|
|
542
|
+
at: 1000,
|
|
543
|
+
});
|
|
544
|
+
expect(await stores.streamLedger.latestSeq('stream-gate')).toBe(1);
|
|
545
|
+
// Unknown kind.
|
|
546
|
+
await expect(stores.streamLedger.appendEvent({
|
|
547
|
+
streamId: 'stream-gate',
|
|
548
|
+
kind: 'evil.kind',
|
|
549
|
+
payload: `{"actorId":"actor-author","agentProfileVersion":"p","kind":"evil.kind","secret":"LEDGER-CANARY-GATE","threadId":"thread-gate","turnId":"turn-gate"}`,
|
|
550
|
+
at: 1001,
|
|
551
|
+
})).rejects.toThrow(/vict.agent-stream@1|kind/);
|
|
552
|
+
// Declared kind mismatched with the payload kind.
|
|
553
|
+
await expect(stores.streamLedger.appendEvent({
|
|
554
|
+
streamId: 'stream-gate',
|
|
555
|
+
kind: 'response.completed',
|
|
556
|
+
payload: `{"actorId":"actor-author","agentProfileVersion":"p","code":"VICT_X","kind":"response.failed","threadId":"thread-gate","turnId":"turn-gate"}`,
|
|
557
|
+
at: 1002,
|
|
558
|
+
})).rejects.toThrow(/kind/);
|
|
559
|
+
// Non-canonical JSON.
|
|
560
|
+
await expect(stores.streamLedger.appendEvent({
|
|
561
|
+
streamId: 'stream-gate',
|
|
562
|
+
kind: 'response.completed',
|
|
563
|
+
payload: `{ "kind" : "response.completed", ${context}}`,
|
|
564
|
+
at: 1003,
|
|
565
|
+
})).rejects.toThrow(/canonical/);
|
|
566
|
+
// Raw text in content.completed (contentRef discipline).
|
|
567
|
+
await expect(stores.streamLedger.appendEvent({
|
|
568
|
+
streamId: 'stream-gate',
|
|
569
|
+
kind: 'content.completed',
|
|
570
|
+
payload: `{"actorId":"actor-author","agentProfileVersion":"p","kind":"content.completed","text":"raw secret content","threadId":"thread-gate","turnId":"turn-gate"}`,
|
|
571
|
+
at: 1004,
|
|
572
|
+
})).rejects.toThrow(/vict.agent-stream@1|UNKNOWN_FIELD|CONTENT_REF|kind/);
|
|
573
|
+
// Malformed correlation id.
|
|
574
|
+
await expect(stores.streamLedger.appendEvent({
|
|
575
|
+
streamId: 'stream-gate',
|
|
576
|
+
kind: 'response.completed',
|
|
577
|
+
payload: `{"actorId":"actor-author","agentProfileVersion":"p","kind":"response.completed","threadId":"thread-gate","turnId":"turn-gate","victRunId":"bad id with spaces"}`,
|
|
578
|
+
at: 1005,
|
|
579
|
+
})).rejects.toThrow(/vict.agent-stream@1|INVALID_ID/);
|
|
580
|
+
// NOTHING mutated: the sequence state and rows are unchanged and no
|
|
581
|
+
// canary reached storage.
|
|
582
|
+
expect(await stores.streamLedger.latestSeq('stream-gate')).toBe(1);
|
|
583
|
+
expect((await stores.streamLedger.listEventsFrom('stream-gate', 0)).length).toBe(1);
|
|
584
|
+
for (const row of await stores.streamLedger.listEventsFrom('stream-gate', 0)) {
|
|
585
|
+
expect(row.payload).not.toContain('LEDGER-CANARY-GATE');
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
await stores.dispose();
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
// ---- ChangeSets ----------------------------------------------------------
|
|
593
|
+
t(`[${factory.name}] changeset save/get round-trips and duplicate ids are rejected`, async () => {
|
|
594
|
+
const stores = await factory.create();
|
|
595
|
+
try {
|
|
596
|
+
const record = makeChangeSetRecord();
|
|
597
|
+
await stores.control.saveChangeSet(record);
|
|
598
|
+
const stored = await stores.control.getChangeSet(record.changesetId);
|
|
599
|
+
expect(stored).toEqual(record);
|
|
600
|
+
await expect(stores.control.saveChangeSet(record)).rejects.toThrow(/already exists/);
|
|
601
|
+
await expect(stores.control.saveChangeSet(makeChangeSetRecord({}, { rationale: 'different content' }))).rejects.toThrow(/already exists/);
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
await stores.dispose();
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
t(`[${factory.name}] changeset raw update enforces the immutable content identity`, async () => {
|
|
608
|
+
const stores = await factory.create();
|
|
609
|
+
try {
|
|
610
|
+
const record = makeChangeSetRecord();
|
|
611
|
+
await stores.control.saveChangeSet(record);
|
|
612
|
+
await stores.control.updateChangeSet(record.changesetId, (current) => ({
|
|
613
|
+
...current,
|
|
614
|
+
status: 'approved',
|
|
615
|
+
}));
|
|
616
|
+
const updated = await stores.control.getChangeSet(record.changesetId);
|
|
617
|
+
expect(updated?.status).toBe('approved');
|
|
618
|
+
// Content mutation through the raw store update is refused.
|
|
619
|
+
await expect(stores.control.updateChangeSet(record.changesetId, (current) => ({
|
|
620
|
+
...current,
|
|
621
|
+
rationale: 'mutated',
|
|
622
|
+
contentHash: controlContentHash({ tampered: true }),
|
|
623
|
+
}))).rejects.toThrow(/immutable content identity/);
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
await stores.dispose();
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
t(`[${factory.name}] changeset approval decisions: first wins, identical idempotent, conflict rejected`, async () => {
|
|
630
|
+
const stores = await factory.create();
|
|
631
|
+
try {
|
|
632
|
+
const record = makeChangeSetRecord();
|
|
633
|
+
await stores.control.saveChangeSet(record);
|
|
634
|
+
const decision = (overrides) => ({
|
|
635
|
+
approvalId: `csa-${overrides.approver ?? 'a1'}`,
|
|
636
|
+
changesetId: record.changesetId,
|
|
637
|
+
contentHash: record.contentHash,
|
|
638
|
+
approverActorId: overrides.approver ?? 'approver-1',
|
|
639
|
+
decision: overrides.decision ?? 'approved',
|
|
640
|
+
decidedAt: 2000,
|
|
641
|
+
});
|
|
642
|
+
await stores.control.recordChangeSetApproval(decision({}));
|
|
643
|
+
await stores.control.recordChangeSetApproval(decision({})); // idempotent
|
|
644
|
+
await expect(stores.control.recordChangeSetApproval(decision({ decision: 'declined' }))).rejects.toThrow(/competing decision|first durable decision/i);
|
|
645
|
+
const approvals = await stores.control.listChangeSetApprovals(record.changesetId);
|
|
646
|
+
expect(approvals).toHaveLength(1);
|
|
647
|
+
expect(approvals[0]?.decision).toBe('approved');
|
|
648
|
+
}
|
|
649
|
+
finally {
|
|
650
|
+
await stores.dispose();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
// ---- Release governance -----------------------------------------------------
|
|
654
|
+
t(`[${factory.name}] release publish is immutable and idempotent; collision fails`, async () => {
|
|
655
|
+
const stores = await factory.create();
|
|
656
|
+
try {
|
|
657
|
+
const release = makeReleaseFixture('release-cf-1');
|
|
658
|
+
await stores.control.publishRelease(release);
|
|
659
|
+
await stores.control.publishRelease(release); // idempotent
|
|
660
|
+
const stored = await stores.control.getRelease('release-cf-1');
|
|
661
|
+
expect(stored).toEqual(release);
|
|
662
|
+
await expect(stores.control.publishRelease({ ...release, applicationVersion: 'appver-cf-2' })).rejects.toThrow(/immutability guard|different content/);
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
await stores.dispose();
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
t(`[${factory.name}] release select/rollback produce monotonic attributable selections`, async () => {
|
|
669
|
+
const stores = await factory.create();
|
|
670
|
+
try {
|
|
671
|
+
await stores.control.publishRelease(makeReleaseFixture('release-cf-1'));
|
|
672
|
+
await stores.control.publishRelease(makeReleaseFixture('release-cf-2'));
|
|
673
|
+
const first = await stores.control.selectRelease({
|
|
674
|
+
applicationId: 'app.cf',
|
|
675
|
+
releaseVersion: 'release-cf-1',
|
|
676
|
+
actorId: 'operator-1',
|
|
677
|
+
at: 1000,
|
|
678
|
+
reason: 'select',
|
|
679
|
+
});
|
|
680
|
+
expect(first.selectionRevision).toBe(1);
|
|
681
|
+
const rollback = await stores.control.selectRelease({
|
|
682
|
+
applicationId: 'app.cf',
|
|
683
|
+
releaseVersion: 'release-cf-1',
|
|
684
|
+
actorId: 'operator-1',
|
|
685
|
+
at: 2000,
|
|
686
|
+
reason: 'rollback',
|
|
687
|
+
});
|
|
688
|
+
expect(rollback.selectionRevision).toBe(2);
|
|
689
|
+
const forward = await stores.control.selectRelease({
|
|
690
|
+
applicationId: 'app.cf',
|
|
691
|
+
releaseVersion: 'release-cf-2',
|
|
692
|
+
actorId: 'operator-1',
|
|
693
|
+
at: 3000,
|
|
694
|
+
reason: 'select',
|
|
695
|
+
});
|
|
696
|
+
expect(forward.selectionRevision).toBe(3);
|
|
697
|
+
const selections = await stores.control.listReleaseSelections('app.cf');
|
|
698
|
+
expect(selections.map((selection) => selection.reason)).toEqual([
|
|
699
|
+
'select',
|
|
700
|
+
'rollback',
|
|
701
|
+
'select',
|
|
702
|
+
]);
|
|
703
|
+
expect((await stores.control.getSelectedRelease('app.cf'))?.releaseVersion).toBe('release-cf-2');
|
|
704
|
+
await expect(stores.control.selectRelease({
|
|
705
|
+
applicationId: 'app.cf',
|
|
706
|
+
releaseVersion: 'release-missing',
|
|
707
|
+
actorId: 'operator-1',
|
|
708
|
+
at: 4000,
|
|
709
|
+
reason: 'select',
|
|
710
|
+
})).rejects.toThrow(/does not exist/);
|
|
711
|
+
}
|
|
712
|
+
finally {
|
|
713
|
+
await stores.dispose();
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
// ---- Audit ------------------------------------------------------------------
|
|
717
|
+
t(`[${factory.name}] audit events are idempotent and attributable`, async () => {
|
|
718
|
+
const stores = await factory.create();
|
|
719
|
+
try {
|
|
720
|
+
const event = (auditId) => ({
|
|
721
|
+
auditId,
|
|
722
|
+
at: 1000,
|
|
723
|
+
actorId: 'actor-1',
|
|
724
|
+
action: 'changeset.proposed',
|
|
725
|
+
subjectType: 'changeset',
|
|
726
|
+
subjectId: 'changeset-cf-1',
|
|
727
|
+
summary: 'proposed',
|
|
728
|
+
});
|
|
729
|
+
await stores.control.appendAuditEvent(event('audit-1'));
|
|
730
|
+
await stores.control.appendAuditEvent(event('audit-1')); // idempotent
|
|
731
|
+
await stores.control.appendAuditEvent({ ...event('audit-2'), subjectId: 'other' });
|
|
732
|
+
expect((await stores.control.listAuditEvents({})).length).toBe(2);
|
|
733
|
+
expect((await stores.control.listAuditEvents({ subjectId: 'changeset-cf-1' })).map((entry) => entry.auditId)).toEqual(['audit-1']);
|
|
734
|
+
}
|
|
735
|
+
finally {
|
|
736
|
+
await stores.dispose();
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
// ---- Turns ------------------------------------------------------------------
|
|
740
|
+
t(`[${factory.name}] turn state machine is forward-only with terminal fencing`, async () => {
|
|
741
|
+
const stores = await factory.create();
|
|
742
|
+
try {
|
|
743
|
+
await stores.turns.createTurnIntent(makeTurnFixture('turn-cf'));
|
|
744
|
+
await expect(stores.turns.createTurnIntent(makeTurnFixture('turn-cf'))).resolves.toBeUndefined(); // idempotent re-intent
|
|
745
|
+
const started = await stores.turns.startTurn('turn-cf', 1100);
|
|
746
|
+
expect(started.status).toBe('running');
|
|
747
|
+
await expect(stores.turns.startTurn('turn-cf', 1200)).rejects.toThrow(/transition/);
|
|
748
|
+
const awaiting = await stores.turns.awaitApproval('turn-cf', 1300, 'approval-cf');
|
|
749
|
+
expect(awaiting.status).toBe('awaiting-approval');
|
|
750
|
+
await stores.turns.resumeTurn('turn-cf', 1400);
|
|
751
|
+
const completed = await stores.turns.completeTurn({
|
|
752
|
+
turnId: 'turn-cf',
|
|
753
|
+
status: 'completed',
|
|
754
|
+
at: 1500,
|
|
755
|
+
});
|
|
756
|
+
expect(completed.status).toBe('completed');
|
|
757
|
+
expect(completed.terminalAt).toBe(1500);
|
|
758
|
+
// Terminal fencing: no late writes.
|
|
759
|
+
await expect(stores.turns.completeTurn({ turnId: 'turn-cf', status: 'failed', at: 1600 })).rejects.toThrow(/transition/);
|
|
760
|
+
// Cancel-intent deduplication.
|
|
761
|
+
const firstCancel = await stores.turns.recordCancelIntent({
|
|
762
|
+
turnId: 'turn-cf',
|
|
763
|
+
cancelId: 'cancel-1',
|
|
764
|
+
actorId: 'actor-1',
|
|
765
|
+
reasonCode: 'user',
|
|
766
|
+
at: 1000,
|
|
767
|
+
});
|
|
768
|
+
expect(firstCancel).toEqual({ accepted: true, duplicate: false });
|
|
769
|
+
const secondCancel = await stores.turns.recordCancelIntent({
|
|
770
|
+
turnId: 'turn-cf',
|
|
771
|
+
cancelId: 'cancel-1',
|
|
772
|
+
actorId: 'actor-1',
|
|
773
|
+
reasonCode: 'user',
|
|
774
|
+
at: 1001,
|
|
775
|
+
});
|
|
776
|
+
expect(secondCancel).toEqual({ accepted: false, duplicate: true });
|
|
777
|
+
expect(await stores.turns.hasCancelIntent('turn-cf')).toBe(true);
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
await stores.dispose();
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
t(`[${factory.name}] turn restart reconciliation is idempotent and honest`, async () => {
|
|
784
|
+
const stores = await factory.create();
|
|
785
|
+
try {
|
|
786
|
+
await stores.turns.createTurnIntent(makeTurnFixture('turn-rc'));
|
|
787
|
+
await stores.turns.startTurn('turn-rc', 1100);
|
|
788
|
+
const reconciled = await stores.turns.reconcileTurn({
|
|
789
|
+
turnId: 'turn-rc',
|
|
790
|
+
status: 'failed',
|
|
791
|
+
reasonCode: 'VICT_TURN_INTERRUPTED',
|
|
792
|
+
at: 2000,
|
|
793
|
+
});
|
|
794
|
+
expect(reconciled?.status).toBe('failed');
|
|
795
|
+
expect(reconciled?.errorCode).toBe('VICT_TURN_INTERRUPTED');
|
|
796
|
+
const again = await stores.turns.reconcileTurn({
|
|
797
|
+
turnId: 'turn-rc',
|
|
798
|
+
status: 'failed',
|
|
799
|
+
reasonCode: 'VICT_TURN_INTERRUPTED',
|
|
800
|
+
at: 2100,
|
|
801
|
+
});
|
|
802
|
+
expect(again?.status).toBe('failed');
|
|
803
|
+
expect(again?.terminalAt).toBe(2000);
|
|
804
|
+
}
|
|
805
|
+
finally {
|
|
806
|
+
await stores.dispose();
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
// ---- Tool invocations --------------------------------------------------------
|
|
810
|
+
t(`[${factory.name}] invocation intents are durable-before-invocation with key dedup`, async () => {
|
|
811
|
+
const stores = await factory.create();
|
|
812
|
+
try {
|
|
813
|
+
await stores.turns.createTurnIntent(makeTurnFixture('turn-cf'));
|
|
814
|
+
const invocation = makeInvocationFixture('inv-1', 'turn-cf');
|
|
815
|
+
const recorded = await stores.invocations.recordInvocationIntent(invocation);
|
|
816
|
+
expect(recorded.status).toBe('intent');
|
|
817
|
+
await expect(stores.invocations.recordInvocationIntent(invocation)).resolves.toEqual(invocation); // idempotent
|
|
818
|
+
// Same key, different digest: collision.
|
|
819
|
+
await expect(stores.invocations.recordInvocationIntent({
|
|
820
|
+
...invocation,
|
|
821
|
+
invocationId: 'inv-2',
|
|
822
|
+
argDigest: 'different',
|
|
823
|
+
})).rejects.toThrow(/digest|key/i);
|
|
824
|
+
// Same key, same digest: the EXISTING invocation is returned.
|
|
825
|
+
const same = await stores.invocations.recordInvocationIntent({
|
|
826
|
+
...invocation,
|
|
827
|
+
invocationId: 'inv-2',
|
|
828
|
+
});
|
|
829
|
+
expect(same.invocationId).toBe('inv-1');
|
|
830
|
+
const advanced = await stores.invocations.updateInvocationStatus({
|
|
831
|
+
invocationId: 'inv-1',
|
|
832
|
+
status: 'running',
|
|
833
|
+
at: 1200,
|
|
834
|
+
});
|
|
835
|
+
expect(advanced.status).toBe('running');
|
|
836
|
+
// Terminal fencing: late results are refused.
|
|
837
|
+
await stores.invocations.updateInvocationStatus({
|
|
838
|
+
invocationId: 'inv-1',
|
|
839
|
+
status: 'completed',
|
|
840
|
+
at: 1300,
|
|
841
|
+
resultSummary: 'ok',
|
|
842
|
+
});
|
|
843
|
+
await expect(stores.invocations.updateInvocationStatus({
|
|
844
|
+
invocationId: 'inv-1',
|
|
845
|
+
status: 'failed',
|
|
846
|
+
at: 1400,
|
|
847
|
+
errorCode: 'LATE',
|
|
848
|
+
})).rejects.toThrow(/terminal|fenced/i);
|
|
849
|
+
}
|
|
850
|
+
finally {
|
|
851
|
+
await stores.dispose();
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
t(`[${factory.name}] invocation attempt fences: one live owner, exact settlement binding, stale owners refused, conservative reconciliation`, async () => {
|
|
855
|
+
const stores = await factory.create();
|
|
856
|
+
try {
|
|
857
|
+
await stores.turns.createTurnIntent(makeTurnFixture('turn-cf-fence'));
|
|
858
|
+
const invocation = makeInvocationFixture('inv-cf-fence', 'turn-cf-fence');
|
|
859
|
+
await stores.invocations.recordInvocationIntent(invocation);
|
|
860
|
+
// Generation 1 owner claims (intent → running, stamped with the
|
|
861
|
+
// attempt fence).
|
|
862
|
+
const claimed = await stores.invocations.claimInvocationRun({
|
|
863
|
+
invocationId: 'inv-cf-fence',
|
|
864
|
+
fenceToken: 'fence-cf-1',
|
|
865
|
+
ownerIdentity: 'owner-A',
|
|
866
|
+
at: 1200,
|
|
867
|
+
});
|
|
868
|
+
expect(claimed.status).toBe('running');
|
|
869
|
+
expect(claimed.runGeneration).toBe(1);
|
|
870
|
+
expect(claimed.runFenceToken).toBe('fence-cf-1');
|
|
871
|
+
// A SECOND claim on the live attempt is refused (duplicate) and the
|
|
872
|
+
// record stays untouched.
|
|
873
|
+
await expect(stores.invocations.claimInvocationRun({
|
|
874
|
+
invocationId: 'inv-cf-fence',
|
|
875
|
+
fenceToken: 'fence-cf-2-claim',
|
|
876
|
+
ownerIdentity: 'owner-B',
|
|
877
|
+
at: 1210,
|
|
878
|
+
})).rejects.toThrow(/live owner/);
|
|
879
|
+
// A settlement under a STALE fence is refused.
|
|
880
|
+
await expect(stores.invocations.settleInvocationRun({
|
|
881
|
+
invocationId: 'inv-cf-fence',
|
|
882
|
+
fenceToken: 'fence-cf-stale',
|
|
883
|
+
status: 'completed',
|
|
884
|
+
at: 1220,
|
|
885
|
+
resultSummary: 'stale',
|
|
886
|
+
})).rejects.toThrow(/fence/i);
|
|
887
|
+
// Reconciliation is EXACT-BINDING: the wrong observed fence fails.
|
|
888
|
+
await expect(stores.invocations.reconcileAbandonedRun({
|
|
889
|
+
invocationId: 'inv-cf-fence',
|
|
890
|
+
observedFenceToken: 'fence-cf-wrong',
|
|
891
|
+
reconciledFenceToken: 'fence-cf-rec',
|
|
892
|
+
at: 1230,
|
|
893
|
+
})).rejects.toThrow(/abandoned attempt/i);
|
|
894
|
+
// The exact observed binding fences the abandoned attempt to the
|
|
895
|
+
// NON-REPLAYABLE outcome_unknown and advances the generation.
|
|
896
|
+
const reconciled = await stores.invocations.reconcileAbandonedRun({
|
|
897
|
+
invocationId: 'inv-cf-fence',
|
|
898
|
+
observedFenceToken: 'fence-cf-1',
|
|
899
|
+
reconciledFenceToken: 'fence-cf-rec',
|
|
900
|
+
at: 1240,
|
|
901
|
+
});
|
|
902
|
+
expect(reconciled.status).toBe('outcome_unknown');
|
|
903
|
+
expect(reconciled.runGeneration).toBe(2);
|
|
904
|
+
// The STALE owner (generation 1) can never settle the later
|
|
905
|
+
// generation — not even idempotently.
|
|
906
|
+
await expect(stores.invocations.settleInvocationRun({
|
|
907
|
+
invocationId: 'inv-cf-fence',
|
|
908
|
+
fenceToken: 'fence-cf-1',
|
|
909
|
+
status: 'outcome_unknown',
|
|
910
|
+
at: 1250,
|
|
911
|
+
})).rejects.toThrow(/fence/i);
|
|
912
|
+
// A SECOND reconciliation is refused: the observed binding moved.
|
|
913
|
+
await expect(stores.invocations.reconcileAbandonedRun({
|
|
914
|
+
invocationId: 'inv-cf-fence',
|
|
915
|
+
observedFenceToken: 'fence-cf-1',
|
|
916
|
+
reconciledFenceToken: 'fence-cf-rec-2',
|
|
917
|
+
at: 1260,
|
|
918
|
+
})).rejects.toThrow(/abandoned attempt/i);
|
|
919
|
+
// The current fence settles with the exact binding.
|
|
920
|
+
const settled = await stores.invocations.settleInvocationRun({
|
|
921
|
+
invocationId: 'inv-cf-fence',
|
|
922
|
+
fenceToken: 'fence-cf-rec',
|
|
923
|
+
status: 'outcome_unknown',
|
|
924
|
+
at: 1270,
|
|
925
|
+
errorCode: 'VICT_CONTROL_INVOCATION_RUN_RECONCILED',
|
|
926
|
+
});
|
|
927
|
+
expect(settled.status).toBe('outcome_unknown');
|
|
928
|
+
// A DIFFERENT terminal settlement under the same fence conflicts.
|
|
929
|
+
await expect(stores.invocations.settleInvocationRun({
|
|
930
|
+
invocationId: 'inv-cf-fence',
|
|
931
|
+
fenceToken: 'fence-cf-rec',
|
|
932
|
+
status: 'failed',
|
|
933
|
+
at: 1280,
|
|
934
|
+
errorCode: 'CONFLICTING',
|
|
935
|
+
})).rejects.toThrow(/terminal/i);
|
|
936
|
+
}
|
|
937
|
+
finally {
|
|
938
|
+
await stores.dispose();
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
// ---- Approvals ---------------------------------------------------------------
|
|
942
|
+
t(`[${factory.name}] approval records: first decision wins, expiry, one pending per invocation`, async () => {
|
|
943
|
+
const stores = await factory.create();
|
|
944
|
+
try {
|
|
945
|
+
await stores.turns.createTurnIntent(makeTurnFixture('turn-cf'));
|
|
946
|
+
const approval = makeApprovalFixture('approval-cf-1', 'inv-cf-1');
|
|
947
|
+
const created = await stores.approvals.createPendingApproval(approval);
|
|
948
|
+
expect(created.status).toBe('pending');
|
|
949
|
+
// A duplicate create for the SAME invocation reuses the pending record.
|
|
950
|
+
const reused = await stores.approvals.createPendingApproval({
|
|
951
|
+
...approval,
|
|
952
|
+
approvalId: 'approval-cf-other',
|
|
953
|
+
});
|
|
954
|
+
expect(reused.approvalId).toBe('approval-cf-1');
|
|
955
|
+
await stores.approvals.decideApproval({
|
|
956
|
+
approvalId: 'approval-cf-1',
|
|
957
|
+
approverActorId: 'approver-1',
|
|
958
|
+
decision: 'approved',
|
|
959
|
+
decidedAt: 1500,
|
|
960
|
+
});
|
|
961
|
+
await stores.approvals.decideApproval({
|
|
962
|
+
approvalId: 'approval-cf-1',
|
|
963
|
+
approverActorId: 'approver-1',
|
|
964
|
+
decision: 'approved',
|
|
965
|
+
decidedAt: 1600,
|
|
966
|
+
}); // idempotent identical
|
|
967
|
+
await expect(stores.approvals.decideApproval({
|
|
968
|
+
approvalId: 'approval-cf-1',
|
|
969
|
+
approverActorId: 'approver-2',
|
|
970
|
+
decision: 'declined',
|
|
971
|
+
decidedAt: 1700,
|
|
972
|
+
})).rejects.toThrow(/competing|first durable/i);
|
|
973
|
+
const expiredFixture = makeApprovalFixture('approval-cf-2', 'inv-cf-2');
|
|
974
|
+
await stores.approvals.createPendingApproval(expiredFixture);
|
|
975
|
+
const expired = await stores.approvals.expireApproval({
|
|
976
|
+
approvalId: 'approval-cf-2',
|
|
977
|
+
at: 2000,
|
|
978
|
+
});
|
|
979
|
+
expect(expired.status).toBe('expired');
|
|
980
|
+
await expect(stores.approvals.decideApproval({
|
|
981
|
+
approvalId: 'approval-cf-2',
|
|
982
|
+
approverActorId: 'approver-1',
|
|
983
|
+
decision: 'approved',
|
|
984
|
+
decidedAt: 2100,
|
|
985
|
+
})).rejects.toThrow(/expired/i);
|
|
986
|
+
}
|
|
987
|
+
finally {
|
|
988
|
+
await stores.dispose();
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
// ---- Stream ledger -------------------------------------------------------------
|
|
992
|
+
t(`[${factory.name}] stream ledger assigns strictly monotonic sequences and persists only durable kinds`, async () => {
|
|
993
|
+
const stores = await factory.create();
|
|
994
|
+
try {
|
|
995
|
+
const first = await stores.streamLedger.appendEvent({
|
|
996
|
+
streamId: 'stream-cf',
|
|
997
|
+
kind: 'response.started',
|
|
998
|
+
payload: '{"actorId":"actor-author","agentProfileVersion":"p","kind":"response.started","threadId":"thread-cf","turnId":"turn-cf"}',
|
|
999
|
+
at: 1000,
|
|
1000
|
+
});
|
|
1001
|
+
const delta = await stores.streamLedger.appendEvent({
|
|
1002
|
+
streamId: 'stream-cf',
|
|
1003
|
+
kind: 'text.delta',
|
|
1004
|
+
payload: '{"actorId":"actor-author","agentProfileVersion":"p","delta":"he","kind":"text.delta","threadId":"thread-cf","turnId":"turn-cf"}',
|
|
1005
|
+
at: 1001,
|
|
1006
|
+
});
|
|
1007
|
+
const third = await stores.streamLedger.appendEvent({
|
|
1008
|
+
streamId: 'stream-cf',
|
|
1009
|
+
kind: 'content.completed',
|
|
1010
|
+
payload: '{"actorId":"actor-author","agentProfileVersion":"p","contentRef":"conversation:vict-actor-actor-author/thread-cf/turn-cf","kind":"content.completed","threadId":"thread-cf","turnId":"turn-cf"}',
|
|
1011
|
+
at: 1002,
|
|
1012
|
+
});
|
|
1013
|
+
expect(first.seq).toBe(1);
|
|
1014
|
+
expect(first.persisted).toBe(true);
|
|
1015
|
+
expect(delta.seq).toBe(2);
|
|
1016
|
+
expect(delta.persisted).toBe(false); // text.delta is transient
|
|
1017
|
+
expect(third.seq).toBe(3);
|
|
1018
|
+
expect(third.persisted).toBe(true);
|
|
1019
|
+
expect(await stores.streamLedger.latestSeq('stream-cf')).toBe(3);
|
|
1020
|
+
const rows = await stores.streamLedger.listEventsFrom('stream-cf', 0);
|
|
1021
|
+
expect(rows.map((row) => row.seq)).toEqual([1, 3]);
|
|
1022
|
+
expect(rows[1]?.payload).toBe('{"actorId":"actor-author","agentProfileVersion":"p","contentRef":"conversation:vict-actor-actor-author/thread-cf/turn-cf","kind":"content.completed","threadId":"thread-cf","turnId":"turn-cf"}');
|
|
1023
|
+
// Paged reads return the SAME rows (bounded replay paging).
|
|
1024
|
+
const page1 = await stores.streamLedger.listEventsFrom('stream-cf', 0, 1);
|
|
1025
|
+
const page2 = await stores.streamLedger.listEventsFrom('stream-cf', page1.at(-1)?.seq ?? 0, 10);
|
|
1026
|
+
expect(page1.map((row) => row.seq)).toEqual([1]);
|
|
1027
|
+
expect(page2.map((row) => row.seq)).toEqual([3]);
|
|
1028
|
+
expect((await stores.streamLedger.listEventsFrom('stream-cf', 3)).length).toBe(0);
|
|
1029
|
+
}
|
|
1030
|
+
finally {
|
|
1031
|
+
await stores.dispose();
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
// ---- Reopen equivalence -------------------------------------------------------
|
|
1035
|
+
const reopen = factory.reopen;
|
|
1036
|
+
const freshPath = factory.freshPath;
|
|
1037
|
+
if (reopen !== undefined && freshPath !== undefined) {
|
|
1038
|
+
t(`[${factory.name}] durable state survives close/reopen with identical semantics`, async () => {
|
|
1039
|
+
const path = freshPath();
|
|
1040
|
+
const stores = await factory.create({ path });
|
|
1041
|
+
const record = makeChangeSetRecord();
|
|
1042
|
+
await stores.control.saveChangeSet(record);
|
|
1043
|
+
await stores.control.recordChangeSetApproval({
|
|
1044
|
+
approvalId: 'csa-1',
|
|
1045
|
+
changesetId: record.changesetId,
|
|
1046
|
+
contentHash: record.contentHash,
|
|
1047
|
+
approverActorId: 'approver-1',
|
|
1048
|
+
decision: 'approved',
|
|
1049
|
+
decidedAt: 2000,
|
|
1050
|
+
});
|
|
1051
|
+
await stores.turns.createTurnIntent(makeTurnFixture('turn-reopen'));
|
|
1052
|
+
await stores.turns.startTurn('turn-reopen', 1100);
|
|
1053
|
+
await stores.turns.completeTurn({ turnId: 'turn-reopen', status: 'completed', at: 1200 });
|
|
1054
|
+
const invocation = makeInvocationFixture('inv-reopen', 'turn-reopen');
|
|
1055
|
+
await stores.invocations.recordInvocationIntent(invocation);
|
|
1056
|
+
await stores.invocations.updateInvocationStatus({
|
|
1057
|
+
invocationId: 'inv-reopen',
|
|
1058
|
+
status: 'completed',
|
|
1059
|
+
at: 1300,
|
|
1060
|
+
resultSummary: 'done',
|
|
1061
|
+
});
|
|
1062
|
+
await stores.approvals.createPendingApproval(makeApprovalFixture('approval-reopen', 'inv-reopen', 'turn-reopen'));
|
|
1063
|
+
await stores.streamLedger.appendEvent({
|
|
1064
|
+
streamId: 'stream-reopen',
|
|
1065
|
+
kind: 'content.completed',
|
|
1066
|
+
payload: '{"actorId":"actor-author","agentProfileVersion":"p","contentRef":"conversation:vict-actor-actor-author/thread-reopen/turn-reopen","kind":"content.completed","threadId":"thread-reopen","turnId":"turn-reopen"}',
|
|
1067
|
+
at: 1400,
|
|
1068
|
+
});
|
|
1069
|
+
await stores.streamLedger.appendEvent({
|
|
1070
|
+
streamId: 'stream-reopen',
|
|
1071
|
+
kind: 'text.delta',
|
|
1072
|
+
payload: '{"actorId":"actor-author","agentProfileVersion":"p","delta":"gone","kind":"text.delta","threadId":"thread-reopen","turnId":"turn-reopen"}',
|
|
1073
|
+
at: 1401,
|
|
1074
|
+
});
|
|
1075
|
+
// A durable idempotency receipt must survive the close/reopen too.
|
|
1076
|
+
await stores.commandIdempotency.claimReceipt({
|
|
1077
|
+
idempotencyKey: 'key-reopen',
|
|
1078
|
+
actorId: 'actor-author',
|
|
1079
|
+
command: 'changeset.propose',
|
|
1080
|
+
requestDigest: 'digest-reopen',
|
|
1081
|
+
status: 'completed',
|
|
1082
|
+
responseCode: undefined,
|
|
1083
|
+
resultJson: '{"changesetId":"changeset-reopen"}',
|
|
1084
|
+
createdAt: 1402,
|
|
1085
|
+
settledAt: 1403,
|
|
1086
|
+
owner: undefined,
|
|
1087
|
+
leaseUntil: undefined,
|
|
1088
|
+
attempts: 1,
|
|
1089
|
+
fenceToken: undefined,
|
|
1090
|
+
});
|
|
1091
|
+
await stores.dispose();
|
|
1092
|
+
const reopened = await reopen({ path });
|
|
1093
|
+
try {
|
|
1094
|
+
const stored = await reopened.control.getChangeSet(record.changesetId);
|
|
1095
|
+
expect(stored?.contentHash).toBe(record.contentHash);
|
|
1096
|
+
expect((await reopened.control.listChangeSetApprovals(record.changesetId)).length).toBe(1);
|
|
1097
|
+
const turn = await reopened.turns.getTurn('turn-reopen');
|
|
1098
|
+
expect(turn?.status).toBe('completed');
|
|
1099
|
+
const invocationReopened = await reopened.invocations.getInvocation('inv-reopen');
|
|
1100
|
+
expect(invocationReopened?.status).toBe('completed');
|
|
1101
|
+
const approvalReopened = await reopened.approvals.getApproval('approval-reopen');
|
|
1102
|
+
expect(approvalReopened?.status).toBe('pending');
|
|
1103
|
+
const rows = await reopened.streamLedger.listEventsFrom('stream-reopen', 0);
|
|
1104
|
+
expect(rows.map((row) => row.kind)).toEqual(['content.completed']);
|
|
1105
|
+
expect(await reopened.streamLedger.latestSeq('stream-reopen')).toBe(2);
|
|
1106
|
+
const receipt = await reopened.commandIdempotency.getReceipt({
|
|
1107
|
+
actorId: 'actor-author',
|
|
1108
|
+
command: 'changeset.propose',
|
|
1109
|
+
idempotencyKey: 'key-reopen',
|
|
1110
|
+
});
|
|
1111
|
+
expect(receipt?.status).toBe('completed');
|
|
1112
|
+
expect(receipt?.resultJson).toBe('{"changesetId":"changeset-reopen"}');
|
|
1113
|
+
}
|
|
1114
|
+
finally {
|
|
1115
|
+
await reopened.dispose();
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
/** Convenience: run the suite against the in-memory factory. */
|
|
1121
|
+
export function inMemoryAgentControlConformanceFactory() {
|
|
1122
|
+
return {
|
|
1123
|
+
name: 'in-memory',
|
|
1124
|
+
async create() {
|
|
1125
|
+
return { ...createInMemoryAgentControlStores(), dispose: () => undefined };
|
|
1126
|
+
},
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
/** Unused-import guard kept minimal. */
|
|
1130
|
+
void undefined;
|
|
1131
|
+
//# sourceMappingURL=control-conformance.js.map
|