Files
stack/apps/orchestrator/src/spawner/docker-sandbox.service.ts
Jason Woltje 5d348526de feat(#71): implement graph data API
Implemented three new API endpoints for knowledge graph visualization:

1. GET /api/knowledge/graph - Full knowledge graph
   - Returns all entries and links with optional filtering
   - Supports filtering by tags, status, and node count limit
   - Includes orphan detection (entries with no links)

2. GET /api/knowledge/graph/stats - Graph statistics
   - Total entries and links counts
   - Orphan entries detection
   - Average links per entry
   - Top 10 most connected entries
   - Tag distribution across entries

3. GET /api/knowledge/graph/:slug - Entry-centered subgraph
   - Returns graph centered on specific entry
   - Supports depth parameter (1-5) for traversal distance
   - Includes all connected nodes up to specified depth

New Files:
- apps/api/src/knowledge/graph.controller.ts
- apps/api/src/knowledge/graph.controller.spec.ts

Modified Files:
- apps/api/src/knowledge/dto/graph-query.dto.ts (added GraphFilterDto)
- apps/api/src/knowledge/entities/graph.entity.ts (extended with new types)
- apps/api/src/knowledge/services/graph.service.ts (added new methods)
- apps/api/src/knowledge/services/graph.service.spec.ts (added tests)
- apps/api/src/knowledge/knowledge.module.ts (registered controller)
- apps/api/src/knowledge/dto/index.ts (exported new DTOs)
- docs/scratchpads/71-graph-data-api.md (implementation notes)

Test Coverage: 21 tests (all passing)
- 14 service tests including orphan detection, filtering, statistics
- 7 controller tests for all three endpoints

Follows TDD principles with tests written before implementation.
All code quality gates passed (lint, typecheck, tests).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 15:27:00 -06:00

255 lines
7.8 KiB
TypeScript

import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Docker from "dockerode";
import {
DockerSandboxOptions,
ContainerCreateResult,
} from "./types/docker-sandbox.types";
/**
* Service for managing Docker container isolation for agents
* Provides secure sandboxing with resource limits and cleanup
*/
@Injectable()
export class DockerSandboxService {
private readonly logger = new Logger(DockerSandboxService.name);
private readonly docker: Docker;
private readonly sandboxEnabled: boolean;
private readonly defaultImage: string;
private readonly defaultMemoryMB: number;
private readonly defaultCpuLimit: number;
private readonly defaultNetworkMode: string;
constructor(
private readonly configService: ConfigService,
docker?: Docker
) {
const socketPath = this.configService.get<string>(
"orchestrator.docker.socketPath",
"/var/run/docker.sock"
);
this.docker = docker ?? new Docker({ socketPath });
this.sandboxEnabled = this.configService.get<boolean>(
"orchestrator.sandbox.enabled",
false
);
this.defaultImage = this.configService.get<string>(
"orchestrator.sandbox.defaultImage",
"node:20-alpine"
);
this.defaultMemoryMB = this.configService.get<number>(
"orchestrator.sandbox.defaultMemoryMB",
512
);
this.defaultCpuLimit = this.configService.get<number>(
"orchestrator.sandbox.defaultCpuLimit",
1.0
);
this.defaultNetworkMode = this.configService.get<string>(
"orchestrator.sandbox.networkMode",
"bridge"
);
this.logger.log(
`DockerSandboxService initialized (enabled: ${this.sandboxEnabled}, socket: ${socketPath})`
);
}
/**
* Create a Docker container for agent isolation
* @param agentId Unique agent identifier
* @param taskId Task identifier
* @param workspacePath Path to workspace directory to mount
* @param options Optional container configuration
* @returns Container creation result
*/
async createContainer(
agentId: string,
taskId: string,
workspacePath: string,
options?: DockerSandboxOptions
): Promise<ContainerCreateResult> {
try {
const image = options?.image ?? this.defaultImage;
const memoryMB = options?.memoryMB ?? this.defaultMemoryMB;
const cpuLimit = options?.cpuLimit ?? this.defaultCpuLimit;
const networkMode = options?.networkMode ?? this.defaultNetworkMode;
// Convert memory from MB to bytes
const memoryBytes = memoryMB * 1024 * 1024;
// Convert CPU limit to NanoCPUs (1.0 = 1,000,000,000 nanocpus)
const nanoCpus = Math.floor(cpuLimit * 1000000000);
// Build environment variables
const env = [
`AGENT_ID=${agentId}`,
`TASK_ID=${taskId}`,
];
if (options?.env) {
Object.entries(options.env).forEach(([key, value]) => {
env.push(`${key}=${value}`);
});
}
// Container name with timestamp to ensure uniqueness
const containerName = `mosaic-agent-${agentId}-${Date.now()}`;
this.logger.log(
`Creating container for agent ${agentId} (image: ${image}, memory: ${memoryMB}MB, cpu: ${cpuLimit})`
);
const container = await this.docker.createContainer({
Image: image,
name: containerName,
User: "node:node", // Non-root user for security
HostConfig: {
Memory: memoryBytes,
NanoCpus: nanoCpus,
NetworkMode: networkMode,
Binds: [`${workspacePath}:/workspace`],
AutoRemove: false, // Manual cleanup for audit trail
ReadonlyRootfs: false, // Allow writes within container
},
WorkingDir: "/workspace",
Env: env,
});
const createdAt = new Date();
this.logger.log(
`Container created successfully: ${container.id} for agent ${agentId}`
);
return {
containerId: container.id,
agentId,
taskId,
createdAt,
};
} catch (error) {
this.logger.error(
`Failed to create container for agent ${agentId}: ${error instanceof Error ? error.message : String(error)}`
);
throw new Error(`Failed to create container for agent ${agentId}`);
}
}
/**
* Start a Docker container
* @param containerId Container ID to start
*/
async startContainer(containerId: string): Promise<void> {
try {
this.logger.log(`Starting container: ${containerId}`);
const container = this.docker.getContainer(containerId);
await container.start();
this.logger.log(`Container started successfully: ${containerId}`);
} catch (error) {
this.logger.error(
`Failed to start container ${containerId}: ${error instanceof Error ? error.message : String(error)}`
);
throw new Error(`Failed to start container ${containerId}`);
}
}
/**
* Stop a Docker container
* @param containerId Container ID to stop
* @param timeout Timeout in seconds (default: 10)
*/
async stopContainer(containerId: string, timeout = 10): Promise<void> {
try {
this.logger.log(`Stopping container: ${containerId} (timeout: ${timeout}s)`);
const container = this.docker.getContainer(containerId);
await container.stop({ t: timeout });
this.logger.log(`Container stopped successfully: ${containerId}`);
} catch (error) {
this.logger.error(
`Failed to stop container ${containerId}: ${error instanceof Error ? error.message : String(error)}`
);
throw new Error(`Failed to stop container ${containerId}`);
}
}
/**
* Remove a Docker container
* @param containerId Container ID to remove
*/
async removeContainer(containerId: string): Promise<void> {
try {
this.logger.log(`Removing container: ${containerId}`);
const container = this.docker.getContainer(containerId);
await container.remove({ force: true });
this.logger.log(`Container removed successfully: ${containerId}`);
} catch (error) {
this.logger.error(
`Failed to remove container ${containerId}: ${error instanceof Error ? error.message : String(error)}`
);
throw new Error(`Failed to remove container ${containerId}`);
}
}
/**
* Get container status
* @param containerId Container ID to inspect
* @returns Container status string
*/
async getContainerStatus(containerId: string): Promise<string> {
try {
const container = this.docker.getContainer(containerId);
const info = await container.inspect();
return info.State.Status;
} catch (error) {
this.logger.error(
`Failed to get container status for ${containerId}: ${error instanceof Error ? error.message : String(error)}`
);
throw new Error(`Failed to get container status for ${containerId}`);
}
}
/**
* Cleanup container (stop and remove)
* @param containerId Container ID to cleanup
*/
async cleanup(containerId: string): Promise<void> {
this.logger.log(`Cleaning up container: ${containerId}`);
try {
// Try to stop first
await this.stopContainer(containerId);
} catch (error) {
this.logger.warn(
`Failed to stop container ${containerId} during cleanup (may already be stopped): ${error instanceof Error ? error.message : String(error)}`
);
}
try {
// Always try to remove
await this.removeContainer(containerId);
} catch (error) {
this.logger.error(
`Failed to remove container ${containerId} during cleanup: ${error instanceof Error ? error.message : String(error)}`
);
throw new Error(`Failed to cleanup container ${containerId}`);
}
this.logger.log(`Container cleanup completed: ${containerId}`);
}
/**
* Check if sandbox mode is enabled
* @returns True if sandbox is enabled
*/
isEnabled(): boolean {
return this.sandboxEnabled;
}
}