From e2b4f4208b51499e7cf81d1451a38f7adc6d1395 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 14:21:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(worker,process,core,mcp):=20smoothness=20p?= =?UTF-8?q?ack=20=E2=80=94=20caffeinate,=20notifier,=20task=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small polish features ported from agent-orchestrator (caffeinate, notifier slot) and hive (cross-task links). - worker: hold a `caffeinate -i -w ` assertion on darwin while the embed loop is running, so a laptop lid-close or system idle no longer silently suspends embedding backfills. No-op on non-darwin and when the embedder failed to load. - process: new `notify({ level, title, body }, { provider, minLevel })` helper. provider='desktop' fans out to osascript/notify-send; provider='none' is a no-op. Fire-and-forget, never throws, never blocks a hot path. Worker uses it to surface embedder-load failures so users see a real signal instead of a stderr line they may never read. - config: new `notify` settings group (provider, minLevel). Defaults to silent so a fresh install stays unobtrusive. - storage: schema v8 adds `task_links` (one row per unordered pair). `linkTasks`/`unlinkTasks`/`linkedTasks` are symmetric and idempotent. - core: `TaskThread.link/unlink/linkedTasks` wraps the storage primitives. - mcp: new tools `task_link`, `task_unlink`, `task_links` so agents on one task can see decisions/blockers from a paired task without copy-paste — equivalent of hive's "worktree connections" minus the GUI. --- .changeset/smoothness-pack-1.md | 67 +++++++++++++++++ apps/mcp-server/src/tools/task.ts | 68 ++++++++++++++++++ apps/mcp-server/test/server.test.ts | 3 + apps/worker/src/caffeinate.ts | 50 +++++++++++++ apps/worker/src/server.ts | 22 +++++- apps/worker/test/caffeinate.test.ts | 15 ++++ packages/config/src/schema.ts | 19 +++++ packages/core/src/task-thread.ts | 37 +++++++++- packages/process/src/index.ts | 2 + packages/process/src/notify.ts | 89 +++++++++++++++++++++++ packages/process/test/notify.test.ts | 43 +++++++++++ packages/storage/src/index.ts | 3 + packages/storage/src/schema.ts | 20 +++++- packages/storage/src/storage.ts | 62 ++++++++++++++++ packages/storage/src/types.ts | 22 ++++++ packages/storage/test/tasks.test.ts | 104 +++++++++++++++++++++++++++ 16 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 .changeset/smoothness-pack-1.md create mode 100644 apps/worker/src/caffeinate.ts create mode 100644 apps/worker/test/caffeinate.test.ts create mode 100644 packages/process/src/notify.ts create mode 100644 packages/process/test/notify.test.ts diff --git a/.changeset/smoothness-pack-1.md b/.changeset/smoothness-pack-1.md new file mode 100644 index 0000000..a4194dc --- /dev/null +++ b/.changeset/smoothness-pack-1.md @@ -0,0 +1,67 @@ +--- +"@colony/process": minor +"@colony/config": minor +"@colony/storage": minor +"@colony/core": minor +"@colony/worker": minor +"@colony/mcp-server": minor +--- + +Smoothness pack: macOS idle-sleep prevention, desktop notifier slot, and +cross-task links. + +`@colony/process`: + +- New `notify({ level, title, body }, { provider, minLevel, log })` helper. + `provider: 'desktop'` fans out to `osascript` on darwin / `notify-send` on + linux; `'none'` is a no-op. Fire-and-forget: never awaits the spawned + helper, never throws, never blocks a hot path. Spawn failures are reported + via the optional `log` callback rather than crashing the caller. +- Re-exports `NotifyLevel`, `NotifyMessage`, `NotifyOptions`, plus a + `buildNotifyArgv` helper for testing. + +`@colony/config`: + +- New `notify` settings group: `provider: 'desktop' | 'none'` (default + `'none'` so a fresh install is silent) and `minLevel: 'info' | 'warn' | + 'error'` (default `'warn'`). Picked up automatically by `colony config + show` and `settingsDocs()`. + +`@colony/storage`: + +- Schema bumps to v8. New `task_links` table stores cross-task edges as one + row per unordered pair (`low_id < high_id` enforced via CHECK), with + `created_by`, `created_at`, and an optional `note`. +- `Storage.linkTasks(p)` is idempotent — re-linking a pair preserves the + original metadata. `Storage.unlinkTasks(a, b)` returns whether a row was + removed. `Storage.linkedTasks(task_id)` returns the *other* side of each + edge with link metadata, regardless of which side originally linked. +- Self-links (`task_id_a === task_id_b`) are rejected as a caller bug. +- New types: `TaskLinkRow`, `NewTaskLink`, `LinkedTask`. + +`@colony/core`: + +- `TaskThread.linkedTasks()`, `TaskThread.link(other_task_id, created_by, + note?)`, `TaskThread.unlink(other_task_id)` — symmetric helpers around + the storage primitives. + +`@colony/worker`: + +- New `apps/worker/src/caffeinate.ts` holds a `caffeinate -i -w ` + assertion on darwin while the embed loop is running, so a laptop lid-close + or system idle doesn't suspend long-running embedding backfills. No-op on + non-darwin and on missing binary; never started when the embedder failed + to load (the worker is then just a viewer + state file writer). +- Worker now emits a desktop notification via `@colony/process` when the + embedder fails to load, so users see a real signal instead of a stderr + line they may never read. Honours `settings.notify`. + +`@colony/mcp-server`: + +- New tools: `task_link(task_id, other_task_id, session_id, note?)`, + `task_unlink(task_id, other_task_id)`, `task_links(task_id)`. Symmetric: + callers don't need to think about ordering, and re-linking the same pair + is idempotent. + +Inspired by patterns in agent-orchestrator (caffeinate, plugin-style +notifier slot) and hive (worktree connections / cross-task linking). diff --git a/apps/mcp-server/src/tools/task.ts b/apps/mcp-server/src/tools/task.ts index 8742daa..05f00b8 100644 --- a/apps/mcp-server/src/tools/task.ts +++ b/apps/mcp-server/src/tools/task.ts @@ -104,4 +104,72 @@ export function register(server: McpServer, ctx: ToolContext): void { return { content: [{ type: 'text', text: JSON.stringify({ observation_id: id }) }] }; }, ); + + // --- task links --- + // Cross-task edges. Linking two tasks lets each side see the other's + // timeline + decisions in their own preface, without copy-paste. The + // storage layer stores one row per unordered pair; the MCP surface is + // symmetric so callers don't need to think about ordering. + + server.tool( + 'task_link', + 'Link two tasks bidirectionally so each side sees the other in attention prefaces. Idempotent.', + { + task_id: z.number().int().positive(), + other_task_id: z.number().int().positive(), + session_id: z.string().min(1), + note: z.string().max(280).optional(), + }, + async ({ task_id, other_task_id, session_id, note }) => { + if (task_id === other_task_id) { + return { + content: [ + { type: 'text', text: JSON.stringify({ error: 'cannot link a task to itself' }) }, + ], + isError: true, + }; + } + const thread = new TaskThread(store, task_id); + const link = thread.link(other_task_id, session_id, note); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + low_id: link.low_id, + high_id: link.high_id, + created_at: link.created_at, + created_by: link.created_by, + note: link.note, + }), + }, + ], + }; + }, + ); + + server.tool( + 'task_unlink', + 'Drop the bidirectional link between two tasks. Returns { removed: boolean }.', + { + task_id: z.number().int().positive(), + other_task_id: z.number().int().positive(), + }, + async ({ task_id, other_task_id }) => { + const thread = new TaskThread(store, task_id); + const removed = thread.unlink(other_task_id); + return { content: [{ type: 'text', text: JSON.stringify({ removed }) }] }; + }, + ); + + server.tool( + 'task_links', + 'List tasks linked to a task. Returns the other side of each edge with link metadata.', + { task_id: z.number().int().positive() }, + async ({ task_id }) => { + const thread = new TaskThread(store, task_id); + const links = thread.linkedTasks(); + return { content: [{ type: 'text', text: JSON.stringify(links) }] }; + }, + ); } diff --git a/apps/mcp-server/test/server.test.ts b/apps/mcp-server/test/server.test.ts index a045120..02140d0 100644 --- a/apps/mcp-server/test/server.test.ts +++ b/apps/mcp-server/test/server.test.ts @@ -71,6 +71,8 @@ describe('MCP server', () => { 'task_decline_handoff', 'task_foraging_report', 'task_hand_off', + 'task_link', + 'task_links', 'task_list', 'task_message', 'task_message_claim', @@ -85,6 +87,7 @@ describe('MCP server', () => { 'task_propose', 'task_reinforce', 'task_timeline', + 'task_unlink', 'task_updates_since', 'task_wake', 'timeline', diff --git a/apps/worker/src/caffeinate.ts b/apps/worker/src/caffeinate.ts new file mode 100644 index 0000000..8a3e6b0 --- /dev/null +++ b/apps/worker/src/caffeinate.ts @@ -0,0 +1,50 @@ +import { type ChildProcess, spawn } from 'node:child_process'; + +export interface CaffeinateHandle { + stop: () => void; +} + +/** + * Hold a `caffeinate -i` assertion on macOS while the worker is running, so a + * laptop lid-close or system idle doesn't suspend the long-running embedding + * backfill loop. `-i` blocks idle sleep only — display sleep and lid-close + * sleep on battery still work, matching the agent-orchestrator approach. + * + * On non-darwin platforms this is a no-op. If the binary is missing (rare — + * darwin always ships it under `/usr/bin/caffeinate`) we log once and return + * a no-op handle so the worker still boots. + */ +export function startCaffeinate(log: (line: string) => void): CaffeinateHandle { + if (process.platform !== 'darwin') { + return { stop: () => {} }; + } + + let child: ChildProcess | null = null; + try { + child = spawn('caffeinate', ['-i', '-w', String(process.pid)], { + stdio: 'ignore', + detached: false, + }); + } catch (err) { + log( + `[colony worker] caffeinate unavailable: ${err instanceof Error ? err.message : String(err)}`, + ); + return { stop: () => {} }; + } + + child.on('error', (err) => { + // ENOENT or permission denied — surface once, don't crash the worker. + log(`[colony worker] caffeinate spawn error: ${err.message}`); + }); + + return { + stop: () => { + if (!child || child.killed) return; + try { + child.kill('SIGTERM'); + } catch { + // Process already exited or PID reused — nothing to clean up. + } + }, + }; +} diff --git a/apps/worker/src/server.ts b/apps/worker/src/server.ts index e7eb0a4..aedf326 100644 --- a/apps/worker/src/server.ts +++ b/apps/worker/src/server.ts @@ -5,9 +5,10 @@ import { expand } from '@colony/compress'; import { type Settings, loadSettings, resolveDataDir } from '@colony/config'; import { type HivemindOptions, MemoryStore, listPlans, readHivemind } from '@colony/core'; import { createEmbedder } from '@colony/embedding'; -import { isMainEntry, removePidFile, writePidFile } from '@colony/process'; +import { isMainEntry, notify, removePidFile, writePidFile } from '@colony/process'; import { serve } from '@hono/node-server'; import { Hono } from 'hono'; +import { type CaffeinateHandle, startCaffeinate } from './caffeinate.js'; import { type EmbedLoopHandle, startEmbedLoop, stateFilePath } from './embed-loop.js'; import { renderIndex, renderSession } from './viewer.js'; @@ -217,10 +218,12 @@ export async function start(): Promise { writePidFile(pidFilePath(settings)); let loop: EmbedLoopHandle | undefined; + let caffeinate: CaffeinateHandle | undefined; const servers: Array> = []; const shutdown = async () => { removePidFile(pidFilePath(settings)); + caffeinate?.stop(); if (loop) await loop.stop(); for (const s of servers) s.close(); store.close(); @@ -244,6 +247,18 @@ export async function start(): Promise { } catch (err) { embedderError = err instanceof Error ? err.message : String(err); process.stderr.write(`[colony worker] embedder unavailable: ${embedderError}\n`); + notify( + { + level: 'warn', + title: 'colony: embedder unavailable', + body: `Semantic search disabled — BM25 still works. (${embedderError})`, + }, + { + provider: settings.notify.provider, + minLevel: settings.notify.minLevel, + log: (line) => process.stderr.write(`${line}\n`), + }, + ); } if (embedder) { @@ -255,6 +270,11 @@ export async function start(): Promise { shutdown().finally(() => process.exit(0)); }, }); + // Only hold the idle-sleep assertion while there's actual background work + // to protect. If the embedder failed to load we skip caffeinate entirely + // — the worker is then effectively just a viewer + state file writer and + // doesn't need to keep the laptop awake. + caffeinate = startCaffeinate((line) => process.stderr.write(`${line}\n`)); } else { // Still write a minimal state file so `colony status` has something to show. writeFileSync( diff --git a/apps/worker/test/caffeinate.test.ts b/apps/worker/test/caffeinate.test.ts new file mode 100644 index 0000000..cd1b7b9 --- /dev/null +++ b/apps/worker/test/caffeinate.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { startCaffeinate } from '../src/caffeinate.js'; + +describe('caffeinate', () => { + it('returns a no-op handle on non-darwin platforms', () => { + if (process.platform === 'darwin') return; // covered by the spawn path + const log: string[] = []; + const handle = startCaffeinate((line) => log.push(line)); + expect(typeof handle.stop).toBe('function'); + expect(log).toEqual([]); + // Idempotent stop — must not throw if called twice. + handle.stop(); + handle.stop(); + }); +}); diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 624afd6..71cc0a6 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -6,6 +6,12 @@ export type CompressionIntensity = z.infer; export const EmbeddingProvider = z.enum(['local', 'ollama', 'openai', 'none']); export type EmbeddingProvider = z.infer; +export const NotifyProvider = z.enum(['desktop', 'none']); +export type NotifyProvider = z.infer; + +export const NotifyLevel = z.enum(['info', 'warn', 'error']); +export type NotifyLevel = z.infer; + export const SettingsSchema = z .object({ dataDir: z @@ -110,6 +116,19 @@ export const SettingsSchema = z .record(z.string(), z.boolean()) .default({}) .describe('Installed IDE integrations (set by `colony install`).'), + notify: z + .object({ + provider: NotifyProvider.default('none').describe( + 'Desktop notification provider. desktop = native (osascript on macOS, notify-send on Linux); none = silent. Default off so colony is unobtrusive on a fresh install.', + ), + minLevel: NotifyLevel.default('warn').describe( + 'Drop messages below this level. error surfaces only failures; warn includes degraded states like a missing embedder.', + ), + }) + .default({ provider: 'none', minLevel: 'warn' }) + .describe( + 'Background notifications. The worker uses this to surface conditions you would otherwise only see by reading stderr or running `colony status`.', + ), foraging: z .object({ enabled: z diff --git a/packages/core/src/task-thread.ts b/packages/core/src/task-thread.ts index d66f57b..e5709aa 100644 --- a/packages/core/src/task-thread.ts +++ b/packages/core/src/task-thread.ts @@ -1,4 +1,11 @@ -import type { ObservationRow, TaskClaimRow, TaskParticipantRow, TaskRow } from '@colony/storage'; +import type { + LinkedTask, + ObservationRow, + TaskClaimRow, + TaskLinkRow, + TaskParticipantRow, + TaskRow, +} from '@colony/storage'; import type { MemoryStore } from './memory-store.js'; import { type AgentProfile, @@ -324,6 +331,34 @@ export class TaskThread { return this.store.storage.listClaims(this.task_id); } + /** + * Tasks linked to this one, in either direction. Cross-task links let an + * agent on a "frontend" task see decisions/blockers from a paired + * "backend" task without copy-paste — the inbox / preface scans + * linkedTimeline() the same way it scans this task's own timeline. + */ + linkedTasks(): LinkedTask[] { + return this.store.storage.linkedTasks(this.task_id); + } + + /** + * Symmetric link operation. Either side can call; the storage layer + * normalises (low_id, high_id) so re-links are idempotent. Note is + * optional and renders next to the link in attention prefaces. + */ + link(other_task_id: number, created_by: string, note?: string): TaskLinkRow { + return this.store.storage.linkTasks({ + task_id_a: this.task_id, + task_id_b: other_task_id, + created_by, + ...(note !== undefined ? { note } : {}), + }); + } + + unlink(other_task_id: number): boolean { + return this.store.storage.unlinkTasks(this.task_id, other_task_id); + } + timeline(limit = 50): ObservationRow[] { return this.store.storage.taskTimeline(this.task_id, limit); } diff --git a/packages/process/src/index.ts b/packages/process/src/index.ts index dcb9451..955da0d 100644 --- a/packages/process/src/index.ts +++ b/packages/process/src/index.ts @@ -2,3 +2,5 @@ export { isMainEntry } from './is-main.js'; export { isAlive } from './alive.js'; export { readPidFile, writePidFile, removePidFile } from './pidfile.js'; export { spawnNodeScript } from './spawn.js'; +export { notify, buildNotifyArgv } from './notify.js'; +export type { NotifyLevel, NotifyMessage, NotifyOptions } from './notify.js'; diff --git a/packages/process/src/notify.ts b/packages/process/src/notify.ts new file mode 100644 index 0000000..9f33764 --- /dev/null +++ b/packages/process/src/notify.ts @@ -0,0 +1,89 @@ +import { spawn } from 'node:child_process'; + +export type NotifyLevel = 'info' | 'warn' | 'error'; + +export interface NotifyMessage { + level: NotifyLevel; + title: string; + body: string; +} + +export interface NotifyOptions { + /** 'desktop' fans out to the platform-native facility (osascript / + * notify-send). 'none' is a no-op. Anything else is treated as + * 'none' for forward compatibility. */ + provider: 'desktop' | 'none'; + /** Drop messages below this level. Defaults to 'warn'. */ + minLevel?: NotifyLevel; + /** Optional logger for diagnostic output (e.g. spawn failures). */ + log?: (line: string) => void; +} + +const LEVEL_ORDER: Record = { info: 0, warn: 1, error: 2 }; + +/** + * Fire-and-forget desktop notification. Returns immediately — never awaits the + * spawned helper, never throws, and never blocks a hot path. Designed so + * callers can sprinkle `notify()` next to a structured-stderr log line + * without thinking about the cost. + * + * Platform mapping: + * - darwin: `osascript -e 'display notification "body" with title "title"'` + * - linux: `notify-send -u <body>` + * - other: no-op (no portable system tray worth depending on) + * + * Spawn errors are swallowed but reported via `opts.log` so a missing + * `notify-send` on a headless box doesn't degrade into a crash loop. + */ +export function notify(msg: NotifyMessage, opts: NotifyOptions): void { + if (opts.provider !== 'desktop') return; + const minLevel = opts.minLevel ?? 'warn'; + if (LEVEL_ORDER[msg.level] < LEVEL_ORDER[minLevel]) return; + + const argv = buildNotifyArgv(msg); + if (!argv) return; + const cmd = argv[0]; + if (cmd === undefined) return; + + try { + const child = spawn(cmd, argv.slice(1), { stdio: 'ignore', detached: false }); + child.on('error', (err: Error) => { + opts.log?.(`[colony notify] spawn error: ${err.message}`); + }); + } catch (err) { + opts.log?.(`[colony notify] failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export function buildNotifyArgv(msg: NotifyMessage): string[] | null { + const title = sanitize(msg.title); + const body = sanitize(msg.body); + if (process.platform === 'darwin') { + const script = `display notification "${body}" with title "${title}"`; + return ['osascript', '-e', script]; + } + if (process.platform === 'linux') { + const urgency = msg.level === 'error' ? 'critical' : msg.level === 'warn' ? 'normal' : 'low'; + return ['notify-send', '-u', urgency, title, body]; + } + return null; +} + +/** + * Drop control chars and shell-sensitive quoting before the title/body hit + * argv. Notifications are diagnostic, not data — we'd rather lose a stray + * quote than risk a malformed osascript call. + */ +function sanitize(s: string): string { + let out = ''; + for (const ch of s) { + const code = ch.charCodeAt(0); + if (code < 32 || code === 127) { + out += ' '; + continue; + } + if (ch === '"' || ch === '\\') continue; + out += ch; + } + return out; +} diff --git a/packages/process/test/notify.test.ts b/packages/process/test/notify.test.ts new file mode 100644 index 0000000..4be034c --- /dev/null +++ b/packages/process/test/notify.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { buildNotifyArgv, notify } from '../src/index.js'; + +describe('notify', () => { + it('is a no-op when provider is none', () => { + const log: string[] = []; + notify( + { level: 'error', title: 'x', body: 'y' }, + { provider: 'none', log: (l) => log.push(l) }, + ); + expect(log).toEqual([]); + }); + + it('builds argv on supported platforms and null elsewhere', () => { + const argv = buildNotifyArgv({ level: 'info', title: 't', body: 'b' }); + if (process.platform === 'darwin') expect(argv?.[0]).toBe('osascript'); + else if (process.platform === 'linux') expect(argv?.[0]).toBe('notify-send'); + else expect(argv).toBeNull(); + }); + + it('strips embedded quotes, backslashes, and control chars from title/body', () => { + const argv = buildNotifyArgv({ + level: 'error', + title: 'col"ony', + body: 'a\\b\nc', + }); + if (!argv) return; // unsupported platform + const joined = argv.join(' '); + expect(joined.includes('"')).toBe(false); + expect(joined.includes('\\')).toBe(false); + expect(joined.includes('\n')).toBe(false); + }); + + it('maps levels to notify-send urgency on linux', () => { + if (process.platform !== 'linux') return; + const error = buildNotifyArgv({ level: 'error', title: 't', body: 'b' }); + const warn = buildNotifyArgv({ level: 'warn', title: 't', body: 'b' }); + const info = buildNotifyArgv({ level: 'info', title: 't', body: 'b' }); + expect(error?.[2]).toBe('critical'); + expect(warn?.[2]).toBe('normal'); + expect(info?.[2]).toBe('low'); + }); +}); diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 7c34520..ca40fbf 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -10,6 +10,9 @@ export type { NewTask, TaskParticipantRow, TaskClaimRow, + TaskLinkRow, + NewTaskLink, + LinkedTask, PheromoneRow, NewPheromone, ProposalRow, diff --git a/packages/storage/src/schema.ts b/packages/storage/src/schema.ts index cfd08ac..219fea0 100644 --- a/packages/storage/src/schema.ts +++ b/packages/storage/src/schema.ts @@ -164,6 +164,24 @@ CREATE TABLE IF NOT EXISTS agent_profiles ( updated_at INTEGER NOT NULL ); +-- Cross-task links: bidirectional edges between two tasks so an agent on +-- task A can see B's timeline events in their own preface (e.g. frontend +-- lane needs the backend lane's decisions). Stored once per unordered pair +-- with low_id < high_id so (A,B) and (B,A) collapse into one row, and +-- listing is symmetric: linked_to(A) returns B regardless of which side +-- created the link. Soft-delete is unnecessary — unlinking just drops the +-- row, the underlying tasks are unaffected. +CREATE TABLE IF NOT EXISTS task_links ( + low_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + high_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, + note TEXT, + PRIMARY KEY (low_id, high_id), + CHECK (low_id < high_id) +); +CREATE INDEX IF NOT EXISTS idx_task_links_high ON task_links(high_id); + -- Foraging food sources: one row per indexed <repo_root>/examples/<name>. -- content_hash is sha256 over (manifest + filetree + key file sizes); the -- scanner uses it to skip work on repeat SessionStarts. observation_count @@ -181,7 +199,7 @@ CREATE TABLE IF NOT EXISTS examples ( ); CREATE INDEX IF NOT EXISTS idx_examples_repo ON examples(repo_root); -INSERT OR IGNORE INTO schema_version(version) VALUES (7); +INSERT OR IGNORE INTO schema_version(version) VALUES (8); `; /** diff --git a/packages/storage/src/storage.ts b/packages/storage/src/storage.ts index 995096f..582a8d6 100644 --- a/packages/storage/src/storage.ts +++ b/packages/storage/src/storage.ts @@ -5,6 +5,7 @@ import { COLUMN_MIGRATIONS, POST_MIGRATION_SQL, SCHEMA_SQL } from './schema.js'; import type { AgentProfileRow, ExampleRow, + LinkedTask, NewAgentProfile, NewExample, NewObservation, @@ -13,6 +14,7 @@ import type { NewReinforcement, NewSummary, NewTask, + NewTaskLink, ObservationRow, PheromoneRow, ProposalRow, @@ -22,6 +24,7 @@ import type { SessionRow, SummaryRow, TaskClaimRow, + TaskLinkRow, TaskParticipantRow, TaskRow, } from './types.js'; @@ -528,6 +531,65 @@ export class Storage { .all(task_id, since_ts, limit) as TaskClaimRow[]; } + // --- task links (cross-task edges) --- + + /** + * Link two tasks bidirectionally. Stored once with (low_id, high_id) so + * order doesn't matter to callers — `linkTasks(A, B)` and `linkTasks(B, A)` + * collapse onto the same row. Idempotent: re-linking an existing pair is a + * no-op (the original `created_by` / `created_at` / `note` are preserved). + * Self-links are rejected — a task linking to itself is meaningless and + * indicates a caller bug. + */ + linkTasks(p: NewTaskLink): TaskLinkRow { + if (p.task_id_a === p.task_id_b) { + throw new Error('cannot link a task to itself'); + } + const [low_id, high_id] = + p.task_id_a < p.task_id_b ? [p.task_id_a, p.task_id_b] : [p.task_id_b, p.task_id_a]; + const now = Date.now(); + this.db + .prepare( + `INSERT OR IGNORE INTO task_links(low_id, high_id, created_by, created_at, note) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(low_id, high_id, p.created_by, now, p.note ?? null); + return this.db + .prepare('SELECT * FROM task_links WHERE low_id = ? AND high_id = ?') + .get(low_id, high_id) as TaskLinkRow; + } + + unlinkTasks(task_id_a: number, task_id_b: number): boolean { + if (task_id_a === task_id_b) return false; + const [low_id, high_id] = + task_id_a < task_id_b ? [task_id_a, task_id_b] : [task_id_b, task_id_a]; + const info = this.db + .prepare('DELETE FROM task_links WHERE low_id = ? AND high_id = ?') + .run(low_id, high_id); + return info.changes > 0; + } + + /** + * Tasks linked to `task_id`, regardless of which side originally created + * the link. Returns the *other* task on each edge — never `task_id` itself + * — and exposes the link's metadata (created_by, created_at, note) so a + * preface can render "linked to #42 by claude — 'frontend ↔ backend lane'". + */ + linkedTasks(task_id: number): LinkedTask[] { + return this.db + .prepare( + `SELECT + CASE WHEN low_id = ? THEN high_id ELSE low_id END AS task_id, + created_at AS linked_at, + created_by AS linked_by, + note + FROM task_links + WHERE low_id = ? OR high_id = ? + ORDER BY created_at DESC`, + ) + .all(task_id, task_id, task_id) as LinkedTask[]; + } + // --- pheromones (ambient decaying activity trails) --- /** diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index 676768c..e0310a9 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -75,6 +75,28 @@ export interface TaskClaimRow { claimed_at: number; } +export interface TaskLinkRow { + low_id: number; + high_id: number; + created_by: string; + created_at: number; + note: string | null; +} + +export interface NewTaskLink { + task_id_a: number; + task_id_b: number; + created_by: string; + note?: string; +} + +export interface LinkedTask { + task_id: number; + linked_at: number; + linked_by: string; + note: string | null; +} + export interface PheromoneRow { task_id: number; file_path: string; diff --git a/packages/storage/test/tasks.test.ts b/packages/storage/test/tasks.test.ts index af646fe..3f2570a 100644 --- a/packages/storage/test/tasks.test.ts +++ b/packages/storage/test/tasks.test.ts @@ -175,6 +175,110 @@ describe('tasks', () => { expect(recent.map((c) => c.file_path)).toEqual(['fresh.ts']); }); + it('linkTasks normalises ordering and is idempotent', () => { + seedSessions('s-a'); + const taskA = storage.findOrCreateTask({ + title: 'A', + repo_root: '/r', + branch: 'a', + created_by: 's-a', + }); + const taskB = storage.findOrCreateTask({ + title: 'B', + repo_root: '/r', + branch: 'b', + created_by: 's-a', + }); + + const first = storage.linkTasks({ + task_id_a: taskB.id, + task_id_b: taskA.id, + created_by: 's-a', + note: 'frontend ↔ backend', + }); + // Stored canonically with low_id < high_id regardless of caller order. + expect(first.low_id).toBeLessThan(first.high_id); + expect(first.low_id).toBe(Math.min(taskA.id, taskB.id)); + expect(first.high_id).toBe(Math.max(taskA.id, taskB.id)); + + // Re-linking the same pair (in either order) preserves the original + // metadata — no clobber of created_by / note. + const second = storage.linkTasks({ + task_id_a: taskA.id, + task_id_b: taskB.id, + created_by: 's-other', + note: 'overwritten?', + }); + expect(second.created_by).toBe('s-a'); + expect(second.note).toBe('frontend ↔ backend'); + }); + + it('linkedTasks returns the other side regardless of insertion order', () => { + seedSessions('s-a'); + const a = storage.findOrCreateTask({ + title: 'A', + repo_root: '/r', + branch: 'a', + created_by: 's-a', + }); + const b = storage.findOrCreateTask({ + title: 'B', + repo_root: '/r', + branch: 'b', + created_by: 's-a', + }); + const c = storage.findOrCreateTask({ + title: 'C', + repo_root: '/r', + branch: 'c', + created_by: 's-a', + }); + + storage.linkTasks({ task_id_a: a.id, task_id_b: b.id, created_by: 's-a' }); + storage.linkTasks({ task_id_a: c.id, task_id_b: a.id, created_by: 's-a', note: 'paired' }); + + const fromA = storage.linkedTasks(a.id); + expect(fromA.map((l) => l.task_id).sort()).toEqual([b.id, c.id].sort()); + const cLink = fromA.find((l) => l.task_id === c.id); + expect(cLink?.note).toBe('paired'); + + // The link is symmetric — listing from B sees A. + expect(storage.linkedTasks(b.id).map((l) => l.task_id)).toEqual([a.id]); + }); + + it('unlinkTasks reports whether a row was removed', () => { + seedSessions('s-a'); + const a = storage.findOrCreateTask({ + title: 'A', + repo_root: '/r', + branch: 'a', + created_by: 's-a', + }); + const b = storage.findOrCreateTask({ + title: 'B', + repo_root: '/r', + branch: 'b', + created_by: 's-a', + }); + storage.linkTasks({ task_id_a: a.id, task_id_b: b.id, created_by: 's-a' }); + expect(storage.unlinkTasks(b.id, a.id)).toBe(true); + expect(storage.unlinkTasks(b.id, a.id)).toBe(false); + expect(storage.linkedTasks(a.id)).toEqual([]); + }); + + it('linkTasks rejects self-links', () => { + seedSessions('s-a'); + const a = storage.findOrCreateTask({ + title: 'A', + repo_root: '/r', + branch: 'a', + created_by: 's-a', + }); + expect(() => + storage.linkTasks({ task_id_a: a.id, task_id_b: a.id, created_by: 's-a' }), + ).toThrow(); + }); + it('reopening an existing database preserves the task schema', () => { seedSessions('s-a'); storage.findOrCreateTask({