@fabric-harness/sdk 1.14.0 → 2.0.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/action.d.ts +69 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +119 -0
- package/dist/action.js.map +1 -0
- package/dist/attachment-store-contract.d.ts +34 -0
- package/dist/attachment-store-contract.d.ts.map +1 -0
- package/dist/attachment-store-contract.js +0 -0
- package/dist/attachment-store-contract.js.map +1 -0
- package/dist/attachment-store.d.ts +107 -0
- package/dist/attachment-store.d.ts.map +1 -0
- package/dist/attachment-store.js +164 -0
- package/dist/attachment-store.js.map +1 -0
- package/dist/channel.d.ts +8 -1
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js.map +1 -1
- package/dist/conversation-projection.d.ts +52 -0
- package/dist/conversation-projection.d.ts.map +1 -0
- package/dist/conversation-projection.js +207 -0
- package/dist/conversation-projection.js.map +1 -0
- package/dist/conversation-stream-contract.d.ts +37 -0
- package/dist/conversation-stream-contract.d.ts.map +1 -0
- package/dist/conversation-stream-contract.js +453 -0
- package/dist/conversation-stream-contract.js.map +1 -0
- package/dist/conversation-stream.d.ts +141 -0
- package/dist/conversation-stream.d.ts.map +1 -0
- package/dist/conversation-stream.js +159 -0
- package/dist/conversation-stream.js.map +1 -0
- package/dist/delivered-message.d.ts +90 -0
- package/dist/delivered-message.d.ts.map +1 -0
- package/dist/delivered-message.js +154 -0
- package/dist/delivered-message.js.map +1 -0
- package/dist/dispatch.d.ts +16 -3
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +5 -1
- package/dist/dispatch.js.map +1 -1
- package/dist/history.d.ts +27 -0
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +94 -0
- package/dist/history.js.map +1 -1
- package/dist/in-memory-submission-store.d.ts +53 -0
- package/dist/in-memory-submission-store.d.ts.map +1 -0
- package/dist/in-memory-submission-store.js +340 -0
- package/dist/in-memory-submission-store.js.map +1 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/persistent-agent.d.ts +6 -0
- package/dist/persistent-agent.d.ts.map +1 -1
- package/dist/persistent-agent.js +6 -0
- package/dist/persistent-agent.js.map +1 -1
- package/dist/session.d.ts +13 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +78 -2
- package/dist/session.js.map +1 -1
- package/dist/strict.d.ts +24 -3
- package/dist/strict.d.ts.map +1 -1
- package/dist/strict.js +13 -2
- package/dist/strict.js.map +1 -1
- package/dist/submission-context.d.ts +26 -0
- package/dist/submission-context.d.ts.map +1 -0
- package/dist/submission-context.js +20 -0
- package/dist/submission-context.js.map +1 -0
- package/dist/submission-runner.d.ts +150 -0
- package/dist/submission-runner.d.ts.map +1 -0
- package/dist/submission-runner.js +767 -0
- package/dist/submission-runner.js.map +1 -0
- package/dist/submission-state.d.ts +50 -0
- package/dist/submission-state.d.ts.map +1 -0
- package/dist/submission-state.js +82 -0
- package/dist/submission-state.js.map +1 -0
- package/dist/submission-store-contract.d.ts +31 -0
- package/dist/submission-store-contract.d.ts.map +1 -0
- package/dist/submission-store-contract.js +730 -0
- package/dist/submission-store-contract.js.map +1 -0
- package/dist/submission-store.d.ts +365 -0
- package/dist/submission-store.d.ts.map +1 -0
- package/dist/submission-store.js +191 -0
- package/dist/submission-store.js.map +1 -0
- package/dist/submission-telemetry.d.ts +56 -0
- package/dist/submission-telemetry.d.ts.map +1 -0
- package/dist/submission-telemetry.js +36 -0
- package/dist/submission-telemetry.js.map +1 -0
- package/dist/testing-contracts.d.ts +17 -0
- package/dist/testing-contracts.d.ts.map +1 -0
- package/dist/testing-contracts.js +14 -0
- package/dist/testing-contracts.js.map +1 -0
- package/dist/types.d.ts +28 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +5 -1
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared contract tests for {@link AgentSubmissionStore} implementations.
|
|
3
|
+
*
|
|
4
|
+
* Store packages call {@link defineSubmissionStoreContractTests} with a
|
|
5
|
+
* factory that creates their backend. The tests exercise every method on
|
|
6
|
+
* `AgentSubmissionStore` with identical behavioral assertions regardless of
|
|
7
|
+
* the underlying storage engine — no store-specific assumptions.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { defineSubmissionStoreContractTests } from '@fabric-harness/sdk/testing';
|
|
12
|
+
*
|
|
13
|
+
* defineSubmissionStoreContractTests('My backend', async () => ({
|
|
14
|
+
* store: await createMyStore(),
|
|
15
|
+
* close: async () => myStore.close(),
|
|
16
|
+
* }));
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
20
|
+
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
21
|
+
const SESSION_ID = 'agent:assistant:agent-1:default';
|
|
22
|
+
function dispatchInput(overrides = {}) {
|
|
23
|
+
return {
|
|
24
|
+
dispatchId: 'dispatch-1',
|
|
25
|
+
agent: 'assistant',
|
|
26
|
+
id: 'agent-1',
|
|
27
|
+
session: 'default',
|
|
28
|
+
message: { kind: 'signal', type: 'test.event', body: 'Hello' },
|
|
29
|
+
input: null,
|
|
30
|
+
acceptedAt: '2026-06-03T00:00:00.000Z',
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function directInput(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
kind: 'direct',
|
|
37
|
+
submissionId: 'direct-1',
|
|
38
|
+
agent: 'assistant',
|
|
39
|
+
id: 'agent-1',
|
|
40
|
+
session: 'default',
|
|
41
|
+
message: { kind: 'user', body: 'Hello' },
|
|
42
|
+
acceptedAt: Date.parse('2026-06-03T00:00:00.000Z'),
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function attempt(submissionId, attemptId) {
|
|
47
|
+
return { submissionId, attemptId };
|
|
48
|
+
}
|
|
49
|
+
function claim(submissionId, attemptId, ownerId = 'test-owner') {
|
|
50
|
+
return { submissionId, attemptId, ownerId, leaseExpiresAt: Date.now() + 30_000 };
|
|
51
|
+
}
|
|
52
|
+
function settlementRecord(outcome, submissionId = 'direct-1') {
|
|
53
|
+
return {
|
|
54
|
+
type: 'submission_settled',
|
|
55
|
+
submissionId,
|
|
56
|
+
outcome,
|
|
57
|
+
...(outcome === 'completed' ? { resultText: 'All done.' } : { error: 'It broke.' }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function admitDispatchReady(store, input) {
|
|
61
|
+
const admission = await store.admitDispatch(input);
|
|
62
|
+
if (admission.kind !== 'submission')
|
|
63
|
+
return admission;
|
|
64
|
+
const submission = await store.markSubmissionCanonicalReady(admission.submission.submissionId);
|
|
65
|
+
return { kind: 'submission', submission: submission ?? admission.submission };
|
|
66
|
+
}
|
|
67
|
+
async function admitDirectReady(store, input) {
|
|
68
|
+
const submission = await store.admitDirect(input);
|
|
69
|
+
return (await store.markSubmissionCanonicalReady(submission.submissionId)) ?? submission;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Register the standard {@link AgentSubmissionStore} contract tests under the
|
|
73
|
+
* given describe label. Each test gets a fresh store from `factory()`.
|
|
74
|
+
*/
|
|
75
|
+
export function defineSubmissionStoreContractTests(name, factory) {
|
|
76
|
+
describe(name, () => {
|
|
77
|
+
let active;
|
|
78
|
+
async function create() {
|
|
79
|
+
active = await factory();
|
|
80
|
+
return active.store;
|
|
81
|
+
}
|
|
82
|
+
afterEach(async () => {
|
|
83
|
+
await active?.close?.();
|
|
84
|
+
active = undefined;
|
|
85
|
+
});
|
|
86
|
+
// ── Dispatch admission ────────────────────────────────────────────
|
|
87
|
+
describe('dispatch admission', () => {
|
|
88
|
+
it('admits one queued dispatch row when the same submission is replayed', async () => {
|
|
89
|
+
const store = await create();
|
|
90
|
+
const first = await store.admitDispatch(dispatchInput());
|
|
91
|
+
const replay = await store.admitDispatch(dispatchInput());
|
|
92
|
+
expect(replay).toEqual(first);
|
|
93
|
+
expect(first).toMatchObject({
|
|
94
|
+
kind: 'submission',
|
|
95
|
+
submission: {
|
|
96
|
+
submissionId: 'dispatch-1',
|
|
97
|
+
storeSessionId: SESSION_ID,
|
|
98
|
+
status: 'queued',
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
it('treats a replay differing only in acceptedAt as an exact replay (redelivery timestamps are volatile)', async () => {
|
|
103
|
+
const store = await create();
|
|
104
|
+
const first = await store.admitDispatch(dispatchInput());
|
|
105
|
+
const redelivered = await store.admitDispatch(dispatchInput({ acceptedAt: '2026-07-09T12:34:56.000Z' }));
|
|
106
|
+
expect(redelivered.kind).toBe('submission');
|
|
107
|
+
if (first.kind === 'submission' && redelivered.kind === 'submission') {
|
|
108
|
+
expect(redelivered.submission.submissionId).toBe(first.submission.submissionId);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
it('returns conflict when one dispatch id is reused with another payload', async () => {
|
|
112
|
+
const store = await create();
|
|
113
|
+
await store.admitDispatch(dispatchInput());
|
|
114
|
+
expect(await store.admitDispatch(dispatchInput({ message: { kind: 'signal', type: 'test.event', body: 'Different' } }))).toEqual({ kind: 'conflict' });
|
|
115
|
+
});
|
|
116
|
+
it('round-trips the dispatch payload, tenant, and actor verbatim', async () => {
|
|
117
|
+
const store = await create();
|
|
118
|
+
const input = dispatchInput({
|
|
119
|
+
message: { kind: 'user', body: 'Here is the screenshot.' },
|
|
120
|
+
tenantId: 'tenant-1',
|
|
121
|
+
actor: { onBehalfOf: 'user-1' },
|
|
122
|
+
});
|
|
123
|
+
const admitted = await admitDispatchReady(store, input);
|
|
124
|
+
expect(admitted).toMatchObject({
|
|
125
|
+
kind: 'submission',
|
|
126
|
+
submission: {
|
|
127
|
+
submissionId: input.dispatchId,
|
|
128
|
+
input: { message: input.message, tenantId: 'tenant-1', actor: { onBehalfOf: 'user-1' } },
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
expect((await store.getSubmission(input.dispatchId))?.input).toMatchObject({
|
|
132
|
+
message: input.message,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
// ── Direct admission ───────────────────────────────────────────────
|
|
137
|
+
describe('direct admission', () => {
|
|
138
|
+
it('round-trips a direct submission input and replays it idempotently', async () => {
|
|
139
|
+
const store = await create();
|
|
140
|
+
const input = directInput();
|
|
141
|
+
const admitted = await admitDirectReady(store, input);
|
|
142
|
+
expect(admitted.input).toEqual(input);
|
|
143
|
+
expect(admitted.storeSessionId).toBe(SESSION_ID);
|
|
144
|
+
const replay = await store.admitDirect(input);
|
|
145
|
+
expect(replay.input).toEqual(input);
|
|
146
|
+
expect(replay.submissionId).toBe(admitted.submissionId);
|
|
147
|
+
expect((await store.getSubmission(input.submissionId))?.input).toEqual(input);
|
|
148
|
+
});
|
|
149
|
+
it('rejects a direct replay with a different payload', async () => {
|
|
150
|
+
const store = await create();
|
|
151
|
+
await admitDirectReady(store, directInput());
|
|
152
|
+
await expect(store.admitDirect(directInput({ message: { kind: 'user', body: 'Different' } }))).rejects.toThrow('unexpected result');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe('canonical readiness', () => {
|
|
156
|
+
it('does not list or claim a queued submission before canonical readiness', async () => {
|
|
157
|
+
const store = await create();
|
|
158
|
+
const admission = await store.admitDispatch(dispatchInput());
|
|
159
|
+
expect(admission).toMatchObject({
|
|
160
|
+
kind: 'submission',
|
|
161
|
+
submission: { canonicalReadyAt: null },
|
|
162
|
+
});
|
|
163
|
+
expect(await store.listRunnableSubmissions()).toEqual([]);
|
|
164
|
+
expect(await store.claimSubmission(claim('dispatch-1', 'attempt-1'))).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
it('lists unready queued submissions in admission order', async () => {
|
|
167
|
+
const store = await create();
|
|
168
|
+
const first = await store.admitDispatch(dispatchInput());
|
|
169
|
+
await admitDispatchReady(store, dispatchInput({ dispatchId: 'dispatch-2' }));
|
|
170
|
+
const third = await store.admitDirect(directInput({ submissionId: 'direct-2', id: 'agent-2' }));
|
|
171
|
+
expect(await store.listUnreadySubmissions()).toEqual([
|
|
172
|
+
expect.objectContaining({
|
|
173
|
+
submissionId: first.kind === 'submission' ? first.submission.submissionId : '',
|
|
174
|
+
}),
|
|
175
|
+
expect.objectContaining({ submissionId: third.submissionId }),
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
it('lists and claims a queued submission after canonical readiness', async () => {
|
|
179
|
+
const store = await create();
|
|
180
|
+
await store.admitDispatch(dispatchInput());
|
|
181
|
+
const ready = await store.markSubmissionCanonicalReady('dispatch-1');
|
|
182
|
+
expect(ready?.canonicalReadyAt).toEqual(expect.any(Number));
|
|
183
|
+
expect(await store.markSubmissionCanonicalReady('dispatch-1')).toEqual(ready);
|
|
184
|
+
expect(await store.listRunnableSubmissions()).toEqual([ready]);
|
|
185
|
+
expect(await store.claimSubmission(claim('dispatch-1', 'attempt-1'))).toMatchObject({
|
|
186
|
+
status: 'running',
|
|
187
|
+
canonicalReadyAt: ready?.canonicalReadyAt,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
// ── Submission ordering ───────────────────────────────────────────
|
|
192
|
+
describe('submission ordering', () => {
|
|
193
|
+
it('orders direct and dispatched submissions together within one session', async () => {
|
|
194
|
+
const store = await create();
|
|
195
|
+
const direct = await admitDirectReady(store, directInput());
|
|
196
|
+
await admitDispatchReady(store, dispatchInput());
|
|
197
|
+
const other = await admitDirectReady(store, directInput({ submissionId: 'direct-2', id: 'agent-2' }));
|
|
198
|
+
expect(await store.listRunnableSubmissions()).toEqual([direct, other]);
|
|
199
|
+
expect(await store.claimSubmission(claim('dispatch-1', 'attempt-blocked'))).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
it('lists queued dispatches in admission order and selects one runnable head per session', async () => {
|
|
202
|
+
const store = await create();
|
|
203
|
+
await admitDispatchReady(store, dispatchInput());
|
|
204
|
+
await admitDispatchReady(store, dispatchInput({ dispatchId: 'dispatch-2' }));
|
|
205
|
+
await admitDispatchReady(store, dispatchInput({ dispatchId: 'dispatch-3', id: 'agent-2' }));
|
|
206
|
+
expect(await store.listRunnableSubmissions()).toEqual([
|
|
207
|
+
expect.objectContaining({ submissionId: 'dispatch-1' }),
|
|
208
|
+
expect.objectContaining({ submissionId: 'dispatch-3' }),
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
it('exposes the next submission of a session only after the head settles', async () => {
|
|
212
|
+
const store = await create();
|
|
213
|
+
await admitDispatchReady(store, dispatchInput());
|
|
214
|
+
await admitDispatchReady(store, dispatchInput({ dispatchId: 'dispatch-2' }));
|
|
215
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
216
|
+
expect(await store.listRunnableSubmissions()).toEqual([]);
|
|
217
|
+
await store.completeSubmission(attempt('dispatch-1', 'attempt-1'));
|
|
218
|
+
expect(await store.listRunnableSubmissions()).toEqual([
|
|
219
|
+
expect.objectContaining({ submissionId: 'dispatch-2' }),
|
|
220
|
+
]);
|
|
221
|
+
expect(await store.claimSubmission(claim('dispatch-2', 'attempt-2'))).toMatchObject({
|
|
222
|
+
submissionId: 'dispatch-2',
|
|
223
|
+
status: 'running',
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
// ── Claim semantics ──────────────────────────────────────────────
|
|
228
|
+
describe('claim semantics', () => {
|
|
229
|
+
it('claims only runnable session heads while allowing separate sessions to claim independently', async () => {
|
|
230
|
+
const store = await create();
|
|
231
|
+
await admitDispatchReady(store, dispatchInput());
|
|
232
|
+
await admitDispatchReady(store, dispatchInput({ dispatchId: 'dispatch-2' }));
|
|
233
|
+
await admitDispatchReady(store, dispatchInput({ dispatchId: 'dispatch-3', id: 'agent-2' }));
|
|
234
|
+
const first = await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
235
|
+
const blocked = await store.claimSubmission(claim('dispatch-2', 'attempt-2'));
|
|
236
|
+
const other = await store.claimSubmission(claim('dispatch-3', 'attempt-3'));
|
|
237
|
+
expect(first).toMatchObject({
|
|
238
|
+
submissionId: 'dispatch-1',
|
|
239
|
+
status: 'running',
|
|
240
|
+
attemptId: 'attempt-1',
|
|
241
|
+
startedAt: expect.any(Number),
|
|
242
|
+
});
|
|
243
|
+
expect(blocked).toBeNull();
|
|
244
|
+
expect(other).toMatchObject({
|
|
245
|
+
submissionId: 'dispatch-3',
|
|
246
|
+
status: 'running',
|
|
247
|
+
attemptId: 'attempt-3',
|
|
248
|
+
});
|
|
249
|
+
expect(await store.listRunningSubmissions()).toEqual([first, other]);
|
|
250
|
+
expect(await store.listRunnableSubmissions()).toEqual([]);
|
|
251
|
+
});
|
|
252
|
+
it('never lets two claims for the same submission both succeed', async () => {
|
|
253
|
+
const store = await create();
|
|
254
|
+
await admitDispatchReady(store, dispatchInput());
|
|
255
|
+
const [first, second] = await Promise.all([
|
|
256
|
+
store.claimSubmission(claim('dispatch-1', 'attempt-a', 'owner-a')),
|
|
257
|
+
store.claimSubmission(claim('dispatch-1', 'attempt-b', 'owner-b')),
|
|
258
|
+
]);
|
|
259
|
+
const successes = [first, second].filter((submission) => submission !== null);
|
|
260
|
+
expect(successes).toHaveLength(1);
|
|
261
|
+
const claimed = await store.getSubmission('dispatch-1');
|
|
262
|
+
expect(claimed?.attemptCount).toBe(1);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
// ── Lifecycle transitions ─────────────────────────────────────────
|
|
266
|
+
describe('lifecycle transitions', () => {
|
|
267
|
+
it('records input application and recovery requests only for the owning attempt', async () => {
|
|
268
|
+
const store = await create();
|
|
269
|
+
await admitDispatchReady(store, dispatchInput());
|
|
270
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
271
|
+
expect(await store.markSubmissionInputApplied(attempt('dispatch-1', 'attempt-1'))).toBe(true);
|
|
272
|
+
expect(await store.markSubmissionInputApplied(attempt('dispatch-1', 'stale-attempt'))).toBe(false);
|
|
273
|
+
expect(await store.requestSubmissionRecovery(attempt('dispatch-1', 'attempt-1'))).toBe(true);
|
|
274
|
+
expect(await store.requestSubmissionRecovery(attempt('dispatch-1', 'stale-attempt'))).toBe(false);
|
|
275
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({
|
|
276
|
+
status: 'running',
|
|
277
|
+
attemptId: 'attempt-1',
|
|
278
|
+
inputAppliedAt: expect.any(Number),
|
|
279
|
+
recoveryRequestedAt: expect.any(Number),
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
it('requeues interrupted attempts only before canonical input application', async () => {
|
|
283
|
+
const store = await create();
|
|
284
|
+
await admitDispatchReady(store, dispatchInput({ dispatchId: 'requeue-safe' }));
|
|
285
|
+
await admitDispatchReady(store, dispatchInput({ dispatchId: 'requeue-unsafe', id: 'agent-2' }));
|
|
286
|
+
await store.claimSubmission(claim('requeue-safe', 'attempt-safe'));
|
|
287
|
+
await store.claimSubmission(claim('requeue-unsafe', 'attempt-unsafe'));
|
|
288
|
+
await store.markSubmissionInputApplied(attempt('requeue-unsafe', 'attempt-unsafe'));
|
|
289
|
+
expect(await store.requeueSubmissionBeforeInputApplied(attempt('requeue-safe', 'attempt-safe'))).toBe(true);
|
|
290
|
+
expect(await store.requeueSubmissionBeforeInputApplied(attempt('requeue-unsafe', 'attempt-unsafe'))).toBe(false);
|
|
291
|
+
expect(await store.getSubmission('requeue-safe')).toMatchObject({ status: 'queued' });
|
|
292
|
+
expect(await store.getSubmission('requeue-unsafe')).toMatchObject({ status: 'running' });
|
|
293
|
+
});
|
|
294
|
+
it('reports unsettled visibility until a claimed dispatch completes', async () => {
|
|
295
|
+
const store = await create();
|
|
296
|
+
await admitDispatchReady(store, dispatchInput());
|
|
297
|
+
expect(await store.hasUnsettledSubmissions()).toBe(true);
|
|
298
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
299
|
+
expect(await store.listRunningSubmissions()).toHaveLength(1);
|
|
300
|
+
await store.completeSubmission(attempt('dispatch-1', 'attempt-1'));
|
|
301
|
+
expect(await store.hasUnsettledSubmissions()).toBe(false);
|
|
302
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({ status: 'settled' });
|
|
303
|
+
});
|
|
304
|
+
it('ignores stale-attempt settlement and keeps the first owning terminal state', async () => {
|
|
305
|
+
const store = await create();
|
|
306
|
+
await admitDispatchReady(store, dispatchInput());
|
|
307
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
308
|
+
expect(await store.completeSubmission(attempt('dispatch-1', 'stale-attempt'))).toBe(false);
|
|
309
|
+
expect(await store.failSubmission(attempt('dispatch-1', 'attempt-1'), new Error('first failure'))).toBe(true);
|
|
310
|
+
expect(await store.completeSubmission(attempt('dispatch-1', 'attempt-1'))).toBe(false);
|
|
311
|
+
expect(await store.failSubmission(attempt('dispatch-1', 'attempt-1'), new Error('later failure'))).toBe(false);
|
|
312
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({
|
|
313
|
+
status: 'settled',
|
|
314
|
+
error: 'first failure',
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
// ── Session abort requests ────────────────────────────────────────
|
|
319
|
+
describe('session abort requests', () => {
|
|
320
|
+
it('stamps an abort intent on a queued submission without changing its status', async () => {
|
|
321
|
+
const store = await create();
|
|
322
|
+
const admitted = await admitDispatchReady(store, dispatchInput());
|
|
323
|
+
const storeSessionId = admitted.kind === 'submission' ? admitted.submission.storeSessionId : '';
|
|
324
|
+
const affected = await store.requestSessionAbort(storeSessionId);
|
|
325
|
+
expect(affected).toEqual(['dispatch-1']);
|
|
326
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({
|
|
327
|
+
status: 'queued',
|
|
328
|
+
abortRequestedAt: expect.any(Number),
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
it('stamps the running head and every queued submission in the session at once', async () => {
|
|
332
|
+
const store = await create();
|
|
333
|
+
// Same instance/session: dispatch-1 runs, the rest queue behind it.
|
|
334
|
+
const admitted = await admitDispatchReady(store, dispatchInput());
|
|
335
|
+
await admitDispatchReady(store, dispatchInput({ dispatchId: 'dispatch-2' }));
|
|
336
|
+
await admitDispatchReady(store, dispatchInput({ dispatchId: 'dispatch-3' }));
|
|
337
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
338
|
+
const storeSessionId = admitted.kind === 'submission' ? admitted.submission.storeSessionId : '';
|
|
339
|
+
const affected = await store.requestSessionAbort(storeSessionId);
|
|
340
|
+
expect(new Set(affected)).toEqual(new Set(['dispatch-1', 'dispatch-2', 'dispatch-3']));
|
|
341
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({
|
|
342
|
+
status: 'running',
|
|
343
|
+
attemptId: 'attempt-1',
|
|
344
|
+
abortRequestedAt: expect.any(Number),
|
|
345
|
+
});
|
|
346
|
+
expect(await store.getSubmission('dispatch-3')).toMatchObject({
|
|
347
|
+
status: 'queued',
|
|
348
|
+
abortRequestedAt: expect.any(Number),
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
it('keeps the first abort timestamp when the request is repeated', async () => {
|
|
352
|
+
const store = await create();
|
|
353
|
+
const admitted = await admitDispatchReady(store, dispatchInput());
|
|
354
|
+
const storeSessionId = admitted.kind === 'submission' ? admitted.submission.storeSessionId : '';
|
|
355
|
+
await store.requestSessionAbort(storeSessionId);
|
|
356
|
+
const stampedAt = (await store.getSubmission('dispatch-1'))?.abortRequestedAt;
|
|
357
|
+
await store.requestSessionAbort(storeSessionId);
|
|
358
|
+
expect((await store.getSubmission('dispatch-1'))?.abortRequestedAt).toBe(stampedAt);
|
|
359
|
+
});
|
|
360
|
+
it('leaves settled submissions untouched and does not resurrect them', async () => {
|
|
361
|
+
const store = await create();
|
|
362
|
+
const admitted = await admitDispatchReady(store, dispatchInput());
|
|
363
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
364
|
+
await store.completeSubmission(attempt('dispatch-1', 'attempt-1'));
|
|
365
|
+
const storeSessionId = admitted.kind === 'submission' ? admitted.submission.storeSessionId : '';
|
|
366
|
+
expect(await store.requestSessionAbort(storeSessionId)).toEqual([]);
|
|
367
|
+
// A settled submission is an immovable sink: never offered for
|
|
368
|
+
// re-processing and cannot be re-claimed.
|
|
369
|
+
expect(await store.listRunnableSubmissions()).toHaveLength(0);
|
|
370
|
+
expect(await store.listExpiredSubmissions()).toHaveLength(0);
|
|
371
|
+
expect(await store.claimSubmission(claim('dispatch-1', 'attempt-2'))).toBeNull();
|
|
372
|
+
});
|
|
373
|
+
it('returns an empty array for a session with no unsettled submissions', async () => {
|
|
374
|
+
const store = await create();
|
|
375
|
+
expect(await store.requestSessionAbort('agent:none:none:none')).toEqual([]);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
// ── Direct settlement obligation ──────────────────────────────────
|
|
379
|
+
describe('direct settlement obligation', () => {
|
|
380
|
+
const recordId = 'direct-1:settled';
|
|
381
|
+
it('reserves a canonical settlement only for the owning direct attempt', async () => {
|
|
382
|
+
const store = await create();
|
|
383
|
+
await admitDirectReady(store, directInput());
|
|
384
|
+
await store.claimSubmission(claim('direct-1', 'attempt-1'));
|
|
385
|
+
const record = settlementRecord('completed');
|
|
386
|
+
expect(await store.reserveSubmissionSettlement(attempt('direct-1', 'stale'), { recordId, record })).toBeNull();
|
|
387
|
+
const reserved = await store.reserveSubmissionSettlement(attempt('direct-1', 'attempt-1'), {
|
|
388
|
+
recordId,
|
|
389
|
+
record,
|
|
390
|
+
});
|
|
391
|
+
expect(reserved).toEqual({
|
|
392
|
+
submissionId: 'direct-1',
|
|
393
|
+
storeSessionId: SESSION_ID,
|
|
394
|
+
attemptId: 'attempt-1',
|
|
395
|
+
recordId,
|
|
396
|
+
record,
|
|
397
|
+
});
|
|
398
|
+
expect(await store.getSubmission('direct-1')).toMatchObject({ status: 'terminalizing' });
|
|
399
|
+
});
|
|
400
|
+
it('rejects a settlement record naming a different submission', async () => {
|
|
401
|
+
const store = await create();
|
|
402
|
+
await admitDirectReady(store, directInput());
|
|
403
|
+
await store.claimSubmission(claim('direct-1', 'attempt-1'));
|
|
404
|
+
expect(await store.reserveSubmissionSettlement(attempt('direct-1', 'attempt-1'), {
|
|
405
|
+
recordId,
|
|
406
|
+
record: settlementRecord('completed', 'someone-else'),
|
|
407
|
+
})).toBeNull();
|
|
408
|
+
expect(await store.getSubmission('direct-1')).toMatchObject({ status: 'running' });
|
|
409
|
+
});
|
|
410
|
+
it('replays an exact obligation and rejects a conflicting settlement payload', async () => {
|
|
411
|
+
const store = await create();
|
|
412
|
+
await admitDirectReady(store, directInput());
|
|
413
|
+
await store.claimSubmission(claim('direct-1', 'attempt-1'));
|
|
414
|
+
const ref = attempt('direct-1', 'attempt-1');
|
|
415
|
+
const completed = settlementRecord('completed');
|
|
416
|
+
const first = await store.reserveSubmissionSettlement(ref, { recordId, record: completed });
|
|
417
|
+
expect(first).not.toBeNull();
|
|
418
|
+
expect(await store.reserveSubmissionSettlement(ref, { recordId, record: completed })).toEqual(first);
|
|
419
|
+
expect(await store.reserveSubmissionSettlement(ref, { recordId, record: settlementRecord('failed') })).toBeNull();
|
|
420
|
+
});
|
|
421
|
+
it('keeps terminalizing work unsettled and ordered but not runnable or reclaimable', async () => {
|
|
422
|
+
const store = await create();
|
|
423
|
+
await admitDirectReady(store, directInput());
|
|
424
|
+
await admitDirectReady(store, directInput({ submissionId: 'direct-2' }));
|
|
425
|
+
await store.claimSubmission({ ...claim('direct-1', 'attempt-1'), leaseExpiresAt: 1 });
|
|
426
|
+
const record = settlementRecord('completed');
|
|
427
|
+
await store.reserveSubmissionSettlement(attempt('direct-1', 'attempt-1'), { recordId, record });
|
|
428
|
+
expect(await store.hasUnsettledSubmissions()).toBe(true);
|
|
429
|
+
expect(await store.listRunnableSubmissions()).toEqual([]);
|
|
430
|
+
expect(await store.listRunningSubmissions()).toEqual([]);
|
|
431
|
+
expect(await store.listExpiredSubmissions()).toEqual([]);
|
|
432
|
+
});
|
|
433
|
+
it('lists and finalizes a pending settlement obligation', async () => {
|
|
434
|
+
const store = await create();
|
|
435
|
+
await admitDirectReady(store, directInput());
|
|
436
|
+
await store.claimSubmission(claim('direct-1', 'attempt-1'));
|
|
437
|
+
const ref = attempt('direct-1', 'attempt-1');
|
|
438
|
+
const record = settlementRecord('completed');
|
|
439
|
+
await store.reserveSubmissionSettlement(ref, { recordId, record });
|
|
440
|
+
expect(await store.listPendingSubmissionSettlements()).toEqual([
|
|
441
|
+
{
|
|
442
|
+
submissionId: 'direct-1',
|
|
443
|
+
storeSessionId: SESSION_ID,
|
|
444
|
+
attemptId: 'attempt-1',
|
|
445
|
+
recordId,
|
|
446
|
+
record,
|
|
447
|
+
},
|
|
448
|
+
]);
|
|
449
|
+
expect(await store.finalizeSubmissionSettlement(ref, recordId)).toBe(true);
|
|
450
|
+
expect(await store.listPendingSubmissionSettlements()).toEqual([]);
|
|
451
|
+
expect(await store.hasUnsettledSubmissions()).toBe(false);
|
|
452
|
+
expect(await store.getSubmission('direct-1')).toMatchObject({ status: 'settled' });
|
|
453
|
+
});
|
|
454
|
+
it('does not finalize for a stale attempt or a different record id', async () => {
|
|
455
|
+
const store = await create();
|
|
456
|
+
await admitDirectReady(store, directInput());
|
|
457
|
+
await store.claimSubmission(claim('direct-1', 'attempt-1'));
|
|
458
|
+
const ref = attempt('direct-1', 'attempt-1');
|
|
459
|
+
await store.reserveSubmissionSettlement(ref, { recordId, record: settlementRecord('completed') });
|
|
460
|
+
expect(await store.finalizeSubmissionSettlement(attempt('direct-1', 'stale'), recordId)).toBe(false);
|
|
461
|
+
expect(await store.finalizeSubmissionSettlement(ref, 'other-record')).toBe(false);
|
|
462
|
+
expect(await store.getSubmission('direct-1')).toMatchObject({ status: 'terminalizing' });
|
|
463
|
+
});
|
|
464
|
+
it('leaves dispatch settlement behavior unchanged', async () => {
|
|
465
|
+
const store = await create();
|
|
466
|
+
await admitDispatchReady(store, dispatchInput());
|
|
467
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
468
|
+
expect(await store.reserveSubmissionSettlement(attempt('dispatch-1', 'attempt-1'), {
|
|
469
|
+
recordId: 'dispatch-1:settled',
|
|
470
|
+
record: settlementRecord('completed', 'dispatch-1'),
|
|
471
|
+
})).toBeNull();
|
|
472
|
+
expect(await store.completeSubmission(attempt('dispatch-1', 'attempt-1'))).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
// ── Durability ───────────────────────────────────────────────────
|
|
476
|
+
describe('durability', () => {
|
|
477
|
+
it('initializes attemptCount to 0 and timeoutAt to 0 at admission', async () => {
|
|
478
|
+
const store = await create();
|
|
479
|
+
await admitDispatchReady(store, dispatchInput());
|
|
480
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({
|
|
481
|
+
attemptCount: 0,
|
|
482
|
+
maxRetry: 10,
|
|
483
|
+
timeoutAt: 0,
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
it('sets attemptCount to 1 and applies system defaults at claim time', async () => {
|
|
487
|
+
const store = await create();
|
|
488
|
+
await admitDispatchReady(store, dispatchInput());
|
|
489
|
+
const before = Date.now();
|
|
490
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
491
|
+
const claimed = await store.getSubmission('dispatch-1');
|
|
492
|
+
if (!claimed)
|
|
493
|
+
throw new Error('Expected claimed submission to exist.');
|
|
494
|
+
expect(claimed.attemptCount).toBe(1);
|
|
495
|
+
expect(claimed.maxRetry).toBe(10);
|
|
496
|
+
expect(claimed.timeoutAt).toBeGreaterThanOrEqual(before + 60 * 60_000);
|
|
497
|
+
});
|
|
498
|
+
it('applies custom durability when input is marked applied', async () => {
|
|
499
|
+
const store = await create();
|
|
500
|
+
await admitDispatchReady(store, dispatchInput());
|
|
501
|
+
const customTimeout = Date.now() + 6 * 60 * 60_000;
|
|
502
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
503
|
+
await store.markSubmissionInputApplied(attempt('dispatch-1', 'attempt-1'), {
|
|
504
|
+
maxRetry: 5,
|
|
505
|
+
timeoutAt: customTimeout,
|
|
506
|
+
});
|
|
507
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({
|
|
508
|
+
attemptCount: 1,
|
|
509
|
+
maxRetry: 5,
|
|
510
|
+
timeoutAt: customTimeout,
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
it('installs durability once — a repeated application keeps the first values', async () => {
|
|
514
|
+
const store = await create();
|
|
515
|
+
await admitDispatchReady(store, dispatchInput());
|
|
516
|
+
const customTimeout = Date.now() + 6 * 60 * 60_000;
|
|
517
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
518
|
+
await store.markSubmissionInputApplied(attempt('dispatch-1', 'attempt-1'), {
|
|
519
|
+
maxRetry: 5,
|
|
520
|
+
timeoutAt: customTimeout,
|
|
521
|
+
});
|
|
522
|
+
const first = await store.getSubmission('dispatch-1');
|
|
523
|
+
// Idempotent-true: the gate still matches, but durability is not rewritten.
|
|
524
|
+
expect(await store.markSubmissionInputApplied(attempt('dispatch-1', 'attempt-1'), {
|
|
525
|
+
maxRetry: 7,
|
|
526
|
+
timeoutAt: customTimeout + 60_000,
|
|
527
|
+
})).toBe(true);
|
|
528
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({
|
|
529
|
+
maxRetry: 5,
|
|
530
|
+
timeoutAt: customTimeout,
|
|
531
|
+
inputAppliedAt: first?.inputAppliedAt,
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
it('increments attemptCount on recovery via replaceSubmissionAttempt', async () => {
|
|
535
|
+
const store = await create();
|
|
536
|
+
await admitDispatchReady(store, dispatchInput());
|
|
537
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
538
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({ attemptCount: 1 });
|
|
539
|
+
const replaced = await store.replaceSubmissionAttempt(attempt('dispatch-1', 'attempt-1'), 'attempt-2');
|
|
540
|
+
expect(replaced).toMatchObject({ attemptCount: 2, attemptId: 'attempt-2' });
|
|
541
|
+
});
|
|
542
|
+
it('increments attemptCount and preserves timeoutAt when reclaiming after requeue', async () => {
|
|
543
|
+
const store = await create();
|
|
544
|
+
await admitDispatchReady(store, dispatchInput());
|
|
545
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
546
|
+
const first = await store.getSubmission('dispatch-1');
|
|
547
|
+
if (!first)
|
|
548
|
+
throw new Error('Expected claimed submission to exist.');
|
|
549
|
+
expect(first.attemptCount).toBe(1);
|
|
550
|
+
await store.requeueSubmissionBeforeInputApplied(attempt('dispatch-1', 'attempt-1'));
|
|
551
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-2'));
|
|
552
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({
|
|
553
|
+
attemptCount: 2,
|
|
554
|
+
timeoutAt: first.timeoutAt,
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
// ── Recovery attempt replacement ──────────────────────────────────
|
|
559
|
+
describe('replaceSubmissionAttempt()', () => {
|
|
560
|
+
it('replaces a running attempt and returns the updated submission', async () => {
|
|
561
|
+
const store = await create();
|
|
562
|
+
await admitDispatchReady(store, dispatchInput());
|
|
563
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
564
|
+
await store.requestSubmissionRecovery(attempt('dispatch-1', 'attempt-1'));
|
|
565
|
+
const replaced = await store.replaceSubmissionAttempt(attempt('dispatch-1', 'attempt-1'), 'attempt-2');
|
|
566
|
+
expect(replaced).toMatchObject({
|
|
567
|
+
submissionId: 'dispatch-1',
|
|
568
|
+
status: 'running',
|
|
569
|
+
attemptId: 'attempt-2',
|
|
570
|
+
});
|
|
571
|
+
// The pending recovery request is cleared by the handoff.
|
|
572
|
+
expect((await store.getSubmission('dispatch-1'))?.recoveryRequestedAt).toBeUndefined();
|
|
573
|
+
});
|
|
574
|
+
it('returns null without writing when the attempt no longer owns the submission', async () => {
|
|
575
|
+
const store = await create();
|
|
576
|
+
await admitDispatchReady(store, dispatchInput());
|
|
577
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
578
|
+
expect(await store.replaceSubmissionAttempt(attempt('dispatch-1', 'attempt-stale'), 'attempt-2')).toBeNull();
|
|
579
|
+
expect(await store.getSubmission('dispatch-1')).toMatchObject({
|
|
580
|
+
attemptId: 'attempt-1',
|
|
581
|
+
attemptCount: 1,
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
it('installs the new lease when one is supplied', async () => {
|
|
585
|
+
const store = await create();
|
|
586
|
+
await admitDispatchReady(store, dispatchInput());
|
|
587
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
588
|
+
const leaseExpiresAt = Date.now() + 60_000;
|
|
589
|
+
const replaced = await store.replaceSubmissionAttempt(attempt('dispatch-1', 'attempt-1'), 'attempt-2', { ownerId: 'owner-2', leaseExpiresAt });
|
|
590
|
+
expect(replaced).toMatchObject({ attemptId: 'attempt-2', ownerId: 'owner-2', leaseExpiresAt });
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
// ── Attempt markers ──────────────────────────────────────────────
|
|
594
|
+
describe('attempt markers', () => {
|
|
595
|
+
it('lists inserted markers with creation timestamps', async () => {
|
|
596
|
+
const store = await create();
|
|
597
|
+
const before = Date.now();
|
|
598
|
+
await store.insertAttemptMarker(attempt('dispatch-1', 'attempt-1'));
|
|
599
|
+
const markers = await store.listAttemptMarkers();
|
|
600
|
+
expect(markers).toEqual([
|
|
601
|
+
{ submissionId: 'dispatch-1', attemptId: 'attempt-1', createdAt: expect.any(Number) },
|
|
602
|
+
]);
|
|
603
|
+
const [marker] = markers;
|
|
604
|
+
if (!marker)
|
|
605
|
+
throw new Error('Expected an attempt marker.');
|
|
606
|
+
expect(marker.createdAt).toBeGreaterThanOrEqual(before);
|
|
607
|
+
});
|
|
608
|
+
it('keeps one marker with the original timestamp when the same attempt is inserted twice', async () => {
|
|
609
|
+
const store = await create();
|
|
610
|
+
await store.insertAttemptMarker(attempt('dispatch-1', 'attempt-1'));
|
|
611
|
+
const first = await store.listAttemptMarkers();
|
|
612
|
+
await store.insertAttemptMarker(attempt('dispatch-1', 'attempt-1'));
|
|
613
|
+
expect(await store.listAttemptMarkers()).toEqual(first);
|
|
614
|
+
});
|
|
615
|
+
it('deletes only the marker matching both submission and attempt', async () => {
|
|
616
|
+
const store = await create();
|
|
617
|
+
await store.insertAttemptMarker(attempt('dispatch-1', 'attempt-1'));
|
|
618
|
+
await store.insertAttemptMarker(attempt('dispatch-1', 'attempt-2'));
|
|
619
|
+
await store.deleteAttemptMarker(attempt('dispatch-1', 'attempt-1'));
|
|
620
|
+
expect(await store.listAttemptMarkers()).toEqual([
|
|
621
|
+
expect.objectContaining({ submissionId: 'dispatch-1', attemptId: 'attempt-2' }),
|
|
622
|
+
]);
|
|
623
|
+
});
|
|
624
|
+
it('silently handles deleting nonexistent markers', async () => {
|
|
625
|
+
const store = await create();
|
|
626
|
+
await expect(store.deleteAttemptMarker(attempt('missing', 'attempt-1'))).resolves.toBeUndefined();
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
// ── Lease management ──────────────────────────────────────────────
|
|
630
|
+
describe('renewLeases()', () => {
|
|
631
|
+
it('extends lease timestamp for owned running submissions', async () => {
|
|
632
|
+
const store = await create();
|
|
633
|
+
await admitDispatchReady(store, dispatchInput());
|
|
634
|
+
const expiry = Date.now() + 5_000;
|
|
635
|
+
await store.claimSubmission({
|
|
636
|
+
submissionId: 'dispatch-1',
|
|
637
|
+
attemptId: 'attempt-1',
|
|
638
|
+
ownerId: 'owner-a',
|
|
639
|
+
leaseExpiresAt: expiry,
|
|
640
|
+
});
|
|
641
|
+
await store.renewLeases('owner-a', ['dispatch-1']);
|
|
642
|
+
const submission = await store.getSubmission('dispatch-1');
|
|
643
|
+
if (!submission)
|
|
644
|
+
throw new Error('Expected renewed submission to exist.');
|
|
645
|
+
expect(submission.leaseExpiresAt).toBeGreaterThan(expiry);
|
|
646
|
+
});
|
|
647
|
+
it('ignores submissions owned by a different coordinator', async () => {
|
|
648
|
+
const store = await create();
|
|
649
|
+
await admitDispatchReady(store, dispatchInput());
|
|
650
|
+
const expiry = Date.now() + 5_000;
|
|
651
|
+
await store.claimSubmission({
|
|
652
|
+
submissionId: 'dispatch-1',
|
|
653
|
+
attemptId: 'attempt-1',
|
|
654
|
+
ownerId: 'owner-a',
|
|
655
|
+
leaseExpiresAt: expiry,
|
|
656
|
+
});
|
|
657
|
+
await store.renewLeases('owner-b', ['dispatch-1']);
|
|
658
|
+
const submission = await store.getSubmission('dispatch-1');
|
|
659
|
+
if (!submission)
|
|
660
|
+
throw new Error('Expected submission to exist.');
|
|
661
|
+
expect(submission.leaseExpiresAt).toBe(expiry);
|
|
662
|
+
});
|
|
663
|
+
it('ignores settled submissions', async () => {
|
|
664
|
+
const store = await create();
|
|
665
|
+
await admitDispatchReady(store, dispatchInput());
|
|
666
|
+
await store.claimSubmission(claim('dispatch-1', 'attempt-1'));
|
|
667
|
+
await store.completeSubmission(attempt('dispatch-1', 'attempt-1'));
|
|
668
|
+
// Should not throw — settled submissions are silently skipped.
|
|
669
|
+
await store.renewLeases('test-owner', ['dispatch-1']);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
describe('listExpiredSubmissions()', () => {
|
|
673
|
+
it('returns running submissions with expired leases', async () => {
|
|
674
|
+
const store = await create();
|
|
675
|
+
await admitDispatchReady(store, dispatchInput());
|
|
676
|
+
await store.claimSubmission({
|
|
677
|
+
submissionId: 'dispatch-1',
|
|
678
|
+
attemptId: 'attempt-1',
|
|
679
|
+
ownerId: 'owner-a',
|
|
680
|
+
leaseExpiresAt: 1, // expired in the past
|
|
681
|
+
});
|
|
682
|
+
const expired = await store.listExpiredSubmissions();
|
|
683
|
+
expect(expired).toHaveLength(1);
|
|
684
|
+
const submission = expired[0];
|
|
685
|
+
if (!submission)
|
|
686
|
+
throw new Error('Expected one expired submission.');
|
|
687
|
+
expect(submission.submissionId).toBe('dispatch-1');
|
|
688
|
+
});
|
|
689
|
+
it('excludes submissions with future lease expiry', async () => {
|
|
690
|
+
const store = await create();
|
|
691
|
+
await admitDispatchReady(store, dispatchInput());
|
|
692
|
+
await store.claimSubmission({
|
|
693
|
+
submissionId: 'dispatch-1',
|
|
694
|
+
attemptId: 'attempt-1',
|
|
695
|
+
ownerId: 'owner-a',
|
|
696
|
+
leaseExpiresAt: Date.now() + 60_000,
|
|
697
|
+
});
|
|
698
|
+
expect(await store.listExpiredSubmissions()).toHaveLength(0);
|
|
699
|
+
});
|
|
700
|
+
it('excludes settled submissions', async () => {
|
|
701
|
+
const store = await create();
|
|
702
|
+
await admitDispatchReady(store, dispatchInput());
|
|
703
|
+
await store.claimSubmission({
|
|
704
|
+
submissionId: 'dispatch-1',
|
|
705
|
+
attemptId: 'attempt-1',
|
|
706
|
+
ownerId: 'owner-a',
|
|
707
|
+
leaseExpiresAt: 1,
|
|
708
|
+
});
|
|
709
|
+
await store.completeSubmission(attempt('dispatch-1', 'attempt-1'));
|
|
710
|
+
expect(await store.listExpiredSubmissions()).toHaveLength(0);
|
|
711
|
+
});
|
|
712
|
+
it('returns empty when no submissions exist', async () => {
|
|
713
|
+
const store = await create();
|
|
714
|
+
expect(await store.listExpiredSubmissions()).toHaveLength(0);
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
// ── Edge cases ────────────────────────────────────────────────────
|
|
718
|
+
describe('edge cases', () => {
|
|
719
|
+
it('reports no unsettled submissions initially', async () => {
|
|
720
|
+
const store = await create();
|
|
721
|
+
expect(await store.hasUnsettledSubmissions()).toBe(false);
|
|
722
|
+
});
|
|
723
|
+
it('getSubmission returns null for unknown ids', async () => {
|
|
724
|
+
const store = await create();
|
|
725
|
+
expect(await store.getSubmission('nonexistent')).toBeNull();
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
//# sourceMappingURL=submission-store-contract.js.map
|