diff --git a/tools/broadcast-local/schema.test.ts b/tools/broadcast-local/schema.test.ts index 8e4ef53ac7..34c88a61e1 100644 --- a/tools/broadcast-local/schema.test.ts +++ b/tools/broadcast-local/schema.test.ts @@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test"; import { DEFAULT_LOCAL_BROADCAST_TTL_MS, + detectLocalBroadcastScopeConflicts, isLocalBroadcastStale, localBroadcastExpiresAt, makeLocalBroadcastReceipt, validateLocalBroadcastEnvelope, type LocalBroadcastEnvelope, + type LocalBroadcastScopeConflict, } from "./schema"; const writtenAt = "2026-05-26T22:50:00Z"; @@ -59,6 +61,183 @@ describe("local broadcast schema", () => { }); }); + test("detects active overlapping scopes across agents", () => { + const vera = { + ...validEnvelope(), + id: "vera-20260526T225000Z", + from: "vera" as const, + summary: "Working on B-0213 conflict detection.", + scope: [{ kind: "path" as const, value: "tools/broadcast-local/" }], + }; + const otto = { + ...validEnvelope(), + id: "otto-20260526T225100Z", + from: "otto" as const, + summary: "Also touching local broadcast tooling.", + scope: [{ kind: "path" as const, value: "tools/broadcast-local/" }], + }; + const riven = { + ...validEnvelope(), + id: "riven-20260526T225200Z", + from: "riven" as const, + status: "idle" as const, + summary: "Idle receipt only.", + scope: [{ kind: "path" as const, value: "tools/broadcast-local/" }], + }; + + const expected: readonly LocalBroadcastScopeConflict[] = [ + { + scope: { kind: "path", value: "tools/broadcast-local/" }, + broadcastIds: ["otto-20260526T225100Z", "vera-20260526T225000Z"], + agents: ["otto", "vera"], + summaries: ["Also touching local broadcast tooling.", "Working on B-0213 conflict detection."], + }, + ]; + + expect(detectLocalBroadcastScopeConflicts([vera, otto, riven], new Date("2026-05-26T22:55:00Z"))).toEqual( + expected, + ); + expect(detectLocalBroadcastScopeConflicts([riven, otto, vera], new Date("2026-05-26T22:55:00Z"))).toEqual( + expected, + ); + }); + + test("orders multiple conflicts deterministically by scope", () => { + const otto = { + ...validEnvelope(), + id: "otto-20260526T225100Z", + from: "otto" as const, + summary: "Touching two local broadcast scopes.", + scope: [ + { kind: "path" as const, value: "tools/broadcast-local/" }, + { kind: "claim" as const, value: "claim/backlog-0213" }, + ], + }; + const vera = { + ...validEnvelope(), + id: "vera-20260526T225000Z", + from: "vera" as const, + summary: "Also touching both local broadcast scopes.", + scope: [ + { kind: "path" as const, value: "tools/broadcast-local/" }, + { kind: "claim" as const, value: "claim/backlog-0213" }, + ], + }; + const expected: readonly LocalBroadcastScopeConflict[] = [ + { + scope: { kind: "claim", value: "claim/backlog-0213" }, + broadcastIds: ["otto-20260526T225100Z", "vera-20260526T225000Z"], + agents: ["otto", "vera"], + summaries: ["Touching two local broadcast scopes.", "Also touching both local broadcast scopes."], + }, + { + scope: { kind: "path", value: "tools/broadcast-local/" }, + broadcastIds: ["otto-20260526T225100Z", "vera-20260526T225000Z"], + agents: ["otto", "vera"], + summaries: ["Touching two local broadcast scopes.", "Also touching both local broadcast scopes."], + }, + ]; + + expect(detectLocalBroadcastScopeConflicts([vera, otto], new Date("2026-05-26T22:55:00Z"))).toEqual(expected); + expect(detectLocalBroadcastScopeConflicts([otto, vera], new Date("2026-05-26T22:55:00Z"))).toEqual(expected); + }); + + test("reports every pair when three agents overlap on one scope", () => { + const otto = { + ...validEnvelope(), + id: "otto-20260526T225100Z", + from: "otto" as const, + summary: "Touching local broadcast tooling from Otto.", + scope: [{ kind: "path" as const, value: "tools/broadcast-local/" }], + }; + const riven = { + ...validEnvelope(), + id: "riven-20260526T225200Z", + from: "riven" as const, + summary: "Touching local broadcast tooling from Riven.", + scope: [{ kind: "path" as const, value: "tools/broadcast-local/" }], + }; + const vera = { + ...validEnvelope(), + id: "vera-20260526T225000Z", + from: "vera" as const, + summary: "Touching local broadcast tooling from Vera.", + scope: [{ kind: "path" as const, value: "tools/broadcast-local/" }], + }; + const expected: readonly LocalBroadcastScopeConflict[] = [ + { + scope: { kind: "path", value: "tools/broadcast-local/" }, + broadcastIds: ["otto-20260526T225100Z", "riven-20260526T225200Z"], + agents: ["otto", "riven"], + summaries: ["Touching local broadcast tooling from Otto.", "Touching local broadcast tooling from Riven."], + }, + { + scope: { kind: "path", value: "tools/broadcast-local/" }, + broadcastIds: ["otto-20260526T225100Z", "vera-20260526T225000Z"], + agents: ["otto", "vera"], + summaries: ["Touching local broadcast tooling from Otto.", "Touching local broadcast tooling from Vera."], + }, + { + scope: { kind: "path", value: "tools/broadcast-local/" }, + broadcastIds: ["riven-20260526T225200Z", "vera-20260526T225000Z"], + agents: ["riven", "vera"], + summaries: ["Touching local broadcast tooling from Riven.", "Touching local broadcast tooling from Vera."], + }, + ]; + + expect(detectLocalBroadcastScopeConflicts([vera, riven, otto], new Date("2026-05-26T22:55:00Z"))).toEqual( + expected, + ); + }); + + test("keeps NUL-bearing scope values exact", () => { + const otto = { + ...validEnvelope(), + id: "otto-20260526T225100Z", + from: "otto" as const, + summary: "Touching NUL path A.", + scope: [{ kind: "path" as const, value: "tools/broadcast-local/\0a" }], + }; + const vera = { + ...validEnvelope(), + id: "vera-20260526T225000Z", + from: "vera" as const, + summary: "Touching NUL path B.", + scope: [{ kind: "path" as const, value: "tools/broadcast-local/\0b" }], + }; + const riven = { + ...validEnvelope(), + id: "riven-20260526T225200Z", + from: "riven" as const, + summary: "Also touching NUL path A.", + scope: [{ kind: "path" as const, value: "tools/broadcast-local/\0a" }], + }; + + const expected: readonly LocalBroadcastScopeConflict[] = [ + { + scope: { kind: "path", value: "tools/broadcast-local/\0a" }, + broadcastIds: ["otto-20260526T225100Z", "riven-20260526T225200Z"], + agents: ["otto", "riven"], + summaries: ["Touching NUL path A.", "Also touching NUL path A."], + }, + ]; + + expect(detectLocalBroadcastScopeConflicts([vera, riven, otto], new Date("2026-05-26T22:55:00Z"))).toEqual( + expected, + ); + }); + + test("ignores stale overlapping scopes", () => { + const vera = validEnvelope(); + const otto = { + ...validEnvelope(), + id: "otto-20260526T225100Z", + from: "otto" as const, + }; + + expect(detectLocalBroadcastScopeConflicts([vera, otto], new Date("2026-05-26T23:30:00Z"))).toEqual([]); + }); + test("validates the required envelope fields", () => { expect(validateLocalBroadcastEnvelope(validEnvelope()).ok).toBe(true); diff --git a/tools/broadcast-local/schema.ts b/tools/broadcast-local/schema.ts index 6fc8caac50..8815cf9246 100644 --- a/tools/broadcast-local/schema.ts +++ b/tools/broadcast-local/schema.ts @@ -59,6 +59,13 @@ export interface LocalBroadcastEnvelope { readonly receipts?: readonly LocalBroadcastReceipt[]; } +export interface LocalBroadcastScopeConflict { + readonly scope: LocalBroadcastScope; + readonly broadcastIds: readonly [string, string]; + readonly agents: readonly [LocalBroadcastAgent, LocalBroadcastAgent]; + readonly summaries: readonly [string, string]; +} + export type LocalBroadcastValidation = | { readonly ok: true; readonly value: LocalBroadcastEnvelope } | { readonly ok: false; readonly errors: readonly string[] }; @@ -120,6 +127,86 @@ export function makeLocalBroadcastReceipt(config: { }; } +function localBroadcastScopeKey(scope: LocalBroadcastScope): string { + return JSON.stringify([scope.kind, scope.value]); +} + +function compareStrings(left: string, right: string): number { + const leftChars = Array.from(left); + const rightChars = Array.from(right); + const length = Math.min(leftChars.length, rightChars.length); + + for (let index = 0; index < length; index += 1) { + const leftCodePoint = leftChars[index]?.codePointAt(0) ?? 0; + const rightCodePoint = rightChars[index]?.codePointAt(0) ?? 0; + if (leftCodePoint !== rightCodePoint) { + return leftCodePoint - rightCodePoint; + } + } + + return leftChars.length - rightChars.length; +} + +function activeConflictCandidates( + envelopes: readonly LocalBroadcastEnvelope[], + now: Date, +): readonly LocalBroadcastEnvelope[] { + return [...envelopes] + .filter((envelope) => envelope.status !== "idle" && !isLocalBroadcastStale(envelope, now)) + .sort((left, right) => { + const agentCompare = compareStrings(left.from, right.from); + if (agentCompare !== 0) { + return agentCompare; + } + return compareStrings(left.id, right.id); + }); +} + +export function detectLocalBroadcastScopeConflicts( + envelopes: readonly LocalBroadcastEnvelope[], + now: Date = new Date(), +): readonly LocalBroadcastScopeConflict[] { + const ownersByScope = new Map(); + const conflicts: LocalBroadcastScopeConflict[] = []; + + for (const envelope of activeConflictCandidates(envelopes, now)) { + const seenInEnvelope = new Set(); + const scopes = [...(envelope.scope ?? [])].sort((left, right) => + compareStrings(localBroadcastScopeKey(left), localBroadcastScopeKey(right)), + ); + for (const scope of scopes) { + const key = localBroadcastScopeKey(scope); + if (seenInEnvelope.has(key)) { + continue; + } + seenInEnvelope.add(key); + + const owners = ownersByScope.get(key) ?? []; + for (const owner of owners) { + if (owner.from === envelope.from || owner.id === envelope.id) { + continue; + } + + conflicts.push({ + scope, + broadcastIds: [owner.id, envelope.id], + agents: [owner.from, envelope.from], + summaries: [owner.summary, envelope.summary], + }); + } + ownersByScope.set(key, [...owners, envelope]); + } + } + + return [...conflicts].sort((left, right) => { + const scopeCompare = compareStrings(localBroadcastScopeKey(left.scope), localBroadcastScopeKey(right.scope)); + if (scopeCompare !== 0) { + return scopeCompare; + } + return compareStrings(JSON.stringify(left.broadcastIds), JSON.stringify(right.broadcastIds)); + }); +} + export function validateLocalBroadcastEnvelope(value: unknown): LocalBroadcastValidation { const errors: string[] = [];