From 6ef86f756536ded85c33f42fde7574479bbfb053 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 14 Jul 2026 09:59:59 -0400 Subject: [PATCH 1/3] fix(ai): repair Hermes triage lifecycle --- DEVLOG.md | 25 ++ docs/agents/ai-triage-and-coach.md | 72 +++-- src/components/ai-triage-card.tsx | 85 ++---- src/lib/ai-triage.ts | 15 + .../routers/__tests__/ai-triage.test.ts | 211 +++++++++++++ src/server/routers/ai.ts | 243 +++++++++++---- src/server/services/ai-coach.ts | 13 +- src/server/services/ai-providers.ts | 40 ++- src/server/services/ai-triage.ts | 84 ++++-- src/server/services/ai.ts | 282 ++++++++++-------- tests/unit/ai-triage-lease.test.ts | 15 + tests/unit/ai-triage-parse.test.ts | 154 ++++++++-- 12 files changed, 909 insertions(+), 330 deletions(-) create mode 100644 src/lib/ai-triage.ts create mode 100644 src/server/routers/__tests__/ai-triage.test.ts create mode 100644 tests/unit/ai-triage-lease.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index 0ba1055f..40302e3f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -12669,3 +12669,28 @@ focused database suites passed (156 tests). The parallel full Vitest run passed 9-test file passed immediately in isolation. A fresh production build passed the Command Center and issue-flow Playwright specs, and the notification drawer focus-containment/restore regression also passed (3 focused browser tests). + +--- + +## 2026-07-14 — AXI-104 AI triage audit and repair + +Audited the full AI-triage path after AXI-104 exposed a Hermes response saying +`Unknown tool: submit_triage`. Hermes now receives an exact plain-JSON response +contract instead of a request-scoped function that its server-side tool +registry cannot execute. Direct OpenAI-compatible providers retain forced tool +output. The compatibility parser now safely resolves unique label names, agent +profile keys, and agent names, while restricting name-based assignments to +explicit assignment clauses and stripping provider/tool failure prose from the +operator-facing rationale. + +Unified Triage, Coach, description assist, plan generation, and AI settings +health around the same workspace-first provider credential resolver. Added +atomic triage claims and apply decisions, rejected duplicate in-flight reruns, +added a five-minute claim lease plus stale-only Retry so process exits cannot +strand the card in `PENDING`, and made Apply run the standard assignment lifecycle: label audit/events, +manual dispatch attribution, engagement-mode metadata, previous-run cleanup, +agent templates, and a single current wake target. + +Verification: focused parser, lease, and database router suites passed 17 tests; lint +passed with existing repository warnings; typecheck passed; and the full +Vitest suite passed 1,223 tests with 1 skip. diff --git a/docs/agents/ai-triage-and-coach.md b/docs/agents/ai-triage-and-coach.md index 7571121a..59d77789 100644 --- a/docs/agents/ai-triage-and-coach.md +++ b/docs/agents/ai-triage-and-coach.md @@ -22,12 +22,17 @@ When enabled, two columns control routing: ### Provider matrix -| Provider | Default model | Env vars | -| --- | --- | --- | -| `hermes` | `claude-haiku-4-5-20251001` | `HERMES_GATEWAY_URL`, `HERMES_GATEWAY_TOKEN` | -| `openai` | `gpt-4o-mini` | `OPENAI_API_KEY`, `OPENAI_BASE_URL` | -| `anthropic` | `claude-haiku-4-5-20251001` | `ANTHROPIC_API_KEY` | -| `custom` | `gpt-4o-mini` | `FORGE_AI_BASE_URL`, `FORGE_AI_API_KEY` | +| Provider | Default model | Env vars | +| ----------- | --------------------------- | -------------------------------------------- | +| `hermes` | `claude-haiku-4-5-20251001` | `HERMES_GATEWAY_URL`, `HERMES_GATEWAY_TOKEN` | +| `openai` | `gpt-4o-mini` | `OPENAI_API_KEY`, `OPENAI_BASE_URL` | +| `anthropic` | `claude-haiku-4-5-20251001` | `ANTHROPIC_API_KEY` | +| `custom` | `gpt-4o-mini` | `FORGE_AI_BASE_URL`, `FORGE_AI_API_KEY` | + +OpenAI, Anthropic, and custom providers can also use an enabled encrypted +`ProviderCredential` stored for the workspace. Stored credentials take +precedence over environment variables and are shared by Triage, Coach, +description assist, and plan generation. `custom` exists for OpenAI-compatible endpoints that aren't OpenAI themselves — Together, Groq, vLLM behind a proxy, a local LLM gateway, etc. @@ -47,8 +52,8 @@ Suggests priority, labels, and an assignee on issue create. ### Knob -| Column | Purpose | -| --- | --- | +| Column | Purpose | +| ---------------------------- | -------------------------------------------------------------------------------------- | | `Workspace.aiTriageOnCreate` | Default `true`. When `aiEnabled` is also true, every new issue triggers a triage call. | ### How it runs @@ -66,8 +71,8 @@ The call loads four pieces of context: capabilities, role). - A small recent-issues sample (titles only) for tone calibration. -It then issues a structured tool-use call against a single tool named -`submit_triage`: +Direct OpenAI-compatible providers receive a structured tool-use call against +a single tool named `submit_triage`: ```ts const tool = { @@ -86,24 +91,33 @@ const tool = { }; ``` -The tool input is validated, mapped against the workspace's actual labels -and agents, and persisted onto the issue: - -| Column | Set to | -| --- | --- | -| `aiTriageStatus` | `READY` on success; `ERROR` on failure. Starts at `PENDING` while in flight. | -| `aiSuggestedPriority` | The model's `priority` choice. | -| `aiSuggestedLabelIds[]` | The model's `label_ids`, filtered to ids that exist in the workspace. | -| `aiSuggestedAgentId` | The model's `agent_id`, or null. | -| `aiTriageReasoning` | The model's free-text rationale. | -| `aiTriagedAt` | Timestamp of completion. | -| `aiTriageDecidedAt` | Set when a human applies or dismisses the suggestion. | +Hermes receives the same schema as a plain-JSON response contract. Forge does +not register `submit_triage` with Hermes because the gateway owns its own +server-side tool registry; sending a request-scoped function there would turn +the schema into an executable agent tool and fail as unknown. + +The response is validated, mapped against the workspace's actual labels and +agents, and persisted onto the issue. Exact ids are preferred. Unique label +names, agent profile keys, and agent names are accepted as a compatibility +fallback; ambiguous names are ignored. Name-based assignment is only parsed +from an explicit agent/assignee clause, never from free-form reasoning. + +| Column | Set to | +| ----------------------- | ---------------------------------------------------------------------------- | +| `aiTriageStatus` | `READY` on success; `ERROR` on failure. Starts at `PENDING` while in flight. | +| `aiSuggestedPriority` | The model's `priority` choice. | +| `aiSuggestedLabelIds[]` | The model's `label_ids`, filtered to ids that exist in the workspace. | +| `aiSuggestedAgentId` | The model's `agent_id`, or null. | +| `aiTriageReasoning` | The model's free-text rationale. | +| `aiTriagedAt` | Timestamp of completion. | +| `aiTriageDecidedAt` | Set when a human applies or dismisses the suggestion. | ### Idempotency -Triage skips any issue with `aiTriageStatus IS NOT NULL`. So if you -manually re-trigger triage you have to clear the status first; otherwise -the call is a no-op. +The runner atomically claims an issue by changing a null status to `PENDING`. +Concurrent create hooks or rerun requests therefore produce at most one model +call. The public rerun mutation clears a completed/failed/decided state before +starting a new claim and rejects a duplicate request while one is pending. ### What the user sees @@ -112,7 +126,9 @@ proposed priority, labels, and agent, plus the reasoning. Two buttons: - **Apply** — writes the suggested values onto the issue's actual `priority`, `labels[]`, and `assignedAgentId`. Sets `aiTriageStatus = - APPLIED` and `aiTriageDecidedAt`. +APPLIED` and `aiTriageDecidedAt`. Label changes are audited, and agent + changes run the standard manual-assignment lifecycle (dispatch reason, + engagement mode, run cleanup, template application, and watcher wake target). - **Dismiss** — leaves the issue alone. Sets `aiTriageStatus = DISMISSED` and `aiTriageDecidedAt`. @@ -125,8 +141,8 @@ Posts a diagnostic comment when work goes off-track. ### Knob -| Column | Purpose | -| --- | --- | +| Column | Purpose | +| -------------------------- | ------------------------------------------------------------------------ | | `Workspace.aiCoachEnabled` | Default `true`. Requires a `COACH`-role agent to exist in the workspace. | ### How it runs diff --git a/src/components/ai-triage-card.tsx b/src/components/ai-triage-card.tsx index e383e2e8..97718f0b 100644 --- a/src/components/ai-triage-card.tsx +++ b/src/components/ai-triage-card.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { isAiTriagePendingStale } from "@/lib/ai-triage"; /** * AI triage suggestion card. Renders above the issue description when the @@ -17,13 +18,7 @@ import { Badge } from "@/components/ui/badge"; interface Issue { id: string; aiTriageStatus: "PENDING" | "READY" | "APPLIED" | "DISMISSED" | "ERROR" | null; - aiSuggestedPriority: - | "NONE" - | "LOW" - | "MEDIUM" - | "HIGH" - | "URGENT" - | null; + aiSuggestedPriority: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "URGENT" | null; aiSuggestedLabelIds: string[]; aiSuggestedAgentId: string | null; aiTriageReasoning: string | null; @@ -49,13 +44,7 @@ const PRIORITY_TONE: Record = { URGENT: "bg-red-400/15 text-red-200", }; -export function AiTriageCard({ - issue, - slug, -}: { - issue: Issue; - slug: string; -}) { +export function AiTriageCard({ issue, slug }: { issue: Issue; slug: string }) { const utils = trpc.useUtils(); const { data: allLabels } = trpc.label.list.useQuery(); const { data: agents } = trpc.agent.list.useQuery(); @@ -70,14 +59,8 @@ export function AiTriageCard({ useEffect(() => { if (issue.aiSuggestedPriority === issue.priority) setApplyPriority(false); - if (issue.aiSuggestedAgentId === (issue.assignedAgent?.id ?? null)) - setApplyAgent(false); - }, [ - issue.aiSuggestedPriority, - issue.priority, - issue.aiSuggestedAgentId, - issue.assignedAgent, - ]); + if (issue.aiSuggestedAgentId === (issue.assignedAgent?.id ?? null)) setApplyAgent(false); + }, [issue.aiSuggestedPriority, issue.priority, issue.aiSuggestedAgentId, issue.assignedAgent]); const apply = trpc.ai.triageApply.useMutation({ onSuccess: () => { @@ -113,10 +96,25 @@ export function AiTriageCard({ if (status === "APPLIED" || status === "DISMISSED") return null; if (status === "PENDING") { + const stale = isAiTriagePendingStale(issue.aiTriagedAt); return ( -
- - Triaging… +
+
+ + {stale ? "Triage is taking longer than expected." : "Triaging…"} +
+ {stale && ( + + )}
); } @@ -154,9 +152,7 @@ export function AiTriageCard({ } // READY - const labelLookup = new Map( - (allLabels ?? []).map((l) => [l.id, l]), - ); + const labelLookup = new Map((allLabels ?? []).map((l) => [l.id, l])); const agentLookup = new Map((agents ?? []).map((a) => [a.id, a])); const newLabels = issue.aiSuggestedLabelIds.filter( @@ -165,12 +161,9 @@ export function AiTriageCard({ const suggestedAgent = issue.aiSuggestedAgentId ? agentLookup.get(issue.aiSuggestedAgentId) : null; - const priorityChanged = - issue.aiSuggestedPriority && - issue.aiSuggestedPriority !== issue.priority; + const priorityChanged = issue.aiSuggestedPriority && issue.aiSuggestedPriority !== issue.priority; - const nothingToApply = - !priorityChanged && newLabels.length === 0 && !suggestedAgent; + const nothingToApply = !priorityChanged && newLabels.length === 0 && !suggestedAgent; return (
@@ -200,11 +193,7 @@ export function AiTriageCard({
{priorityChanged && ( - + @@ -220,11 +209,7 @@ export function AiTriageCard({ )} {newLabels.length > 0 && ( - +
{newLabels.map((id) => { const l = labelLookup.get(id); @@ -239,17 +224,11 @@ export function AiTriageCard({ )} {suggestedAgent && ( - + {suggestedAgent.profileKey} - - ({suggestedAgent.name}) - + ({suggestedAgent.name}) )} @@ -292,9 +271,7 @@ export function AiTriageCard({ }) } disabled={ - apply.isPending || - nothingToApply || - (!applyPriority && !applyLabels && !applyAgent) + apply.isPending || nothingToApply || (!applyPriority && !applyLabels && !applyAgent) } > {apply.isPending ? "Applying…" : "Apply"} diff --git a/src/lib/ai-triage.ts b/src/lib/ai-triage.ts new file mode 100644 index 00000000..df85efd3 --- /dev/null +++ b/src/lib/ai-triage.ts @@ -0,0 +1,15 @@ +/** + * Technical lease for a single small (512-token) triage completion. This is + * not a workspace policy knob: it only bounds ownership of an in-flight + * background claim so a process exit cannot strand PENDING forever. + */ +export const AI_TRIAGE_PENDING_LEASE_MS = 5 * 60 * 1000; + +export function isAiTriagePendingStale( + startedAt: Date | string | null | undefined, + now = Date.now(), +): boolean { + if (!startedAt) return true; + const timestamp = new Date(startedAt).getTime(); + return !Number.isFinite(timestamp) || now - timestamp >= AI_TRIAGE_PENDING_LEASE_MS; +} diff --git a/src/server/routers/__tests__/ai-triage.test.ts b/src/server/routers/__tests__/ai-triage.test.ts new file mode 100644 index 00000000..5c7b62cf --- /dev/null +++ b/src/server/routers/__tests__/ai-triage.test.ts @@ -0,0 +1,211 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { AiTriageStatus, Priority } from "@prisma/client"; +import { aiRouter } from "@/server/routers/ai"; +import { encryptSecret } from "@/server/crypto"; +import { resolveWorkspaceProviderClient } from "@/server/services/ai-providers"; +import { + buildContext, + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +async function setup() { + const fixture = await createWorkspaceFixture({ keyPrefix: "AIT" }); + fixtures.push(fixture); + const caller = aiRouter.createCaller(await buildContext(fixture)); + return { fixture, caller, prisma: getPrisma() }; +} + +describe("aiRouter triage lifecycle", () => { + it("applies suggestions with full label audit and assignment lifecycle side effects", async () => { + const { fixture, caller, prisma } = await setup(); + const issue = await createIssue(fixture, { title: "Repair Hermes triage" }); + const [oldAgent, newAgent, label] = await Promise.all([ + prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Old owner", + profileKey: "old-owner", + }, + }), + prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Victor", + profileKey: "victor", + templateMarkdown: "## Investigation\n\nDocument the root cause.", + }, + }), + prisma.label.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Bug", + color: "#a16207", + }, + }), + ]); + await prisma.issue.update({ + where: { id: issue.id }, + data: { + assignedAgentId: oldAgent.id, + aiTriageStatus: AiTriageStatus.READY, + aiSuggestedPriority: Priority.LOW, + aiSuggestedLabelIds: [label.id], + aiSuggestedAgentId: newAgent.id, + aiTriageReasoning: "Scoped configuration bug.", + }, + }); + const previousRun = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: oldAgent.id, + }, + }); + await prisma.issueWatcher.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: oldAgent.id, + wakeOnActivity: true, + }, + }); + + await caller.triageApply({ issueId: issue.id }); + + const applied = await prisma.issue.findUniqueOrThrow({ + where: { id: issue.id }, + include: { labels: true }, + }); + expect(applied).toMatchObject({ + priority: Priority.LOW, + assignedAgentId: newAgent.id, + aiTriageStatus: AiTriageStatus.APPLIED, + description: "## Investigation\n\nDocument the root cause.", + }); + expect(applied.labels.map((row) => row.labelId)).toEqual([label.id]); + expect(applied.dispatchReason).toMatchObject({ + mode: "MANUAL", + picked: "victor", + }); + expect( + (await prisma.agentRun.findUniqueOrThrow({ where: { id: previousRun.id } })).status, + ).toBe("ABANDONED"); + + const watchers = await prisma.issueWatcher.findMany({ + where: { issueId: issue.id, agentId: { not: null } }, + select: { agentId: true, wakeOnActivity: true }, + }); + expect(watchers).toEqual( + expect.arrayContaining([ + { agentId: oldAgent.id, wakeOnActivity: false }, + { agentId: newAgent.id, wakeOnActivity: true }, + ]), + ); + + const audits = await prisma.auditLog.findMany({ + where: { entityId: issue.id }, + select: { action: true }, + }); + expect(audits.map((audit) => audit.action)).toEqual( + expect.arrayContaining([ + "ai-triage-apply-priority", + "ai-triage-apply-labels", + "ai-triage-apply-agent", + "apply-agent-template", + ]), + ); + const assignmentEvent = await prisma.activityEvent.findFirstOrThrow({ + where: { + subjectId: issue.id, + kind: "AGENT_ASSIGNED", + }, + orderBy: { createdAt: "desc" }, + }); + expect(assignmentEvent.payload).toMatchObject({ + agentId: newAgent.id, + previousAgentId: oldAgent.id, + engagementMode: "EXECUTE", + source: "ai-triage", + }); + }); + + it("rejects duplicate reruns while triage is pending", async () => { + const { fixture, caller, prisma } = await setup(); + const issue = await createIssue(fixture); + await prisma.issue.update({ + where: { id: issue.id }, + data: { aiTriageStatus: AiTriageStatus.PENDING, aiTriagedAt: new Date() }, + }); + + await expect(caller.triageRerun({ issueId: issue.id })).rejects.toMatchObject({ + code: "CONFLICT", + }); + expect((await prisma.issue.findUniqueOrThrow({ where: { id: issue.id } })).aiTriageStatus).toBe( + AiTriageStatus.PENDING, + ); + }); + + it("does not decide a suggestion when no fields are selected", async () => { + const { fixture, caller, prisma } = await setup(); + const issue = await createIssue(fixture); + await prisma.issue.update({ + where: { id: issue.id }, + data: { aiTriageStatus: AiTriageStatus.READY }, + }); + + await expect( + caller.triageApply({ + issueId: issue.id, + applyPriority: false, + applyLabels: false, + applyAgent: false, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect((await prisma.issue.findUniqueOrThrow({ where: { id: issue.id } })).aiTriageStatus).toBe( + AiTriageStatus.READY, + ); + }); + + it("reports and resolves an enabled workspace credential without an env key", async () => { + const { fixture, caller, prisma } = await setup(); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { aiProvider: "openai", aiModel: null }, + }); + await prisma.providerCredential.create({ + data: { + workspaceId: fixture.workspace.id, + providerId: "openai", + apiKeyEnc: encryptSecret("test-workspace-key"), + defaultModel: "workspace-test-model", + enabled: true, + }, + }); + + const status = await caller.status(); + expect(status).toMatchObject({ + activeProvider: "openai", + activeProviderAvailable: true, + apiKeyConfigured: true, + }); + const resolved = await resolveWorkspaceProviderClient(prisma, fixture.workspace.id, "openai"); + expect(resolved).toMatchObject({ + providerId: "openai", + defaultModel: "workspace-test-model", + }); + }); +}); diff --git a/src/server/routers/ai.ts b/src/server/routers/ai.ts index 0dfd6d13..d6308bb7 100644 --- a/src/server/routers/ai.ts +++ b/src/server/routers/ai.ts @@ -1,15 +1,22 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { AgentRole, AiTriageStatus, EventKind } from "@prisma/client"; +import type { EngagementMode } from "@prisma/client"; import { router, workspaceProcedure, adminProcedure } from "@/server/trpc"; import { recordChange } from "@/server/audit"; import { triageIssue } from "@/server/services/ai-triage"; +import { listProviders, runDescriptionDraft, runDescriptionEnhance } from "@/server/services/ai"; import { - listProviders, - runDescriptionDraft, - runDescriptionEnhance, -} from "@/server/services/ai"; + resolveWorkspaceProviderClient, + workspaceChatProviderAvailability, +} from "@/server/services/ai-providers"; +import { recordManualDispatchReason } from "@/server/services/dispatcher"; +import { resolveEngagementMode } from "@/server/services/engagement-mode"; +import { maybeApplyAgentTemplate } from "@/server/services/agent-template"; +import { abandonRunsForAgentReassignment } from "@/server/services/agent-run"; +import { setIssueAgentWakeTarget } from "@/server/services/issue-watchers"; import { encryptSecret } from "@/server/crypto"; +import { isAiTriagePendingStale } from "@/lib/ai-triage"; /** Providers that accept a DB-backed key (hermes is a runtime, not a key). */ const CREDENTIAL_PROVIDER_IDS = ["openai", "anthropic", "custom"] as const; @@ -34,11 +41,16 @@ export const aiRouter = router({ }, select: { id: true, profileKey: true, name: true }, }); - const providers = listProviders(); const ws = await ctx.db.workspace.findUniqueOrThrow({ where: { id: ctx.workspaceId }, select: { aiProvider: true, aiModel: true }, }); + const isAvailable = await workspaceChatProviderAvailability(ctx.db, ctx.workspaceId); + const providers = listProviders().map((provider) => ({ + ...provider, + available: isAvailable(provider.id), + unavailableReason: isAvailable(provider.id) ? undefined : provider.unavailableReason, + })); const active = providers.find((p) => p.id === ws.aiProvider) ?? providers[0]; return { coach, @@ -78,9 +90,13 @@ export const aiRouter = router({ }, select: { id: true, profileKey: true, name: true, status: true }, }); - const providers = listProviders(); - const provider = - providers.find((p) => p.id === (ws.aiProvider ?? "hermes")) ?? providers[0]; + const isAvailable = await workspaceChatProviderAvailability(ctx.db, ctx.workspaceId); + const providers = listProviders().map((candidate) => ({ + ...candidate, + available: isAvailable(candidate.id), + unavailableReason: isAvailable(candidate.id) ? undefined : candidate.unavailableReason, + })); + const provider = providers.find((p) => p.id === (ws.aiProvider ?? "hermes")) ?? providers[0]; let lastFired: { issueId: string; @@ -164,19 +180,34 @@ export const aiRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - // `custom` requires a base URL to be usable. - if (input.providerId === "custom" && input.baseUrl !== undefined && !input.baseUrl) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "A custom provider needs a base URL.", - }); - } const enc = input.apiKey ? encryptSecret(input.apiKey) : undefined; const norm = (v: string | null | undefined) => v === undefined ? undefined : v === "" ? null : v; const baseUrl = norm(input.baseUrl); const defaultModel = norm(input.defaultModel); const label = norm(input.label); + // An enabled custom credential is unusable without its endpoint. Check + // the effective value so partial edits can keep an existing base URL, + // while a new/cleared credential cannot be reported as available. + if (input.providerId === "custom") { + const existing = await ctx.db.providerCredential.findUnique({ + where: { + workspaceId_providerId: { + workspaceId: ctx.workspaceId, + providerId: input.providerId, + }, + }, + select: { baseUrl: true, enabled: true }, + }); + const effectiveBaseUrl = baseUrl === undefined ? existing?.baseUrl : baseUrl; + const effectiveEnabled = input.enabled ?? existing?.enabled ?? true; + if (effectiveEnabled && !effectiveBaseUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "An enabled custom provider needs a base URL.", + }); + } + } const row = await ctx.db.providerCredential.upsert({ where: { workspaceId_providerId: { @@ -304,6 +335,12 @@ export const aiRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + if (!input.applyPriority && !input.applyLabels && !input.applyAgent) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Select at least one suggestion to apply.", + }); + } return ctx.db.$transaction(async (tx) => { const issue = await tx.issue.findFirst({ where: { @@ -328,6 +365,21 @@ export const aiRouter = router({ message: "Triage suggestion is not ready or already decided.", }); } + // Claim the decision before emitting side effects. Concurrent Apply + // requests serialize on this row; exactly one can move READY→APPLIED. + const claimed = await tx.issue.updateMany({ + where: { id: issue.id, aiTriageStatus: AiTriageStatus.READY }, + data: { + aiTriageStatus: AiTriageStatus.APPLIED, + aiTriageDecidedAt: new Date(), + }, + }); + if (claimed.count === 0) { + throw new TRPCError({ + code: "CONFLICT", + message: "This triage suggestion was already decided.", + }); + } const updates: { priority?: typeof issue.priority; @@ -335,6 +387,13 @@ export const aiRouter = router({ } = {}; const previousPriority = issue.priority; const previousAgentId = issue.assignedAgentId; + let assignedAgent: { + id: string; + profileKey: string; + engagementMode: EngagementMode | null; + } | null = null; + let previousLabelIds: string[] = []; + let addedLabelIds: string[] = []; if ( input.applyPriority && @@ -356,9 +415,12 @@ export const aiRouter = router({ workspaceId: ctx.workspaceId, archivedAt: null, }, - select: { id: true }, + select: { id: true, profileKey: true, engagementMode: true }, }); - if (agent) updates.assignedAgentId = agent.id; + if (agent) { + assignedAgent = agent; + updates.assignedAgentId = agent.id; + } } if (Object.keys(updates).length > 0) { @@ -383,9 +445,7 @@ export const aiRouter = router({ select: { labelId: true }, }); const existingSet = new Set(existing.map((e) => e.labelId)); - const toAdd = validLabels - .map((l) => l.id) - .filter((id) => !existingSet.has(id)); + const toAdd = validLabels.map((l) => l.id).filter((id) => !existingSet.has(id)); if (toAdd.length > 0) { await tx.issueLabel.createMany({ data: toAdd.map((labelId) => ({ @@ -394,6 +454,8 @@ export const aiRouter = router({ })), skipDuplicates: true, }); + previousLabelIds = Array.from(existingSet); + addedLabelIds = toAdd; } } @@ -420,7 +482,49 @@ export const aiRouter = router({ userAgent: ctx.userAgent, }); } - if (updates.assignedAgentId) { + if (addedLabelIds.length > 0) { + await recordChange(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + actorAgentId: ctx.apiKey?.linkedAgentId ?? null, + entity: "Issue", + entityId: issue.id, + action: "ai-triage-apply-labels", + before: { labelIds: previousLabelIds }, + after: { labelIds: [...previousLabelIds, ...addedLabelIds] }, + eventKind: EventKind.ISSUE_UPDATED, + subjectType: "issue", + subjectId: issue.id, + payload: { + labelIds: addedLabelIds, + operation: "add", + source: "ai-triage", + }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + } + if (updates.assignedAgentId && assignedAgent) { + const manualReason = await recordManualDispatchReason(tx, { + issueId: issue.id, + agentProfileKey: assignedAgent.profileKey, + actorId: ctx.session.user.id, + }); + const ws = await tx.workspace.findUniqueOrThrow({ + where: { id: ctx.workspaceId }, + select: { + assignmentEngagementMode: true, + mentionEngagementPolicy: true, + mentionDefaultMode: true, + }, + }); + const resolved = resolveEngagementMode({ + surface: "assignment", + workspace: { + ...ws, + assignmentAgentEngagementMode: assignedAgent.engagementMode, + }, + }); await recordChange(tx, { workspaceId: ctx.workspaceId, actorId: ctx.session.user.id, @@ -435,21 +539,32 @@ export const aiRouter = router({ payload: { agentId: updates.assignedAgentId, previousAgentId, + dispatchReason: manualReason, + engagementMode: resolved.mode, + engagementSource: resolved.source, source: "ai-triage", }, ip: ctx.ip, userAgent: ctx.userAgent, }); + if (previousAgentId) { + await abandonRunsForAgentReassignment(tx, { + workspaceId: ctx.workspaceId, + issueId: issue.id, + agentId: previousAgentId, + actorId: ctx.session.user.id, + actorAgentId: ctx.apiKey?.linkedAgentId ?? null, + summary: "Abandoned because AI triage reassigned the issue to another agent.", + }); + } + await maybeApplyAgentTemplate(tx, issue.id, assignedAgent.id); + await setIssueAgentWakeTarget(tx, { + workspaceId: ctx.workspaceId, + issueId: issue.id, + agentId: assignedAgent.id, + }); } - await tx.issue.update({ - where: { id: issue.id }, - data: { - aiTriageStatus: AiTriageStatus.APPLIED, - aiTriageDecidedAt: new Date(), - }, - }); - return { ok: true }; }); }), @@ -500,12 +615,21 @@ export const aiRouter = router({ workspaceId: ctx.workspaceId, deletedAt: null, }, - select: { id: true }, + select: { id: true, aiTriageStatus: true, aiTriagedAt: true }, }); if (!issue) throw new TRPCError({ code: "NOT_FOUND" }); + if ( + issue.aiTriageStatus === AiTriageStatus.PENDING && + !isAiTriagePendingStale(issue.aiTriagedAt) + ) { + throw new TRPCError({ + code: "CONFLICT", + message: "AI triage is already running for this issue.", + }); + } // Reset state so the runner doesn't short-circuit. - await ctx.db.issue.update({ - where: { id: issue.id }, + const reset = await ctx.db.issue.updateMany({ + where: { id: issue.id, aiTriageStatus: issue.aiTriageStatus }, data: { aiTriageStatus: null, aiTriagedAt: null, @@ -516,6 +640,12 @@ export const aiRouter = router({ aiTriageReasoning: null, }, }); + if (reset.count === 0) { + throw new TRPCError({ + code: "CONFLICT", + message: "The triage state changed. Try again.", + }); + } void triageIssue(issue.id); return { ok: true }; }), @@ -542,15 +672,21 @@ export const aiRouter = router({ if (!issue.workspace.aiEnabled) throw new TRPCError({ code: "PRECONDITION_FAILED", - message: - "AI is off for this workspace. Enable it in Settings → Workspace → AI.", + message: "AI is off for this workspace. Enable it in Settings → Workspace → AI.", }); - const markdown = await runDescriptionDraft({ - title: issue.title, - description: issue.description, - provider: issue.workspace.aiProvider, - model: issue.workspace.aiModel, - }); + const providerClient = await resolveWorkspaceProviderClient( + ctx.db, + ctx.workspaceId, + issue.workspace.aiProvider, + ); + const markdown = providerClient + ? await runDescriptionDraft(providerClient, { + title: issue.title, + description: issue.description, + provider: issue.workspace.aiProvider, + model: issue.workspace.aiModel, + }) + : null; if (!markdown) throw new TRPCError({ code: "BAD_GATEWAY", @@ -582,15 +718,21 @@ export const aiRouter = router({ if (!issue.workspace.aiEnabled) throw new TRPCError({ code: "PRECONDITION_FAILED", - message: - "AI is off for this workspace. Enable it in Settings → Workspace → AI.", + message: "AI is off for this workspace. Enable it in Settings → Workspace → AI.", }); - const markdown = await runDescriptionEnhance({ - title: issue.title, - description: issue.description, - provider: issue.workspace.aiProvider, - model: issue.workspace.aiModel, - }); + const providerClient = await resolveWorkspaceProviderClient( + ctx.db, + ctx.workspaceId, + issue.workspace.aiProvider, + ); + const markdown = providerClient + ? await runDescriptionEnhance(providerClient, { + title: issue.title, + description: issue.description, + provider: issue.workspace.aiProvider, + model: issue.workspace.aiModel, + }) + : null; if (!markdown) throw new TRPCError({ code: "BAD_GATEWAY", @@ -653,10 +795,7 @@ export const aiRouter = router({ }); /** Format a Hermes gateway response into a compact markdown block. */ -function formatHermesInfo( - resource: "skills" | "memory" | "health", - data: unknown, -): string { +function formatHermesInfo(resource: "skills" | "memory" | "health", data: unknown): string { if (data == null) return `_No ${resource} data returned._`; if (resource === "skills") { const list = Array.isArray(data) diff --git a/src/server/services/ai-coach.ts b/src/server/services/ai-coach.ts index 94f209a3..ed423df3 100644 --- a/src/server/services/ai-coach.ts +++ b/src/server/services/ai-coach.ts @@ -3,6 +3,7 @@ import type { Prisma, PrismaClient } from "@prisma/client"; import { AgentRole, EventKind } from "@prisma/client"; import { logger } from "@/server/logger"; import { runCoachComment } from "@/server/services/ai"; +import { resolveWorkspaceProviderClient } from "@/server/services/ai-providers"; import { recordChange } from "@/server/audit"; /** @@ -11,7 +12,8 @@ import { recordChange } from "@/server/audit"; * diagnostic comment when: * - the workspace has aiEnabled = true AND aiCoachEnabled = true * - a COACH-role agent exists for the workspace - * - ANTHROPIC_API_KEY is set + * - the selected workspace provider is available via a stored credential + * or environment fallback * * Failures are swallowed and logged. The Coach is decorative — never * blocks the underlying event flow. @@ -91,7 +93,14 @@ export async function coachOnEvent( .reverse() .map((c) => c.body); - const body = await runCoachComment({ + const providerClient = await resolveWorkspaceProviderClient( + client, + params.workspaceId, + ws.aiProvider, + ); + if (!providerClient) return; + + const body = await runCoachComment(providerClient, { eventKind: params.eventKind, issue: { id: issue.id, diff --git a/src/server/services/ai-providers.ts b/src/server/services/ai-providers.ts index e18477a6..8c4b8342 100644 --- a/src/server/services/ai-providers.ts +++ b/src/server/services/ai-providers.ts @@ -1,6 +1,6 @@ import "server-only"; import OpenAI from "openai"; -import type { PrismaClient } from "@prisma/client"; +import type { Prisma, PrismaClient } from "@prisma/client"; import { logger } from "@/server/logger"; import { decryptSecret } from "@/server/crypto"; @@ -76,8 +76,7 @@ const PROVIDERS: Record = { supportsImageInput: true, resolve: () => { const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) - return { ok: false, reason: "OPENAI_API_KEY not set" }; + if (!apiKey) return { ok: false, reason: "OPENAI_API_KEY not set" }; const baseURL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; return { ok: true, baseURL, apiKey }; }, @@ -85,14 +84,12 @@ const PROVIDERS: Record = { anthropic: { id: "anthropic", label: "Anthropic (direct)", - description: - "Anthropic's OpenAI-compatible endpoint. Set ANTHROPIC_API_KEY.", + description: "Anthropic's OpenAI-compatible endpoint. Set ANTHROPIC_API_KEY.", defaultModel: "claude-haiku-4-5-20251001", supportsImageInput: true, resolve: () => { const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) - return { ok: false, reason: "ANTHROPIC_API_KEY not set" }; + if (!apiKey) return { ok: false, reason: "ANTHROPIC_API_KEY not set" }; // Anthropic's OpenAI-compat surface lives under /v1/. return { ok: true, @@ -161,9 +158,7 @@ export function isProviderAvailable(id: string | null | undefined): boolean { * Build a configured OpenAI SDK client for the named provider, or null * if its env isn't set up. Callers treat null as "AI unavailable". */ -export function getClient( - providerId: string | null | undefined, -): { +export function getClient(providerId: string | null | undefined): { client: OpenAI; defaultModel: string; providerId: ProviderId; @@ -172,10 +167,7 @@ export function getClient( const provider = getProvider(providerId); const r = provider.resolve(); if (!r.ok) { - logger.warn( - { provider: provider.id, reason: r.reason }, - "ai-providers: provider unavailable", - ); + logger.warn({ provider: provider.id, reason: r.reason }, "ai-providers: provider unavailable"); return null; } return { @@ -235,7 +227,7 @@ function canonicalBaseFor( * "no chat model configured" notice — never falls back to another platform). */ export async function resolveWorkspaceProviderClient( - db: PrismaClient, + db: PrismaClient | Prisma.TransactionClient, workspaceId: string, providerId: string | null | undefined, ): Promise { @@ -247,10 +239,7 @@ export async function resolveWorkspaceProviderClient( if (cred && cred.enabled && cred.apiKeyEnc) { const base = canonicalBaseFor(provider.id, cred.baseUrl); if (!base) { - logger.warn( - { provider: provider.id }, - "ai-providers: custom credential missing base URL", - ); + logger.warn({ provider: provider.id }, "ai-providers: custom credential missing base URL"); return getClient(provider.id); } let apiKey: string; @@ -276,13 +265,18 @@ export async function resolveWorkspaceProviderClient( * to decide if a Completions turn will reach a model. One query, sync predicate. */ export async function workspaceChatProviderAvailability( - db: PrismaClient, + db: PrismaClient | Prisma.TransactionClient, workspaceId: string, ): Promise<(providerId: string) => boolean> { const creds = await db.providerCredential.findMany({ where: { workspaceId, enabled: true, NOT: { apiKeyEnc: null } }, - select: { providerId: true }, + select: { providerId: true, baseUrl: true }, }); - const dbSet = new Set(creds.map((c) => c.providerId)); - return (providerId: string) => dbSet.has(getProvider(providerId).id) || isProviderAvailable(providerId); + const dbSet = new Set( + creds + .filter((credential) => credential.providerId !== "custom" || credential.baseUrl?.trim()) + .map((credential) => credential.providerId), + ); + return (providerId: string) => + dbSet.has(getProvider(providerId).id) || isProviderAvailable(providerId); } diff --git a/src/server/services/ai-triage.ts b/src/server/services/ai-triage.ts index 2495a01c..7982eefb 100644 --- a/src/server/services/ai-triage.ts +++ b/src/server/services/ai-triage.ts @@ -2,18 +2,19 @@ import "server-only"; import { AiTriageStatus, AgentRole } from "@prisma/client"; import { db } from "@/server/db"; import { logger } from "@/server/logger"; -import { runTriage, aiAvailable } from "@/server/services/ai"; +import { runTriage } from "@/server/services/ai"; +import { resolveWorkspaceProviderClient } from "@/server/services/ai-providers"; /** * Background-runnable triage routine. Loads the issue + workspace context, * calls the AI service, persists the suggestion. Always idempotent: a - * second run for the same issue is a no-op once `aiTriageStatus` is - * non-null and not `PENDING`. + * second run for the same issue is a no-op once `aiTriageStatus` is non-null. * * Caller is responsible for fire-and-forget — this function never throws * out (it logs and writes ERROR status on failure). */ export async function triageIssue(issueId: string): Promise { + let claimStartedAt: Date | null = null; try { const issue = await db.issue.findUnique({ where: { id: issueId }, @@ -22,7 +23,6 @@ export async function triageIssue(issueId: string): Promise { workspaceId: true, title: true, description: true, - aiTriageStatus: true, workspace: { select: { aiEnabled: true, @@ -35,14 +35,41 @@ export async function triageIssue(issueId: string): Promise { }); if (!issue) return; if (!issue.workspace.aiEnabled || !issue.workspace.aiTriageOnCreate) return; - // Idempotent: if we already ran (or someone is running concurrently), - // skip. PENDING means a sibling call is in flight. - if (issue.aiTriageStatus !== null) return; - - await db.issue.update({ - where: { id: issueId }, - data: { aiTriageStatus: AiTriageStatus.PENDING }, + // Atomic claim: two create/rerun workers can observe the same null state, + // but only one is allowed to transition it to PENDING and call the model. + claimStartedAt = new Date(); + const claimed = await db.issue.updateMany({ + where: { id: issueId, aiTriageStatus: null }, + data: { + aiTriageStatus: AiTriageStatus.PENDING, + aiTriagedAt: claimStartedAt, + }, }); + if (claimed.count === 0) { + claimStartedAt = null; + return; + } + + const providerClient = await resolveWorkspaceProviderClient( + db, + issue.workspaceId, + issue.workspace.aiProvider, + ); + if (!providerClient) { + await db.issue.updateMany({ + where: { + id: issueId, + aiTriageStatus: AiTriageStatus.PENDING, + aiTriagedAt: claimStartedAt, + }, + data: { + aiTriageStatus: AiTriageStatus.ERROR, + aiTriageReasoning: `The “${issue.workspace.aiProvider ?? "hermes"}” AI provider isn't configured for this workspace. Set it up in Settings → Workspace → AI.`, + aiTriagedAt: new Date(), + }, + }); + return; + } const [labels, agents, recent] = await Promise.all([ db.label.findMany({ @@ -75,7 +102,7 @@ export async function triageIssue(issueId: string): Promise { }), ]); - const suggestion = await runTriage({ + const suggestion = await runTriage(providerClient, { title: issue.title, description: issue.description, workspaceLabels: labels, @@ -86,24 +113,28 @@ export async function triageIssue(issueId: string): Promise { }); if (!suggestion) { - // Distinguish "provider not wired up" from "model gave nothing usable" - // so the card can show an actionable reason instead of a bare error. - const reason = aiAvailable(issue.workspace.aiProvider) - ? "The model didn't return a usable triage suggestion. Try Re-run, or switch the AI provider/model in Settings → Workspace → AI." - : `The “${issue.workspace.aiProvider ?? "hermes"}” AI provider isn't configured for this workspace. Set it up in Settings → Workspace → AI.`; - await db.issue.update({ - where: { id: issueId }, + await db.issue.updateMany({ + where: { + id: issueId, + aiTriageStatus: AiTriageStatus.PENDING, + aiTriagedAt: claimStartedAt, + }, data: { aiTriageStatus: AiTriageStatus.ERROR, - aiTriageReasoning: reason, + aiTriageReasoning: + "The model didn't return a usable triage suggestion. Try Re-run, or switch the AI provider/model in Settings → Workspace → AI.", aiTriagedAt: new Date(), }, }); return; } - await db.issue.update({ - where: { id: issueId }, + await db.issue.updateMany({ + where: { + id: issueId, + aiTriageStatus: AiTriageStatus.PENDING, + aiTriagedAt: claimStartedAt, + }, data: { aiTriageStatus: AiTriageStatus.READY, aiSuggestedPriority: suggestion.priority, @@ -115,9 +146,14 @@ export async function triageIssue(issueId: string): Promise { }); } catch (err) { logger.warn({ err, issueId }, "ai-triage: unexpected failure"); + if (!claimStartedAt) return; await db.issue - .update({ - where: { id: issueId }, + .updateMany({ + where: { + id: issueId, + aiTriageStatus: AiTriageStatus.PENDING, + aiTriagedAt: claimStartedAt, + }, data: { aiTriageStatus: AiTriageStatus.ERROR, aiTriageReasoning: diff --git a/src/server/services/ai.ts b/src/server/services/ai.ts index 8228dead..659f6779 100644 --- a/src/server/services/ai.ts +++ b/src/server/services/ai.ts @@ -2,12 +2,7 @@ import "server-only"; import { Priority } from "@prisma/client"; import type OpenAI from "openai"; import { logger } from "@/server/logger"; -import { - getClient, - isProviderAvailable, - listProviders, - type ResolvedProviderClient, -} from "@/server/services/ai-providers"; +import { listProviders, type ResolvedProviderClient } from "@/server/services/ai-providers"; /** * AI service — provider-agnostic wrapper around OpenAI's @@ -66,20 +61,11 @@ export interface CoachInput { model?: string | null; } -/** Whether the named provider has its env wired up. */ -export function aiAvailable(provider?: string | null): boolean { - return isProviderAvailable(provider ?? "hermes"); -} - export async function runTriage( + ctx: ResolvedProviderClient, input: TriageInput, ): Promise { - const ctx = getClient(input.provider); - if (!ctx) return null; - - const labelMenu = input.workspaceLabels - .map((l) => `- ${l.name} (id: ${l.id})`) - .join("\n"); + const labelMenu = input.workspaceLabels.map((l) => `- ${l.name} (id: ${l.id})`).join("\n"); const agentMenu = input.agents.length ? input.agents .map( @@ -104,7 +90,7 @@ ${labelMenu || "(none configured)"} Available agents: ${agentMenu}${recentBlock} -Suggest a priority, applicable labels (only from the list above — match by id, not name), and an agent assignment if one of the available agents is a clear fit. Skip agent assignment if no agent is a strong match. Include a 1-2 sentence reasoning the operator can read at a glance.`; +Suggest a priority, applicable labels (only from the list above), and an agent assignment if one of the available agents is a clear fit. Return label and agent ids when possible. Skip agent assignment if no agent is a strong match. Include a 1-2 sentence reasoning the operator can read at a glance.`; const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [ { @@ -122,13 +108,11 @@ Suggest a priority, applicable labels (only from the list above — match by id, label_ids: { type: "array", items: { type: "string" }, - description: - "IDs from the workspace labels list. Empty array if no labels apply.", + description: "IDs from the workspace labels list. Empty array if no labels apply.", }, agent_id: { type: ["string", "null"], - description: - "Agent id from the available agents list, or null if no clear fit.", + description: "Agent id from the available agents list, or null if no clear fit.", }, reasoning: { type: "string", @@ -143,34 +127,41 @@ Suggest a priority, applicable labels (only from the list above — match by id, let completion: OpenAI.Chat.Completions.ChatCompletion; try { - completion = await ctx.client.chat.completions.create({ + const structuredPrompt = + "You are a triage assistant for a project management tool. Be conservative — prefer NONE/LOW priority unless the issue clearly indicates urgency, and don't assign an agent unless one is a strong capability match. Do not invent label ids or agent ids; only use values from the provided lists."; + const request: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { model: input.model || ctx.defaultModel, max_tokens: 512, messages: [ { role: "system", content: - "You are a triage assistant for a project management tool. Make a single tool call to submit_triage. Be conservative — prefer NONE/LOW priority unless the issue clearly indicates urgency, and don't assign an agent unless one is a strong capability match. Do not invent label ids or agent ids; only use values from the provided lists.", + ctx.providerId === "hermes" + ? `${structuredPrompt} Return exactly one JSON object with keys priority, label_ids, agent_id, and reasoning. Do not call tools, add a preamble, or wrap the JSON in Markdown.` + : `${structuredPrompt} Make a single tool call to submit_triage.`, }, { role: "user", content: userMessage }, ], - tools, - tool_choice: { type: "function", function: { name: "submit_triage" } }, - }); + ...(ctx.providerId === "hermes" + ? {} + : { + tools, + tool_choice: { + type: "function" as const, + function: { name: "submit_triage" }, + }, + }), + }; + completion = await ctx.client.chat.completions.create(request); } catch (err) { - logger.warn( - { err, provider: ctx.providerId }, - "ai.triage: chat call failed", - ); + logger.warn({ err, provider: ctx.providerId }, "ai.triage: chat call failed"); return null; } - const validLabelIds = new Set(input.workspaceLabels.map((l) => l.id)); - const validAgentIds = new Set(input.agents.map((a) => a.id)); const suggestion = parseTriageMessage( completion.choices?.[0]?.message, - validLabelIds, - validAgentIds, + input.workspaceLabels, + input.agents, ); if (!suggestion) { logger.warn( @@ -199,19 +190,15 @@ const PRIORITY_PATTERN = "NONE|LOW|MEDIUM|HIGH|URGENT"; export function parseTriageMessage( message: unknown, - validLabelIds: Set, - validAgentIds: Set, + labels: Array<{ id: string; name: string }>, + agents: Array<{ id: string; profileKey: string; name: string }>, ): TriageSuggestion | null { for (const payload of triagePayloadCandidates(message)) { - const fromPayload = suggestionFromPayload( - payload, - validLabelIds, - validAgentIds, - ); + const fromPayload = suggestionFromPayload(payload, labels, agents); if (fromPayload) return fromPayload; } const text = messageTextContent(message); - if (text) return suggestionFromProse(text, validLabelIds, validAgentIds); + if (text) return suggestionFromProse(text, labels, agents); return null; } @@ -268,95 +255,174 @@ function jsonRecord(value: unknown): Record | null { function suggestionFromPayload( parsed: Record, - validLabelIds: Set, - validAgentIds: Set, + labels: Array<{ id: string; name: string }>, + agents: Array<{ id: string; profileKey: string; name: string }>, ): TriageSuggestion | null { const reasoning = typeof parsed.reasoning === "string" && parsed.reasoning.trim() - ? parsed.reasoning.trim().slice(0, 1000) + ? cleanTriageReasoning(parsed.reasoning) : null; // Guard against arbitrary JSON that isn't a triage payload (e.g. a stray // object embedded in prose): it must carry a priority or a reasoning. if (!("priority" in parsed) && reasoning === null) return null; - const priority = isPriority(parsed.priority) ? parsed.priority : Priority.NONE; + const priority = coercePriority(parsed.priority) ?? Priority.NONE; const rawLabels = Array.isArray(parsed.label_ids) ? parsed.label_ids : Array.isArray(parsed.labelIds) ? parsed.labelIds : []; - const labelIds = rawLabels - .filter((x): x is string => typeof x === "string") - .filter((id) => validLabelIds.has(id)); + const labelIds = Array.from( + new Set( + rawLabels + .filter((x): x is string => typeof x === "string") + .map((value) => matchLabel(value, labels)) + .filter((id): id is string => id !== null), + ), + ); const agentRaw = parsed.agent_id ?? parsed.agentId; - const agentId = - typeof agentRaw === "string" && validAgentIds.has(agentRaw) - ? agentRaw - : null; + const agentId = typeof agentRaw === "string" ? matchAgent(agentRaw, agents) : null; return { priority, labelIds, agentId, - reasoning: reasoning ?? "(no reasoning provided)", + reasoning: reasoning ?? "Suggested from the issue's current context.", }; } function suggestionFromProse( text: string, - validLabelIds: Set, - validAgentIds: Set, + labels: Array<{ id: string; name: string }>, + agents: Array<{ id: string; profileKey: string; name: string }>, ): TriageSuggestion | null { const priority = prosePriority(text); if (!priority) return null; // no recognizable triage signal — let caller ERROR. - // ids are unique cuids, so a substring scan is the most format-robust match - // regardless of how the model formatted the prose. - const labelIds = [...validLabelIds].filter((id) => text.includes(id)); - const agentId = [...validAgentIds].find((id) => text.includes(id)) ?? null; + // IDs are safe to match anywhere. Human-readable names are only accepted + // from explicit Labels/Agent clauses so a name in the rationale cannot + // silently assign work to somebody. + const labelIds = labels.filter((label) => text.includes(label.id)).map((label) => label.id); + const labelClause = text.match( + /\blabels?\b\s*(?::|-|are)?\s*([^;\n.]+?)(?=\s+(?:and\s+)?assign(?:ed|ment)?\b|[;\n.]|$)/i, + )?.[1]; + if (labelClause) { + for (const label of labels) { + if (containsNormalizedPhrase(labelClause, label.name)) labelIds.push(label.id); + } + } + + let agentId = agents.find((agent) => text.includes(agent.id))?.id ?? null; + if (!agentId) { + const agentClause = + text.match(/\b(?:agent|assignee)\s*[:\-]\s*([^;\n.]+)/i)?.[1] ?? + text.match(/\bassign(?:ed)?(?:\s+to)?\s+([^;\n.]+)/i)?.[1]; + if (agentClause && !/^\s*(?:none|null|n\/a)\b/i.test(agentClause)) { + agentId = matchAgentPhrase(agentClause, agents); + } + } const reasonMatch = text.match(/reasoning[\s*_]*[:\-]\s*([\s\S]+)/i); - const reasoning = (reasonMatch?.[1]?.trim() || text.trim()).slice(0, 1000); + const reasoning = cleanTriageReasoning(reasonMatch?.[1] ?? text); - return { priority, labelIds, agentId, reasoning }; + return { priority, labelIds: Array.from(new Set(labelIds)), agentId, reasoning }; +} + +function normalizeTriageToken(value: string): string { + return value + .normalize("NFKD") + .toLocaleLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} + +function containsNormalizedPhrase(haystack: string, needle: string): boolean { + const normalizedHaystack = ` ${normalizeTriageToken(haystack)} `; + const normalizedNeedle = normalizeTriageToken(needle); + return normalizedNeedle.length > 0 && normalizedHaystack.includes(` ${normalizedNeedle} `); +} + +function matchLabel(value: string, labels: Array<{ id: string; name: string }>): string | null { + const exact = labels.find((label) => label.id === value); + if (exact) return exact.id; + const normalized = normalizeTriageToken(value); + const matches = labels.filter((label) => normalizeTriageToken(label.name) === normalized); + return new Set(matches.map((label) => label.id)).size === 1 ? matches[0]!.id : null; +} + +function matchAgent( + value: string, + agents: Array<{ id: string; profileKey: string; name: string }>, +): string | null { + const exact = agents.find((agent) => agent.id === value || agent.profileKey === value); + if (exact) return exact.id; + const normalized = normalizeTriageToken(value); + const matches = agents.filter( + (agent) => + normalizeTriageToken(agent.profileKey) === normalized || + normalizeTriageToken(agent.name) === normalized, + ); + return new Set(matches.map((agent) => agent.id)).size === 1 ? matches[0]!.id : null; +} + +function matchAgentPhrase( + value: string, + agents: Array<{ id: string; profileKey: string; name: string }>, +): string | null { + const matches = agents.filter( + (agent) => + value.includes(agent.id) || + containsNormalizedPhrase(value, agent.profileKey) || + containsNormalizedPhrase(value, agent.name), + ); + return new Set(matches.map((agent) => agent.id)).size === 1 ? matches[0]!.id : null; +} + +function cleanTriageReasoning(value: string): string { + const sentences = value + .trim() + .split(/(?<=[.!?])\s+|\r?\n+/) + .map((sentence) => sentence.trim()) + .filter(Boolean) + .filter( + (sentence) => + !/(?:submit_triage|triage submission failed)/i.test(sentence) && + !/^recommended triage\b/i.test(sentence) && + !/^(?:priority|labels?|agent|assignee)\s*[:\-]/i.test(sentence), + ); + const cleaned = sentences.join(" ").trim(); + return (cleaned || "Suggested from the issue's current context.").slice(0, 1000); } function prosePriority(text: string): Priority | null { const labelled = text.match( - new RegExp( - `priority[\\s*_]*[:\\-]?\\s*\\*{0,2}\\s*(${PRIORITY_PATTERN})`, - "i", - ), + new RegExp(`priority[\\s*_]*[:\\-]?\\s*\\*{0,2}\\s*(${PRIORITY_PATTERN})`, "i"), ); - const token = - labelled?.[1] ?? - text.match(new RegExp(`\\b(${PRIORITY_PATTERN})\\b`, "i"))?.[1]; + const token = labelled?.[1] ?? text.match(new RegExp(`\\b(${PRIORITY_PATTERN})\\b`, "i"))?.[1]; if (!token) return null; - const upper = token.toUpperCase(); + return coercePriority(token); +} + +function coercePriority(value: unknown): Priority | null { + if (typeof value !== "string") return null; + const upper = value.toUpperCase(); return isPriority(upper) ? upper : null; } export async function runCoachComment( + ctx: ResolvedProviderClient, input: CoachInput, ): Promise { - const ctx = getClient(input.provider); - if (!ctx) return null; - const eventDescription = { ISSUE_STALLED: "An assigned agent has not moved this issue out of BACKLOG/TODO within the workspace SLA. The issue is stalled.", AGENT_NOACK: "The assigned agent did not comment on or transition this issue within the required acknowledgement window after assignment.", - ISSUE_SLA_BREACH: - "This issue has passed its per-issue SLA target with no resolution.", + ISSUE_SLA_BREACH: "This issue has passed its per-issue SLA target with no resolution.", }[input.eventKind]; - const ageMinutes = Math.round( - (Date.now() - input.issue.createdAt.getTime()) / 60_000, - ); - const sinceUpdateMinutes = Math.round( - (Date.now() - input.issue.updatedAt.getTime()) / 60_000, - ); + const ageMinutes = Math.round((Date.now() - input.issue.createdAt.getTime()) / 60_000); + const sinceUpdateMinutes = Math.round((Date.now() - input.issue.updatedAt.getTime()) / 60_000); const commentsBlock = input.recentCommentBodies.length ? `\n\nRecent comments (oldest first):\n${input.recentCommentBodies @@ -398,10 +464,7 @@ Use plain prose, no bullets, no headers. Do not address the agent directly.`; ], }); } catch (err) { - logger.warn( - { err, provider: ctx.providerId }, - "ai.coach: chat call failed", - ); + logger.warn({ err, provider: ctx.providerId }, "ai.coach: chat call failed"); return null; } @@ -466,9 +529,7 @@ const DESCRIBE_SYSTEM = * exactly two fences, so a description with a genuine embedded code block is * left intact. Returns null for empty/nullish. */ -export function cleanDescriptionOutput( - content: string | null | undefined, -): string | null { +export function cleanDescriptionOutput(content: string | null | undefined): string | null { if (!content) return null; let text = content.trim(); const fenceCount = (text.match(/```/g) ?? []).length; @@ -480,10 +541,9 @@ export function cleanDescriptionOutput( } export async function runDescriptionDraft( + ctx: ResolvedProviderClient, input: DescriptionInput, ): Promise { - const ctx = getClient(input.provider); - if (!ctx) return null; try { const completion = await ctx.client.chat.completions.create({ model: input.model || ctx.defaultModel, @@ -504,13 +564,12 @@ export async function runDescriptionDraft( } export async function runDescriptionEnhance( + ctx: ResolvedProviderClient, input: DescriptionInput, ): Promise { - const ctx = getClient(input.provider); - if (!ctx) return null; const existing = input.description?.trim(); // Nothing to enhance → fall back to a fresh draft. - if (!existing) return runDescriptionDraft(input); + if (!existing) return runDescriptionDraft(ctx, input); try { const completion = await ctx.client.chat.completions.create({ model: input.model || ctx.defaultModel, @@ -541,10 +600,10 @@ export async function runDescriptionEnhance( // works without dispatching to an external agent runtime. The output is mapped // to `AddStepInput[]` by the caller (orchestration-service.generatePlanForGoal). // -// Unlike runTriage/runCoachComment (env-only via getClient), the caller passes -// an already-resolved client from `resolveWorkspaceProviderClient` so a -// DB-credential-only workspace works too. Returns null on any failure — the -// caller surfaces a typed error and never persists a dead empty plan. +// The caller passes an already-resolved workspace client, matching triage, +// Coach, and description assist so DB-credential-only workspaces work too. +// Returns null on any failure — the caller surfaces a typed error and never +// persists a dead empty plan. // --------------------------------------------------------------------------- export interface PlanGenInput { @@ -586,8 +645,7 @@ Decompose this goal into a short, ordered list of concrete execution steps (typi type: "function", function: { name: "submit_plan", - description: - "Submit the decomposed execution plan as an ordered list of steps.", + description: "Submit the decomposed execution plan as an ordered list of steps.", parameters: { type: "object", properties: { @@ -614,8 +672,7 @@ Decompose this goal into a short, ordered list of concrete execution steps (typi depends_on_step_indexes: { type: "array", items: { type: "integer" }, - description: - "0-based indexes of prerequisite steps in this same list.", + description: "0-based indexes of prerequisite steps in this same list.", }, assigned_role: { type: ["string", "null"], @@ -656,10 +713,7 @@ Decompose this goal into a short, ordered list of concrete execution steps (typi tool_choice: { type: "function", function: { name: "submit_plan" } }, }); } catch (err) { - logger.warn( - { err, provider: client.providerId }, - "ai.plan: chat call failed", - ); + logger.warn({ err, provider: client.providerId }, "ai.plan: chat call failed"); return null; } @@ -913,7 +967,9 @@ function markdownDetails(lines: string[]): { const clean = cleanMarkdownInline(line.replace(/^\s*[-*+]\s+/, "")); if (!clean) continue; - const expected = clean.match(/^(?:expected output|expected_output|deliverable|output):\s*(.+)$/i); + const expected = clean.match( + /^(?:expected output|expected_output|deliverable|output):\s*(.+)$/i, + ); if (expected?.[1]) { expectedOutput = expected[1].trim(); continue; @@ -999,9 +1055,7 @@ function coerceStringArray(value: unknown): string[] { function coerceIntegerArray(value: unknown): number[] { if (!Array.isArray(value)) return []; - return value.filter( - (n): n is number => typeof n === "number" && Number.isInteger(n), - ); + return value.filter((n): n is number => typeof n === "number" && Number.isInteger(n)); } function coerceRole(value: unknown): "WORKER" | "REVIEWER" | null { @@ -1014,13 +1068,7 @@ function isPriority(value: unknown): value is Priority { return ( typeof value === "string" && ( - [ - Priority.NONE, - Priority.LOW, - Priority.MEDIUM, - Priority.HIGH, - Priority.URGENT, - ] as string[] + [Priority.NONE, Priority.LOW, Priority.MEDIUM, Priority.HIGH, Priority.URGENT] as string[] ).includes(value) ); } diff --git a/tests/unit/ai-triage-lease.test.ts b/tests/unit/ai-triage-lease.test.ts new file mode 100644 index 00000000..2b16b842 --- /dev/null +++ b/tests/unit/ai-triage-lease.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { AI_TRIAGE_PENDING_LEASE_MS, isAiTriagePendingStale } from "@/lib/ai-triage"; + +describe("AI triage pending lease", () => { + it("keeps a recent claim active", () => { + const now = Date.now(); + expect(isAiTriagePendingStale(new Date(now - 1_000), now)).toBe(false); + }); + + it("allows retry after the lease and recovers legacy pending rows without a timestamp", () => { + const now = Date.now(); + expect(isAiTriagePendingStale(new Date(now - AI_TRIAGE_PENDING_LEASE_MS), now)).toBe(true); + expect(isAiTriagePendingStale(null, now)).toBe(true); + }); +}); diff --git a/tests/unit/ai-triage-parse.test.ts b/tests/unit/ai-triage-parse.test.ts index 658dcf39..dbb641fd 100644 --- a/tests/unit/ai-triage-parse.test.ts +++ b/tests/unit/ai-triage-parse.test.ts @@ -1,12 +1,18 @@ import { describe, expect, it } from "vitest"; -import { parseTriageMessage } from "@/server/services/ai"; +import { parseTriageMessage, runTriage } from "@/server/services/ai"; +import type { ResolvedProviderClient } from "@/server/services/ai-providers"; const LABEL_BUG = "cmofy50kw0001o107hc8dzsft"; const LABEL_UI = "cmofy50kw0002o107aaaaaaaa"; +const LABEL_HERMES = "cmofy50kw0003o107bbbbbbbb"; const AGENT_VICTOR = "cmagent0001victorxxxxxxxx"; -const validLabels = new Set([LABEL_BUG, LABEL_UI]); -const validAgents = new Set([AGENT_VICTOR]); +const labels = [ + { id: LABEL_BUG, name: "Bug" }, + { id: LABEL_UI, name: "UI" }, + { id: LABEL_HERMES, name: "Hermes" }, +]; +const agents = [{ id: AGENT_VICTOR, profileKey: "victor", name: "Victor" }]; describe("parseTriageMessage", () => { it("parses a well-formed submit_triage tool call", () => { @@ -27,7 +33,7 @@ describe("parseTriageMessage", () => { }, ], }; - const out = parseTriageMessage(message, validLabels, validAgents); + const out = parseTriageMessage(message, labels, agents); expect(out).toEqual({ priority: "HIGH", labelIds: [LABEL_BUG], @@ -36,9 +42,25 @@ describe("parseTriageMessage", () => { }); }); - it("falls back to prose when the provider ignores tool_choice (the Hermes case)", () => { - // Verbatim shape of what the Hermes gateway returned in prod: plain prose, - // no tool call, but a usable recommendation that names the valid label id. + it("recovers the exact AXI-104 name-only recommendation without exposing tool failure prose", () => { + const message = { + role: "assistant", + content: + "Triage submission failed because the project tool reported “Unknown tool: submit_triage.” " + + "Recommended triage: LOW priority; labels Bug and Hermes; assign Victor. " + + "The issue is a scoped Hermes-Relay configuration bug with no stated outage or data-loss impact.", + }; + const out = parseTriageMessage(message, labels, agents); + expect(out).toEqual({ + priority: "LOW", + labelIds: [LABEL_BUG, LABEL_HERMES], + agentId: AGENT_VICTOR, + reasoning: + "The issue is a scoped Hermes-Relay configuration bug with no stated outage or data-loss impact.", + }); + }); + + it("falls back to labelled prose when a provider ignores tool_choice", () => { const message = { role: "assistant", content: @@ -47,12 +69,50 @@ describe("parseTriageMessage", () => { `Priority: LOW\nLabels: Bug (${LABEL_BUG})\nAgent: none\n\n` + "Reasoning: Pinned issues being hard to remove is a UI bug but shows no urgency.", }; - const out = parseTriageMessage(message, validLabels, validAgents); + const out = parseTriageMessage(message, labels, agents); expect(out).not.toBeNull(); expect(out!.priority).toBe("LOW"); expect(out!.labelIds).toEqual([LABEL_BUG]); expect(out!.agentId).toBeNull(); - expect(out!.reasoning).toContain("Pinned issues"); + expect(out!.reasoning).toBe( + "Pinned issues being hard to remove is a UI bug but shows no urgency.", + ); + }); + + it("maps structured label names and agent handles when a compatible model returns names", () => { + const message = { + role: "assistant", + content: JSON.stringify({ + priority: "medium", + label_ids: ["UI"], + agent_id: "victor", + reasoning: "A focused interface repair.", + }), + }; + expect(parseTriageMessage(message, labels, agents)).toEqual({ + priority: "MEDIUM", + labelIds: [LABEL_UI], + agentId: AGENT_VICTOR, + reasoning: "A focused interface repair.", + }); + }); + + it("does not infer an assignment from an agent name used only in reasoning", () => { + const message = { + role: "assistant", + content: + "Priority: LOW. Labels: Bug. Reasoning: Victor reported the issue but ownership is unclear.", + }; + expect(parseTriageMessage(message, labels, agents)?.agentId).toBeNull(); + }); + + it("refuses ambiguous name-only agent matches", () => { + const duplicateNames = [ + ...agents, + { id: "cmagent0002victorxxxxxxxx", profileKey: "victor-2", name: "Victor" }, + ]; + const message = { role: "assistant", content: "Priority: HIGH. Assign Victor." }; + expect(parseTriageMessage(message, labels, duplicateNames)?.agentId).toBeNull(); }); it("parses a fenced JSON block embedded in content", () => { @@ -68,24 +128,13 @@ describe("parseTriageMessage", () => { }) + "\n```", }; - const out = parseTriageMessage(message, validLabels, validAgents); - expect(out).toMatchObject({ + expect(parseTriageMessage(message, labels, agents)).toMatchObject({ priority: "MEDIUM", labelIds: [LABEL_UI], agentId: null, }); }); - it("extracts a valid agent id mentioned in prose", () => { - const message = { - role: "assistant", - content: `Priority: URGENT. Assign agent ${AGENT_VICTOR}. Reasoning: outage.`, - }; - const out = parseTriageMessage(message, validLabels, validAgents); - expect(out!.priority).toBe("URGENT"); - expect(out!.agentId).toBe(AGENT_VICTOR); - }); - it("drops label/agent ids that aren't in the workspace", () => { const message = { role: "assistant", @@ -93,7 +142,7 @@ describe("parseTriageMessage", () => { "Priority: HIGH. Labels: cmNOTAREALLABELxxxxxxxxxx. " + "Agent: cmNOTAREALAGENTxxxxxxxxxx. Reasoning: bogus ids.", }; - const out = parseTriageMessage(message, validLabels, validAgents); + const out = parseTriageMessage(message, labels, agents); expect(out!.labelIds).toEqual([]); expect(out!.agentId).toBeNull(); }); @@ -101,16 +150,61 @@ describe("parseTriageMessage", () => { it("returns null when the model refuses with no usable priority", () => { const message = { role: "assistant", - content: - "I can't submit the triage because the required submit_triage tool isn't available.", + content: "I can't submit the triage because the required submit_triage tool isn't available.", }; - expect(parseTriageMessage(message, validLabels, validAgents)).toBeNull(); + expect(parseTriageMessage(message, labels, agents)).toBeNull(); }); - it("returns null for an empty / contentless message", () => { - expect(parseTriageMessage(undefined, validLabels, validAgents)).toBeNull(); - expect( - parseTriageMessage({ role: "assistant", content: "" }, validLabels, validAgents), - ).toBeNull(); + it("returns null for an empty or contentless message", () => { + expect(parseTriageMessage(undefined, labels, agents)).toBeNull(); + expect(parseTriageMessage({ role: "assistant", content: "" }, labels, agents)).toBeNull(); + }); +}); + +describe("runTriage", () => { + it("requests plain JSON from Hermes without registering a server-side tool", async () => { + let captured: Record | null = null; + const provider = { + providerId: "hermes", + defaultModel: "test-model", + supportsImageInput: true, + client: { + chat: { + completions: { + create: async (request: Record) => { + captured = request; + return { + choices: [ + { + finish_reason: "stop", + message: { + role: "assistant", + content: JSON.stringify({ + priority: "LOW", + label_ids: [LABEL_BUG], + agent_id: AGENT_VICTOR, + reasoning: "Scoped bug.", + }), + }, + }, + ], + }; + }, + }, + }, + }, + } as unknown as ResolvedProviderClient; + + const result = await runTriage(provider, { + title: "Hermes configuration bug", + description: null, + workspaceLabels: labels.map((label) => ({ ...label, color: "#000000" })), + agents: agents.map((agent) => ({ ...agent, capabilities: ["hermes"] })), + }); + + expect(result?.agentId).toBe(AGENT_VICTOR); + expect(captured).not.toHaveProperty("tools"); + expect(captured).not.toHaveProperty("tool_choice"); + expect(captured).toMatchObject({ model: "test-model" }); }); }); From 123a68937e97109920e1ef9935e50c69aa339a07 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 14 Jul 2026 12:47:11 -0400 Subject: [PATCH 2/3] feat: add lifecycle lab and unify operator attention --- .gitignore | 2 + DEVLOG.md | 34 + package.json | 5 +- playwright.lifecycle.config.ts | 32 + scripts/lifecycle-lab.sh | 97 +++ scripts/seed-lifecycle-lab.ts | 649 ++++++++++++++++++ .../(app)/w/[slug]/command-center/page.tsx | 3 +- src/app/(app)/w/[slug]/inbox/page.tsx | 126 +++- src/app/(app)/w/[slug]/issues/[id]/page.tsx | 16 +- src/components/activity-drawer.tsx | 123 +++- src/components/breadcrumb.tsx | 4 +- src/components/inputs/mention-input.tsx | 1 + src/components/sidebar.tsx | 1 + src/components/topbar.tsx | 6 +- src/server/routers/inbox.ts | 17 +- tests/lifecycle/lifecycle-journey.spec.ts | 112 +++ tsconfig.json | 3 +- 17 files changed, 1188 insertions(+), 43 deletions(-) create mode 100644 playwright.lifecycle.config.ts create mode 100755 scripts/lifecycle-lab.sh create mode 100644 scripts/seed-lifecycle-lab.ts create mode 100644 tests/lifecycle/lifecycle-journey.spec.ts diff --git a/.gitignore b/.gitignore index d9d146b1..0a25d26f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ tests/e2e/.auth/ playwright-report/ test-results/ .next-e2e/ +.next-lifecycle/ +playwright-report-lifecycle/ diff --git a/DEVLOG.md b/DEVLOG.md index 40302e3f..d24dc523 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -12694,3 +12694,37 @@ agent templates, and a single current wake target. Verification: focused parser, lease, and database router suites passed 17 tests; lint passed with existing repository warnings; typecheck passed; and the full Vitest suite passed 1,223 tests with 1 skip. + +--- + +## 2026-07-14 — Deterministic lifecycle lab and operator journey audit + +Added an isolated Lifecycle Lab for rapid, repeatable issue-lifecycle testing. +It uses the dedicated `forge_lifecycle` database, Redis database 14, port 3300, +and `.next-lifecycle` build output, with guarded deterministic fixtures for +ready, assigned/unacknowledged, active, waiting for user input, runtime +approval, review, completed, and stalled states. The lab disables its in-process +worker so those states remain frozen for inspection and supports both dev and +production-mode runs. + +Added a four-journey Playwright audit that verifies Command Center continuity, +navigate-away/reload persistence, issue waiting and completion handoffs, the +Inbox, the shared notifications drawer, mobile overflow, screenshots, and axe +results. Unified ActionRequests and conversational agent waits into one +deduplicated “Needs your input” stream in both Inbox and the notification +drawer, included new ActionRequests in the Inbox badge, and made agent-authored +mentions robust when an agent API key shares the owner's author ID. Empty Inbox +buckets now stay hidden, Command Center attention cards use a four-column wide +layout, and landmark, heading, dialog, breadcrumb, and mention-combobox +semantics were repaired. + +Verification: lint passed with existing repository warnings; typecheck passed; +the full Vitest suite passed 1,225 tests with 1 skip; a fresh production +Lifecycle Lab build passed; all 4 lifecycle Playwright journeys passed; and all +6 accepted desktop/mobile audit states reported zero axe violations (with +color-contrast excluded from automation and reviewed visually). The remaining +low-priority mobile observation is that the persistent agent-status pill can +cover card content while scrolling. The repository-wide browser run passed 36 +of 38 tests in parallel; the two failures (shared chat state and a composer +locator exposed by the accessibility change) both passed independently after +the composer name was corrected. diff --git a/package.json b/package.json index 6b7412a0..3f2d98b9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev:live:lan": "./scripts/dev-live-stack.sh --lan", "dev:isolated": "next dev --turbo", "dev:local": "./scripts/dev-local.sh", + "dev:lifecycle": "./scripts/lifecycle-lab.sh", "dev:host": "DEV_HOST=0.0.0.0 ./scripts/dev-live.sh", "db:clone-prod": "./scripts/db-clone-prod.sh", "build": "./scripts/build-docs.sh && next build", @@ -24,6 +25,7 @@ "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "tsx prisma/seed.ts", + "seed:lifecycle": "FORGE_LIFECYCLE_LAB=1 tsx scripts/seed-lifecycle-lab.ts", "seed:agents": "tsx scripts/seed-agents.ts", "prisma:studio": "prisma studio", "test": "vitest run", @@ -43,7 +45,8 @@ "forge": "node tools/forge-cli/dist/index.js", "changelog": "tsx tools/scripts/changelog-scaffold.ts", "e2e": "fuser -k 3200/tcp 2>/dev/null; E2E_FORCE_BUILD=1 playwright test", - "e2e:web": "bash scripts/e2e-web.sh" + "e2e:web": "bash scripts/e2e-web.sh", + "test:lifecycle": "playwright test --config=playwright.lifecycle.config.ts" }, "dependencies": { "@auth/prisma-adapter": "^2.7.4", diff --git a/playwright.lifecycle.config.ts b/playwright.lifecycle.config.ts new file mode 100644 index 00000000..3430392f --- /dev/null +++ b/playwright.lifecycle.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = Number(process.env.LIFECYCLE_PORT ?? 3300); +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./tests/lifecycle", + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report-lifecycle" }]], + globalSetup: "./tests/e2e/global-setup.ts", + outputDir: "test-results/lifecycle", + timeout: 90_000, + expect: { timeout: 15_000 }, + use: { + baseURL: BASE_URL, + storageState: "tests/e2e/.auth/owner.json", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "off", + }, + projects: [{ name: "chromium", use: devices["Desktop Chrome"] }], + webServer: { + command: "bash scripts/lifecycle-lab.sh --fresh --production", + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 420_000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/scripts/lifecycle-lab.sh b/scripts/lifecycle-lab.sh new file mode 100755 index 00000000..29f27a6f --- /dev/null +++ b/scripts/lifecycle-lab.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Deterministic issue-lifecycle lab for product iteration and browser audits. +# Uses its own Postgres database, Redis logical DB, Next build directory, and +# port. It cannot read or mutate production or the shared dev:local database. +# +# pnpm dev:lifecycle --fresh # HMR server at http://localhost:3300 +# pnpm dev:lifecycle # reseed lab scenarios, preserve schema +# pnpm dev:lifecycle --production # deterministic next start (Playwright) + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="$ROOT/docker/docker-compose.yml" +PORT="${LIFECYCLE_PORT:-3300}" +FRESH=0 +PRODUCTION=0 + +for arg in "$@"; do + case "$arg" in + --fresh) FRESH=1 ;; + --production) PRODUCTION=1 ;; + *) echo "[lifecycle] Unknown arg: $arg" >&2; exit 1 ;; + esac +done + +# Deliberately ignore inherited application URLs. A shell pointed at staging or +# production must still land on the disposable local lab stores. +export DATABASE_URL="postgresql://forge:forge@localhost:55432/forge_lifecycle?schema=public" +export REDIS_URL="redis://localhost:56379/14" +export S3_ENDPOINT="http://localhost:59000" +export S3_PUBLIC_ENDPOINT="http://localhost:59000" +export S3_REGION="${S3_REGION:-us-east-1}" +export S3_ACCESS_KEY="${S3_ACCESS_KEY:-forgeminio}" +export S3_SECRET_KEY="${S3_SECRET_KEY:-forgeminio-dev-password}" +export S3_FORCE_PATH_STYLE="true" + +export AUTH_SECRET="${AUTH_SECRET:-lifecycle-lab-secret-changeme-32bytes}" +export AUTH_URL="http://localhost:${PORT}" +export NEXT_PUBLIC_APP_URL="$AUTH_URL" +export AUTH_TRUST_HOST="true" +export ADMIN_EMAIL="${ADMIN_EMAIL:-owner@forge.local}" +export ADMIN_PASSWORD="${ADMIN_PASSWORD:-forge-dev}" +export ADMIN_NAME="${ADMIN_NAME:-Forge Owner}" +export ADMIN_HANDLE="${ADMIN_HANDLE:-forge}" +export PLUGIN_JWT_SECRET="${PLUGIN_JWT_SECRET:-lifecycle-plugin-signing-key}" +export PLUGIN_JWT_ISSUER="${PLUGIN_JWT_ISSUER:-forge}" +export PLUGIN_JWT_AUDIENCE="${PLUGIN_JWT_AUDIENCE:-forge-plugins}" + +# Reuse the in-process mock runtime, but keep the lab opt-in independently. +export FORGE_E2E=1 +export FORGE_LIFECYCLE_LAB=1 +# The lab is a frozen UX state matrix. Background dispatch/watchdog workers +# would correctly advance those states, which would make visual audits racey. +export FORGE_DISABLE_IN_PROCESS_WORKER=1 +export NEXT_DIST_DIR=".next-lifecycle" + +echo "[lifecycle] Ensuring isolated docker stack is up…" +docker compose -f "$COMPOSE_FILE" up -d >/dev/null + +echo "[lifecycle] Waiting for Postgres…" +for i in $(seq 1 30); do + docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U forge >/dev/null 2>&1 && break + [[ "$i" == "30" ]] && { echo "[lifecycle] Postgres not ready" >&2; exit 1; } + sleep 1 +done + +docker compose -f "$COMPOSE_FILE" exec -T postgres psql -U forge -d forge -tAc \ + "SELECT 1 FROM pg_database WHERE datname='forge_lifecycle'" 2>/dev/null | grep -q 1 \ + || docker compose -f "$COMPOSE_FILE" exec -T postgres createdb -U forge forge_lifecycle + +if [[ "$FRESH" == "1" ]]; then + echo "[lifecycle] --fresh: recreating the lab schema…" + docker compose -f "$COMPOSE_FILE" exec -T postgres \ + psql -U forge -d forge_lifecycle -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" >/dev/null 2>&1 +fi + +echo "[lifecycle] Applying migrations and deterministic fixtures…" +pnpm exec prisma migrate deploy >/dev/null +pnpm exec prisma generate >/dev/null +pnpm exec tsx prisma/seed.ts >/dev/null +pnpm exec tsx scripts/seed-lifecycle-lab.ts + +echo +echo "[lifecycle] Lab: http://localhost:${PORT}/w/forge/command-center" +echo "[lifecycle] Sign in: $ADMIN_EMAIL / $ADMIN_PASSWORD" +echo "[lifecycle] Data: forge_lifecycle · Redis /14 · $NEXT_DIST_DIR" +echo + +if [[ "$PRODUCTION" == "1" ]]; then + if [[ "${LIFECYCLE_FORCE_BUILD:-0}" == "1" || ! -f "$ROOT/$NEXT_DIST_DIR/BUILD_ID" ]]; then + echo "[lifecycle] Building deterministic production bundle…" + pnpm exec next build + fi + exec pnpm exec next start -p "$PORT" +fi + +exec pnpm exec next dev --turbo -p "$PORT" diff --git a/scripts/seed-lifecycle-lab.ts b/scripts/seed-lifecycle-lab.ts new file mode 100644 index 00000000..6ccf1b6f --- /dev/null +++ b/scripts/seed-lifecycle-lab.ts @@ -0,0 +1,649 @@ +/** + * Deterministic issue lifecycle fixtures for UX audits. + * + * Guarded behind FORGE_LIFECYCLE_LAB=1 and intended only for the dedicated + * forge_lifecycle database provisioned by scripts/lifecycle-lab.sh. + */ +import { + ActionRequestKind, + ActionRequestStatus, + AgentRunStatus, + CommentKind, + EventKind, + ExecutionPlanStatus, + ExecutionStepStatus, + NotificationSeverity, + NotificationStatus, + PrismaClient, + Priority, + ReviewGateStatus, +} from "@prisma/client"; + +if (process.env.FORGE_LIFECYCLE_LAB !== "1") { + throw new Error("Refusing to seed lifecycle fixtures without FORGE_LIFECYCLE_LAB=1"); +} + +const prisma = new PrismaClient(); +const MINUTE = 60_000; +const ISSUE_IDS = { + queued: "clifecyclequeued000000000001", + assigned: "clifecycleassigned0000000001", + active: "clifecycleactive000000000001", + waiting: "clifecyclewaiting0000000001", + approval: "clifecycleapproval000000001", + review: "clifecyclereview00000000001", + completed: "clifecyclecompleted00000001", + stalled: "clifecyclestalled0000000001", +} as const; +const LAB_ISSUE_IDS = Object.values(ISSUE_IDS); + +function ago(minutes: number) { + return new Date(Date.now() - minutes * MINUTE); +} + +async function main() { + const workspace = await prisma.workspace.findUnique({ where: { slug: "forge" } }); + const owner = await prisma.user.findUnique({ where: { email: "owner@forge.local" } }); + if (!workspace || !owner) throw new Error("Run prisma/seed.ts before the lifecycle seed"); + + const [agent, project, statuses] = await Promise.all([ + prisma.agent.findUnique({ + where: { + workspaceId_profileKey: { workspaceId: workspace.id, profileKey: "e2ebot" }, + }, + }), + prisma.project.findUnique({ + where: { workspaceId_key: { workspaceId: workspace.id, key: "CORE" } }, + }), + prisma.status.findMany({ where: { workspaceId: workspace.id } }), + ]); + if (!agent || !project) throw new Error("Lifecycle seed requires E2E fixtures and CORE project"); + + const statusId = (name: string) => { + const status = statuses.find((row) => row.name === name); + if (!status) throw new Error(`Missing status: ${name}`); + return status.id; + }; + + // Polymorphic tables do not all cascade from Issue, so remove the previous + // lab slice explicitly before recreating it. Other seed/user data is untouched. + const oldPlans = await prisma.executionPlan.findMany({ + where: { issueId: { in: LAB_ISSUE_IDS } }, + select: { id: true, steps: { select: { id: true } } }, + }); + const oldTargetIds = oldPlans.flatMap((plan) => [plan.id, ...plan.steps.map((step) => step.id)]); + await prisma.$transaction([ + prisma.reviewGate.deleteMany({ + where: { + workspaceId: workspace.id, + OR: [ + { targetId: { in: LAB_ISSUE_IDS } }, + ...(oldTargetIds.length ? [{ targetId: { in: oldTargetIds } }] : []), + ], + }, + }), + prisma.actionRequest.deleteMany({ + where: { workspaceId: workspace.id, issueId: { in: LAB_ISSUE_IDS } }, + }), + prisma.activityEvent.deleteMany({ + where: { workspaceId: workspace.id, subjectType: "issue", subjectId: { in: LAB_ISSUE_IDS } }, + }), + prisma.executionPlan.deleteMany({ + where: { workspaceId: workspace.id, issueId: { in: LAB_ISSUE_IDS } }, + }), + prisma.issue.deleteMany({ + where: { workspaceId: workspace.id, id: { in: LAB_ISSUE_IDS } }, + }), + ]); + + const common = { + workspaceId: workspace.id, + projectId: project.id, + authorId: owner.id, + priority: Priority.HIGH, + expectedOutput: "A clear final comment with outcome, evidence, and any follow-up work.", + verificationChecklist: [ + { id: "journey", label: "Lifecycle state is visible after reload", kind: "manual" }, + { id: "handoff", label: "Operator can identify the next action", kind: "manual" }, + ], + }; + + await prisma.issue.createMany({ + data: [ + { + ...common, + id: ISSUE_IDS.queued, + number: 9001, + title: "Lifecycle Lab · Ready to assign", + description: + "Queued work with no agent yet. The operator should see that it is ready, unowned, and needs dispatch.", + statusId: statusId("Todo"), + queued: true, + }, + { + ...common, + id: ISSUE_IDS.assigned, + number: 9002, + title: "Lifecycle Lab · Assigned, awaiting acknowledgement", + description: "The agent has been dispatched but has not acknowledged the wake.", + statusId: statusId("Todo"), + assignedAgentId: agent.id, + }, + { + ...common, + id: ISSUE_IDS.active, + number: 9003, + title: "Lifecycle Lab · Agent actively working", + description: "An acknowledged run with live progress and a rolling status update.", + statusId: statusId("In Progress"), + assignedAgentId: agent.id, + startedAt: ago(42), + }, + { + ...common, + id: ISSUE_IDS.waiting, + number: 9004, + title: "Lifecycle Lab · Waiting for user reply", + description: "The agent needs a product decision before it can continue.", + statusId: statusId("In Progress"), + assignedAgentId: agent.id, + startedAt: ago(65), + }, + { + ...common, + id: ISSUE_IDS.approval, + number: 9005, + title: "Lifecycle Lab · Runtime approval required", + description: "The runtime paused before a destructive command and needs explicit approval.", + statusId: statusId("In Progress"), + assignedAgentId: agent.id, + startedAt: ago(28), + }, + { + ...common, + id: ISSUE_IDS.review, + number: 9006, + title: "Lifecycle Lab · Ready for review", + description: "Execution is complete and a human review gate is blocking closure.", + statusId: statusId("In Review"), + assignedAgentId: agent.id, + startedAt: ago(110), + }, + { + ...common, + id: ISSUE_IDS.completed, + number: 9007, + title: "Lifecycle Lab · Completed with final handoff", + description: + "A successful run with verification evidence and an explicit final agent comment.", + statusId: statusId("Done"), + assignedAgentId: agent.id, + startedAt: ago(150), + completedAt: ago(12), + }, + { + ...common, + id: ISSUE_IDS.stalled, + number: 9008, + title: "Lifecycle Lab · Stalled and needs recovery", + description: + "A run stopped emitting progress and should surface as a recoverable operational alert.", + statusId: statusId("In Progress"), + assignedAgentId: agent.id, + startedAt: ago(190), + }, + ], + }); + + const assignmentEvent = await prisma.activityEvent.create({ + data: { + workspaceId: workspace.id, + kind: EventKind.AGENT_ASSIGNED, + actorId: owner.id, + subjectType: "issue", + subjectId: ISSUE_IDS.assigned, + payload: { agentId: agent.id, agentProfileKey: agent.profileKey, number: 9002 }, + createdAt: ago(18), + }, + }); + const assignedRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.assigned, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + startedAt: ago(18), + lastEventAt: ago(1), + currentStep: "Wake sent · awaiting acknowledgement", + assignmentEventId: assignmentEvent.id, + triggerEventId: assignmentEvent.id, + triggerKind: EventKind.AGENT_ASSIGNED, + wakeAttempts: 1, + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: assignedRun.id, + kind: "STARTED", + payload: { message: "Dispatch wake sent" }, + createdAt: ago(18), + }, + }); + + const activeRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.active, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + startedAt: ago(42), + lastEventAt: ago(2), + currentStep: "Running lifecycle regression tests", + acknowledgedAt: ago(41), + outputStartedAt: ago(39), + tokensIn: 8400, + tokensOut: 1900, + costUsd: 0.1834, + }, + }); + await prisma.agentRunEvent.createMany({ + data: [ + { + workspaceId: workspace.id, + runId: activeRun.id, + kind: "STARTED", + payload: { message: "Run acknowledged" }, + createdAt: ago(42), + }, + { + workspaceId: workspace.id, + runId: activeRun.id, + kind: "STEP", + payload: { message: "Mapped state transitions" }, + createdAt: ago(12), + }, + { + workspaceId: workspace.id, + runId: activeRun.id, + kind: "STATUS", + payload: { message: "Running lifecycle regression tests" }, + createdAt: ago(2), + }, + ], + }); + await prisma.comment.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.active, + authorId: owner.id, + authoringAgentId: agent.id, + kind: CommentKind.STATUS, + runId: activeRun.id, + currentStep: "Running lifecycle regression tests", + body: "Mapped the issue states and now verifying navigation, reload persistence, and responsive visibility.", + revisions: [], + createdAt: ago(39), + updatedAt: ago(2), + }, + }); + + const waitingRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.waiting, + agentId: agent.id, + status: AgentRunStatus.WAITING, + startedAt: ago(65), + lastEventAt: ago(8), + currentStep: "Waiting on your product decision", + acknowledgedAt: ago(64), + outputStartedAt: ago(61), + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: waitingRun.id, + kind: "BLOCKED", + payload: { reason: "Operator decision required" }, + createdAt: ago(8), + }, + }); + const waitingComment = await prisma.comment.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.waiting, + authorId: owner.id, + authoringAgentId: agent.id, + body: "@owner Should the completion summary optimize for operators scanning the Command Center, or for the full issue activity record? I can proceed as soon as you choose.", + suggestedReplies: ["Optimize for Command Center", "Optimize for issue detail", "Use both"], + confidence: "HIGH", + createdAt: ago(8), + updatedAt: ago(8), + }, + }); + await prisma.actionRequest.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.waiting, + title: "Choose the completion-summary emphasis", + body: "The agent is blocked until the operator chooses how the handoff should be optimized. This intentionally has enough detail to verify clean card truncation and expansion behavior across Command Center and issue detail.", + status: ActionRequestStatus.OPEN, + severity: NotificationSeverity.WARNING, + kind: ActionRequestKind.FREE_FORM, + requestedByAgentId: agent.id, + assignedUserId: owner.id, + sourceType: "comment", + sourceId: waitingComment.id, + dueAt: new Date(Date.now() + 120 * MINUTE), + createdAt: ago(8), + updatedAt: ago(8), + }, + }); + + const approvalRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.approval, + agentId: agent.id, + status: AgentRunStatus.WAITING, + startedAt: ago(28), + lastEventAt: ago(4), + currentStep: "Approval required before database migration", + acknowledgedAt: ago(27), + outputStartedAt: ago(25), + awaitingApprovalAt: ago(4), + pendingApproval: { + command: "pnpm exec prisma migrate deploy", + description: + "Apply the reviewed lifecycle notification migration to the isolated lab database.", + choices: ["approve", "reject"], + }, + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: approvalRun.id, + kind: "APPROVAL_REQUESTED", + payload: { command: "pnpm exec prisma migrate deploy" }, + createdAt: ago(4), + }, + }); + + const reviewPlan = await prisma.executionPlan.create({ + data: { + id: "clifecycleplanreview000000001", + workspaceId: workspace.id, + issueId: ISSUE_IDS.review, + title: "Lifecycle review handoff", + description: "Verify that the completed work is clear and ready to close.", + status: ExecutionPlanStatus.RUNNING, + createdById: owner.id, + startedAt: ago(105), + }, + }); + const reviewStep = await prisma.executionStep.create({ + data: { + id: "clifecyclestepreview000000001", + workspaceId: workspace.id, + planId: reviewPlan.id, + issueId: ISSUE_IDS.review, + title: "Verify lifecycle handoff", + body: "Check the operator-facing status, evidence, final comment, and next action.", + position: 0, + status: ExecutionStepStatus.REVIEW, + assignedAgentId: agent.id, + expectedOutput: "A review decision with concise feedback.", + verification: [{ label: "State and next action are immediately clear", done: true }], + }, + }); + const reviewRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.review, + agentId: agent.id, + executionStepId: reviewStep.id, + status: AgentRunStatus.COMPLETED, + startedAt: ago(100), + lastEventAt: ago(22), + finishedAt: ago(22), + completedAt: ago(22), + currentStep: "Ready for review", + summary: "Implemented the lifecycle status pass and verified the primary operator surfaces.", + acknowledgedAt: ago(99), + outputStartedAt: ago(96), + verificationResult: [ + { + id: "journey", + label: "Lifecycle state is visible after reload", + done: true, + }, + { + id: "handoff", + label: "Operator can identify the next action", + done: true, + }, + ], + completionMeta: { confidence: "HIGH", contractVersion: 1 }, + }, + }); + await prisma.executionStep.update({ + where: { id: reviewStep.id }, + data: { sourceRunId: reviewRun.id }, + }); + await prisma.reviewGate.create({ + data: { + id: "clifecyclegate00000000000001", + workspaceId: workspace.id, + targetType: "execution-step", + targetId: reviewStep.id, + status: ReviewGateStatus.PENDING, + requestedByAgentId: agent.id, + prompt: + "Does this handoff clearly communicate outcome, evidence, and the next operator action?", + createdAt: ago(20), + }, + }); + await prisma.comment.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.review, + authorId: owner.id, + authoringAgentId: agent.id, + body: "Implementation is complete and the verification checklist passes. I moved this to review; please approve the gate or leave specific feedback.", + confidence: "HIGH", + createdAt: ago(22), + updatedAt: ago(22), + }, + }); + + const completedRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.completed, + agentId: agent.id, + status: AgentRunStatus.COMPLETED, + startedAt: ago(150), + lastEventAt: ago(12), + finishedAt: ago(12), + completedAt: ago(12), + currentStep: "Complete", + summary: "Lifecycle states remain visible and actionable across navigation and reloads.", + acknowledgedAt: ago(149), + outputStartedAt: ago(145), + verificationResult: [ + { + id: "journey", + label: "Lifecycle state is visible after reload", + done: true, + }, + { + id: "handoff", + label: "Operator can identify the next action", + done: true, + }, + ], + followUps: [{ title: "Monitor notification fatigue after rollout", kind: "observation" }], + completionMeta: { confidence: "HIGH", contractVersion: 1 }, + tokensIn: 12900, + tokensOut: 3400, + costUsd: 0.2761, + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: completedRun.id, + kind: "COMPLETED", + payload: { summary: "Lifecycle verification passed" }, + createdAt: ago(12), + }, + }); + await prisma.comment.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.completed, + authorId: owner.id, + authoringAgentId: agent.id, + body: "Completed. The operator journey now exposes queued, active, waiting, review, recovery, and done states across the issue page, Inbox, Command Center, and shared alert drawer. Verification passed after navigating away and reloading. Follow-up: monitor whether repeated stalled alerts create noise.", + confidence: "HIGH", + createdAt: ago(12), + updatedAt: ago(12), + }, + }); + + const stalledRun = await prisma.agentRun.create({ + data: { + workspaceId: workspace.id, + issueId: ISSUE_IDS.stalled, + agentId: agent.id, + status: AgentRunStatus.STALLED, + startedAt: ago(190), + lastEventAt: ago(95), + finishedAt: ago(90), + currentStep: "No progress after test runner launch", + summary: "The run stopped emitting events while waiting for the browser test process.", + acknowledgedAt: ago(189), + outputStartedAt: ago(184), + wakeAttempts: 2, + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: stalledRun.id, + kind: "ERRORED", + payload: { error: "No output received before the stale threshold" }, + createdAt: ago(90), + }, + }); + const stalledEvent = await prisma.activityEvent.create({ + data: { + workspaceId: workspace.id, + kind: EventKind.ISSUE_STALLED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.stalled, + payload: { + issuePrefix: "FRG-9008", + number: 9008, + agentId: agent.id, + agentProfileKey: agent.profileKey, + idleMinutes: 90, + }, + createdAt: ago(90), + }, + }); + await prisma.notificationState.create({ + data: { + workspaceId: workspace.id, + userId: owner.id, + eventId: stalledEvent.id, + replacementKey: `issue:${ISSUE_IDS.stalled}:stalled`, + severity: NotificationSeverity.WARNING, + importance: 65, + status: NotificationStatus.UNREAD, + persistent: true, + primaryHref: `/w/forge/issues/${ISSUE_IDS.stalled}`, + detailHref: `/w/forge/issues/${ISSUE_IDS.stalled}?tab=activity`, + summary: "FRG-9008 stalled", + reason: "The assigned agent stopped emitting progress while the test runner was active.", + recommendedAction: "Inspect the latest trace, then retry, reassign, or clear the failed run.", + createdAt: ago(90), + }, + }); + + // Give every scenario at least one immutable activity marker so the default + // issue rail explains how the current state was reached. + await prisma.activityEvent.createMany({ + data: [ + { + workspaceId: workspace.id, + kind: EventKind.ISSUE_QUEUED, + actorId: owner.id, + subjectType: "issue", + subjectId: ISSUE_IDS.queued, + payload: { number: 9001 }, + createdAt: ago(24), + }, + { + workspaceId: workspace.id, + kind: EventKind.AGENT_RUN_STARTED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.active, + payload: { runId: activeRun.id, number: 9003 }, + createdAt: ago(42), + }, + { + workspaceId: workspace.id, + kind: EventKind.AGENT_RUN_BLOCKED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.waiting, + payload: { runId: waitingRun.id, reason: "Operator decision required", number: 9004 }, + createdAt: ago(8), + }, + { + workspaceId: workspace.id, + kind: EventKind.AGENT_RUN_BLOCKED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.approval, + payload: { runId: approvalRun.id, reason: "Runtime approval required", number: 9005 }, + createdAt: ago(4), + }, + { + workspaceId: workspace.id, + kind: EventKind.AGENT_RUN_COMPLETED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.review, + payload: { runId: reviewRun.id, number: 9006 }, + createdAt: ago(22), + }, + { + workspaceId: workspace.id, + kind: EventKind.AGENT_RUN_COMPLETED, + actorAgentId: agent.id, + subjectType: "issue", + subjectId: ISSUE_IDS.completed, + payload: { runId: completedRun.id, number: 9007 }, + createdAt: ago(12), + }, + ], + }); + + console.log("[lifecycle] Seeded FRG-9001…FRG-9008 lifecycle scenarios"); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/(app)/w/[slug]/command-center/page.tsx b/src/app/(app)/w/[slug]/command-center/page.tsx index 51ba12e4..b49d7258 100644 --- a/src/app/(app)/w/[slug]/command-center/page.tsx +++ b/src/app/(app)/w/[slug]/command-center/page.tsx @@ -213,7 +213,8 @@ export default function CommandCenterPage() { className={cn( "grid grid-cols-1 gap-3 p-3", activeAttentionGroups === 2 && "lg:grid-cols-2", - activeAttentionGroups >= 3 && "lg:grid-cols-3", + activeAttentionGroups === 3 && "lg:grid-cols-3", + activeAttentionGroups >= 4 && "lg:grid-cols-2 xl:grid-cols-4", )} data-testid="command-center-attention-groups" > diff --git a/src/app/(app)/w/[slug]/inbox/page.tsx b/src/app/(app)/w/[slug]/inbox/page.tsx index d39f0518..e4c22ed1 100644 --- a/src/app/(app)/w/[slug]/inbox/page.tsx +++ b/src/app/(app)/w/[slug]/inbox/page.tsx @@ -1,5 +1,6 @@ "use client"; import type { AgentStatus } from "@prisma/client"; +import type { inferRouterOutputs } from "@trpc/server"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { toast } from "sonner"; @@ -36,6 +37,7 @@ import { ProjectChip } from "@/components/project-chip"; import { RowQuickActions } from "@/components/row-quick-actions"; import { SnoozeMenu } from "@/components/snooze-menu"; import { trpc } from "@/lib/trpc"; +import type { AppRouter } from "@/server/routers/_app"; import { cn, formatIssueId, relativeTime } from "@/lib/utils"; import { useWorkspace } from "@/hooks/use-workspace"; import { useHotkey } from "@/lib/keyboard"; @@ -70,6 +72,27 @@ export default function InboxPage() { const [allWorkspaces, setAllWorkspaces] = useState(false); const { data, isLoading } = trpc.inbox.get.useQuery({ allWorkspaces }); + const waitingOnMeQ = trpc.inbox.waitingOnMe.useQuery( + { limit: 25 }, + { enabled: !allWorkspaces }, + ); + const actionRequestsQ = trpc.inbox.actionRequestsForMe.useQuery( + { limit: 50 }, + { enabled: !allWorkspaces }, + ); + const actionRequests = useMemo( + () => actionRequestsQ.data?.items ?? [], + [actionRequestsQ.data?.items], + ); + const requestedIssueIds = useMemo( + () => new Set(actionRequests.flatMap((request) => (request.issueId ? [request.issueId] : []))), + [actionRequests], + ); + const waitingOnMe = useMemo( + () => + (waitingOnMeQ.data?.items ?? []).filter((row) => !requestedIssueIds.has(row.issue.id)), + [requestedIssueIds, waitingOnMeQ.data?.items], + ); const { data: agentQueue, isLoading: queueLoading } = trpc.issue.queue.useQuery( { includeClaimed: true, limit: 25 }, { enabled: !allWorkspaces }, @@ -429,7 +452,9 @@ export default function InboxPage() { />
- {isLoading || !data ? ( + {isLoading || + !data || + (!allWorkspaces && (waitingOnMeQ.isLoading || actionRequestsQ.isLoading)) ? (
@@ -438,7 +463,9 @@ export default function InboxPage() { data.mentions.length === 0 && data.humanStalled.length === 0 && data.agentStalled.length === 0 && - data.snoozed.length === 0 ? ( + data.snoozed.length === 0 && + actionRequests.length === 0 && + waitingOnMe.length === 0 ? (
) : ( <> - 0 && (hoveredRowRef.current = id)} onPickStatus={(id) => setStatusPicker({ issueId: id })} onPickAssignee={(id) => setAssigneePicker({ issueId: id })} - /> + />} -
0 &&
@@ -555,11 +582,17 @@ export default function InboxPage() { }) )} -
+
} - {!allWorkspaces && } + {!allWorkspaces && ( + + )} - 0 && setStatusPicker({ issueId: id })} onPickAssignee={(id) => setAssigneePicker({ issueId: id })} tone="warn" - /> + />} - 0 && setAssigneePicker({ issueId: id })} tone="warn" showAgent - /> + />} ["inbox"]["waitingOnMe"]["items"][number]; +type ActionRequestItem = + inferRouterOutputs["inbox"]["actionRequestsForMe"]["items"][number]; + +function NeedsInputSection({ + slug, + actionRequests, + waitingOnMe, +}: { + slug: string; + actionRequests: ActionRequestItem[]; + waitingOnMe: WaitingOnMeItem[]; +}) { + const count = actionRequests.length + waitingOnMe.length; + if (count === 0) return null; return (
- Waiting on me - {items.length} + Needs your input + {count} } - hint="Agent comments that @-mention you with no reply from you yet." + hint="Direct asks and agent questions waiting for your response." > - {items.map((row) => ( + {actionRequests.map((request) => { + const issue = request.issue; + const href = issue ? `/w/${slug}/issues/${issue.id}` : `/w/${slug}/command-center`; + return ( +
  • + + + +
    +
    + + {issue ? formatIssueId(issue.workspace.key, issue.number) : "Workspace ask"} + + + {request.title} + + {request.issueOpenCount > 1 && ( + +{request.issueOpenCount - 1} more + )} +
    + {request.body && ( +
    {request.body}
    + )} +
    + + {relativeTime(request.createdAt)} + +
  • + ); + })} + {waitingOnMe.map((row) => (
  • } + title={ + + } subtitle={archived} actions={
  • - +
    ); } @@ -1553,11 +1591,17 @@ function DrawerTab({ } type InboxData = inferRouterOutputs["inbox"]["get"]; +type WaitingOnMeItem = inferRouterOutputs["inbox"]["waitingOnMe"]["items"][number]; +type ActionRequestItem = + inferRouterOutputs["inbox"]["actionRequestsForMe"]["items"][number]; function MinePanel({ ws, data, isLoading, + actionRequests, + waitingOnMe, + needsInputLoading, alertRows, alertsLoading, error, @@ -1571,6 +1615,9 @@ function MinePanel({ ws: { slug: string }; data: InboxData | undefined; isLoading: boolean; + actionRequests: ActionRequestItem[]; + waitingOnMe: WaitingOnMeItem[]; + needsInputLoading: boolean; alertRows: NotificationRow[]; alertsLoading: boolean; error: string | null; @@ -1581,14 +1628,26 @@ function MinePanel({ alertsMutatingId: string | null; onNavigate: () => void; }) { - if ((isLoading || alertsLoading) && !data && alertRows.length === 0) { + if ( + (isLoading || needsInputLoading || alertsLoading) && + !data && + actionRequests.length === 0 && + waitingOnMe.length === 0 && + alertRows.length === 0 + ) { return (
    ); } - if (error && !data && alertRows.length === 0) { + if ( + error && + !data && + actionRequests.length === 0 && + waitingOnMe.length === 0 && + alertRows.length === 0 + ) { return (
    ); } - if (!data && alertRows.length === 0) { + if (!data && actionRequests.length === 0 && waitingOnMe.length === 0 && alertRows.length === 0) { return (
    } title="You're caught up." - description="No assigned, mentioned, or stalled items right now." + description="No asks, assignments, mentions, alerts, or stalled items right now." />
    ); @@ -1677,6 +1738,50 @@ function MinePanel({ )} + {actionRequests.length + waitingOnMe.length > 0 && ( + } + > + {actionRequests.slice(0, 6).map((request) => { + const issue = request.issue; + return ( + + {issue ? `${issue.workspace.key}-${issue.number}` : "Ask"} + + } + title={request.title} + meta={ + request.requestedByAgent + ? `@${request.requestedByAgent.profileKey} · ${relativeTime(request.createdAt)}` + : relativeTime(request.createdAt) + } + /> + ); + })} + {waitingOnMe.slice(0, Math.max(0, 6 - actionRequests.length)).map((row) => ( + + {row.issue.workspace.key}-{row.issue.number} + + } + title={row.issue.title} + meta={`@${row.lastComment.author.profileKey} · ${relativeTime(row.lastComment.createdAt)}`} + /> + ))} + + )} + {(data?.counts.assignedUnblocked ?? 0) > 0 && ( ( ref={containerRef} className="relative" role="combobox" + aria-label="Mention autocomplete" aria-haspopup="listbox" aria-expanded={visible} aria-controls={ariaControls} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 68427b61..7908b72e 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -198,6 +198,7 @@ export function Sidebar({ return ( <>