@openparachute/agent 0.2.0 → 0.2.3-rc.10

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.
Files changed (70) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +8 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/registry.ts +65 -27
  6. package/src/daemon.ts +311 -12
  7. package/src/def-vault-triggers.ts +317 -0
  8. package/src/preflight.ts +139 -0
  9. package/src/spawn-agent.ts +16 -0
  10. package/src/step-up.ts +316 -0
  11. package/src/terminal-ui.ts +73 -0
  12. package/src/transports/http-ui.ts +10 -8
  13. package/src/transports/vault.ts +40 -22
  14. package/src/ui-kit.ts +6 -3
  15. package/src/ui-ticket.ts +121 -0
  16. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  17. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  18. package/web/ui/dist/index.html +15 -0
  19. package/src/_parked/interactive-spawn.test.ts +0 -324
  20. package/src/_parked/interactive-spawn.ts +0 -701
  21. package/src/agent-defs.test.ts +0 -1504
  22. package/src/agent-mcp-config.test.ts +0 -115
  23. package/src/agents.test.ts +0 -360
  24. package/src/auth.test.ts +0 -46
  25. package/src/backends/attached-queue.test.ts +0 -376
  26. package/src/backends/programmatic.test.ts +0 -1715
  27. package/src/backends/registry.test.ts +0 -1494
  28. package/src/backends/stream-json.test.ts +0 -570
  29. package/src/channel-backend-wiring.test.ts +0 -237
  30. package/src/credentials.test.ts +0 -274
  31. package/src/cron.test.ts +0 -342
  32. package/src/daemon-agent-def-api.test.ts +0 -166
  33. package/src/daemon-agent-defs-api.test.ts +0 -953
  34. package/src/daemon-agent-env-api.test.ts +0 -338
  35. package/src/daemon-attached-queue-store.test.ts +0 -65
  36. package/src/daemon-config-api.test.ts +0 -962
  37. package/src/daemon-jobs-api.test.ts +0 -271
  38. package/src/daemon-vault-chat.test.ts +0 -250
  39. package/src/daemon.test.ts +0 -746
  40. package/src/def-vaults.test.ts +0 -136
  41. package/src/delivery-state.test.ts +0 -110
  42. package/src/effective-env.test.ts +0 -114
  43. package/src/grants.test.ts +0 -638
  44. package/src/hub-jwt.test.ts +0 -161
  45. package/src/jobs.test.ts +0 -245
  46. package/src/mcp-http.test.ts +0 -265
  47. package/src/mint-token.test.ts +0 -152
  48. package/src/module-manifest.test.ts +0 -158
  49. package/src/programmatic-wiring.test.ts +0 -838
  50. package/src/registry.test.ts +0 -227
  51. package/src/resolve-port.test.ts +0 -64
  52. package/src/routing.test.ts +0 -184
  53. package/src/runner.test.ts +0 -506
  54. package/src/sandbox/config.test.ts +0 -150
  55. package/src/sandbox/egress.test.ts +0 -113
  56. package/src/sandbox/live-seatbelt.test.ts +0 -277
  57. package/src/sandbox/mounts.test.ts +0 -154
  58. package/src/sandbox/sandbox.test.ts +0 -168
  59. package/src/services-manifest.test.ts +0 -106
  60. package/src/spa-serve.test.ts +0 -116
  61. package/src/spawn-agent-cli.test.ts +0 -172
  62. package/src/spawn-agent.test.ts +0 -1218
  63. package/src/spawn-deps.test.ts +0 -54
  64. package/src/terminal-assets.test.ts +0 -50
  65. package/src/terminal.test.ts +0 -530
  66. package/src/transports/http-ui.test.ts +0 -455
  67. package/src/transports/telegram.test.ts +0 -174
  68. package/src/transports/vault.test.ts +0 -2011
  69. package/src/ui-kit.test.ts +0 -178
  70. 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
- });