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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user