feat(#93): implement agent spawn via federation

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

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

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

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

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

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

Refs #93

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

View File

@@ -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