feat(logic): establish derived-activity engine and agent-session protocols
Today we reached a major architectural conclusion: project history shouldn't be stored, it should be derived. We rejected the overhead of a separate SQLite event store in favor of an O(N) snapshot-diffing engine that computes human-readable narratives directly from the issues.jsonl source of truth. Key Triumphs: - Implemented O(N) diffing algorithm in src/lib/snapshot-differ.ts that transforms raw JSONL into 16 distinct social event types. - Engineered a file-based persistence layer (src/lib/activity-persistence.ts) to solve the 'Next.js HMR Wiped My Memory' bug, ensuring project heartbeat survives server restarts. - Developed the agent-session data model that unifies Beads, Activity, and Cross-Agent Mail into a single 'Mission' context. Raw Honest Moment: We struggled for over an hour with 'missing history' before realizing that development-mode reloads were purging our in-memory buffers. The shift to a file-backed ring buffer was a reactive pivot that became a core project strength.
This commit is contained in:
parent
4f8f3006e9
commit
ab051952bd
12 changed files with 1923 additions and 27 deletions
37
src/lib/activity-persistence.ts
Normal file
37
src/lib/activity-persistence.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { ActivityEvent } from './activity';
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
export function activityFilePath(): string {
|
||||
return path.join(userProfileRoot(), '.beadboard', 'activity.json');
|
||||
}
|
||||
|
||||
export async function loadActivityHistory(): Promise<ActivityEvent[]> {
|
||||
const filePath = activityFilePath();
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
console.error('[ActivityPersistence] Failed to load history:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveActivityHistory(history: ActivityEvent[]): Promise<void> {
|
||||
const filePath = activityFilePath();
|
||||
try {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, JSON.stringify(history, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
console.error('[ActivityPersistence] Failed to save history:', error);
|
||||
}
|
||||
}
|
||||
75
src/lib/activity.ts
Normal file
75
src/lib/activity.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { BeadIssueWithProject } from './types';
|
||||
|
||||
/**
|
||||
* 16 transition types for timeline activity events,
|
||||
* as required by the bb-xhm.1 event model specification.
|
||||
*/
|
||||
export type ActivityEventKind =
|
||||
| 'created'
|
||||
| 'closed'
|
||||
| 'reopened'
|
||||
| 'status_changed'
|
||||
| 'priority_changed'
|
||||
| 'assignee_changed'
|
||||
| 'type_changed'
|
||||
| 'title_changed'
|
||||
| 'description_changed'
|
||||
| 'labels_changed'
|
||||
| 'dependency_added'
|
||||
| 'dependency_removed'
|
||||
| 'comment_added'
|
||||
| 'due_date_changed'
|
||||
| 'estimate_changed'
|
||||
| 'field_changed';
|
||||
|
||||
/**
|
||||
* Represents a discrete change or action derived from bead snapshots or interactions.
|
||||
*/
|
||||
export interface ActivityEvent {
|
||||
/** Unique identity for the event instance (likely UUID) */
|
||||
id: string;
|
||||
|
||||
/** The type of transition that occurred */
|
||||
kind: ActivityEventKind;
|
||||
|
||||
/** The issue this event belongs to */
|
||||
beadId: string;
|
||||
|
||||
/** Display title of the issue at the time of the event */
|
||||
beadTitle: string;
|
||||
|
||||
/** The project key this issue belongs to */
|
||||
projectId: string;
|
||||
|
||||
/** Human-readable project name */
|
||||
projectName: string;
|
||||
|
||||
/** ISO8601 timestamp of when the event was detected or recorded */
|
||||
timestamp: string;
|
||||
|
||||
/** The actor who performed the action (assignee, owner, or session ID) */
|
||||
actor: string | null;
|
||||
|
||||
/** Data payload describing the change */
|
||||
payload: {
|
||||
/** The specific field name that changed (for property updates) */
|
||||
field?: string;
|
||||
|
||||
/** The previous value before the transition */
|
||||
from?: any;
|
||||
|
||||
/** The new value after the transition */
|
||||
to?: any;
|
||||
|
||||
/** Optional context message (e.g. comment text or close reason) */
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A pair of snapshots used by the diffing engine to derive ActivityEvents.
|
||||
*/
|
||||
export interface SnapshotDiff {
|
||||
previous: BeadIssueWithProject | null;
|
||||
current: BeadIssueWithProject;
|
||||
}
|
||||
400
src/lib/agent-mail.ts
Normal file
400
src/lib/agent-mail.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { listAgents, showAgent, type AgentRecord } from './agent-registry';
|
||||
|
||||
const MESSAGE_CATEGORIES = ['HANDOFF', 'BLOCKED', 'DECISION', 'INFO'] as const;
|
||||
const MESSAGE_STATES = ['unread', 'read', 'acked'] as const;
|
||||
|
||||
export type MessageCategory = (typeof MESSAGE_CATEGORIES)[number];
|
||||
export type MessageState = (typeof MESSAGE_STATES)[number];
|
||||
export type MailCommandName = 'agent send' | 'agent inbox' | 'agent read' | 'agent ack';
|
||||
|
||||
export interface MailCommandError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MailCommandResponse<T> {
|
||||
ok: boolean;
|
||||
command: MailCommandName;
|
||||
data: T | null;
|
||||
error: MailCommandError | null;
|
||||
}
|
||||
|
||||
export interface AgentMessage {
|
||||
message_id: string;
|
||||
thread_id: string;
|
||||
bead_id: string;
|
||||
from_agent: string;
|
||||
to_agent: string;
|
||||
category: MessageCategory;
|
||||
subject: string;
|
||||
body: string;
|
||||
state: MessageState;
|
||||
requires_ack: boolean;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
acked_at: string | null;
|
||||
}
|
||||
|
||||
export interface SendAgentMessageInput {
|
||||
from: string;
|
||||
to: string;
|
||||
bead: string;
|
||||
category: MessageCategory;
|
||||
subject: string;
|
||||
body: string;
|
||||
thread?: string;
|
||||
}
|
||||
|
||||
export interface SendAgentMessageDeps {
|
||||
now: () => string;
|
||||
idGenerator: () => string;
|
||||
}
|
||||
|
||||
export interface InboxAgentMessagesInput {
|
||||
agent: string;
|
||||
state?: MessageState;
|
||||
bead?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface MessageActionInput {
|
||||
agent: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface MessageMutationDeps {
|
||||
now: () => string;
|
||||
}
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
function agentRoot(): string {
|
||||
return path.join(userProfileRoot(), '.beadboard', 'agent');
|
||||
}
|
||||
|
||||
function messagesRoot(): string {
|
||||
return path.join(agentRoot(), 'messages');
|
||||
}
|
||||
|
||||
function inboxFilePath(agentId: string): string {
|
||||
return path.join(messagesRoot(), `${agentId}.jsonl`);
|
||||
}
|
||||
|
||||
function indexDirectoryPath(): string {
|
||||
return path.join(messagesRoot(), 'index');
|
||||
}
|
||||
|
||||
function indexFilePath(messageId: string): string {
|
||||
return path.join(indexDirectoryPath(), `${messageId}.json`);
|
||||
}
|
||||
|
||||
function trimOrEmpty(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function success<T>(command: MailCommandName, data: T): MailCommandResponse<T> {
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
data,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function invalid(command: MailCommandName, code: string, message: string): MailCommandResponse<never> {
|
||||
return {
|
||||
ok: false,
|
||||
command,
|
||||
data: null,
|
||||
error: { code, message },
|
||||
};
|
||||
}
|
||||
|
||||
function isMessageCategory(value: string): value is MessageCategory {
|
||||
return MESSAGE_CATEGORIES.includes(value as MessageCategory);
|
||||
}
|
||||
|
||||
function isMessageState(value: string): value is MessageState {
|
||||
return MESSAGE_STATES.includes(value as MessageState);
|
||||
}
|
||||
|
||||
function requiresAck(category: MessageCategory): boolean {
|
||||
return category === 'HANDOFF' || category === 'BLOCKED';
|
||||
}
|
||||
|
||||
function defaultMessageId(nowIso: string): string {
|
||||
const seed = Math.random().toString(16).slice(2, 6);
|
||||
const compact = nowIso.replace(/[-:]/g, '').replace('.000Z', '').replace('T', '_');
|
||||
return `msg_${compact}_${seed}`;
|
||||
}
|
||||
|
||||
async function appendInboxMessage(agentId: string, message: AgentMessage): Promise<void> {
|
||||
const filePath = inboxFilePath(agentId);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.appendFile(filePath, `${JSON.stringify(message)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function writeMessageIndex(message: AgentMessage): Promise<void> {
|
||||
const filePath = indexFilePath(message.message_id);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(message, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function readMessageIndex(messageId: string): Promise<AgentMessage | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(indexFilePath(messageId), 'utf8');
|
||||
return JSON.parse(raw) as AgentMessage;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInboxMessages(agentId: string): Promise<AgentMessage[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(inboxFilePath(agentId), 'utf8');
|
||||
const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
const messages: AgentMessage[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as AgentMessage;
|
||||
const current = await readMessageIndex(parsed.message_id);
|
||||
messages.push(current ?? parsed);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return messages.sort((left, right) => right.created_at.localeCompare(left.created_at));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRegisteredAgent(agentId: string): Promise<AgentRecord | null> {
|
||||
const result = await showAgent({ agent: agentId });
|
||||
return result.ok ? result.data : null;
|
||||
}
|
||||
|
||||
export async function sendAgentMessage(
|
||||
input: SendAgentMessageInput,
|
||||
deps: Partial<SendAgentMessageDeps> = {},
|
||||
): Promise<MailCommandResponse<AgentMessage>> {
|
||||
const command: MailCommandName = 'agent send';
|
||||
|
||||
const from = trimOrEmpty(input.from);
|
||||
const to = trimOrEmpty(input.to);
|
||||
const beadId = trimOrEmpty(input.bead);
|
||||
const categoryRaw = trimOrEmpty(input.category);
|
||||
const subject = trimOrEmpty(input.subject);
|
||||
const body = trimOrEmpty(input.body);
|
||||
const threadId = trimOrEmpty(input.thread) || `bead:${beadId}`;
|
||||
|
||||
if (!from || !(await resolveRegisteredAgent(from))) {
|
||||
return invalid(command, 'UNKNOWN_SENDER', 'Sender agent is not registered.');
|
||||
}
|
||||
|
||||
if (!to) {
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', 'Recipient agent is required.');
|
||||
}
|
||||
|
||||
if (to !== 'broadcast' && !(await resolveRegisteredAgent(to))) {
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', 'Recipient agent is not registered.');
|
||||
}
|
||||
|
||||
if (!beadId) {
|
||||
return invalid(command, 'MISSING_BEAD_ID', 'Bead id is required.');
|
||||
}
|
||||
|
||||
if (!isMessageCategory(categoryRaw)) {
|
||||
return invalid(command, 'INVALID_CATEGORY', 'Category must be one of HANDOFF, BLOCKED, DECISION, INFO.');
|
||||
}
|
||||
|
||||
if (!subject || !body) {
|
||||
return invalid(command, 'INVALID_MESSAGE', 'Subject and body are required.');
|
||||
}
|
||||
|
||||
try {
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const generateId = deps.idGenerator ?? (() => defaultMessageId(now));
|
||||
const recipientIds =
|
||||
to === 'broadcast'
|
||||
? ((await listAgents({})).data ?? []).map((agent) => agent.agent_id).filter((agentId) => agentId !== from)
|
||||
: [to];
|
||||
|
||||
if (recipientIds.length === 0) {
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', 'No recipients available for broadcast.');
|
||||
}
|
||||
|
||||
let firstMessage: AgentMessage | null = null;
|
||||
|
||||
for (const recipientId of recipientIds) {
|
||||
const messageId = recipientIds.length === 1 ? generateId() : `${generateId()}_${recipientId}`;
|
||||
const message: AgentMessage = {
|
||||
message_id: messageId,
|
||||
thread_id: threadId,
|
||||
bead_id: beadId,
|
||||
from_agent: from,
|
||||
to_agent: recipientId,
|
||||
category: categoryRaw,
|
||||
subject,
|
||||
body,
|
||||
state: 'unread',
|
||||
requires_ack: requiresAck(categoryRaw),
|
||||
created_at: now,
|
||||
read_at: null,
|
||||
acked_at: null,
|
||||
};
|
||||
|
||||
await appendInboxMessage(recipientId, message);
|
||||
await writeMessageIndex(message);
|
||||
if (!firstMessage) {
|
||||
firstMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
return success(command, firstMessage as AgentMessage);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to send message.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function inboxAgentMessages(
|
||||
input: InboxAgentMessagesInput,
|
||||
): Promise<MailCommandResponse<AgentMessage[]>> {
|
||||
const command: MailCommandName = 'agent inbox';
|
||||
|
||||
const agentId = trimOrEmpty(input.agent);
|
||||
const state = trimOrEmpty(input.state);
|
||||
const beadId = trimOrEmpty(input.bead);
|
||||
const limit = input.limit === undefined ? 50 : input.limit;
|
||||
|
||||
if (!agentId || !(await resolveRegisteredAgent(agentId))) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
if (state && !isMessageState(state)) {
|
||||
return invalid(command, 'INVALID_STATE', 'State must be one of unread, read, acked.');
|
||||
}
|
||||
|
||||
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
|
||||
return invalid(command, 'INVALID_LIMIT', 'Limit must be between 1 and 500.');
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = await loadInboxMessages(agentId);
|
||||
const filtered = messages
|
||||
.filter((message) => {
|
||||
if (state && message.state !== state) {
|
||||
return false;
|
||||
}
|
||||
if (beadId && message.bead_id !== beadId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return success(command, filtered);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to load inbox.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAgentMessage(
|
||||
input: MessageActionInput,
|
||||
deps: Partial<MessageMutationDeps> = {},
|
||||
): Promise<MailCommandResponse<AgentMessage>> {
|
||||
const command: MailCommandName = 'agent read';
|
||||
|
||||
const agentId = trimOrEmpty(input.agent);
|
||||
const messageId = trimOrEmpty(input.message);
|
||||
|
||||
if (!agentId || !(await resolveRegisteredAgent(agentId))) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
if (!messageId) {
|
||||
return invalid(command, 'MESSAGE_NOT_FOUND', 'Message id is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await readMessageIndex(messageId);
|
||||
if (!existing) {
|
||||
return invalid(command, 'MESSAGE_NOT_FOUND', 'Message does not exist.');
|
||||
}
|
||||
|
||||
if (existing.to_agent !== agentId) {
|
||||
return invalid(command, 'READ_FORBIDDEN', 'Only the recipient may read this message.');
|
||||
}
|
||||
|
||||
if (existing.state === 'unread') {
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const updated: AgentMessage = {
|
||||
...existing,
|
||||
state: 'read',
|
||||
read_at: existing.read_at ?? now,
|
||||
};
|
||||
await writeMessageIndex(updated);
|
||||
return success(command, updated);
|
||||
}
|
||||
|
||||
return success(command, existing);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to read message.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function ackAgentMessage(
|
||||
input: MessageActionInput,
|
||||
deps: Partial<MessageMutationDeps> = {},
|
||||
): Promise<MailCommandResponse<AgentMessage>> {
|
||||
const command: MailCommandName = 'agent ack';
|
||||
|
||||
const agentId = trimOrEmpty(input.agent);
|
||||
const messageId = trimOrEmpty(input.message);
|
||||
|
||||
if (!agentId || !(await resolveRegisteredAgent(agentId))) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
if (!messageId) {
|
||||
return invalid(command, 'MESSAGE_NOT_FOUND', 'Message id is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await readMessageIndex(messageId);
|
||||
if (!existing) {
|
||||
return invalid(command, 'MESSAGE_NOT_FOUND', 'Message does not exist.');
|
||||
}
|
||||
|
||||
if (existing.to_agent !== agentId) {
|
||||
return invalid(command, 'ACK_FORBIDDEN', 'Only the recipient may acknowledge this message.');
|
||||
}
|
||||
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const updated: AgentMessage = {
|
||||
...existing,
|
||||
state: 'acked',
|
||||
read_at: existing.read_at ?? now,
|
||||
acked_at: existing.acked_at ?? now,
|
||||
};
|
||||
|
||||
await writeMessageIndex(updated);
|
||||
return success(command, updated);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to acknowledge message.');
|
||||
}
|
||||
}
|
||||
255
src/lib/agent-registry.ts
Normal file
255
src/lib/agent-registry.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show';
|
||||
|
||||
export interface AgentCommandError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AgentCommandResponse<T> {
|
||||
ok: boolean;
|
||||
command: AgentCommandName;
|
||||
data: T | null;
|
||||
error: AgentCommandError | null;
|
||||
}
|
||||
|
||||
export interface AgentRecord {
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RegisterAgentInput {
|
||||
name: string;
|
||||
display?: string;
|
||||
role: string;
|
||||
forceUpdate?: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterAgentDeps {
|
||||
now: () => string;
|
||||
}
|
||||
|
||||
export interface ListAgentsInput {
|
||||
role?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ShowAgentInput {
|
||||
agent: string;
|
||||
}
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
export function agentRegistryRoot(): string {
|
||||
return path.join(userProfileRoot(), '.beadboard', 'agent');
|
||||
}
|
||||
|
||||
export function agentsDirectoryPath(): string {
|
||||
return path.join(agentRegistryRoot(), 'agents');
|
||||
}
|
||||
|
||||
export function agentFilePath(agentId: string): string {
|
||||
return path.join(agentsDirectoryPath(), `${agentId}.json`);
|
||||
}
|
||||
|
||||
function trimOrEmpty(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function invalid(command: AgentCommandName, code: string, message: string): AgentCommandResponse<never> {
|
||||
return {
|
||||
ok: false,
|
||||
command,
|
||||
data: null,
|
||||
error: { code, message },
|
||||
};
|
||||
}
|
||||
|
||||
function success<T>(command: AgentCommandName, data: T): AgentCommandResponse<T> {
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
data,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateAgentId(value: string): AgentCommandError | null {
|
||||
if (!AGENT_ID_PATTERN.test(value) || value.length < 3 || value.length > 48) {
|
||||
return {
|
||||
code: 'INVALID_AGENT_ID',
|
||||
message: 'Agent id must match ^[a-z0-9]+(?:-[a-z0-9]+)*$ and be 3..48 characters.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateRole(value: string): AgentCommandError | null {
|
||||
if (!value) {
|
||||
return {
|
||||
code: 'INVALID_ROLE',
|
||||
message: 'Role is required.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readAgent(agentId: string): Promise<AgentRecord | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(agentFilePath(agentId), 'utf8');
|
||||
const parsed = JSON.parse(raw) as AgentRecord;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAgent(record: AgentRecord): Promise<void> {
|
||||
const filePath = agentFilePath(record.agent_id);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function loadAllAgents(): Promise<AgentRecord[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(agentsDirectoryPath(), { withFileTypes: true });
|
||||
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
|
||||
|
||||
const agents: AgentRecord[] = [];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(agentsDirectoryPath(), file.name);
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
agents.push(JSON.parse(raw) as AgentRecord);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return agents.sort((left, right) => left.agent_id.localeCompare(right.agent_id));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerAgent(
|
||||
input: RegisterAgentInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent register';
|
||||
const agentId = trimOrEmpty(input.name);
|
||||
const role = trimOrEmpty(input.role);
|
||||
const display = trimOrEmpty(input.display) || agentId;
|
||||
|
||||
const agentIdError = validateAgentId(agentId);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
const roleError = validateRole(role);
|
||||
if (roleError) {
|
||||
return invalid(command, roleError.code, roleError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await readAgent(agentId);
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
|
||||
if (existing && !input.forceUpdate) {
|
||||
return invalid(command, 'DUPLICATE_AGENT_ID', 'Agent is already registered. Use --force-update to change display/role.');
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const updated: AgentRecord = {
|
||||
...existing,
|
||||
display_name: display || existing.display_name,
|
||||
role,
|
||||
last_seen_at: now,
|
||||
version: existing.version + 1,
|
||||
};
|
||||
await writeAgent(updated);
|
||||
return success(command, updated);
|
||||
}
|
||||
|
||||
const created: AgentRecord = {
|
||||
agent_id: agentId,
|
||||
display_name: display,
|
||||
role,
|
||||
status: 'idle',
|
||||
created_at: now,
|
||||
last_seen_at: now,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
await writeAgent(created);
|
||||
return success(command, created);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to register agent.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgents(input: ListAgentsInput): Promise<AgentCommandResponse<AgentRecord[]>> {
|
||||
const command: AgentCommandName = 'agent list';
|
||||
const role = trimOrEmpty(input.role);
|
||||
const status = trimOrEmpty(input.status);
|
||||
|
||||
try {
|
||||
const agents = await loadAllAgents();
|
||||
const filtered = agents.filter((agent) => {
|
||||
if (role && agent.role !== role) {
|
||||
return false;
|
||||
}
|
||||
if (status && agent.status !== status) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return success(command, filtered);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to list agents.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function showAgent(input: ShowAgentInput): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent show';
|
||||
const agentId = trimOrEmpty(input.agent);
|
||||
|
||||
const agentIdError = validateAgentId(agentId);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await readAgent(agentId);
|
||||
if (!agent) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
return success(command, agent);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to load agent.');
|
||||
}
|
||||
}
|
||||
427
src/lib/agent-reservations.ts
Normal file
427
src/lib/agent-reservations.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { showAgent } from './agent-registry';
|
||||
import type { AgentMessage } from './agent-mail';
|
||||
|
||||
const MIN_TTL_MINUTES = 5;
|
||||
const MAX_TTL_MINUTES = 1440;
|
||||
const DEFAULT_TTL_MINUTES = 120;
|
||||
|
||||
export type ReservationCommandName = 'agent reserve' | 'agent release' | 'agent status';
|
||||
export type ReservationState = 'active' | 'released' | 'expired';
|
||||
|
||||
export interface ReservationCommandError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ReservationCommandResponse<T> {
|
||||
ok: boolean;
|
||||
command: ReservationCommandName;
|
||||
data: T | null;
|
||||
error: ReservationCommandError | null;
|
||||
}
|
||||
|
||||
export interface AgentReservation {
|
||||
reservation_id: string;
|
||||
scope: string;
|
||||
agent_id: string;
|
||||
bead_id: string;
|
||||
state: ReservationState;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
released_at: string | null;
|
||||
}
|
||||
|
||||
export interface ReserveAgentScopeInput {
|
||||
agent: string;
|
||||
scope: string;
|
||||
bead: string;
|
||||
ttl?: number;
|
||||
takeoverStale?: boolean;
|
||||
}
|
||||
|
||||
export interface ReserveAgentScopeDeps {
|
||||
now: () => string;
|
||||
idGenerator: () => string;
|
||||
}
|
||||
|
||||
export interface ReleaseAgentReservationInput {
|
||||
agent: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface StatusAgentReservationsInput {
|
||||
bead?: string;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
export interface StatusAgentReservationsData {
|
||||
reservations: AgentReservation[];
|
||||
unacked_required_messages: AgentMessage[];
|
||||
summary: {
|
||||
active: number;
|
||||
released: number;
|
||||
expired: number;
|
||||
unacked_required_messages: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface MutationDeps {
|
||||
now: () => string;
|
||||
}
|
||||
|
||||
interface ActiveReservationsFile {
|
||||
reservations: AgentReservation[];
|
||||
}
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
function agentRoot(): string {
|
||||
return path.join(userProfileRoot(), '.beadboard', 'agent');
|
||||
}
|
||||
|
||||
function reservationsRoot(): string {
|
||||
return path.join(agentRoot(), 'reservations');
|
||||
}
|
||||
|
||||
function activeReservationsPath(): string {
|
||||
return path.join(reservationsRoot(), 'active.json');
|
||||
}
|
||||
|
||||
function reservationHistoryPath(): string {
|
||||
return path.join(reservationsRoot(), 'history.jsonl');
|
||||
}
|
||||
|
||||
function messageIndexDirectoryPath(): string {
|
||||
return path.join(agentRoot(), 'messages', 'index');
|
||||
}
|
||||
|
||||
function trimOrEmpty(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function success<T>(command: ReservationCommandName, data: T): ReservationCommandResponse<T> {
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
data,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function invalid(command: ReservationCommandName, code: string, message: string): ReservationCommandResponse<never> {
|
||||
return {
|
||||
ok: false,
|
||||
command,
|
||||
data: null,
|
||||
error: { code, message },
|
||||
};
|
||||
}
|
||||
|
||||
function defaultReservationId(nowIso: string): string {
|
||||
const seed = Math.random().toString(16).slice(2, 6);
|
||||
const compact = nowIso.replace(/[-:]/g, '').replace('.000Z', '').replace('T', '_');
|
||||
return `res_${compact}_${seed}`;
|
||||
}
|
||||
|
||||
function addMinutes(iso: string, minutes: number): string {
|
||||
const base = Date.parse(iso);
|
||||
const next = new Date(base + minutes * 60_000);
|
||||
return next.toISOString();
|
||||
}
|
||||
|
||||
function isExpired(reservation: AgentReservation, nowIso: string): boolean {
|
||||
return reservation.expires_at.localeCompare(nowIso) <= 0;
|
||||
}
|
||||
|
||||
function toActiveFile(reservations: AgentReservation[]): ActiveReservationsFile {
|
||||
return { reservations };
|
||||
}
|
||||
|
||||
function parseActiveFile(raw: string): AgentReservation[] {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed as AgentReservation[];
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as ActiveReservationsFile).reservations)) {
|
||||
return (parsed as ActiveReservationsFile).reservations;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function readActiveReservations(): Promise<AgentReservation[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(activeReservationsPath(), 'utf8');
|
||||
return parseActiveFile(raw);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicWriteJson(filePath: string, payload: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
const tempFile = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||
await fs.writeFile(tempFile, payload, 'utf8');
|
||||
await fs.rename(tempFile, filePath);
|
||||
}
|
||||
|
||||
async function writeActiveReservations(reservations: AgentReservation[]): Promise<void> {
|
||||
const snapshot = `${JSON.stringify(toActiveFile(reservations), null, 2)}\n`;
|
||||
await atomicWriteJson(activeReservationsPath(), snapshot);
|
||||
}
|
||||
|
||||
async function appendReservationHistory(reservation: AgentReservation): Promise<void> {
|
||||
const historyPath = reservationHistoryPath();
|
||||
await fs.mkdir(path.dirname(historyPath), { recursive: true });
|
||||
await fs.appendFile(historyPath, `${JSON.stringify(reservation)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function readRequiredAckMessages(): Promise<AgentMessage[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(messageIndexDirectoryPath(), { withFileTypes: true });
|
||||
const messages: AgentMessage[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(messageIndexDirectoryPath(), entry.name);
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as AgentMessage;
|
||||
if (parsed.requires_ack && !parsed.acked_at) {
|
||||
messages.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return messages.sort((left, right) => right.created_at.localeCompare(left.created_at));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRegisteredAgent(agentId: string): Promise<boolean> {
|
||||
const result = await showAgent({ agent: agentId });
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
async function sweepExpiredReservations(nowIso: string): Promise<{ active: AgentReservation[]; expired: number }> {
|
||||
const reservations = await readActiveReservations();
|
||||
const active: AgentReservation[] = [];
|
||||
const expired: AgentReservation[] = [];
|
||||
|
||||
for (const reservation of reservations) {
|
||||
if (isExpired(reservation, nowIso)) {
|
||||
expired.push({ ...reservation, state: 'expired' });
|
||||
} else {
|
||||
active.push(reservation);
|
||||
}
|
||||
}
|
||||
|
||||
if (expired.length > 0) {
|
||||
await writeActiveReservations(active);
|
||||
for (const reservation of expired) {
|
||||
await appendReservationHistory(reservation);
|
||||
}
|
||||
}
|
||||
|
||||
return { active, expired: expired.length };
|
||||
}
|
||||
|
||||
export async function reserveAgentScope(
|
||||
input: ReserveAgentScopeInput,
|
||||
deps: Partial<ReserveAgentScopeDeps> = {},
|
||||
): Promise<ReservationCommandResponse<AgentReservation>> {
|
||||
const command: ReservationCommandName = 'agent reserve';
|
||||
|
||||
const agentId = trimOrEmpty(input.agent);
|
||||
const scope = trimOrEmpty(input.scope);
|
||||
const beadId = trimOrEmpty(input.bead);
|
||||
const ttlMinutes = input.ttl ?? DEFAULT_TTL_MINUTES;
|
||||
|
||||
if (!agentId || !(await resolveRegisteredAgent(agentId))) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
if (!scope || !beadId) {
|
||||
return invalid(command, 'INVALID_ARGS', 'Scope and bead id are required.');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(ttlMinutes) || ttlMinutes < MIN_TTL_MINUTES || ttlMinutes > MAX_TTL_MINUTES) {
|
||||
return invalid(command, 'INVALID_ARGS', `TTL must be an integer between ${MIN_TTL_MINUTES} and ${MAX_TTL_MINUTES} minutes.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const reservations = await readActiveReservations();
|
||||
const existing = reservations.find((reservation) => reservation.scope === scope);
|
||||
|
||||
if (existing) {
|
||||
if (!isExpired(existing, now)) {
|
||||
return invalid(command, 'RESERVATION_CONFLICT', `Scope is already reserved by ${existing.agent_id}.`);
|
||||
}
|
||||
|
||||
if (!input.takeoverStale) {
|
||||
return invalid(command, 'RESERVATION_STALE_FOUND', 'An expired reservation exists. Re-run with --takeover-stale.');
|
||||
}
|
||||
|
||||
const withoutExisting = reservations.filter((reservation) => reservation.reservation_id !== existing.reservation_id);
|
||||
await writeActiveReservations(withoutExisting);
|
||||
await appendReservationHistory({ ...existing, state: 'expired' });
|
||||
|
||||
const generateId = deps.idGenerator ?? (() => defaultReservationId(now));
|
||||
const created: AgentReservation = {
|
||||
reservation_id: generateId(),
|
||||
scope,
|
||||
agent_id: agentId,
|
||||
bead_id: beadId,
|
||||
state: 'active',
|
||||
created_at: now,
|
||||
expires_at: addMinutes(now, ttlMinutes),
|
||||
released_at: null,
|
||||
};
|
||||
|
||||
await writeActiveReservations([...withoutExisting, created]);
|
||||
return success(command, created);
|
||||
}
|
||||
|
||||
const generateId = deps.idGenerator ?? (() => defaultReservationId(now));
|
||||
const created: AgentReservation = {
|
||||
reservation_id: generateId(),
|
||||
scope,
|
||||
agent_id: agentId,
|
||||
bead_id: beadId,
|
||||
state: 'active',
|
||||
created_at: now,
|
||||
expires_at: addMinutes(now, ttlMinutes),
|
||||
released_at: null,
|
||||
};
|
||||
|
||||
await writeActiveReservations([...reservations, created]);
|
||||
return success(command, created);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to reserve scope.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseAgentReservation(
|
||||
input: ReleaseAgentReservationInput,
|
||||
deps: Partial<MutationDeps> = {},
|
||||
): Promise<ReservationCommandResponse<AgentReservation>> {
|
||||
const command: ReservationCommandName = 'agent release';
|
||||
|
||||
const agentId = trimOrEmpty(input.agent);
|
||||
const scope = trimOrEmpty(input.scope);
|
||||
|
||||
if (!agentId || !(await resolveRegisteredAgent(agentId))) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
if (!scope) {
|
||||
return invalid(command, 'INVALID_ARGS', 'Scope is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const reservations = await readActiveReservations();
|
||||
const existing = reservations.find((reservation) => reservation.scope === scope);
|
||||
|
||||
if (!existing || isExpired(existing, now)) {
|
||||
if (existing && isExpired(existing, now)) {
|
||||
const remaining = reservations.filter((reservation) => reservation.reservation_id !== existing.reservation_id);
|
||||
await writeActiveReservations(remaining);
|
||||
await appendReservationHistory({ ...existing, state: 'expired' });
|
||||
}
|
||||
return invalid(command, 'RESERVATION_NOT_FOUND', 'No active reservation exists for this scope.');
|
||||
}
|
||||
|
||||
if (existing.agent_id !== agentId) {
|
||||
return invalid(command, 'RELEASE_FORBIDDEN', 'Only the reservation owner may release this scope.');
|
||||
}
|
||||
|
||||
const released: AgentReservation = {
|
||||
...existing,
|
||||
state: 'released',
|
||||
released_at: now,
|
||||
};
|
||||
|
||||
const remaining = reservations.filter((reservation) => reservation.reservation_id !== existing.reservation_id);
|
||||
await writeActiveReservations(remaining);
|
||||
await appendReservationHistory(released);
|
||||
|
||||
return success(command, released);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to release reservation.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function statusAgentReservations(
|
||||
input: StatusAgentReservationsInput,
|
||||
deps: Partial<MutationDeps> = {},
|
||||
): Promise<ReservationCommandResponse<StatusAgentReservationsData>> {
|
||||
const command: ReservationCommandName = 'agent status';
|
||||
|
||||
const beadId = trimOrEmpty(input.bead);
|
||||
const agentId = trimOrEmpty(input.agent);
|
||||
|
||||
if (agentId && !(await resolveRegisteredAgent(agentId))) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
try {
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const swept = await sweepExpiredReservations(now);
|
||||
|
||||
const reservations = swept.active.filter((reservation) => {
|
||||
if (beadId && reservation.bead_id !== beadId) {
|
||||
return false;
|
||||
}
|
||||
if (agentId && reservation.agent_id !== agentId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const unackedRequiredMessages = (await readRequiredAckMessages()).filter((message) => {
|
||||
if (beadId && message.bead_id !== beadId) {
|
||||
return false;
|
||||
}
|
||||
if (agentId && message.to_agent !== agentId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return success(command, {
|
||||
reservations,
|
||||
unacked_required_messages: unackedRequiredMessages,
|
||||
summary: {
|
||||
active: reservations.length,
|
||||
released: 0,
|
||||
expired: swept.expired,
|
||||
unacked_required_messages: unackedRequiredMessages.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to load reservation status.');
|
||||
}
|
||||
}
|
||||
197
src/lib/agent-sessions.ts
Normal file
197
src/lib/agent-sessions.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import type { ActivityEvent } from './activity';
|
||||
import type { BeadIssue } from './types';
|
||||
import { listAgents } from './agent-registry';
|
||||
import { inboxAgentMessages, type AgentMessage } from './agent-mail';
|
||||
|
||||
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale';
|
||||
|
||||
export interface SessionTaskCard {
|
||||
id: string;
|
||||
title: string;
|
||||
epicId: string;
|
||||
status: BeadIssue['status'];
|
||||
sessionState: AgentSessionState;
|
||||
owner: string | null;
|
||||
lastActor: string | null;
|
||||
lastActivityAt: string | null;
|
||||
communication: {
|
||||
unreadCount: number;
|
||||
pendingRequired: boolean;
|
||||
latestSnippet: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EpicBucket {
|
||||
epic: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: BeadIssue['status'];
|
||||
};
|
||||
tasks: SessionTaskCard[];
|
||||
}
|
||||
|
||||
export interface CommunicationSummary {
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
// 24 hours in ms
|
||||
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 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 interface AgentMetrics {
|
||||
activeTasks: number;
|
||||
completedTasks: number;
|
||||
handoffsSent: number;
|
||||
recentWins: { id: string; title: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates real-time metrics for a specific agent based on current issues and history.
|
||||
*/
|
||||
export async function getAgentMetrics(
|
||||
agentId: string,
|
||||
issues: BeadIssue[],
|
||||
activity: ActivityEvent[]
|
||||
): Promise<AgentMetrics> {
|
||||
const agentIssues = issues.filter(i => i.assignee === agentId);
|
||||
const activeTasks = agentIssues.filter(i => i.status !== 'closed').length;
|
||||
|
||||
// Tasks closed by this agent
|
||||
const completedTasks = issues.filter(i => i.status === 'closed' && i.assignee === agentId).length;
|
||||
|
||||
// Count handoffs (e.g. status changes or specific handoff events)
|
||||
const handoffsSent = activity.filter(e => e.actor === agentId && e.kind === 'status_changed').length;
|
||||
|
||||
const recentWins = issues
|
||||
.filter(i => i.status === 'closed' && i.assignee === agentId)
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
.slice(0, 3)
|
||||
.map(i => ({ id: i.id, title: i.title }));
|
||||
|
||||
return {
|
||||
activeTasks,
|
||||
completedTasks,
|
||||
handoffsSent,
|
||||
recentWins
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionTaskFeed(
|
||||
issues: BeadIssue[],
|
||||
activity: ActivityEvent[],
|
||||
communicationSummary: CommunicationSummary
|
||||
): EpicBucket[] {
|
||||
const epics = issues.filter(i => i.issue_type === 'epic');
|
||||
const tasks = issues.filter(i => i.issue_type !== 'epic');
|
||||
const epicMap = new Map<string, EpicBucket>();
|
||||
|
||||
// Initialize buckets
|
||||
epics.forEach(epic => {
|
||||
epicMap.set(epic.id, {
|
||||
epic: { id: epic.id, title: epic.title, status: epic.status },
|
||||
tasks: []
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to find the actual epic ID even if parent is a task
|
||||
const findRootEpicId = (task: BeadIssue): string | undefined => {
|
||||
// 1. Explicit parent dependency
|
||||
const parentDep = task.dependencies.find(d => d.type === 'parent');
|
||||
if (parentDep) {
|
||||
// If the parent is an epic, we found it
|
||||
if (epicMap.has(parentDep.target)) return parentDep.target;
|
||||
// If parent is a task, recurse
|
||||
const parentIssue = issues.find(i => i.id === parentDep.target);
|
||||
if (parentIssue) return findRootEpicId(parentIssue);
|
||||
}
|
||||
|
||||
// 2. Convention fallback: root prefix (bb-u6f.3.1 -> bb-u6f)
|
||||
const rootId = task.id.split('.')[0];
|
||||
if (epicMap.has(rootId)) return rootId;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Helper to find latest activity
|
||||
const getActivityForTask = (taskId: string) => {
|
||||
return activity
|
||||
.filter(e => e.beadId === taskId)
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0] ?? null;
|
||||
};
|
||||
|
||||
const deriveState = (task: BeadIssue, lastEvent: ActivityEvent | null, pendingRequired: boolean): AgentSessionState => {
|
||||
if (task.status === 'closed') return 'completed';
|
||||
if (task.status === 'blocked' || pendingRequired) return 'needs_input';
|
||||
|
||||
// Check staleness
|
||||
const lastActiveTime = lastEvent ? new Date(lastEvent.timestamp).getTime() : new Date(task.updated_at).getTime();
|
||||
if (Date.now() - lastActiveTime > STALE_THRESHOLD_MS) {
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
if (task.status === 'in_progress') return 'active';
|
||||
|
||||
return 'deciding';
|
||||
};
|
||||
|
||||
tasks.forEach(task => {
|
||||
let epicId = findRootEpicId(task);
|
||||
let bucket = epicId ? epicMap.get(epicId) : undefined;
|
||||
|
||||
if (!bucket) {
|
||||
if (!epicMap.has('uncategorized')) {
|
||||
epicMap.set('uncategorized', {
|
||||
epic: { id: 'uncategorized', title: 'Uncategorized', status: 'open' },
|
||||
tasks: []
|
||||
});
|
||||
}
|
||||
bucket = epicMap.get('uncategorized')!;
|
||||
epicId = 'uncategorized';
|
||||
}
|
||||
|
||||
const lastEvent = getActivityForTask(task.id);
|
||||
const taskMessages = communicationSummary.messages.filter(m => m.bead_id === task.id);
|
||||
const unreadCount = taskMessages.filter(m => m.state === 'unread').length;
|
||||
const pendingRequired = taskMessages.some(m => m.requires_ack && m.state !== 'acked');
|
||||
const latestMessage = taskMessages.sort((a, b) => b.created_at.localeCompare(a.created_at))[0];
|
||||
|
||||
const sessionState = deriveState(task, lastEvent, pendingRequired);
|
||||
|
||||
const card: SessionTaskCard = {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
epicId: epicId!,
|
||||
status: task.status,
|
||||
sessionState,
|
||||
owner: task.assignee,
|
||||
lastActor: lastEvent?.actor ?? latestMessage?.from_agent ?? null,
|
||||
lastActivityAt: lastEvent?.timestamp ?? latestMessage?.created_at ?? task.updated_at,
|
||||
communication: {
|
||||
unreadCount,
|
||||
pendingRequired,
|
||||
latestSnippet: latestMessage ? latestMessage.subject : null
|
||||
}
|
||||
};
|
||||
|
||||
bucket.tasks.push(card);
|
||||
});
|
||||
|
||||
return Array.from(epicMap.values()).filter(b => b.tasks.length > 0 || b.epic.id !== 'uncategorized');
|
||||
}
|
||||
38
src/lib/read-interactions.ts
Normal file
38
src/lib/read-interactions.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { runBdCommand } from './bridge';
|
||||
|
||||
export interface BeadInteraction {
|
||||
id: string;
|
||||
bead_id: string;
|
||||
actor: string;
|
||||
kind: 'comment';
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export async function readInteractionsViaBd(projectRoot: string, beadId: string): Promise<BeadInteraction[]> {
|
||||
const command = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['comments', beadId, '--json'],
|
||||
});
|
||||
|
||||
if (!command.success) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(command.stdout);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.map(c => ({
|
||||
id: String(c.id),
|
||||
bead_id: beadId,
|
||||
actor: c.author || 'unknown',
|
||||
kind: 'comment',
|
||||
text: c.text || '',
|
||||
timestamp: c.created_at || new Date().toISOString()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[Interactions] Failed to parse:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
145
src/lib/snapshot-differ.ts
Normal file
145
src/lib/snapshot-differ.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import type { ActivityEvent, ActivityEventKind } from './activity';
|
||||
import type { BeadIssueWithProject, BeadDependency } from './types';
|
||||
|
||||
/**
|
||||
* Compares two snapshots of BeadIssueWithProject arrays and returns a list of ActivityEvents
|
||||
* representing the differences.
|
||||
*/
|
||||
export function diffSnapshots(
|
||||
previous: BeadIssueWithProject[] | null,
|
||||
current: BeadIssueWithProject[]
|
||||
): ActivityEvent[] {
|
||||
const events: ActivityEvent[] = [];
|
||||
const prevMap = new Map<string, BeadIssueWithProject>();
|
||||
if (previous) {
|
||||
previous.forEach((issue) => prevMap.set(issue.id, issue));
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
current.forEach((curr) => {
|
||||
const prev = prevMap.get(curr.id);
|
||||
|
||||
if (!prev) {
|
||||
// 1. Issue Created
|
||||
events.push(createEvent('created', curr, now));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Status Changes
|
||||
if (prev.status !== curr.status) {
|
||||
if (curr.status === 'closed') {
|
||||
events.push(createEvent('closed', curr, now, { from: prev.status, to: 'closed', message: curr.close_reason || undefined }));
|
||||
} else if (prev.status === 'closed') {
|
||||
events.push(createEvent('reopened', curr, now, { from: 'closed', to: curr.status }));
|
||||
} else {
|
||||
events.push(createEvent('status_changed', curr, now, { field: 'status', from: prev.status, to: curr.status }));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Property Changes
|
||||
if (prev.title !== curr.title) {
|
||||
events.push(createEvent('title_changed', curr, now, { field: 'title', from: prev.title, to: curr.title }));
|
||||
}
|
||||
|
||||
if (prev.priority !== curr.priority) {
|
||||
events.push(createEvent('priority_changed', curr, now, { field: 'priority', from: prev.priority, to: curr.priority }));
|
||||
}
|
||||
|
||||
if (prev.description !== curr.description) {
|
||||
events.push(createEvent('description_changed', curr, now, { field: 'description', from: prev.description, to: curr.description }));
|
||||
}
|
||||
|
||||
if (prev.issue_type !== curr.issue_type) {
|
||||
events.push(createEvent('type_changed', curr, now, { field: 'issue_type', from: prev.issue_type, to: curr.issue_type }));
|
||||
}
|
||||
|
||||
if (prev.assignee !== curr.assignee) {
|
||||
events.push(createEvent('assignee_changed', curr, now, { field: 'assignee', from: prev.assignee, to: curr.assignee }));
|
||||
}
|
||||
|
||||
if (prev.due_at !== curr.due_at) {
|
||||
events.push(createEvent('due_date_changed', curr, now, { field: 'due_at', from: prev.due_at, to: curr.due_at }));
|
||||
}
|
||||
|
||||
if (prev.estimated_minutes !== curr.estimated_minutes) {
|
||||
events.push(createEvent('estimate_changed', curr, now, { field: 'estimated_minutes', from: prev.estimated_minutes, to: curr.estimated_minutes }));
|
||||
}
|
||||
|
||||
// 4. Collection Changes (Labels)
|
||||
if (!areArraysEqual(prev.labels, curr.labels)) {
|
||||
events.push(createEvent('labels_changed', curr, now, {
|
||||
field: 'labels',
|
||||
from: prev.labels.join(','),
|
||||
to: curr.labels.join(',')
|
||||
}));
|
||||
}
|
||||
|
||||
// 5. Collection Changes (Dependencies)
|
||||
diffDependencies(prev.dependencies, curr.dependencies).forEach(kindAndTarget => {
|
||||
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target }));
|
||||
});
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create an ActivityEvent with standard fields.
|
||||
*/
|
||||
function createEvent(
|
||||
kind: ActivityEventKind,
|
||||
issue: BeadIssueWithProject,
|
||||
timestamp: string,
|
||||
payload: ActivityEvent['payload'] = {}
|
||||
): ActivityEvent {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
kind,
|
||||
beadId: issue.id,
|
||||
beadTitle: issue.title,
|
||||
projectId: issue.project.key,
|
||||
projectName: issue.project.name,
|
||||
timestamp,
|
||||
actor: issue.assignee || issue.owner || issue.created_by,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow equality check for string arrays (labels).
|
||||
*/
|
||||
function areArraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((val, index) => val === sortedB[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects added and removed dependencies.
|
||||
*/
|
||||
function diffDependencies(
|
||||
prev: BeadDependency[],
|
||||
curr: BeadDependency[]
|
||||
): { kind: 'dependency_added' | 'dependency_removed', target: string }[] {
|
||||
const changes: { kind: 'dependency_added' | 'dependency_removed', target: string }[] = [];
|
||||
|
||||
const prevTargets = new Set(prev.map(d => d.target));
|
||||
const currTargets = new Set(curr.map(d => d.target));
|
||||
|
||||
curr.forEach(d => {
|
||||
if (!prevTargets.has(d.target)) {
|
||||
changes.push({ kind: 'dependency_added', target: d.target });
|
||||
}
|
||||
});
|
||||
|
||||
prev.forEach(d => {
|
||||
if (!currTargets.has(d.target)) {
|
||||
changes.push({ kind: 'dependency_removed', target: d.target });
|
||||
}
|
||||
});
|
||||
|
||||
return changes;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue