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:
@@ -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
|
||||
|
||||
@@ -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 ✅**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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._
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user