diff --git a/package-lock.json b/package-lock.json index c9cb7d2..d07026e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13185,7 +13185,7 @@ }, "packages/cli": { "name": "@letra-ai/cli", - "version": "0.6.1", + "version": "0.6.2", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "chalk": "^5.4.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index ed704fe..52eafce 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@letra-ai/cli", - "version": "0.6.1", + "version": "0.6.2", "type": "module", "bin": { "letra": "dist/index.js" diff --git a/packages/cli/src/adapters/builder.ts b/packages/cli/src/adapters/builder.ts index 27af32c..238078d 100644 --- a/packages/cli/src/adapters/builder.ts +++ b/packages/cli/src/adapters/builder.ts @@ -21,6 +21,7 @@ import { loadHarness, resolveHarnessRoot, DEFAULT_HARNESS_VERSION } from "../har import { loadWorkflow } from "../commands/flow-init.js"; import { getLetraDir } from "./../workspace/resolver.js"; import { queryLog } from "../session-log.js"; +import { resolveAgentDirection } from "../agent-direction/service.js"; function countACs( stateDir: string, @@ -142,6 +143,7 @@ export function buildHarnessSnapshot(root: string, options: GenerateOptions): Ha if (!options.workflow || !options.activeStageId) { return { + direction: resolveAgentDirection(root), workflowName: "letra", hasWorkflow: false, items: [], @@ -310,6 +312,7 @@ export function buildHarnessSnapshot(root: string, options: GenerateOptions): Ha } return { + direction: resolveAgentDirection(root), workflowName: workflow.name, hasWorkflow: true, activeStage: stage diff --git a/packages/cli/src/adapters/generate.ts b/packages/cli/src/adapters/generate.ts index 1f7f3b1..079e057 100644 --- a/packages/cli/src/adapters/generate.ts +++ b/packages/cli/src/adapters/generate.ts @@ -16,6 +16,8 @@ import { supportedAdapterTools, } from "./registry.js"; import type { AdapterSource, GenerateOptions } from "./types.js"; +import { loadAgents } from "../agents/service.js"; +import { buildSkillInstructions } from "./skill-bridge.js"; const ADAPTER_HEADER: Record = { init: "# Generated by letra init. Do not edit manually.\n", @@ -54,11 +56,15 @@ export function renderAdapterFiles( } const files: RenderedAdapterFile[] = []; + const agents = loadAgents(root).agents; for (const artifact of instructionArtifactsForAdapters(tools)) { let content = formatAdapterContent(snapshot, artifact.format, { source: options.source, displayName: artifact.displayName, }); + if (agents.length) { + content += `\n\n## Letra agent identities (registry v1)\n${agents.map((agent) => buildSkillInstructions(agent, artifact.tool)).join("\n\n")}`; + } if (artifact.id === "agents-md-shared" && tools.includes("codex")) { content = appendCodexLiveContextInstructions(content); } diff --git a/packages/cli/src/adapters/skill-bridge.ts b/packages/cli/src/adapters/skill-bridge.ts new file mode 100644 index 0000000..abb3459 --- /dev/null +++ b/packages/cli/src/adapters/skill-bridge.ts @@ -0,0 +1,15 @@ +import type { AgentIdentity } from "@letra/types"; + +/** Converts structured skills into deterministic adapter instructions. */ +export function buildSkillInstructions(agent: AgentIdentity, adapter = "generic"): string { + const lines = [`# Agent ${agent.displayName}`, `Role: ${agent.role}`, `Adapter: ${adapter}`]; + if (agent.bio) lines.push(`Purpose: ${agent.bio}`); + if (agent.skills.length) { + lines.push("Skills:"); + for (const skill of agent.skills) lines.push(`- ${skill.label} (${skill.level}${skill.category ? `, ${skill.category}` : ""})`); + } + const hint = agent.adapterHints?.[adapter] ?? agent.adapterHints?.generic; + if (hint) lines.push("Instructions:", hint); + return lines.join("\n"); +} +export const skillBridge = buildSkillInstructions; diff --git a/packages/cli/src/adapters/types.ts b/packages/cli/src/adapters/types.ts index 94121da..e7b51a1 100644 --- a/packages/cli/src/adapters/types.ts +++ b/packages/cli/src/adapters/types.ts @@ -1,3 +1,5 @@ +import type { AgentDirectionSnapshot } from "@letra/types"; + export type AdapterSource = "init" | "flow-move" | "focus" | "flow-ac"; export interface HarnessDirectionCommand { @@ -49,6 +51,8 @@ export interface HandoffData { } export interface HarnessSnapshot { + /** Canonical versioned direction shared with CLI and MCP consumers. */ + direction?: AgentDirectionSnapshot; workflowName: string; hasWorkflow: boolean; activeStage?: { id: string; name: string }; diff --git a/packages/cli/src/agent-direction/service.ts b/packages/cli/src/agent-direction/service.ts index 04b065f..5f3bdee 100644 --- a/packages/cli/src/agent-direction/service.ts +++ b/packages/cli/src/agent-direction/service.ts @@ -37,24 +37,26 @@ function slug(value: string): string { } function findCurrentItem(workflow: Workflow, currentItemId?: string | null): Item | null { + const items = Array.isArray(workflow.items) ? workflow.items : []; + const stages = Array.isArray(workflow.stages) ? workflow.stages : []; if (currentItemId) { - const explicit = workflow.items.find((item) => item.id === currentItemId); + const explicit = items.find((item) => item.id === currentItemId); if (explicit) return explicit; } if (workflow.primaryItemId) { - const primary = workflow.items.find((item) => item.id === workflow.primaryItemId); + const primary = items.find((item) => item.id === workflow.primaryItemId); if (primary) return primary; } const doingStages = new Set( - workflow.stages + stages .filter( (stage, index) => stage.zone === "doing" || - (!stage.zone && index > 0 && index < workflow.stages.length - 1), + (!stage.zone && index > 0 && index < stages.length - 1), ) .map((stage) => stage.id), ); - return workflow.items.find((item) => doingStages.has(item.stage)) ?? null; + return items.find((item) => doingStages.has(item.stage)) ?? null; } function firstPendingAC(content: string | null): AgentDirectionSnapshot["pendingAC"] { @@ -199,6 +201,17 @@ export function createAgentDirectionSnapshot( description: item.description, stage: item.stage, spec: item.spec ?? null, + claimedBy: item.claimedBy ?? null, + claimedAt: item.claimedAt ?? null, + claimExpiresAt: item.claimExpiresAt ?? null, + claimExecutorId: item.claimExecutorId ?? null, + claimCapability: item.claimCapability ?? null, + claimRevision: item.claimRevision ?? null, + claimTtlMinutes: item.claimTtlMinutes ?? null, + activityStatus: item.activityStatus ?? null, + activityStartedAt: item.activityStartedAt ?? null, + lastHeartbeatAt: item.lastHeartbeatAt ?? null, + lastFailure: item.lastFailure ?? null, } : null, roleIds: stage ? [...stage.roleIds] : [], @@ -243,10 +256,12 @@ export function resolveAgentDirection(root: string): AgentDirectionSnapshot { const focus = readFocusFile(root); const workflow = resolution.workflow; const focusedItem = - workflow && focus?.itemId + workflow && focus?.itemId && Array.isArray(workflow.items) ? (workflow.items.find((item) => item.id === focus.itemId) ?? null) : null; - const selectedItem = focusedItem ?? (workflow ? findCurrentItem(workflow) : null); + const selectedItem = + focusedItem ?? + (workflow && Array.isArray(workflow.items) ? findCurrentItem(workflow) : null); const specName = selectedItem?.spec ?? focus?.specName ?? null; return createAgentDirectionSnapshot({ workspaceRoot: root, diff --git a/packages/cli/src/agents/service.test.ts b/packages/cli/src/agents/service.test.ts new file mode 100644 index 0000000..d720895 --- /dev/null +++ b/packages/cli/src/agents/service.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createAgent, listAgents, loadAgents, updateAgent } from "./service.js"; +import type { Workflow } from "@letra/types"; + +const workflow = { stages: [{ id: "code", name: "Code", order: 1, allow: ["implementer"] }] } as Workflow; +describe("agent registry", () => { + it("migrates role defaults and persists a versioned registry", () => { + const root = mkdtempSync(join(tmpdir(), "letra-agents-")); + writeFileSync(join(root, "workflow.json"), "{}"); + const registry = loadAgents(root, workflow); + expect(registry.version).toBe("1"); + expect(registry.agents[0].displayName).toBe("Implementador"); + expect(JSON.parse(readFileSync(join(root, "agents.json"), "utf8")).version).toBe("1"); + }); + it("supports CRUD", () => { + const root = mkdtempSync(join(tmpdir(), "letra-agents-")); + writeFileSync(join(root, "workflow.json"), "{}"); + const agent = { id: "x", displayName: "X", role: "reviewer", avatar: { type: "initials" as const, value: "X" }, color: "red", skills: [], status: "offline" as const, stageBindings: [] }; + createAgent(root, agent, workflow); updateAgent(root, "x", { bio: "Review" }, workflow); + expect(listAgents(root, workflow).find((item) => item.id === "x")?.bio).toBe("Review"); + }); +}); diff --git a/packages/cli/src/agents/service.ts b/packages/cli/src/agents/service.ts new file mode 100644 index 0000000..1588051 --- /dev/null +++ b/packages/cli/src/agents/service.ts @@ -0,0 +1,47 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { AgentIdentity, AgentRegistry, Workflow, ExternalProtocolActor, ExternalProtocolExecutor } from "@letra/types"; +import { getLetraDir, resolveWorkspaceRoot } from "../workspace/resolver.js"; + +const DEFAULTS: Record> = { + analyst: { displayName: "Analista", bio: "Analisa contexto e define direção.", avatar: { type: "emoji", value: "🔎" }, color: "oklch(0.72 0.14 220)", skills: [{ id: "analysis", label: "Análise", level: "expert", category: "process" }], status: "offline", stageBindings: ["design"], adapterHints: {} }, + implementer: { displayName: "Implementador", bio: "Transforma especificações em código.", avatar: { type: "emoji", value: "🛠️" }, color: "oklch(0.72 0.16 150)", skills: [{ id: "coding", label: "Implementação", level: "expert", category: "engineering" }], status: "offline", stageBindings: ["code"], adapterHints: {} }, + reviewer: { displayName: "Revisor", bio: "Confronta código, spec e evidências.", avatar: { type: "emoji", value: "🔍" }, color: "oklch(0.75 0.15 80)", skills: [{ id: "review", label: "Code review", level: "expert", category: "quality" }], status: "offline", stageBindings: ["review"], adapterHints: {} }, + security: { displayName: "Segurança", bio: "Avalia riscos e controles.", avatar: { type: "emoji", value: "🛡️" }, color: "oklch(0.68 0.18 25)", skills: [{ id: "security", label: "Segurança", level: "advanced", category: "quality" }], status: "offline", stageBindings: ["review"], adapterHints: {} }, +}; + +function pathFor(root: string): string { return join(getLetraDir(root), "agents.json"); } +function assertAgent(agent: AgentIdentity): AgentIdentity { + if (!agent || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(agent.id)) throw new Error("Agent id inválido"); + if (!agent.displayName?.trim() || !agent.role?.trim()) throw new Error("displayName e role são obrigatórios"); + if (!agent.avatar || !["emoji", "initials", "image"].includes(agent.avatar.type) || !agent.avatar.value?.trim()) throw new Error("avatar inválido"); + if (typeof agent.color !== "string" || !agent.color.trim()) throw new Error("color deve ser preenchida"); + if (!["online", "offline", "busy"].includes(agent.status)) throw new Error("status inválido"); + if (!Array.isArray(agent.skills) || agent.skills.some((s) => !s.id || !s.label || !["beginner", "intermediate", "advanced", "expert"].includes(s.level))) throw new Error("skills inválidas"); + if (!Array.isArray(agent.stageBindings) || (agent.adapterHints && typeof agent.adapterHints !== "object")) throw new Error("stageBindings ou adapterHints inválidos"); + return agent; +} +function defaults(workflow: Workflow): AgentIdentity[] { + const roles = new Set(workflow.stages.flatMap((s) => s.allow ?? [])); + return [...roles].map((role) => ({ id: role, role, ...(DEFAULTS[role] ?? { displayName: role, bio: "Agente do harness.", avatar: { type: "initials" as const, value: role.slice(0, 2).toUpperCase() }, color: "oklch(0.7 0.12 280)", skills: [], status: "offline" as const, stageBindings: workflow.stages.filter((s) => (s.allow ?? []).includes(role)).map((s) => s.id), adapterHints: {} }) })); +} +export function defaultAgentIdentities(workflow: Workflow): AgentIdentity[] { return defaults(workflow); } +export function loadAgents(root: string, workflow?: Workflow): AgentRegistry { + const file = pathFor(root); + if (existsSync(file)) { + try { const parsed = JSON.parse(readFileSync(file, "utf8")) as AgentRegistry; if (parsed?.version === "1" && Array.isArray(parsed.agents)) { const valid = parsed.agents.filter((a) => { try { assertAgent(a); return true; } catch { return false; } }); const known = new Set(valid.map((a) => a.role)); const merged = workflow ? [...valid, ...defaults(workflow).filter((a) => !known.has(a.role))] : valid; const registry = { ...parsed, agents: merged }; if (merged.length !== parsed.agents.length) saveAgents(root, registry); return registry; } } catch { /* migrate below */ } + } + const registry: AgentRegistry = { version: "1", updatedAt: new Date().toISOString(), agents: workflow ? defaults(workflow) : [] }; + if (workflow) saveAgents(root, registry); + return registry; +} +export function saveAgents(root: string, registry: AgentRegistry): void { const file = pathFor(root); mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, JSON.stringify({ ...registry, version: "1", updatedAt: new Date().toISOString() }, null, 2) + "\n"); } +export function listAgents(root: string, workflow?: Workflow): AgentIdentity[] { return loadAgents(root, workflow).agents; } +export function createAgent(root: string, agent: AgentIdentity, workflow?: Workflow): AgentIdentity { const r = loadAgents(root, workflow); assertAgent(agent); if (r.agents.some((a) => a.id === agent.id)) throw new Error(`Agent ${agent.id} already exists`); r.agents.push(agent); saveAgents(root, r); return agent; } +export function updateAgent(root: string, id: string, patch: Partial, workflow?: Workflow): AgentIdentity { const r = loadAgents(root, workflow); const i = r.agents.findIndex((a) => a.id === id); if (i < 0) throw new Error(`Agent ${id} not found`); const next = assertAgent({ ...r.agents[i], ...patch, avatar: patch.avatar ?? r.agents[i].avatar, skills: patch.skills ?? r.agents[i].skills, stageBindings: patch.stageBindings ?? r.agents[i].stageBindings, adapterHints: patch.adapterHints ?? r.agents[i].adapterHints, id }); r.agents[i] = next; saveAgents(root, r); return next; } +export function deleteAgent(root: string, id: string, workflow?: Workflow): void { const r = loadAgents(root, workflow); if (!r.agents.some((a) => a.id === id)) throw new Error(`Agent ${id} not found`); if (workflow?.items.some((item) => item.claimedBy === id)) throw new Error(`Agent ${id} possui claims ativos`); r.agents = r.agents.filter((a) => a.id !== id); saveAgents(root, r); } +export function agentsFor(root?: string, workflow?: Workflow): AgentIdentity[] { const resolved = resolveWorkspaceRoot(root); return listAgents(resolved.workspaceRoot, workflow); } +/** Canonical identity ↔ protocol mapping used by adapters and claims. */ +export function protocolIdentity(agent: AgentIdentity, toolId: string, toolVersion = "unknown"): { actor: ExternalProtocolActor; executor: ExternalProtocolExecutor } { + return { actor: { agentId: agent.id, displayName: agent.displayName, toolId, toolVersion }, executor: { id: toolId, capabilities: agent.skills.map((s) => s.id), status: agent.status, transport: "cli" } }; +} diff --git a/packages/cli/src/commands/flow-init.ts b/packages/cli/src/commands/flow-init.ts index 739940f..751b087 100644 --- a/packages/cli/src/commands/flow-init.ts +++ b/packages/cli/src/commands/flow-init.ts @@ -58,6 +58,15 @@ export interface Item { tasks?: Task[]; claimedBy?: string; claimedAt?: string; + claimExecutorId?: string; + claimCapability?: string; + claimRevision?: string; + claimExpiresAt?: string; + claimTtlMinutes?: number; + activityStatus?: "started" | "heartbeat" | "succeeded" | "failed"; + activityStartedAt?: string; + lastHeartbeatAt?: string; + lastFailure?: { code: string; message: string; recovery: string; at: string }; currentPhase?: string; handoff?: ItemHandoff; } diff --git a/packages/cli/src/commands/flow-serve.ts b/packages/cli/src/commands/flow-serve.ts index 9588c03..57fbecd 100644 --- a/packages/cli/src/commands/flow-serve.ts +++ b/packages/cli/src/commands/flow-serve.ts @@ -67,11 +67,18 @@ import { createWorkflowRoutes } from "../flow-serve/routes/workflow-routes.js"; import { createWorkspaceRoutes } from "../flow-serve/routes/workspace-routes.js"; import { createAdapterRoutes } from "../flow-serve/routes/adapter-routes.js"; import { createHandoffRoutes } from "../flow-serve/routes/handoff-routes.js"; +import { createAgentRoutes } from "../flow-serve/routes/agent-routes.js"; import { ClientAssets } from "../flow-serve/client-assets.js"; import { AutomationRuntime, type AutomationBinding } from "../flow-serve/automation-runtime.js"; import { Orchestrator } from "../orchestrator/orchestrator.js"; +import { PersistentDispatcher } from "../orchestrator/dispatcher.js"; +import { createSimulatedExecutor } from "../orchestrator/simulated-executor.js"; const DEFAULT_PORT = 3000; +export interface FlowServerOptions { + autopilot?: boolean; + dispatcherIntervalMs?: number; +} /** * Resolve the harness directory for `root`, preferring the workspace-local @@ -107,8 +114,11 @@ export class FlowServer { private resolution: WorkspaceResolution; private activeWorkspaceRoot: string; private activeDirectory: string | null = null; + private dispatcher: PersistentDispatcher | undefined; + private readonly options: FlowServerOptions; - constructor(root: string, port: number = DEFAULT_PORT) { + constructor(root: string, port: number = DEFAULT_PORT, options: FlowServerOptions = {}) { + this.options = options; this.clientAssets = new ClientAssets(root); this.port = port; this.resolution = resolveWorkspaceRoot(root); @@ -134,6 +144,7 @@ export class FlowServer { onHandoffEvent: (payload) => this.events.broadcastHandoff(payload), }); this.orchestrator.registerFromManifest(); + if (options.autopilot) this.dispatcher = this.createDispatcher(); this.router.register((context) => { if (context.path !== "/events") return false; this.events.handleSse(context.req, context.res); @@ -261,6 +272,58 @@ export class FlowServer { }, }), ); + this.router.register(createAgentRoutes({ loadWorkflow: (root) => this.loadWorkflow(root), broadcast: () => this.broadcast() })); + } + + private createDispatcher(): PersistentDispatcher { + const simulated = createSimulatedExecutor("letra-simulated"); + const manifest = this.orchestrator.getManifest(); + return new PersistentDispatcher( + { + loadWorkflow: () => { + const workflow = this.loadWorkflow(); + if (!workflow) throw new Error("No workflow found for dispatcher"); + return workflow; + }, + writeWorkflow: async (workflow) => { + writeWorkflow(this.activeWorkspaceRoot, { + workflow, + source: "orchestrator", + primaryItemId: workflow.primaryItemId, + skipSitrep: true, + }); + this.broadcast(); + }, + advance: () => undefined, + claim: (itemId, executorId, agentId) => + this.orchestrator.autoClaim(itemId, executorId, agentId).success, + }, + () => [simulated], + this.options.dispatcherIntervalMs ?? 30_000, + { + stageActors: (stageId) => { + const templateId = this.loadWorkflow()?.template ?? "flow-main"; + return manifest?.flows[templateId]?.stages.find((stage) => stage.id === stageId)?.agents ?? []; + }, + blocksHandoff: (stage, item) => { + const gate = stage.gate ? manifest?.gates[stage.gate] : undefined; + // A human gate protects entry into the next role. The role that + // owns the current stage must still be allowed to execute and + // produce the evidence presented at that gate. + const currentActor = (manifest?.flows[this.loadWorkflow()?.template ?? "flow-main"]?.stages.find((entry) => entry.id === stage.id)?.agents ?? (stage as typeof stage & { agents?: string[] }).agents)?.[0]; + return gate?.type === "human" && gate.blocksHandoff === true && item.handoff?.to !== currentActor; + }, + onResult: (result) => { + if (result.status === "dispatched" || result.status === "failed") { + logEntry(this.activeWorkspaceRoot, "agent_execution_event", `Autonomous dispatcher: ${result.status}`, { + itemId: result.itemId, + details: { status: result.status, reason: result.reason ?? null }, + }); + } + this.broadcast(); + }, + }, + ); } switchWorkspace(workspaceRoot: string) { @@ -276,6 +339,11 @@ export class FlowServer { onHandoffEvent: (payload) => this.events.broadcastHandoff(payload), }); this.orchestrator.registerFromManifest(); + if (this.options.autopilot) { + this.dispatcher?.stop(); + this.dispatcher = this.createDispatcher(); + this.dispatcher.start(); + } this.orchestrator.startReclaimTimer(); this.broadcast(); } @@ -373,6 +441,7 @@ export class FlowServer { this.server.listen(this.port, () => { this.automationRuntime.start(this.automationBinding()); this.orchestrator.startReclaimTimer(); + this.dispatcher?.start(); resolve(); }); this.server.on("error", reject); @@ -381,6 +450,7 @@ export class FlowServer { stop(): void { this.automationRuntime.stop(); + this.dispatcher?.stop(); this.orchestrator.stopReclaimTimer(); if (this.server) this.server.close(); this.events.close(); @@ -393,7 +463,7 @@ export class FlowServer { export async function flowServeAction( targetPath: string | undefined, - options?: { port?: number; open?: boolean }, + options?: { port?: number; open?: boolean; autopilot?: boolean }, ): Promise { const root = resolve(process.cwd(), targetPath ?? "."); const port = options?.port ?? DEFAULT_PORT; @@ -404,7 +474,7 @@ export async function flowServeAction( return; } - const server = new FlowServer(root, port); + const server = new FlowServer(root, port, { autopilot: options?.autopilot }); try { await server.start(); console.log(`\n Flow Board → http://localhost:${port}\n`); diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts index b5eea8f..1fe5f32 100644 --- a/packages/cli/src/commands/flow.ts +++ b/packages/cli/src/commands/flow.ts @@ -106,11 +106,13 @@ export default function flowCommand() { cmd.command("serve") .option("--port ", "Port to listen on", "3000") .option("--open", "Open browser automatically") + .option("--autopilot", "Enable deterministic semiautonomous dispatcher (human gates still block)") .description("Start local web server with live board") - .action((options: { port?: string; open?: boolean }) => { + .action((options: { port?: string; open?: boolean; autopilot?: boolean }) => { flowServeAction(undefined, { port: options.port ? Number(options.port) : undefined, open: options.open, + autopilot: options.autopilot, }); }); diff --git a/packages/cli/src/commands/operation.ts b/packages/cli/src/commands/operation.ts index d507aca..a9859ee 100644 --- a/packages/cli/src/commands/operation.ts +++ b/packages/cli/src/commands/operation.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; import { Command, Option } from "commander"; +import { activityOperation } from "../domain-operations/service.js"; function collectEvidence(value: string, previous: string[]): string[] { return [...previous, value]; @@ -14,6 +15,34 @@ export default function operationCommand(): Command { "Execute controlled harness operations with structured JSON results", ); + command + .command("event ") + .requiredOption("--status ", "started, heartbeat, succeeded ou failed") + .requiredOption("--executor ", "Executor externo") + .requiredOption("--expected-revision ", "Direction revision returned by Letra") + .requiredOption("--reason ", "Reason for the operation") + .option("--actor ", "Identidade do actor", "agent:codex") + .option("--message ", "Mensagem do evento") + .option("--recovery ", "retry, release, handoff ou human") + .option("--error-code ", "Código da falha") + .action(async (itemId: string, options: { status: "started" | "heartbeat" | "succeeded" | "failed"; executor: string; expectedRevision: string; reason: string; actor: string; message?: string; recovery?: "retry" | "release" | "handoff" | "human"; errorCode?: string }) => { + const { recordExecutionEvent } = await import("../domain-operations/service.js"); + printJson(await recordExecutionEvent(resolve(process.cwd()), { itemId, status: options.status, executorId: options.executor, expectedRevision: options.expectedRevision, reason: options.reason, actor: options.actor, message: options.message, recovery: options.recovery, errorCode: options.errorCode })); + }); + + command + .command("claim ") + .requiredOption("--executor ", "Executor externo") + .requiredOption("--capability ", "Capability usada") + .requiredOption("--expected-revision ", "Direction revision returned by Letra") + .requiredOption("--reason ", "Reason for the operation") + .option("--actor ", "Identidade do actor", "agent:codex") + .option("--ttl ", "TTL do claim em minutos", (value) => Number(value), 30) + .action(async (itemId: string, options: { executor: string; capability: string; expectedRevision: string; reason: string; actor: string; ttl: number }) => { + const { claimOperation } = await import("../domain-operations/service.js"); + printJson(await claimOperation(resolve(process.cwd()), { itemId, executorId: options.executor, capability: options.capability, expectedRevision: options.expectedRevision, reason: options.reason, actor: options.actor, ttlMinutes: options.ttl })); + }); + command .command("validate") .requiredOption("--expected-revision ", "Direction revision returned by Letra") @@ -22,6 +51,30 @@ export default function operationCommand(): Command { const { runValidationOperation } = await import("../domain-operations/service.js"); printJson(await runValidationOperation(resolve(process.cwd()), options)); }); + command.command("activity [item-id]").action((itemId?: string) => printJson(activityOperation(resolve(process.cwd()), itemId))); + command.command("evidence ") + .requiredOption("--executor ", "Executor externo") + .requiredOption("--expected-revision ", "Direction revision") + .requiredOption("--reason ", "Reason") + .requiredOption("--kind ", "diff, file, command, test ou artifact") + .requiredOption("--value ", "Valor/path") + .requiredOption("--source ", "Origem observada") + .option("--actor ", "Actor", "agent:codex") + .action(async (itemId: string, options: { executor: string; expectedRevision: string; reason: string; kind: "diff" | "file" | "command" | "test" | "artifact"; value: string; source: string; actor: string }) => { + const { submitEvidenceOperation } = await import("../domain-operations/service.js"); + printJson(await submitEvidenceOperation(resolve(process.cwd()), { itemId, executorId: options.executor, expectedRevision: options.expectedRevision, reason: options.reason, actor: options.actor, evidence: [{ kind: options.kind, value: options.value, source: options.source }] })); + }); + command.command("handoff ") + .requiredOption("--to ", "Destino") + .requiredOption("--executor ", "Executor") + .requiredOption("--summary ", "Resumo") + .requiredOption("--expected-revision ", "Direction revision") + .requiredOption("--reason ", "Reason") + .option("--actor ", "Actor", "agent:codex") + .action(async (itemId: string, options: { to: string; executor: string; summary: string; expectedRevision: string; reason: string; actor: string }) => { + const { requestHandoffOperation } = await import("../domain-operations/service.js"); + printJson(await requestHandoffOperation(resolve(process.cwd()), { itemId, to: options.to, executorId: options.executor, summary: options.summary, evidence: [], expectedRevision: options.expectedRevision, reason: options.reason, actor: options.actor })); + }); command .command("complete-ac ") diff --git a/packages/cli/src/domain-operations/service.ts b/packages/cli/src/domain-operations/service.ts index 0ed4ae0..e7b44bf 100644 --- a/packages/cli/src/domain-operations/service.ts +++ b/packages/cli/src/domain-operations/service.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, renameSync, writeFileSync, realpathSync, statSync } from "node:fs"; +import { createHash } from "node:crypto"; import { join, resolve } from "node:path"; import type { AgentDirectionSnapshot } from "@letra/types"; import { resolveAgentDirection } from "../agent-direction/service.js"; @@ -10,7 +11,7 @@ import { resolveActiveFlow } from "../flow-definition/resolve.js"; import { createWorkspaceBoundary } from "../security/workspace-boundary.js"; import { logEntry, type LogAction } from "../session-log.js"; import { GateChecker } from "../harness/gate-checker.js"; -import { getLetraDir } from "./../workspace/resolver.js"; +import { getLetraDir, resolveWorkspaceRoot } from "./../workspace/resolver.js"; export type OperationOutcome = "accepted" | "rejected" | "approval-required"; @@ -41,6 +42,66 @@ export interface RequestTransitionInput extends OperationContext { targetStageId: string; } +export interface ClaimOperationInput extends OperationContext { + itemId: string; + executorId: string; + capability: string; + ttlMinutes?: number; +} + +export interface ExecutionEventInput extends OperationContext { + itemId: string; + executorId: string; + status: "started" | "heartbeat" | "succeeded" | "failed"; + message?: string; + recovery?: "retry" | "release" | "handoff" | "human"; + errorCode?: string; +} + +export interface EvidenceInput extends OperationContext { + itemId: string; + executorId: string; + evidence: Array<{ kind: "diff" | "file" | "command" | "test" | "artifact"; value: string; source: string; observedAt?: string; sha256?: string; exitCode?: number }>; +} +export interface HandoffInput extends OperationContext { + itemId: string; to: string; summary: string; evidence: string[]; executorId: string; ttlMinutes?: number; +} + +export function activityOperation(root: string, itemId?: string): AgentDirectionSnapshot["item"] { + const direction = resolveAgentDirection(createWorkspaceBoundary(resolve(root)).root); + return direction.item && (!itemId || direction.item.id === itemId) ? direction.item : null; +} + +export async function submitEvidenceOperation(root: string, input: EvidenceInput): Promise { + const workspaceRoot = createWorkspaceBoundary(resolve(root)).root; + const before = resolveAgentDirection(workspaceRoot); const subject = { itemId: input.itemId, operation: "submit_evidence" }; + const stale = checkRevision(workspaceRoot, before, input, subject); if (stale) return stale; + const item = loadWorkflow(workspaceRoot)?.items.find((candidate) => candidate.id === input.itemId); + if (!item || item.claimedBy !== input.actor || (item.claimExecutorId && item.claimExecutorId !== input.executorId)) return rejected(workspaceRoot, before, "CLAIM_REQUIRED", "A evidência exige claim vigente.", input, subject); + const boundary = createWorkspaceBoundary(resolveWorkspaceRoot(root).workspaceDir); + for (const evidence of input.evidence) { + if (!evidence.source?.trim() || !evidence.value?.trim()) return rejected(workspaceRoot, before, "EVIDENCE_INVALID", "Evidência exige origem e valor.", input, subject); + if (evidence.kind === "file" || evidence.kind === "diff" || evidence.kind === "artifact") { + try { const path = boundary.assertPath(evidence.value); if (existsSync(path) && statSync(path).isSymbolicLink()) throw new Error("symlink"); } + catch { return rejected(workspaceRoot, before, "EVIDENCE_PATH_OUTSIDE_WORKSPACE", "Path da evidência fora do workspace autorizado.", input, subject); } + } + } + const entry = audit(workspaceRoot, "agent_execution_event", before, { outcome: "accepted", reasonCode: "EVIDENCE_ACCEPTED", reason: input.reason, actor: input.actor, itemId: input.itemId, details: { evidence: input.evidence.map((e) => ({ ...e, observedAt: e.observedAt ?? new Date().toISOString(), sha256: e.sha256 ?? createHash("sha256").update(e.value).digest("hex") })) } }); + return result(before, entry.id, "accepted", "EVIDENCE_ACCEPTED", input.reason, resolveAgentDirection(workspaceRoot)); +} + +export async function requestHandoffOperation(root: string, input: HandoffInput): Promise { + const workspaceRoot = createWorkspaceBoundary(resolve(root)).root; const before = resolveAgentDirection(workspaceRoot); const subject = { itemId: input.itemId, operation: "request_handoff" }; + const stale = checkRevision(workspaceRoot, before, input, subject); if (stale) return stale; + const workflow = loadWorkflow(workspaceRoot); const item = workflow?.items.find((candidate) => candidate.id === input.itemId); + if (!workflow || !item || item.claimedBy !== input.actor || (item.claimExecutorId && item.claimExecutorId !== input.executorId)) return rejected(workspaceRoot, before, "CLAIM_REQUIRED", "Handoff exige claim vigente do executor.", input, subject); + if (item.handoff) return rejected(workspaceRoot, before, "HANDOFF_CONFLICT", "Já existe handoff pendente.", input, subject); + const now = new Date(); item.handoff = { from: input.actor ?? "unknown", to: input.to, summary: input.summary, evidence: input.evidence, timestamp: now.toISOString(), expiresAt: new Date(now.getTime() + (input.ttlMinutes ?? 30) * 60000).toISOString(), executorId: input.executorId }; + item.claimedBy = undefined; item.claimedAt = undefined; item.claimExecutorId = undefined; item.claimCapability = undefined; item.claimRevision = undefined; item.claimExpiresAt = undefined; item.claimTtlMinutes = undefined; workflow.updatedAt = now.toISOString(); + const write = await writeWorkflow(workspaceRoot, { workflow, source: "flow-handoff", primaryItemId: item.id, skipSitrep: true, skipLog: true, quiet: true, confineAdapterWrites: true }); if (!write.ok) return rejected(workspaceRoot, before, "HANDOFF_WRITE_FAILED", write.error ?? "Falha ao persistir handoff.", input, subject); + const entry = audit(workspaceRoot, "agent_execution_event", before, { outcome: "accepted", reasonCode: "HANDOFF_ACCEPTED", reason: input.reason, actor: input.actor, itemId: item.id, details: { to: input.to, executorId: input.executorId } }); return result(before, entry.id, "accepted", "HANDOFF_ACCEPTED", input.reason, resolveAgentDirection(workspaceRoot)); +} + function audit( root: string, action: LogAction, @@ -132,6 +193,95 @@ function normalizeAcId(value: string): string { return match ? `AC${match[1]}` : value.trim().toUpperCase(); } +export async function claimOperation( + root: string, + input: ClaimOperationInput, +): Promise { + const workspaceRoot = createWorkspaceBoundary(resolve(root)).root; + const before = resolveAgentDirection(workspaceRoot); + const subject = { itemId: input.itemId, operation: "claim" }; + const stale = checkRevision(workspaceRoot, before, input, subject); + if (stale) return stale; + if (!input.actor?.trim()) + return rejected(workspaceRoot, before, "ACTOR_REQUIRED", "Claim exige identidade do actor.", input, subject); + if (!before.item || before.item.id !== input.itemId) + return rejected(workspaceRoot, before, "ITEM_NOT_CURRENT", "O claim exige o item vigente.", input, subject); + const flow = resolveActiveFlow(workspaceRoot).flow; + const stage = flow?.stages.find((candidate) => candidate.id === before.item?.stage); + const capabilities = stage?.roles.flatMap((role) => role.capabilities) ?? []; + if (capabilities.length > 0 && !capabilities.includes(input.capability)) + return rejected(workspaceRoot, before, "CAPABILITY_INVALID", `Capability não permitida: ${input.capability}.`, input, subject); + const workflow = loadWorkflow(workspaceRoot); + const item = workflow?.items.find((candidate) => candidate.id === input.itemId); + if (!workflow || !item) return rejected(workspaceRoot, before, "ITEM_NOT_FOUND", "Item não encontrado.", input, subject); + const claimExpired = item.claimExpiresAt ? Date.now() >= Date.parse(item.claimExpiresAt) : false; + if (item.claimedBy && !claimExpired && (item.claimedBy !== input.actor || item.claimExecutorId !== input.executorId)) + return rejected(workspaceRoot, before, "CLAIM_CONFLICT", `Item já está sob responsabilidade de ${item.claimedBy}.`, input, subject); + const ttl = Math.max(1, Math.min(1440, input.ttlMinutes ?? 30)); + const now = new Date(); + item.claimedBy = input.actor.trim(); + item.claimedAt = now.toISOString(); + item.claimExecutorId = input.executorId.trim(); + item.claimCapability = input.capability.trim(); + item.claimRevision = before.revision; + item.claimTtlMinutes = ttl; + item.claimExpiresAt = new Date(now.getTime() + ttl * 60_000).toISOString(); + workflow.updatedAt = now.toISOString(); + const writeResult = await writeWorkflow(workspaceRoot, { + workflow, + source: "flow-claim", + primaryItemId: item.id, + skipSitrep: true, + skipLog: true, + quiet: true, + confineAdapterWrites: true, + }); + if (!writeResult.ok) + return rejected(workspaceRoot, before, "CLAIM_WRITE_FAILED", writeResult.error ?? "Falha ao persistir claim.", input, subject); + const after = resolveAgentDirection(workspaceRoot); + const entry = audit(workspaceRoot, "agent_claim_requested", before, { + outcome: "accepted", + reasonCode: "CLAIM_ACCEPTED", + reason: input.reason, + actor: input.actor, + itemId: item.id, + details: { executorId: input.executorId, capability: input.capability, ttlMinutes: ttl, expiresAt: item.claimExpiresAt }, + }); + return result(before, entry.id, "accepted", "CLAIM_ACCEPTED", input.reason, after); +} + +export async function recordExecutionEvent( + root: string, + input: ExecutionEventInput, +): Promise { + const workspaceRoot = createWorkspaceBoundary(resolve(root)).root; + const before = resolveAgentDirection(workspaceRoot); + const subject = { itemId: input.itemId, operation: input.status }; + const stale = checkRevision(workspaceRoot, before, input, subject); + if (stale) return stale; + if (!input.actor?.trim()) return rejected(workspaceRoot, before, "ACTOR_REQUIRED", "Evento exige identidade do actor.", input, subject); + if (!before.item || before.item.id !== input.itemId) return rejected(workspaceRoot, before, "ITEM_NOT_CURRENT", "Evento exige o item vigente.", input, subject); + const workflow = loadWorkflow(workspaceRoot); + const item = workflow?.items.find((candidate) => candidate.id === input.itemId); + if (!workflow || !item) return rejected(workspaceRoot, before, "ITEM_NOT_FOUND", "Item não encontrado.", input, subject); + const expired = !item.claimExpiresAt || Date.now() >= Date.parse(item.claimExpiresAt); + if (item.claimedBy !== input.actor || item.claimExecutorId !== input.executorId) return rejected(workspaceRoot, before, "CLAIM_REQUIRED", "Actor e executor precisam possuir o claim vigente.", input, subject); + if (expired) return rejected(workspaceRoot, before, "CLAIM_EXPIRED", "O lease do claim expirou; faça um novo claim.", input, subject); + const nowDate = new Date(); + const now = nowDate.toISOString(); + item.activityStatus = input.status; + if (input.status === "started") item.activityStartedAt = now; + if (input.status === "heartbeat" || input.status === "started") item.lastHeartbeatAt = now; + if (input.status === "heartbeat") item.claimExpiresAt = new Date(nowDate.getTime() + (item.claimTtlMinutes ?? 30) * 60_000).toISOString(); + if (input.status === "failed") item.lastFailure = { code: input.errorCode ?? "EXECUTION_FAILED", message: input.message ?? "Execução falhou.", recovery: input.recovery ?? "human", at: now }; + workflow.updatedAt = now; + const writeResult = await writeWorkflow(workspaceRoot, { workflow, source: "flow-claim", primaryItemId: item.id, skipSitrep: true, skipLog: true, quiet: true, confineAdapterWrites: true }); + if (!writeResult.ok) return rejected(workspaceRoot, before, "EVENT_WRITE_FAILED", writeResult.error ?? "Falha ao persistir evento.", input, subject); + const after = resolveAgentDirection(workspaceRoot); + const entry = audit(workspaceRoot, "agent_execution_event", before, { outcome: "accepted", reasonCode: "EVENT_RECORDED", reason: input.reason, actor: input.actor, itemId: item.id, details: { status: input.status, executorId: input.executorId, message: input.message, recovery: input.recovery, errorCode: input.errorCode } }); + return result(before, entry.id, "accepted", "EVENT_RECORDED", input.reason, after); +} + function markPendingAc(content: string, acId: string): string | null { const lines = content.split("\n"); const expected = normalizeAcId(acId); @@ -181,8 +331,8 @@ export async function runValidationOperation( } export function completeAcOperation(root: string, input: CompleteAcInput): OperationResult { - const boundary = createWorkspaceBoundary(resolve(root)); - const workspaceRoot = boundary.root; + const workspaceRoot = createWorkspaceBoundary(resolve(root)).root; + const boundary = createWorkspaceBoundary(resolveWorkspaceRoot(root).workspaceDir); const before = resolveAgentDirection(workspaceRoot); const subject = { itemId: before.item?.id, acId: input.acId, operation: "complete_ac" }; const stale = checkRevision(workspaceRoot, before, input, subject); diff --git a/packages/cli/src/flow-definition/resolve.ts b/packages/cli/src/flow-definition/resolve.ts index 513df43..e1ae22a 100644 --- a/packages/cli/src/flow-definition/resolve.ts +++ b/packages/cli/src/flow-definition/resolve.ts @@ -178,6 +178,7 @@ function mergeTemplateStage( roles: resolveRoles(harness, roleIds, warnings, artifactRef), agents: [...roleIds], gate: resolveGate(harness, stageDef.gate, warnings, artifactRef), + preferredExecutor: stageDef.preferredExecutor, phases: resolvePhases(harness, stageDef.phases, warnings, stageDef.id), activity: cloneActivity(stageDef.activity), provenance: "harness", @@ -198,6 +199,7 @@ function workflowStageDefinition( roles: [], agents: [], gate: null, + preferredExecutor: undefined, phases: resolvePhases(null, stage.phases, [], stage.id), activity: undefined, provenance, @@ -257,7 +259,7 @@ function resolveFromWorkflow( harnessVersion: workflow.harnessVersion ?? null, templateVersion: null, name: workflow.name, - stages: workflow.stages + stages: (Array.isArray(workflow.stages) ? workflow.stages : []) .map((stage) => workflowStageDefinition(stage as Stage)) .sort((left, right) => left.order - right.order), roles: [], diff --git a/packages/cli/src/flow-serve/flow-serve.test.ts b/packages/cli/src/flow-serve/flow-serve.test.ts index fab14f6..0fb214f 100644 --- a/packages/cli/src/flow-serve/flow-serve.test.ts +++ b/packages/cli/src/flow-serve/flow-serve.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { FlowServer } from "../commands/flow-serve.js"; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { loadWorkflow, saveWorkflow } from "../commands/flow-init.js"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -116,4 +117,38 @@ describe("FlowServer SSE + Orchestrator Integration", () => { }), ); }); + + it("wires the semiautonomous dispatcher only when autopilot is enabled", async () => { + const server = new FlowServer(root, 3003, { autopilot: true, dispatcherIntervalMs: 60_000 }); + const dispatcher = (server as any).dispatcher; + expect(dispatcher).toBeDefined(); + const startSpy = vi.spyOn(dispatcher, "start"); + await server.start(); + expect(startSpy).toHaveBeenCalledTimes(1); + server.stop(); + }); + + it("runs a handoff to the human gate and leaves an auditable pause", async () => { + const workflow = loadWorkflow(root)!; + workflow.items[0].handoff = { + from: "design", + to: "implementer", + summary: "Implement approved work", + evidence: ["spec-approved"], + timestamp: new Date().toISOString(), + expiresAt: new Date(Date.now() + 1_800_000).toISOString(), + }; + saveWorkflow(root, workflow); + const server = new FlowServer(root, 3004, { autopilot: true, dispatcherIntervalMs: 60_000 }); + await server.start(); + await new Promise((resolve) => setTimeout(resolve, 80)); + server.stop(); + const updated = loadWorkflow(root)!; + expect(updated.items[0].activityStatus).toBe("succeeded"); + expect(updated.items[0].handoff?.to).toBe("human"); + expect(updated.items[0].handoff?.evidence).toContain("simulated:implement:ITEM-1"); + const logFiles = readdirSync(join(root, ".letra", "session-log"), { recursive: true }) as string[]; + const logContent = logFiles.filter((file) => file.endsWith(".jsonl")).map((file) => readFileSync(join(root, ".letra", "session-log", file), "utf8")).join("\n"); + expect(logContent).toContain("Autonomous dispatcher: dispatched"); + }); }); diff --git a/packages/cli/src/flow-serve/routes/agent-routes.ts b/packages/cli/src/flow-serve/routes/agent-routes.ts new file mode 100644 index 0000000..dba5c1f --- /dev/null +++ b/packages/cli/src/flow-serve/routes/agent-routes.ts @@ -0,0 +1,25 @@ +import { readJson, sendError, sendJson } from "../http.js"; +import type { RouteHandler } from "../router.js"; +import { createAgent, deleteAgent, listAgents, updateAgent } from "../../agents/service.js"; +import type { AgentIdentity } from "@letra/types"; + +let agentMutation = false; +async function acquireAgentMutation(): Promise<() => void> { while (agentMutation) await new Promise((resolve) => setTimeout(resolve, 0)); agentMutation = true; return () => { agentMutation = false; }; } + +export function createAgentRoutes(dependencies: { loadWorkflow: (root: string) => any; broadcast?: () => void }): RouteHandler { + return async (context) => { + if (!context.path.startsWith("/api/agents")) return false; + const root = context.workspaceRoot; + try { + if (context.path === "/api/agents" && context.method === "GET") { sendJson(context.res, 200, listAgents(root, dependencies.loadWorkflow(root))); return true; } + if (context.path === "/api/agents" && context.method === "POST") { + const release = await acquireAgentMutation(); try { const agent = await readJson(context.req); const result = createAgent(root, agent, dependencies.loadWorkflow(root)); dependencies.broadcast?.(); sendJson(context.res, 201, result); return true; } finally { release(); } + } + const match = context.path.match(/^\/api\/agents\/([^/]+)$/); if (!match) return false; + const id = decodeURIComponent(match[1]); + if (context.method === "PATCH") { const release = await acquireAgentMutation(); try { const patch = await readJson>(context.req); const result = updateAgent(root, id, patch, dependencies.loadWorkflow(root)); dependencies.broadcast?.(); sendJson(context.res, 200, result); return true; } finally { release(); } } + if (context.method === "DELETE") { const release = await acquireAgentMutation(); try { deleteAgent(root, id, dependencies.loadWorkflow(root)); dependencies.broadcast?.(); sendJson(context.res, 200, { ok: true }); return true; } finally { release(); } } + return false; + } catch (error) { sendError(context.res, 400, (error as Error).message); return true; } + }; +} diff --git a/packages/cli/src/flow-serve/routes/item-routes.test.ts b/packages/cli/src/flow-serve/routes/item-routes.test.ts index 0494843..ee95a2a 100644 --- a/packages/cli/src/flow-serve/routes/item-routes.test.ts +++ b/packages/cli/src/flow-serve/routes/item-routes.test.ts @@ -62,6 +62,7 @@ function configureHumanGate( "request-changes": "previous", reject: "first", }, + gateStageId = "review", ) { vi.mocked(deps.resolveActiveFlow).mockReturnValue({ workflow: null, @@ -88,8 +89,8 @@ function configureHumanGate( provenance: "harness", }, { - id: "review", - name: "Review", + id: gateStageId, + name: gateStageId === "security" ? "Security" : "Review", order: 1, zone: "doing", roleIds: [], @@ -116,6 +117,17 @@ function configureHumanGate( gate: null, provenance: "harness", }, + { + id: "done", + name: "Done", + order: 3, + zone: "done", + roleIds: [], + roles: [], + agents: [], + gate: null, + provenance: "harness", + }, ], }, }); @@ -321,4 +333,23 @@ describe("item routes", () => { expect(res.writeHead).toHaveBeenCalledWith(422, { "Content-Type": "application/json" }); expect(res.end).toHaveBeenCalledWith(expect.stringContaining("decisão humana explícita")); }); + + it("resolves the final human-approved gate to Done or back to Code", async () => { + const { deps, writeWorkflow, logEntry } = dependencies(); + const value = workflowAtGate(); + value.stages = [ + { id: "security", name: "Security", order: 1, zone: "doing" }, + { id: "code", name: "Code", order: 2, zone: "doing" }, + { id: "done", name: "Done", order: 3, zone: "done" }, + ]; + value.items = [{ id: "ITEM-1", description: "", stage: "security", createdAt: "" }]; + configureHumanGate(deps, { approve: "done", "request-changes": "code", reject: "code" }, "security"); + const res = response(); + const context = createRequestContext(request("POST", '{"decision":"approve"}'), res, new URL("http://localhost/api/items/ITEM-1/gate-decisions"), { workspaceRoot: "C:\\workspace", workspaceDir: "C:\\workspace\\.letra", workflow: value }); + await createItemRoutes(deps)(context); + expect(value.items[0].stage).toBe("done"); + expect(value.items[0].handoff).toBeUndefined(); + expect(writeWorkflow).toHaveBeenCalled(); + expect(logEntry).toHaveBeenCalledWith("C:\\workspace", "decision", expect.any(String), expect.objectContaining({ itemId: "ITEM-1" })); + }); }); diff --git a/packages/cli/src/flow-serve/routes/item-routes.ts b/packages/cli/src/flow-serve/routes/item-routes.ts index 04aa0b7..5dece20 100644 --- a/packages/cli/src/flow-serve/routes/item-routes.ts +++ b/packages/cli/src/flow-serve/routes/item-routes.ts @@ -169,6 +169,23 @@ export function createItemRoutes(dependencies: ItemRouteDependencies): RouteHand const sourceStage = item.stage; item.stage = targetStage; + // A decision creates the next durable handoff. Without replacing the + // previous handoff, request-changes could leave the item addressed to + // the old role and the dispatcher would pause forever at the gate. + const targetStageDefinition = flow.stages.find((stage) => stage.id === targetStage); + const targetActor = targetStageDefinition?.agents[0]; + if (targetActor) { + item.handoff = { + from: "human:web-ui", + to: targetActor, + summary: `Gate ${gate.name} decision ${data.decision}; continue at ${targetStageDefinition?.name ?? targetStage}.`, + evidence: [`gate:${gate.id}:${data.decision}`], + timestamp: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(), + }; + } else { + item.handoff = undefined; + } workflow.updatedAt = new Date().toISOString(); await dependencies.writeWorkflow(workspaceRoot, { workflow, diff --git a/packages/cli/src/harness/default/v0.2.0/flows/flow-main.yaml b/packages/cli/src/harness/default/v0.2.0/flows/flow-main.yaml index 887817f..b7331c7 100644 --- a/packages/cli/src/harness/default/v0.2.0/flows/flow-main.yaml +++ b/packages/cli/src/harness/default/v0.2.0/flows/flow-main.yaml @@ -186,7 +186,9 @@ stages: description: Security agent analyzes vulnerabilities and dependencies. agents: - security - gate: security-clear + # Security checks run automatically; the final transition to Done is + # released only by the human-approved gate handled by the UI/API. + gate: human-approved preferredExecutor: opencode phases: initialState: scan diff --git a/packages/cli/src/harness/default/v0.2.0/gates/human-approved.yaml b/packages/cli/src/harness/default/v0.2.0/gates/human-approved.yaml index daf1a23..404cd5d 100644 --- a/packages/cli/src/harness/default/v0.2.0/gates/human-approved.yaml +++ b/packages/cli/src/harness/default/v0.2.0/gates/human-approved.yaml @@ -5,6 +5,6 @@ blocking: true blocksHandoff: true description: Final human approval before marking item as done. decisions: - approve: "All checks passed. Item is complete." - request-changes: "Needs more work. Return to implementer." - reject: "Item is not viable. Reject." + approve: done + request-changes: code + reject: backlog diff --git a/packages/cli/src/harness/default/v0.2.0/gates/spec-approved.yaml b/packages/cli/src/harness/default/v0.2.0/gates/spec-approved.yaml index 2cf4daf..6fa0619 100644 --- a/packages/cli/src/harness/default/v0.2.0/gates/spec-approved.yaml +++ b/packages/cli/src/harness/default/v0.2.0/gates/spec-approved.yaml @@ -5,6 +5,6 @@ blocking: true blocksHandoff: true description: Human approves the spec before implementation begins. decisions: - approve: "Spec is clear and complete. Proceed to implementation." - request-changes: "Spec needs revisions. Return to analyst." - reject: "Spec is not viable. Reject item." + approve: code + request-changes: design + reject: backlog diff --git a/packages/cli/src/mcp/server.test.ts b/packages/cli/src/mcp/server.test.ts index 4bab2f9..cabb6fe 100644 --- a/packages/cli/src/mcp/server.test.ts +++ b/packages/cli/src/mcp/server.test.ts @@ -82,19 +82,25 @@ describe("Letra MCP read-only server", () => { "validate", "complete_ac", "request_transition", + "get_context", + "get_activity", + "claim", + "execution_event", + "submit_evidence", + "request_handoff", "list_gates", "list_roles", ]); expect( tools.tools.filter((tool) => tool.annotations?.readOnlyHint === true).length, - ).toBe(5); + ).toBe(7); expect( tools.tools.filter((tool) => tool.annotations?.readOnlyHint === false).length, - ).toBe(3); + ).toBe(7); expect( tools.tools.filter((tool) => tool.inputSchema?.additionalProperties === false) .length, - ).toBe(3); + ).toBe(8); const direction = toolJson( await client.callTool({ name: "get_direction", arguments: {} }), diff --git a/packages/cli/src/mcp/server.ts b/packages/cli/src/mcp/server.ts index 8ec7818..5f06694 100644 --- a/packages/cli/src/mcp/server.ts +++ b/packages/cli/src/mcp/server.ts @@ -13,6 +13,11 @@ import { completeAcOperation, requestTransitionOperation, runValidationOperation, + claimOperation, + recordExecutionEvent, + activityOperation, + submitEvidenceOperation, + requestHandoffOperation, } from "../domain-operations/service.js"; import { logEntry } from "../session-log.js"; import { createWorkspaceBoundary, type WorkspaceBoundary } from "../security/workspace-boundary.js"; @@ -238,6 +243,12 @@ export function createLetraMcpServer(root: string): McpServer { }), ), ); + server.registerTool("get_context", { description: "Retorna contexto operacional canônico.", annotations: readOnlyAnnotations }, async () => jsonText(auditRead("context"))); + server.registerTool("get_activity", { description: "Retorna atividade operacional vigente.", inputSchema: { itemId: z.string().optional() }, annotations: readOnlyAnnotations }, async ({ itemId }) => jsonText(activityOperation(workspaceRoot, itemId))); + server.registerTool("claim", { description: "Solicita claim exclusivo.", inputSchema: { itemId: z.string(), executorId: z.string(), capability: z.string(), expectedRevision, reason, ttlMinutes: z.number().optional() }, annotations: mutationAnnotations }, async (input) => jsonText(await claimOperation(workspaceRoot, { ...input, actor: clientIdentity().actor }))); + server.registerTool("execution_event", { description: "Registra started, heartbeat, succeeded ou failed.", inputSchema: { itemId: z.string(), status: z.enum(["started", "heartbeat", "succeeded", "failed"]), executorId: z.string(), expectedRevision, reason, message: z.string().optional(), recovery: z.enum(["retry", "release", "handoff", "human"]).optional(), errorCode: z.string().optional() }, annotations: mutationAnnotations }, async (input) => jsonText(await recordExecutionEvent(workspaceRoot, { ...input, actor: clientIdentity().actor }))); + server.registerTool("submit_evidence", { description: "Registra evidência estruturada confinada.", inputSchema: { itemId: z.string(), executorId: z.string(), expectedRevision, reason, evidence: z.array(z.object({ kind: z.enum(["diff", "file", "command", "test", "artifact"]), value: z.string(), source: z.string(), observedAt: z.string().optional(), sha256: z.string().optional(), exitCode: z.number().optional() })) }, annotations: mutationAnnotations }, async (input) => jsonText(await submitEvidenceOperation(workspaceRoot, { ...input, actor: clientIdentity().actor }))); + server.registerTool("request_handoff", { description: "Solicita handoff atômico.", inputSchema: { itemId: z.string(), to: z.string(), executorId: z.string(), summary: z.string(), evidence: z.array(z.string()), expectedRevision, reason }, annotations: mutationAnnotations }, async (input) => jsonText(await requestHandoffOperation(workspaceRoot, { ...input, actor: clientIdentity().actor }))); server.registerResource( "direction", diff --git a/packages/cli/src/orchestrator/dispatcher.test.ts b/packages/cli/src/orchestrator/dispatcher.test.ts new file mode 100644 index 0000000..3def3bb --- /dev/null +++ b/packages/cli/src/orchestrator/dispatcher.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { PersistentDispatcher } from "./dispatcher.js"; +import { createSimulatedExecutor } from "./simulated-executor.js"; + +describe("PersistentDispatcher", () => { + it("dispatches pending handoff and skips offline executors", async () => { + const execute = vi.fn().mockResolvedValue({ success: true, output: "ok", artifacts: [], evidences: [] }); + const workflow = { items: [{ id: "I", description: "", stage: "code", createdAt: "", handoff: { from: "a", to: "b", summary: "", evidence: [], timestamp: "", expiresAt: "" } }], stages: [{ id: "code", name: "Code", order: 1 }], name: "", version: "1", createdAt: "", updatedAt: "", tools: [] } as any; + const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn(), advance: vi.fn() }; + const result = await new PersistentDispatcher(store, () => [{ id: "offline", label: "", capabilities: [], status: "offline", execute }, { id: "online", label: "", capabilities: [], status: "online", execute }]).dispatch(); + expect(result[0].status).toBe("dispatched"); expect(execute).toHaveBeenCalled(); expect(store.writeWorkflow).toHaveBeenCalled(); + }); + + it("records simulated evidence and the next handoff", async () => { + const workflow = { + items: [{ id: "I", description: "", stage: "code", createdAt: "", handoff: { from: "design", to: "implementer", summary: "", evidence: [], timestamp: "", expiresAt: "" } }], + stages: [ + { id: "code", name: "Code", order: 1, agents: ["implementer"] }, + { id: "review", name: "Review", order: 2, agents: ["reviewer"] }, + ], name: "", version: "1", createdAt: "", updatedAt: "", tools: [], + } as any; + const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn((next) => Object.assign(workflow, next)), advance: vi.fn() }; + const result = await new PersistentDispatcher(store, () => [createSimulatedExecutor("simulated", ["code"])], 30_000).dispatch(); + expect(result[0].status).toBe("dispatched"); + expect(workflow.items[0].activityStatus).toBe("succeeded"); + expect(workflow.items[0].stage).toBe("review"); + expect(workflow.items[0].handoff?.to).toBe("reviewer"); + expect(workflow.items[0].handoff?.evidence).toContain("simulated:code:I"); + }); + + it("pauses a handoff addressed to the human", async () => { + const workflow = { items: [{ id: "I", description: "", stage: "security", createdAt: "", handoff: { from: "security", to: "human", summary: "Approve", evidence: [], timestamp: "", expiresAt: "" } }], stages: [{ id: "security", name: "Security", order: 1 }], name: "", version: "1", createdAt: "", updatedAt: "", tools: [] } as any; + const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn(), advance: vi.fn() }; + const result = await new PersistentDispatcher(store, () => [createSimulatedExecutor("simulated")]).dispatch(); + expect(result).toEqual([{ itemId: "I", status: "waiting-human", reason: "aguardando decisão humana" }]); + expect(store.writeWorkflow).not.toHaveBeenCalled(); + }); + + it("lets the current stage owner execute before presenting its human gate", async () => { + const workflow = { items: [{ id: "I", description: "", stage: "security", createdAt: "", handoff: { from: "reviewer", to: "security", summary: "Scan", evidence: [], timestamp: "", expiresAt: "" } }], stages: [{ id: "security", name: "Security", order: 1, agents: ["security"], gate: "human-approved" }], name: "", version: "1", createdAt: "", updatedAt: "", tools: [] } as any; + const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn((next) => Object.assign(workflow, next)), advance: vi.fn() }; + const result = await new PersistentDispatcher(store, () => [createSimulatedExecutor("simulated", ["security"])], 30_000, { blocksHandoff: (stage, item) => stage.gate === "human-approved" && item.handoff?.to !== (stage as typeof stage & { agents?: string[] }).agents?.[0] }).dispatch(); + expect(result[0].status).toBe("dispatched"); + expect(workflow.items[0].handoff?.to).toBe("human"); + }); + + it("uses the durable claim operation when a store provides CAS", async () => { + const workflow = { items: [{ id: "I", description: "", stage: "code", createdAt: "", handoff: { from: "design", to: "implementer", summary: "", evidence: [], timestamp: "", expiresAt: "" } }], stages: [{ id: "code", name: "Code", order: 1 }], name: "", version: "1", createdAt: "", updatedAt: "", tools: [] } as any; + const claim = vi.fn().mockResolvedValue(false); + const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn(), advance: vi.fn(), claim }; + const result = await new PersistentDispatcher(store, () => [createSimulatedExecutor("simulated")]).dispatch(); + expect(result).toEqual([]); + expect(claim).toHaveBeenCalledWith("I", "simulated", "implementer"); + expect(store.writeWorkflow).not.toHaveBeenCalled(); + }); + + it("walks code, review and security until the final human handoff", async () => { + const workflow = { + items: [{ id: "I", description: "", stage: "code", createdAt: "", handoff: { from: "human", to: "implementer", summary: "Approved", evidence: [], timestamp: "", expiresAt: "" } }], + stages: [ + { id: "code", name: "Code", order: 1, agents: ["implementer"] }, + { id: "review", name: "Review", order: 2, agents: ["reviewer"] }, + { id: "security", name: "Security", order: 3, agents: ["security"], gate: "human-approved" }, + ], name: "", version: "1", createdAt: "", updatedAt: "", tools: [], + } as any; + const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn((next) => Object.assign(workflow, next)), advance: vi.fn() }; + const dispatcher = new PersistentDispatcher(store, () => [createSimulatedExecutor("simulated")]); + await dispatcher.dispatch(); + await dispatcher.dispatch(); + await dispatcher.dispatch(); + expect(workflow.items[0].stage).toBe("security"); + expect(workflow.items[0].handoff?.to).toBe("human"); + expect(workflow.items[0].lastHeartbeatAt).toEqual(expect.any(String)); + }); + + it("releases a claim when an executor throws", async () => { + const workflow = { items: [{ id: "I", description: "", stage: "code", createdAt: "", handoff: { from: "design", to: "implementer", summary: "", evidence: [], timestamp: "", expiresAt: "" } }], stages: [{ id: "code", name: "Code", order: 1 }], name: "", version: "1", createdAt: "", updatedAt: "", tools: [] } as any; + const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn((next) => Object.assign(workflow, next)), advance: vi.fn() }; + const failing = { id: "simulated", label: "", capabilities: ["code"], status: "online" as const, execute: vi.fn().mockRejectedValue(new Error("boom")) }; + const result = await new PersistentDispatcher(store, () => [failing]).dispatch(); + expect(result[0].status).toBe("failed"); + expect(workflow.items[0].claimedBy).toBeUndefined(); + expect(workflow.items[0].activityStatus).toBe("failed"); + expect(workflow.items[0].lastFailure?.recovery).toBe("retry"); + }); +}); diff --git a/packages/cli/src/orchestrator/dispatcher.ts b/packages/cli/src/orchestrator/dispatcher.ts new file mode 100644 index 0000000..dc9e64a --- /dev/null +++ b/packages/cli/src/orchestrator/dispatcher.ts @@ -0,0 +1,117 @@ +import type { Item, Workflow } from "../commands/flow-init.js"; +import type { AgenticExecutor } from "../executor/executor.js"; +import type { ExecutionContext, ExecutionResult } from "../harness/types.js"; + +export interface DispatcherStore { + loadWorkflow(): Workflow; + writeWorkflow(workflow: Workflow): Promise | void; + advance(itemId: string, stage: string): Promise | void; + /** Optional durable CAS claim. Returning false means another worker won. */ + claim?(itemId: string, executorId: string, agentId: string): Promise | boolean; +} + +export interface DispatcherResult { itemId: string; status: "dispatched" | "waiting-human" | "offline" | "failed"; reason?: string; } + +export interface DispatcherOptions { + /** Human gates are the only gates that stop an autonomous handoff. */ + blocksHandoff?: (stage: Workflow["stages"][number], item: Item) => boolean; + /** Resolve actors from the harness when workflow instances omit them. */ + stageActors?: (stageId: string) => string[]; + onResult?: (result: DispatcherResult) => void; +} + +/** Durable polling coordinator: one item is claimed before an executor starts. */ +export class PersistentDispatcher { + private timer: ReturnType | undefined; + private running = false; + constructor( + private readonly store: DispatcherStore, + private readonly executors: () => AgenticExecutor[], + private readonly intervalMs = 30_000, + private readonly options: DispatcherOptions = {}, + ) {} + start(): void { if (this.timer) return; void this.dispatch(); this.timer = setInterval(() => void this.dispatch(), this.intervalMs); } + stop(): void { if (this.timer) clearInterval(this.timer); this.timer = undefined; } + async dispatch(): Promise { + if (this.running) return []; + this.running = true; + try { + const workflow = this.store.loadWorkflow(); + const results: DispatcherResult[] = []; + for (const item of workflow.items) { + if (!item.handoff || item.claimedBy) continue; + const stage = workflow.stages.find((entry) => entry.id === item.stage); + if (!stage) continue; + if (item.handoff.to === "human" || item.handoff.to.startsWith("human:")) { + const result = { itemId: item.id, status: "waiting-human" as const, reason: "aguardando decisão humana" }; + results.push(result); this.options.onResult?.(result); continue; + } + if (this.options.blocksHandoff?.(stage, item)) { + const result = { itemId: item.id, status: "waiting-human" as const, reason: "gate humano bloqueante" }; + results.push(result); this.options.onResult?.(result); continue; + } + const capability = stage.id; + const online = this.executors().filter((entry) => entry.status !== "offline"); + const executor = online.find((entry) => entry.capabilities.includes(capability)) ?? online[0]; + if (!executor) { results.push({ itemId: item.id, status: "offline", reason: "nenhum executor online" }); continue; } + const claimed: Item = { + ...item, + claimedBy: item.handoff.to, + claimedAt: new Date().toISOString(), + claimExecutorId: executor.id, + activityStatus: "started", + activityStartedAt: new Date().toISOString(), + lastHeartbeatAt: new Date().toISOString(), + }; + if (this.store.claim) { + const accepted = await this.store.claim(item.id, executor.id, item.handoff.to); + if (!accepted) continue; + } else { + const nextWorkflow = { ...workflow, items: workflow.items.map((entry) => entry.id === item.id ? claimed : entry) }; + await this.store.writeWorkflow(nextWorkflow); + } + try { + if (typeof (executor as AgenticExecutor & { heartbeat?: () => Promise }).heartbeat === "function") { + await (executor as AgenticExecutor & { heartbeat: () => Promise }).heartbeat(); + } + const execution = await executor.execute({ itemId: claimed.id, item: claimed, agent: item.handoff.to, stage: item.stage, spec: claimed.spec ?? null, diff: null, snapshot: { stages: workflow.stages.map((entry) => ({ ...entry, agents: this.options.stageActors?.(entry.id) ?? (entry as typeof entry & { agents?: string[] }).agents })) }, sessionLog: [], commands: [], prohibitions: [] } satisfies ExecutionContext); + const current = this.store.loadWorkflow(); + const updated = current.items.map((entry) => { + if (entry.id !== item.id) return entry; + const next: Item = { ...entry, claimedBy: undefined, claimedAt: undefined, claimExecutorId: undefined, activityStatus: execution.success ? "succeeded" : "failed", lastHeartbeatAt: new Date().toISOString() }; + if (execution.success && execution.handoff) { + const targetStage = workflow.stages.find((candidate) => + (this.options.stageActors?.(candidate.id) ?? (candidate as typeof candidate & { agents?: string[] }).agents ?? []).includes(execution.handoff!.to), + ); + if (targetStage) next.stage = targetStage.id; + next.handoff = { + from: execution.handoff.from, + to: execution.handoff.to, + summary: execution.handoff.summary, + evidence: execution.handoff.evidence ?? execution.evidences ?? [], + timestamp: execution.handoff.timestamp, + expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(), + executorId: executor.id, + }; + } + if (!execution.success) next.lastFailure = { code: "EXECUTOR_FAILED", message: execution.error ?? execution.output, recovery: "retry", at: new Date().toISOString() }; + return next; + }); + await this.store.writeWorkflow({ ...current, items: updated, updatedAt: new Date().toISOString() }); + const result = execution.success ? { itemId: item.id, status: "dispatched" as const } : { itemId: item.id, status: "failed" as const, reason: execution.error ?? execution.output }; + results.push(result); this.options.onResult?.(result); + } catch (error) { + const current = this.store.loadWorkflow(); + const message = error instanceof Error ? error.message : String(error); + const recoveredItems = current.items.map((entry) => entry.id === item.id + ? { ...entry, claimedBy: undefined, claimedAt: undefined, claimExecutorId: undefined, activityStatus: "failed" as const, lastFailure: { code: "EXECUTOR_EXCEPTION", message, recovery: "retry", at: new Date().toISOString() } } + : entry); + await this.store.writeWorkflow({ ...current, items: recoveredItems, updatedAt: new Date().toISOString() }); + const result = { itemId: item.id, status: "failed" as const, reason: error instanceof Error ? error.message : String(error) }; + results.push(result); this.options.onResult?.(result); + } + } + return results; + } finally { this.running = false; } + } +} diff --git a/packages/cli/src/orchestrator/simulated-executor.ts b/packages/cli/src/orchestrator/simulated-executor.ts new file mode 100644 index 0000000..ebf0ace --- /dev/null +++ b/packages/cli/src/orchestrator/simulated-executor.ts @@ -0,0 +1,48 @@ +import type { RichAgenticExecutor } from "../executor/executor.js"; +import type { ExecutionContext, ExecutionResult } from "../harness/types.js"; + +/** + * Deterministic executor used by the local semiautonomous flow preview. + * It produces auditable evidence and hands off to the next configured role; + * it never edits the project or approves a human gate. + */ +export function createSimulatedExecutor( + id: string, + capabilities: string[] = ["design", "code", "review", "security"], +): RichAgenticExecutor { + return { + id, + label: `Simulated ${id}`, + capabilities, + status: "online", + heartbeat: async () => undefined, + async execute(context: ExecutionContext): Promise { + const stages = Array.isArray((context.snapshot as { stages?: unknown[] })?.stages) + ? ((context.snapshot as { stages: Array<{ id: string; order: number; agents?: string[] }> }).stages) + : []; + const current = stages.find((stage) => stage.id === context.stage); + const next = current + ? stages + .filter((stage) => stage.order > current.order) + .sort((a, b) => a.order - b.order)[0] + : undefined; + const target = next?.agents?.[0] ?? "human"; + const timestamp = new Date().toISOString(); + return { + success: true, + output: `Simulated execution completed for ${context.itemId} at ${context.stage}`, + artifacts: [], + evidences: [`simulated:${context.stage}:${context.itemId}`], + handoff: { + type: "handoff", + itemId: context.itemId, + from: id, + to: target, + summary: `Simulated ${context.stage} completed; awaiting ${target}.`, + evidence: [`simulated:${context.stage}:${context.itemId}`], + timestamp, + }, + }; + }, + }; +} diff --git a/packages/cli/src/session-log.ts b/packages/cli/src/session-log.ts index b4b6f8a..5432817 100644 --- a/packages/cli/src/session-log.ts +++ b/packages/cli/src/session-log.ts @@ -36,6 +36,8 @@ export type LogAction = | "agent_direction_read" | "agent_validation_run" | "agent_ac_completion_requested" + | "agent_claim_requested" + | "agent_execution_event" | "agent_transition_requested" | "agent_operation_rejected" | "constitution_read" diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 5d330f5..7a86ef8 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -9,6 +9,7 @@ import FlowView from "./components/Flow/FlowView"; import ContextView from "./components/Context/ContextView"; import type { KnowledgeTab } from "./components/Context/ContextView"; import AuditLogView from "./components/Logs/AuditLogView"; +import AgentsView from "./components/Agents/AgentsView"; import WorkspacesView from "./components/Workspaces/WorkspacesView"; import WorkspaceSettings from "./components/Workspaces/WorkspaceSettings/WorkspaceSettings"; import type { WorkspaceData } from "./components/Workspaces/WorkspacesView"; @@ -306,6 +307,8 @@ function AppContent() { return ; case "activity": return ; + case "agents": + return ; case "settings": return ( ([]); const [draft,setDraft]=useState(null); const [error,setError]=useState(""); const [saving,setSaving]=useState(false); + const load=()=>fetch("/api/agents").then(async r=>{if(!r.ok)throw new Error("Não foi possível carregar a equipe");return r.json();}).then(d=>Array.isArray(d)&&setAgents(d)).catch(e=>setError(e.message)); useEffect(()=>{load();},[]); + const field=(k:K,v:AgentIdentity[K])=>setDraft(d=>d?{...d,[k]:v}:d); + function editSkills(v:string){field("skills",v.split(",").map((label):AgentSkill=>({id:label.trim().toLowerCase().replace(/\s+/g,"-"),label:label.trim(),level:"intermediate",category:"engineering"})).filter(s=>s.label));} + async function save(){if(!draft?.id||!draft.displayName.trim()){setError("ID e nome são obrigatórios");return;}setSaving(true);setError("");const exists=agents.some(a=>a.id===draft.id);try{const r=await fetch(exists?`/api/agents/${encodeURIComponent(draft.id)}`:"/api/agents",{method:exists?"PATCH":"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(draft)});if(!r.ok)throw new Error((await r.json()).error??"Falha ao salvar agente");setDraft(null);await load();}catch(e){setError((e as Error).message);}finally{setSaving(false);}} + return

Equipe de agentes

Identidades, skills e status do harness.

{error&&

{error}

}
{agents.map(a=>

{a.displayName}

{a.role} · {a.status}

{a.bio}

{a.skills.map(s=>{s.label} · {s.level})}
)}
{draft&&

{agents.some(a=>a.id===draft.id)?"Editar agente":"Novo agente"}