Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion packages/cli/src/utils/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,31 @@ function branchRoot(agentId: string): string {
return resolveDeliveryPath(agentId);
}

/** True if `recipient` is a local branch agent or team member (has a branch-office presence). */
function isBranchRecipient(recipient: string): boolean {
const branchDir = join(process.env.HOME || homedir(), ".tps", "branch-office");
if (!existsSync(branchDir)) return false;
if (existsSync(join(branchDir, recipient))) return true; // registered branch / team root
try {
for (const d of readdirSync(branchDir)) {
const sidecar = join(branchDir, d, "team.json");
if (!existsSync(sidecar)) continue;
const s = JSON.parse(readFileSync(sidecar, "utf-8"));
if (Array.isArray(s.members) && s.members.includes(recipient)) return true;
}
} catch {}
return false;
}

/**
* Delivery hook for FileSystemTransport (ops-16): a local branch/team recipient
* reads its workspace inbox, not the flat host mail path, so route relayed mail
* there. Returns null for host agents so they keep the host path unchanged.
*/
function resolveBranchRecipientDir(recipient: string): string | null {
return isBranchRecipient(recipient) ? resolveDeliveryPath(recipient) : null;
}

function assertAgent(agentId: string): void {
const safe = sanitizeIdentifier(agentId);
if (!agentId || safe !== agentId) {
Expand All @@ -92,7 +117,7 @@ function atomicWriteJson(targetPath: string, data: unknown): void {
}

const detectors = new Map<string, LoopDetector>();
const transportRegistry = new TransportRegistry(new FileSystemTransport());
const transportRegistry = new TransportRegistry(new FileSystemTransport(resolveBranchRecipientDir));

function getDetector(agentId: string): LoopDetector {
if (!detectors.has(agentId)) {
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/utils/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,25 @@ function hostMailRoot(): string {
}

export class FileSystemTransport implements DeliveryTransport {
/**
* @param resolveRecipientDir Optional hook: given a recipient id, return the mail
* root to deliver into (its `<root>/new` is used), or null to use the flat host
* mail path. relay.ts injects a branch-aware resolver so a message to a local
* branch agent lands in that branch's workspace inbox (where it actually reads),
* not the host path (ops-16). With no hook, host delivery is unchanged.
*/
constructor(private readonly resolveRecipientDir?: (recipient: string) => string | null) {}

name(): string {
return "filesystem";
}

async deliver(envelope: MailEnvelope): Promise<DeliveryResult> {
try {
const recipientNew = join(hostMailRoot(), envelope.to, "new");
const branchRoot = this.resolveRecipientDir?.(envelope.to) ?? null;
const recipientNew = branchRoot
? join(branchRoot, "new")
: join(hostMailRoot(), envelope.to, "new");
ensureDir(recipientNew);

const payload = {
Expand Down
61 changes: 60 additions & 1 deletion packages/cli/test/relay.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from "node:fs";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { deliverToSandbox, processOutboxOnce } from "../src/utils/relay.js";
Expand Down Expand Up @@ -170,6 +170,65 @@ describe("relay utils", () => {
expect(msg.body).toBe("hello team member");
});

test("relays branch→branch mail to the recipient's workspace inbox, not the host path (ops-16)", async () => {
const out = outboxNew("brancha");
// branchb is a registered branch (has a branch-office dir) → reads its workspace inbox.
const recipBranch = join(root, ".tps", "branch-office", "branchb");
mkdirSync(recipBranch, { recursive: true });
writeJson(join(out, "m1.json"), {
id: "msg-bb", from: "brancha", to: "branchb",
body: "hello branch b", timestamp: new Date().toISOString(),
});

const res = await processOutboxOnce("brancha");
expect(res.processed).toBe(1);

// Lands in branchb's workspace inbox (branch-office/branchb/mail/new)...
const wsInbox = join(recipBranch, "mail", "new");
expect(readdirSync(wsInbox).filter((f) => f.endsWith(".json")).length).toBe(1);
// ...not the flat host path (~/.tps/mail/branchb/new).
const hostPath = join(root, ".tps", "mail", "branchb", "new");
const hostCount = existsSync(hostPath) ? readdirSync(hostPath).filter((f) => f.endsWith(".json")).length : 0;
expect(hostCount).toBe(0);
});

test("relays to a team member's workspace inbox, not the host path (ops-16)", async () => {
const out = outboxNew("brancha");
const teamDir = join(root, ".tps", "branch-office", "team1");
const workspaceMail = join(teamDir, "workspace", "mail");
mkdirSync(teamDir, { recursive: true });
writeJson(join(teamDir, "team.json"), {
teamId: "team1", members: ["member1"], workspaceMail, createdAt: new Date().toISOString(),
});
writeJson(join(out, "m1.json"), {
id: "msg-m1", from: "brancha", to: "member1",
body: "hello member", timestamp: new Date().toISOString(),
});

const res = await processOutboxOnce("brancha");
expect(res.processed).toBe(1);

expect(readdirSync(join(workspaceMail, "new")).filter((f) => f.endsWith(".json")).length).toBe(1);
const hostPath = join(root, ".tps", "mail", "member1", "new");
const hostCount = existsSync(hostPath) ? readdirSync(hostPath).filter((f) => f.endsWith(".json")).length : 0;
expect(hostCount).toBe(0);
});

test("relays to a non-branch (host) recipient via the host mail path (ops-16 regression guard)", async () => {
const out = outboxNew("brancha");
writeJson(join(out, "m1.json"), {
id: "msg-host", from: "brancha", to: "kern",
body: "hello host agent", timestamp: new Date().toISOString(),
});

const res = await processOutboxOnce("brancha");
expect(res.processed).toBe(1);

// kern has no branch-office presence → host path, unchanged.
const hostInbox = join(root, ".tps", "mail", "kern", "new");
expect(readdirSync(hostInbox).filter((f) => f.endsWith(".json")).length).toBe(1);
});

test("relay pauses duplicate messages (loop detection)", async () => {
const agentId = "brancha";
const out = outboxNew(agentId);
Expand Down
Loading