@openparachute/agent 0.2.3-rc.2 → 0.2.3-rc.4
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/package.json +4 -1
- package/src/transports/vault.ts +19 -1
- package/web/ui/dist/assets/{index-5KEwEhfi.js → index-0e7eQymr.js} +1 -1
- package/web/ui/dist/assets/index-tvKbxee4.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2012
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
- package/web/ui/tsconfig.json +0 -21
|
@@ -1,376 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the ATTACHED-backend queue registry (`src/backends/attached-queue.ts`) —
|
|
3
|
-
* the parallel-to-ProgrammaticAgentRegistry registry a `backend:attached` agent's
|
|
4
|
-
* connected Claude Code session pulls from (design 2026-06-18-channel-backend.md).
|
|
5
|
-
*
|
|
6
|
-
* A FAKE store (implements {@link AttachedQueueStore}) stands in for the channel's
|
|
7
|
-
* VaultTransport: it holds inbound notes in memory with a mutable `status`/`claimedAt`
|
|
8
|
-
* (the vault IS the queue + source of truth), and records outbound replies. So the
|
|
9
|
-
* claim/reply/release/sweep semantics + the restart-safety (re-read from the store)
|
|
10
|
-
* are asserted with no real vault.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { describe, test, expect } from "bun:test";
|
|
14
|
-
import {
|
|
15
|
-
AttachedQueueRegistry,
|
|
16
|
-
type AttachedQueueStore,
|
|
17
|
-
} from "./attached-queue.ts";
|
|
18
|
-
import type { AgentSpec } from "../sandbox/types.ts";
|
|
19
|
-
import { InboundClaimConflictError } from "../transports/vault.ts";
|
|
20
|
-
import type { InboundQueueNote, InboundStatus } from "../transports/vault.ts";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* A fake durable inbound-note store. Notes live in a Map (id → note); `reply` records
|
|
24
|
-
* outbound writes. Mirrors the VaultTransport methods the registry calls. The status
|
|
25
|
-
* mutations persist in the Map, so re-reading models the vault's restart-safety.
|
|
26
|
-
*/
|
|
27
|
-
class FakeStore implements AttachedQueueStore {
|
|
28
|
-
readonly notes = new Map<string, InboundQueueNote>();
|
|
29
|
-
readonly outbound: Array<{ text: string; inReplyTo?: string }> = [];
|
|
30
|
-
/** If set, the NEXT `setInboundStatus` throws this (to test claim-fail safety). */
|
|
31
|
-
throwOnNextSetStatus: Error | null = null;
|
|
32
|
-
/** If set, `reply` throws this (to test reply-before-handled ordering). */
|
|
33
|
-
throwOnReply: Error | null = null;
|
|
34
|
-
|
|
35
|
-
add(note: InboundQueueNote): void {
|
|
36
|
-
this.notes.set(note.id, note);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async listInboundQueue(): Promise<InboundQueueNote[]> {
|
|
40
|
-
// Ascending by ts (the real transport sorts this way), returning COPIES so the
|
|
41
|
-
// registry can't mutate the store except through setInboundStatus (models the
|
|
42
|
-
// vault round-trip — the registry reads values, then PATCHes).
|
|
43
|
-
return [...this.notes.values()]
|
|
44
|
-
.map((n) => ({ ...n }))
|
|
45
|
-
.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async setInboundStatus(
|
|
49
|
-
id: string,
|
|
50
|
-
status: InboundStatus,
|
|
51
|
-
claimedAt?: string | null,
|
|
52
|
-
ifUpdatedAt?: string,
|
|
53
|
-
): Promise<void> {
|
|
54
|
-
if (this.throwOnNextSetStatus) {
|
|
55
|
-
const e = this.throwOnNextSetStatus;
|
|
56
|
-
this.throwOnNextSetStatus = null;
|
|
57
|
-
throw e;
|
|
58
|
-
}
|
|
59
|
-
const note = this.notes.get(id);
|
|
60
|
-
if (!note) throw new Error(`fake store: no note ${id}`);
|
|
61
|
-
// CAS (FIX 3): when a precondition is supplied, the claim only lands if the note's
|
|
62
|
-
// `updatedAt` still matches what the caller last saw — else the race is lost (the
|
|
63
|
-
// real vault returns 409 → InboundClaimConflictError). On a successful CAS write we
|
|
64
|
-
// ADVANCE `updatedAt` (the vault bumps it on every write) so a second concurrent
|
|
65
|
-
// claimer with the now-stale precondition fails, modelling the real round-trip.
|
|
66
|
-
if (ifUpdatedAt !== undefined) {
|
|
67
|
-
if (note.updatedAt !== ifUpdatedAt) {
|
|
68
|
-
throw new InboundClaimConflictError(id, 409);
|
|
69
|
-
}
|
|
70
|
-
note.updatedAt = `${ifUpdatedAt}::bumped`;
|
|
71
|
-
}
|
|
72
|
-
note.status = status;
|
|
73
|
-
if (claimedAt === null) delete note.claimedAt;
|
|
74
|
-
else if (claimedAt !== undefined) note.claimedAt = claimedAt;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async reply(args: { text: string; inReplyTo?: string }): Promise<{ sent: string[] }> {
|
|
78
|
-
if (this.throwOnReply) throw this.throwOnReply;
|
|
79
|
-
this.outbound.push({ text: args.text, ...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}) });
|
|
80
|
-
return { sent: [`outbound-${this.outbound.length}`] };
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const specFor = (name: string, systemPrompt?: string): AgentSpec => ({
|
|
85
|
-
name,
|
|
86
|
-
channels: [name],
|
|
87
|
-
backend: "attached",
|
|
88
|
-
...(systemPrompt ? { systemPrompt } : {}),
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const inbound = (id: string, text: string, ts: string, status: InboundStatus = "pending"): InboundQueueNote => ({
|
|
92
|
-
id,
|
|
93
|
-
text,
|
|
94
|
-
sender: "operator",
|
|
95
|
-
ts,
|
|
96
|
-
status,
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe("AttachedQueueRegistry — registration", () => {
|
|
100
|
-
test("register indexes by channel + name; deregister drops it", () => {
|
|
101
|
-
const reg = new AttachedQueueRegistry();
|
|
102
|
-
const store = new FakeStore();
|
|
103
|
-
expect(reg.hasChannel("laptop")).toBe(false);
|
|
104
|
-
reg.register(specFor("laptop"), store);
|
|
105
|
-
expect(reg.hasChannel("laptop")).toBe(true);
|
|
106
|
-
expect(reg.hasName("laptop")).toBe(true);
|
|
107
|
-
expect(reg.channels()).toEqual(["laptop"]);
|
|
108
|
-
expect(reg.deregister("laptop")).toBe(true);
|
|
109
|
-
expect(reg.hasChannel("laptop")).toBe(false);
|
|
110
|
-
expect(reg.deregister("laptop")).toBe(false); // already gone.
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("register throws for a spec with no channel", () => {
|
|
114
|
-
const reg = new AttachedQueueRegistry();
|
|
115
|
-
expect(() => reg.register({ name: "x", channels: [], backend: "attached" }, new FakeStore())).toThrow(/no channel/);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe("AttachedQueueRegistry — pending peek", () => {
|
|
120
|
-
test("counts only pending; previews the oldest-first", async () => {
|
|
121
|
-
const reg = new AttachedQueueRegistry();
|
|
122
|
-
const store = new FakeStore();
|
|
123
|
-
store.add(inbound("a", "first message", "2026-06-18T10:00:00Z"));
|
|
124
|
-
store.add(inbound("b", "second message", "2026-06-18T10:01:00Z"));
|
|
125
|
-
store.add(inbound("c", "already handled", "2026-06-18T09:00:00Z", "handled"));
|
|
126
|
-
reg.register(specFor("laptop"), store);
|
|
127
|
-
|
|
128
|
-
const view = await reg.pending("laptop");
|
|
129
|
-
expect(view.count).toBe(2); // c is handled, excluded.
|
|
130
|
-
expect(view.items.map((i) => i.id)).toEqual(["a", "b"]); // oldest-first.
|
|
131
|
-
expect(view.items[0]!.preview).toBe("first message");
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("a non-attached channel yields an empty no-op view", async () => {
|
|
135
|
-
const reg = new AttachedQueueRegistry();
|
|
136
|
-
const view = await reg.pending("unknown");
|
|
137
|
-
expect(view).toEqual({ count: 0, items: [] });
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
describe("AttachedQueueRegistry — claimNext (single-claim)", () => {
|
|
142
|
-
test("claims the OLDEST pending, sets in-flight + claimedAt, returns text + systemPrompt", async () => {
|
|
143
|
-
const reg = new AttachedQueueRegistry();
|
|
144
|
-
const store = new FakeStore();
|
|
145
|
-
store.add(inbound("b", "newer", "2026-06-18T10:05:00Z"));
|
|
146
|
-
store.add(inbound("a", "older", "2026-06-18T10:00:00Z"));
|
|
147
|
-
reg.register(specFor("laptop", "You are the laptop agent."), store);
|
|
148
|
-
|
|
149
|
-
const claimed = await reg.claimNext("laptop", () => new Date("2026-06-18T11:00:00Z"));
|
|
150
|
-
expect(claimed).not.toBeNull();
|
|
151
|
-
expect(claimed!.id).toBe("a"); // oldest.
|
|
152
|
-
expect(claimed!.text).toBe("older");
|
|
153
|
-
expect(claimed!.inReplyTo).toBe("a");
|
|
154
|
-
expect(claimed!.systemPrompt).toBe("You are the laptop agent."); // the def body → persona.
|
|
155
|
-
// The store note flipped to in-flight + got a claimedAt (the durable claim).
|
|
156
|
-
expect(store.notes.get("a")!.status).toBe("in-flight");
|
|
157
|
-
expect(store.notes.get("a")!.claimedAt).toBe("2026-06-18T11:00:00.000Z");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("a SECOND claimNext does not return the same message (single-claim)", async () => {
|
|
161
|
-
const reg = new AttachedQueueRegistry();
|
|
162
|
-
const store = new FakeStore();
|
|
163
|
-
store.add(inbound("a", "older", "2026-06-18T10:00:00Z"));
|
|
164
|
-
store.add(inbound("b", "newer", "2026-06-18T10:05:00Z"));
|
|
165
|
-
reg.register(specFor("laptop"), store);
|
|
166
|
-
|
|
167
|
-
const first = await reg.claimNext("laptop");
|
|
168
|
-
const second = await reg.claimNext("laptop");
|
|
169
|
-
expect(first!.id).toBe("a");
|
|
170
|
-
expect(second!.id).toBe("b"); // a is now in-flight → not re-presented; b is next.
|
|
171
|
-
const third = await reg.claimNext("laptop");
|
|
172
|
-
expect(third).toBeNull(); // nothing pending left.
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("returns null when none pending", async () => {
|
|
176
|
-
const reg = new AttachedQueueRegistry();
|
|
177
|
-
const store = new FakeStore();
|
|
178
|
-
store.add(inbound("a", "done", "2026-06-18T10:00:00Z", "handled"));
|
|
179
|
-
reg.register(specFor("laptop"), store);
|
|
180
|
-
expect(await reg.claimNext("laptop")).toBeNull();
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("a claim PATCH failure surfaces + leaves the note pending (no hand-out)", async () => {
|
|
184
|
-
const reg = new AttachedQueueRegistry();
|
|
185
|
-
const store = new FakeStore();
|
|
186
|
-
store.add(inbound("a", "older", "2026-06-18T10:00:00Z"));
|
|
187
|
-
reg.register(specFor("laptop"), store);
|
|
188
|
-
store.throwOnNextSetStatus = new Error("vault 500");
|
|
189
|
-
await expect(reg.claimNext("laptop")).rejects.toThrow(/vault 500/);
|
|
190
|
-
expect(store.notes.get("a")!.status).toBe("pending"); // not lost — retryable.
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("FIX 3: a passed-through updatedAt is used as the CAS precondition on the claim", async () => {
|
|
194
|
-
const reg = new AttachedQueueRegistry();
|
|
195
|
-
const store = new FakeStore();
|
|
196
|
-
store.add({ ...inbound("a", "older", "2026-06-18T10:00:00Z"), updatedAt: "rev-1" });
|
|
197
|
-
reg.register(specFor("laptop"), store);
|
|
198
|
-
const claimed = await reg.claimNext("laptop");
|
|
199
|
-
expect(claimed!.id).toBe("a");
|
|
200
|
-
// CAS landed → the store bumped updatedAt (modelling the vault advancing it on write).
|
|
201
|
-
expect(store.notes.get("a")!.status).toBe("in-flight");
|
|
202
|
-
expect(store.notes.get("a")!.updatedAt).toBe("rev-1::bumped");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("FIX 3: a 428/409 conflict on the claim PATCH makes claimNext skip to the NEXT pending (no double-claim)", async () => {
|
|
206
|
-
const reg = new AttachedQueueRegistry();
|
|
207
|
-
const store = new FakeStore();
|
|
208
|
-
// Two pending notes, each with a known revision for the CAS precondition.
|
|
209
|
-
store.add({ ...inbound("a", "older", "2026-06-18T10:00:00Z"), updatedAt: "rev-a" });
|
|
210
|
-
store.add({ ...inbound("b", "newer", "2026-06-18T10:05:00Z"), updatedAt: "rev-b" });
|
|
211
|
-
reg.register(specFor("laptop"), store);
|
|
212
|
-
|
|
213
|
-
// Simulate a CONCURRENT winner: between claimNext's list and its PATCH of "a",
|
|
214
|
-
// another session claims "a" and advances its revision. The next PATCH of "a" with the
|
|
215
|
-
// now-stale precondition will throw InboundClaimConflictError → re-list → claim "b".
|
|
216
|
-
const realSet = store.setInboundStatus.bind(store);
|
|
217
|
-
let firstPatch = true;
|
|
218
|
-
store.setInboundStatus = (async (id, status, claimedAt, ifUpdatedAt) => {
|
|
219
|
-
if (firstPatch && id === "a") {
|
|
220
|
-
firstPatch = false;
|
|
221
|
-
// The "other session" already claimed "a" (its revision moved on).
|
|
222
|
-
store.notes.get("a")!.updatedAt = "rev-a-claimed-by-someone-else";
|
|
223
|
-
store.notes.get("a")!.status = "in-flight";
|
|
224
|
-
}
|
|
225
|
-
return realSet(id, status, claimedAt, ifUpdatedAt);
|
|
226
|
-
}) as typeof store.setInboundStatus;
|
|
227
|
-
|
|
228
|
-
const claimed = await reg.claimNext("laptop");
|
|
229
|
-
// The conflict on "a" was caught + re-listed; we claimed "b" instead — never "a" twice.
|
|
230
|
-
expect(claimed!.id).toBe("b");
|
|
231
|
-
expect(store.notes.get("b")!.status).toBe("in-flight");
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test("FIX 3: a conflict with NO other pending returns null (nothing claimable right now)", async () => {
|
|
235
|
-
const reg = new AttachedQueueRegistry();
|
|
236
|
-
const store = new FakeStore();
|
|
237
|
-
store.add({ ...inbound("a", "only", "2026-06-18T10:00:00Z"), updatedAt: "rev-a" });
|
|
238
|
-
reg.register(specFor("laptop"), store);
|
|
239
|
-
// Every CAS on "a" loses (the precondition is always stale → conflict). After the
|
|
240
|
-
// conflict, "a" is left in-flight (by the simulated winner), so the re-list finds no
|
|
241
|
-
// pending and returns null — not a double-claim, not an error.
|
|
242
|
-
store.setInboundStatus = (async (id: string) => {
|
|
243
|
-
store.notes.get(id)!.status = "in-flight";
|
|
244
|
-
throw new InboundClaimConflictError(id, 409);
|
|
245
|
-
}) as typeof store.setInboundStatus;
|
|
246
|
-
const claimed = await reg.claimNext("laptop");
|
|
247
|
-
expect(claimed).toBeNull();
|
|
248
|
-
});
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
describe("AttachedQueueRegistry — reply (outbound + mark handled)", () => {
|
|
252
|
-
test("writes the outbound via the store reply, THEN marks the inbound handled", async () => {
|
|
253
|
-
const reg = new AttachedQueueRegistry();
|
|
254
|
-
const store = new FakeStore();
|
|
255
|
-
store.add(inbound("a", "question", "2026-06-18T10:00:00Z", "in-flight"));
|
|
256
|
-
store.notes.get("a")!.claimedAt = "2026-06-18T10:01:00Z";
|
|
257
|
-
reg.register(specFor("laptop"), store);
|
|
258
|
-
|
|
259
|
-
const sent = await reg.reply("laptop", { inReplyTo: "a", text: "the answer" });
|
|
260
|
-
expect(sent.sent.length).toBe(1);
|
|
261
|
-
// Outbound recorded through the SAME reply seam (threads inReplyTo).
|
|
262
|
-
expect(store.outbound).toEqual([{ text: "the answer", inReplyTo: "a" }]);
|
|
263
|
-
// Inbound marked handled + claimedAt cleared.
|
|
264
|
-
expect(store.notes.get("a")!.status).toBe("handled");
|
|
265
|
-
expect(store.notes.get("a")!.claimedAt).toBeUndefined();
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
test("if the outbound write fails, the inbound is NOT marked handled (retryable)", async () => {
|
|
269
|
-
const reg = new AttachedQueueRegistry();
|
|
270
|
-
const store = new FakeStore();
|
|
271
|
-
store.add(inbound("a", "question", "2026-06-18T10:00:00Z", "in-flight"));
|
|
272
|
-
reg.register(specFor("laptop"), store);
|
|
273
|
-
store.throwOnReply = new Error("vault write 500");
|
|
274
|
-
|
|
275
|
-
await expect(reg.reply("laptop", { inReplyTo: "a", text: "x" })).rejects.toThrow(/vault write 500/);
|
|
276
|
-
expect(store.notes.get("a")!.status).toBe("in-flight"); // still claimed, not handled.
|
|
277
|
-
expect(store.outbound.length).toBe(0);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
test("reply for an unregistered channel throws", async () => {
|
|
281
|
-
const reg = new AttachedQueueRegistry();
|
|
282
|
-
await expect(reg.reply("nope", { text: "x" })).rejects.toThrow(/no attached-backend agent/);
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
describe("AttachedQueueRegistry — release", () => {
|
|
287
|
-
test("returns an in-flight note to pending + clears claimedAt", async () => {
|
|
288
|
-
const reg = new AttachedQueueRegistry();
|
|
289
|
-
const store = new FakeStore();
|
|
290
|
-
store.add(inbound("a", "q", "2026-06-18T10:00:00Z", "in-flight"));
|
|
291
|
-
store.notes.get("a")!.claimedAt = "2026-06-18T10:01:00Z";
|
|
292
|
-
reg.register(specFor("laptop"), store);
|
|
293
|
-
|
|
294
|
-
await reg.release("laptop", "a");
|
|
295
|
-
expect(store.notes.get("a")!.status).toBe("pending");
|
|
296
|
-
expect(store.notes.get("a")!.claimedAt).toBeUndefined();
|
|
297
|
-
// It's claimable again.
|
|
298
|
-
const claimed = await reg.claimNext("laptop");
|
|
299
|
-
expect(claimed!.id).toBe("a");
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
describe("AttachedQueueRegistry — sweepExpired (TTL auto-release)", () => {
|
|
304
|
-
test("resets a STALE in-flight note to pending; leaves a fresh one alone", async () => {
|
|
305
|
-
const ttl = 15 * 60 * 1000; // 15 min.
|
|
306
|
-
const reg = new AttachedQueueRegistry({ claimTtlMs: ttl });
|
|
307
|
-
const store = new FakeStore();
|
|
308
|
-
const now = new Date("2026-06-18T12:00:00Z");
|
|
309
|
-
// 'stale' claimed 20 min ago (> TTL) → released; 'fresh' claimed 5 min ago → kept.
|
|
310
|
-
store.add({ id: "stale", text: "s", sender: "operator", ts: "2026-06-18T11:00:00Z", status: "in-flight", claimedAt: "2026-06-18T11:40:00Z" });
|
|
311
|
-
store.add({ id: "fresh", text: "f", sender: "operator", ts: "2026-06-18T11:30:00Z", status: "in-flight", claimedAt: "2026-06-18T11:55:00Z" });
|
|
312
|
-
store.add(inbound("pend", "p", "2026-06-18T11:45:00Z")); // already pending — untouched.
|
|
313
|
-
reg.register(specFor("laptop"), store);
|
|
314
|
-
|
|
315
|
-
const released = await reg.sweepExpired(now);
|
|
316
|
-
expect(released).toBe(1);
|
|
317
|
-
expect(store.notes.get("stale")!.status).toBe("pending");
|
|
318
|
-
expect(store.notes.get("stale")!.claimedAt).toBeUndefined();
|
|
319
|
-
expect(store.notes.get("fresh")!.status).toBe("in-flight"); // still fresh.
|
|
320
|
-
expect(store.notes.get("pend")!.status).toBe("pending");
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
test("an in-flight note with no claimedAt is left alone (can't judge its age)", async () => {
|
|
324
|
-
const reg = new AttachedQueueRegistry({ claimTtlMs: 1000 });
|
|
325
|
-
const store = new FakeStore();
|
|
326
|
-
store.add(inbound("a", "q", "2026-06-18T10:00:00Z", "in-flight")); // no claimedAt.
|
|
327
|
-
reg.register(specFor("laptop"), store);
|
|
328
|
-
const released = await reg.sweepExpired(new Date("2026-06-18T12:00:00Z"));
|
|
329
|
-
expect(released).toBe(0);
|
|
330
|
-
expect(store.notes.get("a")!.status).toBe("in-flight");
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
test("one channel's store error doesn't abort the sweep of the others", async () => {
|
|
334
|
-
const reg = new AttachedQueueRegistry({ claimTtlMs: 1000 });
|
|
335
|
-
const bad = new FakeStore();
|
|
336
|
-
bad.listInboundQueue = async () => {
|
|
337
|
-
throw new Error("vault down");
|
|
338
|
-
};
|
|
339
|
-
const good = new FakeStore();
|
|
340
|
-
good.add({ id: "stale", text: "s", sender: "operator", ts: "t", status: "in-flight", claimedAt: "2026-06-18T00:00:00Z" });
|
|
341
|
-
reg.register(specFor("bad"), bad);
|
|
342
|
-
reg.register(specFor("good"), good);
|
|
343
|
-
const released = await reg.sweepExpired(new Date("2026-06-18T12:00:00Z"));
|
|
344
|
-
expect(released).toBe(1); // good swept despite bad throwing.
|
|
345
|
-
expect(good.notes.get("stale")!.status).toBe("pending");
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
describe("AttachedQueueRegistry — restart safety (vault is the source of truth)", () => {
|
|
350
|
-
test("a claim survives a 'restart' (a fresh registry reading the same store)", async () => {
|
|
351
|
-
const store = new FakeStore();
|
|
352
|
-
store.add(inbound("a", "q1", "2026-06-18T10:00:00Z"));
|
|
353
|
-
store.add(inbound("b", "q2", "2026-06-18T10:05:00Z"));
|
|
354
|
-
|
|
355
|
-
// Registry instance #1 claims 'a'.
|
|
356
|
-
const reg1 = new AttachedQueueRegistry();
|
|
357
|
-
reg1.register(specFor("laptop"), store);
|
|
358
|
-
const claimed = await reg1.claimNext("laptop");
|
|
359
|
-
expect(claimed!.id).toBe("a");
|
|
360
|
-
|
|
361
|
-
// "Daemon restart": a BRAND-NEW registry over the SAME durable store. The claim
|
|
362
|
-
// (status:in-flight on note 'a') persisted — so the new registry's first claim is
|
|
363
|
-
// 'b' (a is still in-flight, not re-presented), and a handled message would not
|
|
364
|
-
// reappear either. The vault is the source of truth, not in-memory state.
|
|
365
|
-
const reg2 = new AttachedQueueRegistry();
|
|
366
|
-
reg2.register(specFor("laptop"), store);
|
|
367
|
-
const afterRestart = await reg2.claimNext("laptop");
|
|
368
|
-
expect(afterRestart!.id).toBe("b");
|
|
369
|
-
expect(store.notes.get("a")!.status).toBe("in-flight"); // claim survived.
|
|
370
|
-
|
|
371
|
-
// Replying (post-restart) marks 'b' handled; a re-read never re-presents it.
|
|
372
|
-
await reg2.reply("laptop", { inReplyTo: "b", text: "answer" });
|
|
373
|
-
expect(store.notes.get("b")!.status).toBe("handled");
|
|
374
|
-
expect(await reg2.pending("laptop")).toEqual({ count: 0, items: [] });
|
|
375
|
-
});
|
|
376
|
-
});
|