chore: checkpoint before DAG views UX overhaul
This commit is contained in:
parent
5695125a75
commit
a03def1ca1
125 changed files with 40711 additions and 581 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue