From 6d5a2570d448a079d6fd8d23f5e966112118790a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 17:16:21 -0400 Subject: [PATCH] feat(relay): in-memory queue with consume-once semantics --- tools/relay/queue.test.ts | 58 +++++++++++++++++++++++++++++++++++++++ tools/relay/queue.ts | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 tools/relay/queue.test.ts create mode 100644 tools/relay/queue.ts diff --git a/tools/relay/queue.test.ts b/tools/relay/queue.test.ts new file mode 100644 index 0000000..892da0b --- /dev/null +++ b/tools/relay/queue.test.ts @@ -0,0 +1,58 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { RelayQueue, isRole } from "./queue.ts"; + +describe("RelayQueue", () => { + let q: RelayQueue; + + beforeEach(() => { + q = new RelayQueue(); + }); + + it("post + read roundtrip returns the message with correct fields", () => { + q.post("dev-b", "pm", "status", "Task P4 DONE"); + const msgs = q.read("pm"); + assert.equal(msgs.length, 1); + assert.equal(msgs[0].from, "dev-b"); + assert.equal(msgs[0].to, "pm"); + assert.equal(msgs[0].kind, "status"); + assert.equal(msgs[0].body, "Task P4 DONE"); + assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0); + assert.ok(typeof msgs[0].ts === "string"); + }); + + it("consume-once: second read returns empty", () => { + q.post("dev-a", "pm", "question", "Should I use approach A?"); + q.read("pm"); + const second = q.read("pm"); + assert.deepEqual(second, []); + }); + + it("list_pending does not drain inbox", () => { + q.post("dev-b", "pm", "directive", "PROCEED"); + const before = q.pending("pm"); + assert.equal(before.count, 1); + const after = q.read("pm"); + assert.equal(after.length, 1); + }); + + it("FIFO ordering across multiple senders", () => { + q.post("dev-a", "pm", "status", "first"); + q.post("dev-b", "pm", "status", "second"); + q.post("dev-a", "pm", "question", "third"); + const msgs = q.read("pm"); + assert.equal(msgs.length, 3); + assert.equal(msgs[0].body, "first"); + assert.equal(msgs[1].body, "second"); + assert.equal(msgs[2].body, "third"); + }); + + it("isRole rejects unknown strings", () => { + assert.ok(isRole("pm")); + assert.ok(isRole("dev-a")); + assert.ok(isRole("dev-b")); + assert.ok(!isRole("dev-c")); + assert.ok(!isRole("")); + assert.ok(!isRole("PM")); + }); +}); diff --git a/tools/relay/queue.ts b/tools/relay/queue.ts new file mode 100644 index 0000000..da7fede --- /dev/null +++ b/tools/relay/queue.ts @@ -0,0 +1,55 @@ +import { randomUUID } from "node:crypto"; + +export type Role = "pm" | "dev-a" | "dev-b"; +export type MessageKind = "status" | "question" | "directive" | "free"; + +export interface RelayMessage { + id: string; + from: Role; + to: Role; + kind: MessageKind; + body: string; + ts: string; +} + +const KNOWN_ROLES = new Set(["pm", "dev-a", "dev-b"]); + +export function isRole(s: string): s is Role { + return KNOWN_ROLES.has(s); +} + +export class RelayQueue { + private readonly queues = new Map([ + ["pm", []], + ["dev-a", []], + ["dev-b", []], + ]); + + post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage { + const msg: RelayMessage = { + id: randomUUID(), + from, + to, + kind, + body, + ts: new Date().toISOString(), + }; + this.queues.get(to)!.push(msg); + return msg; + } + + read(forRole: Role): RelayMessage[] { + const inbox = this.queues.get(forRole)!; + const messages = [...inbox]; + inbox.length = 0; + return messages; + } + + pending(forRole: Role): { count: number; kinds: MessageKind[] } { + const inbox = this.queues.get(forRole)!; + return { + count: inbox.length, + kinds: inbox.map((m) => m.kind), + }; + } +}