Today we reached a major architectural conclusion: project history shouldn't be stored, it should be derived. We rejected the overhead of a separate SQLite event store in favor of an O(N) snapshot-diffing engine that computes human-readable narratives directly from the issues.jsonl source of truth. Key Triumphs: - Implemented O(N) diffing algorithm in src/lib/snapshot-differ.ts that transforms raw JSONL into 16 distinct social event types. - Engineered a file-based persistence layer (src/lib/activity-persistence.ts) to solve the 'Next.js HMR Wiped My Memory' bug, ensuring project heartbeat survives server restarts. - Developed the agent-session data model that unifies Beads, Activity, and Cross-Agent Mail into a single 'Mission' context. Raw Honest Moment: We struggled for over an hour with 'missing history' before realizing that development-mode reloads were purging our in-memory buffers. The shift to a file-backed ring buffer was a reactive pivot that became a core project strength.
145 lines
4.6 KiB
TypeScript
145 lines
4.6 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import type { ActivityEvent, ActivityEventKind } from './activity';
|
|
import type { BeadIssueWithProject, BeadDependency } from './types';
|
|
|
|
/**
|
|
* Compares two snapshots of BeadIssueWithProject arrays and returns a list of ActivityEvents
|
|
* representing the differences.
|
|
*/
|
|
export function diffSnapshots(
|
|
previous: BeadIssueWithProject[] | null,
|
|
current: BeadIssueWithProject[]
|
|
): ActivityEvent[] {
|
|
const events: ActivityEvent[] = [];
|
|
const prevMap = new Map<string, BeadIssueWithProject>();
|
|
if (previous) {
|
|
previous.forEach((issue) => prevMap.set(issue.id, issue));
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
current.forEach((curr) => {
|
|
const prev = prevMap.get(curr.id);
|
|
|
|
if (!prev) {
|
|
// 1. Issue Created
|
|
events.push(createEvent('created', curr, now));
|
|
return;
|
|
}
|
|
|
|
// 2. Status Changes
|
|
if (prev.status !== curr.status) {
|
|
if (curr.status === 'closed') {
|
|
events.push(createEvent('closed', curr, now, { from: prev.status, to: 'closed', message: curr.close_reason || undefined }));
|
|
} else if (prev.status === 'closed') {
|
|
events.push(createEvent('reopened', curr, now, { from: 'closed', to: curr.status }));
|
|
} else {
|
|
events.push(createEvent('status_changed', curr, now, { field: 'status', from: prev.status, to: curr.status }));
|
|
}
|
|
}
|
|
|
|
// 3. Property Changes
|
|
if (prev.title !== curr.title) {
|
|
events.push(createEvent('title_changed', curr, now, { field: 'title', from: prev.title, to: curr.title }));
|
|
}
|
|
|
|
if (prev.priority !== curr.priority) {
|
|
events.push(createEvent('priority_changed', curr, now, { field: 'priority', from: prev.priority, to: curr.priority }));
|
|
}
|
|
|
|
if (prev.description !== curr.description) {
|
|
events.push(createEvent('description_changed', curr, now, { field: 'description', from: prev.description, to: curr.description }));
|
|
}
|
|
|
|
if (prev.issue_type !== curr.issue_type) {
|
|
events.push(createEvent('type_changed', curr, now, { field: 'issue_type', from: prev.issue_type, to: curr.issue_type }));
|
|
}
|
|
|
|
if (prev.assignee !== curr.assignee) {
|
|
events.push(createEvent('assignee_changed', curr, now, { field: 'assignee', from: prev.assignee, to: curr.assignee }));
|
|
}
|
|
|
|
if (prev.due_at !== curr.due_at) {
|
|
events.push(createEvent('due_date_changed', curr, now, { field: 'due_at', from: prev.due_at, to: curr.due_at }));
|
|
}
|
|
|
|
if (prev.estimated_minutes !== curr.estimated_minutes) {
|
|
events.push(createEvent('estimate_changed', curr, now, { field: 'estimated_minutes', from: prev.estimated_minutes, to: curr.estimated_minutes }));
|
|
}
|
|
|
|
// 4. Collection Changes (Labels)
|
|
if (!areArraysEqual(prev.labels, curr.labels)) {
|
|
events.push(createEvent('labels_changed', curr, now, {
|
|
field: 'labels',
|
|
from: prev.labels.join(','),
|
|
to: curr.labels.join(',')
|
|
}));
|
|
}
|
|
|
|
// 5. Collection Changes (Dependencies)
|
|
diffDependencies(prev.dependencies, curr.dependencies).forEach(kindAndTarget => {
|
|
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target }));
|
|
});
|
|
});
|
|
|
|
return events;
|
|
}
|
|
|
|
/**
|
|
* Helper to create an ActivityEvent with standard fields.
|
|
*/
|
|
function createEvent(
|
|
kind: ActivityEventKind,
|
|
issue: BeadIssueWithProject,
|
|
timestamp: string,
|
|
payload: ActivityEvent['payload'] = {}
|
|
): ActivityEvent {
|
|
return {
|
|
id: randomUUID(),
|
|
kind,
|
|
beadId: issue.id,
|
|
beadTitle: issue.title,
|
|
projectId: issue.project.key,
|
|
projectName: issue.project.name,
|
|
timestamp,
|
|
actor: issue.assignee || issue.owner || issue.created_by,
|
|
payload,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Shallow equality check for string arrays (labels).
|
|
*/
|
|
function areArraysEqual(a: string[], b: string[]): boolean {
|
|
if (a.length !== b.length) return false;
|
|
const sortedA = [...a].sort();
|
|
const sortedB = [...b].sort();
|
|
return sortedA.every((val, index) => val === sortedB[index]);
|
|
}
|
|
|
|
/**
|
|
* Detects added and removed dependencies.
|
|
*/
|
|
function diffDependencies(
|
|
prev: BeadDependency[],
|
|
curr: BeadDependency[]
|
|
): { kind: 'dependency_added' | 'dependency_removed', target: string }[] {
|
|
const changes: { kind: 'dependency_added' | 'dependency_removed', target: string }[] = [];
|
|
|
|
const prevTargets = new Set(prev.map(d => d.target));
|
|
const currTargets = new Set(curr.map(d => d.target));
|
|
|
|
curr.forEach(d => {
|
|
if (!prevTargets.has(d.target)) {
|
|
changes.push({ kind: 'dependency_added', target: d.target });
|
|
}
|
|
});
|
|
|
|
prev.forEach(d => {
|
|
if (!currTargets.has(d.target)) {
|
|
changes.push({ kind: 'dependency_removed', target: d.target });
|
|
}
|
|
});
|
|
|
|
return changes;
|
|
}
|