Merge origin/main into feature/assign-archetypes-to-tasks-ui
Resolved conflicts: - .gitignore: kept both bd.sock.startlock and .beadboard/ entries - package.json: kept feature branch test script (explicit enumeration) - API routes: kept dynamic export + isValidProjectRoot from main - globals.css: kept HEAD slideInFromRight animation - use-beads-subscription.ts: kept HEAD onopen handler - realtime.ts: kept main console.log in emit() - snapshot-differ.ts: kept main type-aware dependency diff Blue colors preserved from feature branch.
This commit is contained in:
commit
a8079813b8
28 changed files with 931 additions and 70 deletions
|
|
@ -98,6 +98,12 @@ 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,
|
||||
|
|
@ -352,6 +358,10 @@ export async function readAgentMessage(
|
|||
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) {
|
||||
|
|
@ -396,6 +406,10 @@ export async function ackAgentMessage(
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ function trimOrEmpty(value: unknown): string {
|
|||
async function callBdAgentShow(beadId: string, projectRoot: string): Promise<AgentRecord | null> {
|
||||
const showResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['show', beadId, '--json'],
|
||||
args: ['agent', 'show', beadId, '--json'],
|
||||
});
|
||||
|
||||
if (!showResult.success) {
|
||||
|
|
|
|||
|
|
@ -215,6 +215,49 @@ async function readActiveReservations(): Promise<AgentReservation[]> {
|
|||
}
|
||||
}
|
||||
|
||||
// Simple mutex-based locking using a shared lock file to prevent race conditions
|
||||
const LOCK_FILE_PATH = path.join(reservationsRoot(), '.lock');
|
||||
|
||||
async function lockActiveReservations(): Promise<void> {
|
||||
// Ensure the directory exists
|
||||
await fs.mkdir(path.dirname(LOCK_FILE_PATH), { recursive: true });
|
||||
|
||||
// Use a simple file-based mutex - create file exclusively, fail if exists
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
await fs.writeFile(LOCK_FILE_PATH, String(process.pid), { flag: 'wx' });
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
// Lock file exists, wait and retry
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
attempts++;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to acquire lock after maximum attempts');
|
||||
}
|
||||
|
||||
async function unlockActiveReservations(): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(LOCK_FILE_PATH, 'utf8');
|
||||
// Only release if we own the lock
|
||||
if (content.trim() === String(process.pid)) {
|
||||
await fs.unlink(LOCK_FILE_PATH);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
// Lock file doesn't exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicWriteJson(filePath: string, payload: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
|
|
@ -317,6 +360,9 @@ export async function reserveAgentScope(
|
|||
}
|
||||
|
||||
try {
|
||||
// Acquire exclusive lock to prevent race conditions
|
||||
await lockActiveReservations();
|
||||
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const reservations = await readActiveReservations();
|
||||
const normalizedScope = normalizePath(scope);
|
||||
|
|
@ -384,6 +430,8 @@ export async function reserveAgentScope(
|
|||
return success(command, created);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to reserve scope.');
|
||||
} finally {
|
||||
await unlockActiveReservations();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -405,6 +453,9 @@ export async function releaseAgentReservation(
|
|||
}
|
||||
|
||||
try {
|
||||
// Acquire exclusive lock to prevent race conditions
|
||||
await lockActiveReservations();
|
||||
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const reservations = await readActiveReservations();
|
||||
const normalizedScope = normalizePath(scope);
|
||||
|
|
@ -436,6 +487,8 @@ export async function releaseAgentReservation(
|
|||
return success(command, released);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to release reservation.');
|
||||
} finally {
|
||||
await unlockActiveReservations();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,14 @@ function asNonEmptyString(value: unknown, field: string): string {
|
|||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new MutationValidationError(`"${field}" is required.`);
|
||||
}
|
||||
return value.trim();
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export function parseIssuesJsonl(text: string, options: ParseIssuesOptions = {})
|
|||
}
|
||||
|
||||
// Exclude agent identities from standard mission lists
|
||||
if (normalized.labels.includes('gt:agent')) {
|
||||
if (!options.skipAgentFilter && normalized.labels.includes('gt:agent')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import path from 'node:path';
|
||||
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
|
||||
import type { ActivityEvent } from './activity';
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ export class IssuesEventBus {
|
|||
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})`);
|
||||
|
|
@ -97,6 +99,7 @@ export class ActivityEventBus {
|
|||
private readonly history: ActivityEvent[] = [];
|
||||
private readonly MAX_HISTORY = 100;
|
||||
private initialized = false;
|
||||
private savePromise: Promise<void> | null = null;
|
||||
|
||||
private nextSubscriberId = 1;
|
||||
|
||||
|
|
@ -118,14 +121,30 @@ export class ActivityEventBus {
|
|||
};
|
||||
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
|
||||
void saveActivityHistory(this.history);
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export function diffSnapshots(
|
|||
|
||||
// 5. Collection Changes (Dependencies)
|
||||
diffDependencies(prev.dependencies, curr.dependencies).forEach(kindAndTarget => {
|
||||
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target }));
|
||||
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target, field: kindAndTarget.type }));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -129,25 +129,28 @@ function areArraysEqual(a: string[], b: string[]): boolean {
|
|||
|
||||
/**
|
||||
* 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 }[] {
|
||||
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));
|
||||
): { 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 => {
|
||||
if (!prevTargets.has(d.target)) {
|
||||
changes.push({ kind: 'dependency_added', target: d.target });
|
||||
const key = `${d.type}:${d.target}`;
|
||||
if (!prevKeys.has(key)) {
|
||||
changes.push({ kind: 'dependency_added', target: d.target, type: d.type });
|
||||
}
|
||||
});
|
||||
|
||||
prev.forEach(d => {
|
||||
if (!currTargets.has(d.target)) {
|
||||
changes.push({ kind: 'dependency_removed', target: d.target });
|
||||
const key = `${d.type}:${d.target}`;
|
||||
if (!currKeys.has(key)) {
|
||||
changes.push({ kind: 'dependency_removed', target: d.target, type: d.type });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ function getGlobalAgentMessagesPath(): string {
|
|||
interface WatchRegistration {
|
||||
projectRoot: string;
|
||||
watcher: FSWatcher;
|
||||
handlers?: {
|
||||
onAdd: (changedPath: string) => void;
|
||||
onChange: (changedPath: string) => void;
|
||||
onUnlink: (changedPath: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WatchManagerOptions {
|
||||
|
|
@ -152,13 +157,19 @@ export class IssuesWatchManager {
|
|||
this.queueCoalescedEvent(projectRoot, changedPath, kind);
|
||||
};
|
||||
|
||||
watcher.on('add', (changedPath) => onFileEvent('add', changedPath));
|
||||
watcher.on('change', (changedPath) => onFileEvent('change', changedPath));
|
||||
watcher.on('unlink', (changedPath) => onFileEvent('unlink', changedPath));
|
||||
// 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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +181,14 @@ export class IssuesWatchManager {
|
|||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
@ -178,6 +197,12 @@ export class IssuesWatchManager {
|
|||
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());
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue