checkpoint: pre-split branch cleanup
This commit is contained in:
parent
4c2ae2e5b7
commit
b5db7a7753
276 changed files with 35912 additions and 60119 deletions
|
|
@ -1,76 +1,76 @@
|
|||
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'
|
||||
| 'heartbeat';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
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'
|
||||
| 'heartbeat';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,436 +1,436 @@
|
|||
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 isValidMessageId(value: string): boolean {
|
||||
// Message IDs must be alphanumeric with underscores, hyphens, and colons
|
||||
// This prevents path traversal attacks
|
||||
return /^[a-zA-Z0-9_\-:]+$/.test(value);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function resolveRecipients(to: string, from: string): Promise<string[]> {
|
||||
if (to === 'broadcast') {
|
||||
const agents = (await listAgents({})).data ?? [];
|
||||
return agents.map((a) => a.agent_id).filter((id) => id !== from);
|
||||
}
|
||||
|
||||
if (to.startsWith('role:')) {
|
||||
const role = to.slice(5);
|
||||
const agents = (await listAgents({ role })).data ?? [];
|
||||
return agents.map((a) => a.agent_id).filter((id) => id !== from);
|
||||
}
|
||||
|
||||
return [to];
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
const isRoleOrBroadcast = to === 'broadcast' || to.startsWith('role:');
|
||||
|
||||
if (!isRoleOrBroadcast && !(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 = await resolveRecipients(to, from);
|
||||
|
||||
if (recipientIds.length === 0) {
|
||||
if (to.startsWith('role:')) {
|
||||
const role = to.slice(5);
|
||||
const allWithRole = (await listAgents({ role })).data ?? [];
|
||||
if (allWithRole.length === 0) {
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', `no agents found with role '${role}'.`);
|
||||
}
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', 'all recipients were excluded (sender).');
|
||||
}
|
||||
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.');
|
||||
}
|
||||
|
||||
if (!isValidMessageId(messageId)) {
|
||||
return invalid(command, 'INVALID_MESSAGE_ID', 'Message id contains invalid characters.');
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
if (!isValidMessageId(messageId)) {
|
||||
return invalid(command, 'INVALID_MESSAGE_ID', 'Message id contains invalid characters.');
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
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 isValidMessageId(value: string): boolean {
|
||||
// Message IDs must be alphanumeric with underscores, hyphens, and colons
|
||||
// This prevents path traversal attacks
|
||||
return /^[a-zA-Z0-9_\-:]+$/.test(value);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function resolveRecipients(to: string, from: string): Promise<string[]> {
|
||||
if (to === 'broadcast') {
|
||||
const agents = (await listAgents({})).data ?? [];
|
||||
return agents.map((a) => a.agent_id).filter((id) => id !== from);
|
||||
}
|
||||
|
||||
if (to.startsWith('role:')) {
|
||||
const role = to.slice(5);
|
||||
const agents = (await listAgents({ role })).data ?? [];
|
||||
return agents.map((a) => a.agent_id).filter((id) => id !== from);
|
||||
}
|
||||
|
||||
return [to];
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
const isRoleOrBroadcast = to === 'broadcast' || to.startsWith('role:');
|
||||
|
||||
if (!isRoleOrBroadcast && !(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 = await resolveRecipients(to, from);
|
||||
|
||||
if (recipientIds.length === 0) {
|
||||
if (to.startsWith('role:')) {
|
||||
const role = to.slice(5);
|
||||
const allWithRole = (await listAgents({ role })).data ?? [];
|
||||
if (allWithRole.length === 0) {
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', `no agents found with role '${role}'.`);
|
||||
}
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', 'all recipients were excluded (sender).');
|
||||
}
|
||||
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.');
|
||||
}
|
||||
|
||||
if (!isValidMessageId(messageId)) {
|
||||
return invalid(command, 'INVALID_MESSAGE_ID', 'Message id contains invalid characters.');
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
if (!isValidMessageId(messageId)) {
|
||||
return invalid(command, 'INVALID_MESSAGE_ID', 'Message id contains invalid characters.');
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -8,142 +8,142 @@ import {
|
|||
projectReservations,
|
||||
readCoordEventsFromDisk,
|
||||
} from './coord-projections';
|
||||
|
||||
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted' | 'idle' | 'stuck' | 'dead';
|
||||
|
||||
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 type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted' | 'idle' | 'stuck' | 'dead';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
// 15 minutes default stale threshold
|
||||
const STALE_THRESHOLD_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Derives the session state for a task based on task status, liveness, and ZFC state.
|
||||
* Priority: completed > stuck > dead > needs_input > evicted > stale > active > deciding
|
||||
*/
|
||||
export function deriveSessionState(
|
||||
task: BeadIssue,
|
||||
lastEvent: ActivityEvent | null,
|
||||
pendingRequired: boolean,
|
||||
ownerLiveness?: string,
|
||||
ownerZfcState?: string
|
||||
): AgentSessionState {
|
||||
if (task.status === 'closed') return 'completed';
|
||||
if (ownerZfcState === 'stuck') return 'stuck';
|
||||
if (ownerZfcState === 'dead') return 'dead';
|
||||
if (task.status === 'blocked' || pendingRequired) return 'needs_input';
|
||||
if (ownerLiveness === 'evicted') return 'evicted';
|
||||
if (ownerLiveness === 'stale') return 'stale';
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all active (non-closed) tasks owned by a specific agent.
|
||||
* Used for mission pathing: drawing visual links between working agents and their tasks.
|
||||
*/
|
||||
export function getAgentActiveMissions(
|
||||
feed: EpicBucket[],
|
||||
agentId: string
|
||||
): SessionTaskCard[] {
|
||||
return feed
|
||||
.flatMap(bucket => bucket.tasks)
|
||||
.filter(task => task.owner === agentId && task.status !== 'closed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns count of active missions for an agent.
|
||||
* Used for visual indicators in the sessions header.
|
||||
*/
|
||||
export function getActiveMissionCount(feed: EpicBucket[], agentId: string): number {
|
||||
return getAgentActiveMissions(feed, agentId).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups all active missions by agent ID.
|
||||
* Used for efficient batch rendering of mission paths.
|
||||
*/
|
||||
export function getMissionsByAgent(feed: EpicBucket[]): Record<string, SessionTaskCard[]> {
|
||||
const missions: Record<string, SessionTaskCard[]> = {};
|
||||
|
||||
for (const bucket of feed) {
|
||||
for (const task of bucket.tasks) {
|
||||
if (task.owner && task.status !== 'closed') {
|
||||
if (!missions[task.owner]) {
|
||||
missions[task.owner] = [];
|
||||
}
|
||||
missions[task.owner].push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return missions;
|
||||
}
|
||||
|
||||
export async function getAgentLivenessMap(
|
||||
projectRoot: string = process.cwd(),
|
||||
activityHistory: ActivityEvent[] = []
|
||||
): Promise<Record<string, string>> {
|
||||
const agentsResult = await listAgents({}, { projectRoot });
|
||||
const agents = agentsResult.data ?? [];
|
||||
const map: Record<string, string> = {};
|
||||
const now = new Date();
|
||||
|
||||
// Group activity by actor to find latest heartbeat
|
||||
const latestHeartbeatByAgent = new Map<string, string>();
|
||||
activityHistory
|
||||
.filter(e => e.kind === 'heartbeat')
|
||||
.forEach(e => {
|
||||
const current = latestHeartbeatByAgent.get(e.actor || '');
|
||||
if (!current || new Date(e.timestamp) > new Date(current)) {
|
||||
latestHeartbeatByAgent.set(e.actor || '', e.timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
for (const agent of agents) {
|
||||
const telemetryLastSeen = latestHeartbeatByAgent.get(agent.agent_id);
|
||||
const metadataLastSeen = agent.last_seen_at;
|
||||
|
||||
// Use most recent signal
|
||||
let effectiveLastSeen = metadataLastSeen;
|
||||
if (telemetryLastSeen && new Date(telemetryLastSeen) > new Date(metadataLastSeen)) {
|
||||
effectiveLastSeen = telemetryLastSeen;
|
||||
}
|
||||
|
||||
map[agent.agent_id] = deriveLiveness(effectiveLastSeen, now);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
|
||||
// 15 minutes default stale threshold
|
||||
const STALE_THRESHOLD_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Derives the session state for a task based on task status, liveness, and ZFC state.
|
||||
* Priority: completed > stuck > dead > needs_input > evicted > stale > active > deciding
|
||||
*/
|
||||
export function deriveSessionState(
|
||||
task: BeadIssue,
|
||||
lastEvent: ActivityEvent | null,
|
||||
pendingRequired: boolean,
|
||||
ownerLiveness?: string,
|
||||
ownerZfcState?: string
|
||||
): AgentSessionState {
|
||||
if (task.status === 'closed') return 'completed';
|
||||
if (ownerZfcState === 'stuck') return 'stuck';
|
||||
if (ownerZfcState === 'dead') return 'dead';
|
||||
if (task.status === 'blocked' || pendingRequired) return 'needs_input';
|
||||
if (ownerLiveness === 'evicted') return 'evicted';
|
||||
if (ownerLiveness === 'stale') return 'stale';
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all active (non-closed) tasks owned by a specific agent.
|
||||
* Used for mission pathing: drawing visual links between working agents and their tasks.
|
||||
*/
|
||||
export function getAgentActiveMissions(
|
||||
feed: EpicBucket[],
|
||||
agentId: string
|
||||
): SessionTaskCard[] {
|
||||
return feed
|
||||
.flatMap(bucket => bucket.tasks)
|
||||
.filter(task => task.owner === agentId && task.status !== 'closed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns count of active missions for an agent.
|
||||
* Used for visual indicators in the sessions header.
|
||||
*/
|
||||
export function getActiveMissionCount(feed: EpicBucket[], agentId: string): number {
|
||||
return getAgentActiveMissions(feed, agentId).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups all active missions by agent ID.
|
||||
* Used for efficient batch rendering of mission paths.
|
||||
*/
|
||||
export function getMissionsByAgent(feed: EpicBucket[]): Record<string, SessionTaskCard[]> {
|
||||
const missions: Record<string, SessionTaskCard[]> = {};
|
||||
|
||||
for (const bucket of feed) {
|
||||
for (const task of bucket.tasks) {
|
||||
if (task.owner && task.status !== 'closed') {
|
||||
if (!missions[task.owner]) {
|
||||
missions[task.owner] = [];
|
||||
}
|
||||
missions[task.owner].push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return missions;
|
||||
}
|
||||
|
||||
export async function getAgentLivenessMap(
|
||||
projectRoot: string = process.cwd(),
|
||||
activityHistory: ActivityEvent[] = []
|
||||
): Promise<Record<string, string>> {
|
||||
const agentsResult = await listAgents({}, { projectRoot });
|
||||
const agents = agentsResult.data ?? [];
|
||||
const map: Record<string, string> = {};
|
||||
const now = new Date();
|
||||
|
||||
// Group activity by actor to find latest heartbeat
|
||||
const latestHeartbeatByAgent = new Map<string, string>();
|
||||
activityHistory
|
||||
.filter(e => e.kind === 'heartbeat')
|
||||
.forEach(e => {
|
||||
const current = latestHeartbeatByAgent.get(e.actor || '');
|
||||
if (!current || new Date(e.timestamp) > new Date(current)) {
|
||||
latestHeartbeatByAgent.set(e.actor || '', e.timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
for (const agent of agents) {
|
||||
const telemetryLastSeen = latestHeartbeatByAgent.get(agent.agent_id);
|
||||
const metadataLastSeen = agent.last_seen_at;
|
||||
|
||||
// Use most recent signal
|
||||
let effectiveLastSeen = metadataLastSeen;
|
||||
if (telemetryLastSeen && new Date(telemetryLastSeen) > new Date(metadataLastSeen)) {
|
||||
effectiveLastSeen = telemetryLastSeen;
|
||||
}
|
||||
|
||||
map[agent.agent_id] = deriveLiveness(effectiveLastSeen, now);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export interface Incursion {
|
||||
scope: string;
|
||||
agents: string[];
|
||||
|
|
@ -161,163 +161,163 @@ export async function calculateIncursions(
|
|||
const reservations = projectReservations(events, agentLivenessMap);
|
||||
return calculateReservationIncursions(reservations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers all relevant communication for all agents to build a summary for aggregation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gathers all relevant communication for all agents to build a summary for aggregation.
|
||||
*/
|
||||
export async function getCommunicationSummary(projectRoot: string = process.cwd()): Promise<CommunicationSummary> {
|
||||
const coordMessages = await projectInboxFromDisk(projectRoot);
|
||||
return { messages: coordMessages };
|
||||
}
|
||||
|
||||
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,
|
||||
agentLivenessMap: Record<string, string> = {}
|
||||
): 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,
|
||||
ownerLiveness?: string
|
||||
): AgentSessionState => {
|
||||
if (task.status === 'closed') return 'completed';
|
||||
if (task.status === 'blocked' || pendingRequired) return 'needs_input';
|
||||
|
||||
// If agent is evicted, the task session state is definitely evicted
|
||||
if (ownerLiveness === 'evicted') return 'evicted';
|
||||
if (ownerLiveness === 'stale') return 'stale';
|
||||
|
||||
// Check staleness of the TASK activity itself
|
||||
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 ownerLiveness = task.assignee ? agentLivenessMap[task.assignee] : undefined;
|
||||
const sessionState = deriveState(task, lastEvent, pendingRequired, ownerLiveness);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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,
|
||||
agentLivenessMap: Record<string, string> = {}
|
||||
): 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,
|
||||
ownerLiveness?: string
|
||||
): AgentSessionState => {
|
||||
if (task.status === 'closed') return 'completed';
|
||||
if (task.status === 'blocked' || pendingRequired) return 'needs_input';
|
||||
|
||||
// If agent is evicted, the task session state is definitely evicted
|
||||
if (ownerLiveness === 'evicted') return 'evicted';
|
||||
if (ownerLiveness === 'stale') return 'stale';
|
||||
|
||||
// Check staleness of the TASK activity itself
|
||||
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 ownerLiveness = task.assignee ? agentLivenessMap[task.assignee] : undefined;
|
||||
const sessionState = deriveState(task, lastEvent, pendingRequired, ownerLiveness);
|
||||
|
||||
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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,52 @@
|
|||
import type { BeadDependency, BeadIssueWithProject } from './types';
|
||||
import type { ProjectScopeOption } from './project-scope';
|
||||
import { readIssuesFromDisk } from './read-issues';
|
||||
|
||||
function scopeIssueId(projectKey: string, issueId: string): string {
|
||||
if (issueId.includes('::')) {
|
||||
return issueId;
|
||||
}
|
||||
return `${projectKey}::${issueId}`;
|
||||
}
|
||||
|
||||
function remapDependencies(
|
||||
dependencies: BeadDependency[],
|
||||
scopedIssueByOriginalId: Map<string, string>,
|
||||
): BeadDependency[] {
|
||||
return dependencies.map((dependency) => ({
|
||||
...dependency,
|
||||
target: scopedIssueByOriginalId.get(dependency.target) ?? dependency.target,
|
||||
}));
|
||||
}
|
||||
|
||||
function scopeIssuesForProject(
|
||||
project: ProjectScopeOption,
|
||||
issues: BeadIssueWithProject[],
|
||||
): BeadIssueWithProject[] {
|
||||
const scopedIssueByOriginalId = new Map<string, string>();
|
||||
for (const issue of issues) {
|
||||
scopedIssueByOriginalId.set(issue.id, scopeIssueId(project.key, issue.id));
|
||||
}
|
||||
|
||||
return issues.map((issue) => {
|
||||
const scopedId = scopedIssueByOriginalId.get(issue.id) ?? scopeIssueId(project.key, issue.id);
|
||||
return {
|
||||
...issue,
|
||||
id: scopedId,
|
||||
dependencies: remapDependencies(issue.dependencies, scopedIssueByOriginalId),
|
||||
metadata: {
|
||||
...issue.metadata,
|
||||
original_id: issue.id,
|
||||
project_key: project.key,
|
||||
},
|
||||
project: {
|
||||
...issue.project,
|
||||
key: project.key,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
import type { BeadDependency, BeadIssueWithProject } from './types';
|
||||
import type { ProjectScopeOption } from './project-scope';
|
||||
import { readIssuesFromDisk } from './read-issues';
|
||||
|
||||
function scopeIssueId(projectKey: string, issueId: string): string {
|
||||
if (issueId.includes('::')) {
|
||||
return issueId;
|
||||
}
|
||||
return `${projectKey}::${issueId}`;
|
||||
}
|
||||
|
||||
function remapDependencies(
|
||||
dependencies: BeadDependency[],
|
||||
scopedIssueByOriginalId: Map<string, string>,
|
||||
): BeadDependency[] {
|
||||
return dependencies.map((dependency) => ({
|
||||
...dependency,
|
||||
target: scopedIssueByOriginalId.get(dependency.target) ?? dependency.target,
|
||||
}));
|
||||
}
|
||||
|
||||
function scopeIssuesForProject(
|
||||
project: ProjectScopeOption,
|
||||
issues: BeadIssueWithProject[],
|
||||
): BeadIssueWithProject[] {
|
||||
const scopedIssueByOriginalId = new Map<string, string>();
|
||||
for (const issue of issues) {
|
||||
scopedIssueByOriginalId.set(issue.id, scopeIssueId(project.key, issue.id));
|
||||
}
|
||||
|
||||
return issues.map((issue) => {
|
||||
const scopedId = scopedIssueByOriginalId.get(issue.id) ?? scopeIssueId(project.key, issue.id);
|
||||
return {
|
||||
...issue,
|
||||
id: scopedId,
|
||||
dependencies: remapDependencies(issue.dependencies, scopedIssueByOriginalId),
|
||||
metadata: {
|
||||
...issue.metadata,
|
||||
original_id: issue.id,
|
||||
project_key: project.key,
|
||||
},
|
||||
project: {
|
||||
...issue.project,
|
||||
key: project.key,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function readIssuesForScope(options: {
|
||||
mode: 'single' | 'aggregate';
|
||||
selected: ProjectScopeOption;
|
||||
|
|
|
|||
|
|
@ -1,246 +1,246 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { normalizeProjectRootForRuntime } from './project-root';
|
||||
|
||||
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
|
||||
|
||||
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 {
|
||||
success: boolean;
|
||||
classification: BdFailureClassification | null;
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
durationMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface RunBdCommandDeps {
|
||||
exec: (
|
||||
command: string,
|
||||
options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv; stdinText?: string },
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function normalizeOutput(text: unknown): string {
|
||||
if (typeof text !== 'string') return '';
|
||||
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 (
|
||||
/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 = sanitizedExecutable.split(path.sep).join('/');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// 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;
|
||||
});
|
||||
return [quotedExe, ...quotedArgs].join(' ');
|
||||
} else {
|
||||
const escapeArg = (a: string) => `'${a.replace(/'/g, "'\''")}'`;
|
||||
return [normalizedExe, ...args.map(escapeArg)].join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
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 = normalizeProjectRootForRuntime(options.projectRoot);
|
||||
const args = [...options.args];
|
||||
if (process.env.BD_NO_DAEMON === 'true') {
|
||||
args.unshift('--no-daemon');
|
||||
}
|
||||
|
||||
const deps: RunBdCommandDeps = {
|
||||
exec: injectedDeps?.exec ?? execShellCommand,
|
||||
env: injectedDeps?.env ?? process.env,
|
||||
};
|
||||
|
||||
const command = 'bd';
|
||||
|
||||
try {
|
||||
const shellCommand = buildShellCommand(command, args);
|
||||
|
||||
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,
|
||||
stdinText: options.stdinText,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
classification: null,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(stdout),
|
||||
stderr: normalizeOutput(stderr),
|
||||
code: 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: null,
|
||||
};
|
||||
} catch (rawError) {
|
||||
const error = rawError as NodeJS.ErrnoException & {
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
};
|
||||
const classification = classifyFailure(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
classification,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(error.stdout),
|
||||
stderr: normalizeOutput(error.stderr),
|
||||
code: typeof error.code === 'number' ? error.code : getExitCode(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: classification === 'not_found' ? buildBdNotFoundMessage() : toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { normalizeProjectRootForRuntime } from './project-root';
|
||||
|
||||
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
|
||||
|
||||
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 {
|
||||
success: boolean;
|
||||
classification: BdFailureClassification | null;
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
durationMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface RunBdCommandDeps {
|
||||
exec: (
|
||||
command: string,
|
||||
options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv; stdinText?: string },
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function normalizeOutput(text: unknown): string {
|
||||
if (typeof text !== 'string') return '';
|
||||
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 (
|
||||
/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 = sanitizedExecutable.split(path.sep).join('/');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// 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;
|
||||
});
|
||||
return [quotedExe, ...quotedArgs].join(' ');
|
||||
} else {
|
||||
const escapeArg = (a: string) => `'${a.replace(/'/g, "'\''")}'`;
|
||||
return [normalizedExe, ...args.map(escapeArg)].join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
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 = normalizeProjectRootForRuntime(options.projectRoot);
|
||||
const args = [...options.args];
|
||||
if (process.env.BD_NO_DAEMON === 'true') {
|
||||
args.unshift('--no-daemon');
|
||||
}
|
||||
|
||||
const deps: RunBdCommandDeps = {
|
||||
exec: injectedDeps?.exec ?? execShellCommand,
|
||||
env: injectedDeps?.env ?? process.env,
|
||||
};
|
||||
|
||||
const command = 'bd';
|
||||
|
||||
try {
|
||||
const shellCommand = buildShellCommand(command, args);
|
||||
|
||||
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,
|
||||
stdinText: options.stdinText,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
classification: null,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(stdout),
|
||||
stderr: normalizeOutput(stderr),
|
||||
code: 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: null,
|
||||
};
|
||||
} catch (rawError) {
|
||||
const error = rawError as NodeJS.ErrnoException & {
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
};
|
||||
const classification = classifyFailure(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
classification,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(error.stdout),
|
||||
stderr: normalizeOutput(error.stderr),
|
||||
code: typeof error.code === 'number' ? error.code : getExitCode(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: classification === 'not_found' ? buildBdNotFoundMessage() : toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +1,76 @@
|
|||
import { windowsPathKey } from './pathing';
|
||||
|
||||
export interface CoalescedEventInput<T> {
|
||||
projectRoot: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
interface PendingEvent<T> {
|
||||
timer: NodeJS.Timeout;
|
||||
projectRoot: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export class ProjectEventCoalescer<T> {
|
||||
private readonly pending = new Map<string, PendingEvent<T>>();
|
||||
|
||||
private readonly debounceMs: number;
|
||||
|
||||
private readonly onFlush: (event: CoalescedEventInput<T>) => void;
|
||||
|
||||
constructor(debounceMs: number, onFlush: (event: CoalescedEventInput<T>) => void) {
|
||||
this.debounceMs = debounceMs;
|
||||
this.onFlush = onFlush;
|
||||
}
|
||||
|
||||
queue(projectRoot: string, payload: T): void {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const existing = this.pending.get(projectKey);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.projectRoot = projectRoot;
|
||||
existing.payload = payload;
|
||||
existing.timer = setTimeout(() => this.flush(projectKey), this.debounceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.set(projectKey, {
|
||||
projectRoot,
|
||||
payload,
|
||||
timer: setTimeout(() => this.flush(projectKey), this.debounceMs),
|
||||
});
|
||||
}
|
||||
|
||||
cancel(projectRoot: string): void {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const pending = this.pending.get(projectKey);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(projectKey);
|
||||
}
|
||||
|
||||
cancelAll(): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
pendingCount(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
|
||||
private flush(projectKey: string): void {
|
||||
const pending = this.pending.get(projectKey);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(projectKey);
|
||||
this.onFlush({
|
||||
projectRoot: pending.projectRoot,
|
||||
payload: pending.payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
import { windowsPathKey } from './pathing';
|
||||
|
||||
export interface CoalescedEventInput<T> {
|
||||
projectRoot: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
interface PendingEvent<T> {
|
||||
timer: NodeJS.Timeout;
|
||||
projectRoot: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export class ProjectEventCoalescer<T> {
|
||||
private readonly pending = new Map<string, PendingEvent<T>>();
|
||||
|
||||
private readonly debounceMs: number;
|
||||
|
||||
private readonly onFlush: (event: CoalescedEventInput<T>) => void;
|
||||
|
||||
constructor(debounceMs: number, onFlush: (event: CoalescedEventInput<T>) => void) {
|
||||
this.debounceMs = debounceMs;
|
||||
this.onFlush = onFlush;
|
||||
}
|
||||
|
||||
queue(projectRoot: string, payload: T): void {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const existing = this.pending.get(projectKey);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.projectRoot = projectRoot;
|
||||
existing.payload = payload;
|
||||
existing.timer = setTimeout(() => this.flush(projectKey), this.debounceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.set(projectKey, {
|
||||
projectRoot,
|
||||
payload,
|
||||
timer: setTimeout(() => this.flush(projectKey), this.debounceMs),
|
||||
});
|
||||
}
|
||||
|
||||
cancel(projectRoot: string): void {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const pending = this.pending.get(projectKey);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(projectKey);
|
||||
}
|
||||
|
||||
cancelAll(): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
pendingCount(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
|
||||
private flush(projectKey: string): void {
|
||||
const pending = this.pending.get(projectKey);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(projectKey);
|
||||
this.onFlush({
|
||||
projectRoot: pending.projectRoot,
|
||||
payload: pending.payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
200
src/lib/epic-graph.ts
Normal file
200
src/lib/epic-graph.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import type { BeadIssue } from './types';
|
||||
|
||||
export interface WorkflowEdgeDescriptor {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
kind: 'blocks' | 'subtask';
|
||||
isUpstreamOfFocus: boolean;
|
||||
isDownstreamOfFocus: boolean;
|
||||
isDirectlyFocused: boolean;
|
||||
isUnrelated: boolean;
|
||||
sourceStatus: string;
|
||||
targetStatus: string;
|
||||
}
|
||||
|
||||
interface BuildWorkflowEdgesOptions {
|
||||
issues: BeadIssue[];
|
||||
visibleIds: Set<string>;
|
||||
selectedId: string | null;
|
||||
includeHierarchy: boolean;
|
||||
}
|
||||
|
||||
export function collectEpicDescendantIds(issues: BeadIssue[], epicId: string): Set<string> {
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const dep of issue.dependencies) {
|
||||
if (dep.type !== 'parent') continue;
|
||||
const list = childrenByParent.get(dep.target) ?? [];
|
||||
list.push(issue.id);
|
||||
childrenByParent.set(dep.target, list);
|
||||
}
|
||||
}
|
||||
|
||||
const descendants = new Set<string>();
|
||||
const queue = [...(childrenByParent.get(epicId) ?? [])];
|
||||
const seen = new Set<string>([epicId]);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
const issue = issueById.get(id);
|
||||
if (issue && issue.issue_type !== 'epic') {
|
||||
descendants.add(id);
|
||||
}
|
||||
|
||||
const children = childrenByParent.get(id) ?? [];
|
||||
for (const child of children) {
|
||||
if (!seen.has(child)) {
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return descendants;
|
||||
}
|
||||
|
||||
export function buildWorkflowEdges({
|
||||
issues,
|
||||
visibleIds,
|
||||
selectedId,
|
||||
includeHierarchy,
|
||||
}: BuildWorkflowEdgesOptions): WorkflowEdgeDescriptor[] {
|
||||
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
const edgeIds = new Set<string>();
|
||||
const edges: WorkflowEdgeDescriptor[] = [];
|
||||
|
||||
const upstreamIds = new Set<string>();
|
||||
const downstreamIds = new Set<string>();
|
||||
|
||||
if (selectedId) {
|
||||
upstreamIds.add(selectedId);
|
||||
downstreamIds.add(selectedId);
|
||||
|
||||
// Build adjacency just for tracing blockers
|
||||
const outgoing = new Map<string, string[]>(); // id -> nodes it blocks
|
||||
const incoming = new Map<string, string[]>(); // id -> nodes blocking it
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const dep of issue.dependencies) {
|
||||
if (dep.type === 'blocks') {
|
||||
const blocker = dep.target;
|
||||
const blocked = issue.id;
|
||||
|
||||
if (!outgoing.has(blocker)) outgoing.set(blocker, []);
|
||||
if (!incoming.has(blocked)) incoming.set(blocked, []);
|
||||
|
||||
outgoing.get(blocker)!.push(blocked);
|
||||
incoming.get(blocked)!.push(blocker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trace incoming (upstream blockers)
|
||||
let queue = [selectedId];
|
||||
while (queue.length > 0) {
|
||||
const curr = queue.shift()!;
|
||||
for (const b of (incoming.get(curr) || [])) {
|
||||
if (!upstreamIds.has(b)) {
|
||||
upstreamIds.add(b);
|
||||
queue.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trace outgoing (downstream blocked)
|
||||
queue = [selectedId];
|
||||
while (queue.length > 0) {
|
||||
const curr = queue.shift()!;
|
||||
for (const b of (outgoing.get(curr) || [])) {
|
||||
if (!downstreamIds.has(b)) {
|
||||
downstreamIds.add(b);
|
||||
queue.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
if (!visibleIds.has(issue.id)) continue;
|
||||
|
||||
for (const dep of issue.dependencies) {
|
||||
if (!visibleIds.has(dep.target)) continue;
|
||||
if (dep.target === issue.id) continue;
|
||||
|
||||
if (dep.type === 'blocks') {
|
||||
const source = dep.target;
|
||||
const target = issue.id;
|
||||
const id = `${source}:blocks:${target}`;
|
||||
if (edgeIds.has(id)) continue;
|
||||
edgeIds.add(id);
|
||||
|
||||
const sourceIssue = issueById.get(source);
|
||||
const sourceStatus = sourceIssue?.status || 'open';
|
||||
const targetStatus = issue.status;
|
||||
|
||||
const isUpstreamOfFocus = selectedId ? upstreamIds.has(source) && upstreamIds.has(target) : false;
|
||||
const isDownstreamOfFocus = selectedId ? downstreamIds.has(source) && downstreamIds.has(target) : false;
|
||||
const isDirectlyFocused = selectedId ? source === selectedId || target === selectedId : false;
|
||||
|
||||
let isUnrelated = false;
|
||||
if (selectedId) {
|
||||
isUnrelated = !isUpstreamOfFocus && !isDownstreamOfFocus && !isDirectlyFocused;
|
||||
}
|
||||
|
||||
edges.push({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
kind: 'blocks',
|
||||
isUpstreamOfFocus,
|
||||
isDownstreamOfFocus,
|
||||
isDirectlyFocused,
|
||||
isUnrelated,
|
||||
sourceStatus,
|
||||
targetStatus
|
||||
});
|
||||
}
|
||||
|
||||
if (includeHierarchy && dep.type === 'parent') {
|
||||
const source = dep.target;
|
||||
const target = issue.id;
|
||||
const id = `${source}:subtask:${target}`;
|
||||
if (edgeIds.has(id)) continue;
|
||||
edgeIds.add(id);
|
||||
|
||||
const sourceIssue = issueById.get(source);
|
||||
const sourceStatus = sourceIssue?.status || 'open';
|
||||
const targetStatus = issue.status;
|
||||
|
||||
const isUpstreamOfFocus = selectedId ? upstreamIds.has(source) && upstreamIds.has(target) : false;
|
||||
const isDownstreamOfFocus = selectedId ? downstreamIds.has(source) && downstreamIds.has(target) : false;
|
||||
const isDirectlyFocused = selectedId ? source === selectedId || target === selectedId : false;
|
||||
|
||||
let isUnrelated = false;
|
||||
if (selectedId) {
|
||||
isUnrelated = !isUpstreamOfFocus && !isDownstreamOfFocus && !isDirectlyFocused;
|
||||
}
|
||||
|
||||
edges.push({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
kind: 'subtask',
|
||||
isUpstreamOfFocus,
|
||||
isDownstreamOfFocus,
|
||||
isDirectlyFocused,
|
||||
isUnrelated,
|
||||
sourceStatus,
|
||||
targetStatus
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
240
src/lib/graph.ts
240
src/lib/graph.ts
|
|
@ -1,105 +1,105 @@
|
|||
import type { BeadDependencyType, BeadIssue } from './types';
|
||||
|
||||
type SupportedGraphEdgeType = Extract<
|
||||
BeadDependencyType,
|
||||
'blocks' | 'parent' | 'relates_to' | 'duplicates' | 'supersedes'
|
||||
>;
|
||||
|
||||
const SUPPORTED_EDGE_TYPES = new Set<BeadDependencyType>([
|
||||
'blocks',
|
||||
'parent',
|
||||
'relates_to',
|
||||
'duplicates',
|
||||
'supersedes',
|
||||
]);
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
title: string;
|
||||
status: BeadIssue['status'];
|
||||
priority: number;
|
||||
issueType: string;
|
||||
assignee: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: SupportedGraphEdgeType;
|
||||
}
|
||||
|
||||
export interface GraphAdjacencyEntry {
|
||||
incoming: GraphEdge[];
|
||||
outgoing: GraphEdge[];
|
||||
}
|
||||
|
||||
export interface GraphModelDiagnostics {
|
||||
missingTargets: number;
|
||||
droppedDuplicates: number;
|
||||
unsupportedTypes: number;
|
||||
}
|
||||
|
||||
export interface GraphModel {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
adjacency: Record<string, GraphAdjacencyEntry>;
|
||||
diagnostics: GraphModelDiagnostics;
|
||||
projectKey: string | null;
|
||||
}
|
||||
|
||||
export interface BuildGraphModelOptions {
|
||||
projectKey?: string;
|
||||
}
|
||||
|
||||
function edgeSort(a: GraphEdge, b: GraphEdge): number {
|
||||
if (a.source !== b.source) {
|
||||
return a.source.localeCompare(b.source);
|
||||
}
|
||||
if (a.type !== b.type) {
|
||||
return a.type.localeCompare(b.type);
|
||||
}
|
||||
return a.target.localeCompare(b.target);
|
||||
}
|
||||
|
||||
function isSupportedEdgeType(type: BeadDependencyType): type is SupportedGraphEdgeType {
|
||||
return SUPPORTED_EDGE_TYPES.has(type);
|
||||
}
|
||||
|
||||
import type { BeadDependencyType, BeadIssue } from './types';
|
||||
|
||||
type SupportedGraphEdgeType = Extract<
|
||||
BeadDependencyType,
|
||||
'blocks' | 'parent' | 'relates_to' | 'duplicates' | 'supersedes'
|
||||
>;
|
||||
|
||||
const SUPPORTED_EDGE_TYPES = new Set<BeadDependencyType>([
|
||||
'blocks',
|
||||
'parent',
|
||||
'relates_to',
|
||||
'duplicates',
|
||||
'supersedes',
|
||||
]);
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
title: string;
|
||||
status: BeadIssue['status'];
|
||||
priority: number;
|
||||
issueType: string;
|
||||
assignee: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: SupportedGraphEdgeType;
|
||||
}
|
||||
|
||||
export interface GraphAdjacencyEntry {
|
||||
incoming: GraphEdge[];
|
||||
outgoing: GraphEdge[];
|
||||
}
|
||||
|
||||
export interface GraphModelDiagnostics {
|
||||
missingTargets: number;
|
||||
droppedDuplicates: number;
|
||||
unsupportedTypes: number;
|
||||
}
|
||||
|
||||
export interface GraphModel {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
adjacency: Record<string, GraphAdjacencyEntry>;
|
||||
diagnostics: GraphModelDiagnostics;
|
||||
projectKey: string | null;
|
||||
}
|
||||
|
||||
export interface BuildGraphModelOptions {
|
||||
projectKey?: string;
|
||||
}
|
||||
|
||||
function edgeSort(a: GraphEdge, b: GraphEdge): number {
|
||||
if (a.source !== b.source) {
|
||||
return a.source.localeCompare(b.source);
|
||||
}
|
||||
if (a.type !== b.type) {
|
||||
return a.type.localeCompare(b.type);
|
||||
}
|
||||
return a.target.localeCompare(b.target);
|
||||
}
|
||||
|
||||
function isSupportedEdgeType(type: BeadDependencyType): type is SupportedGraphEdgeType {
|
||||
return SUPPORTED_EDGE_TYPES.has(type);
|
||||
}
|
||||
|
||||
export function buildGraphModel(issues: BeadIssue[], options: BuildGraphModelOptions = {}): GraphModel {
|
||||
const nodes = issues
|
||||
.map((issue) => ({
|
||||
id: issue.id,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
issueType: issue.issue_type,
|
||||
assignee: issue.assignee,
|
||||
updatedAt: issue.updated_at,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edgeKeys = new Set<string>();
|
||||
const edges: GraphEdge[] = [];
|
||||
const diagnostics: GraphModelDiagnostics = {
|
||||
missingTargets: 0,
|
||||
droppedDuplicates: 0,
|
||||
unsupportedTypes: 0,
|
||||
};
|
||||
|
||||
const nodes = issues
|
||||
.map((issue) => ({
|
||||
id: issue.id,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
issueType: issue.issue_type,
|
||||
assignee: issue.assignee,
|
||||
updatedAt: issue.updated_at,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edgeKeys = new Set<string>();
|
||||
const edges: GraphEdge[] = [];
|
||||
const diagnostics: GraphModelDiagnostics = {
|
||||
missingTargets: 0,
|
||||
droppedDuplicates: 0,
|
||||
unsupportedTypes: 0,
|
||||
};
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const dependency of issue.dependencies) {
|
||||
if (!isSupportedEdgeType(dependency.type)) {
|
||||
diagnostics.unsupportedTypes += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodeIds.has(dependency.target)) {
|
||||
diagnostics.missingTargets += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isSupportedEdgeType(dependency.type)) {
|
||||
diagnostics.unsupportedTypes += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodeIds.has(dependency.target)) {
|
||||
diagnostics.missingTargets += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Beads "blocks" dependency means: issue depends on target, so target blocks issue.
|
||||
// Normalize graph direction to blocker -> blocked for all blocker analytics and UI signals.
|
||||
const source = dependency.type === 'blocks' ? dependency.target : issue.id;
|
||||
|
|
@ -119,24 +119,24 @@ export function buildGraphModel(issues: BeadIssue[], options: BuildGraphModelOpt
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
edges.sort(edgeSort);
|
||||
|
||||
const adjacency: Record<string, GraphAdjacencyEntry> = {};
|
||||
for (const node of nodes) {
|
||||
adjacency[node.id] = { incoming: [], outgoing: [] };
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
adjacency[edge.source].outgoing.push(edge);
|
||||
adjacency[edge.target].incoming.push(edge);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
adjacency,
|
||||
diagnostics,
|
||||
projectKey: options.projectKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
edges.sort(edgeSort);
|
||||
|
||||
const adjacency: Record<string, GraphAdjacencyEntry> = {};
|
||||
for (const node of nodes) {
|
||||
adjacency[node.id] = { incoming: [], outgoing: [] };
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
adjacency[edge.source].outgoing.push(edge);
|
||||
adjacency[edge.target].incoming.push(edge);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
adjacency,
|
||||
diagnostics,
|
||||
projectKey: options.projectKey ?? null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
103
src/lib/install-manifest.ts
Normal file
103
src/lib/install-manifest.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
export const INSTALLER_SCHEMA_VERSION = 'installer.v1' as const;
|
||||
export const DRIVER_REMEDIATION_MODE = 'detect_only' as const;
|
||||
|
||||
export interface InstallerManifest {
|
||||
version: typeof INSTALLER_SCHEMA_VERSION;
|
||||
distribution: {
|
||||
packageName: string;
|
||||
shimNames: string[];
|
||||
};
|
||||
wrappers: {
|
||||
windows: {
|
||||
script: string;
|
||||
};
|
||||
posix: {
|
||||
script: string;
|
||||
};
|
||||
};
|
||||
runtime: {
|
||||
start: string;
|
||||
open: string;
|
||||
status: string;
|
||||
};
|
||||
driver: {
|
||||
remediationMode: typeof DRIVER_REMEDIATION_MODE;
|
||||
installSideEffects: false;
|
||||
};
|
||||
}
|
||||
|
||||
export type InstallerManifestValidationResult =
|
||||
| { ok: true; value: InstallerManifest }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function fail(error: string): InstallerManifestValidationResult {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
function isObject(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === 'object' && input !== null;
|
||||
}
|
||||
|
||||
function nonEmptyString(input: unknown): input is string {
|
||||
return typeof input === 'string' && input.trim().length > 0;
|
||||
}
|
||||
|
||||
export function validateInstallerManifest(input: unknown): InstallerManifestValidationResult {
|
||||
if (!isObject(input)) return fail('manifest must be an object');
|
||||
if (input.version !== INSTALLER_SCHEMA_VERSION) {
|
||||
return fail(`version must be "${INSTALLER_SCHEMA_VERSION}"`);
|
||||
}
|
||||
|
||||
if (!isObject(input.distribution)) return fail('distribution is required');
|
||||
if (!nonEmptyString(input.distribution.packageName)) return fail('distribution.packageName is required');
|
||||
if (!Array.isArray(input.distribution.shimNames) || input.distribution.shimNames.length === 0) {
|
||||
return fail('distribution.shimNames must be a non-empty array');
|
||||
}
|
||||
for (const shimName of input.distribution.shimNames) {
|
||||
if (!nonEmptyString(shimName)) return fail('distribution.shimNames must contain only non-empty strings');
|
||||
}
|
||||
|
||||
if (!isObject(input.wrappers)) return fail('wrappers is required');
|
||||
if (!isObject(input.wrappers.windows) || !nonEmptyString(input.wrappers.windows.script)) {
|
||||
return fail('wrappers.windows.script is required');
|
||||
}
|
||||
if (!isObject(input.wrappers.posix) || !nonEmptyString(input.wrappers.posix.script)) {
|
||||
return fail('wrappers.posix.script is required');
|
||||
}
|
||||
|
||||
if (!isObject(input.runtime)) return fail('runtime is required');
|
||||
if (!nonEmptyString(input.runtime.start)) return fail('runtime.start is required');
|
||||
if (!nonEmptyString(input.runtime.open)) return fail('runtime.open is required');
|
||||
if (!nonEmptyString(input.runtime.status)) return fail('runtime.status is required');
|
||||
|
||||
if (!isObject(input.driver)) return fail('driver is required');
|
||||
if (input.driver.remediationMode !== DRIVER_REMEDIATION_MODE) {
|
||||
return fail(`driver.remediationMode must be "${DRIVER_REMEDIATION_MODE}"`);
|
||||
}
|
||||
if (input.driver.installSideEffects !== false) {
|
||||
return fail('driver.installSideEffects must be false');
|
||||
}
|
||||
|
||||
return { ok: true, value: input as unknown as InstallerManifest };
|
||||
}
|
||||
|
||||
export const canonicalInstallerManifest: InstallerManifest = {
|
||||
version: INSTALLER_SCHEMA_VERSION,
|
||||
distribution: {
|
||||
packageName: 'beadboard',
|
||||
shimNames: ['bb', 'beadboard'],
|
||||
},
|
||||
wrappers: {
|
||||
windows: { script: 'install.ps1' },
|
||||
posix: { script: 'install.sh' },
|
||||
},
|
||||
runtime: {
|
||||
start: 'beadboard start',
|
||||
open: 'beadboard open',
|
||||
status: 'beadboard status',
|
||||
},
|
||||
driver: {
|
||||
remediationMode: DRIVER_REMEDIATION_MODE,
|
||||
installSideEffects: false,
|
||||
},
|
||||
};
|
||||
|
|
@ -1,168 +1,168 @@
|
|||
import type { MutationStatus, UpdateMutationPayload } from './mutations';
|
||||
import type { BeadIssue } from './types';
|
||||
|
||||
export interface EditableIssueDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
status: MutationStatus;
|
||||
priority: number;
|
||||
issueType: string;
|
||||
assignee: string;
|
||||
owner: string;
|
||||
labelsInput: string;
|
||||
}
|
||||
|
||||
export type EditableIssueFieldErrors = Partial<Record<keyof EditableIssueDraft, string>>;
|
||||
|
||||
export type EditState = 'pristine' | 'dirty' | 'saving' | 'error';
|
||||
|
||||
export function parseLabelsInput(labelsInput: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
for (const rawPart of labelsInput.split(',')) {
|
||||
const part = rawPart.trim();
|
||||
if (!part || seen.has(part)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(part);
|
||||
labels.push(part);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function buildEditableIssueDraft(issue: BeadIssue): EditableIssueDraft {
|
||||
const editableStatus: MutationStatus =
|
||||
issue.status === 'open' ||
|
||||
issue.status === 'in_progress' ||
|
||||
issue.status === 'blocked' ||
|
||||
issue.status === 'deferred' ||
|
||||
issue.status === 'closed'
|
||||
? issue.status
|
||||
: 'open';
|
||||
|
||||
return {
|
||||
title: issue.title,
|
||||
description: issue.description ?? '',
|
||||
status: editableStatus,
|
||||
priority: issue.priority,
|
||||
issueType: issue.issue_type,
|
||||
assignee: issue.assignee ?? '',
|
||||
owner: issue.owner ?? '',
|
||||
labelsInput: issue.labels.map((label) => label.trim()).filter(Boolean).join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateEditableIssueDraft(draft: EditableIssueDraft): { ok: true; errors: {} } | { ok: false; errors: EditableIssueFieldErrors } {
|
||||
const errors: EditableIssueFieldErrors = {};
|
||||
if (!draft.title.trim()) {
|
||||
errors.title = 'Title is required.';
|
||||
}
|
||||
if (!Number.isInteger(draft.priority) || draft.priority < 0 || draft.priority > 4) {
|
||||
errors.priority = 'Priority must be between 0 and 4.';
|
||||
}
|
||||
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(draft.status)) {
|
||||
errors.status = 'Status must be open, in progress, blocked, deferred, or closed.';
|
||||
}
|
||||
if (!draft.issueType.trim()) {
|
||||
errors.issueType = 'Issue type is required.';
|
||||
}
|
||||
|
||||
const parts = draft.labelsInput.split(',').map((part) => part.trim());
|
||||
if (parts.some((part) => part.length === 0) && draft.labelsInput.trim().length > 0) {
|
||||
errors.labelsInput = 'Labels must be comma-separated non-empty values.';
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
return { ok: true, errors: {} };
|
||||
}
|
||||
|
||||
function normalizeNullable(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function labelsChanged(current: string[], next: string[]): boolean {
|
||||
if (current.length !== next.length) {
|
||||
return true;
|
||||
}
|
||||
for (let i = 0; i < current.length; i += 1) {
|
||||
if (current[i] !== next[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildIssueUpdatePayload(
|
||||
issue: BeadIssue,
|
||||
draft: EditableIssueDraft,
|
||||
projectRoot: string,
|
||||
): UpdateMutationPayload | null {
|
||||
const nextTitle = draft.title.trim();
|
||||
const nextDescription = draft.description.trim();
|
||||
const nextAssignee = normalizeNullable(draft.assignee);
|
||||
const nextIssueType = draft.issueType.trim();
|
||||
const nextLabels = parseLabelsInput(draft.labelsInput);
|
||||
|
||||
const payload: UpdateMutationPayload = {
|
||||
projectRoot,
|
||||
id: issue.id,
|
||||
};
|
||||
|
||||
if (nextTitle !== issue.title) {
|
||||
payload.title = nextTitle;
|
||||
}
|
||||
|
||||
if (nextDescription !== (issue.description ?? '')) {
|
||||
payload.description = nextDescription;
|
||||
}
|
||||
|
||||
if (draft.priority !== issue.priority) {
|
||||
payload.priority = draft.priority;
|
||||
}
|
||||
|
||||
if (draft.status !== issue.status) {
|
||||
payload.status = draft.status;
|
||||
}
|
||||
|
||||
if (nextIssueType !== issue.issue_type) {
|
||||
payload.issueType = nextIssueType;
|
||||
}
|
||||
|
||||
if (nextAssignee !== (issue.assignee ?? undefined)) {
|
||||
payload.assignee = nextAssignee;
|
||||
}
|
||||
|
||||
if (labelsChanged(issue.labels, nextLabels)) {
|
||||
payload.labels = nextLabels;
|
||||
}
|
||||
|
||||
if (
|
||||
payload.title === undefined &&
|
||||
payload.description === undefined &&
|
||||
payload.status === undefined &&
|
||||
payload.priority === undefined &&
|
||||
payload.issueType === undefined &&
|
||||
payload.assignee === undefined &&
|
||||
payload.labels === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function classifyEditState(input: { dirty: boolean; saving: boolean; error: string | null }): EditState {
|
||||
if (input.saving) {
|
||||
return 'saving';
|
||||
}
|
||||
if (input.error) {
|
||||
return 'error';
|
||||
}
|
||||
if (input.dirty) {
|
||||
return 'dirty';
|
||||
}
|
||||
return 'pristine';
|
||||
}
|
||||
import type { MutationStatus, UpdateMutationPayload } from './mutations';
|
||||
import type { BeadIssue } from './types';
|
||||
|
||||
export interface EditableIssueDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
status: MutationStatus;
|
||||
priority: number;
|
||||
issueType: string;
|
||||
assignee: string;
|
||||
owner: string;
|
||||
labelsInput: string;
|
||||
}
|
||||
|
||||
export type EditableIssueFieldErrors = Partial<Record<keyof EditableIssueDraft, string>>;
|
||||
|
||||
export type EditState = 'pristine' | 'dirty' | 'saving' | 'error';
|
||||
|
||||
export function parseLabelsInput(labelsInput: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
for (const rawPart of labelsInput.split(',')) {
|
||||
const part = rawPart.trim();
|
||||
if (!part || seen.has(part)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(part);
|
||||
labels.push(part);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function buildEditableIssueDraft(issue: BeadIssue): EditableIssueDraft {
|
||||
const editableStatus: MutationStatus =
|
||||
issue.status === 'open' ||
|
||||
issue.status === 'in_progress' ||
|
||||
issue.status === 'blocked' ||
|
||||
issue.status === 'deferred' ||
|
||||
issue.status === 'closed'
|
||||
? issue.status
|
||||
: 'open';
|
||||
|
||||
return {
|
||||
title: issue.title,
|
||||
description: issue.description ?? '',
|
||||
status: editableStatus,
|
||||
priority: issue.priority,
|
||||
issueType: issue.issue_type,
|
||||
assignee: issue.assignee ?? '',
|
||||
owner: issue.owner ?? '',
|
||||
labelsInput: issue.labels.map((label) => label.trim()).filter(Boolean).join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateEditableIssueDraft(draft: EditableIssueDraft): { ok: true; errors: {} } | { ok: false; errors: EditableIssueFieldErrors } {
|
||||
const errors: EditableIssueFieldErrors = {};
|
||||
if (!draft.title.trim()) {
|
||||
errors.title = 'Title is required.';
|
||||
}
|
||||
if (!Number.isInteger(draft.priority) || draft.priority < 0 || draft.priority > 4) {
|
||||
errors.priority = 'Priority must be between 0 and 4.';
|
||||
}
|
||||
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(draft.status)) {
|
||||
errors.status = 'Status must be open, in progress, blocked, deferred, or closed.';
|
||||
}
|
||||
if (!draft.issueType.trim()) {
|
||||
errors.issueType = 'Issue type is required.';
|
||||
}
|
||||
|
||||
const parts = draft.labelsInput.split(',').map((part) => part.trim());
|
||||
if (parts.some((part) => part.length === 0) && draft.labelsInput.trim().length > 0) {
|
||||
errors.labelsInput = 'Labels must be comma-separated non-empty values.';
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
return { ok: true, errors: {} };
|
||||
}
|
||||
|
||||
function normalizeNullable(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function labelsChanged(current: string[], next: string[]): boolean {
|
||||
if (current.length !== next.length) {
|
||||
return true;
|
||||
}
|
||||
for (let i = 0; i < current.length; i += 1) {
|
||||
if (current[i] !== next[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildIssueUpdatePayload(
|
||||
issue: BeadIssue,
|
||||
draft: EditableIssueDraft,
|
||||
projectRoot: string,
|
||||
): UpdateMutationPayload | null {
|
||||
const nextTitle = draft.title.trim();
|
||||
const nextDescription = draft.description.trim();
|
||||
const nextAssignee = normalizeNullable(draft.assignee);
|
||||
const nextIssueType = draft.issueType.trim();
|
||||
const nextLabels = parseLabelsInput(draft.labelsInput);
|
||||
|
||||
const payload: UpdateMutationPayload = {
|
||||
projectRoot,
|
||||
id: issue.id,
|
||||
};
|
||||
|
||||
if (nextTitle !== issue.title) {
|
||||
payload.title = nextTitle;
|
||||
}
|
||||
|
||||
if (nextDescription !== (issue.description ?? '')) {
|
||||
payload.description = nextDescription;
|
||||
}
|
||||
|
||||
if (draft.priority !== issue.priority) {
|
||||
payload.priority = draft.priority;
|
||||
}
|
||||
|
||||
if (draft.status !== issue.status) {
|
||||
payload.status = draft.status;
|
||||
}
|
||||
|
||||
if (nextIssueType !== issue.issue_type) {
|
||||
payload.issueType = nextIssueType;
|
||||
}
|
||||
|
||||
if (nextAssignee !== (issue.assignee ?? undefined)) {
|
||||
payload.assignee = nextAssignee;
|
||||
}
|
||||
|
||||
if (labelsChanged(issue.labels, nextLabels)) {
|
||||
payload.labels = nextLabels;
|
||||
}
|
||||
|
||||
if (
|
||||
payload.title === undefined &&
|
||||
payload.description === undefined &&
|
||||
payload.status === undefined &&
|
||||
payload.priority === undefined &&
|
||||
payload.issueType === undefined &&
|
||||
payload.assignee === undefined &&
|
||||
payload.labels === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function classifyEditState(input: { dirty: boolean; saving: boolean; error: string | null }): EditState {
|
||||
if (input.saving) {
|
||||
return 'saving';
|
||||
}
|
||||
if (input.error) {
|
||||
return 'error';
|
||||
}
|
||||
if (input.dirty) {
|
||||
return 'dirty';
|
||||
}
|
||||
return 'pristine';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { BeadIssue } from './types';
|
||||
|
||||
export const KANBAN_STATUSES = ['ready', 'in_progress', 'blocked', 'closed'] as const;
|
||||
|
||||
export type KanbanStatus = (typeof KANBAN_STATUSES)[number];
|
||||
|
||||
export type KanbanColumns = Record<KanbanStatus, BeadIssue[]>;
|
||||
|
||||
import type { BeadIssue } from './types';
|
||||
|
||||
export const KANBAN_STATUSES = ['ready', 'in_progress', 'blocked', 'closed'] as const;
|
||||
|
||||
export type KanbanStatus = (typeof KANBAN_STATUSES)[number];
|
||||
|
||||
export type KanbanColumns = Record<KanbanStatus, BeadIssue[]>;
|
||||
|
||||
export interface KanbanFilterOptions {
|
||||
query?: string;
|
||||
type?: string;
|
||||
|
|
@ -13,43 +13,43 @@ export interface KanbanFilterOptions {
|
|||
showClosed?: boolean;
|
||||
epicId?: string;
|
||||
}
|
||||
|
||||
export interface KanbanStats {
|
||||
total: number;
|
||||
ready: number;
|
||||
active: number;
|
||||
blocked: number;
|
||||
done: number;
|
||||
p0: number;
|
||||
}
|
||||
|
||||
export type BoardMutationStatus = 'open' | 'in_progress' | 'blocked' | 'closed';
|
||||
|
||||
export interface BlockedTreeNode {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface ExecutionChecklistItem {
|
||||
key: 'owner_assigned' | 'no_open_blockers' | 'quality_signal' | 'execution_compatible';
|
||||
label: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
function isReviewStatus(status: string): boolean {
|
||||
return status.toLowerCase().includes('review');
|
||||
}
|
||||
|
||||
function issueSort(a: BeadIssue, b: BeadIssue): number {
|
||||
const priorityDiff = a.priority - b.priority;
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
|
||||
return b.updated_at.localeCompare(a.updated_at);
|
||||
}
|
||||
|
||||
|
||||
export interface KanbanStats {
|
||||
total: number;
|
||||
ready: number;
|
||||
active: number;
|
||||
blocked: number;
|
||||
done: number;
|
||||
p0: number;
|
||||
}
|
||||
|
||||
export type BoardMutationStatus = 'open' | 'in_progress' | 'blocked' | 'closed';
|
||||
|
||||
export interface BlockedTreeNode {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface ExecutionChecklistItem {
|
||||
key: 'owner_assigned' | 'no_open_blockers' | 'quality_signal' | 'execution_compatible';
|
||||
label: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
function isReviewStatus(status: string): boolean {
|
||||
return status.toLowerCase().includes('review');
|
||||
}
|
||||
|
||||
function issueSort(a: BeadIssue, b: BeadIssue): number {
|
||||
const priorityDiff = a.priority - b.priority;
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
|
||||
return b.updated_at.localeCompare(a.updated_at);
|
||||
}
|
||||
|
||||
export function hasOpenBlockers(issues: BeadIssue[], targetId: string): boolean {
|
||||
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
const target = issueById.get(targetId);
|
||||
|
|
@ -65,28 +65,28 @@ export function hasOpenBlockers(issues: BeadIssue[], targetId: string): boolean
|
|||
return blocker ? blocker.status !== 'closed' : false;
|
||||
});
|
||||
}
|
||||
|
||||
function hasQualitySignal(issue: BeadIssue): boolean {
|
||||
const description = issue.description?.trim() ?? '';
|
||||
if (description.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (issue.labels.some((label) => label.toLowerCase().includes('accept'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const acceptance = issue.metadata.acceptance;
|
||||
if (typeof acceptance === 'string') {
|
||||
return acceptance.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(acceptance)) {
|
||||
return acceptance.length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function hasQualitySignal(issue: BeadIssue): boolean {
|
||||
const description = issue.description?.trim() ?? '';
|
||||
if (description.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (issue.labels.some((label) => label.toLowerCase().includes('accept'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const acceptance = issue.metadata.acceptance;
|
||||
if (typeof acceptance === 'string') {
|
||||
return acceptance.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(acceptance)) {
|
||||
return acceptance.length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function deriveBlockedIds(issues: BeadIssue[]): Set<string> {
|
||||
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
const blockedIds = new Set<string>();
|
||||
|
|
@ -105,20 +105,20 @@ export function deriveBlockedIds(issues: BeadIssue[]): Set<string> {
|
|||
|
||||
return blockedIds;
|
||||
}
|
||||
|
||||
function laneForIssue(issue: BeadIssue, blockedIds: Set<string>): KanbanStatus {
|
||||
if (issue.status === 'closed') {
|
||||
return 'closed';
|
||||
}
|
||||
if (issue.status === 'blocked' || blockedIds.has(issue.id)) {
|
||||
return 'blocked';
|
||||
}
|
||||
if (issue.status === 'in_progress' || isReviewStatus(issue.status)) {
|
||||
return 'in_progress';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
|
||||
function laneForIssue(issue: BeadIssue, blockedIds: Set<string>): KanbanStatus {
|
||||
if (issue.status === 'closed') {
|
||||
return 'closed';
|
||||
}
|
||||
if (issue.status === 'blocked' || blockedIds.has(issue.id)) {
|
||||
return 'blocked';
|
||||
}
|
||||
if (issue.status === 'in_progress' || isReviewStatus(issue.status)) {
|
||||
return 'in_progress';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOptions): BeadIssue[] {
|
||||
const query = (filters.query ?? '').trim().toLowerCase();
|
||||
const type = (filters.type ?? '').trim().toLowerCase();
|
||||
|
|
@ -158,68 +158,68 @@ export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOpt
|
|||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildKanbanColumns(issues: BeadIssue[]): KanbanColumns {
|
||||
const columns = {
|
||||
ready: [],
|
||||
in_progress: [],
|
||||
blocked: [],
|
||||
closed: [],
|
||||
} as KanbanColumns;
|
||||
|
||||
const blockedIds = deriveBlockedIds(issues);
|
||||
for (const issue of issues) {
|
||||
const lane = laneForIssue(issue, blockedIds);
|
||||
if (lane === 'ready' && issue.issue_type === 'epic') {
|
||||
continue;
|
||||
}
|
||||
columns[lane].push(issue);
|
||||
}
|
||||
|
||||
for (const status of KANBAN_STATUSES) {
|
||||
columns[status].sort(issueSort);
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
export function buildKanbanStats(issues: BeadIssue[]): KanbanStats {
|
||||
const columns = buildKanbanColumns(issues);
|
||||
return {
|
||||
total: issues.length,
|
||||
ready: columns.ready.length,
|
||||
active: columns.in_progress.length,
|
||||
blocked: columns.blocked.length,
|
||||
done: columns.closed.length,
|
||||
p0: issues.filter((x) => x.priority === 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function laneToMutationStatus(status: KanbanStatus): BoardMutationStatus {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'open';
|
||||
case 'in_progress':
|
||||
return 'in_progress';
|
||||
case 'blocked':
|
||||
return 'blocked';
|
||||
case 'closed':
|
||||
return 'closed';
|
||||
default:
|
||||
return 'open';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function buildKanbanColumns(issues: BeadIssue[]): KanbanColumns {
|
||||
const columns = {
|
||||
ready: [],
|
||||
in_progress: [],
|
||||
blocked: [],
|
||||
closed: [],
|
||||
} as KanbanColumns;
|
||||
|
||||
const blockedIds = deriveBlockedIds(issues);
|
||||
for (const issue of issues) {
|
||||
const lane = laneForIssue(issue, blockedIds);
|
||||
if (lane === 'ready' && issue.issue_type === 'epic') {
|
||||
continue;
|
||||
}
|
||||
columns[lane].push(issue);
|
||||
}
|
||||
|
||||
for (const status of KANBAN_STATUSES) {
|
||||
columns[status].sort(issueSort);
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
export function buildKanbanStats(issues: BeadIssue[]): KanbanStats {
|
||||
const columns = buildKanbanColumns(issues);
|
||||
return {
|
||||
total: issues.length,
|
||||
ready: columns.ready.length,
|
||||
active: columns.in_progress.length,
|
||||
blocked: columns.blocked.length,
|
||||
done: columns.closed.length,
|
||||
p0: issues.filter((x) => x.priority === 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function laneToMutationStatus(status: KanbanStatus): BoardMutationStatus {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'open';
|
||||
case 'in_progress':
|
||||
return 'in_progress';
|
||||
case 'blocked':
|
||||
return 'blocked';
|
||||
case 'closed':
|
||||
return 'closed';
|
||||
default:
|
||||
return 'open';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBlockedByTree(
|
||||
issues: BeadIssue[],
|
||||
focusId: string | null,
|
||||
options: { maxNodes?: number } = {},
|
||||
issues: BeadIssue[],
|
||||
focusId: string | null,
|
||||
options: { maxNodes?: number } = {},
|
||||
): { total: number; nodes: BlockedTreeNode[] } {
|
||||
if (!focusId) {
|
||||
return { total: 0, nodes: [] };
|
||||
}
|
||||
|
||||
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
if (!focusId) {
|
||||
return { total: 0, nodes: [] };
|
||||
}
|
||||
|
||||
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
const blockersByIssue = new Map<string, string[]>();
|
||||
for (const issue of issues) {
|
||||
const blockers = [
|
||||
|
|
@ -231,46 +231,46 @@ export function buildBlockedByTree(
|
|||
].sort((a, b) => a.localeCompare(b));
|
||||
blockersByIssue.set(issue.id, blockers);
|
||||
}
|
||||
|
||||
const maxNodes = Math.max(1, options.maxNodes ?? 12);
|
||||
const visited = new Set<string>([focusId]);
|
||||
const queued = new Set<string>();
|
||||
const queue: Array<{ id: string; level: number }> = [{ id: focusId, level: 0 }];
|
||||
const nodes: BlockedTreeNode[] = [];
|
||||
let total = 0;
|
||||
|
||||
|
||||
const maxNodes = Math.max(1, options.maxNodes ?? 12);
|
||||
const visited = new Set<string>([focusId]);
|
||||
const queued = new Set<string>();
|
||||
const queue: Array<{ id: string; level: number }> = [{ id: focusId, level: 0 }];
|
||||
const nodes: BlockedTreeNode[] = [];
|
||||
let total = 0;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as { id: string; level: number };
|
||||
const blockers = blockersByIssue.get(current.id) ?? [];
|
||||
for (const blockerId of blockers) {
|
||||
if (visited.has(blockerId) || queued.has(blockerId)) continue;
|
||||
queued.add(blockerId);
|
||||
total += 1;
|
||||
const blocker = issueById.get(blockerId);
|
||||
if (nodes.length < maxNodes) {
|
||||
nodes.push({
|
||||
id: blockerId,
|
||||
title: blocker?.title ?? blockerId,
|
||||
level: current.level + 1,
|
||||
});
|
||||
}
|
||||
queue.push({ id: blockerId, level: current.level + 1 });
|
||||
}
|
||||
visited.add(current.id);
|
||||
}
|
||||
|
||||
return { total, nodes };
|
||||
}
|
||||
|
||||
export function findIssueLane(columns: KanbanColumns, issueId: string): KanbanStatus | null {
|
||||
for (const status of KANBAN_STATUSES) {
|
||||
if (columns[status].some((issue) => issue.id === issueId)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
total += 1;
|
||||
const blocker = issueById.get(blockerId);
|
||||
if (nodes.length < maxNodes) {
|
||||
nodes.push({
|
||||
id: blockerId,
|
||||
title: blocker?.title ?? blockerId,
|
||||
level: current.level + 1,
|
||||
});
|
||||
}
|
||||
queue.push({ id: blockerId, level: current.level + 1 });
|
||||
}
|
||||
visited.add(current.id);
|
||||
}
|
||||
|
||||
return { total, nodes };
|
||||
}
|
||||
|
||||
export function findIssueLane(columns: KanbanColumns, issueId: string): KanbanStatus | null {
|
||||
for (const status of KANBAN_STATUSES) {
|
||||
if (columns[status].some((issue) => issue.id === issueId)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildUnblocksCountByIssue(issues: BeadIssue[]): Map<string, number> {
|
||||
const unblocksByIssue = new Map<string, number>();
|
||||
for (const issue of issues) {
|
||||
|
|
@ -287,74 +287,74 @@ export function buildUnblocksCountByIssue(issues: BeadIssue[]): Map<string, numb
|
|||
}
|
||||
return unblocksByIssue;
|
||||
}
|
||||
|
||||
export function pickNextActionableIssue(columns: KanbanColumns, issues: BeadIssue[]): BeadIssue | null {
|
||||
if (columns.ready.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unblocksByIssue = buildUnblocksCountByIssue(issues);
|
||||
const ranked = [...columns.ready].sort((a, b) => {
|
||||
const priorityDiff = a.priority - b.priority;
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
|
||||
const unblocksDiff = (unblocksByIssue.get(b.id) ?? 0) - (unblocksByIssue.get(a.id) ?? 0);
|
||||
if (unblocksDiff !== 0) {
|
||||
return unblocksDiff;
|
||||
}
|
||||
|
||||
const updatedDiff = b.updated_at.localeCompare(a.updated_at);
|
||||
if (updatedDiff !== 0) {
|
||||
return updatedDiff;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
return ranked[0] ?? null;
|
||||
}
|
||||
|
||||
export function formatUpdatedRecency(updatedAt: string | null | undefined, nowMs = Date.now()): string {
|
||||
if (!updatedAt) {
|
||||
return 'updated unknown';
|
||||
}
|
||||
|
||||
const parsed = Date.parse(updatedAt);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return 'updated unknown';
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, Math.floor((nowMs - parsed) / 1000));
|
||||
if (elapsedSeconds < 60) {
|
||||
return 'updated now';
|
||||
}
|
||||
if (elapsedSeconds < 3600) {
|
||||
return `updated ${Math.floor(elapsedSeconds / 60)}m`;
|
||||
}
|
||||
if (elapsedSeconds < 86400) {
|
||||
return `updated ${Math.floor(elapsedSeconds / 3600)}h`;
|
||||
}
|
||||
if (elapsedSeconds < 604800) {
|
||||
return `updated ${Math.floor(elapsedSeconds / 86400)}d`;
|
||||
}
|
||||
return `updated ${Math.floor(elapsedSeconds / 604800)}w`;
|
||||
}
|
||||
|
||||
export function buildExecutionChecklist(issue: BeadIssue, issues: BeadIssue[]): ExecutionChecklistItem[] {
|
||||
const columns = buildKanbanColumns(issues);
|
||||
const lane = findIssueLane(columns, issue.id);
|
||||
const openBlockers = hasOpenBlockers(issues, issue.id);
|
||||
|
||||
return [
|
||||
{ key: 'owner_assigned', label: 'Owner assigned', passed: Boolean(issue.owner?.trim()) },
|
||||
{ key: 'no_open_blockers', label: 'Not blocked by open blockers', passed: !openBlockers },
|
||||
{ key: 'quality_signal', label: 'Has acceptance or description signal', passed: hasQualitySignal(issue) },
|
||||
{
|
||||
key: 'execution_compatible',
|
||||
label: 'Execution-compatible status (ready or in progress)',
|
||||
passed: lane === 'ready' || lane === 'in_progress',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function pickNextActionableIssue(columns: KanbanColumns, issues: BeadIssue[]): BeadIssue | null {
|
||||
if (columns.ready.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unblocksByIssue = buildUnblocksCountByIssue(issues);
|
||||
const ranked = [...columns.ready].sort((a, b) => {
|
||||
const priorityDiff = a.priority - b.priority;
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
|
||||
const unblocksDiff = (unblocksByIssue.get(b.id) ?? 0) - (unblocksByIssue.get(a.id) ?? 0);
|
||||
if (unblocksDiff !== 0) {
|
||||
return unblocksDiff;
|
||||
}
|
||||
|
||||
const updatedDiff = b.updated_at.localeCompare(a.updated_at);
|
||||
if (updatedDiff !== 0) {
|
||||
return updatedDiff;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
return ranked[0] ?? null;
|
||||
}
|
||||
|
||||
export function formatUpdatedRecency(updatedAt: string | null | undefined, nowMs = Date.now()): string {
|
||||
if (!updatedAt) {
|
||||
return 'updated unknown';
|
||||
}
|
||||
|
||||
const parsed = Date.parse(updatedAt);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return 'updated unknown';
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, Math.floor((nowMs - parsed) / 1000));
|
||||
if (elapsedSeconds < 60) {
|
||||
return 'updated now';
|
||||
}
|
||||
if (elapsedSeconds < 3600) {
|
||||
return `updated ${Math.floor(elapsedSeconds / 60)}m`;
|
||||
}
|
||||
if (elapsedSeconds < 86400) {
|
||||
return `updated ${Math.floor(elapsedSeconds / 3600)}h`;
|
||||
}
|
||||
if (elapsedSeconds < 604800) {
|
||||
return `updated ${Math.floor(elapsedSeconds / 86400)}d`;
|
||||
}
|
||||
return `updated ${Math.floor(elapsedSeconds / 604800)}w`;
|
||||
}
|
||||
|
||||
export function buildExecutionChecklist(issue: BeadIssue, issues: BeadIssue[]): ExecutionChecklistItem[] {
|
||||
const columns = buildKanbanColumns(issues);
|
||||
const lane = findIssueLane(columns, issue.id);
|
||||
const openBlockers = hasOpenBlockers(issues, issue.id);
|
||||
|
||||
return [
|
||||
{ key: 'owner_assigned', label: 'Owner assigned', passed: Boolean(issue.owner?.trim()) },
|
||||
{ key: 'no_open_blockers', label: 'Not blocked by open blockers', passed: !openBlockers },
|
||||
{ key: 'quality_signal', label: 'Has acceptance or description signal', passed: hasQualitySignal(issue) },
|
||||
{
|
||||
key: 'execution_compatible',
|
||||
label: 'Execution-compatible status (ready or in progress)',
|
||||
passed: lane === 'ready' || lane === 'in_progress',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,335 +1,335 @@
|
|||
import { runBdCommand, type RunBdCommandResult } from './bridge';
|
||||
import { issuesEventBus } from './realtime';
|
||||
|
||||
export type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
|
||||
export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed';
|
||||
|
||||
interface MutationBasePayload {
|
||||
projectRoot: string;
|
||||
bdPath?: string;
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
export interface CreateMutationPayload extends MutationBasePayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
issueType?: string;
|
||||
assignee?: string;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: MutationStatus;
|
||||
priority?: number;
|
||||
issueType?: string;
|
||||
assignee?: string;
|
||||
labels?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CloseMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ReopenMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CommentMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type MutationPayload =
|
||||
| CreateMutationPayload
|
||||
| UpdateMutationPayload
|
||||
| CloseMutationPayload
|
||||
| ReopenMutationPayload
|
||||
| CommentMutationPayload;
|
||||
|
||||
export interface MutationErrorShape {
|
||||
classification: 'bad_args' | 'not_found' | 'timeout' | 'non_zero_exit' | 'unknown';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MutationResponse {
|
||||
ok: boolean;
|
||||
operation: MutationOperation;
|
||||
command: RunBdCommandResult;
|
||||
error?: MutationErrorShape;
|
||||
}
|
||||
|
||||
export class MutationValidationError extends Error {
|
||||
readonly code = 'MUTATION_VALIDATION_ERROR';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MutationValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new MutationValidationError(`"${field}" is required.`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
// Remove control characters that could cause issues in command execution
|
||||
// Preserve backslashes for Windows paths and punctuation for user text
|
||||
const sanitized = trimmed.replace(/[\x00-\x1f\x7f]/g, '');
|
||||
if (!sanitized) {
|
||||
throw new MutationValidationError(`"${field}" contains only invalid characters.`);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new MutationValidationError('Expected a string value.');
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function asOptionalPriority(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 4) {
|
||||
throw new MutationValidationError('"priority" must be a number between 0 and 4.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asOptionalLabels(value: unknown): string[] | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new MutationValidationError('"labels" must be an array of strings.');
|
||||
}
|
||||
const labels = value.map((label) => {
|
||||
if (typeof label !== 'string' || !label.trim()) {
|
||||
throw new MutationValidationError('"labels" must be an array of non-empty strings.');
|
||||
}
|
||||
return label.trim();
|
||||
});
|
||||
|
||||
return labels.length ? labels : undefined;
|
||||
}
|
||||
|
||||
function asOptionalMetadata(value: unknown): Record<string, unknown> | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new MutationValidationError('"metadata" must be a JSON object.');
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asOptionalStatus(value: unknown): MutationStatus | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const status = asNonEmptyString(value, 'status');
|
||||
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status)) {
|
||||
throw new MutationValidationError('"status" is invalid.');
|
||||
}
|
||||
return status as MutationStatus;
|
||||
}
|
||||
|
||||
function parseBasePayload(raw: unknown): MutationBasePayload {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new MutationValidationError('Payload must be a JSON object.');
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
return {
|
||||
projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'),
|
||||
bdPath: asOptionalString(data.bdPath),
|
||||
actor: asOptionalString(data.actor),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateMutationPayload(operation: MutationOperation, payload: unknown): MutationPayload {
|
||||
const base = parseBasePayload(payload);
|
||||
const data = payload as Record<string, unknown>;
|
||||
|
||||
if (operation === 'create') {
|
||||
return {
|
||||
...base,
|
||||
title: asNonEmptyString(data.title, 'title'),
|
||||
description: asOptionalString(data.description),
|
||||
priority: asOptionalPriority(data.priority),
|
||||
issueType: asOptionalString(data.issueType),
|
||||
assignee: asOptionalString(data.assignee),
|
||||
labels: asOptionalLabels(data.labels),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const mapped: UpdateMutationPayload = {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
title: asOptionalString(data.title),
|
||||
description: asOptionalString(data.description),
|
||||
status: asOptionalStatus(data.status),
|
||||
priority: asOptionalPriority(data.priority),
|
||||
issueType: asOptionalString(data.issueType),
|
||||
assignee: asOptionalString(data.assignee),
|
||||
labels: asOptionalLabels(data.labels),
|
||||
metadata: asOptionalMetadata(data.metadata),
|
||||
};
|
||||
|
||||
if (
|
||||
!mapped.title &&
|
||||
!mapped.description &&
|
||||
!mapped.status &&
|
||||
mapped.priority === undefined &&
|
||||
!mapped.issueType &&
|
||||
!mapped.assignee &&
|
||||
!mapped.labels &&
|
||||
!mapped.metadata
|
||||
) {
|
||||
throw new MutationValidationError('At least one update field is required.');
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (operation === 'close') {
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
reason: asOptionalString(data.reason),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === 'reopen') {
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
reason: asOptionalString(data.reason),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
text: asNonEmptyString(data.text, 'text'),
|
||||
};
|
||||
}
|
||||
|
||||
function pushOptionalArg(args: string[], flag: string, value: string | undefined): void {
|
||||
if (value) {
|
||||
args.push(flag, value);
|
||||
}
|
||||
}
|
||||
|
||||
function pushOptionalLabels(args: string[], labels: string[] | undefined): void {
|
||||
if (labels && labels.length > 0) {
|
||||
args.push('--set-labels', labels.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBdMutationArgs(operation: MutationOperation, payload: MutationPayload): string[] {
|
||||
if (operation === 'create') {
|
||||
const data = payload as CreateMutationPayload;
|
||||
const args = ['create', data.title];
|
||||
pushOptionalArg(args, '-d', data.description);
|
||||
if (data.priority !== undefined) {
|
||||
args.push('-p', String(data.priority));
|
||||
}
|
||||
pushOptionalArg(args, '-t', data.issueType);
|
||||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const data = payload as UpdateMutationPayload;
|
||||
const args = ['update', data.id];
|
||||
pushOptionalArg(args, '--title', data.title);
|
||||
pushOptionalArg(args, '-d', data.description);
|
||||
pushOptionalArg(args, '-s', data.status);
|
||||
if (data.priority !== undefined) {
|
||||
args.push('-p', String(data.priority));
|
||||
}
|
||||
pushOptionalArg(args, '-t', data.issueType);
|
||||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
if (data.metadata) {
|
||||
args.push(`--metadata=${JSON.stringify(data.metadata)}`);
|
||||
}
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'close') {
|
||||
const data = payload as CloseMutationPayload;
|
||||
const args = ['close', data.id];
|
||||
pushOptionalArg(args, '-r', data.reason);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'reopen') {
|
||||
const data = payload as ReopenMutationPayload;
|
||||
const args = ['reopen', data.id];
|
||||
pushOptionalArg(args, '-r', data.reason);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
const data = payload as CommentMutationPayload;
|
||||
return ['comments', 'add', data.id, data.text, '--json'];
|
||||
}
|
||||
|
||||
interface ExecuteMutationDeps {
|
||||
runBdCommand: typeof runBdCommand;
|
||||
}
|
||||
|
||||
export async function executeMutation(
|
||||
operation: MutationOperation,
|
||||
payload: MutationPayload,
|
||||
deps: Partial<ExecuteMutationDeps> = {},
|
||||
): Promise<MutationResponse> {
|
||||
const runner = deps.runBdCommand ?? runBdCommand;
|
||||
const args = payload.actor
|
||||
? ['--actor', payload.actor, ...buildBdMutationArgs(operation, payload)]
|
||||
: buildBdMutationArgs(operation, payload);
|
||||
const command = await runner({
|
||||
projectRoot: payload.projectRoot,
|
||||
args,
|
||||
});
|
||||
|
||||
if (!command.success) {
|
||||
return {
|
||||
ok: false,
|
||||
operation,
|
||||
command,
|
||||
error: {
|
||||
classification: command.classification ?? 'unknown',
|
||||
message: command.stderr || command.error || 'Mutation command failed.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
issuesEventBus.emit(payload.projectRoot, undefined, 'changed');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
operation,
|
||||
command,
|
||||
};
|
||||
}
|
||||
import { runBdCommand, type RunBdCommandResult } from './bridge';
|
||||
import { issuesEventBus } from './realtime';
|
||||
|
||||
export type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
|
||||
export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed';
|
||||
|
||||
interface MutationBasePayload {
|
||||
projectRoot: string;
|
||||
bdPath?: string;
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
export interface CreateMutationPayload extends MutationBasePayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
issueType?: string;
|
||||
assignee?: string;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: MutationStatus;
|
||||
priority?: number;
|
||||
issueType?: string;
|
||||
assignee?: string;
|
||||
labels?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CloseMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ReopenMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CommentMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type MutationPayload =
|
||||
| CreateMutationPayload
|
||||
| UpdateMutationPayload
|
||||
| CloseMutationPayload
|
||||
| ReopenMutationPayload
|
||||
| CommentMutationPayload;
|
||||
|
||||
export interface MutationErrorShape {
|
||||
classification: 'bad_args' | 'not_found' | 'timeout' | 'non_zero_exit' | 'unknown';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MutationResponse {
|
||||
ok: boolean;
|
||||
operation: MutationOperation;
|
||||
command: RunBdCommandResult;
|
||||
error?: MutationErrorShape;
|
||||
}
|
||||
|
||||
export class MutationValidationError extends Error {
|
||||
readonly code = 'MUTATION_VALIDATION_ERROR';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MutationValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new MutationValidationError(`"${field}" is required.`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
// Remove control characters that could cause issues in command execution
|
||||
// Preserve backslashes for Windows paths and punctuation for user text
|
||||
const sanitized = trimmed.replace(/[\x00-\x1f\x7f]/g, '');
|
||||
if (!sanitized) {
|
||||
throw new MutationValidationError(`"${field}" contains only invalid characters.`);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new MutationValidationError('Expected a string value.');
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function asOptionalPriority(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 4) {
|
||||
throw new MutationValidationError('"priority" must be a number between 0 and 4.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asOptionalLabels(value: unknown): string[] | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new MutationValidationError('"labels" must be an array of strings.');
|
||||
}
|
||||
const labels = value.map((label) => {
|
||||
if (typeof label !== 'string' || !label.trim()) {
|
||||
throw new MutationValidationError('"labels" must be an array of non-empty strings.');
|
||||
}
|
||||
return label.trim();
|
||||
});
|
||||
|
||||
return labels.length ? labels : undefined;
|
||||
}
|
||||
|
||||
function asOptionalMetadata(value: unknown): Record<string, unknown> | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new MutationValidationError('"metadata" must be a JSON object.');
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asOptionalStatus(value: unknown): MutationStatus | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const status = asNonEmptyString(value, 'status');
|
||||
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status)) {
|
||||
throw new MutationValidationError('"status" is invalid.');
|
||||
}
|
||||
return status as MutationStatus;
|
||||
}
|
||||
|
||||
function parseBasePayload(raw: unknown): MutationBasePayload {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new MutationValidationError('Payload must be a JSON object.');
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
return {
|
||||
projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'),
|
||||
bdPath: asOptionalString(data.bdPath),
|
||||
actor: asOptionalString(data.actor),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateMutationPayload(operation: MutationOperation, payload: unknown): MutationPayload {
|
||||
const base = parseBasePayload(payload);
|
||||
const data = payload as Record<string, unknown>;
|
||||
|
||||
if (operation === 'create') {
|
||||
return {
|
||||
...base,
|
||||
title: asNonEmptyString(data.title, 'title'),
|
||||
description: asOptionalString(data.description),
|
||||
priority: asOptionalPriority(data.priority),
|
||||
issueType: asOptionalString(data.issueType),
|
||||
assignee: asOptionalString(data.assignee),
|
||||
labels: asOptionalLabels(data.labels),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const mapped: UpdateMutationPayload = {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
title: asOptionalString(data.title),
|
||||
description: asOptionalString(data.description),
|
||||
status: asOptionalStatus(data.status),
|
||||
priority: asOptionalPriority(data.priority),
|
||||
issueType: asOptionalString(data.issueType),
|
||||
assignee: asOptionalString(data.assignee),
|
||||
labels: asOptionalLabels(data.labels),
|
||||
metadata: asOptionalMetadata(data.metadata),
|
||||
};
|
||||
|
||||
if (
|
||||
!mapped.title &&
|
||||
!mapped.description &&
|
||||
!mapped.status &&
|
||||
mapped.priority === undefined &&
|
||||
!mapped.issueType &&
|
||||
!mapped.assignee &&
|
||||
!mapped.labels &&
|
||||
!mapped.metadata
|
||||
) {
|
||||
throw new MutationValidationError('At least one update field is required.');
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (operation === 'close') {
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
reason: asOptionalString(data.reason),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === 'reopen') {
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
reason: asOptionalString(data.reason),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
text: asNonEmptyString(data.text, 'text'),
|
||||
};
|
||||
}
|
||||
|
||||
function pushOptionalArg(args: string[], flag: string, value: string | undefined): void {
|
||||
if (value) {
|
||||
args.push(flag, value);
|
||||
}
|
||||
}
|
||||
|
||||
function pushOptionalLabels(args: string[], labels: string[] | undefined): void {
|
||||
if (labels && labels.length > 0) {
|
||||
args.push('--set-labels', labels.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBdMutationArgs(operation: MutationOperation, payload: MutationPayload): string[] {
|
||||
if (operation === 'create') {
|
||||
const data = payload as CreateMutationPayload;
|
||||
const args = ['create', data.title];
|
||||
pushOptionalArg(args, '-d', data.description);
|
||||
if (data.priority !== undefined) {
|
||||
args.push('-p', String(data.priority));
|
||||
}
|
||||
pushOptionalArg(args, '-t', data.issueType);
|
||||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const data = payload as UpdateMutationPayload;
|
||||
const args = ['update', data.id];
|
||||
pushOptionalArg(args, '--title', data.title);
|
||||
pushOptionalArg(args, '-d', data.description);
|
||||
pushOptionalArg(args, '-s', data.status);
|
||||
if (data.priority !== undefined) {
|
||||
args.push('-p', String(data.priority));
|
||||
}
|
||||
pushOptionalArg(args, '-t', data.issueType);
|
||||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
if (data.metadata) {
|
||||
args.push(`--metadata=${JSON.stringify(data.metadata)}`);
|
||||
}
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'close') {
|
||||
const data = payload as CloseMutationPayload;
|
||||
const args = ['close', data.id];
|
||||
pushOptionalArg(args, '-r', data.reason);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'reopen') {
|
||||
const data = payload as ReopenMutationPayload;
|
||||
const args = ['reopen', data.id];
|
||||
pushOptionalArg(args, '-r', data.reason);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
const data = payload as CommentMutationPayload;
|
||||
return ['comments', 'add', data.id, data.text, '--json'];
|
||||
}
|
||||
|
||||
interface ExecuteMutationDeps {
|
||||
runBdCommand: typeof runBdCommand;
|
||||
}
|
||||
|
||||
export async function executeMutation(
|
||||
operation: MutationOperation,
|
||||
payload: MutationPayload,
|
||||
deps: Partial<ExecuteMutationDeps> = {},
|
||||
): Promise<MutationResponse> {
|
||||
const runner = deps.runBdCommand ?? runBdCommand;
|
||||
const args = payload.actor
|
||||
? ['--actor', payload.actor, ...buildBdMutationArgs(operation, payload)]
|
||||
: buildBdMutationArgs(operation, payload);
|
||||
const command = await runner({
|
||||
projectRoot: payload.projectRoot,
|
||||
args,
|
||||
});
|
||||
|
||||
if (!command.success) {
|
||||
return {
|
||||
ok: false,
|
||||
operation,
|
||||
command,
|
||||
error: {
|
||||
classification: command.classification ?? 'unknown',
|
||||
message: command.stderr || command.error || 'Mutation command failed.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
issuesEventBus.emit(payload.projectRoot, undefined, 'changed');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
operation,
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,99 +1,99 @@
|
|||
import type { BeadDependency, BeadIssue, ParseableBeadIssue } from './types';
|
||||
|
||||
export interface ParseIssuesOptions {
|
||||
includeTombstones?: boolean;
|
||||
skipAgentFilter?: boolean;
|
||||
}
|
||||
|
||||
function normalizeDependencies(value: unknown): BeadDependency[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dep = item as { type?: unknown; target?: unknown; depends_on_id?: unknown };
|
||||
if (typeof dep.type !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = typeof dep.target === 'string' ? dep.target : typeof dep.depends_on_id === 'string' ? dep.depends_on_id : null;
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedType = dep.type === 'parent-child' ? 'parent' : dep.type;
|
||||
|
||||
return {
|
||||
type: normalizedType as BeadDependency['type'],
|
||||
target,
|
||||
};
|
||||
})
|
||||
.filter((dep): dep is BeadDependency => dep !== null);
|
||||
}
|
||||
|
||||
function normalizeIssue(raw: ParseableBeadIssue): BeadIssue {
|
||||
return {
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
description: typeof raw.description === 'string' ? raw.description : null,
|
||||
status: (raw.status ?? 'open') as BeadIssue['status'],
|
||||
priority: typeof raw.priority === 'number' ? raw.priority : 2,
|
||||
issue_type: (raw.issue_type ?? 'task') as BeadIssue['issue_type'],
|
||||
assignee: typeof raw.assignee === 'string' ? raw.assignee : null,
|
||||
templateId: null,
|
||||
owner: typeof raw.owner === 'string' ? raw.owner : null,
|
||||
labels: Array.isArray(raw.labels) ? raw.labels.filter((x): x is string => typeof x === 'string') : [],
|
||||
dependencies: normalizeDependencies(raw.dependencies),
|
||||
created_at: typeof raw.created_at === 'string' ? raw.created_at : '',
|
||||
updated_at: typeof raw.updated_at === 'string' ? raw.updated_at : '',
|
||||
closed_at: typeof raw.closed_at === 'string' ? raw.closed_at : null,
|
||||
close_reason: typeof raw.close_reason === 'string' ? raw.close_reason : null,
|
||||
closed_by_session: typeof raw.closed_by_session === 'string' ? raw.closed_by_session : null,
|
||||
created_by: typeof raw.created_by === 'string' ? raw.created_by : null,
|
||||
due_at: typeof raw.due_at === 'string' ? raw.due_at : null,
|
||||
estimated_minutes: typeof raw.estimated_minutes === 'number' ? raw.estimated_minutes : null,
|
||||
external_ref: typeof raw.external_ref === 'string' ? raw.external_ref : null,
|
||||
metadata: typeof raw.metadata === 'object' && raw.metadata !== null ? (raw.metadata as Record<string, unknown>) : {},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIssuesJsonl(text: string, options: ParseIssuesOptions = {}): BeadIssue[] {
|
||||
const includeTombstones = options.includeTombstones ?? false;
|
||||
const issues: BeadIssue[] = [];
|
||||
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as ParseableBeadIssue;
|
||||
if (!parsed.id || !parsed.title) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = normalizeIssue(parsed);
|
||||
if (!includeTombstones && normalized.status === 'tombstone') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exclude agent identities from standard mission lists
|
||||
if (!options.skipAgentFilter && normalized.labels.includes('gt:agent')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
issues.push(normalized);
|
||||
} catch {
|
||||
// Skip malformed lines to keep parser resilient against partial writes.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
import type { BeadDependency, BeadIssue, ParseableBeadIssue } from './types';
|
||||
|
||||
export interface ParseIssuesOptions {
|
||||
includeTombstones?: boolean;
|
||||
skipAgentFilter?: boolean;
|
||||
}
|
||||
|
||||
function normalizeDependencies(value: unknown): BeadDependency[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dep = item as { type?: unknown; target?: unknown; depends_on_id?: unknown };
|
||||
if (typeof dep.type !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = typeof dep.target === 'string' ? dep.target : typeof dep.depends_on_id === 'string' ? dep.depends_on_id : null;
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedType = dep.type === 'parent-child' ? 'parent' : dep.type;
|
||||
|
||||
return {
|
||||
type: normalizedType as BeadDependency['type'],
|
||||
target,
|
||||
};
|
||||
})
|
||||
.filter((dep): dep is BeadDependency => dep !== null);
|
||||
}
|
||||
|
||||
function normalizeIssue(raw: ParseableBeadIssue): BeadIssue {
|
||||
return {
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
description: typeof raw.description === 'string' ? raw.description : null,
|
||||
status: (raw.status ?? 'open') as BeadIssue['status'],
|
||||
priority: typeof raw.priority === 'number' ? raw.priority : 2,
|
||||
issue_type: (raw.issue_type ?? 'task') as BeadIssue['issue_type'],
|
||||
assignee: typeof raw.assignee === 'string' ? raw.assignee : null,
|
||||
templateId: null,
|
||||
owner: typeof raw.owner === 'string' ? raw.owner : null,
|
||||
labels: Array.isArray(raw.labels) ? raw.labels.filter((x): x is string => typeof x === 'string') : [],
|
||||
dependencies: normalizeDependencies(raw.dependencies),
|
||||
created_at: typeof raw.created_at === 'string' ? raw.created_at : '',
|
||||
updated_at: typeof raw.updated_at === 'string' ? raw.updated_at : '',
|
||||
closed_at: typeof raw.closed_at === 'string' ? raw.closed_at : null,
|
||||
close_reason: typeof raw.close_reason === 'string' ? raw.close_reason : null,
|
||||
closed_by_session: typeof raw.closed_by_session === 'string' ? raw.closed_by_session : null,
|
||||
created_by: typeof raw.created_by === 'string' ? raw.created_by : null,
|
||||
due_at: typeof raw.due_at === 'string' ? raw.due_at : null,
|
||||
estimated_minutes: typeof raw.estimated_minutes === 'number' ? raw.estimated_minutes : null,
|
||||
external_ref: typeof raw.external_ref === 'string' ? raw.external_ref : null,
|
||||
metadata: typeof raw.metadata === 'object' && raw.metadata !== null ? (raw.metadata as Record<string, unknown>) : {},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIssuesJsonl(text: string, options: ParseIssuesOptions = {}): BeadIssue[] {
|
||||
const includeTombstones = options.includeTombstones ?? false;
|
||||
const issues: BeadIssue[] = [];
|
||||
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as ParseableBeadIssue;
|
||||
if (!parsed.id || !parsed.title) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = normalizeIssue(parsed);
|
||||
if (!includeTombstones && normalized.status === 'tombstone') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exclude agent identities from standard mission lists
|
||||
if (!options.skipAgentFilter && normalized.labels.includes('gt:agent')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
issues.push(normalized);
|
||||
} catch {
|
||||
// Skip malformed lines to keep parser resilient against partial writes.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,4 +33,4 @@ export function toDisplayPath(input: string): string {
|
|||
|
||||
export function sameWindowsPath(a: string, b: string): boolean {
|
||||
return windowsPathKey(a) === windowsPathKey(b);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
import type { ProjectContext, ProjectSource } from './types';
|
||||
|
||||
interface BuildProjectContextOptions {
|
||||
source?: ProjectSource;
|
||||
addedAt?: string | null;
|
||||
}
|
||||
|
||||
export function buildProjectContext(root: string, options: BuildProjectContextOptions = {}): ProjectContext {
|
||||
if (!root) {
|
||||
throw new Error('Project root is required to build project context.');
|
||||
}
|
||||
|
||||
const normalizedRoot = canonicalizeWindowsPath(root);
|
||||
return {
|
||||
key: windowsPathKey(normalizedRoot),
|
||||
root: normalizedRoot,
|
||||
displayPath: toDisplayPath(normalizedRoot),
|
||||
name: path.basename(normalizedRoot),
|
||||
source: options.source ?? 'local',
|
||||
addedAt: options.addedAt ?? null,
|
||||
};
|
||||
}
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
import type { ProjectContext, ProjectSource } from './types';
|
||||
|
||||
interface BuildProjectContextOptions {
|
||||
source?: ProjectSource;
|
||||
addedAt?: string | null;
|
||||
}
|
||||
|
||||
export function buildProjectContext(root: string, options: BuildProjectContextOptions = {}): ProjectContext {
|
||||
if (!root) {
|
||||
throw new Error('Project root is required to build project context.');
|
||||
}
|
||||
|
||||
const normalizedRoot = canonicalizeWindowsPath(root);
|
||||
return {
|
||||
key: windowsPathKey(normalizedRoot),
|
||||
root: normalizedRoot,
|
||||
displayPath: toDisplayPath(normalizedRoot),
|
||||
name: path.basename(normalizedRoot),
|
||||
source: options.source ?? 'local',
|
||||
addedAt: options.addedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,104 @@
|
|||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
|
||||
export interface ProjectScopeRegistryEntry {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ProjectScopeOption {
|
||||
key: string;
|
||||
root: string;
|
||||
displayPath: string;
|
||||
source: 'local' | 'registry';
|
||||
}
|
||||
|
||||
export type ProjectScopeMode = 'single' | 'aggregate';
|
||||
|
||||
export interface ResolveProjectScopeInput {
|
||||
currentProjectRoot: string;
|
||||
registryProjects: ProjectScopeRegistryEntry[];
|
||||
requestedProjectKey?: string | null;
|
||||
requestedMode?: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedProjectScope {
|
||||
mode: ProjectScopeMode;
|
||||
selected: ProjectScopeOption;
|
||||
readRoots: string[];
|
||||
options: ProjectScopeOption[];
|
||||
}
|
||||
|
||||
function normalizeRequestedKey(input?: string | null): string | null {
|
||||
if (typeof input !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function buildLocalOption(currentProjectRoot: string): ProjectScopeOption {
|
||||
const root = canonicalizeWindowsPath(currentProjectRoot);
|
||||
return {
|
||||
key: 'local',
|
||||
root,
|
||||
displayPath: toDisplayPath(root),
|
||||
source: 'local',
|
||||
};
|
||||
}
|
||||
|
||||
function buildRegistryOptions(registryProjects: ProjectScopeRegistryEntry[]): ProjectScopeOption[] {
|
||||
const seen = new Set<string>();
|
||||
const options: ProjectScopeOption[] = [];
|
||||
|
||||
for (const project of registryProjects) {
|
||||
const root = canonicalizeWindowsPath(project.path);
|
||||
const key = windowsPathKey(root);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push({
|
||||
key,
|
||||
root,
|
||||
displayPath: toDisplayPath(root),
|
||||
source: 'registry',
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function normalizeMode(input?: string | null): ProjectScopeMode {
|
||||
if (input === 'aggregate') {
|
||||
return 'aggregate';
|
||||
}
|
||||
return 'single';
|
||||
}
|
||||
|
||||
export function resolveProjectScope(input: ResolveProjectScopeInput): ResolvedProjectScope {
|
||||
const local = buildLocalOption(input.currentProjectRoot);
|
||||
const registry = buildRegistryOptions(input.registryProjects);
|
||||
const options = [local, ...registry];
|
||||
const requestedKey = normalizeRequestedKey(input.requestedProjectKey);
|
||||
const mode = normalizeMode(input.requestedMode);
|
||||
const readRoots =
|
||||
mode === 'aggregate' ? options.map((option) => option.root) : [local.root];
|
||||
|
||||
if (!requestedKey || requestedKey === 'local') {
|
||||
return { mode, selected: local, readRoots, options };
|
||||
}
|
||||
|
||||
const selected = options.find((option) => option.key === requestedKey);
|
||||
const resolvedSelected = selected ?? local;
|
||||
const resolvedReadRoots =
|
||||
mode === 'aggregate' ? readRoots : [resolvedSelected.root];
|
||||
|
||||
return {
|
||||
mode,
|
||||
selected: resolvedSelected,
|
||||
readRoots: resolvedReadRoots,
|
||||
options,
|
||||
};
|
||||
}
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
|
||||
export interface ProjectScopeRegistryEntry {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ProjectScopeOption {
|
||||
key: string;
|
||||
root: string;
|
||||
displayPath: string;
|
||||
source: 'local' | 'registry';
|
||||
}
|
||||
|
||||
export type ProjectScopeMode = 'single' | 'aggregate';
|
||||
|
||||
export interface ResolveProjectScopeInput {
|
||||
currentProjectRoot: string;
|
||||
registryProjects: ProjectScopeRegistryEntry[];
|
||||
requestedProjectKey?: string | null;
|
||||
requestedMode?: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedProjectScope {
|
||||
mode: ProjectScopeMode;
|
||||
selected: ProjectScopeOption;
|
||||
readRoots: string[];
|
||||
options: ProjectScopeOption[];
|
||||
}
|
||||
|
||||
function normalizeRequestedKey(input?: string | null): string | null {
|
||||
if (typeof input !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function buildLocalOption(currentProjectRoot: string): ProjectScopeOption {
|
||||
const root = canonicalizeWindowsPath(currentProjectRoot);
|
||||
return {
|
||||
key: 'local',
|
||||
root,
|
||||
displayPath: toDisplayPath(root),
|
||||
source: 'local',
|
||||
};
|
||||
}
|
||||
|
||||
function buildRegistryOptions(registryProjects: ProjectScopeRegistryEntry[]): ProjectScopeOption[] {
|
||||
const seen = new Set<string>();
|
||||
const options: ProjectScopeOption[] = [];
|
||||
|
||||
for (const project of registryProjects) {
|
||||
const root = canonicalizeWindowsPath(project.path);
|
||||
const key = windowsPathKey(root);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push({
|
||||
key,
|
||||
root,
|
||||
displayPath: toDisplayPath(root),
|
||||
source: 'registry',
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function normalizeMode(input?: string | null): ProjectScopeMode {
|
||||
if (input === 'aggregate') {
|
||||
return 'aggregate';
|
||||
}
|
||||
return 'single';
|
||||
}
|
||||
|
||||
export function resolveProjectScope(input: ResolveProjectScopeInput): ResolvedProjectScope {
|
||||
const local = buildLocalOption(input.currentProjectRoot);
|
||||
const registry = buildRegistryOptions(input.registryProjects);
|
||||
const options = [local, ...registry];
|
||||
const requestedKey = normalizeRequestedKey(input.requestedProjectKey);
|
||||
const mode = normalizeMode(input.requestedMode);
|
||||
const readRoots =
|
||||
mode === 'aggregate' ? options.map((option) => option.root) : [local.root];
|
||||
|
||||
if (!requestedKey || requestedKey === 'local') {
|
||||
return { mode, selected: local, readRoots, options };
|
||||
}
|
||||
|
||||
const selected = options.find((option) => option.key === requestedKey);
|
||||
const resolvedSelected = selected ?? local;
|
||||
const resolvedReadRoots =
|
||||
mode === 'aggregate' ? readRoots : [resolvedSelected.root];
|
||||
|
||||
return {
|
||||
mode,
|
||||
selected: resolvedSelected,
|
||||
readRoots: resolvedReadRoots,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,41 @@
|
|||
import { runBdCommand } from './bridge';
|
||||
import { getDoltConnection } from './dolt-client';
|
||||
import type { ResultSetHeader } from 'mysql2';
|
||||
|
||||
export interface BeadInteraction {
|
||||
id: string;
|
||||
bead_id: string;
|
||||
actor: string;
|
||||
kind: 'comment';
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
|
||||
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 [];
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +1,41 @@
|
|||
import fs from 'node:fs/promises';
|
||||
|
||||
const DEFAULT_RETRY_CODES = new Set(['EBUSY', 'EPERM']);
|
||||
|
||||
export interface ReadTextRetryOptions {
|
||||
retries?: number;
|
||||
delayMs?: number;
|
||||
retryCodes?: Set<string>;
|
||||
}
|
||||
|
||||
function sleep(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
function shouldRetry(error: unknown, retryCodes: Set<string>): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return typeof code === 'string' && retryCodes.has(code);
|
||||
}
|
||||
|
||||
export async function readTextFileWithRetry(
|
||||
filePath: string,
|
||||
options: ReadTextRetryOptions = {},
|
||||
): Promise<string> {
|
||||
const retries = options.retries ?? 2;
|
||||
const delayMs = options.delayMs ?? 40;
|
||||
const retryCodes = options.retryCodes ?? DEFAULT_RETRY_CODES;
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (attempt >= retries || !shouldRetry(error, retryCodes)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const DEFAULT_RETRY_CODES = new Set(['EBUSY', 'EPERM']);
|
||||
|
||||
export interface ReadTextRetryOptions {
|
||||
retries?: number;
|
||||
delayMs?: number;
|
||||
retryCodes?: Set<string>;
|
||||
}
|
||||
|
||||
function sleep(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
function shouldRetry(error: unknown, retryCodes: Set<string>): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return typeof code === 'string' && retryCodes.has(code);
|
||||
}
|
||||
|
||||
export async function readTextFileWithRetry(
|
||||
filePath: string,
|
||||
options: ReadTextRetryOptions = {},
|
||||
): Promise<string> {
|
||||
const retries = options.retries ?? 2;
|
||||
const delayMs = options.delayMs ?? 40;
|
||||
const retryCodes = options.retryCodes ?? DEFAULT_RETRY_CODES;
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (attempt >= retries || !shouldRetry(error, retryCodes)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,218 +1,218 @@
|
|||
import path from 'node:path';
|
||||
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
|
||||
import type { ActivityEvent } from './activity';
|
||||
|
||||
export type IssuesChangeKind = 'changed' | 'renamed' | 'telemetry';
|
||||
|
||||
export interface IssuesChangedEvent {
|
||||
id: number;
|
||||
projectRoot: string;
|
||||
changedPath?: string;
|
||||
kind: IssuesChangeKind;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface ActivityDispatchedEvent {
|
||||
id: number;
|
||||
event: ActivityEvent;
|
||||
}
|
||||
|
||||
interface Subscriber {
|
||||
projectKey?: string;
|
||||
listener: (event: IssuesChangedEvent) => void;
|
||||
}
|
||||
|
||||
interface ActivitySubscriber {
|
||||
projectKey?: string;
|
||||
listener: (event: ActivityDispatchedEvent) => void;
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
export class IssuesEventBus {
|
||||
private nextEventId = 1;
|
||||
|
||||
private readonly subscribers = new Map<number, Subscriber>();
|
||||
|
||||
private nextSubscriberId = 1;
|
||||
|
||||
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
|
||||
console.log(`[IssuesBus] Emitting event: ${kind} for project (${changedPath ? path.basename(changedPath) : 'unknown'})`);
|
||||
const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot);
|
||||
const projectKey = windowsPathKey(canonicalProjectRoot);
|
||||
console.log(`[IssuesBus] Emitting event: ${kind} for ${projectKey} (path: ${changedPath}, subscribers: ${this.subscribers.size})`);
|
||||
const event: IssuesChangedEvent = {
|
||||
id: this.nextEventId,
|
||||
projectRoot: canonicalProjectRoot,
|
||||
changedPath: changedPath ? canonicalizeWindowsPath(changedPath) : undefined,
|
||||
kind,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
this.nextEventId += 1;
|
||||
|
||||
let delivered = 0;
|
||||
for (const subscriber of this.subscribers.values()) {
|
||||
if (!subscriber.projectKey || subscriber.projectKey === projectKey) {
|
||||
subscriber.listener(event);
|
||||
delivered++;
|
||||
}
|
||||
}
|
||||
console.log(`[IssuesBus] Delivered to ${delivered} subscribers`);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
subscribe(listener: (event: IssuesChangedEvent) => void, options: SubscribeOptions = {}): () => void {
|
||||
const id = this.nextSubscriberId;
|
||||
this.nextSubscriberId += 1;
|
||||
const canonicalRoot = options.projectRoot ? canonicalizeWindowsPath(options.projectRoot) : undefined;
|
||||
|
||||
this.subscribers.set(id, {
|
||||
listener,
|
||||
projectKey: canonicalRoot ? windowsPathKey(canonicalRoot) : undefined,
|
||||
});
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
getSubscriberCount(): number {
|
||||
return this.subscribers.size;
|
||||
}
|
||||
|
||||
resetForTests(): void {
|
||||
this.subscribers.clear();
|
||||
this.nextSubscriberId = 1;
|
||||
this.nextEventId = 1;
|
||||
}
|
||||
}
|
||||
|
||||
import { loadActivityHistory, saveActivityHistory } from './activity-persistence';
|
||||
|
||||
export class ActivityEventBus {
|
||||
private nextEventId = 1;
|
||||
|
||||
private readonly subscribers = new Map<number, ActivitySubscriber>();
|
||||
private readonly history: ActivityEvent[] = [];
|
||||
private readonly MAX_HISTORY = 100;
|
||||
private initialized = false;
|
||||
private savePromise: Promise<void> | null = null;
|
||||
|
||||
private nextSubscriberId = 1;
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
const history = await loadActivityHistory();
|
||||
this.history.push(...history);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
emit(activity: ActivityEvent): ActivityDispatchedEvent {
|
||||
const projectKey = windowsPathKey(activity.projectId);
|
||||
const event: ActivityDispatchedEvent = {
|
||||
id: this.nextEventId,
|
||||
event: activity,
|
||||
};
|
||||
this.nextEventId += 1;
|
||||
|
||||
// Capture history snapshot BEFORE modification for persistence
|
||||
const historySnapshot = [...this.history];
|
||||
|
||||
// Buffer history
|
||||
this.history.unshift(activity);
|
||||
if (this.history.length > this.MAX_HISTORY) {
|
||||
this.history.pop();
|
||||
}
|
||||
|
||||
// Persist async with deduplication - wait for any pending save to complete
|
||||
const persist = async () => {
|
||||
try {
|
||||
await saveActivityHistory(historySnapshot);
|
||||
} catch (error) {
|
||||
console.error('[ActivityEventBus] Failed to save history:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.savePromise === null) {
|
||||
this.savePromise = persist();
|
||||
} else {
|
||||
// Chain to existing promise to prevent concurrent writes
|
||||
this.savePromise = this.savePromise.then(persist);
|
||||
}
|
||||
|
||||
for (const subscriber of this.subscribers.values()) {
|
||||
if (!subscriber.projectKey || subscriber.projectKey === projectKey) {
|
||||
subscriber.listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
getHistory(projectRoot?: string): ActivityEvent[] {
|
||||
if (!projectRoot) {
|
||||
return [...this.history];
|
||||
}
|
||||
const key = windowsPathKey(canonicalizeWindowsPath(projectRoot));
|
||||
return this.history.filter(e => windowsPathKey(e.projectId) === key);
|
||||
}
|
||||
|
||||
subscribe(listener: (event: ActivityDispatchedEvent) => void, options: SubscribeOptions = {}): () => void {
|
||||
const id = this.nextSubscriberId;
|
||||
this.nextSubscriberId += 1;
|
||||
const projectKey = options.projectRoot ? windowsPathKey(canonicalizeWindowsPath(options.projectRoot)) : undefined;
|
||||
|
||||
this.subscribers.set(id, {
|
||||
listener,
|
||||
projectKey,
|
||||
});
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
getSubscriberCount(): number {
|
||||
return this.subscribers.size;
|
||||
}
|
||||
|
||||
resetForTests(): void {
|
||||
this.subscribers.clear();
|
||||
this.history.length = 0;
|
||||
this.nextSubscriberId = 1;
|
||||
this.nextEventId = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const globalRegistry = globalThis as typeof globalThis & {
|
||||
__beadboardIssuesEventBus?: IssuesEventBus;
|
||||
__beadboardActivityEventBus?: ActivityEventBus;
|
||||
};
|
||||
|
||||
export const issuesEventBus = globalRegistry.__beadboardIssuesEventBus ?? new IssuesEventBus();
|
||||
if (!globalRegistry.__beadboardIssuesEventBus) {
|
||||
globalRegistry.__beadboardIssuesEventBus = issuesEventBus;
|
||||
}
|
||||
|
||||
export const activityEventBus = globalRegistry.__beadboardActivityEventBus ?? new ActivityEventBus();
|
||||
if (!globalRegistry.__beadboardActivityEventBus) {
|
||||
globalRegistry.__beadboardActivityEventBus = activityEventBus;
|
||||
}
|
||||
|
||||
export function toSseFrame(event: IssuesChangedEvent): string {
|
||||
const eventName = event.kind === 'telemetry' ? 'telemetry' : 'issues';
|
||||
return `id: ${event.id}\nevent: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
|
||||
export function toActivitySseFrame(event: ActivityDispatchedEvent): string {
|
||||
return `id: ${event.id}\nevent: activity\ndata: ${JSON.stringify(event.event)}\n\n`;
|
||||
}
|
||||
|
||||
export const SSE_HEARTBEAT_FRAME = ': heartbeat\n\n';
|
||||
export const SSE_CONNECTED_FRAME = ': connected\n\n';
|
||||
import path from 'node:path';
|
||||
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
|
||||
import type { ActivityEvent } from './activity';
|
||||
|
||||
export type IssuesChangeKind = 'changed' | 'renamed' | 'telemetry';
|
||||
|
||||
export interface IssuesChangedEvent {
|
||||
id: number;
|
||||
projectRoot: string;
|
||||
changedPath?: string;
|
||||
kind: IssuesChangeKind;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface ActivityDispatchedEvent {
|
||||
id: number;
|
||||
event: ActivityEvent;
|
||||
}
|
||||
|
||||
interface Subscriber {
|
||||
projectKey?: string;
|
||||
listener: (event: IssuesChangedEvent) => void;
|
||||
}
|
||||
|
||||
interface ActivitySubscriber {
|
||||
projectKey?: string;
|
||||
listener: (event: ActivityDispatchedEvent) => void;
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
export class IssuesEventBus {
|
||||
private nextEventId = 1;
|
||||
|
||||
private readonly subscribers = new Map<number, Subscriber>();
|
||||
|
||||
private nextSubscriberId = 1;
|
||||
|
||||
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
|
||||
console.log(`[IssuesBus] Emitting event: ${kind} for project (${changedPath ? path.basename(changedPath) : 'unknown'})`);
|
||||
const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot);
|
||||
const projectKey = windowsPathKey(canonicalProjectRoot);
|
||||
console.log(`[IssuesBus] Emitting event: ${kind} for ${projectKey} (path: ${changedPath}, subscribers: ${this.subscribers.size})`);
|
||||
const event: IssuesChangedEvent = {
|
||||
id: this.nextEventId,
|
||||
projectRoot: canonicalProjectRoot,
|
||||
changedPath: changedPath ? canonicalizeWindowsPath(changedPath) : undefined,
|
||||
kind,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
this.nextEventId += 1;
|
||||
|
||||
let delivered = 0;
|
||||
for (const subscriber of this.subscribers.values()) {
|
||||
if (!subscriber.projectKey || subscriber.projectKey === projectKey) {
|
||||
subscriber.listener(event);
|
||||
delivered++;
|
||||
}
|
||||
}
|
||||
console.log(`[IssuesBus] Delivered to ${delivered} subscribers`);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
subscribe(listener: (event: IssuesChangedEvent) => void, options: SubscribeOptions = {}): () => void {
|
||||
const id = this.nextSubscriberId;
|
||||
this.nextSubscriberId += 1;
|
||||
const canonicalRoot = options.projectRoot ? canonicalizeWindowsPath(options.projectRoot) : undefined;
|
||||
|
||||
this.subscribers.set(id, {
|
||||
listener,
|
||||
projectKey: canonicalRoot ? windowsPathKey(canonicalRoot) : undefined,
|
||||
});
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
getSubscriberCount(): number {
|
||||
return this.subscribers.size;
|
||||
}
|
||||
|
||||
resetForTests(): void {
|
||||
this.subscribers.clear();
|
||||
this.nextSubscriberId = 1;
|
||||
this.nextEventId = 1;
|
||||
}
|
||||
}
|
||||
|
||||
import { loadActivityHistory, saveActivityHistory } from './activity-persistence';
|
||||
|
||||
export class ActivityEventBus {
|
||||
private nextEventId = 1;
|
||||
|
||||
private readonly subscribers = new Map<number, ActivitySubscriber>();
|
||||
private readonly history: ActivityEvent[] = [];
|
||||
private readonly MAX_HISTORY = 100;
|
||||
private initialized = false;
|
||||
private savePromise: Promise<void> | null = null;
|
||||
|
||||
private nextSubscriberId = 1;
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
const history = await loadActivityHistory();
|
||||
this.history.push(...history);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
emit(activity: ActivityEvent): ActivityDispatchedEvent {
|
||||
const projectKey = windowsPathKey(activity.projectId);
|
||||
const event: ActivityDispatchedEvent = {
|
||||
id: this.nextEventId,
|
||||
event: activity,
|
||||
};
|
||||
this.nextEventId += 1;
|
||||
|
||||
// Capture history snapshot BEFORE modification for persistence
|
||||
const historySnapshot = [...this.history];
|
||||
|
||||
// Buffer history
|
||||
this.history.unshift(activity);
|
||||
if (this.history.length > this.MAX_HISTORY) {
|
||||
this.history.pop();
|
||||
}
|
||||
|
||||
// Persist async with deduplication - wait for any pending save to complete
|
||||
const persist = async () => {
|
||||
try {
|
||||
await saveActivityHistory(historySnapshot);
|
||||
} catch (error) {
|
||||
console.error('[ActivityEventBus] Failed to save history:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.savePromise === null) {
|
||||
this.savePromise = persist();
|
||||
} else {
|
||||
// Chain to existing promise to prevent concurrent writes
|
||||
this.savePromise = this.savePromise.then(persist);
|
||||
}
|
||||
|
||||
for (const subscriber of this.subscribers.values()) {
|
||||
if (!subscriber.projectKey || subscriber.projectKey === projectKey) {
|
||||
subscriber.listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
getHistory(projectRoot?: string): ActivityEvent[] {
|
||||
if (!projectRoot) {
|
||||
return [...this.history];
|
||||
}
|
||||
const key = windowsPathKey(canonicalizeWindowsPath(projectRoot));
|
||||
return this.history.filter(e => windowsPathKey(e.projectId) === key);
|
||||
}
|
||||
|
||||
subscribe(listener: (event: ActivityDispatchedEvent) => void, options: SubscribeOptions = {}): () => void {
|
||||
const id = this.nextSubscriberId;
|
||||
this.nextSubscriberId += 1;
|
||||
const projectKey = options.projectRoot ? windowsPathKey(canonicalizeWindowsPath(options.projectRoot)) : undefined;
|
||||
|
||||
this.subscribers.set(id, {
|
||||
listener,
|
||||
projectKey,
|
||||
});
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
getSubscriberCount(): number {
|
||||
return this.subscribers.size;
|
||||
}
|
||||
|
||||
resetForTests(): void {
|
||||
this.subscribers.clear();
|
||||
this.history.length = 0;
|
||||
this.nextSubscriberId = 1;
|
||||
this.nextEventId = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const globalRegistry = globalThis as typeof globalThis & {
|
||||
__beadboardIssuesEventBus?: IssuesEventBus;
|
||||
__beadboardActivityEventBus?: ActivityEventBus;
|
||||
};
|
||||
|
||||
export const issuesEventBus = globalRegistry.__beadboardIssuesEventBus ?? new IssuesEventBus();
|
||||
if (!globalRegistry.__beadboardIssuesEventBus) {
|
||||
globalRegistry.__beadboardIssuesEventBus = issuesEventBus;
|
||||
}
|
||||
|
||||
export const activityEventBus = globalRegistry.__beadboardActivityEventBus ?? new ActivityEventBus();
|
||||
if (!globalRegistry.__beadboardActivityEventBus) {
|
||||
globalRegistry.__beadboardActivityEventBus = activityEventBus;
|
||||
}
|
||||
|
||||
export function toSseFrame(event: IssuesChangedEvent): string {
|
||||
const eventName = event.kind === 'telemetry' ? 'telemetry' : 'issues';
|
||||
return `id: ${event.id}\nevent: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
|
||||
export function toActivitySseFrame(event: ActivityDispatchedEvent): string {
|
||||
return `id: ${event.id}\nevent: activity\ndata: ${JSON.stringify(event.event)}\n\n`;
|
||||
}
|
||||
|
||||
export const SSE_HEARTBEAT_FRAME = ': heartbeat\n\n';
|
||||
export const SSE_CONNECTED_FRAME = ': connected\n\n';
|
||||
|
|
|
|||
|
|
@ -1,140 +1,140 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
|
||||
export interface RegistryProject {
|
||||
path: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface RegistryDocument {
|
||||
version: 1;
|
||||
projects: RegistryProject[];
|
||||
}
|
||||
|
||||
export class RegistryValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RegistryValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
export function registryFilePath(): string {
|
||||
return path.join(userProfileRoot(), '.beadboard', 'projects.json');
|
||||
}
|
||||
|
||||
function ensureWindowsAbsolutePath(input: string): string {
|
||||
const normalized = canonicalizeWindowsPath(input.trim());
|
||||
if (!/^[A-Za-z]:\\/.test(normalized)) {
|
||||
throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeProject(input: string): RegistryProject {
|
||||
const normalized = ensureWindowsAbsolutePath(input);
|
||||
return {
|
||||
path: toDisplayPath(normalized),
|
||||
key: windowsPathKey(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjects(input: unknown): RegistryProject[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: RegistryProject[] = [];
|
||||
|
||||
for (const item of input) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = item as { path?: unknown };
|
||||
if (typeof candidate.path !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = normalizeProject(candidate.path);
|
||||
if (!seen.has(project.key)) {
|
||||
seen.add(project.key);
|
||||
normalized.push(project);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function readRegistryDocument(): Promise<RegistryDocument> {
|
||||
const filePath = registryFilePath();
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { projects?: unknown };
|
||||
return {
|
||||
version: 1,
|
||||
projects: normalizeProjects(parsed.projects),
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { version: 1, projects: [] };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRegistryDocument(document: RegistryDocument): Promise<void> {
|
||||
const filePath = registryFilePath();
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<RegistryProject[]> {
|
||||
const document = await readRegistryDocument();
|
||||
return document.projects;
|
||||
}
|
||||
|
||||
export async function addProject(projectPath: string): Promise<{ added: boolean; projects: RegistryProject[] }> {
|
||||
const document = await readRegistryDocument();
|
||||
const project = normalizeProject(projectPath);
|
||||
|
||||
if (document.projects.some((entry) => entry.key === project.key)) {
|
||||
return { added: false, projects: document.projects };
|
||||
}
|
||||
|
||||
document.projects.push(project);
|
||||
await writeRegistryDocument(document);
|
||||
return { added: true, projects: document.projects };
|
||||
}
|
||||
|
||||
export async function removeProject(projectPath: string): Promise<{ removed: boolean; projects: RegistryProject[] }> {
|
||||
const document = await readRegistryDocument();
|
||||
const project = normalizeProject(projectPath);
|
||||
const nextProjects = document.projects.filter((entry) => entry.key !== project.key);
|
||||
|
||||
if (nextProjects.length === document.projects.length) {
|
||||
return { removed: false, projects: document.projects };
|
||||
}
|
||||
|
||||
const nextDocument: RegistryDocument = {
|
||||
version: 1,
|
||||
projects: nextProjects,
|
||||
};
|
||||
|
||||
await writeRegistryDocument(nextDocument);
|
||||
return { removed: true, projects: nextDocument.projects };
|
||||
}
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
|
||||
export interface RegistryProject {
|
||||
path: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface RegistryDocument {
|
||||
version: 1;
|
||||
projects: RegistryProject[];
|
||||
}
|
||||
|
||||
export class RegistryValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RegistryValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
export function registryFilePath(): string {
|
||||
return path.join(userProfileRoot(), '.beadboard', 'projects.json');
|
||||
}
|
||||
|
||||
function ensureWindowsAbsolutePath(input: string): string {
|
||||
const normalized = canonicalizeWindowsPath(input.trim());
|
||||
if (!/^[A-Za-z]:\\/.test(normalized)) {
|
||||
throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeProject(input: string): RegistryProject {
|
||||
const normalized = ensureWindowsAbsolutePath(input);
|
||||
return {
|
||||
path: toDisplayPath(normalized),
|
||||
key: windowsPathKey(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjects(input: unknown): RegistryProject[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: RegistryProject[] = [];
|
||||
|
||||
for (const item of input) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = item as { path?: unknown };
|
||||
if (typeof candidate.path !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = normalizeProject(candidate.path);
|
||||
if (!seen.has(project.key)) {
|
||||
seen.add(project.key);
|
||||
normalized.push(project);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function readRegistryDocument(): Promise<RegistryDocument> {
|
||||
const filePath = registryFilePath();
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { projects?: unknown };
|
||||
return {
|
||||
version: 1,
|
||||
projects: normalizeProjects(parsed.projects),
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { version: 1, projects: [] };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRegistryDocument(document: RegistryDocument): Promise<void> {
|
||||
const filePath = registryFilePath();
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<RegistryProject[]> {
|
||||
const document = await readRegistryDocument();
|
||||
return document.projects;
|
||||
}
|
||||
|
||||
export async function addProject(projectPath: string): Promise<{ added: boolean; projects: RegistryProject[] }> {
|
||||
const document = await readRegistryDocument();
|
||||
const project = normalizeProject(projectPath);
|
||||
|
||||
if (document.projects.some((entry) => entry.key === project.key)) {
|
||||
return { added: false, projects: document.projects };
|
||||
}
|
||||
|
||||
document.projects.push(project);
|
||||
await writeRegistryDocument(document);
|
||||
return { added: true, projects: document.projects };
|
||||
}
|
||||
|
||||
export async function removeProject(projectPath: string): Promise<{ removed: boolean; projects: RegistryProject[] }> {
|
||||
const document = await readRegistryDocument();
|
||||
const project = normalizeProject(projectPath);
|
||||
const nextProjects = document.projects.filter((entry) => entry.key !== project.key);
|
||||
|
||||
if (nextProjects.length === document.projects.length) {
|
||||
return { removed: false, projects: document.projects };
|
||||
}
|
||||
|
||||
const nextDocument: RegistryDocument = {
|
||||
version: 1,
|
||||
projects: nextProjects,
|
||||
};
|
||||
|
||||
await writeRegistryDocument(nextDocument);
|
||||
return { removed: true, projects: nextDocument.projects };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,275 +1,275 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
import { listProjects } from './registry';
|
||||
|
||||
export type ScanMode = 'default' | 'full-drive';
|
||||
|
||||
export interface ScannerProject {
|
||||
root: string;
|
||||
key: string;
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
export interface ScanStats {
|
||||
scannedDirectories: number;
|
||||
ignoredDirectories: number;
|
||||
skippedDirectories: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ScanOptions {
|
||||
mode?: ScanMode;
|
||||
maxDepth?: number;
|
||||
roots?: string[];
|
||||
ignoreDirectories?: string[];
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
mode: ScanMode;
|
||||
roots: string[];
|
||||
projects: ScannerProject[];
|
||||
stats: ScanStats;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 6;
|
||||
const DEFAULT_IGNORE_DIRECTORIES = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'coverage',
|
||||
'artifacts',
|
||||
'logs',
|
||||
'.worktrees', // TODO: confirm whether worktrees should be scan targets.
|
||||
'worktrees',
|
||||
'.agents',
|
||||
'.kimi',
|
||||
'.zenflow',
|
||||
'.gemini',
|
||||
'appdata',
|
||||
];
|
||||
|
||||
const DEFAULT_IGNORE_PATH_FRAGMENTS = [
|
||||
'\\go\\pkg\\mod\\',
|
||||
'\\.agents\\skills\\',
|
||||
'\\.kimi\\skills\\',
|
||||
'\\.gemini\\skills\\',
|
||||
'\\.zenflow\\worktrees\\',
|
||||
];
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
function toCanonicalRoot(input: string): string {
|
||||
return canonicalizeWindowsPath(input);
|
||||
}
|
||||
|
||||
function shouldSkipFsError(error: NodeJS.ErrnoException): boolean {
|
||||
return error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES' || error.code === 'EPERM';
|
||||
}
|
||||
|
||||
async function ensureDirectoryExists(input: string): Promise<string | null> {
|
||||
try {
|
||||
const stat = await fs.stat(input);
|
||||
return stat.isDirectory() ? input : null;
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(input: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(input);
|
||||
return stat.isFile();
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFullDriveRoots(): Promise<string[]> {
|
||||
const candidates = ['C:\\', 'D:\\'];
|
||||
const roots: string[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const existing = await ensureDirectoryExists(candidate);
|
||||
if (existing) {
|
||||
roots.push(existing);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
export async function resolveScanRoots(options: ScanOptions = {}): Promise<string[]> {
|
||||
const mode: ScanMode = options.mode ?? 'default';
|
||||
const registryProjects = await listProjects();
|
||||
const roots = [
|
||||
userProfileRoot(),
|
||||
...registryProjects.map((project) => project.path),
|
||||
...(options.roots ?? []),
|
||||
];
|
||||
|
||||
if (mode === 'full-drive') {
|
||||
roots.push(...(await resolveFullDriveRoots()));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalizedRoots: string[] = [];
|
||||
|
||||
for (const root of roots) {
|
||||
const normalized = toCanonicalRoot(root);
|
||||
const key = windowsPathKey(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await ensureDirectoryExists(normalized);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
normalizedRoots.push(existing);
|
||||
}
|
||||
|
||||
return normalizedRoots;
|
||||
}
|
||||
|
||||
function buildIgnoreSet(additional: string[] = []): Set<string> {
|
||||
return new Set(
|
||||
[...DEFAULT_IGNORE_DIRECTORIES, ...additional].map((entry) => entry.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldIgnorePath(dir: string): boolean {
|
||||
const normalized = toCanonicalRoot(dir).toLowerCase();
|
||||
return DEFAULT_IGNORE_PATH_FRAGMENTS.some((fragment) => normalized.includes(fragment));
|
||||
}
|
||||
|
||||
function shouldIgnoreDirectoryName(name: string): boolean {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return (
|
||||
normalized.startsWith('beadboard-read-') ||
|
||||
normalized.startsWith('beadboard-watch-') ||
|
||||
normalized.startsWith('skills-')
|
||||
);
|
||||
}
|
||||
|
||||
function recordProject(projects: Map<string, ScannerProject>, root: string): void {
|
||||
const normalized = toCanonicalRoot(root);
|
||||
const key = windowsPathKey(normalized);
|
||||
if (!projects.has(key)) {
|
||||
projects.set(key, {
|
||||
root: normalized,
|
||||
key,
|
||||
displayPath: toDisplayPath(normalized),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function scanRoot(
|
||||
root: string,
|
||||
maxDepth: number,
|
||||
ignoreSet: Set<string>,
|
||||
projects: Map<string, ScannerProject>,
|
||||
stats: ScanStats,
|
||||
): Promise<void> {
|
||||
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.depth > 0 && shouldIgnorePath(current.dir)) {
|
||||
stats.ignoredDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.scannedDirectories += 1;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(current.dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
stats.skippedDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let hasBeads = false;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name === '.beads') {
|
||||
hasBeads = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryName = entry.name.toLowerCase();
|
||||
if (ignoreSet.has(entryName) || shouldIgnoreDirectoryName(entryName)) {
|
||||
stats.ignoredDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.depth < maxDepth) {
|
||||
queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBeads) {
|
||||
const issuesPath = path.join(current.dir, '.beads', 'issues.jsonl');
|
||||
const fallbackIssuesPath = path.join(current.dir, '.beads', 'issues.jsonl.new');
|
||||
const [primaryExists, fallbackExists] = await Promise.all([fileExists(issuesPath), fileExists(fallbackIssuesPath)]);
|
||||
|
||||
if (primaryExists || fallbackExists) {
|
||||
recordProject(projects, current.dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanForProjects(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
const mode: ScanMode = options.mode ?? 'default';
|
||||
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||||
const ignoreSet = buildIgnoreSet(options.ignoreDirectories);
|
||||
const roots = await resolveScanRoots(options);
|
||||
const projects = new Map<string, ScannerProject>();
|
||||
const stats: ScanStats = {
|
||||
scannedDirectories: 0,
|
||||
ignoredDirectories: 0,
|
||||
skippedDirectories: 0,
|
||||
elapsedMs: 0,
|
||||
};
|
||||
const start = Date.now();
|
||||
|
||||
for (const root of roots) {
|
||||
await scanRoot(root, maxDepth, ignoreSet, projects, stats);
|
||||
}
|
||||
|
||||
stats.elapsedMs = Date.now() - start;
|
||||
|
||||
return {
|
||||
mode,
|
||||
roots,
|
||||
projects: Array.from(projects.values()),
|
||||
stats,
|
||||
};
|
||||
}
|
||||
import fs from 'node:fs/promises';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
import { listProjects } from './registry';
|
||||
|
||||
export type ScanMode = 'default' | 'full-drive';
|
||||
|
||||
export interface ScannerProject {
|
||||
root: string;
|
||||
key: string;
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
export interface ScanStats {
|
||||
scannedDirectories: number;
|
||||
ignoredDirectories: number;
|
||||
skippedDirectories: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ScanOptions {
|
||||
mode?: ScanMode;
|
||||
maxDepth?: number;
|
||||
roots?: string[];
|
||||
ignoreDirectories?: string[];
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
mode: ScanMode;
|
||||
roots: string[];
|
||||
projects: ScannerProject[];
|
||||
stats: ScanStats;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 6;
|
||||
const DEFAULT_IGNORE_DIRECTORIES = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'coverage',
|
||||
'artifacts',
|
||||
'logs',
|
||||
'.worktrees', // TODO: confirm whether worktrees should be scan targets.
|
||||
'worktrees',
|
||||
'.agents',
|
||||
'.kimi',
|
||||
'.zenflow',
|
||||
'.gemini',
|
||||
'appdata',
|
||||
];
|
||||
|
||||
const DEFAULT_IGNORE_PATH_FRAGMENTS = [
|
||||
'\\go\\pkg\\mod\\',
|
||||
'\\.agents\\skills\\',
|
||||
'\\.kimi\\skills\\',
|
||||
'\\.gemini\\skills\\',
|
||||
'\\.zenflow\\worktrees\\',
|
||||
];
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
function toCanonicalRoot(input: string): string {
|
||||
return canonicalizeWindowsPath(input);
|
||||
}
|
||||
|
||||
function shouldSkipFsError(error: NodeJS.ErrnoException): boolean {
|
||||
return error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES' || error.code === 'EPERM';
|
||||
}
|
||||
|
||||
async function ensureDirectoryExists(input: string): Promise<string | null> {
|
||||
try {
|
||||
const stat = await fs.stat(input);
|
||||
return stat.isDirectory() ? input : null;
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(input: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(input);
|
||||
return stat.isFile();
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFullDriveRoots(): Promise<string[]> {
|
||||
const candidates = ['C:\\', 'D:\\'];
|
||||
const roots: string[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const existing = await ensureDirectoryExists(candidate);
|
||||
if (existing) {
|
||||
roots.push(existing);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
export async function resolveScanRoots(options: ScanOptions = {}): Promise<string[]> {
|
||||
const mode: ScanMode = options.mode ?? 'default';
|
||||
const registryProjects = await listProjects();
|
||||
const roots = [
|
||||
userProfileRoot(),
|
||||
...registryProjects.map((project) => project.path),
|
||||
...(options.roots ?? []),
|
||||
];
|
||||
|
||||
if (mode === 'full-drive') {
|
||||
roots.push(...(await resolveFullDriveRoots()));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalizedRoots: string[] = [];
|
||||
|
||||
for (const root of roots) {
|
||||
const normalized = toCanonicalRoot(root);
|
||||
const key = windowsPathKey(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await ensureDirectoryExists(normalized);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
normalizedRoots.push(existing);
|
||||
}
|
||||
|
||||
return normalizedRoots;
|
||||
}
|
||||
|
||||
function buildIgnoreSet(additional: string[] = []): Set<string> {
|
||||
return new Set(
|
||||
[...DEFAULT_IGNORE_DIRECTORIES, ...additional].map((entry) => entry.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldIgnorePath(dir: string): boolean {
|
||||
const normalized = toCanonicalRoot(dir).toLowerCase();
|
||||
return DEFAULT_IGNORE_PATH_FRAGMENTS.some((fragment) => normalized.includes(fragment));
|
||||
}
|
||||
|
||||
function shouldIgnoreDirectoryName(name: string): boolean {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return (
|
||||
normalized.startsWith('beadboard-read-') ||
|
||||
normalized.startsWith('beadboard-watch-') ||
|
||||
normalized.startsWith('skills-')
|
||||
);
|
||||
}
|
||||
|
||||
function recordProject(projects: Map<string, ScannerProject>, root: string): void {
|
||||
const normalized = toCanonicalRoot(root);
|
||||
const key = windowsPathKey(normalized);
|
||||
if (!projects.has(key)) {
|
||||
projects.set(key, {
|
||||
root: normalized,
|
||||
key,
|
||||
displayPath: toDisplayPath(normalized),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function scanRoot(
|
||||
root: string,
|
||||
maxDepth: number,
|
||||
ignoreSet: Set<string>,
|
||||
projects: Map<string, ScannerProject>,
|
||||
stats: ScanStats,
|
||||
): Promise<void> {
|
||||
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.depth > 0 && shouldIgnorePath(current.dir)) {
|
||||
stats.ignoredDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.scannedDirectories += 1;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(current.dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
stats.skippedDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let hasBeads = false;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name === '.beads') {
|
||||
hasBeads = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryName = entry.name.toLowerCase();
|
||||
if (ignoreSet.has(entryName) || shouldIgnoreDirectoryName(entryName)) {
|
||||
stats.ignoredDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.depth < maxDepth) {
|
||||
queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBeads) {
|
||||
const issuesPath = path.join(current.dir, '.beads', 'issues.jsonl');
|
||||
const fallbackIssuesPath = path.join(current.dir, '.beads', 'issues.jsonl.new');
|
||||
const [primaryExists, fallbackExists] = await Promise.all([fileExists(issuesPath), fileExists(fallbackIssuesPath)]);
|
||||
|
||||
if (primaryExists || fallbackExists) {
|
||||
recordProject(projects, current.dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanForProjects(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
const mode: ScanMode = options.mode ?? 'default';
|
||||
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||||
const ignoreSet = buildIgnoreSet(options.ignoreDirectories);
|
||||
const roots = await resolveScanRoots(options);
|
||||
const projects = new Map<string, ScannerProject>();
|
||||
const stats: ScanStats = {
|
||||
scannedDirectories: 0,
|
||||
ignoredDirectories: 0,
|
||||
skippedDirectories: 0,
|
||||
elapsedMs: 0,
|
||||
};
|
||||
const start = Date.now();
|
||||
|
||||
for (const root of roots) {
|
||||
await scanRoot(root, maxDepth, ignoreSet, projects, stats);
|
||||
}
|
||||
|
||||
stats.elapsedMs = Date.now() - start;
|
||||
|
||||
return {
|
||||
mode,
|
||||
roots,
|
||||
projects: Array.from(projects.values()),
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,158 +1,158 @@
|
|||
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, field: kindAndTarget.type }));
|
||||
});
|
||||
});
|
||||
|
||||
// 6. Detect Deleted Issues
|
||||
if (previous) {
|
||||
const currMap = new Set(current.map(c => c.id));
|
||||
previous.forEach(prev => {
|
||||
if (!currMap.has(prev.id)) {
|
||||
events.push(createEvent('deleted' as any, prev, now)); // Force cast as 'deleted' may not be in ActivityEventKind type
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
* Uses composite key `${type}:${target}` to detect type changes as well.
|
||||
*/
|
||||
function diffDependencies(
|
||||
prev: BeadDependency[],
|
||||
curr: BeadDependency[]
|
||||
): { kind: 'dependency_added' | 'dependency_removed', target: string, type: string }[] {
|
||||
const changes: { kind: 'dependency_added' | 'dependency_removed', target: string, type: string }[] = [];
|
||||
|
||||
const prevKeys = new Set(prev.map(d => `${d.type}:${d.target}`));
|
||||
const currKeys = new Set(curr.map(d => `${d.type}:${d.target}`));
|
||||
|
||||
curr.forEach(d => {
|
||||
const key = `${d.type}:${d.target}`;
|
||||
if (!prevKeys.has(key)) {
|
||||
changes.push({ kind: 'dependency_added', target: d.target, type: d.type });
|
||||
}
|
||||
});
|
||||
|
||||
prev.forEach(d => {
|
||||
const key = `${d.type}:${d.target}`;
|
||||
if (!currKeys.has(key)) {
|
||||
changes.push({ kind: 'dependency_removed', target: d.target, type: d.type });
|
||||
}
|
||||
});
|
||||
|
||||
return changes;
|
||||
}
|
||||
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, field: kindAndTarget.type }));
|
||||
});
|
||||
});
|
||||
|
||||
// 6. Detect Deleted Issues
|
||||
if (previous) {
|
||||
const currMap = new Set(current.map(c => c.id));
|
||||
previous.forEach(prev => {
|
||||
if (!currMap.has(prev.id)) {
|
||||
events.push(createEvent('deleted' as any, prev, now)); // Force cast as 'deleted' may not be in ActivityEventKind type
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
* Uses composite key `${type}:${target}` to detect type changes as well.
|
||||
*/
|
||||
function diffDependencies(
|
||||
prev: BeadDependency[],
|
||||
curr: BeadDependency[]
|
||||
): { kind: 'dependency_added' | 'dependency_removed', target: string, type: string }[] {
|
||||
const changes: { kind: 'dependency_added' | 'dependency_removed', target: string, type: string }[] = [];
|
||||
|
||||
const prevKeys = new Set(prev.map(d => `${d.type}:${d.target}`));
|
||||
const currKeys = new Set(curr.map(d => `${d.type}:${d.target}`));
|
||||
|
||||
curr.forEach(d => {
|
||||
const key = `${d.type}:${d.target}`;
|
||||
if (!prevKeys.has(key)) {
|
||||
changes.push({ kind: 'dependency_added', target: d.target, type: d.type });
|
||||
}
|
||||
});
|
||||
|
||||
prev.forEach(d => {
|
||||
const key = `${d.type}:${d.target}`;
|
||||
if (!currKeys.has(key)) {
|
||||
changes.push({ kind: 'dependency_removed', target: d.target, type: d.type });
|
||||
}
|
||||
});
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +1,98 @@
|
|||
import type { BeadIssue } from './types';
|
||||
import type { ThreadItem } from '../components/shared/thread-view';
|
||||
|
||||
/**
|
||||
* Build thread items from a bead's history and metadata.
|
||||
* This creates a timeline of status changes, close events, etc.
|
||||
*/
|
||||
export function buildThreadItemsFromBead(issue: BeadIssue | null): ThreadItem[] {
|
||||
if (!issue) return [];
|
||||
|
||||
const items: ThreadItem[] = [];
|
||||
|
||||
// Creation event
|
||||
items.push({
|
||||
id: `${issue.id}-created`,
|
||||
type: 'status_change',
|
||||
from: 'none',
|
||||
to: issue.status,
|
||||
timestamp: new Date(issue.created_at),
|
||||
});
|
||||
|
||||
// If closed, add close event
|
||||
if (issue.closed_at && issue.status === 'closed') {
|
||||
items.push({
|
||||
id: `${issue.id}-closed`,
|
||||
type: 'status_change',
|
||||
from: issue.status,
|
||||
to: 'closed',
|
||||
timestamp: new Date(issue.closed_at),
|
||||
});
|
||||
|
||||
// Close reason as a comment if present
|
||||
if (issue.close_reason) {
|
||||
items.push({
|
||||
id: `${issue.id}-close-reason`,
|
||||
type: 'comment',
|
||||
author: issue.closed_by_session || 'system',
|
||||
content: issue.close_reason,
|
||||
timestamp: new Date(issue.closed_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Updated event (if significantly different from created)
|
||||
const created = new Date(issue.created_at).getTime();
|
||||
const updated = new Date(issue.updated_at).getTime();
|
||||
if (updated > created + 60000) { // More than 1 minute difference
|
||||
items.push({
|
||||
id: `${issue.id}-updated`,
|
||||
type: 'comment',
|
||||
author: 'system',
|
||||
content: `Last updated`,
|
||||
timestamp: new Date(issue.updated_at),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
items.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build thread items for a swarm (epic) showing aggregate info
|
||||
*/
|
||||
export function buildThreadItemsForSwarm(
|
||||
epicIssue: BeadIssue | null,
|
||||
childIssues: BeadIssue[]
|
||||
): ThreadItem[] {
|
||||
if (!epicIssue) return [];
|
||||
|
||||
const items: ThreadItem[] = [];
|
||||
|
||||
// Epic creation
|
||||
items.push({
|
||||
id: `${epicIssue.id}-created`,
|
||||
type: 'status_change',
|
||||
from: 'none',
|
||||
to: epicIssue.status,
|
||||
timestamp: new Date(epicIssue.created_at),
|
||||
});
|
||||
|
||||
// Summary of children
|
||||
const completed = childIssues.filter(i => i.status === 'closed').length;
|
||||
const inProgress = childIssues.filter(i => i.status === 'in_progress').length;
|
||||
|
||||
if (childIssues.length > 0) {
|
||||
items.push({
|
||||
id: `${epicIssue.id}-summary`,
|
||||
type: 'comment',
|
||||
author: 'system',
|
||||
content: `${childIssues.length} tasks: ${completed} closed, ${inProgress} in progress`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
import type { BeadIssue } from './types';
|
||||
import type { ThreadItem } from '../components/shared/thread-view';
|
||||
|
||||
/**
|
||||
* Build thread items from a bead's history and metadata.
|
||||
* This creates a timeline of status changes, close events, etc.
|
||||
*/
|
||||
export function buildThreadItemsFromBead(issue: BeadIssue | null): ThreadItem[] {
|
||||
if (!issue) return [];
|
||||
|
||||
const items: ThreadItem[] = [];
|
||||
|
||||
// Creation event
|
||||
items.push({
|
||||
id: `${issue.id}-created`,
|
||||
type: 'status_change',
|
||||
from: 'none',
|
||||
to: issue.status,
|
||||
timestamp: new Date(issue.created_at),
|
||||
});
|
||||
|
||||
// If closed, add close event
|
||||
if (issue.closed_at && issue.status === 'closed') {
|
||||
items.push({
|
||||
id: `${issue.id}-closed`,
|
||||
type: 'status_change',
|
||||
from: issue.status,
|
||||
to: 'closed',
|
||||
timestamp: new Date(issue.closed_at),
|
||||
});
|
||||
|
||||
// Close reason as a comment if present
|
||||
if (issue.close_reason) {
|
||||
items.push({
|
||||
id: `${issue.id}-close-reason`,
|
||||
type: 'comment',
|
||||
author: issue.closed_by_session || 'system',
|
||||
content: issue.close_reason,
|
||||
timestamp: new Date(issue.closed_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Updated event (if significantly different from created)
|
||||
const created = new Date(issue.created_at).getTime();
|
||||
const updated = new Date(issue.updated_at).getTime();
|
||||
if (updated > created + 60000) { // More than 1 minute difference
|
||||
items.push({
|
||||
id: `${issue.id}-updated`,
|
||||
type: 'comment',
|
||||
author: 'system',
|
||||
content: `Last updated`,
|
||||
timestamp: new Date(issue.updated_at),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
items.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build thread items for a swarm (epic) showing aggregate info
|
||||
*/
|
||||
export function buildThreadItemsForSwarm(
|
||||
epicIssue: BeadIssue | null,
|
||||
childIssues: BeadIssue[]
|
||||
): ThreadItem[] {
|
||||
if (!epicIssue) return [];
|
||||
|
||||
const items: ThreadItem[] = [];
|
||||
|
||||
// Epic creation
|
||||
items.push({
|
||||
id: `${epicIssue.id}-created`,
|
||||
type: 'status_change',
|
||||
from: 'none',
|
||||
to: epicIssue.status,
|
||||
timestamp: new Date(epicIssue.created_at),
|
||||
});
|
||||
|
||||
// Summary of children
|
||||
const completed = childIssues.filter(i => i.status === 'closed').length;
|
||||
const inProgress = childIssues.filter(i => i.status === 'in_progress').length;
|
||||
|
||||
if (childIssues.length > 0) {
|
||||
items.push({
|
||||
id: `${epicIssue.id}-summary`,
|
||||
type: 'comment',
|
||||
author: 'system',
|
||||
content: `${childIssues.length} tasks: ${completed} closed, ${inProgress} in progress`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
|
|
|||
144
src/lib/types.ts
144
src/lib/types.ts
|
|
@ -1,75 +1,75 @@
|
|||
export const BEAD_STATUSES = [
|
||||
'open',
|
||||
'in_progress',
|
||||
'blocked',
|
||||
'deferred',
|
||||
'closed',
|
||||
'tombstone',
|
||||
'pinned',
|
||||
'hooked',
|
||||
] as const;
|
||||
|
||||
export type BeadStatus = (typeof BEAD_STATUSES)[number];
|
||||
|
||||
export const BEAD_DEPENDENCY_TYPES = [
|
||||
'blocks',
|
||||
'parent',
|
||||
'relates_to',
|
||||
'duplicates',
|
||||
'supersedes',
|
||||
'replies_to',
|
||||
] as const;
|
||||
|
||||
export type BeadDependencyType = (typeof BEAD_DEPENDENCY_TYPES)[number];
|
||||
|
||||
export const CORE_ISSUE_TYPES = ['task', 'bug', 'feature', 'epic', 'chore'] as const;
|
||||
|
||||
export type CoreIssueType = (typeof CORE_ISSUE_TYPES)[number];
|
||||
export type BeadIssueType = CoreIssueType | (string & {});
|
||||
|
||||
export interface BeadDependency {
|
||||
type: BeadDependencyType;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface BeadIssue {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: BeadStatus;
|
||||
priority: number;
|
||||
issue_type: BeadIssueType;
|
||||
export const BEAD_STATUSES = [
|
||||
'open',
|
||||
'in_progress',
|
||||
'blocked',
|
||||
'deferred',
|
||||
'closed',
|
||||
'tombstone',
|
||||
'pinned',
|
||||
'hooked',
|
||||
] as const;
|
||||
|
||||
export type BeadStatus = (typeof BEAD_STATUSES)[number];
|
||||
|
||||
export const BEAD_DEPENDENCY_TYPES = [
|
||||
'blocks',
|
||||
'parent',
|
||||
'relates_to',
|
||||
'duplicates',
|
||||
'supersedes',
|
||||
'replies_to',
|
||||
] as const;
|
||||
|
||||
export type BeadDependencyType = (typeof BEAD_DEPENDENCY_TYPES)[number];
|
||||
|
||||
export const CORE_ISSUE_TYPES = ['task', 'bug', 'feature', 'epic', 'chore'] as const;
|
||||
|
||||
export type CoreIssueType = (typeof CORE_ISSUE_TYPES)[number];
|
||||
export type BeadIssueType = CoreIssueType | (string & {});
|
||||
|
||||
export interface BeadDependency {
|
||||
type: BeadDependencyType;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface BeadIssue {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: BeadStatus;
|
||||
priority: number;
|
||||
issue_type: BeadIssueType;
|
||||
assignee: string | null;
|
||||
templateId: string | null;
|
||||
owner: string | null;
|
||||
labels: string[];
|
||||
dependencies: BeadDependency[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at: string | null;
|
||||
close_reason: string | null;
|
||||
closed_by_session: string | null;
|
||||
created_by: string | null;
|
||||
due_at: string | null;
|
||||
estimated_minutes: number | null;
|
||||
external_ref: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ParseableBeadIssue extends Partial<BeadIssue> {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type ProjectSource = 'local' | 'registry' | 'scanner';
|
||||
|
||||
export interface ProjectContext {
|
||||
key: string;
|
||||
root: string;
|
||||
displayPath: string;
|
||||
name: string;
|
||||
source: ProjectSource;
|
||||
addedAt: string | null;
|
||||
}
|
||||
|
||||
export type BeadIssueWithProject = BeadIssue & { project: ProjectContext };
|
||||
labels: string[];
|
||||
dependencies: BeadDependency[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at: string | null;
|
||||
close_reason: string | null;
|
||||
closed_by_session: string | null;
|
||||
created_by: string | null;
|
||||
due_at: string | null;
|
||||
estimated_minutes: number | null;
|
||||
external_ref: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ParseableBeadIssue extends Partial<BeadIssue> {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type ProjectSource = 'local' | 'registry' | 'scanner';
|
||||
|
||||
export interface ProjectContext {
|
||||
key: string;
|
||||
root: string;
|
||||
displayPath: string;
|
||||
name: string;
|
||||
source: ProjectSource;
|
||||
addedAt: string | null;
|
||||
}
|
||||
|
||||
export type BeadIssueWithProject = BeadIssue & { project: ProjectContext };
|
||||
|
|
|
|||
|
|
@ -1,246 +1,246 @@
|
|||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import { ProjectEventCoalescer } from './coalescer';
|
||||
import { windowsPathKey } from './pathing';
|
||||
import { issuesEventBus, activityEventBus, type IssuesChangeKind, type IssuesEventBus, type ActivityEventBus } from './realtime';
|
||||
import { readIssuesFromDisk, resolveIssuesJsonlPathCandidates } from './read-issues';
|
||||
import { diffSnapshots } from './snapshot-differ';
|
||||
import type { BeadIssueWithProject } from './types';
|
||||
|
||||
type FileEventName = 'add' | 'change' | 'unlink';
|
||||
|
||||
function getGlobalAgentMessagesPath(): string {
|
||||
const userProfile = process.env.USERPROFILE?.trim() || os.homedir();
|
||||
return path.join(userProfile, '.beadboard', 'agent', 'messages');
|
||||
}
|
||||
|
||||
interface WatchRegistration {
|
||||
projectRoot: string;
|
||||
watcher: FSWatcher;
|
||||
handlers?: {
|
||||
onAdd: (changedPath: string) => void;
|
||||
onChange: (changedPath: string) => void;
|
||||
onUnlink: (changedPath: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WatchManagerOptions {
|
||||
debounceMs?: number;
|
||||
eventBus?: IssuesEventBus;
|
||||
activityBus?: ActivityEventBus;
|
||||
}
|
||||
|
||||
export class IssuesWatchManager {
|
||||
private readonly registrations = new Map<string, WatchRegistration>();
|
||||
|
||||
private readonly snapshots = new Map<string, BeadIssueWithProject[]>();
|
||||
|
||||
private readonly eventBus: IssuesEventBus;
|
||||
private readonly activityBus: ActivityEventBus;
|
||||
|
||||
private readonly coalescer: ProjectEventCoalescer<{
|
||||
changedPath?: string;
|
||||
kind: IssuesChangeKind;
|
||||
}>;
|
||||
|
||||
constructor(options: WatchManagerOptions = {}) {
|
||||
const debounceMs = options.debounceMs ?? 450;
|
||||
this.eventBus = options.eventBus ?? issuesEventBus;
|
||||
this.activityBus = options.activityBus ?? activityEventBus;
|
||||
this.coalescer = new ProjectEventCoalescer(debounceMs, async ({ projectRoot, payload }) => {
|
||||
console.log(`[Watcher] Processing event for ${projectRoot}: ${payload.kind} (${payload.changedPath})`);
|
||||
|
||||
// 1. Emit basic file change event
|
||||
// If it's just last-touched or a DB file change, we treat it as telemetry initially
|
||||
const changedPath = payload.changedPath || '';
|
||||
const isIssuesJsonl = changedPath.endsWith('issues.jsonl') || changedPath.endsWith('issues.jsonl.new');
|
||||
const isLastTouched = changedPath.includes('last-touched');
|
||||
const isDbPulse = changedPath.includes('beads.db');
|
||||
const isArchetype = changedPath.includes('.beads') && changedPath.includes('archetypes');
|
||||
const isTemplate = changedPath.includes('.beads') && changedPath.includes('templates');
|
||||
|
||||
const isBaseTelemetry = (isLastTouched || isDbPulse) && !isIssuesJsonl && !isArchetype && !isTemplate;
|
||||
|
||||
console.log(`[Watcher] Base Telemetry Emit -> ${isBaseTelemetry ? 'telemetry' : payload.kind}`);
|
||||
this.eventBus.emit(projectRoot, payload.changedPath, isBaseTelemetry ? 'telemetry' : payload.kind);
|
||||
|
||||
// 2. Perform snapshot diffing if issues.jsonl changed
|
||||
const isBeadsDb = changedPath.includes('beads.db') || isLastTouched;
|
||||
const isGlobalMessages = changedPath.includes('.beadboard') && changedPath.includes('messages');
|
||||
|
||||
if (isIssuesJsonl || isBeadsDb) {
|
||||
console.log(`[Watcher] Issues changed. Syncing activity for ${projectRoot}...`);
|
||||
const hadMutations = await this.syncActivity(projectRoot);
|
||||
|
||||
// If it was just a telemetry pulse, but we discovered actual structural changes, emit an issues event to refresh UI
|
||||
if (hadMutations && isBaseTelemetry) {
|
||||
console.log(`[Watcher] Structural changes found in telemetry pulse. Upgrading to issues event.`);
|
||||
this.eventBus.emit(projectRoot, payload.changedPath, payload.kind);
|
||||
}
|
||||
} else if (isGlobalMessages) {
|
||||
console.log(`[Watcher] Global agent messages changed. Triggering refresh for ${projectRoot}.`);
|
||||
// No need to syncActivity (diff issues) if only messages changed,
|
||||
// the 'issues' event emitted above will trigger client refresh.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async syncActivity(projectRoot: string): Promise<boolean> {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const previous = this.snapshots.get(projectKey) ?? null;
|
||||
|
||||
try {
|
||||
const current = await readIssuesFromDisk({ projectRoot, preferBd: true, skipAgentFilter: true });
|
||||
const events = diffSnapshots(previous, current);
|
||||
|
||||
console.log(`[Watcher] syncActivity for ${projectRoot}: generated ${events.length} events (prev: ${previous?.length ?? 0}, current: ${current.length})`);
|
||||
|
||||
this.snapshots.set(projectKey, current);
|
||||
|
||||
events.forEach(event => {
|
||||
this.activityBus.emit(event);
|
||||
});
|
||||
|
||||
return events.length > 0;
|
||||
} catch (error) {
|
||||
console.error(`[Watcher] Failed to sync activity for ${projectRoot}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async startWatch(projectRoot: string): Promise<void> {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
if (this.registrations.has(projectKey)) {
|
||||
console.log(`[Watcher] Already watching: ${projectKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Watcher] Starting watch for: ${projectRoot} (key: ${projectKey})`);
|
||||
|
||||
// Pre-populate snapshot to avoid "all created" burst on first change
|
||||
try {
|
||||
const initial = await readIssuesFromDisk({ projectRoot, preferBd: true, skipAgentFilter: true });
|
||||
this.snapshots.set(projectKey, initial);
|
||||
console.log(`[Watcher] Initial snapshot: ${initial.length} issues`);
|
||||
} catch (err) {
|
||||
console.log(`[Watcher] Initial snapshot failed:`, err);
|
||||
// Ignore initial read failure, will retry on first change
|
||||
}
|
||||
|
||||
const watchedPaths = resolveIssuesJsonlPathCandidates(projectRoot);
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'beads.db'));
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'beads.db-wal'));
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'last-touched'));
|
||||
|
||||
// Watch archetypes and templates directories for real-time updates
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'archetypes'));
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'templates'));
|
||||
|
||||
// Add global agent messages to enable cross-project communication real-time updates
|
||||
watchedPaths.push(getGlobalAgentMessagesPath());
|
||||
|
||||
console.log(`[Watcher] Watching paths:`, watchedPaths);
|
||||
|
||||
const watcher = chokidar.watch(watchedPaths, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 80,
|
||||
pollInterval: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const onFileEvent = (eventName: FileEventName, changedPath: string) => {
|
||||
console.log(`[Watcher] File event: ${eventName} on ${changedPath}`);
|
||||
const kind: IssuesChangeKind = eventName === 'unlink' ? 'renamed' : 'changed';
|
||||
this.queueCoalescedEvent(projectRoot, changedPath, kind);
|
||||
};
|
||||
|
||||
// Store references to event handlers for proper cleanup
|
||||
const onAdd = (changedPath: string) => onFileEvent('add', changedPath);
|
||||
const onChange = (changedPath: string) => onFileEvent('change', changedPath);
|
||||
const onUnlink = (changedPath: string) => onFileEvent('unlink', changedPath);
|
||||
|
||||
watcher.on('add', onAdd);
|
||||
watcher.on('change', onChange);
|
||||
watcher.on('unlink', onUnlink);
|
||||
|
||||
this.registrations.set(projectKey, {
|
||||
projectRoot,
|
||||
watcher,
|
||||
handlers: { onAdd, onChange, onUnlink },
|
||||
});
|
||||
}
|
||||
|
||||
async stopWatch(projectRoot: string): Promise<void> {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const registration = this.registrations.get(projectKey);
|
||||
if (!registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.coalescer.cancel(projectRoot);
|
||||
|
||||
// Explicitly remove event listeners before closing to prevent memory leaks
|
||||
if (registration.handlers) {
|
||||
registration.watcher.removeListener('add', registration.handlers.onAdd);
|
||||
registration.watcher.removeListener('change', registration.handlers.onChange);
|
||||
registration.watcher.removeListener('unlink', registration.handlers.onUnlink);
|
||||
}
|
||||
|
||||
this.registrations.delete(projectKey);
|
||||
await registration.watcher.close();
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
const closeOps: Promise<void>[] = [];
|
||||
|
||||
for (const registration of this.registrations.values()) {
|
||||
// Explicitly remove event listeners before closing to prevent memory leaks
|
||||
if (registration.handlers) {
|
||||
registration.watcher.removeListener('add', registration.handlers.onAdd);
|
||||
registration.watcher.removeListener('change', registration.handlers.onChange);
|
||||
registration.watcher.removeListener('unlink', registration.handlers.onUnlink);
|
||||
}
|
||||
closeOps.push(registration.watcher.close());
|
||||
}
|
||||
|
||||
this.coalescer.cancelAll();
|
||||
this.registrations.clear();
|
||||
await Promise.all(closeOps);
|
||||
}
|
||||
|
||||
getWatchedProjectCount(): number {
|
||||
return this.registrations.size;
|
||||
}
|
||||
|
||||
private queueCoalescedEvent(projectRoot: string, changedPath: string, kind: IssuesChangeKind): void {
|
||||
this.coalescer.queue(projectRoot, {
|
||||
changedPath,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const WATCHER_VERSION = 4; // Bump this to force re-creation on HMR (v4: fix beads.db telemetry classification)
|
||||
|
||||
const globalRegistry = globalThis as typeof globalThis & {
|
||||
__beadboardWatchManager?: IssuesWatchManager;
|
||||
__beadboardWatcherVersion?: number;
|
||||
};
|
||||
|
||||
export function getIssuesWatchManager(): IssuesWatchManager {
|
||||
if (!globalRegistry.__beadboardWatchManager || globalRegistry.__beadboardWatcherVersion !== WATCHER_VERSION) {
|
||||
if (globalRegistry.__beadboardWatchManager) {
|
||||
console.log('[Watcher] Stopping stale watcher instance...');
|
||||
// Best effort stop of old instance
|
||||
void globalRegistry.__beadboardWatchManager.stopAll();
|
||||
}
|
||||
console.log(`[Watcher] Initializing new manager (v${WATCHER_VERSION})...`);
|
||||
globalRegistry.__beadboardWatchManager = new IssuesWatchManager();
|
||||
globalRegistry.__beadboardWatcherVersion = WATCHER_VERSION;
|
||||
}
|
||||
|
||||
return globalRegistry.__beadboardWatchManager;
|
||||
}
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import { ProjectEventCoalescer } from './coalescer';
|
||||
import { windowsPathKey } from './pathing';
|
||||
import { issuesEventBus, activityEventBus, type IssuesChangeKind, type IssuesEventBus, type ActivityEventBus } from './realtime';
|
||||
import { readIssuesFromDisk, resolveIssuesJsonlPathCandidates } from './read-issues';
|
||||
import { diffSnapshots } from './snapshot-differ';
|
||||
import type { BeadIssueWithProject } from './types';
|
||||
|
||||
type FileEventName = 'add' | 'change' | 'unlink';
|
||||
|
||||
function getGlobalAgentMessagesPath(): string {
|
||||
const userProfile = process.env.USERPROFILE?.trim() || os.homedir();
|
||||
return path.join(userProfile, '.beadboard', 'agent', 'messages');
|
||||
}
|
||||
|
||||
interface WatchRegistration {
|
||||
projectRoot: string;
|
||||
watcher: FSWatcher;
|
||||
handlers?: {
|
||||
onAdd: (changedPath: string) => void;
|
||||
onChange: (changedPath: string) => void;
|
||||
onUnlink: (changedPath: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WatchManagerOptions {
|
||||
debounceMs?: number;
|
||||
eventBus?: IssuesEventBus;
|
||||
activityBus?: ActivityEventBus;
|
||||
}
|
||||
|
||||
export class IssuesWatchManager {
|
||||
private readonly registrations = new Map<string, WatchRegistration>();
|
||||
|
||||
private readonly snapshots = new Map<string, BeadIssueWithProject[]>();
|
||||
|
||||
private readonly eventBus: IssuesEventBus;
|
||||
private readonly activityBus: ActivityEventBus;
|
||||
|
||||
private readonly coalescer: ProjectEventCoalescer<{
|
||||
changedPath?: string;
|
||||
kind: IssuesChangeKind;
|
||||
}>;
|
||||
|
||||
constructor(options: WatchManagerOptions = {}) {
|
||||
const debounceMs = options.debounceMs ?? 450;
|
||||
this.eventBus = options.eventBus ?? issuesEventBus;
|
||||
this.activityBus = options.activityBus ?? activityEventBus;
|
||||
this.coalescer = new ProjectEventCoalescer(debounceMs, async ({ projectRoot, payload }) => {
|
||||
console.log(`[Watcher] Processing event for ${projectRoot}: ${payload.kind} (${payload.changedPath})`);
|
||||
|
||||
// 1. Emit basic file change event
|
||||
// If it's just last-touched or a DB file change, we treat it as telemetry initially
|
||||
const changedPath = payload.changedPath || '';
|
||||
const isIssuesJsonl = changedPath.endsWith('issues.jsonl') || changedPath.endsWith('issues.jsonl.new');
|
||||
const isLastTouched = changedPath.includes('last-touched');
|
||||
const isDbPulse = changedPath.includes('beads.db');
|
||||
const isArchetype = changedPath.includes('.beads') && changedPath.includes('archetypes');
|
||||
const isTemplate = changedPath.includes('.beads') && changedPath.includes('templates');
|
||||
|
||||
const isBaseTelemetry = (isLastTouched || isDbPulse) && !isIssuesJsonl && !isArchetype && !isTemplate;
|
||||
|
||||
console.log(`[Watcher] Base Telemetry Emit -> ${isBaseTelemetry ? 'telemetry' : payload.kind}`);
|
||||
this.eventBus.emit(projectRoot, payload.changedPath, isBaseTelemetry ? 'telemetry' : payload.kind);
|
||||
|
||||
// 2. Perform snapshot diffing if issues.jsonl changed
|
||||
const isBeadsDb = changedPath.includes('beads.db') || isLastTouched;
|
||||
const isGlobalMessages = changedPath.includes('.beadboard') && changedPath.includes('messages');
|
||||
|
||||
if (isIssuesJsonl || isBeadsDb) {
|
||||
console.log(`[Watcher] Issues changed. Syncing activity for ${projectRoot}...`);
|
||||
const hadMutations = await this.syncActivity(projectRoot);
|
||||
|
||||
// If it was just a telemetry pulse, but we discovered actual structural changes, emit an issues event to refresh UI
|
||||
if (hadMutations && isBaseTelemetry) {
|
||||
console.log(`[Watcher] Structural changes found in telemetry pulse. Upgrading to issues event.`);
|
||||
this.eventBus.emit(projectRoot, payload.changedPath, payload.kind);
|
||||
}
|
||||
} else if (isGlobalMessages) {
|
||||
console.log(`[Watcher] Global agent messages changed. Triggering refresh for ${projectRoot}.`);
|
||||
// No need to syncActivity (diff issues) if only messages changed,
|
||||
// the 'issues' event emitted above will trigger client refresh.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async syncActivity(projectRoot: string): Promise<boolean> {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const previous = this.snapshots.get(projectKey) ?? null;
|
||||
|
||||
try {
|
||||
const current = await readIssuesFromDisk({ projectRoot, preferBd: true, skipAgentFilter: true });
|
||||
const events = diffSnapshots(previous, current);
|
||||
|
||||
console.log(`[Watcher] syncActivity for ${projectRoot}: generated ${events.length} events (prev: ${previous?.length ?? 0}, current: ${current.length})`);
|
||||
|
||||
this.snapshots.set(projectKey, current);
|
||||
|
||||
events.forEach(event => {
|
||||
this.activityBus.emit(event);
|
||||
});
|
||||
|
||||
return events.length > 0;
|
||||
} catch (error) {
|
||||
console.error(`[Watcher] Failed to sync activity for ${projectRoot}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async startWatch(projectRoot: string): Promise<void> {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
if (this.registrations.has(projectKey)) {
|
||||
console.log(`[Watcher] Already watching: ${projectKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Watcher] Starting watch for: ${projectRoot} (key: ${projectKey})`);
|
||||
|
||||
// Pre-populate snapshot to avoid "all created" burst on first change
|
||||
try {
|
||||
const initial = await readIssuesFromDisk({ projectRoot, preferBd: true, skipAgentFilter: true });
|
||||
this.snapshots.set(projectKey, initial);
|
||||
console.log(`[Watcher] Initial snapshot: ${initial.length} issues`);
|
||||
} catch (err) {
|
||||
console.log(`[Watcher] Initial snapshot failed:`, err);
|
||||
// Ignore initial read failure, will retry on first change
|
||||
}
|
||||
|
||||
const watchedPaths = resolveIssuesJsonlPathCandidates(projectRoot);
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'beads.db'));
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'beads.db-wal'));
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'last-touched'));
|
||||
|
||||
// Watch archetypes and templates directories for real-time updates
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'archetypes'));
|
||||
watchedPaths.push(path.join(projectRoot, '.beads', 'templates'));
|
||||
|
||||
// Add global agent messages to enable cross-project communication real-time updates
|
||||
watchedPaths.push(getGlobalAgentMessagesPath());
|
||||
|
||||
console.log(`[Watcher] Watching paths:`, watchedPaths);
|
||||
|
||||
const watcher = chokidar.watch(watchedPaths, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 80,
|
||||
pollInterval: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const onFileEvent = (eventName: FileEventName, changedPath: string) => {
|
||||
console.log(`[Watcher] File event: ${eventName} on ${changedPath}`);
|
||||
const kind: IssuesChangeKind = eventName === 'unlink' ? 'renamed' : 'changed';
|
||||
this.queueCoalescedEvent(projectRoot, changedPath, kind);
|
||||
};
|
||||
|
||||
// Store references to event handlers for proper cleanup
|
||||
const onAdd = (changedPath: string) => onFileEvent('add', changedPath);
|
||||
const onChange = (changedPath: string) => onFileEvent('change', changedPath);
|
||||
const onUnlink = (changedPath: string) => onFileEvent('unlink', changedPath);
|
||||
|
||||
watcher.on('add', onAdd);
|
||||
watcher.on('change', onChange);
|
||||
watcher.on('unlink', onUnlink);
|
||||
|
||||
this.registrations.set(projectKey, {
|
||||
projectRoot,
|
||||
watcher,
|
||||
handlers: { onAdd, onChange, onUnlink },
|
||||
});
|
||||
}
|
||||
|
||||
async stopWatch(projectRoot: string): Promise<void> {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const registration = this.registrations.get(projectKey);
|
||||
if (!registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.coalescer.cancel(projectRoot);
|
||||
|
||||
// Explicitly remove event listeners before closing to prevent memory leaks
|
||||
if (registration.handlers) {
|
||||
registration.watcher.removeListener('add', registration.handlers.onAdd);
|
||||
registration.watcher.removeListener('change', registration.handlers.onChange);
|
||||
registration.watcher.removeListener('unlink', registration.handlers.onUnlink);
|
||||
}
|
||||
|
||||
this.registrations.delete(projectKey);
|
||||
await registration.watcher.close();
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
const closeOps: Promise<void>[] = [];
|
||||
|
||||
for (const registration of this.registrations.values()) {
|
||||
// Explicitly remove event listeners before closing to prevent memory leaks
|
||||
if (registration.handlers) {
|
||||
registration.watcher.removeListener('add', registration.handlers.onAdd);
|
||||
registration.watcher.removeListener('change', registration.handlers.onChange);
|
||||
registration.watcher.removeListener('unlink', registration.handlers.onUnlink);
|
||||
}
|
||||
closeOps.push(registration.watcher.close());
|
||||
}
|
||||
|
||||
this.coalescer.cancelAll();
|
||||
this.registrations.clear();
|
||||
await Promise.all(closeOps);
|
||||
}
|
||||
|
||||
getWatchedProjectCount(): number {
|
||||
return this.registrations.size;
|
||||
}
|
||||
|
||||
private queueCoalescedEvent(projectRoot: string, changedPath: string, kind: IssuesChangeKind): void {
|
||||
this.coalescer.queue(projectRoot, {
|
||||
changedPath,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const WATCHER_VERSION = 4; // Bump this to force re-creation on HMR (v4: fix beads.db telemetry classification)
|
||||
|
||||
const globalRegistry = globalThis as typeof globalThis & {
|
||||
__beadboardWatchManager?: IssuesWatchManager;
|
||||
__beadboardWatcherVersion?: number;
|
||||
};
|
||||
|
||||
export function getIssuesWatchManager(): IssuesWatchManager {
|
||||
if (!globalRegistry.__beadboardWatchManager || globalRegistry.__beadboardWatcherVersion !== WATCHER_VERSION) {
|
||||
if (globalRegistry.__beadboardWatchManager) {
|
||||
console.log('[Watcher] Stopping stale watcher instance...');
|
||||
// Best effort stop of old instance
|
||||
void globalRegistry.__beadboardWatchManager.stopAll();
|
||||
}
|
||||
console.log(`[Watcher] Initializing new manager (v${WATCHER_VERSION})...`);
|
||||
globalRegistry.__beadboardWatchManager = new IssuesWatchManager();
|
||||
globalRegistry.__beadboardWatcherVersion = WATCHER_VERSION;
|
||||
}
|
||||
|
||||
return globalRegistry.__beadboardWatchManager;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue