From 1c54db5a83784612488271767aade0f79c37b0a7 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Thu, 16 Jul 2026 10:49:58 -0500 Subject: [PATCH 1/2] feat(miner-ui): register portfolio release/requeue chat actions Wire chat-text resolution through the shared chokepoint dispatch layer so operators can release/requeue queue items from chat without a second write path (#6520). Co-authored-by: Cursor --- .../src/components/chat/fixtures.ts | 30 ++ .../lib/chat-portfolio-queue-actions.test.tsx | 359 ++++++++++++++++++ .../src/lib/chat-portfolio-queue-actions.ts | 309 +++++++++++++++ .../src/lib/chat-portfolio-queue-resolve.ts | 70 ++++ 4 files changed, 768 insertions(+) create mode 100644 apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.test.tsx create mode 100644 apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.ts create mode 100644 apps/loopover-miner-ui/src/lib/chat-portfolio-queue-resolve.ts diff --git a/apps/loopover-miner-ui/src/components/chat/fixtures.ts b/apps/loopover-miner-ui/src/components/chat/fixtures.ts index 7d00c2f664..548b44242c 100644 --- a/apps/loopover-miner-ui/src/components/chat/fixtures.ts +++ b/apps/loopover-miner-ui/src/components/chat/fixtures.ts @@ -62,6 +62,36 @@ export const multiTurnConversation: ChatMessage[] = [ }, ]; +/** Portfolio release/requeue action-result entries as rendered into the message list (#6520). */ +export const portfolioQueueActionConversation: ChatMessage[] = [ + { + id: "pq1", + role: "user", + content: "release acme/widgets #12", + timestamp: "2026-07-16T09:00:00.000Z", + authorName: "operator", + }, + { + id: "pq2", + role: "system", + content: "Queue release succeeded for acme/widgets (issue:12) — status is now queued.", + timestamp: "2026-07-16T09:00:01.000Z", + }, + { + id: "pq3", + role: "user", + content: "requeue acme/widgets #7", + timestamp: "2026-07-16T09:00:10.000Z", + authorName: "operator", + }, + { + id: "pq4", + role: "system", + content: "Queue requeue succeeded for acme/widgets (issue:7) — status is now queued.", + timestamp: "2026-07-16T09:00:11.000Z", + }, +]; + /** A long-content edge case — exercises wrapping/overflow in MessageBubble and MessageList's viewport. */ export const longContentConversation: ChatMessage[] = [ { diff --git a/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.test.tsx b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.test.tsx new file mode 100644 index 0000000000..bc64d90878 --- /dev/null +++ b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.test.tsx @@ -0,0 +1,359 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { dispatchChatAction, registerChatAction } = vi.hoisted(() => ({ + dispatchChatAction: vi.fn(), + registerChatAction: vi.fn(), +})); + +vi.mock("../../../../packages/loopover-miner/lib/chat-action-dispatch.js", () => ({ + CHAT_ACTION_DISPATCH_FLAG: "LOOPOVER_MINER_CHAT_ACTIONS", + CHAT_ACTION_DISPATCH_ENABLE_VALUE: "enabled", + dispatchChatAction, +})); + +vi.mock("../../../../packages/loopover-miner/lib/chat-action-registry.js", () => { + const GOVERNOR_GATED = Symbol("loopover.chat-action.governor-gated"); + return { + createChatActionRegistry: () => { + throw new Error("tests use an injected isolated registry"); + }, + registerChatAction, + governorGatedHandler: (run: (request: unknown) => unknown) => { + const handler = async (request: unknown) => { + const result = await run(request); + return { ok: true, status: "executed", decision: { stage: "allow" }, result }; + }; + Object.defineProperty(handler, GOVERNOR_GATED, { value: true }); + return handler; + }, + isGovernorGatedHandler: (handler: unknown) => + typeof handler === "function" && (handler as unknown as { [k: symbol]: unknown })[GOVERNOR_GATED] === true, + }; +}); + +import { MessageList } from "../components/chat/message-list"; +import { portfolioQueueActionConversation } from "../components/chat/fixtures"; +import { + formatPortfolioQueueChatResultMessage, + handlePortfolioQueueChatCommand, + matchPortfolioQueueChatTarget, + PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, + registerPortfolioQueueChatActions, + resetPortfolioQueueChatActionsRegistrationForTest, + resolvePortfolioQueueChatAction, +} from "./chat-portfolio-queue-actions"; +import { + PORTFOLIO_QUEUE_RELEASE_API_PATH, + PORTFOLIO_QUEUE_REQUEUE_API_PATH, + type PortfolioQueueActionItem, +} from "./portfolio-queue-actions"; + +const inProgressItem: PortfolioQueueActionItem = { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + identifier: "issue:12", + status: "in_progress", +}; + +const doneItem: PortfolioQueueActionItem = { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + identifier: "issue:7", + status: "done", +}; + +const allowGate = () => ({ decision: { stage: "allow" } }); +const enabledEnv = { LOOPOVER_MINER_CHAT_ACTIONS: "enabled" }; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +function isolatedRegistry() { + const actions = new Map boolean; handler: (request: unknown) => Promise }>(); + return { + register(name: string, definition: { paramsValidator: (params: unknown) => boolean; handler: (request: unknown) => Promise }) { + actions.set(name, definition); + return definition; + }, + get: (name: string) => actions.get(name), + has: (name: string) => actions.has(name), + names: () => [...actions.keys()], + get size() { + return actions.size; + }, + }; +} + +describe("resolvePortfolioQueueChatAction (#6520)", () => { + it("resolves release/requeue with a repo and optional identifier", () => { + expect(resolvePortfolioQueueChatAction("release acme/widgets")).toEqual({ + ok: true, + action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + target: { repoFullName: "acme/widgets" }, + }); + expect(resolvePortfolioQueueChatAction("please requeue org/repo #7")).toEqual({ + ok: true, + action: PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, + target: { repoFullName: "org/repo", identifier: "issue:7" }, + }); + expect(resolvePortfolioQueueChatAction("release the queued item for acme/widgets issue:12")).toEqual({ + ok: true, + action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + target: { repoFullName: "acme/widgets", identifier: "issue:12" }, + }); + }); + + it("rejects empty, action-less, dual-action, and repo-less text without guessing", () => { + for (const text of ["", " ", "status please", "release something", "release and requeue acme/widgets", "requeued acme/widgets"]) { + const result = resolvePortfolioQueueChatAction(text); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toMatch(/Couldn't determine/i); + } + }); +}); + +describe("matchPortfolioQueueChatTarget (#6520)", () => { + it("matches release to in_progress and requeue to done", () => { + const items = { ok: true as const, items: [inProgressItem, doneItem] }; + expect(matchPortfolioQueueChatTarget(PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, { repoFullName: "acme/widgets" }, items)).toEqual({ + ok: true, + item: inProgressItem, + }); + expect(matchPortfolioQueueChatTarget(PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, { repoFullName: "acme/widgets", identifier: "issue:7" }, items)).toEqual({ + ok: true, + item: doneItem, + }); + }); + + it("rejects items-API errors, zero matches, and ambiguous multi-matches", () => { + expect(matchPortfolioQueueChatTarget(PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, { repoFullName: "acme/widgets" }, { ok: false, error: "down" }).ok).toBe(false); + expect( + matchPortfolioQueueChatTarget(PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, { repoFullName: "acme/widgets" }, { ok: true, items: [doneItem] }).ok, + ).toBe(false); + const twin: PortfolioQueueActionItem = { ...inProgressItem, identifier: "issue:99" }; + const ambiguous = matchPortfolioQueueChatTarget( + PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + { repoFullName: "acme/widgets" }, + { ok: true, items: [inProgressItem, twin] }, + ); + expect(ambiguous.ok).toBe(false); + if (!ambiguous.ok) expect(ambiguous.message).toMatch(/name an identifier/i); + }); +}); + +describe("handlePortfolioQueueChatCommand (#6520)", () => { + beforeEach(() => { + resetPortfolioQueueChatActionsRegistrationForTest(); + dispatchChatAction.mockReset(); + registerChatAction.mockReset(); + }); + + it("rejects an unresolvable instruction before the dispatch layer is invoked", async () => { + const result = await handlePortfolioQueueChatCommand("what is the queue status?", { + registry: isolatedRegistry() as never, + loadItems: async () => { + throw new Error("must not load items"); + }, + buildGovernorInput: () => { + throw new Error("must not build governor input"); + }, + evaluateGate: allowGate, + nowIso: () => "2026-07-16T09:00:00.000Z", + newId: () => "sys-1", + }); + + expect(result.dispatched).toBe(false); + expect(result.messages).toEqual([ + expect.objectContaining({ + role: "system", + content: expect.stringMatching(/Couldn't determine/i), + }), + ]); + expect(dispatchChatAction).not.toHaveBeenCalled(); + }); + + it("dispatches a successful release through the shared layer and surfaces it in the message list", async () => { + const registry = isolatedRegistry(); + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + expect(String(input)).toBe(PORTFOLIO_QUEUE_RELEASE_API_PATH); + return jsonResponse({ entry: { repoFullName: "acme/widgets", identifier: "issue:12", status: "queued" } }); + }); + + dispatchChatAction.mockImplementation(async (request: { action?: string; params?: unknown }) => { + const entry = registry.get(request.action ?? ""); + expect(entry).toBeTruthy(); + expect(entry!.paramsValidator(request.params)).toBe(true); + const handlerResult = await entry!.handler(request); + return { ok: true, status: "dispatched", action: request.action, result: handlerResult }; + }); + + const result = await handlePortfolioQueueChatCommand("release acme/widgets #12", { + env: enabledEnv, + registry: registry as never, + loadItems: async () => ({ ok: true, items: [inProgressItem, doneItem] }), + buildGovernorInput: () => ({ actionClass: "open_pr" }), + evaluateGate: allowGate, + fetchImpl: fetchImpl as unknown as typeof fetch, + nowIso: () => "2026-07-16T09:00:00.000Z", + newId: () => "sys-release", + }); + + expect(result.dispatched).toBe(true); + expect(dispatchChatAction).toHaveBeenCalledTimes(1); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result.messages[0]?.content).toContain("Queue release succeeded for acme/widgets (issue:12)"); + expect(result.messages[0]?.role).toBe("system"); + }); + + it("dispatches a successful requeue through the shared layer", async () => { + const registry = isolatedRegistry(); + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + expect(String(input)).toBe(PORTFOLIO_QUEUE_REQUEUE_API_PATH); + return jsonResponse({ entry: { repoFullName: "acme/widgets", identifier: "issue:7", status: "queued" } }); + }); + + dispatchChatAction.mockImplementation(async (request: { action?: string; params?: unknown }) => { + const entry = registry.get(request.action ?? ""); + const handlerResult = await entry!.handler(request); + return { ok: true, status: "dispatched", action: request.action, result: handlerResult }; + }); + + const result = await handlePortfolioQueueChatCommand("requeue acme/widgets", { + env: enabledEnv, + registry: registry as never, + loadItems: async () => ({ ok: true, items: [doneItem] }), + buildGovernorInput: () => ({}), + evaluateGate: allowGate, + fetchImpl: fetchImpl as unknown as typeof fetch, + nowIso: () => "2026-07-16T09:00:00.000Z", + newId: () => "sys-requeue", + }); + + expect(result.dispatched).toBe(true); + expect(result.messages[0]?.content).toContain("Queue requeue succeeded for acme/widgets (issue:7)"); + }); + + it("surfaces an endpoint error verbatim in the message list", async () => { + const registry = isolatedRegistry(); + const fetchImpl = vi.fn(async () => jsonResponse({ error: "item is not in_progress" }, 409)); + + dispatchChatAction.mockImplementation(async (request: { action?: string; params?: unknown }) => { + const entry = registry.get(request.action ?? ""); + const handlerResult = await entry!.handler(request); + return { ok: true, status: "dispatched", action: request.action, result: handlerResult }; + }); + + const result = await handlePortfolioQueueChatCommand("release acme/widgets", { + env: enabledEnv, + registry: registry as never, + loadItems: async () => ({ ok: true, items: [inProgressItem] }), + buildGovernorInput: () => ({}), + evaluateGate: allowGate, + fetchImpl: fetchImpl as unknown as typeof fetch, + nowIso: () => "2026-07-16T09:00:00.000Z", + newId: () => "sys-err", + }); + + expect(result.dispatched).toBe(true); + expect(result.messages[0]?.content).toContain("item is not in_progress"); + }); + + it("reports disabled when the scaffolding flag is off (still after a successful resolve+match)", async () => { + const registry = isolatedRegistry(); + registerPortfolioQueueChatActions({ registry: registry as never, evaluateGate: allowGate }); + dispatchChatAction.mockResolvedValue({ ok: false, status: "disabled", action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION }); + + const result = await handlePortfolioQueueChatCommand("release acme/widgets", { + env: {}, + registry: registry as never, + loadItems: async () => ({ ok: true, items: [inProgressItem] }), + buildGovernorInput: () => ({}), + evaluateGate: allowGate, + fetchImpl: vi.fn() as unknown as typeof fetch, + nowIso: () => "2026-07-16T09:00:00.000Z", + newId: () => "sys-disabled", + }); + + expect(result.dispatched).toBe(true); + expect(result.messages[0]?.content).toMatch(/Chat actions are disabled/i); + }); +}); + +describe("formatPortfolioQueueChatResultMessage + MessageList (#6520)", () => { + it("renders portfolio action-result fixtures inline in the message list", () => { + render(); + expect(screen.getByText(/Queue release succeeded for acme\/widgets/i)).toBeTruthy(); + expect(screen.getByText(/Queue requeue succeeded for acme\/widgets/i)).toBeTruthy(); + }); + + it("formats a gated dispatch as a system message naming the target", () => { + const message = formatPortfolioQueueChatResultMessage({ + id: "g1", + timestamp: "2026-07-16T09:00:00.000Z", + action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + item: inProgressItem, + dispatch: { + ok: true, + status: "dispatched", + action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + result: { ok: false, status: "gated", decision: { stage: "kill_switch" } }, + }, + }); + expect(message.content).toMatch(/Governor blocked the release for acme\/widgets \(issue:12\)/); + }); + + it("formats errorMessage, invalid_params, unknown_action, and fallback dispatch lines", () => { + expect( + formatPortfolioQueueChatResultMessage({ + id: "e1", + timestamp: "2026-07-16T09:00:00.000Z", + action: null, + errorMessage: "Couldn't determine a portfolio-queue action.", + }).content, + ).toBe("Couldn't determine a portfolio-queue action."); + + expect( + formatPortfolioQueueChatResultMessage({ + id: "e2", + timestamp: "2026-07-16T09:00:00.000Z", + action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + dispatch: { ok: false, status: "invalid_params", action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION }, + }).content, + ).toMatch(/invalid parameters/i); + + expect( + formatPortfolioQueueChatResultMessage({ + id: "e3", + timestamp: "2026-07-16T09:00:00.000Z", + action: PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, + dispatch: { ok: false, status: "unknown_action", action: PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION }, + }).content, + ).toMatch(/not registered/i); + + expect( + formatPortfolioQueueChatResultMessage({ + id: "e4", + timestamp: "2026-07-16T09:00:00.000Z", + action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + item: inProgressItem, + dispatch: { ok: true, status: "dispatched", action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, result: { ok: true, status: "executed" } }, + }).content, + ).toMatch(/Queue release dispatched for acme\/widgets/); + }); + + it("rejects a chat target when the items loader fails to match", async () => { + const result = await handlePortfolioQueueChatCommand("release missing/repo", { + registry: isolatedRegistry() as never, + loadItems: async () => ({ ok: true, items: [] }), + buildGovernorInput: () => ({}), + evaluateGate: allowGate, + nowIso: () => "2026-07-16T09:00:00.000Z", + newId: () => "sys-nomatch", + }); + expect(result.dispatched).toBe(false); + expect(result.messages[0]?.content).toMatch(/Couldn't determine a portfolio-queue target/i); + }); +}); diff --git a/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.ts b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.ts new file mode 100644 index 0000000000..73058279b6 --- /dev/null +++ b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.ts @@ -0,0 +1,309 @@ +// Portfolio-queue chat action registration + runner (#6520). Registers `portfolio.release` / +// `portfolio.requeue` into the shared (or an injected) chat-action registry, each thin-wrapping +// `releasePortfolioQueueItem` / `requeuePortfolioQueueItem` — the SAME client module the portfolio page +// buttons already use. Every invocation goes through `dispatchChatAction` (flag + registry + params +// validator) and `governorGatedHandler` (chokepoint). No second fetch/POST path, no new API route. + +import { + CHAT_ACTION_DISPATCH_ENABLE_VALUE, + CHAT_ACTION_DISPATCH_FLAG, + dispatchChatAction, +} from "../../../../packages/loopover-miner/lib/chat-action-dispatch.js"; +import type { ChatActionDispatchResult } from "../../../../packages/loopover-miner/lib/chat-action-dispatch.js"; +import { + governorGatedHandler, + registerChatAction, +} from "../../../../packages/loopover-miner/lib/chat-action-registry.js"; +import type { ChatActionRegistry } from "../../../../packages/loopover-miner/lib/chat-action-registry.js"; +import type { ChatMessage } from "../components/chat/fixtures"; +import { + PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, + resolvePortfolioQueueChatAction, + type PortfolioQueueChatActionName, + type PortfolioQueueChatActionTarget, +} from "./chat-portfolio-queue-resolve"; +import { + releasePortfolioQueueItem, + requeuePortfolioQueueItem, + type PortfolioQueueActionItem, + type PortfolioQueueActionResult, + type PortfolioQueueItemsResult, +} from "./portfolio-queue-actions"; + +export { + PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, + resolvePortfolioQueueChatAction, +}; + +export type PortfolioQueueChatParams = { + repoFullName: string; + identifier: string; + apiBaseUrl: string; +}; + +function isPortfolioQueueChatParams(params: unknown): params is PortfolioQueueChatParams { + if (typeof params !== "object" || params === null) return false; + const row = params as Record; + return ( + typeof row.repoFullName === "string" && + row.repoFullName.includes("/") && + typeof row.identifier === "string" && + row.identifier.length > 0 && + typeof row.apiBaseUrl === "string" && + row.apiBaseUrl.length > 0 + ); +} + +export type RegisterPortfolioQueueChatActionsOptions = { + /** Isolated registry for tests; defaults to the shared `registerChatAction` target. */ + registry?: ChatActionRegistry; + /** Override the Governor gate (tests inject an allow/deny stub). */ + evaluateGate?: (input: unknown, gateOptions?: unknown) => unknown; + /** Injected fetch so tests never hit the network. */ + fetchImpl?: typeof fetch; +}; + +let sharedRegistrationDone = false; + +/** + * Register the two portfolio-queue chat actions. Idempotent on the shared registry (safe to call from + * multiple entry points). When an isolated `registry` is supplied, always registers onto that instance. + */ +export function registerPortfolioQueueChatActions(options: RegisterPortfolioQueueChatActionsOptions = {}): void { + if (!options.registry && sharedRegistrationDone) return; + + const fetchImpl = options.fetchImpl ?? fetch; + const gateOpts = options.evaluateGate ? { evaluateGate: options.evaluateGate } : undefined; + + const releaseHandler = governorGatedHandler(async (request) => { + const params = request.params as PortfolioQueueChatParams; + return releasePortfolioQueueItem(params, fetchImpl); + }, gateOpts); + + const requeueHandler = governorGatedHandler(async (request) => { + const params = request.params as PortfolioQueueChatParams; + return requeuePortfolioQueueItem(params, fetchImpl); + }, gateOpts); + + const definitionFor = (handler: ReturnType) => ({ + paramsValidator: isPortfolioQueueChatParams, + handler, + }); + + const register = options.registry + ? (name: string, definition: Parameters[1]) => options.registry!.register(name, definition) + : registerChatAction; + + register(PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, definitionFor(releaseHandler)); + register(PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, definitionFor(requeueHandler)); + + if (!options.registry) sharedRegistrationDone = true; +} + +/** Test-only: reset the shared-registry once-flag so a later registration can run again. */ +export function resetPortfolioQueueChatActionsRegistrationForTest(): void { + sharedRegistrationDone = false; +} + +export type MatchPortfolioQueueChatTargetResult = + | { ok: true; item: PortfolioQueueActionItem } + | { ok: false; message: string }; + +/** + * Match a resolved chat target against live actionable queue items. Release only matches `in_progress`; + * requeue only matches `done` — same rules the portfolio table buttons enforce. + */ +export function matchPortfolioQueueChatTarget( + action: PortfolioQueueChatActionName, + target: PortfolioQueueChatActionTarget, + itemsResult: PortfolioQueueItemsResult, +): MatchPortfolioQueueChatTargetResult { + if (!itemsResult.ok) { + return { ok: false, message: `Couldn't determine a portfolio-queue target: ${itemsResult.error}` }; + } + const wantedStatus = action === PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION ? "in_progress" : "done"; + const repoKey = target.repoFullName.toLowerCase(); + const matches = itemsResult.items.filter((item) => { + if (item.repoFullName.toLowerCase() !== repoKey) return false; + if (item.status !== wantedStatus) return false; + if (target.identifier && item.identifier !== target.identifier) return false; + return true; + }); + if (matches.length === 0) { + return { + ok: false, + message: `Couldn't determine a portfolio-queue target: no ${wantedStatus.replace("_", "-")} item matched ${target.repoFullName}${target.identifier ? ` (${target.identifier})` : ""}.`, + }; + } + if (matches.length > 1) { + return { + ok: false, + message: `Couldn't determine a portfolio-queue target: ${matches.length} items matched ${target.repoFullName}; name an identifier (e.g. #12).`, + }; + } + return { ok: true, item: matches[0]! }; +} + +type GovernorHandlerResult = { + ok: boolean; + status: string; + result?: PortfolioQueueActionResult; +}; + +function readHandlerResult(dispatch: ChatActionDispatchResult): GovernorHandlerResult | undefined { + if (!dispatch.ok || dispatch.status !== "dispatched") return undefined; + const inner = dispatch.result; + if (!inner || typeof inner !== "object") return undefined; + return inner as GovernorHandlerResult; +} + +/** Format a dispatch / match outcome as a system chat message for the message list (#6515 / #6520). */ +export function formatPortfolioQueueChatResultMessage(input: { + id: string; + timestamp: string; + action: PortfolioQueueChatActionName | null; + item?: Pick | undefined; + dispatch?: ChatActionDispatchResult | undefined; + errorMessage?: string | undefined; +}): ChatMessage { + const verb = input.action === PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION ? "requeue" : "release"; + const targetLabel = input.item ? `${input.item.repoFullName} (${input.item.identifier})` : null; + + if (input.errorMessage) { + return { id: input.id, role: "system", content: input.errorMessage, timestamp: input.timestamp }; + } + + if (input.dispatch && !input.dispatch.ok) { + const detail = + input.dispatch.status === "disabled" + ? "Chat actions are disabled (set LOOPOVER_MINER_CHAT_ACTIONS=enabled to allow them)." + : input.dispatch.status === "invalid_params" + ? `Couldn't ${verb}: invalid parameters.` + : input.dispatch.status === "unknown_action" + ? `Couldn't ${verb}: action is not registered.` + : `Couldn't ${verb}: ${input.dispatch.status}.`; + return { id: input.id, role: "system", content: detail, timestamp: input.timestamp }; + } + + const handler = input.dispatch ? readHandlerResult(input.dispatch) : undefined; + if (handler && handler.ok === false && handler.status === "gated") { + return { + id: input.id, + role: "system", + content: `Governor blocked the ${verb} for ${targetLabel ?? "the target"}.`, + timestamp: input.timestamp, + }; + } + + const write = handler?.result; + if (write && !write.ok) { + return { + id: input.id, + role: "system", + content: `Queue ${verb} failed for ${targetLabel ?? "the target"}: ${write.error}`, + timestamp: input.timestamp, + }; + } + if (write?.ok) { + return { + id: input.id, + role: "system", + content: `Queue ${verb} succeeded for ${write.entry.repoFullName} (${write.entry.identifier}) — status is now ${write.entry.status}.`, + timestamp: input.timestamp, + }; + } + + return { + id: input.id, + role: "system", + content: targetLabel ? `Queue ${verb} dispatched for ${targetLabel}.` : `Queue ${verb} dispatched.`, + timestamp: input.timestamp, + }; +} + +export type HandlePortfolioQueueChatCommandDeps = { + env?: Record; + registry?: ChatActionRegistry; + /** Load actionable queue rows (defaults to the live items API via the registration's fetchImpl path). */ + loadItems: () => Promise; + /** Build Governor chokepoint input for the matched item. */ + buildGovernorInput: (item: PortfolioQueueActionItem, action: PortfolioQueueChatActionName) => unknown; + nowIso?: () => string; + newId?: () => string; + evaluateGate?: (input: unknown, gateOptions?: unknown) => unknown; + fetchImpl?: typeof fetch; +}; + +export type HandlePortfolioQueueChatCommandResult = { + /** Messages to append to the chat list (user echo is the caller's job; this returns system outcomes). */ + messages: ChatMessage[]; + /** True only when `dispatchChatAction` was invoked. */ + dispatched: boolean; +}; + +/** + * End-to-end chat command handler for portfolio release/requeue (#6520): resolve → match → dispatch → + * message-list entry. An unresolvable / ambiguous instruction never reaches the dispatch layer. + */ +export async function handlePortfolioQueueChatCommand( + text: string, + deps: HandlePortfolioQueueChatCommandDeps, +): Promise { + const nowIso = deps.nowIso ?? (() => new Date().toISOString()); + const newId = deps.newId ?? (() => crypto.randomUUID()); + const stamp = nowIso(); + + const resolved = resolvePortfolioQueueChatAction(text); + if (!resolved.ok) { + return { + dispatched: false, + messages: [{ id: newId(), role: "system", content: resolved.message, timestamp: stamp }], + }; + } + + const items = await deps.loadItems(); + const matched = matchPortfolioQueueChatTarget(resolved.action, resolved.target, items); + if (!matched.ok) { + return { + dispatched: false, + messages: [{ id: newId(), role: "system", content: matched.message, timestamp: stamp }], + }; + } + + registerPortfolioQueueChatActions({ + ...(deps.registry ? { registry: deps.registry } : {}), + evaluateGate: deps.evaluateGate, + fetchImpl: deps.fetchImpl, + }); + + const env = deps.env ?? { [CHAT_ACTION_DISPATCH_FLAG]: CHAT_ACTION_DISPATCH_ENABLE_VALUE }; + const params: PortfolioQueueChatParams = { + repoFullName: matched.item.repoFullName, + identifier: matched.item.identifier, + apiBaseUrl: matched.item.apiBaseUrl, + }; + + const dispatch = await dispatchChatAction( + { + action: resolved.action, + params, + governorInput: deps.buildGovernorInput(matched.item, resolved.action), + }, + { env, ...(deps.registry ? { registry: deps.registry } : {}) }, + ); + + return { + dispatched: true, + messages: [ + formatPortfolioQueueChatResultMessage({ + id: newId(), + timestamp: stamp, + action: resolved.action, + item: matched.item, + dispatch, + }), + ], + }; +} diff --git a/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-resolve.ts b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-resolve.ts new file mode 100644 index 0000000000..46a61d21ad --- /dev/null +++ b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-resolve.ts @@ -0,0 +1,70 @@ +// Chat-text → portfolio-queue action resolution (#6520). Pure: no fetch, no dispatch. Parses an operator's +// chat message into one of the two known actions (`portfolio.release` / `portfolio.requeue`) plus a target +// repo (and optional identifier). Ambiguous / malformed text returns an explicit unresolvable result so the +// caller never falls through to a best-guess `dispatchChatAction` call. + +export const PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION = "portfolio.release"; +export const PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION = "portfolio.requeue"; + +export type PortfolioQueueChatActionName = + | typeof PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION + | typeof PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION; + +/** Fields the dispatch handler needs; `apiBaseUrl` is filled in after matching a live queue item. */ +export type PortfolioQueueChatActionTarget = { + repoFullName: string; + /** Present when the operator named an issue/identifier; otherwise the runner matches by repo alone. */ + identifier?: string; +}; + +export type PortfolioQueueChatResolveResult = + | { ok: true; action: PortfolioQueueChatActionName; target: PortfolioQueueChatActionTarget } + | { ok: false; reason: "unresolvable"; message: string }; + +const ACTION_WORD: Record = { + release: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + requeue: PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, +}; + +/** `owner/repo` — letters, digits, dots, underscores, hyphens; one slash. */ +const REPO_RE = /\b([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)\b/; +/** Optional identifier: `issue:12`, `#12`, or a bare positive integer. */ +const IDENTIFIER_RE = /\b(?:issue:(\d+)|#(\d+)|(?).filter((word) => { + // Word boundary so "prelease" / "requeued" don't match; allow leading verbs ("please release…"). + return new RegExp(`\\b${word}\\b`, "i").test(lower); + }); + if (actionHits.length !== 1) { + return { ok: false, reason: "unresolvable", message: UNRESOLVABLE }; + } + const action = ACTION_WORD[actionHits[0]!]!; + + const repoMatch = trimmed.match(REPO_RE); + if (!repoMatch?.[1]) { + return { ok: false, reason: "unresolvable", message: UNRESOLVABLE }; + } + const repoFullName = repoMatch[1]; + + // Strip the matched repo from the remainder so a bare owner segment isn't treated as an identifier. + const withoutRepo = trimmed.replace(repoMatch[0], " "); + const idMatch = withoutRepo.match(IDENTIFIER_RE); + const identifierNum = idMatch?.[1] ?? idMatch?.[2] ?? idMatch?.[3]; + const target: PortfolioQueueChatActionTarget = identifierNum + ? { repoFullName, identifier: `issue:${identifierNum}` } + : { repoFullName }; + + return { ok: true, action, target }; +} From 3bbd2dbf3e4ffc2ba9bd0b90f88140658a3e266c Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Thu, 16 Jul 2026 10:57:29 -0500 Subject: [PATCH 2/2] style(miner-ui): prettier-fix portfolio chat action modules EOF Co-authored-by: Cursor --- .../lib/chat-portfolio-queue-actions.test.tsx | 58 ++++++++++++++++--- .../src/lib/chat-portfolio-queue-actions.ts | 12 ++-- .../src/lib/chat-portfolio-queue-resolve.ts | 5 +- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.test.tsx b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.test.tsx index bc64d90878..7906f19a66 100644 --- a/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.test.tsx +++ b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.test.tsx @@ -72,9 +72,15 @@ function jsonResponse(body: unknown, status = 200): Response { } function isolatedRegistry() { - const actions = new Map boolean; handler: (request: unknown) => Promise }>(); + const actions = new Map< + string, + { paramsValidator: (params: unknown) => boolean; handler: (request: unknown) => Promise } + >(); return { - register(name: string, definition: { paramsValidator: (params: unknown) => boolean; handler: (request: unknown) => Promise }) { + register( + name: string, + definition: { paramsValidator: (params: unknown) => boolean; handler: (request: unknown) => Promise }, + ) { actions.set(name, definition); return definition; }, @@ -107,7 +113,14 @@ describe("resolvePortfolioQueueChatAction (#6520)", () => { }); it("rejects empty, action-less, dual-action, and repo-less text without guessing", () => { - for (const text of ["", " ", "status please", "release something", "release and requeue acme/widgets", "requeued acme/widgets"]) { + for (const text of [ + "", + " ", + "status please", + "release something", + "release and requeue acme/widgets", + "requeued acme/widgets", + ]) { const result = resolvePortfolioQueueChatAction(text); expect(result.ok).toBe(false); if (!result.ok) expect(result.message).toMatch(/Couldn't determine/i); @@ -118,20 +131,38 @@ describe("resolvePortfolioQueueChatAction (#6520)", () => { describe("matchPortfolioQueueChatTarget (#6520)", () => { it("matches release to in_progress and requeue to done", () => { const items = { ok: true as const, items: [inProgressItem, doneItem] }; - expect(matchPortfolioQueueChatTarget(PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, { repoFullName: "acme/widgets" }, items)).toEqual({ + expect( + matchPortfolioQueueChatTarget(PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, { repoFullName: "acme/widgets" }, items), + ).toEqual({ ok: true, item: inProgressItem, }); - expect(matchPortfolioQueueChatTarget(PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, { repoFullName: "acme/widgets", identifier: "issue:7" }, items)).toEqual({ + expect( + matchPortfolioQueueChatTarget( + PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, + { repoFullName: "acme/widgets", identifier: "issue:7" }, + items, + ), + ).toEqual({ ok: true, item: doneItem, }); }); it("rejects items-API errors, zero matches, and ambiguous multi-matches", () => { - expect(matchPortfolioQueueChatTarget(PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, { repoFullName: "acme/widgets" }, { ok: false, error: "down" }).ok).toBe(false); expect( - matchPortfolioQueueChatTarget(PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, { repoFullName: "acme/widgets" }, { ok: true, items: [doneItem] }).ok, + matchPortfolioQueueChatTarget( + PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + { repoFullName: "acme/widgets" }, + { ok: false, error: "down" }, + ).ok, + ).toBe(false); + expect( + matchPortfolioQueueChatTarget( + PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + { repoFullName: "acme/widgets" }, + { ok: true, items: [doneItem] }, + ).ok, ).toBe(false); const twin: PortfolioQueueActionItem = { ...inProgressItem, identifier: "issue:99" }; const ambiguous = matchPortfolioQueueChatTarget( @@ -264,7 +295,11 @@ describe("handlePortfolioQueueChatCommand (#6520)", () => { it("reports disabled when the scaffolding flag is off (still after a successful resolve+match)", async () => { const registry = isolatedRegistry(); registerPortfolioQueueChatActions({ registry: registry as never, evaluateGate: allowGate }); - dispatchChatAction.mockResolvedValue({ ok: false, status: "disabled", action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION }); + dispatchChatAction.mockResolvedValue({ + ok: false, + status: "disabled", + action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + }); const result = await handlePortfolioQueueChatCommand("release acme/widgets", { env: {}, @@ -339,7 +374,12 @@ describe("formatPortfolioQueueChatResultMessage + MessageList (#6520)", () => { timestamp: "2026-07-16T09:00:00.000Z", action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, item: inProgressItem, - dispatch: { ok: true, status: "dispatched", action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, result: { ok: true, status: "executed" } }, + dispatch: { + ok: true, + status: "dispatched", + action: PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, + result: { ok: true, status: "executed" }, + }, }).content, ).toMatch(/Queue release dispatched for acme\/widgets/); }); diff --git a/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.ts b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.ts index 73058279b6..2cd2b0f134 100644 --- a/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.ts +++ b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-actions.ts @@ -31,11 +31,7 @@ import { type PortfolioQueueItemsResult, } from "./portfolio-queue-actions"; -export { - PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, - PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, - resolvePortfolioQueueChatAction, -}; +export { PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION, resolvePortfolioQueueChatAction }; export type PortfolioQueueChatParams = { repoFullName: string; @@ -93,7 +89,8 @@ export function registerPortfolioQueueChatActions(options: RegisterPortfolioQueu }); const register = options.registry - ? (name: string, definition: Parameters[1]) => options.registry!.register(name, definition) + ? (name: string, definition: Parameters[1]) => + options.registry!.register(name, definition) : registerChatAction; register(PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION, definitionFor(releaseHandler)); @@ -108,8 +105,7 @@ export function resetPortfolioQueueChatActionsRegistrationForTest(): void { } export type MatchPortfolioQueueChatTargetResult = - | { ok: true; item: PortfolioQueueActionItem } - | { ok: false; message: string }; + { ok: true; item: PortfolioQueueActionItem } | { ok: false; message: string }; /** * Match a resolved chat target against live actionable queue items. Release only matches `in_progress`; diff --git a/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-resolve.ts b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-resolve.ts index 46a61d21ad..f771779971 100644 --- a/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-resolve.ts +++ b/apps/loopover-miner-ui/src/lib/chat-portfolio-queue-resolve.ts @@ -7,8 +7,7 @@ export const PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION = "portfolio.release"; export const PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION = "portfolio.requeue"; export type PortfolioQueueChatActionName = - | typeof PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION - | typeof PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION; + typeof PORTFOLIO_QUEUE_CHAT_RELEASE_ACTION | typeof PORTFOLIO_QUEUE_CHAT_REQUEUE_ACTION; /** Fields the dispatch handler needs; `apiBaseUrl` is filled in after matching a live queue item. */ export type PortfolioQueueChatActionTarget = { @@ -32,7 +31,7 @@ const REPO_RE = /\b([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)\b/; const IDENTIFIER_RE = /\b(?:issue:(\d+)|#(\d+)|(?