chore: checkpoint before DAG views UX overhaul

This commit is contained in:
zenchantlive 2026-02-22 20:43:59 -08:00
parent 5695125a75
commit a03def1ca1
125 changed files with 40711 additions and 581 deletions

View file

@ -1,4 +1,5 @@
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';
@ -298,6 +299,9 @@ export async function executeMutation(
};
}
// Emit event to notify SSE clients of the change
issuesEventBus.emit(payload.projectRoot, undefined, 'changed');
return {
ok: true,
operation,

View file

@ -38,9 +38,9 @@ export class IssuesEventBus {
private nextSubscriberId = 1;
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
console.log(`[IssuesBus] Emitting event: ${kind} for ${projectRoot} (${changedPath})`);
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,
@ -50,11 +50,14 @@ export class IssuesEventBus {
};
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;
}

View file

@ -5,6 +5,84 @@ import { AgentArchetype, SwarmTemplate } from '../types-swarm';
const ARCHE_DIR = path.join(process.cwd(), '.beads', 'archetypes');
const TEMPLATE_DIR = path.join(process.cwd(), '.beads', 'templates');
export function slugify(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export type SaveArchetypeInput = Partial<AgentArchetype> & {
name: string;
description: string;
systemPrompt: string;
capabilities: string[];
color: string;
};
export async function saveArchetype(input: SaveArchetypeInput): Promise<AgentArchetype> {
await fs.mkdir(ARCHE_DIR, { recursive: true });
const id = input.id || slugify(input.name);
const now = new Date().toISOString();
let isBuiltIn = input.isBuiltIn ?? false;
let createdAt = input.createdAt || now;
try {
const existingContent = await fs.readFile(path.join(ARCHE_DIR, `${id}.json`), 'utf-8');
const existing = JSON.parse(existingContent);
if (existing.isBuiltIn) {
isBuiltIn = true; // Protect built-in status
}
if (existing.createdAt) {
createdAt = existing.createdAt;
}
} catch {
// File doesn't exist, which is fine
}
const archetype: AgentArchetype = {
id,
name: input.name,
description: input.description,
systemPrompt: input.systemPrompt,
capabilities: input.capabilities,
color: input.color,
createdAt,
updatedAt: now,
isBuiltIn
};
await fs.writeFile(
path.join(ARCHE_DIR, `${id}.json`),
JSON.stringify(archetype, null, 2)
);
return archetype;
}
export async function deleteArchetype(id: string): Promise<void> {
const filePath = path.join(ARCHE_DIR, `${id}.json`);
let archetype: AgentArchetype;
try {
const content = await fs.readFile(filePath, 'utf-8');
archetype = JSON.parse(content);
} catch {
throw new Error(`Archetype not found: ${id}`);
}
if (archetype.isBuiltIn) {
throw new Error(`Cannot delete built-in archetype: ${id}`);
}
await fs.unlink(filePath);
}
const SEED_ARCHETYPES: AgentArchetype[] = [
{
id: 'architect',
@ -113,3 +191,77 @@ export async function getTemplates(): Promise<SwarmTemplate[]> {
return [];
}
}
export type SaveTemplateInput = Partial<SwarmTemplate> & {
name: string;
description: string;
team: { archetypeId: string; count: number }[];
};
export async function saveTemplate(input: SaveTemplateInput): Promise<SwarmTemplate> {
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
const archetypes = await getArchetypes();
const validArchetypeIds = new Set(archetypes.map(a => a.id));
for (const member of input.team) {
if (!validArchetypeIds.has(member.archetypeId)) {
throw new Error(`Invalid archetype ID in team: ${member.archetypeId}`);
}
}
const id = input.id || slugify(input.name);
const now = new Date().toISOString();
let isBuiltIn = input.isBuiltIn ?? false;
let createdAt = input.createdAt || now;
try {
const existingContent = await fs.readFile(path.join(TEMPLATE_DIR, `${id}.json`), 'utf-8');
const existing = JSON.parse(existingContent);
if (existing.isBuiltIn) {
isBuiltIn = true; // Protect built-in status
}
if (existing.createdAt) {
createdAt = existing.createdAt;
}
} catch {
// File doesn't exist, which is fine
}
const template: SwarmTemplate = {
id,
name: input.name,
description: input.description,
team: input.team,
protoFormula: input.protoFormula,
createdAt,
updatedAt: now,
isBuiltIn
};
await fs.writeFile(
path.join(TEMPLATE_DIR, `${id}.json`),
JSON.stringify(template, null, 2)
);
return template;
}
export async function deleteTemplate(id: string): Promise<void> {
const filePath = path.join(TEMPLATE_DIR, `${id}.json`);
let template: SwarmTemplate;
try {
const content = await fs.readFile(filePath, 'utf-8');
template = JSON.parse(content);
} catch {
throw new Error(`Template not found: ${id}`);
}
if (template.isBuiltIn) {
throw new Error(`Cannot delete built-in template: ${id}`);
}
await fs.unlink(filePath);
}

View file

@ -69,10 +69,10 @@ export function diffSnapshots(
// 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(',')
events.push(createEvent('labels_changed', curr, now, {
field: 'labels',
from: prev.labels.join(','),
to: curr.labels.join(',')
}));
}
@ -82,6 +82,16 @@ export function diffSnapshots(
});
});
// 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;
}
@ -125,7 +135,7 @@ function diffDependencies(
curr: BeadDependency[]
): { kind: 'dependency_added' | 'dependency_removed', target: string }[] {
const changes: { kind: 'dependency_added' | 'dependency_removed', target: string }[] = [];
const prevTargets = new Set(prev.map(d => d.target));
const currTargets = new Set(curr.map(d => d.target));

View file

@ -41,29 +41,39 @@ export class IssuesWatchManager {
}>;
constructor(options: WatchManagerOptions = {}) {
const debounceMs = options.debounceMs ?? 150;
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
// 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 kind = (isLastTouched || isDbPulse) && !isIssuesJsonl ? 'telemetry' : payload.kind;
this.eventBus.emit(projectRoot, payload.changedPath, kind);
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}...`);
await this.syncActivity(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,
@ -72,35 +82,45 @@ export class IssuesWatchManager {
});
}
private async syncActivity(projectRoot: string): Promise<void> {
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);
} catch {
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
}
@ -109,9 +129,15 @@ export class IssuesWatchManager {
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: {
@ -121,6 +147,7 @@ export class IssuesWatchManager {
});
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);
};