feat(agents): restore agent-worker container architecture + fix chat scroll and parallel chats

- Restore agent-worker from commit 153399f: autonomous HTTP server per agent
  (main.go 597 lines, main_test.go 438 lines, Dockerfile.agent-worker)
- Add container fields to agents table (serviceName, servicePort, containerImage, containerStatus)
- Update executor.go: real delegateToAgent() with HTTP POST to agent containers
- Update db.go: GetAgentByID, UpdateContainerStatus, GetAgentHistory, SaveHistory
- Update orchestrator.go: inject DB into executor for container address resolution
- Add tRPC endpoints: agents.deployContainer, agents.stopContainer, agents.containerStatus
- Add Docker Swarm deploy/stop logic in server/agents.ts
- Add Start/Stop container buttons to Agents.tsx with status badges
- Fix chat auto-scroll: replace ScrollArea with overflow-y-auto for direct scrollTop control
- Fix parallel chats: make isThinking per-conversation (thinkingConvId) instead of global
  so switching between chats works while one is processing
This commit is contained in:
¨NW¨
2026-04-10 15:43:33 +01:00
parent 42a4f2d01d
commit 0f23dffc26
14 changed files with 2583 additions and 473 deletions

View File

@@ -1,12 +1,28 @@
import { eq, and, desc, gte } from "drizzle-orm";
import { agents, agentMetrics, agentHistory, agentAccessControl, type Agent, type InsertAgent, type AgentMetric, type InsertAgentMetric } from "../drizzle/schema";
import {
agents,
agentMetrics,
agentHistory,
agentAccessControl,
type Agent,
type InsertAgent,
type AgentMetric,
type InsertAgentMetric,
} from "../drizzle/schema";
import { getDb } from "./db";
import { nanoid } from "nanoid";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
/**
* Создать нового агента
*/
export async function createAgent(userId: number, data: InsertAgent): Promise<Agent | null> {
export async function createAgent(
userId: number,
data: InsertAgent
): Promise<Agent | null> {
const db = await getDb();
if (!db) return null;
@@ -17,7 +33,11 @@ export async function createAgent(userId: number, data: InsertAgent): Promise<Ag
});
const agentId = result[0].insertId;
const created = await db.select().from(agents).where(eq(agents.id, Number(agentId))).limit(1);
const created = await db
.select()
.from(agents)
.where(eq(agents.id, Number(agentId)))
.limit(1);
return created[0] || null;
} catch (error) {
console.error("[DB] Failed to create agent:", error);
@@ -33,7 +53,11 @@ export async function getAgentById(agentId: number): Promise<Agent | null> {
if (!db) return null;
try {
const result = await db.select().from(agents).where(eq(agents.id, agentId)).limit(1);
const result = await db
.select()
.from(agents)
.where(eq(agents.id, agentId))
.limit(1);
return result[0] || null;
} catch (error) {
console.error("[DB] Failed to get agent:", error);
@@ -80,7 +104,11 @@ export async function getSystemAgents(): Promise<Agent[]> {
if (!db) return [];
try {
return await db.select().from(agents).where(eq(agents.isSystem, true)).orderBy(agents.id);
return await db
.select()
.from(agents)
.where(eq(agents.isSystem, true))
.orderBy(agents.id);
} catch (error) {
console.error("[DB] Failed to get system agents:", error);
return [];
@@ -90,7 +118,10 @@ export async function getSystemAgents(): Promise<Agent[]> {
/**
* Обновить конфигурацию агента
*/
export async function updateAgent(agentId: number, updates: Partial<InsertAgent>): Promise<Agent | null> {
export async function updateAgent(
agentId: number,
updates: Partial<InsertAgent>
): Promise<Agent | null> {
const db = await getDb();
if (!db) return null;
@@ -122,7 +153,10 @@ export async function deleteAgent(agentId: number): Promise<boolean> {
/**
* Сохранить метрику запроса
*/
export async function saveMetric(agentId: number, data: Omit<InsertAgentMetric, "agentId">): Promise<AgentMetric | null> {
export async function saveMetric(
agentId: number,
data: Omit<InsertAgentMetric, "agentId">
): Promise<AgentMetric | null> {
const db = await getDb();
if (!db) return null;
@@ -135,7 +169,11 @@ export async function saveMetric(agentId: number, data: Omit<InsertAgentMetric,
});
const metricId = result[0].insertId;
const created = await db.select().from(agentMetrics).where(eq(agentMetrics.id, Number(metricId))).limit(1);
const created = await db
.select()
.from(agentMetrics)
.where(eq(agentMetrics.id, Number(metricId)))
.limit(1);
return created[0] || null;
} catch (error) {
console.error("[DB] Failed to save metric:", error);
@@ -146,7 +184,10 @@ export async function saveMetric(agentId: number, data: Omit<InsertAgentMetric,
/**
* Получить метрики агента за последние N часов
*/
export async function getAgentMetrics(agentId: number, hoursBack: number = 24): Promise<AgentMetric[]> {
export async function getAgentMetrics(
agentId: number,
hoursBack: number = 24
): Promise<AgentMetric[]> {
const db = await getDb();
if (!db) return [];
@@ -155,7 +196,12 @@ export async function getAgentMetrics(agentId: number, hoursBack: number = 24):
return await db
.select()
.from(agentMetrics)
.where(and(eq(agentMetrics.agentId, agentId), gte(agentMetrics.createdAt, since)))
.where(
and(
eq(agentMetrics.agentId, agentId),
gte(agentMetrics.createdAt, since)
)
)
.orderBy(desc(agentMetrics.createdAt));
} catch (error) {
console.error("[DB] Failed to get agent metrics:", error);
@@ -174,17 +220,26 @@ export async function getAgentStats(agentId: number, hoursBack: number = 24) {
const metrics = await getAgentMetrics(agentId, hoursBack);
const totalRequests = metrics.length;
const successRequests = metrics.filter((m) => m.status === "success").length;
const errorRequests = metrics.filter((m) => m.status === "error").length;
const avgProcessingTime = metrics.length > 0 ? metrics.reduce((sum, m) => sum + m.processingTimeMs, 0) / metrics.length : 0;
const totalTokens = metrics.reduce((sum, m) => sum + (m.totalTokens || 0), 0);
const avgTokensPerRequest = metrics.length > 0 ? totalTokens / metrics.length : 0;
const successRequests = metrics.filter(m => m.status === "success").length;
const errorRequests = metrics.filter(m => m.status === "error").length;
const avgProcessingTime =
metrics.length > 0
? metrics.reduce((sum, m) => sum + m.processingTimeMs, 0) /
metrics.length
: 0;
const totalTokens = metrics.reduce(
(sum, m) => sum + (m.totalTokens || 0),
0
);
const avgTokensPerRequest =
metrics.length > 0 ? totalTokens / metrics.length : 0;
return {
totalRequests,
successRequests,
errorRequests,
successRate: totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0,
successRate:
totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0,
avgProcessingTime: Math.round(avgProcessingTime),
totalTokens,
avgTokensPerRequest: Math.round(avgTokensPerRequest),
@@ -224,7 +279,10 @@ export async function getAgentAccessControl(agentId: number) {
if (!db) return [];
try {
return await db.select().from(agentAccessControl).where(eq(agentAccessControl.agentId, agentId));
return await db
.select()
.from(agentAccessControl)
.where(eq(agentAccessControl.agentId, agentId));
} catch (error) {
console.error("[DB] Failed to get agent access control:", error);
return [];
@@ -234,7 +292,11 @@ export async function getAgentAccessControl(agentId: number) {
/**
* Обновить управление доступами для инструмента
*/
export async function updateToolAccess(agentId: number, tool: string, updates: Partial<typeof agentAccessControl.$inferInsert>) {
export async function updateToolAccess(
agentId: number,
tool: string,
updates: Partial<typeof agentAccessControl.$inferInsert>
) {
const db = await getDb();
if (!db) return null;
@@ -242,14 +304,24 @@ export async function updateToolAccess(agentId: number, tool: string, updates: P
const existing = await db
.select()
.from(agentAccessControl)
.where(and(eq(agentAccessControl.agentId, agentId), eq(agentAccessControl.tool, tool)))
.where(
and(
eq(agentAccessControl.agentId, agentId),
eq(agentAccessControl.tool, tool)
)
)
.limit(1);
if (existing.length > 0) {
await db
.update(agentAccessControl)
.set(updates)
.where(and(eq(agentAccessControl.agentId, agentId), eq(agentAccessControl.tool, tool)));
.where(
and(
eq(agentAccessControl.agentId, agentId),
eq(agentAccessControl.tool, tool)
)
);
} else {
await db.insert(agentAccessControl).values({
agentId,
@@ -261,7 +333,12 @@ export async function updateToolAccess(agentId: number, tool: string, updates: P
const result = await db
.select()
.from(agentAccessControl)
.where(and(eq(agentAccessControl.agentId, agentId), eq(agentAccessControl.tool, tool)))
.where(
and(
eq(agentAccessControl.agentId, agentId),
eq(agentAccessControl.tool, tool)
)
)
.limit(1);
return result[0] || null;
@@ -300,3 +377,204 @@ export async function saveHistory(
return null;
}
}
// ─── Container Management (Docker Swarm) ────────────────────────────────────
/**
* Deploy an agent as a Docker Swarm service.
* Each agent runs in its own container with the agent-worker binary.
*/
export async function deployAgentContainer(
agentId: number
): Promise<{
success: boolean;
serviceName: string;
servicePort: number;
error?: string;
}> {
const db = await getDb();
if (!db)
return {
success: false,
serviceName: "",
servicePort: 0,
error: "DB not available",
};
const agent = await getAgentById(agentId);
if (!agent)
return {
success: false,
serviceName: "",
servicePort: 0,
error: "Agent not found",
};
const serviceName = `goclaw-agent-${agentId}`;
const servicePort = 8001 + ((agentId - 1) % 999); // Ports 8001-8999
const containerImage =
(agent as any).containerImage || "goclaw-agent-worker:latest";
try {
// Update status to deploying
await db
.update(agents)
.set({
containerStatus: "deploying",
serviceName,
servicePort,
} as any)
.where(eq(agents.id, agentId));
// Build Docker Swarm service create command
const dbUrl =
process.env.DATABASE_URL || "mysql://goclaw:goclaw123@db:3306/goclaw";
const llmBaseUrl = process.env.LLM_BASE_URL || "https://api.openai.com/v1";
const llmApiKey = process.env.LLM_API_KEY || "";
const cmd = [
"docker service create",
`--name ${serviceName}`,
`--env AGENT_ID=${agentId}`,
`--env AGENT_PORT=${servicePort}`,
`--env DATABASE_URL="${dbUrl}"`,
`--env LLM_BASE_URL="${llmBaseUrl}"`,
`--env LLM_API_KEY="${llmApiKey}"`,
`--network goclaw-overlay`,
`--replicas 1`,
containerImage,
].join(" \\\n ");
console.log(
`[Container] Deploying agent ${agentId}: ${serviceName} on port ${servicePort}`
);
const { stdout, stderr } = await execAsync(cmd, { timeout: 30000 });
// Update status to running
await db
.update(agents)
.set({
containerStatus: "running",
serviceName,
servicePort,
} as any)
.where(eq(agents.id, agentId));
console.log(`[Container] Agent ${agentId} deployed: ${serviceName}`);
return { success: true, serviceName, servicePort };
} catch (error: any) {
console.error(
`[Container] Failed to deploy agent ${agentId}:`,
error.message
);
// Update status to error
await db
.update(agents)
.set({
containerStatus: "error",
} as any)
.where(eq(agents.id, agentId));
return {
success: false,
serviceName,
servicePort: 0,
error: error.message,
};
}
}
/**
* Stop and remove an agent's Docker Swarm service.
*/
export async function stopAgentContainer(
agentId: number
): Promise<{ success: boolean; error?: string }> {
const db = await getDb();
if (!db) return { success: false, error: "DB not available" };
const agent = await getAgentById(agentId);
if (!agent) return { success: false, error: "Agent not found" };
const serviceName = (agent as any).serviceName || `goclaw-agent-${agentId}`;
try {
// Remove the Docker Swarm service
console.log(`[Container] Stopping agent ${agentId}: ${serviceName}`);
await execAsync(`docker service rm ${serviceName}`, {
timeout: 15000,
}).catch(() => {
// Service may not exist — that's OK
console.log(
`[Container] Service ${serviceName} not found (already removed)`
);
});
// Update status to stopped
await db
.update(agents)
.set({
containerStatus: "stopped",
serviceName: null,
servicePort: null,
} as any)
.where(eq(agents.id, agentId));
console.log(`[Container] Agent ${agentId} stopped: ${serviceName}`);
return { success: true };
} catch (error: any) {
console.error(
`[Container] Failed to stop agent ${agentId}:`,
error.message
);
// Still mark as stopped in DB even if Docker failed
await db
.update(agents)
.set({
containerStatus: "stopped",
} as any)
.where(eq(agents.id, agentId));
return { success: false, error: error.message };
}
}
/**
* Get the status of an agent's container.
* Checks Docker Swarm service and returns current state.
*/
export async function getAgentContainerStatus(
agentId: number
): Promise<{ status: string; serviceName?: string; servicePort?: number }> {
const db = await getDb();
if (!db) return { status: "unknown" };
const agent = await getAgentById(agentId);
if (!agent) return { status: "unknown" };
const containerStatus = (agent as any).containerStatus || "stopped";
const serviceName = (agent as any).serviceName || "";
const servicePort = (agent as any).servicePort || 0;
// Verify actual Docker Swarm state
if (serviceName) {
try {
const { stdout } = await execAsync(
`docker service inspect ${serviceName} --format '{{.Spec.Mode.Replicated.Replicas}}'`,
{ timeout: 5000 }
);
const replicas = parseInt(stdout.trim());
if (replicas > 0) {
return { status: "running", serviceName, servicePort };
}
} catch {
// Service doesn't exist
return { status: "stopped" };
}
}
return { status: containerStatus, serviceName, servicePort };
}