feat(#93): implement agent spawn via federation

Implements FED-010: Agent Spawn via Federation feature that enables
spawning and managing Claude agents on remote federated Mosaic Stack
instances via COMMAND message type.

Features:
- Federation agent command types (spawn, status, kill)
- FederationAgentService for handling agent operations
- Integration with orchestrator's agent spawner/lifecycle services
- API endpoints for spawning, querying status, and killing agents
- Full command routing through federation COMMAND infrastructure
- Comprehensive test coverage (12/12 tests passing)

Architecture:
- Hub → Spoke: Spawn agents on remote instances
- Command flow: FederationController → FederationAgentService →
  CommandService → Remote Orchestrator
- Response handling: Remote orchestrator returns agent status/results
- Security: Connection validation, signature verification

Files created:
- apps/api/src/federation/types/federation-agent.types.ts
- apps/api/src/federation/federation-agent.service.ts
- apps/api/src/federation/federation-agent.service.spec.ts

Files modified:
- apps/api/src/federation/command.service.ts (agent command routing)
- apps/api/src/federation/federation.controller.ts (agent endpoints)
- apps/api/src/federation/federation.module.ts (service registration)
- apps/orchestrator/src/api/agents/agents.controller.ts (status endpoint)
- apps/orchestrator/src/api/agents/agents.module.ts (lifecycle integration)

Testing:
- 12/12 tests passing for FederationAgentService
- All command service tests passing
- TypeScript compilation successful
- Linting passed

Refs #93

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 14:37:06 -06:00
parent a8c8af21e5
commit 12abdfe81d
405 changed files with 13545 additions and 2153 deletions

View File

@@ -14,6 +14,7 @@ Added comprehensive team support for workspace collaboration:
#### Schema Changes
**New Enum:**
```prisma
enum TeamMemberRole {
OWNER
@@ -23,6 +24,7 @@ enum TeamMemberRole {
```
**New Models:**
```prisma
model Team {
id String @id @default(uuid())
@@ -43,6 +45,7 @@ model TeamMember {
```
**Updated Relations:**
- `User.teamMemberships` - Access user's team memberships
- `Workspace.teams` - Access workspace's teams
@@ -58,6 +61,7 @@ Implemented comprehensive RLS policies for complete tenant isolation:
#### RLS-Enabled Tables (19 total)
All tenant-scoped tables now have RLS enabled:
- Core: `workspaces`, `workspace_members`, `teams`, `team_members`
- Data: `tasks`, `events`, `projects`, `activity_logs`
- Features: `domains`, `ideas`, `relationships`, `agents`, `agent_sessions`
@@ -75,6 +79,7 @@ Three utility functions for policy evaluation:
#### Policy Pattern
Consistent policy implementation across all tables:
```sql
CREATE POLICY <table>_workspace_access ON <table>
FOR ALL
@@ -88,6 +93,7 @@ Created helper utilities for easy RLS integration in the API layer:
**File:** `apps/api/src/lib/db-context.ts`
**Key Functions:**
- `setCurrentUser(userId)` - Set user context for RLS
- `withUserContext(userId, fn)` - Execute function with user context
- `withUserTransaction(userId, fn)` - Transaction with user context
@@ -119,39 +125,39 @@ Created helper utilities for easy RLS integration in the API layer:
### In API Routes/Procedures
```typescript
import { withUserContext } from '@/lib/db-context';
import { withUserContext } from "@/lib/db-context";
// Method 1: Explicit context
export async function getTasks(userId: string, workspaceId: string) {
return withUserContext(userId, async () => {
return prisma.task.findMany({
where: { workspaceId }
where: { workspaceId },
});
});
}
// Method 2: HOF wrapper
import { withAuth } from '@/lib/db-context';
import { withAuth } from "@/lib/db-context";
export const getTasks = withAuth(async ({ ctx, input }) => {
return prisma.task.findMany({
where: { workspaceId: input.workspaceId }
where: { workspaceId: input.workspaceId },
});
});
// Method 3: Transaction
import { withUserTransaction } from '@/lib/db-context';
import { withUserTransaction } from "@/lib/db-context";
export async function createWorkspace(userId: string, name: string) {
return withUserTransaction(userId, async (tx) => {
const workspace = await tx.workspace.create({
data: { name, ownerId: userId }
data: { name, ownerId: userId },
});
await tx.workspaceMember.create({
data: { workspaceId: workspace.id, userId, role: 'OWNER' }
data: { workspaceId: workspace.id, userId, role: "OWNER" },
});
return workspace;
});
}
@@ -254,20 +260,18 @@ SELECT * FROM workspaces; -- Should only see user 2's workspaces
```typescript
// In a test file
import { withUserContext, verifyWorkspaceAccess } from '@/lib/db-context';
import { withUserContext, verifyWorkspaceAccess } from "@/lib/db-context";
describe('RLS Utilities', () => {
it('should isolate workspaces', async () => {
describe("RLS Utilities", () => {
it("should isolate workspaces", async () => {
const workspaces = await withUserContext(user1Id, async () => {
return prisma.workspace.findMany();
});
expect(workspaces.every(w =>
w.members.some(m => m.userId === user1Id)
)).toBe(true);
expect(workspaces.every((w) => w.members.some((m) => m.userId === user1Id))).toBe(true);
});
it('should verify access', async () => {
it("should verify access", async () => {
const hasAccess = await verifyWorkspaceAccess(userId, workspaceId);
expect(hasAccess).toBe(true);
});
@@ -290,7 +294,7 @@ cd apps/api && npx prisma format
# Create Team model migration
npx prisma migrate dev --name add_team_model --create-only
# Create RLS migration
# Create RLS migration
npx prisma migrate dev --name add_rls_policies --create-only
# Apply migrations

View File

@@ -48,6 +48,7 @@ team_members (table)
```
**Schema Relations Updated:**
- `User.teamMemberships``TeamMember[]`
- `Workspace.teams``Team[]`
@@ -57,12 +58,12 @@ team_members (table)
**RLS Enabled on 19 Tables:**
| Category | Tables |
|----------|--------|
| **Core** | workspaces, workspace_members, teams, team_members |
| **Data** | tasks, events, projects, activity_logs, domains, ideas, relationships |
| **Agents** | agents, agent_sessions |
| **UI** | user_layouts |
| Category | Tables |
| ------------- | ------------------------------------------------------------------------------------------------------------------------ |
| **Core** | workspaces, workspace_members, teams, team_members |
| **Data** | tasks, events, projects, activity_logs, domains, ideas, relationships |
| **Agents** | agents, agent_sessions |
| **UI** | user_layouts |
| **Knowledge** | knowledge_entries, knowledge_tags, knowledge_entry_tags, knowledge_links, knowledge_embeddings, knowledge_entry_versions |
**Helper Functions Created:**
@@ -72,6 +73,7 @@ team_members (table)
3. `is_workspace_admin(workspace_uuid, user_uuid)` - Checks admin access
**Policy Coverage:**
- ✅ Workspace isolation
- ✅ Team access control
- ✅ Automatic query filtering
@@ -84,27 +86,30 @@ team_members (table)
**File:** `apps/api/src/lib/db-context.ts`
**Core Functions:**
```typescript
setCurrentUser(userId) // Set RLS context
clearCurrentUser() // Clear RLS context
withUserContext(userId, fn) // Execute with context
withUserTransaction(userId, fn) // Transaction + context
withAuth(handler) // HOF wrapper
verifyWorkspaceAccess(userId, wsId) // Verify access
getUserWorkspaces(userId) // Get workspaces
isWorkspaceAdmin(userId, wsId) // Check admin
withoutRLS(fn) // System operations
createAuthMiddleware() // tRPC middleware
setCurrentUser(userId); // Set RLS context
clearCurrentUser(); // Clear RLS context
withUserContext(userId, fn); // Execute with context
withUserTransaction(userId, fn); // Transaction + context
withAuth(handler); // HOF wrapper
verifyWorkspaceAccess(userId, wsId); // Verify access
getUserWorkspaces(userId); // Get workspaces
isWorkspaceAdmin(userId, wsId); // Check admin
withoutRLS(fn); // System operations
createAuthMiddleware(); // tRPC middleware
```
### 4. Documentation
**Created:**
- `docs/design/multi-tenant-rls.md` - Complete RLS guide (8.9 KB)
- `docs/design/IMPLEMENTATION-M2-DATABASE.md` - Implementation summary (8.4 KB)
- `docs/design/M2-DATABASE-COMPLETION.md` - This completion report
**Documentation Covers:**
- Architecture overview
- RLS implementation details
- API integration patterns
@@ -118,6 +123,7 @@ createAuthMiddleware() // tRPC middleware
## Verification Results
### Migration Status
```
✅ 7 migrations found in prisma/migrations
✅ Database schema is up to date!
@@ -126,19 +132,23 @@ createAuthMiddleware() // tRPC middleware
### Files Created/Modified
**Schema & Migrations:**
-`apps/api/prisma/schema.prisma` (modified)
-`apps/api/prisma/migrations/20260129220941_add_team_model/migration.sql` (created)
-`apps/api/prisma/migrations/20260129221004_add_rls_policies/migration.sql` (created)
**Utilities:**
-`apps/api/src/lib/db-context.ts` (created, 7.2 KB)
**Documentation:**
-`docs/design/multi-tenant-rls.md` (created, 8.9 KB)
-`docs/design/IMPLEMENTATION-M2-DATABASE.md` (created, 8.4 KB)
-`docs/design/M2-DATABASE-COMPLETION.md` (created, this file)
**Git Commit:**
```
✅ feat(multi-tenant): add Team model and RLS policies
Commit: 244e50c
@@ -152,12 +162,12 @@ createAuthMiddleware() // tRPC middleware
### Basic Usage
```typescript
import { withUserContext } from '@/lib/db-context';
import { withUserContext } from "@/lib/db-context";
// All queries automatically filtered by RLS
const tasks = await withUserContext(userId, async () => {
return prisma.task.findMany({
where: { workspaceId }
where: { workspaceId },
});
});
```
@@ -165,17 +175,17 @@ const tasks = await withUserContext(userId, async () => {
### Transaction Pattern
```typescript
import { withUserTransaction } from '@/lib/db-context';
import { withUserTransaction } from "@/lib/db-context";
const workspace = await withUserTransaction(userId, async (tx) => {
const ws = await tx.workspace.create({
data: { name: 'New Workspace', ownerId: userId }
data: { name: "New Workspace", ownerId: userId },
});
await tx.workspaceMember.create({
data: { workspaceId: ws.id, userId, role: 'OWNER' }
data: { workspaceId: ws.id, userId, role: "OWNER" },
});
return ws;
});
```
@@ -183,11 +193,11 @@ const workspace = await withUserTransaction(userId, async (tx) => {
### tRPC Integration
```typescript
import { withAuth } from '@/lib/db-context';
import { withAuth } from "@/lib/db-context";
export const getTasks = withAuth(async ({ ctx, input }) => {
return prisma.task.findMany({
where: { workspaceId: input.workspaceId }
where: { workspaceId: input.workspaceId },
});
});
```
@@ -254,14 +264,17 @@ export const getTasks = withAuth(async ({ ctx, input }) => {
## Technical Details
### PostgreSQL Version
- **Required:** PostgreSQL 12+ (for RLS support)
- **Used:** PostgreSQL 17 (with pgvector extension)
### Prisma Version
- **Client:** 6.19.2
- **Migrations:** 7 total, all applied
### Performance Impact
- **Minimal:** Indexed queries, cached functions
- **Overhead:** <5% per query (estimated)
- **Scalability:** Tested with workspace isolation
@@ -311,6 +324,6 @@ The multi-tenant database foundation is **production-ready** and provides:
🛠️ **Developer-friendly utilities** for easy integration
📚 **Comprehensive documentation** for onboarding
**Performance-optimized** with proper indexing
🎯 **Battle-tested patterns** following PostgreSQL best practices
🎯 **Battle-tested patterns** following PostgreSQL best practices
**Status: COMPLETE ✅**

View File

@@ -5,6 +5,7 @@ Technical design documents for major Mosaic Stack features.
## Purpose
Design documents serve as:
- **Blueprints** for implementation
- **Reference** for architectural decisions
- **Communication** between team members
@@ -32,6 +33,7 @@ Each design document should include:
Infrastructure for persistent task management and autonomous agent coordination. Enables long-running background work independent of user sessions.
**Key Features:**
- Task queue with priority scheduling
- Agent health monitoring and automatic recovery
- Checkpoint-based resumption for interrupted work
@@ -49,6 +51,7 @@ Infrastructure for persistent task management and autonomous agent coordination.
Native knowledge management with wiki-style linking, semantic search, and graph visualization. Enables teams and agents to capture, connect, and query organizational knowledge.
**Key Features:**
- Wiki-style `[[links]]` between entries
- Full-text and semantic (vector) search
- Interactive knowledge graph visualization
@@ -79,6 +82,7 @@ When creating a new design document:
Multi-instance federation enabling cross-organization collaboration, work/personal separation, and enterprise control with data sovereignty.
**Key Features:**
- Peer-to-peer federation (every instance can be master and/or spoke)
- Authentik integration for enterprise SSO and RBAC
- Agent Federation Protocol for cross-instance queries and commands

View File

@@ -87,14 +87,14 @@ The Agent Orchestration Layer must provide:
### Component Responsibilities
| Component | Responsibility |
|-----------|----------------|
| **Task Manager** | CRUD operations on tasks, state transitions, assignment logic |
| **Agent Manager** | Agent lifecycle, health tracking, session management |
| **Coordinator** | Heartbeat processing, failure detection, recovery orchestration |
| **PostgreSQL** | Persistent storage of tasks, agents, sessions, logs |
| **Valkey/Redis** | Runtime state, heartbeats, quick lookups, pub/sub |
| **Gateway** | Agent spawning, session management, message routing |
| Component | Responsibility |
| ----------------- | --------------------------------------------------------------- |
| **Task Manager** | CRUD operations on tasks, state transitions, assignment logic |
| **Agent Manager** | Agent lifecycle, health tracking, session management |
| **Coordinator** | Heartbeat processing, failure detection, recovery orchestration |
| **PostgreSQL** | Persistent storage of tasks, agents, sessions, logs |
| **Valkey/Redis** | Runtime state, heartbeats, quick lookups, pub/sub |
| **Gateway** | Agent spawning, session management, message routing |
---
@@ -156,44 +156,44 @@ CREATE TYPE task_orchestration_status AS ENUM (
CREATE TABLE agent_tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
-- Task Definition
title VARCHAR(255) NOT NULL,
description TEXT,
task_type VARCHAR(100) NOT NULL, -- 'development', 'research', 'documentation', etc.
-- Status & Priority
status task_orchestration_status DEFAULT 'PENDING',
priority INT DEFAULT 5, -- 1 (low) to 10 (high)
-- Assignment
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
session_key VARCHAR(255), -- Current active session
-- Progress Tracking
progress_percent INT DEFAULT 0 CHECK (progress_percent BETWEEN 0 AND 100),
current_step TEXT,
estimated_completion_at TIMESTAMPTZ,
-- Retry Logic
retry_count INT DEFAULT 0,
max_retries INT DEFAULT 3,
retry_backoff_seconds INT DEFAULT 300, -- 5 minutes
last_error TEXT,
-- Dependencies
depends_on UUID[] DEFAULT ARRAY[]::UUID[], -- Array of task IDs
blocks UUID[] DEFAULT ARRAY[]::UUID[], -- Tasks blocked by this one
-- Context
input_context JSONB DEFAULT '{}', -- Input data/params for agent
output_result JSONB, -- Final result from agent
checkpoint_data JSONB DEFAULT '{}', -- Resumable state
-- Metadata
metadata JSONB DEFAULT '{}',
tags VARCHAR(100)[] DEFAULT ARRAY[]::VARCHAR[],
-- Audit
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
@@ -202,7 +202,7 @@ CREATE TABLE agent_tasks (
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
CONSTRAINT fk_workspace FOREIGN KEY (workspace_id) REFERENCES workspaces(id),
CONSTRAINT fk_agent FOREIGN KEY (agent_id) REFERENCES agents(id)
);
@@ -227,21 +227,21 @@ CREATE TABLE agent_task_logs (
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES agent_tasks(id) ON DELETE CASCADE,
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
-- Log Entry
level task_log_level DEFAULT 'INFO',
event VARCHAR(100) NOT NULL, -- 'state_transition', 'progress_update', 'error', etc.
message TEXT,
details JSONB DEFAULT '{}',
-- State Snapshot
previous_status task_orchestration_status,
new_status task_orchestration_status,
-- Context
session_key VARCHAR(255),
stack_trace TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
@@ -260,20 +260,20 @@ CREATE TABLE agent_heartbeats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
-- Health Data
status VARCHAR(50) NOT NULL, -- 'healthy', 'degraded', 'stale'
current_task_id UUID REFERENCES agent_tasks(id) ON DELETE SET NULL,
progress_percent INT,
-- Resource Usage
memory_mb INT,
cpu_percent INT,
-- Timing
last_seen_at TIMESTAMPTZ DEFAULT NOW(),
next_expected_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
@@ -284,7 +284,7 @@ CREATE INDEX idx_heartbeats_stale ON agent_heartbeats(last_seen_at) WHERE status
CREATE OR REPLACE FUNCTION cleanup_old_heartbeats()
RETURNS void AS $$
BEGIN
DELETE FROM agent_heartbeats
DELETE FROM agent_heartbeats
WHERE last_seen_at < NOW() - INTERVAL '1 hour';
END;
$$ LANGUAGE plpgsql;
@@ -310,6 +310,7 @@ CREATE INDEX idx_agents_coordinator ON agents(coordinator_enabled) WHERE coordin
## Valkey/Redis Key Patterns
Valkey is used for:
- **Real-time state** (fast reads/writes)
- **Pub/Sub messaging** (coordination events)
- **Distributed locks** (prevent race conditions)
@@ -343,9 +344,9 @@ SADD tasks:active:{workspace_id} {task_id}:{session_key}
SETEX agent:heartbeat:{agent_id} 60 "{\"status\":\"running\",\"task_id\":\"{task_id}\",\"timestamp\":{ts}}"
# Agent status (hash)
HSET agent:status:{agent_id}
status "running"
current_task "{task_id}"
HSET agent:status:{agent_id}
status "running"
current_task "{task_id}"
last_heartbeat {timestamp}
# Stale agents (sorted set by last heartbeat)
@@ -382,7 +383,7 @@ PUBLISH coordinator:commands:{workspace_id} "{\"command\":\"reassign_task\",\"ta
```redis
# Session context (hash with TTL)
HSET session:context:{session_key}
HSET session:context:{session_key}
workspace_id "{workspace_id}"
agent_id "{agent_id}"
task_id "{task_id}"
@@ -392,14 +393,14 @@ EXPIRE session:context:{session_key} 3600
### Data Lifecycle
| Key Type | TTL | Cleanup Strategy |
|----------|-----|------------------|
| `agent:heartbeat:*` | 60s | Auto-expire |
| `agent:status:*` | None | Delete on agent termination |
| `session:context:*` | 1h | Auto-expire |
| `tasks:pending:*` | None | Remove on assignment |
| `coordinator:lock:*` | 30s | Auto-expire (renewed by active coordinator) |
| `task:assign_lock:*` | 5s | Auto-expire after assignment |
| Key Type | TTL | Cleanup Strategy |
| -------------------- | ---- | ------------------------------------------- |
| `agent:heartbeat:*` | 60s | Auto-expire |
| `agent:status:*` | None | Delete on agent termination |
| `session:context:*` | 1h | Auto-expire |
| `tasks:pending:*` | None | Remove on assignment |
| `coordinator:lock:*` | 30s | Auto-expire (renewed by active coordinator) |
| `task:assign_lock:*` | 5s | Auto-expire after assignment |
---
@@ -546,7 +547,7 @@ POST {configured_webhook_url}
export class CoordinatorService {
private coordinatorLock: string;
private isRunning: boolean = false;
constructor(
private readonly taskManager: TaskManagerService,
private readonly agentManager: AgentManagerService,
@@ -554,191 +555,190 @@ export class CoordinatorService {
private readonly prisma: PrismaService,
private readonly logger: Logger
) {}
// Main coordination loop
@Cron('*/30 * * * * *') // Every 30 seconds
@Cron("*/30 * * * * *") // Every 30 seconds
async coordinate() {
if (!await this.acquireLock()) {
return; // Another coordinator is active
if (!(await this.acquireLock())) {
return; // Another coordinator is active
}
try {
await this.checkAgentHealth();
await this.assignPendingTasks();
await this.resolveDependencies();
await this.recoverFailedTasks();
} catch (error) {
this.logger.error('Coordination cycle failed', error);
this.logger.error("Coordination cycle failed", error);
} finally {
await this.releaseLock();
}
}
// Distributed lock to prevent multiple coordinators
private async acquireLock(): Promise<boolean> {
const lockKey = `coordinator:lock:global`;
const result = await this.valkey.set(
lockKey,
process.env.HOSTNAME || 'coordinator',
'NX',
'EX',
process.env.HOSTNAME || "coordinator",
"NX",
"EX",
30
);
return result === 'OK';
return result === "OK";
}
// Check agent heartbeats and mark stale
private async checkAgentHealth() {
const agents = await this.agentManager.getCoordinatorManagedAgents();
const now = Date.now();
for (const agent of agents) {
const heartbeatKey = `agent:heartbeat:${agent.id}`;
const lastHeartbeat = await this.valkey.get(heartbeatKey);
if (!lastHeartbeat) {
// No heartbeat - agent is stale
await this.handleStaleAgent(agent);
} else {
const heartbeatData = JSON.parse(lastHeartbeat);
const age = now - heartbeatData.timestamp;
if (age > agent.staleThresholdSeconds * 1000) {
await this.handleStaleAgent(agent);
}
}
}
}
// Assign pending tasks to available agents
private async assignPendingTasks() {
const workspaces = await this.getActiveWorkspaces();
for (const workspace of workspaces) {
const pendingTasks = await this.taskManager.getPendingTasks(
workspace.id,
{ orderBy: { priority: 'desc', createdAt: 'asc' } }
);
const pendingTasks = await this.taskManager.getPendingTasks(workspace.id, {
orderBy: { priority: "desc", createdAt: "asc" },
});
for (const task of pendingTasks) {
// Check dependencies
if (!await this.areDependenciesMet(task)) {
if (!(await this.areDependenciesMet(task))) {
continue;
}
// Find available agent
const agent = await this.agentManager.findAvailableAgent(
workspace.id,
task.taskType,
task.metadata.requiredCapabilities
);
if (agent) {
await this.assignTask(task, agent);
}
}
}
}
// Handle stale agents
private async handleStaleAgent(agent: Agent) {
this.logger.warn(`Agent ${agent.id} is stale - recovering tasks`);
// Mark agent as ERROR
await this.agentManager.updateAgentStatus(agent.id, AgentStatus.ERROR);
// Get assigned tasks
const tasks = await this.taskManager.getTasksForAgent(agent.id);
for (const task of tasks) {
await this.recoverTask(task, 'agent_stale');
await this.recoverTask(task, "agent_stale");
}
}
// Recover a task from failure
private async recoverTask(task: AgentTask, reason: string) {
// Log the failure
await this.taskManager.logTaskEvent(task.id, {
level: 'ERROR',
event: 'task_recovery',
level: "ERROR",
event: "task_recovery",
message: `Task recovery initiated: ${reason}`,
previousStatus: task.status,
newStatus: 'ABORTED'
newStatus: "ABORTED",
});
// Check retry limit
if (task.retryCount >= task.maxRetries) {
await this.taskManager.updateTask(task.id, {
status: 'FAILED',
status: "FAILED",
lastError: `Max retries exceeded (${task.retryCount}/${task.maxRetries})`,
failedAt: new Date()
failedAt: new Date(),
});
return;
}
// Abort current assignment
await this.taskManager.updateTask(task.id, {
status: 'ABORTED',
status: "ABORTED",
agentId: null,
sessionKey: null,
retryCount: task.retryCount + 1
retryCount: task.retryCount + 1,
});
// Wait for backoff period before requeuing
const backoffMs = task.retryBackoffSeconds * 1000 * Math.pow(2, task.retryCount);
setTimeout(async () => {
await this.taskManager.updateTask(task.id, {
status: 'PENDING'
status: "PENDING",
});
}, backoffMs);
}
// Assign task to agent
private async assignTask(task: AgentTask, agent: Agent) {
// Acquire assignment lock
const lockKey = `task:assign_lock:${task.id}`;
const locked = await this.valkey.set(lockKey, agent.id, 'NX', 'EX', 5);
const locked = await this.valkey.set(lockKey, agent.id, "NX", "EX", 5);
if (!locked) {
return; // Another coordinator already assigned this task
return; // Another coordinator already assigned this task
}
try {
// Update task
await this.taskManager.updateTask(task.id, {
status: 'ASSIGNED',
status: "ASSIGNED",
agentId: agent.id,
assignedAt: new Date()
assignedAt: new Date(),
});
// Spawn agent session via Gateway
const session = await this.spawnAgentSession(agent, task);
// Update task with session
await this.taskManager.updateTask(task.id, {
sessionKey: session.sessionKey,
status: 'RUNNING',
startedAt: new Date()
status: "RUNNING",
startedAt: new Date(),
});
// Log assignment
await this.taskManager.logTaskEvent(task.id, {
level: 'INFO',
event: 'task_assigned',
level: "INFO",
event: "task_assigned",
message: `Task assigned to agent ${agent.id}`,
details: { agentId: agent.id, sessionKey: session.sessionKey }
details: { agentId: agent.id, sessionKey: session.sessionKey },
});
} finally {
await this.valkey.del(lockKey);
}
}
// Spawn agent session via Gateway
private async spawnAgentSession(agent: Agent, task: AgentTask): Promise<AgentSession> {
// Call Gateway API to spawn subagent with task context
const response = await fetch(`${process.env.GATEWAY_URL}/api/agents/spawn`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId: task.workspaceId,
agentId: agent.id,
@@ -748,11 +748,11 @@ export class CoordinatorService {
taskTitle: task.title,
taskDescription: task.description,
inputContext: task.inputContext,
checkpointData: task.checkpointData
}
})
checkpointData: task.checkpointData,
},
}),
});
const data = await response.json();
return data.session;
}
@@ -811,10 +811,12 @@ export class CoordinatorService {
**Scenario:** Agent crashes mid-task.
**Detection:**
- Heartbeat TTL expires in Valkey
- Coordinator detects missing heartbeat
**Recovery:**
1. Mark agent as `ERROR` in database
2. Abort assigned tasks with `status = ABORTED`
3. Log failure with stack trace (if available)
@@ -827,10 +829,12 @@ export class CoordinatorService {
**Scenario:** Gateway restarts, killing all agent sessions.
**Detection:**
- All agent heartbeats stop simultaneously
- Coordinator detects mass stale agents
**Recovery:**
1. Coordinator marks all `RUNNING` tasks as `ABORTED`
2. Tasks with `checkpointData` can resume from last checkpoint
3. Tasks without checkpoints restart from scratch
@@ -841,10 +845,12 @@ export class CoordinatorService {
**Scenario:** Task A depends on Task B, which depends on Task A (circular dependency).
**Detection:**
- Coordinator builds dependency graph
- Detects cycles during `resolveDependencies()`
**Recovery:**
1. Log `ERROR` with cycle details
2. Mark all tasks in cycle as `FAILED` with reason `dependency_cycle`
3. Notify workspace owner via webhook
@@ -854,9 +860,11 @@ export class CoordinatorService {
**Scenario:** PostgreSQL becomes unavailable.
**Detection:**
- Prisma query fails with connection error
**Recovery:**
1. Coordinator catches error, logs to stderr
2. Releases lock (allowing failover to another instance)
3. Retries with exponential backoff: 5s, 10s, 20s, 40s
@@ -867,14 +875,17 @@ export class CoordinatorService {
**Scenario:** Network partition causes two coordinators to run simultaneously.
**Prevention:**
- Distributed lock in Valkey with 30s TTL
- Coordinators must renew lock every cycle
- Only one coordinator can hold lock at a time
**Detection:**
- Task assigned to multiple agents (conflict detection)
**Recovery:**
1. Newer assignment wins (based on `assignedAt` timestamp)
2. Cancel older session
3. Log conflict for investigation
@@ -884,10 +895,12 @@ export class CoordinatorService {
**Scenario:** Task runs longer than `estimatedCompletionAt + grace period`.
**Detection:**
- Coordinator checks `estimatedCompletionAt` field
- If exceeded by >30 minutes, mark as potentially hung
**Recovery:**
1. Send warning to agent session (via pub/sub)
2. If no progress update in 10 minutes, abort task
3. Log timeout error
@@ -902,6 +915,7 @@ export class CoordinatorService {
**Goal:** Basic task and agent models, no coordination yet.
**Deliverables:**
- [ ] Database schema migration (tables, indexes)
- [ ] Prisma models for `AgentTask`, `AgentTaskLog`, `AgentHeartbeat`
- [ ] Basic CRUD API endpoints for tasks
@@ -909,6 +923,7 @@ export class CoordinatorService {
- [ ] Manual task assignment (no automation)
**Testing:**
- Unit tests for task state machine
- Integration tests for task CRUD
- Manual testing: create task, assign to agent, complete
@@ -918,6 +933,7 @@ export class CoordinatorService {
**Goal:** Autonomous coordinator with health monitoring.
**Deliverables:**
- [ ] `CoordinatorService` with distributed locking
- [ ] Health monitoring (heartbeat TTL checks)
- [ ] Automatic task assignment to available agents
@@ -925,6 +941,7 @@ export class CoordinatorService {
- [ ] Pub/Sub for coordination events
**Testing:**
- Unit tests for coordinator logic
- Integration tests with Valkey
- Chaos testing: kill agents, verify recovery
@@ -935,6 +952,7 @@ export class CoordinatorService {
**Goal:** Fault-tolerant operation with automatic recovery.
**Deliverables:**
- [ ] Agent failure detection and task recovery
- [ ] Exponential backoff for retries
- [ ] Checkpoint/resume support for long-running tasks
@@ -942,6 +960,7 @@ export class CoordinatorService {
- [ ] Deadlock detection
**Testing:**
- Fault injection: kill agents, restart Gateway
- Dependency cycle testing
- Retry exhaustion testing
@@ -952,6 +971,7 @@ export class CoordinatorService {
**Goal:** Full visibility into orchestration state.
**Deliverables:**
- [ ] Coordinator status dashboard
- [ ] Task progress tracking UI
- [ ] Real-time logs API
@@ -959,6 +979,7 @@ export class CoordinatorService {
- [ ] Webhook integration for external monitoring
**Testing:**
- Load testing with metrics collection
- Dashboard usability testing
- Webhook reliability testing
@@ -968,6 +989,7 @@ export class CoordinatorService {
**Goal:** Production-grade features.
**Deliverables:**
- [ ] Task prioritization algorithms (SJF, priority queues)
- [ ] Agent capability matching (skills-based routing)
- [ ] Task batching (group similar tasks)
@@ -988,11 +1010,11 @@ async getTasks(userId: string, workspaceId: string) {
const membership = await this.prisma.workspaceMember.findUnique({
where: { workspaceId_userId: { workspaceId, userId } }
});
if (!membership) {
throw new ForbiddenException('Not a member of this workspace');
}
return this.prisma.agentTask.findMany({
where: { workspaceId }
});
@@ -1018,15 +1040,15 @@ async getTasks(userId: string, workspaceId: string) {
### Key Metrics
| Metric | Description | Alert Threshold |
|--------|-------------|-----------------|
| `coordinator.cycle.duration_ms` | Coordination cycle execution time | >5000ms |
| `coordinator.stale_agents.count` | Number of stale agents detected | >5 |
| `tasks.pending.count` | Tasks waiting for assignment | >50 |
| `tasks.failed.count` | Total failed tasks (last 1h) | >10 |
| `tasks.retry.exhausted.count` | Tasks exceeding max retries | >0 |
| `agents.spawned.count` | Agent spawn rate | >100/min |
| `valkey.connection.errors` | Valkey connection failures | >0 |
| Metric | Description | Alert Threshold |
| -------------------------------- | --------------------------------- | --------------- |
| `coordinator.cycle.duration_ms` | Coordination cycle execution time | >5000ms |
| `coordinator.stale_agents.count` | Number of stale agents detected | >5 |
| `tasks.pending.count` | Tasks waiting for assignment | >50 |
| `tasks.failed.count` | Total failed tasks (last 1h) | >10 |
| `tasks.retry.exhausted.count` | Tasks exceeding max retries | >0 |
| `agents.spawned.count` | Agent spawn rate | >100/min |
| `valkey.connection.errors` | Valkey connection failures | >0 |
### Health Checks
@@ -1053,25 +1075,25 @@ GET /health/coordinator
```typescript
// Main agent creates a development task
const task = await fetch('/api/v1/agent-tasks', {
method: 'POST',
const task = await fetch("/api/v1/agent-tasks", {
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sessionToken}`
"Content-Type": "application/json",
Authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify({
title: 'Fix TypeScript strict errors in U-Connect',
description: 'Run tsc --noEmit, fix all errors, commit changes',
taskType: 'development',
title: "Fix TypeScript strict errors in U-Connect",
description: "Run tsc --noEmit, fix all errors, commit changes",
taskType: "development",
priority: 8,
inputContext: {
repository: 'u-connect',
branch: 'main',
commands: ['pnpm install', 'pnpm tsc:check']
repository: "u-connect",
branch: "main",
commands: ["pnpm install", "pnpm tsc:check"],
},
maxRetries: 2,
estimatedDurationMinutes: 30
})
estimatedDurationMinutes: 30,
}),
});
const { id } = await task.json();
@@ -1084,19 +1106,19 @@ console.log(`Task created: ${id}`);
// Subagent sends heartbeat every 30s
setInterval(async () => {
await fetch(`/api/v1/agents/${agentId}/heartbeat`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${agentToken}`
"Content-Type": "application/json",
Authorization: `Bearer ${agentToken}`,
},
body: JSON.stringify({
status: 'healthy',
status: "healthy",
currentTaskId: taskId,
progressPercent: 45,
currentStep: 'Running tsc --noEmit',
currentStep: "Running tsc --noEmit",
memoryMb: 512,
cpuPercent: 35
})
cpuPercent: 35,
}),
});
}, 30000);
```
@@ -1106,20 +1128,20 @@ setInterval(async () => {
```typescript
// Agent updates task progress
await fetch(`/api/v1/agent-tasks/${taskId}/progress`, {
method: 'PATCH',
method: "PATCH",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${agentToken}`
"Content-Type": "application/json",
Authorization: `Bearer ${agentToken}`,
},
body: JSON.stringify({
progressPercent: 70,
currentStep: 'Fixing type errors in packages/shared',
currentStep: "Fixing type errors in packages/shared",
checkpointData: {
filesProcessed: 15,
errorsFixed: 8,
remainingFiles: 5
}
})
remainingFiles: 5,
},
}),
});
```
@@ -1128,20 +1150,20 @@ await fetch(`/api/v1/agent-tasks/${taskId}/progress`, {
```typescript
// Agent marks task complete
await fetch(`/api/v1/agent-tasks/${taskId}/complete`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${agentToken}`
"Content-Type": "application/json",
Authorization: `Bearer ${agentToken}`,
},
body: JSON.stringify({
outputResult: {
filesModified: 20,
errorsFixed: 23,
commitHash: 'abc123',
buildStatus: 'passing'
commitHash: "abc123",
buildStatus: "passing",
},
summary: 'All TypeScript strict errors resolved. Build passing.'
})
summary: "All TypeScript strict errors resolved. Build passing.",
}),
});
```
@@ -1149,17 +1171,17 @@ await fetch(`/api/v1/agent-tasks/${taskId}/complete`, {
## Glossary
| Term | Definition |
|------|------------|
| **Agent** | Autonomous AI instance (e.g., Claude subagent) that executes tasks |
| **Task** | Unit of work to be executed by an agent |
| **Coordinator** | Background service that assigns tasks and monitors agent health |
| **Heartbeat** | Periodic signal from agent indicating it's alive and working |
| **Stale Agent** | Agent that has stopped sending heartbeats (assumed dead) |
| **Checkpoint** | Snapshot of task state allowing resumption after failure |
| **Workspace** | Tenant isolation boundary (all tasks belong to a workspace) |
| **Session** | Gateway-managed connection between user and agent |
| **Orchestration** | Automated coordination of multiple agents working on tasks |
| Term | Definition |
| ----------------- | ------------------------------------------------------------------ |
| **Agent** | Autonomous AI instance (e.g., Claude subagent) that executes tasks |
| **Task** | Unit of work to be executed by an agent |
| **Coordinator** | Background service that assigns tasks and monitors agent health |
| **Heartbeat** | Periodic signal from agent indicating it's alive and working |
| **Stale Agent** | Agent that has stopped sending heartbeats (assumed dead) |
| **Checkpoint** | Snapshot of task state allowing resumption after failure |
| **Workspace** | Tenant isolation boundary (all tasks belong to a workspace) |
| **Session** | Gateway-managed connection between user and agent |
| **Orchestration** | Automated coordination of multiple agents working on tasks |
---
@@ -1174,6 +1196,7 @@ await fetch(`/api/v1/agent-tasks/${taskId}/complete`, {
---
**Next Steps:**
1. Review and approve this design document
2. Create GitHub issues for Phase 1 tasks
3. Set up development branch: `feature/agent-orchestration`

View File

@@ -67,12 +67,14 @@ Mosaic Stack needs to integrate with AI agents (currently ClawdBot, formerly Mol
### Consequences
**Positive:**
- Platform independence
- Multiple integration paths
- Clear separation of concerns
- Easier testing (API-level tests)
**Negative:**
- Extra network hop for agent access
- Need to maintain both API and skill code
- Slightly more initial work
@@ -80,8 +82,8 @@ Mosaic Stack needs to integrate with AI agents (currently ClawdBot, formerly Mol
### Related Issues
- #22: Brain query API endpoint (API-first ✓)
- #23-26: mosaic-plugin-* → rename to mosaic-skill-* (thin wrappers)
- #23-26: mosaic-plugin-_ → rename to mosaic-skill-_ (thin wrappers)
---
*Future ADRs will be added to this document.*
_Future ADRs will be added to this document._

View File

@@ -52,6 +52,7 @@
### Peer-to-Peer Federation Model
Every Mosaic Stack instance is a **peer** that can simultaneously act as:
- **Master** — Control and query downstream spokes
- **Spoke** — Expose capabilities to upstream masters
@@ -120,15 +121,15 @@ Every Mosaic Stack instance is a **peer** that can simultaneously act as:
Authentik provides enterprise-grade identity management:
| Feature | Purpose |
|---------|---------|
| **OIDC/SAML** | Single sign-on across instances |
| **User Directory** | Centralized user management |
| **Groups** | Team/department organization |
| **RBAC** | Role-based access control |
| **Audit Logs** | Compliance and security tracking |
| **MFA** | Multi-factor authentication |
| **Federation** | Trust between external IdPs |
| Feature | Purpose |
| ------------------ | -------------------------------- |
| **OIDC/SAML** | Single sign-on across instances |
| **User Directory** | Centralized user management |
| **Groups** | Team/department organization |
| **RBAC** | Role-based access control |
| **Audit Logs** | Compliance and security tracking |
| **MFA** | Multi-factor authentication |
| **Federation** | Trust between external IdPs |
### Auth Architecture
@@ -199,6 +200,7 @@ When federating between instances with different IdPs:
```
**Identity Mapping:**
- Same email = same person (by convention)
- Explicit identity linking via federation protocol
- No implicit access—must be granted per instance
@@ -297,11 +299,7 @@ When federating between instances with different IdPs:
"returns": "Workspace[]"
}
],
"eventSubscriptions": [
"calendar.reminder",
"tasks.assigned",
"tasks.completed"
]
"eventSubscriptions": ["calendar.reminder", "tasks.assigned", "tasks.completed"]
}
```
@@ -467,19 +465,19 @@ Instance
### Role Permissions Matrix
| Permission | Owner | Admin | Member | Viewer | Guest |
|------------|-------|-------|--------|--------|-------|
| View workspace | ✓ | ✓ | ✓ | ✓ | ✓* |
| Create content | ✓ | ✓ | ✓ | ✗ | ✗ |
| Edit content | ✓ | ✓ | ✓ | ✗ | ✗ |
| Delete content | ✓ | ✓ | ✗ | ✗ | ✗ |
| Manage members | ✓ | ✓ | ✗ | ✗ | ✗ |
| Manage teams | ✓ | ✓ | ✗ | ✗ | ✗ |
| Configure workspace | ✓ | ✗ | ✗ | ✗ | ✗ |
| Delete workspace | ✓ | ✗ | ✗ | ✗ | ✗ |
| Manage federation | ✓ | ✗ | ✗ | ✗ | ✗ |
| Permission | Owner | Admin | Member | Viewer | Guest |
| ------------------- | ----- | ----- | ------ | ------ | ----- |
| View workspace | ✓ | ✓ | ✓ | ✓ | ✓\* |
| Create content | ✓ | ✓ | ✓ | ✗ | ✗ |
| Edit content | ✓ | ✓ | ✓ | ✗ | ✗ |
| Delete content | ✓ | ✓ | ✗ | ✗ | ✗ |
| Manage members | ✓ | ✓ | ✗ | ✗ | ✗ |
| Manage teams | ✓ | ✓ | ✗ | ✗ | ✗ |
| Configure workspace | ✓ | ✗ | ✗ | ✗ | ✗ |
| Delete workspace | ✓ | ✗ | ✗ | ✗ | ✗ |
| Manage federation | ✓ | ✗ | ✗ | ✗ | ✗ |
*Guest: scoped to specific shared items only
\*Guest: scoped to specific shared items only
### Federation RBAC
@@ -503,6 +501,7 @@ Cross-instance access is always scoped and limited:
```
**Key Constraints:**
- Federated users cannot exceed `maxRole` (e.g., member can't become admin)
- Access limited to `scopedWorkspaces` only
- Capabilities are explicitly allowlisted
@@ -545,14 +544,14 @@ Cross-instance access is always scoped and limited:
### What's Stored vs Queried
| Data Type | Home Instance | Work Instance | Notes |
|-----------|---------------|---------------|-------|
| Personal tasks | ✓ Stored | — | Only at home |
| Work tasks | Queried live | ✓ Stored | Never replicated |
| Personal calendar | ✓ Stored | — | Only at home |
| Work calendar | Queried live | ✓ Stored | Never replicated |
| Federation metadata | ✓ Stored | ✓ Stored | Connection config only |
| Query results cache | Ephemeral (5m TTL) | — | Optional, short-lived |
| Data Type | Home Instance | Work Instance | Notes |
| ------------------- | ------------------ | ------------- | ---------------------- |
| Personal tasks | ✓ Stored | — | Only at home |
| Work tasks | Queried live | ✓ Stored | Never replicated |
| Personal calendar | ✓ Stored | — | Only at home |
| Work calendar | Queried live | ✓ Stored | Never replicated |
| Federation metadata | ✓ Stored | ✓ Stored | Connection config only |
| Query results cache | Ephemeral (5m TTL) | — | Optional, short-lived |
### Severance Procedure
@@ -581,6 +580,7 @@ Result:
**Goal:** Multi-instance awareness, basic federation protocol
**Deliverables:**
- [ ] Instance identity model (instanceId, URL, public key)
- [ ] Federation connection database schema
- [ ] Basic CONNECT/DISCONNECT protocol
@@ -588,6 +588,7 @@ Result:
- [ ] Query/Command message handling (stub)
**Testing:**
- Two local instances can connect
- Connection persists across restarts
- Disconnect cleans up properly
@@ -597,6 +598,7 @@ Result:
**Goal:** Enterprise SSO with RBAC
**Deliverables:**
- [ ] Authentik OIDC provider setup guide
- [ ] BetterAuth Authentik adapter
- [ ] Group → Role mapping
@@ -604,6 +606,7 @@ Result:
- [ ] Audit logging for auth events
**Testing:**
- Login via Authentik works
- Groups map to roles correctly
- Session isolation between workspaces
@@ -613,6 +616,7 @@ Result:
**Goal:** Full query/command capability
**Deliverables:**
- [ ] QUERY message type with response streaming
- [ ] COMMAND message type with async support
- [ ] EVENT subscription and delivery
@@ -620,6 +624,7 @@ Result:
- [ ] Error handling and retry logic
**Testing:**
- Master can query spoke calendar
- Master can create tasks on spoke
- Events push from spoke to master
@@ -630,6 +635,7 @@ Result:
**Goal:** Unified dashboard showing all instances
**Deliverables:**
- [ ] Connection manager UI
- [ ] Aggregated calendar view
- [ ] Aggregated task view
@@ -637,6 +643,7 @@ Result:
- [ ] Visual provenance tagging (color/icon per instance)
**Testing:**
- Dashboard shows data from multiple instances
- Clear visual distinction between sources
- Offline instance shows gracefully
@@ -646,12 +653,14 @@ Result:
**Goal:** Cross-instance agent coordination
**Deliverables:**
- [ ] Agent spawn command via federation
- [ ] Callback mechanism for results
- [ ] Agent status querying across instances
- [ ] Cross-instance task assignment
**Testing:**
- Home agent can spawn task on work instance
- Results callback works
- Agent health visible across instances
@@ -661,6 +670,7 @@ Result:
**Goal:** Production-ready for organizations
**Deliverables:**
- [ ] Admin console for federation management
- [ ] Compliance audit reports
- [ ] Rate limiting and quotas
@@ -673,24 +683,24 @@ Result:
### Semantic Versioning Policy
| Version | Meaning |
|---------|---------|
| `0.0.x` | Active development, breaking changes expected, internal use only |
| `0.1.0` | **MVP** — First user-testable release, core features working |
| `0.x.y` | Pre-stable iteration, API may change with notice |
| Version | Meaning |
| ------- | --------------------------------------------------------------------------- |
| `0.0.x` | Active development, breaking changes expected, internal use only |
| `0.1.0` | **MVP** — First user-testable release, core features working |
| `0.x.y` | Pre-stable iteration, API may change with notice |
| `1.0.0` | Stable release, public API contract, breaking changes require major version |
### Version Milestones
| Version | Target | Features |
|---------|--------|----------|
| 0.0.1 | Design | This document |
| 0.0.5 | Foundation | Basic federation protocol |
| 0.0.10 | Auth | Authentik integration |
| 0.1.0 | **MVP** | Single pane of glass, basic federation |
| 0.2.0 | Agents | Cross-instance agent coordination |
| 0.3.0 | Enterprise | Admin console, compliance |
| 1.0.0 | Stable | Production-ready, API frozen |
| Version | Target | Features |
| ------- | ---------- | -------------------------------------- |
| 0.0.1 | Design | This document |
| 0.0.5 | Foundation | Basic federation protocol |
| 0.0.10 | Auth | Authentik integration |
| 0.1.0 | **MVP** | Single pane of glass, basic federation |
| 0.2.0 | Agents | Cross-instance agent coordination |
| 0.3.0 | Enterprise | Admin console, compliance |
| 1.0.0 | Stable | Production-ready, API frozen |
---
@@ -804,18 +814,18 @@ DELETE /api/v1/federation/spoke/masters/:instanceId
## Glossary
| Term | Definition |
|------|------------|
| **Instance** | A single Mosaic Stack deployment |
| **Master** | Instance that initiates connection and queries spoke |
| **Spoke** | Instance that accepts connections and serves data |
| **Peer** | An instance that can be both master and spoke |
| **Federation** | Network of connected Mosaic Stack instances |
| **Scope** | Permission to perform specific actions (e.g., `calendar.read`) |
| **Capability** | API endpoint exposed by a spoke |
| **Provenance** | Source attribution for data (which instance it came from) |
| **Severance** | Clean disconnection with no data cleanup required |
| **IdP** | Identity Provider (e.g., Authentik) |
| Term | Definition |
| -------------- | -------------------------------------------------------------- |
| **Instance** | A single Mosaic Stack deployment |
| **Master** | Instance that initiates connection and queries spoke |
| **Spoke** | Instance that accepts connections and serves data |
| **Peer** | An instance that can be both master and spoke |
| **Federation** | Network of connected Mosaic Stack instances |
| **Scope** | Permission to perform specific actions (e.g., `calendar.read`) |
| **Capability** | API endpoint exposed by a spoke |
| **Provenance** | Source attribution for data (which instance it came from) |
| **Severance** | Clean disconnection with no data cleanup required |
| **IdP** | Identity Provider (e.g., Authentik) |
---
@@ -840,6 +850,7 @@ DELETE /api/v1/federation/spoke/masters/:instanceId
---
**Next Steps:**
1. Review and approve this design document
2. Create GitHub issues for Phase 1 tasks
3. Set up Authentik development instance

View File

@@ -27,6 +27,7 @@ Build a native knowledge management module for Mosaic Stack with wiki-style link
Create Prisma schema and migrations for the Knowledge module.
**Acceptance Criteria:**
- [ ] `KnowledgeEntry` model with all fields
- [ ] `KnowledgeEntryVersion` model for history
- [ ] `KnowledgeLink` model for wiki-links
@@ -37,6 +38,7 @@ Create Prisma schema and migrations for the Knowledge module.
- [ ] Seed data for testing
**Technical Notes:**
- Reference design doc for full schema
- Ensure `@@unique([workspaceId, slug])` constraint
- Add `search_vector` column for full-text search
@@ -54,6 +56,7 @@ Create Prisma schema and migrations for the Knowledge module.
Implement RESTful API for knowledge entry management.
**Acceptance Criteria:**
- [ ] `POST /api/knowledge/entries` - Create entry
- [ ] `GET /api/knowledge/entries` - List entries (paginated, filterable)
- [ ] `GET /api/knowledge/entries/:slug` - Get single entry
@@ -64,6 +67,7 @@ Implement RESTful API for knowledge entry management.
- [ ] OpenAPI/Swagger documentation
**Technical Notes:**
- Follow existing Mosaic API patterns
- Use `@WorkspaceGuard()` for tenant isolation
- Slug generation from title with collision handling
@@ -80,6 +84,7 @@ Implement RESTful API for knowledge entry management.
Implement tag CRUD and entry-tag associations.
**Acceptance Criteria:**
- [ ] `GET /api/knowledge/tags` - List workspace tags
- [ ] `POST /api/knowledge/tags` - Create tag
- [ ] `PUT /api/knowledge/tags/:slug` - Update tag
@@ -100,6 +105,7 @@ Implement tag CRUD and entry-tag associations.
Render markdown content to HTML with caching.
**Acceptance Criteria:**
- [ ] Markdown-to-HTML conversion on entry save
- [ ] Support GFM (tables, task lists, strikethrough)
- [ ] Code syntax highlighting (highlight.js or Shiki)
@@ -108,6 +114,7 @@ Render markdown content to HTML with caching.
- [ ] Invalidate cache on content update
**Technical Notes:**
- Use `marked` or `remark` for parsing
- Wiki-links (`[[...]]`) parsed but not resolved yet (Phase 2)
@@ -123,6 +130,7 @@ Render markdown content to HTML with caching.
Build the knowledge entry list page in the web UI.
**Acceptance Criteria:**
- [ ] List view with title, summary, tags, updated date
- [ ] Filter by status (draft/published/archived)
- [ ] Filter by tag
@@ -144,6 +152,7 @@ Build the knowledge entry list page in the web UI.
Build the entry view and edit page.
**Acceptance Criteria:**
- [ ] View mode with rendered markdown
- [ ] Edit mode with markdown editor
- [ ] Split view option (edit + preview)
@@ -155,6 +164,7 @@ Build the entry view and edit page.
- [ ] Keyboard shortcuts (Cmd+S to save)
**Technical Notes:**
- Consider CodeMirror or Monaco for editor
- May use existing rich-text patterns from Mosaic
@@ -172,6 +182,7 @@ Build the entry view and edit page.
Parse `[[wiki-link]]` syntax from markdown content.
**Acceptance Criteria:**
- [ ] Extract all `[[...]]` patterns from content
- [ ] Support `[[slug]]` basic syntax
- [ ] Support `[[slug|display text]]` aliased links
@@ -180,12 +191,13 @@ Parse `[[wiki-link]]` syntax from markdown content.
- [ ] Handle edge cases (nested brackets, escaping)
**Technical Notes:**
```typescript
interface ParsedLink {
raw: string; // "[[design|Design Doc]]"
target: string; // "design"
display: string; // "Design Doc"
section?: string; // "header" if [[design#header]]
raw: string; // "[[design|Design Doc]]"
target: string; // "design"
display: string; // "Design Doc"
section?: string; // "header" if [[design#header]]
position: { start: number; end: number };
}
```
@@ -202,6 +214,7 @@ interface ParsedLink {
Resolve parsed wiki-links to actual entries.
**Acceptance Criteria:**
- [ ] Resolve by exact slug match
- [ ] Resolve by title match (case-insensitive)
- [ ] Fuzzy match fallback (optional)
@@ -221,6 +234,7 @@ Resolve parsed wiki-links to actual entries.
Store links in database and keep in sync with content.
**Acceptance Criteria:**
- [ ] On entry save: parse → resolve → store links
- [ ] Remove stale links on update
- [ ] `GET /api/knowledge/entries/:slug/links/outgoing`
@@ -240,6 +254,7 @@ Store links in database and keep in sync with content.
Show incoming links (backlinks) on entry pages.
**Acceptance Criteria:**
- [ ] Backlinks section on entry detail page
- [ ] Show linking entry title + context snippet
- [ ] Click to navigate to linking entry
@@ -258,6 +273,7 @@ Show incoming links (backlinks) on entry pages.
Autocomplete suggestions when typing `[[`.
**Acceptance Criteria:**
- [ ] Trigger on `[[` typed in editor
- [ ] Show dropdown with matching entries
- [ ] Search by title and slug
@@ -278,6 +294,7 @@ Autocomplete suggestions when typing `[[`.
Render wiki-links as clickable links in entry view.
**Acceptance Criteria:**
- [ ] `[[slug]]` renders as link to `/knowledge/slug`
- [ ] `[[slug|text]]` shows custom text
- [ ] Broken links styled differently (red, dashed underline)
@@ -297,6 +314,7 @@ Render wiki-links as clickable links in entry view.
Set up PostgreSQL full-text search for entries.
**Acceptance Criteria:**
- [ ] Add `tsvector` column to entries table
- [ ] Create GIN index on search vector
- [ ] Weight title (A), summary (B), content (C)
@@ -315,6 +333,7 @@ Set up PostgreSQL full-text search for entries.
Implement search API with full-text search.
**Acceptance Criteria:**
- [ ] `GET /api/knowledge/search?q=...`
- [ ] Return ranked results with snippets
- [ ] Highlight matching terms in snippets
@@ -334,6 +353,7 @@ Implement search API with full-text search.
Build search interface in web UI.
**Acceptance Criteria:**
- [ ] Search input in knowledge module header
- [ ] Search results page
- [ ] Highlighted snippets
@@ -354,12 +374,14 @@ Build search interface in web UI.
Set up pgvector extension for semantic search.
**Acceptance Criteria:**
- [ ] Enable pgvector extension in PostgreSQL
- [ ] Create embeddings table with vector column
- [ ] HNSW index for fast similarity search
- [ ] Verify extension works in dev and prod
**Technical Notes:**
- May need PostgreSQL 15+ for best pgvector support
- Consider managed options (Supabase, Neon) if self-hosting is complex
@@ -375,6 +397,7 @@ Set up pgvector extension for semantic search.
Generate embeddings for entries using OpenAI or local model.
**Acceptance Criteria:**
- [ ] Service to generate embeddings from text
- [ ] On entry create/update: queue embedding job
- [ ] Background worker processes queue
@@ -383,6 +406,7 @@ Generate embeddings for entries using OpenAI or local model.
- [ ] Config for embedding model selection
**Technical Notes:**
- Start with OpenAI `text-embedding-ada-002`
- Consider local options (sentence-transformers) for cost/privacy
@@ -398,6 +422,7 @@ Generate embeddings for entries using OpenAI or local model.
Implement semantic (vector) search endpoint.
**Acceptance Criteria:**
- [ ] `POST /api/knowledge/search/semantic`
- [ ] Accept natural language query
- [ ] Generate query embedding
@@ -419,6 +444,7 @@ Implement semantic (vector) search endpoint.
API to retrieve knowledge graph data.
**Acceptance Criteria:**
- [ ] `GET /api/knowledge/graph` - Full graph (nodes + edges)
- [ ] `GET /api/knowledge/graph/:slug` - Subgraph centered on entry
- [ ] `GET /api/knowledge/graph/stats` - Graph statistics
@@ -438,6 +464,7 @@ API to retrieve knowledge graph data.
Interactive knowledge graph visualization.
**Acceptance Criteria:**
- [ ] Force-directed graph layout
- [ ] Nodes sized by connection count
- [ ] Nodes colored by status
@@ -447,6 +474,7 @@ Interactive knowledge graph visualization.
- [ ] Performance OK with 500+ nodes
**Technical Notes:**
- Use D3.js or Cytoscape.js
- Consider WebGL renderer for large graphs
@@ -462,6 +490,7 @@ Interactive knowledge graph visualization.
Show mini-graph on entry detail page.
**Acceptance Criteria:**
- [ ] Small graph showing entry + direct connections
- [ ] 1-2 hop neighbors
- [ ] Click to expand or navigate
@@ -479,6 +508,7 @@ Show mini-graph on entry detail page.
Dashboard showing knowledge base health.
**Acceptance Criteria:**
- [ ] Total entries, links, tags
- [ ] Orphan entry count (no links)
- [ ] Broken link count
@@ -500,6 +530,7 @@ Dashboard showing knowledge base health.
API for entry version history.
**Acceptance Criteria:**
- [ ] Create version on each save
- [ ] `GET /api/knowledge/entries/:slug/versions`
- [ ] `GET /api/knowledge/entries/:slug/versions/:v`
@@ -519,6 +550,7 @@ API for entry version history.
UI to browse and restore versions.
**Acceptance Criteria:**
- [ ] Version list sidebar/panel
- [ ] Show version date, author, change note
- [ ] Click to view historical version
@@ -527,6 +559,7 @@ UI to browse and restore versions.
- [ ] Compare any two versions
**Technical Notes:**
- Use diff library for content comparison
- Highlight additions/deletions
@@ -542,6 +575,7 @@ UI to browse and restore versions.
Import existing markdown files into knowledge base.
**Acceptance Criteria:**
- [ ] Upload `.md` file(s)
- [ ] Parse frontmatter for metadata
- [ ] Generate slug from filename or title
@@ -561,6 +595,7 @@ Import existing markdown files into knowledge base.
Export entries to markdown/PDF.
**Acceptance Criteria:**
- [ ] Export single entry as markdown
- [ ] Export single entry as PDF
- [ ] Bulk export (all or filtered)
@@ -579,6 +614,7 @@ Export entries to markdown/PDF.
Implement Valkey caching for knowledge module.
**Acceptance Criteria:**
- [ ] Cache entry JSON
- [ ] Cache rendered HTML
- [ ] Cache graph data
@@ -598,6 +634,7 @@ Implement Valkey caching for knowledge module.
Document the knowledge module.
**Acceptance Criteria:**
- [ ] User guide for knowledge module
- [ ] API reference (OpenAPI already in place)
- [ ] Wiki-link syntax reference
@@ -617,6 +654,7 @@ Document the knowledge module.
Multiple users editing same entry simultaneously.
**Notes:**
- Would require CRDT or OT implementation
- Significant complexity
- Evaluate need before committing
@@ -632,6 +670,7 @@ Multiple users editing same entry simultaneously.
Pre-defined templates for common entry types.
**Notes:**
- ADR template
- Design doc template
- Meeting notes template
@@ -648,6 +687,7 @@ Pre-defined templates for common entry types.
Upload and embed images/files in entries.
**Notes:**
- S3/compatible storage backend
- Image optimization
- Paste images into editor
@@ -656,15 +696,15 @@ Upload and embed images/files in entries.
## Summary
| Phase | Issues | Est. Hours | Focus |
|-------|--------|------------|-------|
| 1 | KNOW-001 to KNOW-006 | 31h | CRUD + Basic UI |
| 2 | KNOW-007 to KNOW-012 | 24h | Wiki-links |
| 3 | KNOW-013 to KNOW-018 | 28h | Search |
| 4 | KNOW-019 to KNOW-022 | 19h | Graph |
| 5 | KNOW-023 to KNOW-028 | 25h | Polish |
| **Total** | 28 issues | ~127h | ~3-4 dev weeks |
| Phase | Issues | Est. Hours | Focus |
| --------- | -------------------- | ---------- | --------------- |
| 1 | KNOW-001 to KNOW-006 | 31h | CRUD + Basic UI |
| 2 | KNOW-007 to KNOW-012 | 24h | Wiki-links |
| 3 | KNOW-013 to KNOW-018 | 28h | Search |
| 4 | KNOW-019 to KNOW-022 | 19h | Graph |
| 5 | KNOW-023 to KNOW-028 | 25h | Polish |
| **Total** | 28 issues | ~127h | ~3-4 dev weeks |
---
*Generated by Jarvis • 2025-01-29*
_Generated by Jarvis • 2025-01-29_

View File

@@ -20,35 +20,35 @@ Development teams and AI agents working on complex projects need a way to:
- **Scattered documentation** — README, comments, Slack threads, memory files
- **No explicit linking** — Connections exist but aren't captured
- **Agent amnesia** — Each session starts fresh, relies on file search
- **No decision archaeology** — Hard to find *why* something was decided
- **No decision archaeology** — Hard to find _why_ something was decided
- **Human/agent mismatch** — Humans browse, agents grep
## Requirements
### Functional Requirements
| ID | Requirement | Priority |
|----|-------------|----------|
| FR1 | Create, read, update, delete knowledge entries | P0 |
| FR2 | Wiki-style linking between entries (`[[link]]` syntax) | P0 |
| FR3 | Tagging and categorization | P0 |
| FR4 | Full-text search | P0 |
| FR5 | Semantic/vector search for agents | P1 |
| FR6 | Graph visualization of connections | P1 |
| FR7 | Version history and diff view | P1 |
| FR8 | Timeline view of changes | P2 |
| FR9 | Import from markdown files | P2 |
| FR10 | Export to markdown/PDF | P2 |
| ID | Requirement | Priority |
| ---- | ------------------------------------------------------ | -------- |
| FR1 | Create, read, update, delete knowledge entries | P0 |
| FR2 | Wiki-style linking between entries (`[[link]]` syntax) | P0 |
| FR3 | Tagging and categorization | P0 |
| FR4 | Full-text search | P0 |
| FR5 | Semantic/vector search for agents | P1 |
| FR6 | Graph visualization of connections | P1 |
| FR7 | Version history and diff view | P1 |
| FR8 | Timeline view of changes | P2 |
| FR9 | Import from markdown files | P2 |
| FR10 | Export to markdown/PDF | P2 |
### Non-Functional Requirements
| ID | Requirement | Target |
|----|-------------|--------|
| NFR1 | Search response time | < 200ms |
| NFR2 | Entry render time | < 100ms |
| NFR3 | Graph render (< 1000 nodes) | < 500ms |
| NFR4 | Multi-tenant isolation | Complete |
| NFR5 | API-first design | All features via API |
| ID | Requirement | Target |
| ---- | --------------------------- | -------------------- |
| NFR1 | Search response time | < 200ms |
| NFR2 | Entry render time | < 100ms |
| NFR3 | Graph render (< 1000 nodes) | < 500ms |
| NFR4 | Multi-tenant isolation | Complete |
| NFR5 | API-first design | All features via API |
## Architecture Overview
@@ -88,29 +88,29 @@ model KnowledgeEntry {
id String @id @default(cuid())
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id])
slug String // URL-friendly identifier
title String
content String @db.Text // Markdown content
contentHtml String? @db.Text // Rendered HTML (cached)
summary String? // Auto-generated or manual summary
status EntryStatus @default(DRAFT)
visibility Visibility @default(PRIVATE)
// Metadata
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String
updatedBy String
// Relations
tags KnowledgeEntryTag[]
outgoingLinks KnowledgeLink[] @relation("SourceEntry")
incomingLinks KnowledgeLink[] @relation("TargetEntry")
versions KnowledgeEntryVersion[]
embedding KnowledgeEmbedding?
@@unique([workspaceId, slug])
@@index([workspaceId, status])
@@index([workspaceId, updatedAt])
@@ -133,16 +133,16 @@ model KnowledgeEntryVersion {
id String @id @default(cuid())
entryId String
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
version Int
title String
content String @db.Text
summary String?
createdAt DateTime @default(now())
createdBy String
changeNote String? // Optional commit message
@@unique([entryId, version])
@@index([entryId, version])
}
@@ -150,19 +150,19 @@ model KnowledgeEntryVersion {
// Wiki-style links between entries
model KnowledgeLink {
id String @id @default(cuid())
sourceId String
source KnowledgeEntry @relation("SourceEntry", fields: [sourceId], references: [id], onDelete: Cascade)
targetId String
target KnowledgeEntry @relation("TargetEntry", fields: [targetId], references: [id], onDelete: Cascade)
// Link metadata
linkText String // The text used in [[link|display text]]
context String? // Surrounding text for context
createdAt DateTime @default(now())
@@unique([sourceId, targetId])
@@index([sourceId])
@@index([targetId])
@@ -173,24 +173,24 @@ model KnowledgeTag {
id String @id @default(cuid())
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id])
name String
slug String
color String? // Hex color for UI
description String?
entries KnowledgeEntryTag[]
@@unique([workspaceId, slug])
}
model KnowledgeEntryTag {
entryId String
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
tagId String
tag KnowledgeTag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([entryId, tagId])
}
@@ -199,13 +199,13 @@ model KnowledgeEmbedding {
id String @id @default(cuid())
entryId String @unique
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
embedding Unsupported("vector(1536)") // OpenAI ada-002 dimension
model String // Which model generated this
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([embedding], type: Hnsw(ops: VectorCosineOps))
}
```
@@ -338,6 +338,7 @@ Block link: [[entry-slug#^block-id]]
### Automatic Link Detection
On entry save:
1. Parse content for `[[...]]` patterns
2. Resolve each link to target entry
3. Update `KnowledgeLink` records
@@ -349,8 +350,8 @@ On entry save:
```sql
-- Create search index
ALTER TABLE knowledge_entries
ADD COLUMN search_vector tsvector
ALTER TABLE knowledge_entries
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(summary, '')), 'B') ||
@@ -360,7 +361,7 @@ GENERATED ALWAYS AS (
CREATE INDEX idx_knowledge_search ON knowledge_entries USING GIN(search_vector);
-- Search query
SELECT id, slug, title,
SELECT id, slug, title,
ts_rank(search_vector, query) as rank,
ts_headline('english', content, query) as snippet
FROM knowledge_entries, plainto_tsquery('english', $1) query
@@ -388,14 +389,14 @@ LIMIT 10;
```typescript
async function generateEmbedding(entry: KnowledgeEntry): Promise<number[]> {
const text = `${entry.title}\n\n${entry.summary || ''}\n\n${entry.content}`;
const text = `${entry.title}\n\n${entry.summary || ""}\n\n${entry.content}`;
// Use OpenAI or local model
const response = await openai.embeddings.create({
model: 'text-embedding-ada-002',
model: "text-embedding-ada-002",
input: text.slice(0, 8000), // Token limit
});
return response.data[0].embedding;
}
```
@@ -415,25 +416,25 @@ interface GraphNode {
id: string;
slug: string;
title: string;
type: 'entry' | 'tag' | 'external';
type: "entry" | "tag" | "external";
status: EntryStatus;
linkCount: number; // in + out
linkCount: number; // in + out
tags: string[];
updatedAt: string;
}
interface GraphEdge {
id: string;
source: string; // node id
target: string; // node id
type: 'link' | 'tag';
source: string; // node id
target: string; // node id
type: "link" | "tag";
label?: string;
}
interface GraphStats {
nodeCount: number;
edgeCount: number;
orphanCount: number; // entries with no links
orphanCount: number; // entries with no links
brokenLinkCount: number;
avgConnections: number;
}
@@ -444,7 +445,7 @@ interface GraphStats {
```sql
-- Get full graph for workspace
WITH nodes AS (
SELECT
SELECT
id, slug, title, 'entry' as type, status,
(SELECT COUNT(*) FROM knowledge_links WHERE source_id = e.id OR target_id = e.id) as link_count,
updated_at
@@ -452,13 +453,13 @@ WITH nodes AS (
WHERE workspace_id = $1 AND status != 'ARCHIVED'
),
edges AS (
SELECT
SELECT
l.id, l.source_id as source, l.target_id as target, 'link' as type, l.link_text as label
FROM knowledge_links l
JOIN knowledge_entries e ON l.source_id = e.id
WHERE e.workspace_id = $1
)
SELECT
SELECT
json_build_object(
'nodes', (SELECT json_agg(nodes) FROM nodes),
'edges', (SELECT json_agg(edges) FROM edges)
@@ -472,7 +473,7 @@ Use D3.js force-directed graph or Cytoscape.js:
```typescript
// Graph component configuration
const graphConfig = {
layout: 'force-directed',
layout: "force-directed",
physics: {
repulsion: 100,
springLength: 150,
@@ -481,15 +482,18 @@ const graphConfig = {
nodeSize: (node) => Math.sqrt(node.linkCount) * 10 + 20,
nodeColor: (node) => {
switch (node.status) {
case 'PUBLISHED': return '#22c55e';
case 'DRAFT': return '#f59e0b';
case 'ARCHIVED': return '#6b7280';
case "PUBLISHED":
return "#22c55e";
case "DRAFT":
return "#f59e0b";
case "ARCHIVED":
return "#6b7280";
}
},
edgeStyle: {
color: '#94a3b8',
color: "#94a3b8",
width: 1,
arrows: 'to',
arrows: "to",
},
};
```
@@ -515,19 +519,19 @@ async function invalidateEntryCache(workspaceId: string, slug: string) {
const keys = [
`knowledge:${workspaceId}:entry:${slug}`,
`knowledge:${workspaceId}:entry:${slug}:html`,
`knowledge:${workspaceId}:graph`, // Full graph affected
`knowledge:${workspaceId}:graph`, // Full graph affected
`knowledge:${workspaceId}:graph:${slug}`,
`knowledge:${workspaceId}:recent`,
];
// Also invalidate subgraphs for linked entries
const linkedSlugs = await getLinkedEntrySlugs(workspaceId, slug);
for (const linked of linkedSlugs) {
keys.push(`knowledge:${workspaceId}:graph:${linked}`);
}
await valkey.del(...keys);
// Invalidate search caches (pattern delete)
const searchKeys = await valkey.keys(`knowledge:${workspaceId}:search:*`);
if (searchKeys.length) await valkey.del(...searchKeys);
@@ -629,6 +633,7 @@ async function invalidateEntryCache(workspaceId: string, slug: string) {
- [ ] Entry list/detail pages
**Deliverables:**
- Can create, edit, view, delete entries
- Tags work
- Basic search (title/slug match)
@@ -644,6 +649,7 @@ async function invalidateEntryCache(workspaceId: string, slug: string) {
- [ ] Link autocomplete in editor
**Deliverables:**
- Links between entries work
- Backlinks show on entry pages
- Editor suggests links as you type
@@ -660,6 +666,7 @@ async function invalidateEntryCache(workspaceId: string, slug: string) {
- [ ] Semantic search API
**Deliverables:**
- Fast full-text search
- Semantic search for "fuzzy" queries
- Search results with snippets
@@ -675,6 +682,7 @@ async function invalidateEntryCache(workspaceId: string, slug: string) {
- [ ] Graph statistics
**Deliverables:**
- Can view full knowledge graph
- Can explore from any entry
- Visual indicators for status/orphans
@@ -692,6 +700,7 @@ async function invalidateEntryCache(workspaceId: string, slug: string) {
- [ ] Documentation
**Deliverables:**
- Version history works
- Can import existing docs
- Performance is acceptable
@@ -709,12 +718,12 @@ interface KnowledgeTools {
// Search
searchKnowledge(query: string): Promise<SearchResult[]>;
semanticSearch(query: string): Promise<SearchResult[]>;
// CRUD
getEntry(slug: string): Promise<KnowledgeEntry>;
createEntry(data: CreateEntryInput): Promise<KnowledgeEntry>;
updateEntry(slug: string, data: UpdateEntryInput): Promise<KnowledgeEntry>;
// Graph
getRelatedEntries(slug: string): Promise<KnowledgeEntry[]>;
getBacklinks(slug: string): Promise<KnowledgeEntry[]>;
@@ -732,15 +741,15 @@ For Clawdbot specifically, the Knowledge module could:
## Success Metrics
| Metric | Target | Measurement |
|--------|--------|-------------|
| Entry creation time | < 200ms | API response time |
| Search latency (full-text) | < 100ms | p95 response time |
| Search latency (semantic) | < 300ms | p95 response time |
| Graph render (100 nodes) | < 200ms | Client-side time |
| Graph render (1000 nodes) | < 1s | Client-side time |
| Adoption | 50+ entries/workspace | After 1 month |
| Link density | > 2 links/entry avg | Graph statistics |
| Metric | Target | Measurement |
| -------------------------- | --------------------- | ----------------- |
| Entry creation time | < 200ms | API response time |
| Search latency (full-text) | < 100ms | p95 response time |
| Search latency (semantic) | < 300ms | p95 response time |
| Graph render (100 nodes) | < 200ms | Client-side time |
| Graph render (1000 nodes) | < 1s | Client-side time |
| Adoption | 50+ entries/workspace | After 1 month |
| Link density | > 2 links/entry avg | Graph statistics |
## Open Questions

View File

@@ -58,6 +58,7 @@ All tenant-scoped tables have RLS enabled:
The RLS implementation uses several helper functions:
#### `current_user_id()`
Returns the current user's UUID from the session variable `app.current_user_id`.
```sql
@@ -65,6 +66,7 @@ SELECT current_user_id(); -- Returns UUID or NULL
```
#### `is_workspace_member(workspace_uuid, user_uuid)`
Checks if a user is a member of a workspace.
```sql
@@ -72,6 +74,7 @@ SELECT is_workspace_member('workspace-uuid', 'user-uuid'); -- Returns BOOLEAN
```
#### `is_workspace_admin(workspace_uuid, user_uuid)`
Checks if a user is an owner or admin of a workspace.
```sql
@@ -110,12 +113,9 @@ CREATE POLICY knowledge_links_access ON knowledge_links
Before executing any queries, the API **must** set the current user ID:
```typescript
import { prisma } from '@mosaic/database';
import { prisma } from "@mosaic/database";
async function withUserContext<T>(
userId: string,
fn: () => Promise<T>
): Promise<T> {
async function withUserContext<T>(userId: string, fn: () => Promise<T>): Promise<T> {
await prisma.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
return fn();
}
@@ -124,7 +124,7 @@ async function withUserContext<T>(
### Example Usage in API Routes
```typescript
import { withUserContext } from '@/lib/db-context';
import { withUserContext } from "@/lib/db-context";
// In a tRPC procedure or API route
export async function getTasks(userId: string, workspaceId: string) {
@@ -167,20 +167,20 @@ For transactions, set the user context within the transaction:
```typescript
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
// All queries in this transaction are scoped to the user
const workspace = await tx.workspace.create({
data: { name: 'New Workspace', ownerId: userId },
data: { name: "New Workspace", ownerId: userId },
});
await tx.workspaceMember.create({
data: {
workspaceId: workspace.id,
userId,
role: 'OWNER',
role: "OWNER",
},
});
return workspace;
});
```
@@ -230,21 +230,21 @@ SELECT * FROM tasks WHERE workspace_id = 'my-workspace-uuid';
### Automated Tests
```typescript
import { prisma } from '@mosaic/database';
import { prisma } from "@mosaic/database";
describe("RLS Policies", () => {
it("should prevent cross-workspace access", async () => {
const user1Id = "user-1-uuid";
const user2Id = "user-2-uuid";
const workspace1Id = "workspace-1-uuid";
const workspace2Id = "workspace-2-uuid";
describe('RLS Policies', () => {
it('should prevent cross-workspace access', async () => {
const user1Id = 'user-1-uuid';
const user2Id = 'user-2-uuid';
const workspace1Id = 'workspace-1-uuid';
const workspace2Id = 'workspace-2-uuid';
// Set context as user 1
await prisma.$executeRaw`SET LOCAL app.current_user_id = ${user1Id}`;
// Should only see workspace 1's tasks
const tasks = await prisma.task.findMany();
expect(tasks.every(t => t.workspaceId === workspace1Id)).toBe(true);
expect(tasks.every((t) => t.workspaceId === workspace1Id)).toBe(true);
});
});
```