From 7b9d2b0649c7ca1318495c946b00e7b7ded6fcb2 Mon Sep 17 00:00:00 2001 From: rnascimento10 Date: Tue, 8 Sep 2026 12:23:12 -0300 Subject: [PATCH 01/19] feat(protocol): define external executor identity types --- packages/types/src/external-protocol.test.ts | 36 ++++++++++++++++++++ packages/types/src/index.ts | 36 ++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 packages/types/src/external-protocol.test.ts diff --git a/packages/types/src/external-protocol.test.ts b/packages/types/src/external-protocol.test.ts new file mode 100644 index 0000000..50b6905 --- /dev/null +++ b/packages/types/src/external-protocol.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { + ExternalProtocolActor, + ExternalProtocolContext, + ExternalProtocolExecutor, +} from "./index.js"; + +describe("external executor protocol types", () => { + it("describes the actor and executor identity required by AC1", () => { + const actor: ExternalProtocolActor = { + agentId: "agent-analyst-1", + displayName: "Analyst", + toolId: "claude-code", + toolVersion: "1.0.0", + }; + const executor: ExternalProtocolExecutor = { + id: "claude-code-local", + capabilities: ["read_context", "write_code"], + status: "online", + transport: "cli", + maxExecutionTime: 1800, + }; + const context: ExternalProtocolContext = { + schemaVersion: "1", + workspace: { workspaceId: "ws_letra", workspaceRoot: "C:/Workspace/letra" }, + actor, + executor, + revision: "sha256:" + "a".repeat(64), + timestamp: "2026-09-08T00:00:00.000Z", + }; + + expect(context.actor.toolId).toBe("claude-code"); + expect(context.executor.capabilities).toContain("write_code"); + expect(context.workspace.workspaceId).toBe("ws_letra"); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f28b837..f5c5bdd 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,3 +1,39 @@ +/** Versioned identity of the agent/tool making a protocol request. */ +export interface ExternalProtocolActor { + agentId: string; + displayName: string; + toolId: string; + toolVersion: string; +} + +/** Capabilities an external executor advertises to the Letra control plane. */ +export type ExternalProtocolCapability = string; + +/** Stable workspace reference carried by protocol messages. */ +export interface ExternalProtocolWorkspaceRef { + workspaceId: string; + workspaceRoot: string; +} + +/** Versioned description of an external executor and its transport. */ +export interface ExternalProtocolExecutor { + id: string; + capabilities: ExternalProtocolCapability[]; + status: "online" | "offline" | "busy"; + transport: "cli" | "mcp" | "webhook" | "file"; + maxExecutionTime?: number; +} + +/** Common envelope for requests made against the external executor protocol. */ +export interface ExternalProtocolContext { + schemaVersion: "1"; + workspace: ExternalProtocolWorkspaceRef; + actor: ExternalProtocolActor; + executor: ExternalProtocolExecutor; + revision: string; + timestamp: string; +} + export interface Stage { id: string; name: string; From d1942ca5c8f19380db8ffec989da5ff0026a5f07 Mon Sep 17 00:00:00 2001 From: rnascimento10 Date: Tue, 8 Sep 2026 12:26:22 -0300 Subject: [PATCH 02/19] fix(workspace): respect external data boundary for AC operations --- packages/cli/src/domain-operations/service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/domain-operations/service.ts b/packages/cli/src/domain-operations/service.ts index 0ed4ae0..b9928c9 100644 --- a/packages/cli/src/domain-operations/service.ts +++ b/packages/cli/src/domain-operations/service.ts @@ -10,7 +10,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"; @@ -181,8 +181,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); From 7707e89354d370400a67c6fad9c82999bfcf36d1 Mon Sep 17 00:00:00 2001 From: rnascimento10 Date: Tue, 8 Sep 2026 12:38:46 -0300 Subject: [PATCH 03/19] feat(protocol): expose canonical direction to adapters --- packages/cli/src/adapters/builder.ts | 3 +++ packages/cli/src/adapters/types.ts | 4 ++++ 2 files changed, 7 insertions(+) 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/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 }; From 1a2eefb168ce6ad4559cd9e8a9d974a0c45804a5 Mon Sep 17 00:00:00 2001 From: rnascimento10 Date: Tue, 8 Sep 2026 12:43:21 -0300 Subject: [PATCH 04/19] feat(protocol): add versioned claim operation --- packages/cli/src/agent-direction/service.ts | 3 + packages/cli/src/commands/flow-init.ts | 1 + packages/cli/src/commands/operation.ts | 13 ++++ packages/cli/src/domain-operations/service.ts | 59 +++++++++++++++++++ packages/cli/src/session-log.ts | 1 + packages/types/src/index.ts | 3 + 6 files changed, 80 insertions(+) diff --git a/packages/cli/src/agent-direction/service.ts b/packages/cli/src/agent-direction/service.ts index 04b065f..5049364 100644 --- a/packages/cli/src/agent-direction/service.ts +++ b/packages/cli/src/agent-direction/service.ts @@ -199,6 +199,9 @@ 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, } : null, roleIds: stage ? [...stage.roleIds] : [], diff --git a/packages/cli/src/commands/flow-init.ts b/packages/cli/src/commands/flow-init.ts index 739940f..7d029f9 100644 --- a/packages/cli/src/commands/flow-init.ts +++ b/packages/cli/src/commands/flow-init.ts @@ -58,6 +58,7 @@ export interface Item { tasks?: Task[]; claimedBy?: string; claimedAt?: string; + claimExpiresAt?: string; currentPhase?: string; handoff?: ItemHandoff; } diff --git a/packages/cli/src/commands/operation.ts b/packages/cli/src/commands/operation.ts index d507aca..d51cec8 100644 --- a/packages/cli/src/commands/operation.ts +++ b/packages/cli/src/commands/operation.ts @@ -14,6 +14,19 @@ export default function operationCommand(): Command { "Execute controlled harness operations with structured JSON results", ); + 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") diff --git a/packages/cli/src/domain-operations/service.ts b/packages/cli/src/domain-operations/service.ts index b9928c9..6b99572 100644 --- a/packages/cli/src/domain-operations/service.ts +++ b/packages/cli/src/domain-operations/service.ts @@ -41,6 +41,13 @@ export interface RequestTransitionInput extends OperationContext { targetStageId: string; } +export interface ClaimOperationInput extends OperationContext { + itemId: string; + executorId: string; + capability: string; + ttlMinutes?: number; +} + function audit( root: string, action: LogAction, @@ -132,6 +139,58 @@ 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); + if (item.claimedBy && item.claimedBy !== input.actor) + 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.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); +} + function markPendingAc(content: string, acId: string): string | null { const lines = content.split("\n"); const expected = normalizeAcId(acId); diff --git a/packages/cli/src/session-log.ts b/packages/cli/src/session-log.ts index b4b6f8a..08a83ee 100644 --- a/packages/cli/src/session-log.ts +++ b/packages/cli/src/session-log.ts @@ -36,6 +36,7 @@ export type LogAction = | "agent_direction_read" | "agent_validation_run" | "agent_ac_completion_requested" + | "agent_claim_requested" | "agent_transition_requested" | "agent_operation_rejected" | "constitution_read" diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f5c5bdd..57c07b2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -180,6 +180,9 @@ export interface AgentDirectionSnapshot { description: string; stage: string; spec: string | null; + claimedBy?: string | null; + claimedAt?: string | null; + claimExpiresAt?: string | null; } | null; roleIds: string[]; allowedStageIds: string[]; From ac368d673bc1c1e70bb92ed2b67c6a0febd6430e Mon Sep 17 00:00:00 2001 From: rnascimento10 Date: Tue, 8 Sep 2026 12:59:16 -0300 Subject: [PATCH 05/19] feat(protocol): define execution event schemas --- packages/types/src/index.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 57c07b2..e23d440 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -34,6 +34,35 @@ export interface ExternalProtocolContext { timestamp: string; } +export type ExternalProtocolEventStatus = "started" | "heartbeat" | "succeeded" | "failed"; + +export interface ExternalProtocolEvent { + schemaVersion: "1"; + status: ExternalProtocolEventStatus; + itemId: string; + actor: ExternalProtocolActor; + executorId: string; + revision: string; + timestamp: string; + message?: string; + metadata?: Record; +} + +export interface ExternalProtocolEvidence { + kind: "diff" | "file" | "command" | "test" | "artifact"; + value: string; + sha256?: string; + exitCode?: number; + observedAt: string; + source: string; +} + +export interface ExternalProtocolFailure extends ExternalProtocolEvent { + status: "failed"; + recovery: "retry" | "release" | "handoff" | "human"; + errorCode: string; +} + export interface Stage { id: string; name: string; From 846fbb19d4cb41769a4c447b01cea242a55d6767 Mon Sep 17 00:00:00 2001 From: rnascimento10 Date: Tue, 8 Sep 2026 13:01:36 -0300 Subject: [PATCH 06/19] feat(protocol): persist execution lifecycle events --- packages/cli/src/agent-direction/service.ts | 4 ++ packages/cli/src/commands/flow-init.ts | 4 ++ packages/cli/src/commands/operation.ts | 15 ++++++++ packages/cli/src/domain-operations/service.ts | 37 +++++++++++++++++++ packages/cli/src/session-log.ts | 1 + packages/types/src/index.ts | 4 ++ 6 files changed, 65 insertions(+) diff --git a/packages/cli/src/agent-direction/service.ts b/packages/cli/src/agent-direction/service.ts index 5049364..eda0d4e 100644 --- a/packages/cli/src/agent-direction/service.ts +++ b/packages/cli/src/agent-direction/service.ts @@ -202,6 +202,10 @@ export function createAgentDirectionSnapshot( claimedBy: item.claimedBy ?? null, claimedAt: item.claimedAt ?? null, claimExpiresAt: item.claimExpiresAt ?? null, + activityStatus: item.activityStatus ?? null, + activityStartedAt: item.activityStartedAt ?? null, + lastHeartbeatAt: item.lastHeartbeatAt ?? null, + lastFailure: item.lastFailure ?? null, } : null, roleIds: stage ? [...stage.roleIds] : [], diff --git a/packages/cli/src/commands/flow-init.ts b/packages/cli/src/commands/flow-init.ts index 7d029f9..408258e 100644 --- a/packages/cli/src/commands/flow-init.ts +++ b/packages/cli/src/commands/flow-init.ts @@ -59,6 +59,10 @@ export interface Item { claimedBy?: string; claimedAt?: string; claimExpiresAt?: string; + 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/operation.ts b/packages/cli/src/commands/operation.ts index d51cec8..bc240d0 100644 --- a/packages/cli/src/commands/operation.ts +++ b/packages/cli/src/commands/operation.ts @@ -14,6 +14,21 @@ 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") diff --git a/packages/cli/src/domain-operations/service.ts b/packages/cli/src/domain-operations/service.ts index 6b99572..7a1c838 100644 --- a/packages/cli/src/domain-operations/service.ts +++ b/packages/cli/src/domain-operations/service.ts @@ -48,6 +48,15 @@ export interface ClaimOperationInput extends OperationContext { 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; +} + function audit( root: string, action: LogAction, @@ -191,6 +200,34 @@ export async function claimOperation( 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); + if (item.claimedBy !== input.actor) return rejected(workspaceRoot, before, "CLAIM_REQUIRED", "O actor precisa possuir o claim vigente.", input, subject); + const now = new Date().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 === "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); diff --git a/packages/cli/src/session-log.ts b/packages/cli/src/session-log.ts index 08a83ee..5432817 100644 --- a/packages/cli/src/session-log.ts +++ b/packages/cli/src/session-log.ts @@ -37,6 +37,7 @@ export type LogAction = | "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/types/src/index.ts b/packages/types/src/index.ts index e23d440..38809d5 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -212,6 +212,10 @@ export interface AgentDirectionSnapshot { claimedBy?: string | null; claimedAt?: string | null; claimExpiresAt?: string | null; + activityStatus?: ExternalProtocolEventStatus | null; + activityStartedAt?: string | null; + lastHeartbeatAt?: string | null; + lastFailure?: { code: string; message: string; recovery: string; at: string } | null; } | null; roleIds: string[]; allowedStageIds: string[]; From ffa6c2afdfcaf0685f2bb7a3384ed240522508e0 Mon Sep 17 00:00:00 2001 From: rnascimento10 Date: Tue, 8 Sep 2026 13:18:56 -0300 Subject: [PATCH 07/19] feat(protocol): complete executor operations and lease visibility --- packages/cli/src/commands/flow-init.ts | 4 ++ packages/cli/src/commands/operation.ts | 25 ++++++++ packages/cli/src/domain-operations/service.ts | 62 +++++++++++++++++-- packages/cli/src/mcp/server.ts | 11 ++++ .../src/components/Flow/KanbanBoard.tsx | 7 +++ packages/types/src/index.ts | 13 ++++ 6 files changed, 118 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/flow-init.ts b/packages/cli/src/commands/flow-init.ts index 408258e..751b087 100644 --- a/packages/cli/src/commands/flow-init.ts +++ b/packages/cli/src/commands/flow-init.ts @@ -58,7 +58,11 @@ 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; diff --git a/packages/cli/src/commands/operation.ts b/packages/cli/src/commands/operation.ts index bc240d0..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]; @@ -50,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 7a1c838..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"; @@ -57,6 +58,50 @@ export interface ExecutionEventInput extends OperationContext { 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, @@ -169,12 +214,17 @@ export async function claimOperation( 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); - if (item.claimedBy && item.claimedBy !== input.actor) + 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, { @@ -214,11 +264,15 @@ export async function recordExecutionEvent( 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); - if (item.claimedBy !== input.actor) return rejected(workspaceRoot, before, "CLAIM_REQUIRED", "O actor precisa possuir o claim vigente.", input, subject); - const now = new Date().toISOString(); + 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 }); 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/client/src/components/Flow/KanbanBoard.tsx b/packages/client/src/components/Flow/KanbanBoard.tsx index dcbe1a1..a3aa2db 100644 --- a/packages/client/src/components/Flow/KanbanBoard.tsx +++ b/packages/client/src/components/Flow/KanbanBoard.tsx @@ -272,6 +272,13 @@ function ItemCard({ {agentAction} + {item.claimedBy && ( +
+ Execução ativa · {item.claimExecutorId ?? "executor desconhecido"} + Início: {item.activityStartedAt ?? item.claimedAt ?? "—"} · Heartbeat: {item.lastHeartbeatAt ?? "pendente"} + Lease: {item.claimExpiresAt ?? "sem validade registrada"} +
+ )} {hasProgress ? (
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 38809d5..2ebc486 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -94,6 +94,15 @@ export interface Item { tasks?: Task[]; claimedBy?: string; claimedAt?: string; + claimExecutorId?: string; + claimCapability?: string; + claimRevision?: string; + claimExpiresAt?: string; + claimTtlMinutes?: number; + activityStatus?: ExternalProtocolEventStatus; + activityStartedAt?: string; + lastHeartbeatAt?: string; + lastFailure?: { code: string; message: string; recovery: string; at: string }; currentPhase?: string; } @@ -212,6 +221,10 @@ export interface AgentDirectionSnapshot { claimedBy?: string | null; claimedAt?: string | null; claimExpiresAt?: string | null; + claimExecutorId?: string | null; + claimCapability?: string | null; + claimRevision?: string | null; + claimTtlMinutes?: number | null; activityStatus?: ExternalProtocolEventStatus | null; activityStartedAt?: string | null; lastHeartbeatAt?: string | null; From a9bde7e46109956855ff323a904675632585b081 Mon Sep 17 00:00:00 2001 From: rnascimento10 Date: Tue, 8 Sep 2026 13:19:25 -0300 Subject: [PATCH 08/19] feat(direction): expose executor lease activity fields --- packages/cli/src/agent-direction/service.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/agent-direction/service.ts b/packages/cli/src/agent-direction/service.ts index eda0d4e..e17cd6d 100644 --- a/packages/cli/src/agent-direction/service.ts +++ b/packages/cli/src/agent-direction/service.ts @@ -202,6 +202,10 @@ export function createAgentDirectionSnapshot( 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, From f1b68d700ba294d08490702bbf94d00f9a09c2b5 Mon Sep 17 00:00:00 2001 From: rnascimento10 Date: Tue, 8 Sep 2026 13:42:53 -0300 Subject: [PATCH 09/19] feat(agents): add identity registry and team management surface --- packages/cli/src/adapters/skill-bridge.ts | 15 +++++++++ packages/cli/src/agents/service.test.ts | 25 ++++++++++++++ packages/cli/src/agents/service.ts | 33 +++++++++++++++++++ packages/cli/src/commands/flow-serve.ts | 2 ++ .../cli/src/flow-serve/routes/agent-routes.ts | 22 +++++++++++++ packages/client/src/App.tsx | 3 ++ .../src/components/Agents/AgentsView.tsx | 12 +++++++ .../src/components/Flow/KanbanBoard.tsx | 11 +++++-- .../client/src/components/Sidebar/Sidebar.tsx | 3 +- packages/types/src/index.ts | 25 ++++++++++++++ packages/ui/src/agent-avatar.tsx | 10 ++++++ packages/ui/src/index.ts | 1 + 12 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/adapters/skill-bridge.ts create mode 100644 packages/cli/src/agents/service.test.ts create mode 100644 packages/cli/src/agents/service.ts create mode 100644 packages/cli/src/flow-serve/routes/agent-routes.ts create mode 100644 packages/client/src/components/Agents/AgentsView.tsx create mode 100644 packages/ui/src/agent-avatar.tsx 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/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..e43318e --- /dev/null +++ b/packages/cli/src/agents/service.ts @@ -0,0 +1,33 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { AgentIdentity, AgentRegistry, Workflow } 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 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)) return parsed; } 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); 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`); r.agents[i] = { ...r.agents[i], ...patch, id }; saveAgents(root, r); return r.agents[i]; } +export function deleteAgent(root: string, id: string, workflow?: Workflow): void { const r = loadAgents(root, workflow); 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); } diff --git a/packages/cli/src/commands/flow-serve.ts b/packages/cli/src/commands/flow-serve.ts index 9588c03..fcabd61 100644 --- a/packages/cli/src/commands/flow-serve.ts +++ b/packages/cli/src/commands/flow-serve.ts @@ -67,6 +67,7 @@ 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"; @@ -261,6 +262,7 @@ export class FlowServer { }, }), ); + this.router.register(createAgentRoutes({ loadWorkflow: (root) => this.loadWorkflow(root), broadcast: () => this.broadcast() })); } switchWorkspace(workspaceRoot: string) { 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..e4ca992 --- /dev/null +++ b/packages/cli/src/flow-serve/routes/agent-routes.ts @@ -0,0 +1,22 @@ +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"; + +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 agent = await readJson(context.req); sendJson(context.res, 201, createAgent(root, agent, dependencies.loadWorkflow(root))); return true; + } + const match = context.path.match(/^\/api\/agents\/([^/]+)$/); if (!match) return false; + const id = decodeURIComponent(match[1]); + if (context.method === "PATCH") { const patch = await readJson>(context.req); const result = updateAgent(root, id, patch, dependencies.loadWorkflow(root)); dependencies.broadcast?.(); sendJson(context.res, 200, result); return true; } + if (context.method === "DELETE") { deleteAgent(root, id, dependencies.loadWorkflow(root)); dependencies.broadcast?.(); sendJson(context.res, 200, { ok: true }); return true; } + return false; + } catch (error) { sendError(context.res, 400, (error as Error).message); return true; } + }; +} 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 load = () => fetch("/api/agents").then((r) => r.json()).then((d) => Array.isArray(d) && setAgents(d)).catch(() => {}); + useEffect(() => { load(); }, []); + async function save() { if (!draft?.id || !draft.displayName) return; const exists = agents.some((a) => a.id === draft.id); await fetch(exists ? `/api/agents/${encodeURIComponent(draft.id)}` : "/api/agents", { method: exists ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(draft) }); setDraft(null); load(); } + return

Equipe de agentes

Identidades, skills e status do harness.

{agents.map((agent) =>

{agent.displayName}

{agent.role} · {agent.status}

{agent.bio}

{agent.skills.map((skill) => {skill.label} · {skill.level})}
)}
{draft &&

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

setDraft({ ...draft, displayName: e.target.value })} /> setDraft({ ...draft, role: e.target.value })} />