feat(ux): consolidate Launch Swarm + telemetry UX with minimized strip
- Removed broken LaunchSwarmDialog (formula-based) from TopBar/LeftPanel - All Rocket buttons (TopBar, LeftPanel, DAG nodes, social cards) now open AssignmentPanel (archetype-based) which actually works - Every Rocket clears taskId first so assignMode && !taskId condition passes - Conversation button priority: taskId always shows conversation, not assign panel - Added TelemetryStrip: minimized right sidebar with status dots when non-telemetry panel (conversation/assignment) is active - Live feed has minimize button → restores last taskId or assignMode - DAG nodes: Signal icon → restores telemetry feed - Social button on DAG nodes: single router.push to avoid race (setView + setTaskId) - Fixed social card message button: opens right panel with drawer:closed (no popup) Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
parent
65d69ecbbc
commit
c246ceaf21
165 changed files with 13730 additions and 1132 deletions
|
|
@ -1,8 +1,13 @@
|
|||
import type { ActivityEvent } from './activity';
|
||||
import type { BeadIssue } from './types';
|
||||
import { listAgents, deriveLiveness } from './agent-registry';
|
||||
import { inboxAgentMessages, type AgentMessage } from './agent-mail';
|
||||
import { statusAgentReservations, classifyOverlap } from './agent-reservations';
|
||||
import type { AgentMessage } from './agent-mail';
|
||||
import {
|
||||
calculateReservationIncursions,
|
||||
projectInboxFromDisk,
|
||||
projectReservations,
|
||||
readCoordEventsFromDisk,
|
||||
} from './coord-projections';
|
||||
|
||||
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted' | 'idle' | 'stuck' | 'dead';
|
||||
|
||||
|
|
@ -148,56 +153,21 @@ export interface Incursion {
|
|||
/**
|
||||
* Calculates global incursions by comparing all active reservations.
|
||||
*/
|
||||
export async function calculateIncursions(): Promise<Incursion[]> {
|
||||
const statusResult = await statusAgentReservations({});
|
||||
if (!statusResult.ok || !statusResult.data) return [];
|
||||
|
||||
const reservations = statusResult.data.reservations;
|
||||
const incursions: Incursion[] = [];
|
||||
const processedPairs = new Set<string>();
|
||||
|
||||
for (let i = 0; i < reservations.length; i++) {
|
||||
for (let j = i + 1; j < reservations.length; j++) {
|
||||
const resA = reservations[i];
|
||||
const resB = reservations[j];
|
||||
|
||||
// Don't compare an agent against themselves
|
||||
if (resA.agent_id === resB.agent_id) continue;
|
||||
|
||||
const overlap = classifyOverlap(resA.scope, resB.scope);
|
||||
if (overlap !== 'disjoint') {
|
||||
const key = [resA.agent_id, resB.agent_id].sort().join(':') + ':' + [resA.scope, resB.scope].sort().join('|');
|
||||
if (processedPairs.has(key)) continue;
|
||||
processedPairs.add(key);
|
||||
|
||||
incursions.push({
|
||||
scope: overlap === 'exact' ? resA.scope : `${resA.scope} ↔ ${resB.scope}`,
|
||||
agents: [resA.agent_id, resB.agent_id],
|
||||
severity: overlap
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return incursions;
|
||||
export async function calculateIncursions(
|
||||
projectRoot: string = process.cwd(),
|
||||
agentLivenessMap: Record<string, string> = {},
|
||||
): Promise<Incursion[]> {
|
||||
const events = await readCoordEventsFromDisk(projectRoot);
|
||||
const reservations = projectReservations(events, agentLivenessMap);
|
||||
return calculateReservationIncursions(reservations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers all relevant communication for all agents to build a summary for aggregation.
|
||||
*/
|
||||
export async function getCommunicationSummary(): Promise<CommunicationSummary> {
|
||||
const agentsResult = await listAgents({});
|
||||
const agents = agentsResult.data ?? [];
|
||||
const allMessages: AgentMessage[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
const inbox = await inboxAgentMessages({ agent: agent.agent_id });
|
||||
if (inbox.data) {
|
||||
allMessages.push(...inbox.data);
|
||||
}
|
||||
}
|
||||
|
||||
return { messages: allMessages };
|
||||
export async function getCommunicationSummary(projectRoot: string = process.cwd()): Promise<CommunicationSummary> {
|
||||
const coordMessages = await projectInboxFromDisk(projectRoot);
|
||||
return { messages: coordMessages };
|
||||
}
|
||||
|
||||
export interface AgentMetrics {
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface ResolveBdExecutableOptions {
|
||||
explicitPath?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface BdExecutableResolution {
|
||||
executable: string;
|
||||
source: 'config' | 'path';
|
||||
}
|
||||
|
||||
export class BdExecutableNotFoundError extends Error {
|
||||
readonly code = 'BD_NOT_FOUND';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BdExecutableNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function splitEnvPath(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const value = env.Path ?? env.PATH ?? '';
|
||||
if (!value.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.split(';').map((segment) => segment.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function executableCandidates(directory: string): string[] {
|
||||
return ['bd.exe', 'bd.cmd', 'bd.bat', 'bd'].map((name) => path.join(directory, name));
|
||||
}
|
||||
|
||||
function buildNotFoundMessage(explicitPath?: string | null): string {
|
||||
const lines = [
|
||||
'bd.exe was not found.',
|
||||
'Install it with: npm install -g @beads/bd',
|
||||
'Or configure an explicit executable path in request payload/config.',
|
||||
];
|
||||
|
||||
if (explicitPath) {
|
||||
lines.push(`Configured path was not found: ${explicitPath}`);
|
||||
}
|
||||
|
||||
return lines.join(' ');
|
||||
}
|
||||
|
||||
export async function resolveBdExecutable(options: ResolveBdExecutableOptions = {}): Promise<BdExecutableResolution> {
|
||||
if (options.explicitPath && options.explicitPath.trim()) {
|
||||
const explicit = path.resolve(options.explicitPath);
|
||||
if (await fileExists(explicit)) {
|
||||
return { executable: explicit, source: 'config' };
|
||||
}
|
||||
|
||||
throw new BdExecutableNotFoundError(buildNotFoundMessage(options.explicitPath));
|
||||
}
|
||||
|
||||
for (const dir of splitEnvPath(options.env)) {
|
||||
for (const candidate of executableCandidates(dir)) {
|
||||
if (await fileExists(candidate)) {
|
||||
return { executable: candidate, source: 'path' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new BdExecutableNotFoundError(buildNotFoundMessage());
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
import { exec as nodeExec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path';
|
||||
|
||||
const execAsync = promisify(nodeExec);
|
||||
import { normalizeProjectRootForRuntime } from './project-root';
|
||||
|
||||
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
|
||||
|
||||
|
|
@ -12,7 +9,9 @@ export interface RunBdCommandOptions {
|
|||
projectRoot: string;
|
||||
args: string[];
|
||||
timeoutMs?: number;
|
||||
// Deprecated: accepted for payload compatibility, ignored by runner.
|
||||
explicitBdPath?: string | null;
|
||||
stdinText?: string;
|
||||
}
|
||||
|
||||
export interface RunBdCommandResult {
|
||||
|
|
@ -29,8 +28,10 @@ export interface RunBdCommandResult {
|
|||
}
|
||||
|
||||
interface RunBdCommandDeps {
|
||||
resolveBdExecutable: typeof resolveBdExecutable;
|
||||
exec: (command: string, options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv }) => Promise<{ stdout: string; stderr: string }>;
|
||||
exec: (
|
||||
command: string,
|
||||
options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv; stdinText?: string },
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
|
|
@ -39,29 +40,51 @@ function normalizeOutput(text: unknown): string {
|
|||
return text.replaceAll('\r\n', '\n').trim();
|
||||
}
|
||||
|
||||
function getExitCode(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object') return null;
|
||||
const value = (error as { exitCode?: unknown }).exitCode;
|
||||
return typeof value === 'number' ? value : null;
|
||||
}
|
||||
|
||||
function toErrorMessage(value: unknown): string {
|
||||
if (value instanceof Error) return value.message;
|
||||
return String(value ?? 'Unknown error');
|
||||
}
|
||||
|
||||
function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification {
|
||||
const exitCode = getExitCode(error);
|
||||
if (error.code === 'ENOENT') return 'not_found';
|
||||
if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') return 'timeout';
|
||||
const stderr = normalizeOutput(error.stderr);
|
||||
if (typeof error.code === 'number') {
|
||||
if (
|
||||
/not recognized as an internal or external command/i.test(stderr) ||
|
||||
/command not found/i.test(stderr) ||
|
||||
/["']bd["'] is not recognized/i.test(stderr) ||
|
||||
/bd: not found/i.test(stderr)
|
||||
) {
|
||||
return 'not_found';
|
||||
}
|
||||
if (typeof error.code === 'number' || exitCode !== null) {
|
||||
if (/(unknown|invalid|required|usage)/i.test(stderr)) return 'bad_args';
|
||||
return 'non_zero_exit';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function buildBdNotFoundMessage(): string {
|
||||
return 'bd command not found in PATH. Install with: npm install -g @beads/bd';
|
||||
}
|
||||
|
||||
function buildShellCommand(executable: string, args: string[]): string {
|
||||
const sanitizedExecutable = executable.replace(/^['"]+|['"]+$/g, '');
|
||||
// Normalize to forward slashes for Windows shell compatibility
|
||||
const normalizedExe = executable.split(path.sep).join('/');
|
||||
const normalizedExe = sanitizedExecutable.split(path.sep).join('/');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: quote the executable path, leave simple args unquoted
|
||||
const quotedExe = `"${normalizedExe}"`;
|
||||
// Windows: do not quote plain command tokens like `bd`; quote only when needed.
|
||||
const quotedExe = /[\s&|<>()^"]/.test(normalizedExe)
|
||||
? `"${normalizedExe.replace(/"/g, '""')}"`
|
||||
: normalizedExe;
|
||||
const quotedArgs = args.map(a => {
|
||||
if (/[\s&|<>()^"]/.test(a)) return `"${a.replace(/"/g, '""')}"`;
|
||||
return a;
|
||||
|
|
@ -73,45 +96,117 @@ function buildShellCommand(executable: string, args: string[]): string {
|
|||
}
|
||||
}
|
||||
|
||||
async function execShellCommand(
|
||||
command: string,
|
||||
options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv; stdinText?: string },
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
|
||||
const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
|
||||
|
||||
const child = spawn(shell, shellArgs, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
}, options.timeout);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
const wrapped = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
|
||||
wrapped.stdout = stdout;
|
||||
wrapped.stderr = stderr;
|
||||
reject(wrapped);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0 && !timedOut) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
const error = new Error(`Command failed with code ${code ?? 'null'}`) as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
};
|
||||
error.code = timedOut ? 'ETIMEDOUT' : 'BD_EXIT';
|
||||
error.stdout = stdout;
|
||||
error.stderr = stderr;
|
||||
error.killed = timedOut;
|
||||
error.signal = signal ?? undefined;
|
||||
(error as { exitCode?: number }).exitCode = code ?? 1;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (options.stdinText !== undefined) {
|
||||
child.stdin.write(options.stdinText);
|
||||
}
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function runBdCommand(
|
||||
options: RunBdCommandOptions,
|
||||
injectedDeps?: Partial<RunBdCommandDeps>,
|
||||
): Promise<RunBdCommandResult> {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
const cwd = options.projectRoot;
|
||||
const cwd = normalizeProjectRootForRuntime(options.projectRoot);
|
||||
const args = [...options.args];
|
||||
if (process.env.BD_NO_DAEMON === 'true') {
|
||||
args.unshift('--no-daemon');
|
||||
}
|
||||
|
||||
const deps: RunBdCommandDeps = {
|
||||
resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable,
|
||||
exec: injectedDeps?.exec ?? execAsync,
|
||||
exec: injectedDeps?.exec ?? execShellCommand,
|
||||
env: injectedDeps?.env ?? process.env,
|
||||
};
|
||||
|
||||
let command = options.explicitBdPath ?? 'bd';
|
||||
const command = 'bd';
|
||||
|
||||
try {
|
||||
const resolved = await deps.resolveBdExecutable({
|
||||
explicitPath: options.explicitBdPath,
|
||||
env: deps.env,
|
||||
});
|
||||
command = resolved.executable;
|
||||
|
||||
const shellCommand = buildShellCommand(command, args);
|
||||
|
||||
const mingwBin = 'C:\\msys64\\mingw64\\bin';
|
||||
const existingPath = deps.env.Path ?? deps.env.PATH ?? '';
|
||||
const enhancedPath = existingPath.includes('mingw64')
|
||||
? existingPath
|
||||
: `${mingwBin};${existingPath}`;
|
||||
let env = deps.env;
|
||||
if (process.platform === 'win32') {
|
||||
const mingwBin = 'C:\\msys64\\mingw64\\bin';
|
||||
const existingPath = deps.env.Path ?? deps.env.PATH ?? '';
|
||||
const enhancedPath = existingPath.includes('mingw64')
|
||||
? existingPath
|
||||
: `${mingwBin};${existingPath}`;
|
||||
env = { ...deps.env, Path: enhancedPath, PATH: enhancedPath };
|
||||
} else {
|
||||
// Ensure ~/.local/bin is in PATH so bd is found regardless of how the server was started
|
||||
const home = deps.env.HOME ?? '';
|
||||
const localBin = `${home}/.local/bin`;
|
||||
const existingPath = deps.env.PATH ?? '';
|
||||
if (home && !existingPath.includes(localBin)) {
|
||||
env = { ...deps.env, PATH: `${localBin}:${existingPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await deps.exec(shellCommand, {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
env: { ...deps.env, Path: enhancedPath, PATH: enhancedPath },
|
||||
env,
|
||||
stdinText: options.stdinText,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -127,39 +222,25 @@ export async function runBdCommand(
|
|||
error: null,
|
||||
};
|
||||
} catch (rawError) {
|
||||
if (rawError instanceof BdExecutableNotFoundError) {
|
||||
return {
|
||||
success: false,
|
||||
classification: 'not_found',
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: rawError.message,
|
||||
};
|
||||
}
|
||||
|
||||
const error = rawError as NodeJS.ErrnoException & {
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
};
|
||||
const classification = classifyFailure(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
classification: classifyFailure(error),
|
||||
classification,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(error.stdout),
|
||||
stderr: normalizeOutput(error.stderr),
|
||||
code: typeof error.code === 'number' ? error.code : null,
|
||||
code: typeof error.code === 'number' ? error.code : getExitCode(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: toErrorMessage(error),
|
||||
error: classification === 'not_found' ? buildBdNotFoundMessage() : toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
src/lib/coord-events.ts
Normal file
73
src/lib/coord-events.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { runBdCommand, type RunBdCommandResult } from './bridge';
|
||||
import { validateCoordEventEnvelope, type CoordEventEnvelope } from './coord-schema';
|
||||
|
||||
export interface WriteCoordEventOptions {
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
export interface WriteCoordEventError {
|
||||
classification: 'bad_args' | 'non_zero_exit' | 'not_found' | 'timeout' | 'unknown';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type WriteCoordEventResult =
|
||||
| { ok: true; eventId: string; commandResult: RunBdCommandResult }
|
||||
| { ok: false; error: WriteCoordEventError };
|
||||
|
||||
interface WriteCoordEventDeps {
|
||||
runBdCommand: typeof runBdCommand;
|
||||
}
|
||||
|
||||
function buildAuditEntry(event: CoordEventEnvelope): Record<string, unknown> {
|
||||
return {
|
||||
version: event.version,
|
||||
kind: event.kind,
|
||||
issue_id: event.issue_id,
|
||||
actor: event.actor,
|
||||
timestamp: event.timestamp,
|
||||
data: event.data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeCoordEvent(
|
||||
input: unknown,
|
||||
options: WriteCoordEventOptions,
|
||||
deps?: Partial<WriteCoordEventDeps>,
|
||||
): Promise<WriteCoordEventResult> {
|
||||
const validated = validateCoordEventEnvelope(input);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'bad_args',
|
||||
message: validated.error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const event = validated.value;
|
||||
const auditEntry = buildAuditEntry(event);
|
||||
const runner = deps?.runBdCommand ?? runBdCommand;
|
||||
|
||||
const commandResult = await runner({
|
||||
projectRoot: options.projectRoot,
|
||||
args: ['audit', 'record', '--stdin', '--json'],
|
||||
stdinText: `${JSON.stringify(auditEntry)}\n`,
|
||||
});
|
||||
|
||||
if (!commandResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
classification: commandResult.classification ?? 'unknown',
|
||||
message: commandResult.error ?? commandResult.stderr ?? 'Failed to record coordination event',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
eventId: event.data.event_id,
|
||||
commandResult,
|
||||
};
|
||||
}
|
||||
272
src/lib/coord-projections.ts
Normal file
272
src/lib/coord-projections.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AgentMessage } from './agent-mail';
|
||||
import { classifyOverlap, normalizePath } from './agent-reservations';
|
||||
import type { CoordEventEnvelope } from './coord-schema';
|
||||
|
||||
export type CoordProtocolEvent = CoordEventEnvelope;
|
||||
export type TakeoverMode = 'stale' | 'evicted';
|
||||
|
||||
export interface ProjectedCoordMessage {
|
||||
message_id: string;
|
||||
thread_id: string;
|
||||
bead_id: string;
|
||||
from_agent: string;
|
||||
to_agent: string;
|
||||
category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO';
|
||||
subject: string;
|
||||
body: string;
|
||||
state: 'unread' | 'read' | 'acked';
|
||||
requires_ack: boolean;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
acked_at: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectedReservation {
|
||||
scope: string;
|
||||
normalized_scope: string;
|
||||
agent_id: string;
|
||||
bead_id: string;
|
||||
state: 'active';
|
||||
created_at: string;
|
||||
takeover_mode: TakeoverMode | null;
|
||||
}
|
||||
|
||||
export interface ProjectedReservationIncursion {
|
||||
scope: string;
|
||||
agents: string[];
|
||||
severity: 'exact' | 'partial';
|
||||
}
|
||||
|
||||
type MessageState = 'unread' | 'read' | 'acked';
|
||||
type EventRefMap = Map<string, { state: MessageState; readAt: string | null; ackedAt: string | null }>;
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function toCategory(value: unknown): ProjectedCoordMessage['category'] {
|
||||
if (value === 'HANDOFF' || value === 'BLOCKED' || value === 'DECISION' || value === 'INFO') {
|
||||
return value;
|
||||
}
|
||||
return 'INFO';
|
||||
}
|
||||
|
||||
function requiresAck(category: ProjectedCoordMessage['category']): boolean {
|
||||
return category === 'HANDOFF' || category === 'BLOCKED';
|
||||
}
|
||||
|
||||
export async function readCoordEventsFromDisk(projectRoot: string): Promise<CoordProtocolEvent[]> {
|
||||
const filePath = path.join(projectRoot, '.beads', 'interactions.jsonl');
|
||||
let raw = '';
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const events: CoordProtocolEvent[] = [];
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!isObject(parsed)) continue;
|
||||
if (parsed.version !== 'coord.v1' || parsed.kind !== 'coord_event') continue;
|
||||
events.push(parsed as unknown as CoordProtocolEvent);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export function projectMessageState(events: CoordProtocolEvent[]): Map<string, MessageState> {
|
||||
const stateMap = new Map<string, MessageState>();
|
||||
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
|
||||
for (const event of sorted) {
|
||||
if (event.data.event_type === 'SEND') {
|
||||
stateMap.set(event.data.event_id, 'unread');
|
||||
continue;
|
||||
}
|
||||
const ref = event.data.event_ref;
|
||||
if (!ref || !stateMap.has(ref)) continue;
|
||||
if (event.data.event_type === 'READ') {
|
||||
stateMap.set(ref, 'read');
|
||||
}
|
||||
if (event.data.event_type === 'ACK') {
|
||||
stateMap.set(ref, 'acked');
|
||||
}
|
||||
}
|
||||
|
||||
return stateMap;
|
||||
}
|
||||
|
||||
function projectMessageStateDetails(events: CoordProtocolEvent[]): EventRefMap {
|
||||
const details: EventRefMap = new Map();
|
||||
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
|
||||
for (const event of sorted) {
|
||||
if (event.data.event_type === 'SEND') {
|
||||
details.set(event.data.event_id, { state: 'unread', readAt: null, ackedAt: null });
|
||||
continue;
|
||||
}
|
||||
const ref = event.data.event_ref;
|
||||
if (!ref) continue;
|
||||
const current = details.get(ref);
|
||||
if (!current) continue;
|
||||
if (event.data.event_type === 'READ' && current.state === 'unread') {
|
||||
current.state = 'read';
|
||||
current.readAt = event.timestamp;
|
||||
}
|
||||
if (event.data.event_type === 'ACK') {
|
||||
current.state = 'acked';
|
||||
if (!current.readAt) current.readAt = event.timestamp;
|
||||
current.ackedAt = event.timestamp;
|
||||
}
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
export function projectInbox(
|
||||
events: CoordProtocolEvent[],
|
||||
beadId?: string,
|
||||
agentId?: string,
|
||||
): ProjectedCoordMessage[] {
|
||||
const messageState = projectMessageStateDetails(events);
|
||||
const messages: ProjectedCoordMessage[] = [];
|
||||
const sorted = [...events].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
|
||||
for (const event of sorted) {
|
||||
if (event.data.event_type !== 'SEND') continue;
|
||||
if (beadId && event.issue_id !== beadId) continue;
|
||||
if (agentId && event.data.to_agent !== agentId) continue;
|
||||
const payload = isObject(event.data.payload) ? event.data.payload : {};
|
||||
const category = toCategory(payload.category);
|
||||
const state = messageState.get(event.data.event_id) ?? { state: 'unread', readAt: null, ackedAt: null };
|
||||
|
||||
messages.push({
|
||||
message_id: event.data.event_id,
|
||||
thread_id: `bead:${event.issue_id}`,
|
||||
bead_id: event.issue_id,
|
||||
from_agent: event.actor,
|
||||
to_agent: typeof event.data.to_agent === 'string' ? event.data.to_agent : 'unknown',
|
||||
category,
|
||||
subject: typeof payload.subject === 'string' ? payload.subject : '',
|
||||
body: typeof payload.body === 'string' ? payload.body : '',
|
||||
state: state.state,
|
||||
requires_ack: requiresAck(category),
|
||||
created_at: event.timestamp,
|
||||
read_at: state.readAt,
|
||||
acked_at: state.ackedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export async function projectInboxFromDisk(projectRoot: string, beadId?: string, agentId?: string): Promise<AgentMessage[]> {
|
||||
const events = await readCoordEventsFromDisk(projectRoot);
|
||||
return projectInbox(events, beadId, agentId);
|
||||
}
|
||||
|
||||
export function isTakeoverAllowed(ownerLiveness: string, mode: TakeoverMode): boolean {
|
||||
if (ownerLiveness === 'active') return false;
|
||||
if (ownerLiveness === 'stale') return mode === 'stale';
|
||||
if (ownerLiveness === 'evicted') return mode === 'stale' || mode === 'evicted';
|
||||
return false;
|
||||
}
|
||||
|
||||
export function projectReservations(
|
||||
events: CoordProtocolEvent[],
|
||||
livenessMap: Record<string, string> = {},
|
||||
): ProjectedReservation[] {
|
||||
const activeByScope = new Map<string, ProjectedReservation>();
|
||||
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
|
||||
for (const event of sorted) {
|
||||
const type = event.data.event_type;
|
||||
if (type !== 'RESERVE' && type !== 'RELEASE' && type !== 'TAKEOVER') continue;
|
||||
|
||||
const rawScope = typeof event.data.scope === 'string' ? event.data.scope : '';
|
||||
if (!rawScope) continue;
|
||||
const normalizedScope = normalizePath(rawScope.replace(/\*$/, ''));
|
||||
const key = `${event.data.project_root}:${normalizedScope}`;
|
||||
|
||||
if (type === 'RELEASE') {
|
||||
activeByScope.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'RESERVE') {
|
||||
activeByScope.set(key, {
|
||||
scope: rawScope,
|
||||
normalized_scope: normalizedScope,
|
||||
agent_id: event.actor,
|
||||
bead_id: event.issue_id,
|
||||
state: 'active',
|
||||
created_at: event.timestamp,
|
||||
takeover_mode: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'TAKEOVER') {
|
||||
const mode = event.data.takeover_mode;
|
||||
if (mode !== 'stale' && mode !== 'evicted') continue;
|
||||
const existing = activeByScope.get(key);
|
||||
if (existing) {
|
||||
const ownerLiveness = livenessMap[existing.agent_id] ?? 'active';
|
||||
if (!isTakeoverAllowed(ownerLiveness, mode)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
activeByScope.set(key, {
|
||||
scope: rawScope,
|
||||
normalized_scope: normalizedScope,
|
||||
agent_id: event.actor,
|
||||
bead_id: event.issue_id,
|
||||
state: 'active',
|
||||
created_at: event.timestamp,
|
||||
takeover_mode: mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...activeByScope.values()];
|
||||
}
|
||||
|
||||
export function calculateReservationIncursions(reservations: ProjectedReservation[]): ProjectedReservationIncursion[] {
|
||||
const incursions: ProjectedReservationIncursion[] = [];
|
||||
const processedPairs = new Set<string>();
|
||||
|
||||
for (let i = 0; i < reservations.length; i++) {
|
||||
for (let j = i + 1; j < reservations.length; j++) {
|
||||
const left = reservations[i];
|
||||
const right = reservations[j];
|
||||
if (left.agent_id === right.agent_id) continue;
|
||||
|
||||
const overlap = classifyOverlap(left.scope, right.scope);
|
||||
if (overlap === 'disjoint') continue;
|
||||
|
||||
const key = [left.agent_id, right.agent_id].sort().join(':') + ':' + [left.scope, right.scope].sort().join('|');
|
||||
if (processedPairs.has(key)) continue;
|
||||
processedPairs.add(key);
|
||||
|
||||
incursions.push({
|
||||
scope: overlap === 'exact' ? left.scope : `${left.scope} ↔ ${right.scope}`,
|
||||
agents: [left.agent_id, right.agent_id],
|
||||
severity: overlap,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return incursions;
|
||||
}
|
||||
115
src/lib/coord-schema.ts
Normal file
115
src/lib/coord-schema.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
export const COORD_SCHEMA_VERSION = 'coord.v1' as const;
|
||||
export const COORD_EVENT_KIND = 'coord_event' as const;
|
||||
|
||||
export type CoordEventType =
|
||||
| 'SEND'
|
||||
| 'READ'
|
||||
| 'ACK'
|
||||
| 'RESERVE'
|
||||
| 'RELEASE'
|
||||
| 'TAKEOVER'
|
||||
| 'RESUME'
|
||||
| 'BLOCKED'
|
||||
| 'HANDOFF'
|
||||
| 'INCURSION';
|
||||
|
||||
export type TakeoverMode = 'stale' | 'evicted';
|
||||
|
||||
export interface CoordEventData {
|
||||
event_type: CoordEventType;
|
||||
event_id: string;
|
||||
project_root: string;
|
||||
payload: Record<string, unknown>;
|
||||
to_agent?: string;
|
||||
scope?: string;
|
||||
state?: 'unread' | 'read' | 'acked';
|
||||
event_ref?: string;
|
||||
takeover_mode?: TakeoverMode;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CoordEventEnvelope {
|
||||
version: typeof COORD_SCHEMA_VERSION;
|
||||
kind: typeof COORD_EVENT_KIND;
|
||||
issue_id: string;
|
||||
actor: string;
|
||||
timestamp: string;
|
||||
data: CoordEventData;
|
||||
}
|
||||
|
||||
export type CoordValidationResult =
|
||||
| { ok: true; value: CoordEventEnvelope }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isEventType(value: unknown): value is CoordEventType {
|
||||
if (!nonEmptyString(value)) return false;
|
||||
return (
|
||||
value === 'SEND' ||
|
||||
value === 'READ' ||
|
||||
value === 'ACK' ||
|
||||
value === 'RESERVE' ||
|
||||
value === 'RELEASE' ||
|
||||
value === 'TAKEOVER' ||
|
||||
value === 'RESUME' ||
|
||||
value === 'BLOCKED' ||
|
||||
value === 'HANDOFF' ||
|
||||
value === 'INCURSION'
|
||||
);
|
||||
}
|
||||
|
||||
function fail(error: string): CoordValidationResult {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
export function validateCoordEventEnvelope(input: unknown): CoordValidationResult {
|
||||
if (!isObject(input)) return fail('Envelope must be an object');
|
||||
|
||||
if (input.version !== COORD_SCHEMA_VERSION) {
|
||||
return fail(`version must be "${COORD_SCHEMA_VERSION}"`);
|
||||
}
|
||||
if (input.kind !== COORD_EVENT_KIND) {
|
||||
return fail(`kind must be "${COORD_EVENT_KIND}"`);
|
||||
}
|
||||
if (!nonEmptyString(input.issue_id)) return fail('issue_id is required');
|
||||
if (!nonEmptyString(input.actor)) return fail('actor is required');
|
||||
if (!nonEmptyString(input.timestamp)) return fail('timestamp is required');
|
||||
|
||||
if (!isObject(input.data)) return fail('data object is required');
|
||||
|
||||
const data = input.data;
|
||||
if (!isEventType(data.event_type)) return fail('data.event_type is invalid');
|
||||
if (!nonEmptyString(data.event_id)) return fail('data.event_id is required');
|
||||
if (!nonEmptyString(data.project_root)) return fail('data.project_root is required');
|
||||
if (!isObject(data.payload)) return fail('data.payload must be an object');
|
||||
|
||||
if ((data.event_type === 'READ' || data.event_type === 'ACK') && !nonEmptyString(data.event_ref)) {
|
||||
return fail('data.event_ref is required for READ/ACK');
|
||||
}
|
||||
|
||||
if (data.event_type === 'TAKEOVER') {
|
||||
if (!nonEmptyString(data.scope)) return fail('data.scope is required for TAKEOVER');
|
||||
if (data.takeover_mode !== 'stale' && data.takeover_mode !== 'evicted') {
|
||||
return fail('data.takeover_mode must be stale or evicted');
|
||||
}
|
||||
if (!nonEmptyString(data.reason)) return fail('data.reason is required for TAKEOVER');
|
||||
}
|
||||
|
||||
if (data.event_type === 'SEND') {
|
||||
if (!nonEmptyString(data.to_agent)) return fail('data.to_agent is required for SEND');
|
||||
if (data.state !== 'unread' && data.state !== 'read' && data.state !== 'acked') {
|
||||
return fail('data.state must be unread/read/acked for SEND');
|
||||
}
|
||||
if (!nonEmptyString(data.payload.subject)) return fail('data.payload.subject is required for SEND');
|
||||
if (!nonEmptyString(data.payload.body)) return fail('data.payload.body is required for SEND');
|
||||
}
|
||||
|
||||
return { ok: true, value: input as unknown as CoordEventEnvelope };
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | '
|
|||
interface MutationBasePayload {
|
||||
projectRoot: string;
|
||||
bdPath?: string;
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
export interface CreateMutationPayload extends MutationBasePayload {
|
||||
|
|
@ -155,6 +156,7 @@ function parseBasePayload(raw: unknown): MutationBasePayload {
|
|||
return {
|
||||
projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'),
|
||||
bdPath: asOptionalString(data.bdPath),
|
||||
actor: asOptionalString(data.actor),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +237,7 @@ function pushOptionalArg(args: string[], flag: string, value: string | undefined
|
|||
|
||||
function pushOptionalLabels(args: string[], labels: string[] | undefined): void {
|
||||
if (labels && labels.length > 0) {
|
||||
args.push('-l', labels.join(','));
|
||||
args.push('--set-labels', labels.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -267,7 +269,7 @@ export function buildBdMutationArgs(operation: MutationOperation, payload: Mutat
|
|||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
if (data.metadata) {
|
||||
args.push('--metadata', JSON.stringify(data.metadata));
|
||||
args.push(`--metadata=${JSON.stringify(data.metadata)}`);
|
||||
}
|
||||
args.push('--json');
|
||||
return args;
|
||||
|
|
@ -303,11 +305,12 @@ export async function executeMutation(
|
|||
deps: Partial<ExecuteMutationDeps> = {},
|
||||
): Promise<MutationResponse> {
|
||||
const runner = deps.runBdCommand ?? runBdCommand;
|
||||
const args = buildBdMutationArgs(operation, payload);
|
||||
const args = payload.actor
|
||||
? ['--actor', payload.actor, ...buildBdMutationArgs(operation, payload)]
|
||||
: buildBdMutationArgs(operation, payload);
|
||||
const command = await runner({
|
||||
projectRoot: payload.projectRoot,
|
||||
args,
|
||||
explicitBdPath: payload.bdPath,
|
||||
});
|
||||
|
||||
if (!command.success) {
|
||||
|
|
@ -317,7 +320,7 @@ export async function executeMutation(
|
|||
command,
|
||||
error: {
|
||||
classification: command.classification ?? 'unknown',
|
||||
message: command.error ?? (command.stderr || 'Mutation command failed.'),
|
||||
message: command.stderr || command.error || 'Mutation command failed.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
26
src/lib/project-root.ts
Normal file
26
src/lib/project-root.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import path from 'node:path';
|
||||
|
||||
function isWindowsAbsolute(input: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(input);
|
||||
}
|
||||
|
||||
function windowsToPosixMount(input: string): string {
|
||||
const drive = input[0].toLowerCase();
|
||||
const tail = input.slice(2).replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
return `/mnt/${drive}/${tail}`;
|
||||
}
|
||||
|
||||
export function normalizeProjectRootForRuntime(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
if (isWindowsAbsolute(trimmed)) {
|
||||
return path.resolve(windowsToPosixMount(trimmed));
|
||||
}
|
||||
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue