From a628d28898bb499e089602edc8a76fcb7e6b74ec Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 2 Sep 2026 20:37:02 -0300 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20orchestration=20canvas=20fas?= =?UTF-8?q?e=202=20=E2=80=94=20repeat=20action=20+=20A2A=20memory=20hits?= =?UTF-8?q?=20(2.6/2.7)=20(#12508)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): conductor task creation route (repeat support) * feat(a2a): record memoryHits consulted per task (observability, 2.7) * feat(dashboard): repeat action in orchestration drawer (2.6 — repeat only) * fix(dashboard): require every field each repeat contract needs before enabling the action * feat(dashboard): memory-used drawer section + fase2 i18n/changelog (2.7) * fix(dashboard): validate memoryHits shape before rendering + locale wording fixes * fix(dashboard,a2a): stop memoryHits leaking into repeats, harden drawer guard + status clamp Final whole-branch review fix wave for the Orchestration Canvas Fase 2 PR-C. - a2a: `createTask` stores a COPY of `input.metadata` instead of aliasing it, so the observability `memoryHits` written by `executeA2ATaskWithState` no longer leak into `task.input.metadata`, into the persisted `a2a_tasks.input_json`, or into the drawer's "Repeat" body (a repeated task was born carrying the previous run's memory snippets, even with the `OMNIROUTE_A2A_MEMORY_HITS=0` kill-switch on). - dashboard: `repeatReqFor` strips `memoryHits` from the a2a repeat metadata, so tasks persisted before the copy-fix do not propagate them either. - dashboard: the "Memory used" section now requires all four rendered fields (id, key, type, snippet) to be strings — `{ id: "x", key: { a: 1 } }` used to throw "Objects are not valid as a React child" and take the whole drawer down. - dashboard: an `/a2a` action answered with a JSON-RPC error under HTTP 200 is reported as a failure (`RPC `, code only — never the upstream message) instead of a success toast; the secured-deployment rejection keeps surfacing the sanitized `HTTP 400`. - api: the conductor task-creation route clamps a hub status outside 400-599 to 502, so an out-of-range status can no longer turn a hub refusal into a `RangeError`. - dashboard: the History tab's `onActionDone` keeps the drawer mounted (and refreshes the range) instead of closing it, so the repeat/cancel confirmation is actually visible. - a2a: documented the recall owner-id limitation — `task.owner` is a SHA-256 key prefix while memory rows are keyed by the DB api-key id, and no hash-to-id lookup exists today, so recall only resolves under the keyless posture. * refactor(dashboard): split drawer repeat helpers and test file under the size/complexity gates --------- Co-authored-by: Markus Hartung --- .env.example | 6 + .../features/orchestration-repeat-memory.md | 24 + docs/openapi.yaml | 8 + docs/reference/ENVIRONMENT.md | 1 + .../drawer/OrchestrationDrawer.tsx | 159 +++- .../orchestration/drawer/useDrawerDetail.ts | 144 +++- .../orchestration/tabs/HistoryTab.tsx | 22 +- src/app/api/conductor/tasks/route.ts | 66 ++ src/i18n/messages/ar.json | 7 +- src/i18n/messages/az.json | 7 +- src/i18n/messages/bg.json | 7 +- src/i18n/messages/bn.json | 7 +- src/i18n/messages/cs.json | 7 +- src/i18n/messages/da.json | 7 +- src/i18n/messages/de.json | 7 +- src/i18n/messages/en.json | 7 +- src/i18n/messages/es.json | 7 +- src/i18n/messages/fa.json | 7 +- src/i18n/messages/fi.json | 7 +- src/i18n/messages/fr.json | 7 +- src/i18n/messages/gu.json | 7 +- src/i18n/messages/he.json | 7 +- src/i18n/messages/hi.json | 7 +- src/i18n/messages/hu.json | 7 +- src/i18n/messages/id.json | 7 +- src/i18n/messages/it.json | 7 +- src/i18n/messages/ja.json | 7 +- src/i18n/messages/ko.json | 7 +- src/i18n/messages/mr.json | 7 +- src/i18n/messages/ms.json | 7 +- src/i18n/messages/nl.json | 7 +- src/i18n/messages/no.json | 7 +- src/i18n/messages/phi.json | 7 +- src/i18n/messages/pl.json | 7 +- src/i18n/messages/pt-BR.json | 7 +- src/i18n/messages/pt.json | 7 +- src/i18n/messages/ro.json | 7 +- src/i18n/messages/ru.json | 7 +- src/i18n/messages/sk.json | 7 +- src/i18n/messages/sv.json | 7 +- src/i18n/messages/sw.json | 7 +- src/i18n/messages/ta.json | 7 +- src/i18n/messages/te.json | 7 +- src/i18n/messages/th.json | 7 +- src/i18n/messages/tr.json | 7 +- src/i18n/messages/uk-UA.json | 7 +- src/i18n/messages/ur.json | 7 +- src/i18n/messages/vi.json | 7 +- src/i18n/messages/zh-CN.json | 7 +- src/i18n/messages/zh-TW.json | 7 +- src/lib/a2a/taskExecution.ts | 99 ++- src/lib/a2a/taskManager.ts | 15 +- tests/unit/a2a-memory-hits.test.ts | 331 ++++++++ tests/unit/conductor-create-route.test.ts | 155 ++++ .../ui/orchestrationDrawerRepeat.test.tsx | 786 ++++++++++++++++++ .../unit/ui/orchestrationHistoryTab.test.tsx | 30 + 56 files changed, 2078 insertions(+), 62 deletions(-) create mode 100644 changelog.d/features/orchestration-repeat-memory.md create mode 100644 src/app/api/conductor/tasks/route.ts create mode 100644 tests/unit/a2a-memory-hits.test.ts create mode 100644 tests/unit/conductor-create-route.test.ts create mode 100644 tests/unit/ui/orchestrationDrawerRepeat.test.tsx diff --git a/.env.example b/.env.example index 187a1049d3..4e6438b593 100644 --- a/.env.example +++ b/.env.example @@ -869,6 +869,12 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # or <= 0 falls back to the default. # OMNIROUTE_A2A_HISTORY_RETENTION_DAYS=30 +# Kill-switch for the A2A memory-hits observability feature (Orchestration Canvas +# Fase 2). Set to "0" to skip the memory recall lookup entirely; any other value +# (including unset) keeps it enabled. +# Used by: src/lib/a2a/taskExecution.ts (collectMemoryHits). +# OMNIROUTE_A2A_MEMORY_HITS=1 + # Enable the offline/local Issue Agent recorded-triage endpoint. # Used by: src/app/api/issue-agent/runs/route.ts. Default: disabled. # OMNIROUTE_ISSUE_AGENT_ENABLED=false diff --git a/changelog.d/features/orchestration-repeat-memory.md b/changelog.d/features/orchestration-repeat-memory.md new file mode 100644 index 0000000000..b1daddb0aa --- /dev/null +++ b/changelog.d/features/orchestration-repeat-memory.md @@ -0,0 +1,24 @@ +- **feat(dashboard):** the orchestration detail drawer gained a "Repeat" action for Cloud Agent, + A2A and Conductor tasks — a two-click confirm (click once to arm, click again within the + confirm window to fire) re-submits the original prompt/input as a new run. The button is + disabled with an explanatory tooltip whenever the original input can't be recovered from the + loaded task detail (e.g. it never carried a prompt, or the detail failed to load). + Two limitations of the A2A variant, by design: it targets the `/a2a` JSON-RPC endpoint, which + authenticates with an API key only (`REQUIRE_API_KEY=true` or a configured `OMNIROUTE_API_KEY` + makes a dashboard-session repeat answer `HTTP 400` — surfaced verbatim in the drawer's error + line, never as a success), and the `message/send` call is SYNCHRONOUS: the POST blocks for the + whole skill run, so the success confirmation only appears once the repeated task finishes. + A dashboard-authenticated A2A creation path is deliberately left to a follow-up — widening the + endpoint's auth posture is an operator decision, not a side effect of this feature. +- **feat(a2a):** A2A task execution now records which memories were consulted for the task's + last user message as `metadata.memoryHits` (id/key/type/content-snippet) plus a `memory_hits` + history event, purely for observability — the retrieved memory is never injected into a + skill's prompt or behavior. Gated by the `OMNIROUTE_A2A_MEMORY_HITS` kill-switch (default + enabled; set to `0` to skip the recall lookup entirely). The drawer's new "Memory used" + section lists these hits for a2a tasks and is omitted whenever there are none. Known + limitation: recall only resolves under the keyless posture — a keyed caller's task owner is a + SHA-256 prefix of the API key, while memory rows are keyed by the database api-key id, and no + hash→id lookup exists today, so the hit list stays empty for keyed callers. The recorded hits + are also kept out of the task's own `input` (and therefore out of the persisted input and of + the "Repeat" request body), so repeating a task never re-sends the previous run's memory + snippets. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a8d679f8dc..2f83d88080 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -8953,6 +8953,14 @@ paths: responses: "200": description: OK + /api/conductor/tasks: + post: + tags: + - Conductor + summary: "POST conductor › tasks" + responses: + "201": + description: Created /api/conductor/tasks/{id}: get: tags: diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 2dded98167..ace16cf6c9 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -502,6 +502,7 @@ detection above). | `OMNIROUTE_API_KEY_ID` | _(unset)_ | `open-sse/mcp-server/audit.ts` | Key ID for MCP audit log attribution. | | `ROUTER_API_KEY` | _(unset)_ | Legacy | Legacy alias for `OMNIROUTE_API_KEY`. | | `OMNIROUTE_A2A_HISTORY_RETENTION_DAYS` | `30` | `src/lib/a2a/taskManager.ts` | Days of A2A task history kept in the local database before the daily purge deletes a row. Unset, non-numeric, or `<= 0` falls back to `30`. | +| `OMNIROUTE_A2A_MEMORY_HITS` | `1` | `src/lib/a2a/taskExecution.ts` | Kill-switch for the A2A memory-hits observability feature. Set to `0` to skip the memory recall lookup for a task entirely; any other value (including unset) keeps it enabled. | | `OMNIROUTE_ISSUE_AGENT_ENABLED` | `false` | `src/app/api/issue-agent/runs/route.ts` | Enables the offline/local Issue Agent recorded-triage endpoint. Leave disabled unless explicitly running local recorded-triage workflows. | | `OMNIROUTE_ISSUE_AGENT_TIMEOUT_MS` | _(unset)_ | `src/lib/issueAgent/execution.ts` | Timeout (ms) for a single Issue Agent recorded-triage run. Clamped to an internal maximum; falls back to the built-in default when unset or invalid. | | `OMNIROUTE_CONTEXT` | _(active context)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | CLI remote-mode context/profile for `omniroute` commands; overrides the active context in the local contexts store. Equivalent to `--context `. | diff --git a/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx b/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx index 39f5edcef0..1480e4e3d5 100644 --- a/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx @@ -9,6 +9,7 @@ import type { CloudAgentTask } from "@/lib/cloudAgent/types"; import type { A2ATask } from "@/lib/a2a/taskManager"; const TOAST_MS = 2500; +const REPEAT_CONFIRM_MS = 3000; /** Timeline normalized by source — the same data the Timeline component displays. */ function normalizedTimeline(node: OrchNode, detail: unknown): unknown { @@ -256,27 +257,150 @@ function DrawerResult({ ); } -/** Approve/cancel action buttons — omitted when neither action is available. */ +/** + * "Memory used" section (Task D4, PR-C): lists the memories consulted for an a2a task's + * last user message (`task.metadata.memoryHits`, written by `collectMemoryHits` in + * `src/lib/a2a/taskExecution.ts` — observability only, never injected into a skill's + * behavior). `metadata` is caller-supplied and unvalidated end to end + * (`src/app/a2a/route.ts` passes `params?.metadata` straight through, and + * `collectMemoryHits` only overwrites it when it finds hits), so `memoryHits` cannot be + * trusted at the `A2ATask` type — a malicious/buggy A2A client could post + * `metadata: { memoryHits: "boom" }` (a string's `.length` is truthy, so a plain + * `!hits || hits.length === 0` guard would let it through) or an array containing + * malformed entries. Every entry is validated defensively before it is ever rendered, so a + * bad payload silently drops that entry instead of crashing the drawer — and the check + * covers ALL FOUR fields, not just `id`: `type`, `key` and `snippet` are rendered as React + * children, so `{ id: "x", key: { a: 1 } }` (a validated id next to an object field) would + * throw "Objects are not valid as a React child" and take the whole drawer down. + */ +const MEMORY_HIT_FIELDS = ["id", "key", "type", "snippet"] as const; + +function DrawerMemory({ a2a, t }: { a2a: A2ATask | null; t: Translate }) { + const raw = a2a?.metadata?.memoryHits; + const hits = (Array.isArray(raw) ? raw : []).filter( + (h): h is { id: string; key: string; type: string; snippet: string } => + !!h && + typeof h === "object" && + MEMORY_HIT_FIELDS.every((f) => typeof (h as Record)[f] === "string") + ); + if (hits.length === 0) return null; + return ( +
+
    + {hits.map((h) => ( +
  • + {h.type} + {h.key} +
    {h.snippet}
    +
  • + ))} +
+
+ ); +} + +/** + * Two-click confirm for a single non-idempotent action — deliberately NOT + * `window.confirm`, since modal dialogs block browser automation. The first click + * arms `confirming` for `REPEAT_CONFIRM_MS`; a second click within that window runs + * `onConfirm`. The timer is cleared before it can fire again (a stale timeout must + * never flip an already-fired confirmation back) and on unmount. + */ +function useTwoClickConfirm(onConfirm: () => void) { + const [confirming, setConfirming] = useState(false); + const timerRef = useRef | null>(null); + + const clearTimer = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + }; + + const onClick = () => { + if (confirming) { + clearTimer(); + setConfirming(false); + onConfirm(); + return; + } + setConfirming(true); + timerRef.current = setTimeout(() => setConfirming(false), REPEAT_CONFIRM_MS); + }; + + useEffect(() => clearTimer, []); + + return { confirming, onClick }; +} + +/** + * Repeat button: two-click confirm, disabled + tooltip when the input isn't recoverable. + * `canRepeat` already folds in `!busy` (so the button is disabled while a repeat POST is + * in flight), but the tooltip must not claim the input is unrecoverable in that case — a + * successful repeat is legitimately busy, not unavailable. `busy` is threaded through + * separately so the title can tell the two apart: silent (no title) while busy, the real + * `repeatUnavailable` message only when the input truly can't be recovered. + */ +function RepeatButton({ + canRepeat, + busy, + repeat, + onActionDone, + onToast, + t, +}: { + canRepeat: boolean; + busy: boolean; + repeat: () => Promise; + onActionDone: () => void; + onToast: (text: string) => void; + t: Translate; +}) { + const { confirming, onClick } = useTwoClickConfirm(() => { + void (async () => { + if (await repeat()) { + onActionDone(); + onToast(t("repeatDone")); + } + })(); + }); + return ( + + ); +} + +/** Approve/cancel/repeat action buttons — omitted when none of them apply. */ function DrawerActions({ canApprove, canCancel, + showRepeat, + canRepeat, busy, approve, cancel, + repeat, onActionDone, onToast, t, }: { canApprove: boolean; canCancel: boolean; + showRepeat: boolean; + canRepeat: boolean; busy: boolean; approve: () => Promise; cancel: () => Promise; + repeat: () => Promise; onActionDone: () => void; onToast: (text: string) => void; t: Translate; }) { - if (!canApprove && !canCancel) return null; + if (!canApprove && !canCancel && !showRepeat) return null; const run = async (fn: () => Promise) => { if (await fn()) { onActionDone(); @@ -304,6 +428,16 @@ function DrawerActions({ {t("actionCancel")} )} + {showRepeat && ( + + )} ); @@ -357,14 +491,27 @@ export function OrchestrationDrawer({ onActionDone: () => void; }) { const t = useTranslations("orchestration"); - const { detail, isLoading, busy, error, errorKind, canApprove, canCancel, approve, cancel } = - useDrawerDetail(node); + const { + detail, + isLoading, + busy, + error, + errorKind, + canApprove, + canCancel, + canRepeat, + approve, + cancel, + repeat, + } = useDrawerDetail(node); useCloseOnEscape(node, onClose); const { toast, showToast } = useDrawerToast(); if (!node) return null; const state = node.state ?? "queued"; const { ca, a2a } = narrowDetail(node, detail); + const showRepeat = + node.source === "cloud-agent" || node.source === "a2a" || node.source === "conductor"; return ( <> @@ -397,12 +544,16 @@ export function OrchestrationDrawer({ + ` errors and AbortError pass through verbatim, everything else collapses to a generic string. +// Client-safe stand-in for sanitizeErrorMessage (server-only, breaks the client bundle — #10692): only our own `HTTP ` / `RPC ` errors and AbortError pass through verbatim, everything else collapses to a generic string. `RPC ` carries the JSON-RPC error CODE only — never the upstream `error.message`, which is attacker/upstream-controlled text (Hard Rule #12). function toSafeErrorText(err: unknown): string { if (err instanceof Error) { if (/^HTTP \d{3}$/.test(err.message)) return err.message; + if (/^RPC -?\d{1,6}$/.test(err.message)) return err.message; if (err.name === "AbortError") return "Request cancelled"; } return "Request failed"; @@ -55,6 +59,115 @@ function routeFor(node: OrchNode): SourceRoute { return { detailUrl: null, cancelReq: null, approveReq: null }; // runners/overflow: raw only } +/** + * Builds the POST request that recreates a task with the same input, from the LOADED + * DETAIL — never from `node` (the node only carries display fields, not the full + * original request). Returns `null` when the original input cannot be recovered, so + * the caller can render the "Repeat" action disabled instead of firing a bad request. + * Contracts, verified against the live routes (not assumed) — the null-guard requires + * EVERY field the target route treats as mandatory, not merely one of them (a partially + * recoverable detail is not recoverable: a POST missing one required field 400s, which is + * an enabled button that cannot work): + * - cloud-agent → `POST /api/v1/agents/tasks`, `CreateCloudAgentTaskSchema` shape + * (`src/lib/cloudAgent/types.ts`) — `providerId`, `prompt` and `source` are all + * required there; `options` is optional. + * - a2a → `POST /a2a`, JSON-RPC `message/send` (`src/app/a2a/route.ts`) — only + * `messages` is required (`skill` defaults to `"smart-routing"`, `metadata` is + * optional), so that is the only field guarded here. + * - conductor → `POST /api/conductor/tasks` (D1, `src/app/api/conductor/tasks/route.ts`) + * — `repoUrl` and `prompt` are both `z.string().min(1)` (required); `ConductorTaskDetail` + * leaves `repo`/`prompt` independently nullable, so either one missing must null out + * the whole request. + */ +/** + * Strips `memoryHits` from the metadata a repeat re-sends. `metadata.memoryHits` is + * OBSERVABILITY written by the previous run (`src/lib/a2a/taskExecution.ts`) — never + * caller input — so echoing it back would make the new task be born carrying the old + * run's memory snippets, and would keep showing them in the drawer even with the + * `OMNIROUTE_A2A_MEMORY_HITS=0` kill-switch on. `taskManager.createTask` no longer aliases + * `metadata` into `input`, but historical tasks persisted before that fix still carry the + * hits inside `input.metadata`, so the repeat path must drop them too. + * Returns `undefined` for a missing/non-object metadata so the JSON body omits the field + * entirely (the route treats `params.metadata` as optional). + */ +function withoutMemoryHits(metadata: unknown): Record | undefined { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined; + const rest = { ...(metadata as Record) }; + delete rest.memoryHits; + return rest; +} + +/** Builds the JSON-body `RequestInit` shared by every `repeatReqFor*` source builder below. */ +function postJson(body: unknown): RequestInit { + return { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +/** cloud-agent repeat builder — `POST /api/v1/agents/tasks`, `CreateCloudAgentTaskSchema` shape. */ +function repeatReqForCloudAgent(detail: unknown): { url: string; init: RequestInit } | null { + const d = detail as CloudAgentTask | null; + if (!d?.providerId || !d?.prompt || !d?.source) return null; + return { + url: "/api/v1/agents/tasks", + init: postJson({ + providerId: d.providerId, + prompt: d.prompt, + source: d.source, + options: d.options, + }), + }; +} + +/** a2a repeat builder — `POST /a2a`, JSON-RPC `message/send` from `detail.input`. */ +function repeatReqForA2a( + nodeId: string, + detail: unknown +): { url: string; init: RequestInit } | null { + const d = detail as A2ATask | null; + if (!d?.input?.messages?.length) return null; + return { + url: "/a2a", + init: postJson({ + jsonrpc: "2.0", + id: nodeId, + method: "message/send", + params: { + skill: d.input.skill, + messages: d.input.messages, + metadata: withoutMemoryHits(d.input.metadata), + }, + }), + }; +} + +/** conductor repeat builder — `POST /api/conductor/tasks` (D1 task-creation route). */ +function repeatReqForConductor(detail: unknown): { url: string; init: RequestInit } | null { + const d = detail as ConductorTaskDetail | null; + if (!d?.repo || !d?.prompt) return null; + return { + url: "/api/conductor/tasks", + init: postJson({ + repoUrl: d.repo, + prompt: d.prompt, + baseRef: d.base_ref ?? undefined, + mode: d.mode, + }), + }; +} + +export function repeatReqFor( + node: OrchNode, + detail: unknown +): { url: string; init: RequestInit } | null { + if (node.id.startsWith("cloud-agent:")) return repeatReqForCloudAgent(detail); + if (node.id.startsWith("a2a:")) return repeatReqForA2a(node.id, detail); + if (node.id.startsWith("conductor:task:")) return repeatReqForConductor(detail); + return null; +} + /** * Unwraps a task-detail GET response to the actual task payload. Each source's * route has its own envelope — verified against the live handlers, not assumed: @@ -138,6 +251,26 @@ function useFetchDetail( }, [node?.id]); } +/** + * A JSON-RPC endpoint can report a failure with an HTTP 200: `/a2a`'s `jsonRpcError()` + * only maps a few codes to 4xx/5xx and defaults to `status: 200` + * (`src/app/a2a/route.ts`). `res.ok` alone would then render the success toast for a run + * that never happened, so the `/a2a` action also inspects the envelope. Only the numeric + * `error.code` is surfaced (`RPC `) — never the upstream `error.message`. + */ +async function jsonRpcErrorCode(res: { + json?: () => Promise; +}): Promise { + try { + const body = (await res.json?.()) as { error?: { code?: unknown } } | undefined; + const code = body?.error?.code; + return typeof code === "number" ? code : body?.error ? -32603 : undefined; + } catch { + // A non-JSON / already-consumed body is not evidence of failure — the status stands. + return undefined; + } +} + async function performAction( req: { url: string; init: RequestInit } | null, setActionError: (text: string) => void @@ -146,6 +279,10 @@ async function performAction( try { const res = await fetch(req.url, req.init); if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (req.url === "/a2a") { + const code = await jsonRpcErrorCode(res); + if (code !== undefined) throw new Error(`RPC ${code}`); + } return true; } catch (err) { setActionError(toSafeErrorText(err)); @@ -167,6 +304,7 @@ export function useDrawerDetail(node: OrchNode | null) { useFetchDetail(node, route, setDetail, setDetailError, setIsLoading); const { canApprove, canCancel } = deriveActionAvailability(route, node); + const repeatReq = node ? repeatReqFor(node, detail) : null; const runAction = async (req: { url: string; init: RequestInit } | null): Promise => { if (busy) return false; @@ -186,7 +324,9 @@ export function useDrawerDetail(node: OrchNode | null) { errorKind: error?.kind ?? null, canApprove, canCancel, + canRepeat: !!repeatReq && !busy, approve: () => runAction(route?.approveReq ?? null), cancel: () => runAction(route?.cancelReq ?? null), + repeat: () => runAction(repeatReq), }; } diff --git a/src/app/(dashboard)/dashboard/orchestration/tabs/HistoryTab.tsx b/src/app/(dashboard)/dashboard/orchestration/tabs/HistoryTab.tsx index bd17c46e7f..d8939cf7f6 100644 --- a/src/app/(dashboard)/dashboard/orchestration/tabs/HistoryTab.tsx +++ b/src/app/(dashboard)/dashboard/orchestration/tabs/HistoryTab.tsx @@ -191,9 +191,7 @@ function PresetButtons({ type="button" aria-pressed={preset === p} className={`px-2 py-1 text-xs rounded border ${ - preset === p - ? "border-primary bg-primary/10 font-medium" - : "border-border text-muted" + preset === p ? "border-primary bg-primary/10 font-medium" : "border-border text-muted" }`} onClick={() => onSelect(p)} > @@ -265,9 +263,7 @@ function HistoryGridTable({ className="text-left px-2 py-1 sticky left-0 bg-surface whitespace-nowrap font-normal" > {row.identity}{" "} - - {t(SOURCE_KEY[row.source])} - + {t(SOURCE_KEY[row.source])} {row.cells.map((cell, i) => ( @@ -350,10 +346,22 @@ export function HistoryTab() { )} + {/* `onActionDone` must NOT close the drawer: the drawer renders its own success toast + right after calling it, so unmounting here threw the confirmation away and the + operator saw a repeat/cancel silently do nothing. Re-sampling `nowMs` instead + keeps the drawer mounted (the toast lands) and refreshes the grid through the new + range — the same "refetch, don't close" contract `OrchestrationPageClient` uses. + `Date.now()` is sampled inside a real event-driven callback, never during render + (see the `nowMs` note above), and the updater is pure — it only picks the larger of + the sampled clock and `prev + 1`, so the range always changes (and the refetch + always happens) even when two samples land in the same millisecond. */} setSelected(null)} - onActionDone={() => setSelected(null)} + onActionDone={() => { + const sampled = Date.now(); + setNowMs((prev) => (sampled > prev ? sampled : prev + 1)); + }} /> ); diff --git a/src/app/api/conductor/tasks/route.ts b/src/app/api/conductor/tasks/route.ts new file mode 100644 index 0000000000..02168a69c8 --- /dev/null +++ b/src/app/api/conductor/tasks/route.ts @@ -0,0 +1,66 @@ +/** + * POST /api/conductor/tasks — creates a task on the Conductor hub (Orchestration Canvas + * Fase 2, "Repeat" action on the drawer). Thin creation route: validate → auth → delegate + * to `createConductorTask`. A hub refusal comes back as the hub's status with a sanitized + * body (never the raw upstream body — Hard Rule #12). + */ + +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createConductorTask } from "@/lib/conductor/hubProxy"; + +/** + * Clamps a hub status before it is used as OUR response status. `createConductorTask` + * mirrors whatever the hub answered, and `Response.json()` throws a `RangeError` for any + * status outside 200-599 — a hub (or a stubbed fetch) answering `0`/`600` would turn a + * hub refusal into a 500 from an unhandled throw. A 3xx/2xx reaching this branch is + * equally meaningless as an error status, so anything outside 400-599 becomes 502 + * (Bad Gateway — the honest description of "the upstream hub answered something we + * cannot forward"). + */ +function clampErrorStatus(status: unknown): number { + const s = Number(status); + return Number.isInteger(s) && s >= 400 && s <= 599 ? s : 502; +} + +const createTaskSchema = z.object({ + repoUrl: z.string().min(1), + prompt: z.string().min(1), + baseRef: z.string().optional(), + mode: z.string().optional(), + cli: z.string().optional(), + model: z.string().optional(), +}); + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return createErrorResponse({ status: 400, message: "Invalid JSON body" }); + } + + const parsed = createTaskSchema.safeParse(rawBody); + if (!parsed.success) { + return createErrorResponse({ + status: 400, + message: "Invalid request body", + details: parsed.error.flatten(), + }); + } + + const result = await createConductorTask(parsed.data); + if (!result.ok || !result.task_id) { + return createErrorResponse({ + status: clampErrorStatus(result.status), + message: `Conductor hub refused the task creation (HTTP ${result.status})`, + }); + } + return NextResponse.json({ task_id: result.task_id }, { status: 201 }); +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index fdcaf03dba..d33a7633bf 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "الجدول الزمني", "drawerMetrics": "المقاييس", "drawerResult": "النتيجة", + "drawerMemory": "الذاكرة المستخدمة", "drawerActions": "الإجراءات", "drawerClose": "إغلاق", "copyTrace": "نسخ أثر التتبع (JSON)", @@ -13997,7 +13998,11 @@ "actionDone": "تم تطبيق الإجراء", "actionFailed": "فشل الإجراء: {error}", "detailFailed": "فشل تحميل التفاصيل: {error}", - "mirroredInA2A": "معكوس في A2A" + "mirroredInA2A": "معكوس في A2A", + "actionRepeat": "تكرار", + "repeatConfirm": "انقر مرة أخرى للتأكيد", + "repeatDone": "تم إنشاء تشغيل جديد", + "repeatUnavailable": "المُدخل الأصلي غير متاح" }, "cliproxyProviderExposure": { "title": "تعرض المزود", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 1dd62f5f51..a50b88762f 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Zaman xətti", "drawerMetrics": "Metriklər", "drawerResult": "Nəticə", + "drawerMemory": "İstifadə olunan yaddaş", "drawerActions": "Əməliyyatlar", "drawerClose": "Bağla", "copyTrace": "İzləmə JSON-unu kopyala", @@ -13997,7 +13998,11 @@ "actionDone": "Əməliyyat tətbiq edildi", "actionFailed": "Əməliyyat uğursuz oldu: {error}", "detailFailed": "Detallar yüklənmədi: {error}", - "mirroredInA2A": "A2A-da əks olunub" + "mirroredInA2A": "A2A-da əks olunub", + "actionRepeat": "Təkrarla", + "repeatConfirm": "Təsdiqləmək üçün yenidən klikləyin", + "repeatDone": "Yeni icra yaradıldı", + "repeatUnavailable": "Orijinal giriş mövcud deyil" }, "cliproxyProviderExposure": { "title": "Təchizatçı Məlumatı", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index a686606a62..8086f96fc5 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Хронология", "drawerMetrics": "Метрики", "drawerResult": "Резултат", + "drawerMemory": "Използвана памет", "drawerActions": "Действия", "drawerClose": "Затваряне", "copyTrace": "Копиране на трасето (JSON)", @@ -13997,7 +13998,11 @@ "actionDone": "Действието е приложено", "actionFailed": "Действието се провали: {error}", "detailFailed": "Неуспешно зареждане на детайлите: {error}", - "mirroredInA2A": "Отразено в A2A" + "mirroredInA2A": "Отразено в A2A", + "actionRepeat": "Повтори", + "repeatConfirm": "Кликнете отново, за да потвърдите", + "repeatDone": "Създадено е ново изпълнение", + "repeatUnavailable": "Оригиналният вход не е наличен" }, "cliproxyProviderExposure": { "title": "Излагане на доставчика", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index e7c9c57add..507007dc7b 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "টাইমলাইন", "drawerMetrics": "মেট্রিক্স", "drawerResult": "ফলাফল", + "drawerMemory": "ব্যবহৃত মেমরি", "drawerActions": "কার্যক্রম", "drawerClose": "বন্ধ করুন", "copyTrace": "ট্রেস JSON কপি করুন", @@ -13997,7 +13998,11 @@ "actionDone": "কার্যক্রম প্রয়োগ করা হয়েছে", "actionFailed": "কার্যক্রম ব্যর্থ হয়েছে: {error}", "detailFailed": "বিবরণ লোড করতে ব্যর্থ: {error}", - "mirroredInA2A": "A2A-তে প্রতিফলিত" + "mirroredInA2A": "A2A-তে প্রতিফলিত", + "actionRepeat": "পুনরাবৃত্তি করুন", + "repeatConfirm": "নিশ্চিত করতে আবার ক্লিক করুন", + "repeatDone": "নতুন রান তৈরি হয়েছে", + "repeatUnavailable": "মূল ইনপুট উপলব্ধ নেই" }, "cliproxyProviderExposure": { "title": "প্রদানকারী এক্সপোজার", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index cde44ef0ca..f68aa4b51d 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Časová osa", "drawerMetrics": "Metriky", "drawerResult": "Výsledek", + "drawerMemory": "Použitá paměť", "drawerActions": "Akce", "drawerClose": "Zavřít", "copyTrace": "Kopírovat trasování (JSON)", @@ -13997,7 +13998,11 @@ "actionDone": "Akce provedena", "actionFailed": "Akce selhala: {error}", "detailFailed": "Nepodařilo se načíst podrobnosti: {error}", - "mirroredInA2A": "Zrcadleno v A2A" + "mirroredInA2A": "Zrcadleno v A2A", + "actionRepeat": "Opakovat", + "repeatConfirm": "Kliknutím znovu potvrdíte", + "repeatDone": "Vytvořeno nové spuštění", + "repeatUnavailable": "Původní vstup není k dispozici" }, "cliproxyProviderExposure": { "title": "Expozice Poskytovatele", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index d763f93dd6..f326e8f313 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Tidslinje", "drawerMetrics": "Målinger", "drawerResult": "Resultat", + "drawerMemory": "Anvendt hukommelse", "drawerActions": "Handlinger", "drawerClose": "Luk", "copyTrace": "Kopiér sporing (JSON)", @@ -13997,7 +13998,11 @@ "actionDone": "Handling udført", "actionFailed": "Handling mislykkedes: {error}", "detailFailed": "Kunne ikke indlæse detaljer: {error}", - "mirroredInA2A": "Spejlet i A2A" + "mirroredInA2A": "Spejlet i A2A", + "actionRepeat": "Gentag", + "repeatConfirm": "Klik igen for at bekræfte", + "repeatDone": "Ny kørsel oprettet", + "repeatUnavailable": "Oprindeligt input ikke tilgængeligt" }, "cliproxyProviderExposure": { "title": "Udbyder Eksponering", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index ae2cd0724f..4b51536213 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -13995,6 +13995,7 @@ "drawerTimeline": "Zeitachse", "drawerMetrics": "Metriken", "drawerResult": "Ergebnis", + "drawerMemory": "Genutzte Erinnerungen", "drawerActions": "Aktionen", "drawerClose": "Schließen", "copyTrace": "Trace-JSON kopieren", @@ -14004,7 +14005,11 @@ "actionDone": "Aktion angewendet", "actionFailed": "Aktion fehlgeschlagen: {error}", "detailFailed": "Details konnten nicht geladen werden: {error}", - "mirroredInA2A": "In A2A gespiegelt" + "mirroredInA2A": "In A2A gespiegelt", + "actionRepeat": "Wiederholen", + "repeatConfirm": "Zum Bestätigen erneut klicken", + "repeatDone": "Neuer Lauf erstellt", + "repeatUnavailable": "Ursprüngliche Eingabe nicht verfügbar" }, "cliproxyProviderExposure": { "title": "Anbieterexposition", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 7c9d7439d4..292d76c86d 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -13995,16 +13995,21 @@ "drawerTimeline": "Timeline", "drawerMetrics": "Metrics", "drawerResult": "Result", + "drawerMemory": "Memory used", "drawerActions": "Actions", "drawerClose": "Close", "copyTrace": "Copy trace JSON", "actionApprove": "Approve plan", "actionCancel": "Cancel", "actionSeeInGraph": "See in graph", + "actionRepeat": "Repeat", "actionDone": "Action applied", "actionFailed": "Action failed: {error}", "detailFailed": "Failed to load details: {error}", - "mirroredInA2A": "Mirrored in A2A" + "mirroredInA2A": "Mirrored in A2A", + "repeatConfirm": "Click again to confirm", + "repeatDone": "New run created", + "repeatUnavailable": "Original input not available" }, "cliproxyProviderExposure": { "title": "Provider Exposure", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 4ce646e653..b1fbcc4fe6 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Cronología", "drawerMetrics": "Métricas", "drawerResult": "Resultado", + "drawerMemory": "Memoria utilizada", "drawerActions": "Acciones", "drawerClose": "Cerrar", "copyTrace": "Copiar traza JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Acción aplicada", "actionFailed": "Error en la acción: {error}", "detailFailed": "No se pudieron cargar los detalles: {error}", - "mirroredInA2A": "Reflejado en A2A" + "mirroredInA2A": "Reflejado en A2A", + "actionRepeat": "Repetir", + "repeatConfirm": "Hacer clic de nuevo para confirmar", + "repeatDone": "Nueva ejecución creada", + "repeatUnavailable": "Entrada original no disponible" }, "cliproxyProviderExposure": { "title": "Exposición del Proveedor", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 5c5112f3cc..b57be44976 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "جدول زمانی", "drawerMetrics": "معیارها", "drawerResult": "نتیجه", + "drawerMemory": "حافظه استفاده‌شده", "drawerActions": "اقدامات", "drawerClose": "بستن", "copyTrace": "کپی ردیابی JSON", @@ -13997,7 +13998,11 @@ "actionDone": "اقدام اعمال شد", "actionFailed": "اقدام ناموفق بود: {error}", "detailFailed": "بارگذاری جزئیات ناموفق بود: {error}", - "mirroredInA2A": "بازتاب‌یافته در A2A" + "mirroredInA2A": "بازتاب‌یافته در A2A", + "actionRepeat": "تکرار", + "repeatConfirm": "برای تأیید دوباره کلیک کنید", + "repeatDone": "اجرای جدید ایجاد شد", + "repeatUnavailable": "ورودی اصلی در دسترس نیست" }, "cliproxyProviderExposure": { "title": "قرار گرفتن در معرض ارائه‌دهنده", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 9124a16634..0b220d98b7 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Aikajana", "drawerMetrics": "Mittarit", "drawerResult": "Tulos", + "drawerMemory": "Käytetty muisti", "drawerActions": "Toiminnot", "drawerClose": "Sulje", "copyTrace": "Kopioi jäljitys-JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Toiminto suoritettu", "actionFailed": "Toiminto epäonnistui: {error}", "detailFailed": "Tietojen lataus epäonnistui: {error}", - "mirroredInA2A": "Peilattu A2A:ssa" + "mirroredInA2A": "Peilattu A2A:ssa", + "actionRepeat": "Toista", + "repeatConfirm": "Vahvista napsauttamalla uudelleen", + "repeatDone": "Uusi ajo luotu", + "repeatUnavailable": "Alkuperäistä syötettä ei ole saatavilla" }, "cliproxyProviderExposure": { "title": "Palveluntarjoajan Altistus", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 34577a4dbb..59cae55660 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Chronologie", "drawerMetrics": "Métriques", "drawerResult": "Résultat", + "drawerMemory": "Mémoire utilisée", "drawerActions": "Actions", "drawerClose": "Fermer", "copyTrace": "Copier la trace JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Action appliquée", "actionFailed": "Échec de l'action : {error}", "detailFailed": "Échec du chargement des détails : {error}", - "mirroredInA2A": "Reflété dans A2A" + "mirroredInA2A": "Reflété dans A2A", + "actionRepeat": "Répéter", + "repeatConfirm": "Cliquez à nouveau pour confirmer", + "repeatDone": "Nouvelle exécution créée", + "repeatUnavailable": "Entrée d'origine non disponible" }, "cliproxyProviderExposure": { "title": "Exposition du Fournisseur", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 1f278c1f98..152237a606 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "સમયરેખા", "drawerMetrics": "મેટ્રિક્સ", "drawerResult": "પરિણામ", + "drawerMemory": "વપરાયેલ મેમરી", "drawerActions": "ક્રિયાઓ", "drawerClose": "બંધ કરો", "copyTrace": "ટ્રેસ JSON કૉપિ કરો", @@ -13997,7 +13998,11 @@ "actionDone": "ક્રિયા લાગુ કરાઈ", "actionFailed": "ક્રિયા નિષ્ફળ: {error}", "detailFailed": "વિગતો લોડ કરવામાં નિષ્ફળ: {error}", - "mirroredInA2A": "A2A માં પ્રતિબિંબિત" + "mirroredInA2A": "A2A માં પ્રતિબિંબિત", + "actionRepeat": "પુનરાવર્તન કરો", + "repeatConfirm": "પુષ્ટિ કરવા માટે ફરીથી ક્લિક કરો", + "repeatDone": "નવું રન બનાવવામાં આવ્યું", + "repeatUnavailable": "મૂળ ઇનપુટ ઉપલબ્ધ નથી" }, "cliproxyProviderExposure": { "title": "પ્રદાતા એક્સપોઝર", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index e720fe27be..7f45b461c0 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "ציר זמן", "drawerMetrics": "מדדים", "drawerResult": "תוצאה", + "drawerMemory": "זיכרון בשימוש", "drawerActions": "פעולות", "drawerClose": "סגירה", "copyTrace": "העתקת מעקב JSON", @@ -13997,7 +13998,11 @@ "actionDone": "הפעולה בוצעה", "actionFailed": "הפעולה נכשלה: {error}", "detailFailed": "טעינת הפרטים נכשלה: {error}", - "mirroredInA2A": "משוקף ב-A2A" + "mirroredInA2A": "משוקף ב-A2A", + "actionRepeat": "הרץ שוב", + "repeatConfirm": "לחץ שוב כדי לאשר", + "repeatDone": "נוצרה הרצה חדשה", + "repeatUnavailable": "הקלט המקורי אינו זמין" }, "cliproxyProviderExposure": { "title": "חשיפת ספק", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 1bd1536902..3867eeb43a 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "समयरेखा", "drawerMetrics": "मेट्रिक्स", "drawerResult": "परिणाम", + "drawerMemory": "उपयोग की गई मेमोरी", "drawerActions": "कार्रवाइयां", "drawerClose": "बंद करें", "copyTrace": "ट्रेस JSON कॉपी करें", @@ -13997,7 +13998,11 @@ "actionDone": "कार्रवाई लागू की गई", "actionFailed": "कार्रवाई विफल: {error}", "detailFailed": "विवरण लोड करने में विफल: {error}", - "mirroredInA2A": "A2A में प्रतिबिंबित" + "mirroredInA2A": "A2A में प्रतिबिंबित", + "actionRepeat": "दोहराएं", + "repeatConfirm": "पुष्टि के लिए फिर से क्लिक करें", + "repeatDone": "नया रन बनाया गया", + "repeatUnavailable": "मूल इनपुट उपलब्ध नहीं है" }, "cliproxyProviderExposure": { "title": "प्रदाता एक्सपोजर", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 66459aa3af..5334482837 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Idővonal", "drawerMetrics": "Metrikák", "drawerResult": "Eredmény", + "drawerMemory": "Felhasznált memória", "drawerActions": "Műveletek", "drawerClose": "Bezárás", "copyTrace": "Nyomkövetési JSON másolása", @@ -13997,7 +13998,11 @@ "actionDone": "Művelet végrehajtva", "actionFailed": "A művelet sikertelen: {error}", "detailFailed": "A részletek betöltése sikertelen: {error}", - "mirroredInA2A": "Tükrözve az A2A-ban" + "mirroredInA2A": "Tükrözve az A2A-ban", + "actionRepeat": "Ismétlés", + "repeatConfirm": "Kattintson újra a megerősítéshez", + "repeatDone": "Új futás létrehozva", + "repeatUnavailable": "Az eredeti bemenet nem érhető el" }, "cliproxyProviderExposure": { "title": "Szolgáltató Expozíció", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 3369790cfd..545e9a5a41 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Linimasa", "drawerMetrics": "Metrik", "drawerResult": "Hasil", + "drawerMemory": "Memori yang digunakan", "drawerActions": "Tindakan", "drawerClose": "Tutup", "copyTrace": "Salin trace JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Tindakan diterapkan", "actionFailed": "Tindakan gagal: {error}", "detailFailed": "Gagal memuat detail: {error}", - "mirroredInA2A": "Dicerminkan di A2A" + "mirroredInA2A": "Dicerminkan di A2A", + "actionRepeat": "Ulangi", + "repeatConfirm": "Klik lagi untuk konfirmasi", + "repeatDone": "Proses baru dibuat", + "repeatUnavailable": "Input asli tidak tersedia" }, "cliproxyProviderExposure": { "title": "Paparan Penyedia", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 4121406bc5..dc01def708 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Cronologia", "drawerMetrics": "Metriche", "drawerResult": "Risultato", + "drawerMemory": "Memoria utilizzata", "drawerActions": "Azioni", "drawerClose": "Chiudi", "copyTrace": "Copia trace JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Azione applicata", "actionFailed": "Azione non riuscita: {error}", "detailFailed": "Impossibile caricare i dettagli: {error}", - "mirroredInA2A": "Rispecchiato in A2A" + "mirroredInA2A": "Rispecchiato in A2A", + "actionRepeat": "Ripeti", + "repeatConfirm": "Clicca di nuovo per confermare", + "repeatDone": "Nuova esecuzione creata", + "repeatUnavailable": "Input originale non disponibile" }, "cliproxyProviderExposure": { "title": "Esposizione del Fornitore", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 9dad5adbaf..ba8c064462 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "タイムライン", "drawerMetrics": "メトリクス", "drawerResult": "結果", + "drawerMemory": "使用したメモリ", "drawerActions": "アクション", "drawerClose": "閉じる", "copyTrace": "トレースJSONをコピー", @@ -13997,7 +13998,11 @@ "actionDone": "アクションを適用しました", "actionFailed": "アクションが失敗しました: {error}", "detailFailed": "詳細の読み込みに失敗しました: {error}", - "mirroredInA2A": "A2Aにミラーリング" + "mirroredInA2A": "A2Aにミラーリング", + "actionRepeat": "再実行", + "repeatConfirm": "確認するにはもう一度クリック", + "repeatDone": "新しい実行を作成しました", + "repeatUnavailable": "元の入力を利用できません" }, "cliproxyProviderExposure": { "title": "プロバイダーの露出", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 2791c958cd..ab4b4a67f7 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "타임라인", "drawerMetrics": "지표", "drawerResult": "결과", + "drawerMemory": "사용된 메모리", "drawerActions": "작업", "drawerClose": "닫기", "copyTrace": "추적 JSON 복사", @@ -13997,7 +13998,11 @@ "actionDone": "작업이 적용됨", "actionFailed": "작업 실패: {error}", "detailFailed": "세부정보를 불러오지 못했습니다: {error}", - "mirroredInA2A": "A2A에 미러링됨" + "mirroredInA2A": "A2A에 미러링됨", + "actionRepeat": "반복", + "repeatConfirm": "확인하려면 다시 클릭하세요", + "repeatDone": "새 실행이 생성되었습니다", + "repeatUnavailable": "원본 입력을 사용할 수 없습니다" }, "cliproxyProviderExposure": { "title": "제공자 노출", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 48cd0e4010..53af82d57a 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "टाइमलाइन", "drawerMetrics": "मेट्रिक्स", "drawerResult": "निकाल", + "drawerMemory": "वापरलेली मेमरी", "drawerActions": "क्रिया", "drawerClose": "बंद करा", "copyTrace": "ट्रेस JSON कॉपी करा", @@ -13997,7 +13998,11 @@ "actionDone": "क्रिया लागू केली", "actionFailed": "क्रिया अयशस्वी: {error}", "detailFailed": "तपशील लोड करण्यात अयशस्वी: {error}", - "mirroredInA2A": "A2A मध्ये प्रतिबिंबित" + "mirroredInA2A": "A2A मध्ये प्रतिबिंबित", + "actionRepeat": "पुनरावृत्ती करा", + "repeatConfirm": "पुष्टीसाठी पुन्हा क्लिक करा", + "repeatDone": "नवीन रन तयार केला", + "repeatUnavailable": "मूळ इनपुट उपलब्ध नाही" }, "cliproxyProviderExposure": { "title": "प्रदाता प्रदर्शन", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 2bfa46ba2d..05b504ffde 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Garis Masa", "drawerMetrics": "Metrik", "drawerResult": "Keputusan", + "drawerMemory": "Memori digunakan", "drawerActions": "Tindakan", "drawerClose": "Tutup", "copyTrace": "Salin JSON jejak", @@ -13997,7 +13998,11 @@ "actionDone": "Tindakan digunakan", "actionFailed": "Tindakan gagal: {error}", "detailFailed": "Gagal memuatkan butiran: {error}", - "mirroredInA2A": "Dicerminkan dalam A2A" + "mirroredInA2A": "Dicerminkan dalam A2A", + "actionRepeat": "Ulang", + "repeatConfirm": "Klik sekali lagi untuk sahkan", + "repeatDone": "Larian baharu dicipta", + "repeatUnavailable": "Input asal tidak tersedia" }, "cliproxyProviderExposure": { "title": "Pendedahan Penyedia", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 3cbf5e726f..c50a7beebc 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Tijdlijn", "drawerMetrics": "Statistieken", "drawerResult": "Resultaat", + "drawerMemory": "Gebruikt geheugen", "drawerActions": "Acties", "drawerClose": "Sluiten", "copyTrace": "Trace-JSON kopiëren", @@ -13997,7 +13998,11 @@ "actionDone": "Actie toegepast", "actionFailed": "Actie mislukt: {error}", "detailFailed": "Details laden mislukt: {error}", - "mirroredInA2A": "Weergegeven in A2A" + "mirroredInA2A": "Weergegeven in A2A", + "actionRepeat": "Herhalen", + "repeatConfirm": "Klik nogmaals om te bevestigen", + "repeatDone": "Nieuwe run aangemaakt", + "repeatUnavailable": "Oorspronkelijke invoer niet beschikbaar" }, "cliproxyProviderExposure": { "title": "Provider Blootstelling", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 786339d422..5f7a1c64b0 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Tidslinje", "drawerMetrics": "Målinger", "drawerResult": "Resultat", + "drawerMemory": "Brukt minne", "drawerActions": "Handlinger", "drawerClose": "Lukk", "copyTrace": "Kopiér spor (JSON)", @@ -13997,7 +13998,11 @@ "actionDone": "Handling utført", "actionFailed": "Handling mislyktes: {error}", "detailFailed": "Kunne ikke laste inn detaljer: {error}", - "mirroredInA2A": "Speilet i A2A" + "mirroredInA2A": "Speilet i A2A", + "actionRepeat": "Gjenta", + "repeatConfirm": "Klikk igjen for å bekrefte", + "repeatDone": "Ny kjøring opprettet", + "repeatUnavailable": "Opprinnelig inndata ikke tilgjengelig" }, "cliproxyProviderExposure": { "title": "Leverandør Eksponering", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index dcb12cb233..3a3080eded 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Timeline", "drawerMetrics": "Mga Sukatan", "drawerResult": "Resulta", + "drawerMemory": "Ginamit na memory", "drawerActions": "Mga Aksyon", "drawerClose": "Isara", "copyTrace": "Kopyahin ang trace JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Naisagawa ang aksyon", "actionFailed": "Nabigo ang aksyon: {error}", "detailFailed": "Nabigong i-load ang mga detalye: {error}", - "mirroredInA2A": "Naka-mirror sa A2A" + "mirroredInA2A": "Naka-mirror sa A2A", + "actionRepeat": "Ulitin", + "repeatConfirm": "I-click ulit para kumpirmahin", + "repeatDone": "Ginawa ang bagong run", + "repeatUnavailable": "Hindi available ang orihinal na input" }, "cliproxyProviderExposure": { "title": "Provider Exposure", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 4e23cacf45..bd07b8321e 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Oś czasu", "drawerMetrics": "Metryki", "drawerResult": "Wynik", + "drawerMemory": "Wykorzystana pamięć", "drawerActions": "Akcje", "drawerClose": "Zamknij", "copyTrace": "Kopiuj JSON śladu", @@ -13997,7 +13998,11 @@ "actionDone": "Akcja wykonana", "actionFailed": "Akcja nie powiodła się: {error}", "detailFailed": "Nie udało się wczytać szczegółów: {error}", - "mirroredInA2A": "Odzwierciedlone w A2A" + "mirroredInA2A": "Odzwierciedlone w A2A", + "actionRepeat": "Powtórz", + "repeatConfirm": "Kliknij ponownie, aby potwierdzić", + "repeatDone": "Utworzono nowe uruchomienie", + "repeatUnavailable": "Oryginalne dane wejściowe niedostępne" }, "cliproxyProviderExposure": { "title": "Ekspozycja Dostawcy", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 4ce0321975..7c4d1eb12c 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -13996,6 +13996,7 @@ "drawerTimeline": "Linha do tempo", "drawerMetrics": "Métricas", "drawerResult": "Resultado", + "drawerMemory": "Memória usada", "drawerActions": "Ações", "drawerClose": "Fechar", "copyTrace": "Copiar trace em JSON", @@ -14005,7 +14006,11 @@ "actionDone": "Ação aplicada", "actionFailed": "Ação falhou: {error}", "detailFailed": "Falha ao carregar detalhes: {error}", - "mirroredInA2A": "Espelhado no A2A" + "mirroredInA2A": "Espelhado no A2A", + "actionRepeat": "Repetir", + "repeatConfirm": "Clique novamente para confirmar", + "repeatDone": "Nova execução criada", + "repeatUnavailable": "Entrada original não disponível" }, "cliproxyProviderExposure": { "title": "Exposição do Provedor", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index dfc77dc34a..3533f6370a 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Linha Cronológica", "drawerMetrics": "Métricas", "drawerResult": "Resultado", + "drawerMemory": "Memória utilizada", "drawerActions": "Ações", "drawerClose": "Fechar", "copyTrace": "Copiar trace em JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Ação aplicada", "actionFailed": "Falha na ação: {error}", "detailFailed": "Falha ao carregar detalhes: {error}", - "mirroredInA2A": "Espelhado no A2A" + "mirroredInA2A": "Espelhado no A2A", + "actionRepeat": "Repetir", + "repeatConfirm": "Clique novamente para confirmar", + "repeatDone": "Nova execução criada", + "repeatUnavailable": "Entrada original não disponível" }, "cliproxyProviderExposure": { "title": "Exposição do Fornecedor", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 657c1c4f49..afe466f408 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Cronologie", "drawerMetrics": "Metrici", "drawerResult": "Rezultat", + "drawerMemory": "Memorie utilizată", "drawerActions": "Acțiuni", "drawerClose": "Închide", "copyTrace": "Copiază JSON-ul de urmărire", @@ -13997,7 +13998,11 @@ "actionDone": "Acțiune aplicată", "actionFailed": "Acțiune eșuată: {error}", "detailFailed": "Încărcarea detaliilor a eșuat: {error}", - "mirroredInA2A": "Reflectat în A2A" + "mirroredInA2A": "Reflectat în A2A", + "actionRepeat": "Repetă", + "repeatConfirm": "Apasă din nou pentru a confirma", + "repeatDone": "Rulare nouă creată", + "repeatUnavailable": "Intrarea originală nu este disponibilă" }, "cliproxyProviderExposure": { "title": "Expunerea Furnizorului", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 5249870f77..dbd845f383 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Хронология", "drawerMetrics": "Метрики", "drawerResult": "Результат", + "drawerMemory": "Использованная память", "drawerActions": "Действия", "drawerClose": "Закрыть", "copyTrace": "Скопировать трассировку JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Действие применено", "actionFailed": "Действие не удалось: {error}", "detailFailed": "Не удалось загрузить детали: {error}", - "mirroredInA2A": "Отражено в A2A" + "mirroredInA2A": "Отражено в A2A", + "actionRepeat": "Повторить", + "repeatConfirm": "Нажмите ещё раз для подтверждения", + "repeatDone": "Создан новый запуск", + "repeatUnavailable": "Исходные данные недоступны" }, "cliproxyProviderExposure": { "title": "Экспозиция Провайдера", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index bdca1f08d8..40f2003b77 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Časová os", "drawerMetrics": "Metriky", "drawerResult": "Výsledok", + "drawerMemory": "Použitá pamäť", "drawerActions": "Akcie", "drawerClose": "Zavrieť", "copyTrace": "Kopírovať trasovanie JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Akcia použitá", "actionFailed": "Akcia zlyhala: {error}", "detailFailed": "Nepodarilo sa načítať podrobnosti: {error}", - "mirroredInA2A": "Zrkadlené v A2A" + "mirroredInA2A": "Zrkadlené v A2A", + "actionRepeat": "Opakovať", + "repeatConfirm": "Kliknutím znova potvrďte", + "repeatDone": "Vytvorené nové spustenie", + "repeatUnavailable": "Pôvodný vstup nie je k dispozícii" }, "cliproxyProviderExposure": { "title": "Expozícia poskytovateľa", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 99a0669db9..175c8e1f97 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Tidslinje", "drawerMetrics": "Mätvärden", "drawerResult": "Resultat", + "drawerMemory": "Använt minne", "drawerActions": "Åtgärder", "drawerClose": "Stäng", "copyTrace": "Kopiera spårnings-JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Åtgärd tillämpad", "actionFailed": "Åtgärden misslyckades: {error}", "detailFailed": "Det gick inte att läsa in detaljer: {error}", - "mirroredInA2A": "Speglad i A2A" + "mirroredInA2A": "Speglad i A2A", + "actionRepeat": "Upprepa", + "repeatConfirm": "Klicka igen för att bekräfta", + "repeatDone": "Ny körning skapad", + "repeatUnavailable": "Ursprunglig indata ej tillgänglig" }, "cliproxyProviderExposure": { "title": "Leverantörsexponering", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index be5407c630..b8288d3863 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Ratiba ya matukio", "drawerMetrics": "Vipimo", "drawerResult": "Matokeo", + "drawerMemory": "Kumbukumbu iliyotumika", "drawerActions": "Vitendo", "drawerClose": "Funga", "copyTrace": "Nakili JSON ya ufuatiliaji", @@ -13997,7 +13998,11 @@ "actionDone": "Kitendo kimetumika", "actionFailed": "Kitendo kimeshindwa: {error}", "detailFailed": "Imeshindwa kupakia maelezo: {error}", - "mirroredInA2A": "Kimeakisiwa katika A2A" + "mirroredInA2A": "Kimeakisiwa katika A2A", + "actionRepeat": "Rudia", + "repeatConfirm": "Bofya tena kuthibitisha", + "repeatDone": "Uendeshaji mpya umeundwa", + "repeatUnavailable": "Ingizo asili halipatikani" }, "cliproxyProviderExposure": { "title": "Ufunuo wa Mtoa Huduma", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index b492d8a373..e0614361fc 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "காலவரிசை", "drawerMetrics": "அளவீடுகள்", "drawerResult": "முடிவு", + "drawerMemory": "பயன்படுத்தப்பட்ட நினைவகம்", "drawerActions": "செயல்கள்", "drawerClose": "மூடு", "copyTrace": "ட்ரேஸ் JSON-ஐ நகலெடு", @@ -13997,7 +13998,11 @@ "actionDone": "செயல் பயன்படுத்தப்பட்டது", "actionFailed": "செயல் தோல்வியடைந்தது: {error}", "detailFailed": "விவரங்களை ஏற்ற முடியவில்லை: {error}", - "mirroredInA2A": "A2A இல் பிரதிபலிக்கப்பட்டது" + "mirroredInA2A": "A2A இல் பிரதிபலிக்கப்பட்டது", + "actionRepeat": "மீண்டும் செய்", + "repeatConfirm": "உறுதிப்படுத்த மீண்டும் கிளிக் செய்யவும்", + "repeatDone": "புதிய இயக்கம் உருவாக்கப்பட்டது", + "repeatUnavailable": "அசல் உள்ளீடு கிடைக்கவில்லை" }, "cliproxyProviderExposure": { "title": "சேவையாளர் வெளிப்பாடு", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index fa123c3386..6dfa5290b7 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "కాలక్రమం", "drawerMetrics": "మెట్రిక్స్", "drawerResult": "ఫలితం", + "drawerMemory": "ఉపయోగించిన మెమరీ", "drawerActions": "చర్యలు", "drawerClose": "మూసివేయి", "copyTrace": "ట్రేస్ JSON కాపీ చేయి", @@ -13997,7 +13998,11 @@ "actionDone": "చర్య వర్తింపజేయబడింది", "actionFailed": "చర్య విఫలమైంది: {error}", "detailFailed": "వివరాలను లోడ్ చేయడంలో విఫలమైంది: {error}", - "mirroredInA2A": "A2Aలో ప్రతిబింబించబడింది" + "mirroredInA2A": "A2Aలో ప్రతిబింబించబడింది", + "actionRepeat": "పునరావృతం చేయండి", + "repeatConfirm": "నిర్ధారించడానికి మళ్లీ క్లిక్ చేయండి", + "repeatDone": "కొత్త రన్ సృష్టించబడింది", + "repeatUnavailable": "అసలు ఇన్‌పుట్ అందుబాటులో లేదు" }, "cliproxyProviderExposure": { "title": "ప్రొవైడర్ ఎక్స్‌పోజర్", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 6b0e800747..126df99e1b 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "ไทม์ไลน์", "drawerMetrics": "เมตริก", "drawerResult": "ผลลัพธ์", + "drawerMemory": "หน่วยความจำที่ใช้", "drawerActions": "การดำเนินการ", "drawerClose": "ปิด", "copyTrace": "คัดลอก JSON การติดตาม", @@ -13997,7 +13998,11 @@ "actionDone": "ดำเนินการเรียบร้อยแล้ว", "actionFailed": "การดำเนินการล้มเหลว: {error}", "detailFailed": "โหลดรายละเอียดไม่สำเร็จ: {error}", - "mirroredInA2A": "สะท้อนใน A2A" + "mirroredInA2A": "สะท้อนใน A2A", + "actionRepeat": "ทำซ้ำ", + "repeatConfirm": "คลิกอีกครั้งเพื่อยืนยัน", + "repeatDone": "สร้างการรันใหม่แล้ว", + "repeatUnavailable": "ไม่มีข้อมูลนำเข้าต้นฉบับ" }, "cliproxyProviderExposure": { "title": "การเปิดเผยผู้ให้บริการ", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 84cc51f338..5ee309e90d 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Zaman Çizelgesi", "drawerMetrics": "Metrikler", "drawerResult": "Sonuç", + "drawerMemory": "Kullanılan bellek", "drawerActions": "Eylemler", "drawerClose": "Kapat", "copyTrace": "İzleme JSON'ını kopyala", @@ -13997,7 +13998,11 @@ "actionDone": "İşlem uygulandı", "actionFailed": "İşlem başarısız: {error}", "detailFailed": "Ayrıntılar yüklenemedi: {error}", - "mirroredInA2A": "A2A'da yansıtıldı" + "mirroredInA2A": "A2A'da yansıtıldı", + "actionRepeat": "Tekrarla", + "repeatConfirm": "Onaylamak için tekrar tıklayın", + "repeatDone": "Yeni çalıştırma oluşturuldu", + "repeatUnavailable": "Orijinal girdi kullanılamıyor" }, "cliproxyProviderExposure": { "title": "Sağlayıcı Maruziyeti", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 566d659277..e1203b1d1b 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "Хронологія", "drawerMetrics": "Метрики", "drawerResult": "Результат", + "drawerMemory": "Використана пам'ять", "drawerActions": "Дії", "drawerClose": "Закрити", "copyTrace": "Копіювати трасування JSON", @@ -13997,7 +13998,11 @@ "actionDone": "Дію виконано", "actionFailed": "Дія не виконана: {error}", "detailFailed": "Не вдалося завантажити деталі: {error}", - "mirroredInA2A": "Відображено в A2A" + "mirroredInA2A": "Відображено в A2A", + "actionRepeat": "Повторити", + "repeatConfirm": "Натисніть ще раз, щоб підтвердити", + "repeatDone": "Створено новий запуск", + "repeatUnavailable": "Початкові вхідні дані недоступні" }, "cliproxyProviderExposure": { "title": "Виток Постачальника", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 4ff890871d..fdc5391976 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "ٹائم لائن", "drawerMetrics": "میٹرکس", "drawerResult": "نتیجہ", + "drawerMemory": "استعمال شدہ میموری", "drawerActions": "اقدامات", "drawerClose": "بند کریں", "copyTrace": "ٹریس JSON کاپی کریں", @@ -13997,7 +13998,11 @@ "actionDone": "اقدام لاگو ہو گیا", "actionFailed": "اقدام ناکام: {error}", "detailFailed": "تفصیلات لوڈ کرنے میں ناکامی: {error}", - "mirroredInA2A": "A2A میں مطابقت شدہ" + "mirroredInA2A": "A2A میں مطابقت شدہ", + "actionRepeat": "دہرائیں", + "repeatConfirm": "تصدیق کے لیے دوبارہ کلک کریں", + "repeatDone": "نئی رن بنائی گئی", + "repeatUnavailable": "اصل ان پٹ دستیاب نہیں ہے" }, "cliproxyProviderExposure": { "title": "پرووائیڈر ایکسپوژر", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 1200687d7f..61287b0446 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -13996,6 +13996,7 @@ "drawerTimeline": "Dòng thời gian", "drawerMetrics": "Chỉ số", "drawerResult": "Kết quả", + "drawerMemory": "Bộ nhớ đã sử dụng", "drawerActions": "Hành động", "drawerClose": "Đóng", "copyTrace": "Sao chép JSON theo dõi", @@ -14005,7 +14006,11 @@ "actionDone": "Đã áp dụng hành động", "actionFailed": "Hành động thất bại: {error}", "detailFailed": "Không tải được chi tiết: {error}", - "mirroredInA2A": "Được phản chiếu trong A2A" + "mirroredInA2A": "Được phản chiếu trong A2A", + "actionRepeat": "Lặp lại", + "repeatConfirm": "Nhấp lại để xác nhận", + "repeatDone": "Đã tạo lượt chạy mới", + "repeatUnavailable": "Không có dữ liệu đầu vào gốc" }, "cliproxyProviderExposure": { "title": "Tiếp Xúc Nhà Cung Cấp", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 7d42b0a86e..4306b98ac2 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "时间线", "drawerMetrics": "指标", "drawerResult": "结果", + "drawerMemory": "已使用的记忆", "drawerActions": "操作", "drawerClose": "关闭", "copyTrace": "复制追踪 JSON", @@ -13997,7 +13998,11 @@ "actionDone": "操作已应用", "actionFailed": "操作失败:{error}", "detailFailed": "加载详情失败:{error}", - "mirroredInA2A": "已在 A2A 中镜像" + "mirroredInA2A": "已在 A2A 中镜像", + "actionRepeat": "重复", + "repeatConfirm": "再次点击以确认", + "repeatDone": "已创建新的运行", + "repeatUnavailable": "原始输入不可用" }, "cliproxyProviderExposure": { "title": "提供者暴露", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index be9aac3293..1a4db6102b 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -13988,6 +13988,7 @@ "drawerTimeline": "時間軸", "drawerMetrics": "指標", "drawerResult": "結果", + "drawerMemory": "已使用的記憶", "drawerActions": "動作", "drawerClose": "關閉", "copyTrace": "複製追蹤 JSON", @@ -13997,7 +13998,11 @@ "actionDone": "動作已套用", "actionFailed": "動作失敗:{error}", "detailFailed": "載入詳細資料失敗:{error}", - "mirroredInA2A": "已在 A2A 中鏡射" + "mirroredInA2A": "已在 A2A 中鏡射", + "actionRepeat": "重複", + "repeatConfirm": "再次點擊以確認", + "repeatDone": "已建立新的執行", + "repeatUnavailable": "原始輸入無法使用" }, "cliproxyProviderExposure": { "title": "提供者暴露", diff --git a/src/lib/a2a/taskExecution.ts b/src/lib/a2a/taskExecution.ts index 421a36bb95..68c8ff0937 100644 --- a/src/lib/a2a/taskExecution.ts +++ b/src/lib/a2a/taskExecution.ts @@ -1,4 +1,6 @@ import type { A2ATask, TaskArtifact } from "./taskManager"; +import { appendA2ATaskEvent } from "@/lib/db/a2aTasks"; +import { memoryManager } from "@/lib/memory/manager"; type TaskManagerLike = { updateTask: ( @@ -14,6 +16,90 @@ type StreamTaskResult = { metadata: Record; }; +/** + * Task D2 (Orchestration Canvas Fase 2, PR-C): a memory hit recorded for OBSERVABILITY ONLY. + * The retrieved memory is never injected into a skill's prompt or behavior — it is only + * mirrored into `task.metadata.memoryHits` and a `memory_hits` history event so the dashboard + * can show which memories were consulted for a given A2A task. + * + * Note on drift from the original spec: `Memory` (`src/lib/memory/types.ts`) does not expose a + * `score` field, so hits carry `key`/`type` instead of a relevance score. + */ +export interface MemoryHit { + id: string; + key: string; + type: string; + /** `content` truncated to 200 chars — never the full memory body. */ + snippet: string; +} + +/** DI seam for `collectMemoryHits` — tests need neither a real memory backend nor a database. */ +export interface MemoryHitsDeps { + search?: (cfg: { + query: string; + apiKeyId: string; + limit?: number; + }) => Promise>; + appendEvent?: (taskId: string, eventType: string, dataJson?: string) => void; +} + +/** + * Collect the memories consulted for a task's last user message, as pure observability. + * + * - Kill-switch: `OMNIROUTE_A2A_MEMORY_HITS=0` returns `[]` without querying anything. + * - Query = the content of the LAST message with `role === "user"`; empty/absent ⇒ `[]`. + * - Owner id = `task.owner ?? "mcp"` — the same keyless fallback the MCP memory tools use + * (`open-sse/mcp-server/tools/memoryTools.ts::resolveMemoryOwnerId`). + * - Any failure in the recall path ⇒ `[]` — this must never fail the caller's task. + * + * KNOWN LIMITATION — recall only resolves under the KEYLESS posture. `task.owner` is a + * SHA-256 PREFIX of the raw API key (`src/lib/a2a/authenticate.ts::resolveA2AOwner`), while + * memory rows are keyed by the DB api-key **id** (`String(apiKeyInfo.id)`, the value + * `getApiKeyMetadata()` returns — see `open-sse/mcp-server/mcpCallerIdentity.ts`). The two + * live in different namespaces, so for a keyed caller the search below matches nothing and + * the hits list is always empty; only the keyless case (`owner === undefined` → `"mcp"`) + * lines up with the MCP-tool owner id. Bridging them needs a hash→api-key-id lookup that + * does NOT exist today: `src/lib/db/apiKeys.ts` only ever looks a key up by its RAW value + * (`WHERE key = ? OR key_hash = ?`, with the FULL sha256 hex), and the raw key is long gone + * by the time a task executes. Deliberately NOT worked around here — inventing a + * prefix-scan lookup over `api_keys` would be a new auth-adjacent surface. Follow-up: + * either persist the DB api-key id on the task alongside the hash, or add an explicit + * `getApiKeyIdByKeyHashPrefix()` in the db layer. + */ +export async function collectMemoryHits( + task: A2ATask, + deps?: MemoryHitsDeps +): Promise { + if (process.env.OMNIROUTE_A2A_MEMORY_HITS === "0") return []; + + const messages = task.input?.messages ?? []; + let query: string | undefined; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") { + query = messages[i].content; + break; + } + } + if (!query || query.trim() === "") return []; + + try { + const search = + deps?.search ?? + (async (cfg: { query: string; apiKeyId: string; limit?: number }) => + memoryManager.getPrimaryBackend().search(cfg)); + const apiKeyId = task.owner ?? "mcp"; + const results = await search({ query, apiKeyId, limit: 5 }); + return results.map((m) => ({ + id: m.id, + key: m.key, + type: m.type, + snippet: m.content.slice(0, 200), + })); + } catch { + return []; + } +} + export type A2ASkillHandler = (task: A2ATask) => Promise; export const A2A_SKILL_HANDLERS: Record = { @@ -46,9 +132,20 @@ export const A2A_SKILL_HANDLERS: Record = { export async function executeA2ATaskWithState( tm: TaskManagerLike, task: A2ATask, - handler: (task: A2ATask) => Promise + handler: (task: A2ATask) => Promise, + deps?: MemoryHitsDeps ) { try { + const hits = await collectMemoryHits(task, deps); + if (hits.length) { + task.metadata.memoryHits = hits; + try { + (deps?.appendEvent ?? appendA2ATaskEvent)(task.id, "memory_hits", JSON.stringify(hits)); + } catch { + // best-effort — never break the task's write path + } + } + const result = await handler(task); tm.updateTask(task.id, "completed", result.artifacts); return result; diff --git a/src/lib/a2a/taskManager.ts b/src/lib/a2a/taskManager.ts index 7b6d18b254..0c9f280176 100644 --- a/src/lib/a2a/taskManager.ts +++ b/src/lib/a2a/taskManager.ts @@ -14,11 +14,7 @@ import { randomUUID } from "crypto"; import { emit } from "@/lib/events/eventBus"; -import { - upsertA2ATask, - appendA2ATaskEvent, - purgeA2AHistory, -} from "@/lib/db/a2aTasks"; +import { upsertA2ATask, appendA2ATaskEvent, purgeA2AHistory } from "@/lib/db/a2aTasks"; import { logger } from "@omniroute/open-sse/utils/logger"; const log = logger("A2A_TASKS"); @@ -204,7 +200,14 @@ export class A2ATaskManager { input, artifacts: [], events: [{ timestamp: now.toISOString(), state: "submitted" }], - metadata: input.metadata || {}, + // COPY, never the caller's object: `metadata` is the task's own mutable + // runtime bag (`taskExecution.ts` writes `memoryHits` into it), while + // `input.metadata` is the immutable record of what the caller sent. Sharing + // one reference made every runtime write leak back into `input` — and from + // there into the persisted `a2a_tasks.input_json` and into the drawer's + // "Repeat" body, so a repeated task was born carrying the previous run's + // memory snippets even with `OMNIROUTE_A2A_MEMORY_HITS=0`. + metadata: { ...(input.metadata ?? {}) }, createdAt: now.toISOString(), updatedAt: now.toISOString(), expiresAt: new Date(now.getTime() + this.ttlMs).toISOString(), diff --git a/tests/unit/a2a-memory-hits.test.ts b/tests/unit/a2a-memory-hits.test.ts new file mode 100644 index 0000000000..9d6a42486f --- /dev/null +++ b/tests/unit/a2a-memory-hits.test.ts @@ -0,0 +1,331 @@ +/** + * Task D2 (Orchestration Canvas Fase 2, PR-C): `collectMemoryHits` records WHICH memories were + * consulted for an A2A task, as pure observability — the hits are never injected into the + * skill's prompt or behavior, only mirrored into `task.metadata.memoryHits` and a + * `memory_hits` history event. + * + * Uses FAKE `MemoryHitsDeps` throughout (no real memory backend, no SQLite) — the DI seam + * exists precisely so this suite needs neither. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + collectMemoryHits, + executeA2ATaskWithState, + type MemoryHit, + type MemoryHitsDeps, +} from "../../src/lib/a2a/taskExecution.ts"; +import { + A2ATaskManager, + type A2APersistence, + type A2ATask, +} from "../../src/lib/a2a/taskManager.ts"; + +function makeTask(overrides: Partial = {}): A2ATask { + return { + id: "task-1", + skill: "smart-routing", + state: "working", + input: { + skill: "smart-routing", + messages: [{ role: "user", content: "what is the cheapest gpt-4 provider?" }], + }, + artifacts: [], + events: [], + metadata: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ...overrides, + }; +} + +const ENV_KEY = "OMNIROUTE_A2A_MEMORY_HITS"; + +function withEnv(value: string | undefined, fn: () => Promise) { + const original = process.env[ENV_KEY]; + if (value === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = value; + return fn().finally(() => { + if (original === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = original; + }); +} + +test("collectMemoryHits maps search results and truncates snippet to 200 chars", async () => { + const longContent = "x".repeat(250); + const searchCalls: Array<{ query: string; apiKeyId: string; limit?: number }> = []; + const deps: MemoryHitsDeps = { + search: async (cfg) => { + searchCalls.push(cfg); + return [ + { id: "m1", key: "k1", type: "factual", content: longContent }, + { id: "m2", key: "k2", type: "episodic", content: "short" }, + ]; + }, + }; + + const task = makeTask(); + const hits = await collectMemoryHits(task, deps); + + assert.equal(searchCalls.length, 1); + assert.equal(searchCalls[0].query, "what is the cheapest gpt-4 provider?"); + assert.equal(searchCalls[0].apiKeyId, "mcp"); + + assert.deepEqual(hits, [ + { id: "m1", key: "k1", type: "factual", snippet: longContent.slice(0, 200) }, + { id: "m2", key: "k2", type: "episodic", snippet: "short" }, + ] satisfies MemoryHit[]); + assert.equal(hits[0].snippet.length, 200); +}); + +test("collectMemoryHits uses task.owner as apiKeyId when present", async () => { + let seenApiKeyId: string | undefined; + const deps: MemoryHitsDeps = { + search: async (cfg) => { + seenApiKeyId = cfg.apiKeyId; + return []; + }, + }; + + const task = makeTask({ owner: "owner-123" }); + await collectMemoryHits(task, deps); + + assert.equal(seenApiKeyId, "owner-123"); +}); + +test("collectMemoryHits uses the LAST user message as the query", async () => { + let seenQuery: string | undefined; + const deps: MemoryHitsDeps = { + search: async (cfg) => { + seenQuery = cfg.query; + return []; + }, + }; + + const task = makeTask({ + input: { + skill: "smart-routing", + messages: [ + { role: "user", content: "first question" }, + { role: "assistant", content: "an answer" }, + { role: "user", content: "second question" }, + ], + }, + }); + await collectMemoryHits(task, deps); + + assert.equal(seenQuery, "second question"); +}); + +test("collectMemoryHits returns [] and never calls search when there is no user message", async () => { + let called = false; + const deps: MemoryHitsDeps = { + search: async () => { + called = true; + return []; + }, + }; + + const task = makeTask({ + input: { skill: "smart-routing", messages: [{ role: "assistant", content: "hi" }] }, + }); + const hits = await collectMemoryHits(task, deps); + + assert.deepEqual(hits, []); + assert.equal(called, false); +}); + +test("collectMemoryHits returns [] when search throws — never fails the caller", async () => { + const deps: MemoryHitsDeps = { + search: async () => { + throw new Error("boom"); + }, + }; + + const task = makeTask(); + const hits = await collectMemoryHits(task, deps); + + assert.deepEqual(hits, []); +}); + +test("collectMemoryHits kill-switch (OMNIROUTE_A2A_MEMORY_HITS=0) returns [] without calling search", async () => { + await withEnv("0", async () => { + let called = false; + const deps: MemoryHitsDeps = { + search: async () => { + called = true; + return []; + }, + }; + + const task = makeTask(); + const hits = await collectMemoryHits(task, deps); + + assert.deepEqual(hits, []); + assert.equal(called, false); + }); +}); + +test("executeA2ATaskWithState sets task.metadata.memoryHits and appends a memory_hits event when there are hits", async () => { + const appendEventCalls: Array<{ taskId: string; eventType: string; dataJson?: string }> = []; + const deps: MemoryHitsDeps = { + search: async () => [{ id: "m1", key: "k1", type: "factual", content: "hello" }], + appendEvent: (taskId, eventType, dataJson) => { + appendEventCalls.push({ taskId, eventType, dataJson }); + }, + }; + + const updateTaskCalls: unknown[] = []; + const tm = { + updateTask: (...args: unknown[]) => { + updateTaskCalls.push(args); + }, + }; + + const task = makeTask(); + const result = await executeA2ATaskWithState( + tm, + task, + async () => ({ artifacts: [], metadata: {} }), + deps + ); + + assert.deepEqual(result.artifacts, []); + assert.deepEqual(task.metadata.memoryHits, [ + { id: "m1", key: "k1", type: "factual", snippet: "hello" }, + ]); + assert.equal(appendEventCalls.length, 1); + assert.equal(appendEventCalls[0].taskId, "task-1"); + assert.equal(appendEventCalls[0].eventType, "memory_hits"); + assert.deepEqual(JSON.parse(appendEventCalls[0].dataJson ?? "[]"), [ + { id: "m1", key: "k1", type: "factual", snippet: "hello" }, + ]); + assert.equal(updateTaskCalls.length, 1); +}); + +test("executeA2ATaskWithState does not set metadata.memoryHits or append an event when there are no hits", async () => { + const appendEventCalls: unknown[] = []; + const deps: MemoryHitsDeps = { + search: async () => [], + appendEvent: (...args: unknown[]) => { + appendEventCalls.push(args); + }, + }; + + const tm = { updateTask: () => {} }; + const task = makeTask(); + await executeA2ATaskWithState(tm, task, async () => ({ artifacts: [], metadata: {} }), deps); + + assert.equal("memoryHits" in task.metadata, false); + assert.equal(appendEventCalls.length, 0); +}); + +test("executeA2ATaskWithState completes the task normally even when memory recall throws", async () => { + const deps: MemoryHitsDeps = { + search: async () => { + throw new Error("recall backend down"); + }, + }; + + let completedState: string | undefined; + const tm = { + updateTask: (_taskId: string, state: string) => { + completedState = state; + }, + }; + + const task = makeTask(); + const result = await executeA2ATaskWithState( + tm, + task, + async () => ({ artifacts: [{ type: "text", content: "ok" }], metadata: {} }), + deps + ); + + assert.equal(completedState, "completed"); + assert.deepEqual(result.artifacts, [{ type: "text", content: "ok" }]); + assert.equal("memoryHits" in task.metadata, false); +}); + +test("executeA2ATaskWithState swallows a throwing appendEvent (best-effort) and still completes", async () => { + const deps: MemoryHitsDeps = { + search: async () => [{ id: "m1", key: "k1", type: "factual", content: "hello" }], + appendEvent: () => { + throw new Error("db unavailable"); + }, + }; + + let completedState: string | undefined; + const tm = { + updateTask: (_taskId: string, state: string) => { + completedState = state; + }, + }; + + const task = makeTask(); + await executeA2ATaskWithState(tm, task, async () => ({ artifacts: [], metadata: {} }), deps); + + assert.equal(completedState, "completed"); + assert.deepEqual(task.metadata.memoryHits, [ + { id: "m1", key: "k1", type: "factual", snippet: "hello" }, + ]); +}); + +/** + * Regression (whole-branch review, Important 1): `createTask` used to store the CALLER's + * `input.metadata` object as the task's own `metadata`, so the `memoryHits` written above + * landed inside `task.input.metadata` too — from where it was serialized into + * `a2a_tasks.input_json` and echoed back by the drawer's "Repeat" body, making the repeated + * task be born carrying the previous run's memory snippets (visible even with the + * `OMNIROUTE_A2A_MEMORY_HITS=0` kill-switch on). `metadata` must be a COPY. + */ +test("executeA2ATaskWithState never leaks memoryHits into task.input.metadata or the persisted input", async () => { + const upsertCalls: Array<{ inputJson: string | null }> = []; + const persistence: A2APersistence = { + upsert: ((row: { inputJson: string | null }) => { + upsertCalls.push(row); + }) as A2APersistence["upsert"], + appendEvent: (() => {}) as A2APersistence["appendEvent"], + purge: ((): number => 0) as A2APersistence["purge"], + }; + const tm = new A2ATaskManager(5, persistence); + try { + const callerMetadata = { role: "general" }; + const task = tm.createTask({ + skill: "smart-routing", + messages: [{ role: "user", content: "route this please" }], + metadata: callerMetadata, + }); + + await executeA2ATaskWithState( + { updateTask: () => {} }, + task, + async () => ({ artifacts: [], metadata: {} }), + { + search: async () => [{ id: "m1", key: "k1", type: "factual", content: "hello" }], + appendEvent: () => {}, + } + ); + + // The hits ARE recorded on the task's runtime metadata … + assert.deepEqual(task.metadata.memoryHits, [ + { id: "m1", key: "k1", type: "factual", snippet: "hello" }, + ]); + // … but never on the immutable record of what the caller sent. + assert.equal("memoryHits" in (task.input.metadata ?? {}), false); + assert.deepEqual(task.input.metadata, { role: "general" }); + // … nor on the caller's own object (no aliasing in either direction). + assert.deepEqual(callerMetadata, { role: "general" }); + + // A persist AFTER the hits were recorded must still write a clean input_json. + tm.updateTask(task.id, "working"); + assert.ok(upsertCalls.length >= 2); + for (const row of upsertCalls) { + assert.ok(!String(row.inputJson).includes("memoryHits"), "input_json carries no memoryHits"); + } + } finally { + tm.destroy(); + } +}); diff --git a/tests/unit/conductor-create-route.test.ts b/tests/unit/conductor-create-route.test.ts new file mode 100644 index 0000000000..d16d47b1d3 --- /dev/null +++ b/tests/unit/conductor-create-route.test.ts @@ -0,0 +1,155 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createServer, type Server } from "node:http"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-conductor-create-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const createRoute = await import("../../src/app/api/conductor/tasks/route.ts"); + +const servers: Server[] = []; + +function fakeHub(routes: Record): Promise { + const server = createServer((req, res) => { + const hit = Object.entries(routes).find(([p]) => (req.url ?? "").startsWith(p)); + res.writeHead(hit ? hit[1].status : 404, { "content-type": "application/json" }); + res.end( + JSON.stringify(hit ? hit[1].body : { error: "hub: segredo interno que NÃO pode vazar" }) + ); + }); + servers.push(server); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + resolve(`http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`); + }); + }); +} + +function postJson(body: unknown): Request { + return new Request("http://localhost/api/conductor/tasks", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + delete process.env.CONDUCTOR_HUB_URL; + delete process.env.CONDUCTOR_HUB_TOKEN; +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + delete process.env.CONDUCTOR_HUB_URL; + delete process.env.CONDUCTOR_HUB_TOKEN; + while (servers.length > 0) { + const s = servers.pop(); + await new Promise((resolve) => s?.close(resolve)); + } +}); + +test("route: requireManagementAuth antes de criar a task no hub", () => { + const src = fs.readFileSync( + path.join(process.cwd(), "src/app/api/conductor/tasks/route.ts"), + "utf8" + ); + const authAt = src.indexOf("requireManagementAuth("); + assert.ok(authAt > 0, "handler chama requireManagementAuth"); + assert.match(src, /if \(authError\) return authError;/, "curto-circuito no erro de auth"); + const proxyAt = src.indexOf("createConductorTask("); + assert.ok(proxyAt > authAt, "proxy ao hub só depois do gate de auth"); + assert.ok( + !src.includes("CONDUCTOR_HUB_TOKEN"), + "token nunca manuseado na rota (vive no hubProxy)" + ); +}); + +test("POST /api/conductor/tasks: body válido + hub ok → 201 {task_id}", async () => { + process.env.CONDUCTOR_HUB_URL = await fakeHub({ + "/v1/tasks": { status: 201, body: { id: "t_repeat_1" } }, + }); + process.env.CONDUCTOR_HUB_TOKEN = "tok"; + + const res = await createRoute.POST( + postJson({ repoUrl: "https://git.x/repo", prompt: "refaça isso" }) + ); + assert.equal(res.status, 201); + assert.deepEqual(await res.json(), { task_id: "t_repeat_1" }); +}); + +test("POST /api/conductor/tasks: hub recusa (502) → status espelhado, sem corpo upstream", async () => { + process.env.CONDUCTOR_HUB_URL = await fakeHub({ + "/v1/tasks": { status: 502, body: { error: "segredo interno que NÃO pode vazar" } }, + }); + process.env.CONDUCTOR_HUB_TOKEN = "tok"; + + const res = await createRoute.POST( + postJson({ repoUrl: "https://git.x/repo", prompt: "refaça isso" }) + ); + assert.equal(res.status, 502); + const text = await res.text(); + assert.ok(!text.includes("segredo interno"), "corpo do hub NUNCA repassado (HR#12)"); +}); + +test("POST /api/conductor/tasks: body inválido (sem prompt) → 400", async () => { + const res = await createRoute.POST(postJson({ repoUrl: "https://git.x/repo" })); + assert.equal(res.status, 400); +}); + +test("POST /api/conductor/tasks: body inválido (sem repoUrl) → 400", async () => { + const res = await createRoute.POST(postJson({ prompt: "refaça isso" })); + assert.equal(res.status, 400); +}); + +test("POST /api/conductor/tasks: JSON malformado → 400", async () => { + const res = await createRoute.POST( + new Request("http://localhost/api/conductor/tasks", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not json", + }) + ); + assert.equal(res.status, 400); +}); + +/** + * Review finding (Minor A): the hub's status was forwarded verbatim as OUR response status. + * `Response.json()` throws a `RangeError` for anything outside 200-599, and a 3xx/2xx is + * meaningless as an error status anyway — so anything outside 400-599 must become a 502 + * instead of an unhandled throw. A 302 with no `Location` is returned as-is by fetch (there + * is nothing to follow), which reproduces the out-of-band status without a fake fetch impl. + */ +test("POST /api/conductor/tasks: status fora de 400-599 vindo do hub é clampado para 502", async () => { + process.env.CONDUCTOR_HUB_URL = await fakeHub({ + "/v1/tasks": { status: 302, body: { error: "segredo interno que NÃO pode vazar" } }, + }); + process.env.CONDUCTOR_HUB_TOKEN = "tok"; + + const res = await createRoute.POST( + postJson({ repoUrl: "https://git.x/repo", prompt: "refaça isso" }) + ); + assert.equal(res.status, 502); + const text = await res.text(); + assert.ok(!text.includes("segredo interno"), "corpo do hub NUNCA repassado (HR#12)"); +}); + +test("POST /api/conductor/tasks: status 4xx/5xx legítimo do hub continua espelhado", async () => { + process.env.CONDUCTOR_HUB_URL = await fakeHub({ + "/v1/tasks": { status: 429, body: { error: "rate limited" } }, + }); + process.env.CONDUCTOR_HUB_TOKEN = "tok"; + + const res = await createRoute.POST( + postJson({ repoUrl: "https://git.x/repo", prompt: "refaça isso" }) + ); + assert.equal(res.status, 429); +}); diff --git a/tests/unit/ui/orchestrationDrawerRepeat.test.tsx b/tests/unit/ui/orchestrationDrawerRepeat.test.tsx new file mode 100644 index 0000000000..761b0d922f --- /dev/null +++ b/tests/unit/ui/orchestrationDrawerRepeat.test.tsx @@ -0,0 +1,786 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (k: string, v?: Record) => + v ? `${k}:${JSON.stringify(v)}` : k, +})); + +import { OrchestrationDrawer } from "@/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer"; +import { repeatReqFor } from "@/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail"; + +function render(el: React.ReactElement) { + const c = document.createElement("div"); + document.body.appendChild(c); + const root = createRoot(c); + act(() => root.render(el)); + return { + c, + cleanup: () => { + act(() => root.unmount()); + c.remove(); + }, + }; +} +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("OrchestrationDrawer memory section", () => { + it("renders the memory-used section (type/key/snippet) when an a2a task carries metadata.memoryHits", async () => { + const a2aTask = { + id: "1", + skill: "smart-routing", + state: "working", + input: { skill: "smart-routing", messages: [{ role: "user", content: "route this please" }] }, + artifacts: [], + events: [], + metadata: { + memoryHits: [ + { id: "m1", key: "user-pref-model", type: "preference", snippet: "prefers claude" }, + ], + }, + createdAt: "x", + updatedAt: "y", + expiresAt: "z", + }; + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: a2aTask }) })) + ); + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await act(async () => { + await Promise.resolve(); + }); + expect(c.textContent).toContain("drawerMemory"); + expect(c.textContent).toContain("preference"); + expect(c.textContent).toContain("user-pref-model"); + expect(c.textContent).toContain("prefers claude"); + cleanup(); + }); + + it("omits the memory-used section when an a2a task has no memoryHits", async () => { + const a2aTask = { + id: "1", + skill: "smart-routing", + state: "working", + input: { skill: "smart-routing", messages: [{ role: "user", content: "route this please" }] }, + artifacts: [], + events: [], + metadata: {}, + createdAt: "x", + updatedAt: "y", + expiresAt: "z", + }; + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: a2aTask }) })) + ); + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await act(async () => { + await Promise.resolve(); + }); + expect(c.textContent).not.toContain("drawerMemory"); + cleanup(); + }); + + it("renders nothing and does not throw when metadata.memoryHits is a malformed, non-array shape (a string, not an array of hits)", async () => { + const a2aTask = { + id: "1", + skill: "smart-routing", + state: "working", + input: { skill: "smart-routing", messages: [{ role: "user", content: "route this please" }] }, + artifacts: [], + events: [], + metadata: { memoryHits: "boom" }, + createdAt: "x", + updatedAt: "y", + expiresAt: "z", + }; + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: a2aTask }) })) + ); + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await expect( + act(async () => { + await Promise.resolve(); + }) + ).resolves.not.toThrow(); + expect(c.textContent).not.toContain("drawerMemory"); + cleanup(); + }); + + it("filters out malformed entries in metadata.memoryHits (an array of junk) without throwing", async () => { + const a2aTask = { + id: "1", + skill: "smart-routing", + state: "working", + input: { skill: "smart-routing", messages: [{ role: "user", content: "route this please" }] }, + artifacts: [], + events: [], + // `{ id: "x", key: {...} }` is the dangerous shape: a VALID string id next to an + // object field that the section renders as a React child — a guard that only checks + // `id` lets it through and React throws "Objects are not valid as a React child", + // taking the whole drawer down. Every one of the four rendered fields must be a string. + metadata: { + memoryHits: [ + { notId: "x" }, + "nope", + 123, + null, + { id: "x", key: { a: 1 }, type: "factual", snippet: "s" }, + { id: "y", key: "k", type: ["nope"], snippet: "s" }, + { id: "z", key: "k", type: "factual", snippet: { toString: "boom" } }, + { id: "w", key: "k", type: "factual" }, + ], + }, + createdAt: "x", + updatedAt: "y", + expiresAt: "z", + }; + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: a2aTask }) })) + ); + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await expect( + act(async () => { + await Promise.resolve(); + }) + ).resolves.not.toThrow(); + expect(c.textContent).not.toContain("drawerMemory"); + cleanup(); + }); + + it("never shows the memory-used section for non-a2a sources, even with attacker-shaped raw data", async () => { + const detail = { + data: { + id: "t1", + providerId: "devin", + status: "succeeded", + prompt: "x", + source: { repoName: "r", repoUrl: "https://x" }, + options: {}, + activities: [], + metadata: { + memoryHits: [{ id: "m1", key: "k", type: "t", snippet: "s" }], + }, + createdAt: "x", + updatedAt: "y", + }, + }; + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(detail) })) + ); + const node = { + id: "cloud-agent:t1", + kind: "work", + source: "cloud-agent", + state: "succeeded", + label: "x", + }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await act(async () => { + await Promise.resolve(); + }); + expect(c.textContent).not.toContain("drawerMemory"); + cleanup(); + }); +}); + +describe("repeatReqFor", () => { + it("builds the cloud-agent repeat request from the loaded detail (CreateCloudAgentTaskSchema shape)", () => { + const node = { + id: "cloud-agent:t1", + kind: "work", + source: "cloud-agent", + state: "succeeded", + label: "x", + }; + const detail = { + id: "t1", + providerId: "devin", + prompt: "do the thing", + source: { repoName: "r", repoUrl: "https://x" }, + options: { autoCreatePr: true }, + activities: [], + }; + const req = repeatReqFor(node as never, detail); + expect(req?.url).toBe("/api/v1/agents/tasks"); + expect(req?.init.method).toBe("POST"); + expect(JSON.parse(String(req?.init.body))).toEqual({ + providerId: "devin", + prompt: "do the thing", + source: { repoName: "r", repoUrl: "https://x" }, + options: { autoCreatePr: true }, + }); + }); + + it("returns null for cloud-agent when neither providerId nor prompt is recoverable", () => { + const node = { + id: "cloud-agent:t1", + kind: "work", + source: "cloud-agent", + state: "succeeded", + label: "x", + }; + expect(repeatReqFor(node as never, { source: {}, options: {}, activities: [] })).toBeNull(); + }); + + it("returns null for cloud-agent when providerId is the only missing field", () => { + const node = { + id: "cloud-agent:t1", + kind: "work", + source: "cloud-agent", + state: "succeeded", + label: "x", + }; + const detail = { + prompt: "do the thing", + source: { repoName: "r", repoUrl: "https://x" }, + options: {}, + activities: [], + }; + expect(repeatReqFor(node as never, detail)).toBeNull(); + }); + + it("returns null for cloud-agent when prompt is the only missing field", () => { + const node = { + id: "cloud-agent:t1", + kind: "work", + source: "cloud-agent", + state: "succeeded", + label: "x", + }; + const detail = { + providerId: "devin", + source: { repoName: "r", repoUrl: "https://x" }, + options: {}, + activities: [], + }; + expect(repeatReqFor(node as never, detail)).toBeNull(); + }); + + it("returns null for cloud-agent when source is the only missing field (CreateCloudAgentTaskSchema also requires it — the field this fix started checking)", () => { + const node = { + id: "cloud-agent:t1", + kind: "work", + source: "cloud-agent", + state: "succeeded", + label: "x", + }; + const detail = { + providerId: "devin", + prompt: "do the thing", + options: {}, + activities: [], + }; + expect(repeatReqFor(node as never, detail)).toBeNull(); + }); + + it("builds the a2a repeat request as a message/send JSON-RPC call from detail.input", () => { + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "succeeded", label: "x" }; + const detail = { + input: { + skill: "smart-routing", + messages: [{ role: "user", content: "route this please" }], + metadata: { role: "general" }, + }, + }; + const req = repeatReqFor(node as never, detail); + expect(req?.url).toBe("/a2a"); + expect(JSON.parse(String(req?.init.body))).toEqual({ + jsonrpc: "2.0", + id: "a2a:1", + method: "message/send", + params: { + skill: "smart-routing", + messages: [{ role: "user", content: "route this please" }], + metadata: { role: "general" }, + }, + }); + }); + + it("strips memoryHits from the a2a repeat metadata (never re-sends the previous run's memory)", () => { + // `memoryHits` is observability written by the PREVIOUS run, never caller input. Tasks + // persisted before the createTask copy-fix still carry it inside `input.metadata`, so the + // repeat path has to drop it — otherwise the new task is born with the old run's snippets + // and shows them in the drawer even with `OMNIROUTE_A2A_MEMORY_HITS=0`. + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "succeeded", label: "x" }; + const detail = { + input: { + skill: "smart-routing", + messages: [{ role: "user", content: "route this please" }], + metadata: { + role: "general", + memoryHits: [{ id: "m1", key: "k1", type: "factual", snippet: "leaked" }], + }, + }, + }; + const body = JSON.parse(String(repeatReqFor(node as never, detail)?.init.body)); + expect(body.params.metadata).toEqual({ role: "general" }); + expect(JSON.stringify(body)).not.toContain("memoryHits"); + }); + + it("omits metadata entirely when the a2a detail carries none (or a non-object one)", () => { + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "succeeded", label: "x" }; + const messages = [{ role: "user", content: "route this please" }]; + const bare = JSON.parse( + String(repeatReqFor(node as never, { input: { skill: "s", messages } })?.init.body) + ); + expect("metadata" in bare.params).toBe(false); + const junk = JSON.parse( + String( + repeatReqFor(node as never, { input: { skill: "s", messages, metadata: "boom" } })?.init + .body + ) + ); + expect("metadata" in junk.params).toBe(false); + }); + + it("returns null for a2a when input.messages is empty or missing", () => { + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "succeeded", label: "x" }; + expect(repeatReqFor(node as never, { input: { skill: "s", messages: [] } })).toBeNull(); + expect(repeatReqFor(node as never, {})).toBeNull(); + }); + + it("builds the conductor repeat request against the D1 task-creation route", () => { + const node = { + id: "conductor:task:1", + kind: "work", + source: "conductor", + state: "succeeded", + label: "x", + }; + const detail = { + repo: "https://github.com/x/y", + prompt: "fix the bug", + base_ref: "main", + mode: "auto", + }; + const req = repeatReqFor(node as never, detail); + expect(req?.url).toBe("/api/conductor/tasks"); + expect(JSON.parse(String(req?.init.body))).toEqual({ + repoUrl: "https://github.com/x/y", + prompt: "fix the bug", + baseRef: "main", + mode: "auto", + }); + }); + + it("returns null for conductor when neither repo nor prompt is recoverable", () => { + const node = { + id: "conductor:task:1", + kind: "work", + source: "conductor", + state: "succeeded", + label: "x", + }; + expect(repeatReqFor(node as never, { mode: "auto" })).toBeNull(); + }); + + it("returns null for conductor when prompt is the only missing field (a hub task with repo but no spec.prompt must not POST prompt:null — HTTP 400)", () => { + const node = { + id: "conductor:task:1", + kind: "work", + source: "conductor", + state: "succeeded", + label: "x", + }; + const detail = { repo: "https://github.com/x/y", prompt: null, base_ref: "main", mode: "auto" }; + expect(repeatReqFor(node as never, detail)).toBeNull(); + }); + + it("returns null for conductor when repo is the only missing field", () => { + const node = { + id: "conductor:task:1", + kind: "work", + source: "conductor", + state: "succeeded", + label: "x", + }; + const detail = { repo: null, prompt: "fix the bug", base_ref: "main", mode: "auto" }; + expect(repeatReqFor(node as never, detail)).toBeNull(); + }); + + it("returns null for a source with no known repeat contract (runner/overflow)", () => { + const node = { id: "overflow:1", kind: "overflow", state: "succeeded", label: "x" }; + expect(repeatReqFor(node as never, {})).toBeNull(); + }); +}); + +describe("OrchestrationDrawer repeat action (two-click confirm)", () => { + const A2A_TASK = { + id: "1", + skill: "smart-routing", + state: "working", + input: { + skill: "smart-routing", + messages: [{ role: "user", content: "route this please" }], + }, + artifacts: [], + events: [], + metadata: {}, + createdAt: "x", + updatedAt: "y", + expiresAt: "z", + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + function findRepeatButton(c: HTMLElement): HTMLButtonElement { + return Array.from(c.querySelectorAll("button")).find( + (b) => b.textContent?.includes("actionRepeat") || b.textContent?.includes("repeatConfirm") + ) as HTMLButtonElement; + } + + it("disables the repeat button with the repeatUnavailable tooltip when the input cannot be recovered", async () => { + const unrecoverable = { ...A2A_TASK, input: { skill: "smart-routing", messages: [] } }; + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({ task: unrecoverable }) }) + ) + ); + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + const btn = findRepeatButton(c); + expect(btn.disabled).toBe(true); + expect(btn.getAttribute("title")).toBe("repeatUnavailable"); + cleanup(); + }); + + it("first click arms the confirm label without posting; second click within the window posts and reports success", async () => { + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) }); + }); + vi.stubGlobal("fetch", fetchMock); + let done = false; + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} + onActionDone={() => { + done = true; + }} + /> + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + findRepeatButton(c).click(); + }); + expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit)?.method === "POST")).toBe( + false + ); + expect(findRepeatButton(c).textContent).toContain("repeatConfirm"); + + await act(async () => { + findRepeatButton(c).click(); + await Promise.resolve(); + await Promise.resolve(); + }); + const post = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === "POST"); + expect(post).toBeTruthy(); + expect(post![0]).toBe("/a2a"); + expect(JSON.parse(String((post![1] as RequestInit).body))).toEqual({ + jsonrpc: "2.0", + id: "a2a:1", + method: "message/send", + params: { + skill: "smart-routing", + messages: [{ role: "user", content: "route this please" }], + }, + }); + expect(done).toBe(true); + expect(c.textContent).toContain("repeatDone"); + cleanup(); + }); + + it("resets the confirm label back to actionRepeat after 3s with no second click", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) })) + ); + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + findRepeatButton(c).click(); + }); + expect(findRepeatButton(c).textContent).toContain("repeatConfirm"); + + await act(async () => { + vi.advanceTimersByTime(3000); + }); + expect(findRepeatButton(c).textContent).toContain("actionRepeat"); + cleanup(); + }); + + it("a click after the 3s window expired re-arms the confirm instead of posting (it is a fresh first click, not a stale second click)", async () => { + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) }); + }); + vi.stubGlobal("fetch", fetchMock); + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + findRepeatButton(c).click(); + }); + expect(findRepeatButton(c).textContent).toContain("repeatConfirm"); + + await act(async () => { + vi.advanceTimersByTime(3000); + }); + expect(findRepeatButton(c).textContent).toContain("actionRepeat"); + + // The window has expired — this click must be treated as a fresh first click + // (arm + wait), never as the stale second click that would fire the POST. + await act(async () => { + findRepeatButton(c).click(); + }); + expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit)?.method === "POST")).toBe( + false + ); + expect(findRepeatButton(c).textContent).toContain("repeatConfirm"); + cleanup(); + }); + + it("clears the pending 3s confirm timer on unmount so it can never fire after teardown", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) })) + ); + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + findRepeatButton(c).click(); + }); + expect(vi.getTimerCount()).toBeGreaterThan(0); + + cleanup(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("shows actionFailed when the repeat POST fails, without touching onActionDone", async () => { + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) }); + }); + vi.stubGlobal("fetch", fetchMock); + let done = false; + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} + onActionDone={() => { + done = true; + }} + /> + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + findRepeatButton(c).click(); + }); + await act(async () => { + findRepeatButton(c).click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(c.textContent).toContain("actionFailed"); + expect(done).toBe(false); + cleanup(); + }); + it("does not send the previous run's memoryHits in the repeat POST body", async () => { + const withHits = { + ...A2A_TASK, + input: { + ...A2A_TASK.input, + metadata: { + role: "general", + memoryHits: [{ id: "m1", key: "k1", type: "factual", snippet: "leaked" }], + }, + }, + }; + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: withHits }) }); + }); + vi.stubGlobal("fetch", fetchMock); + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + findRepeatButton(c).click(); + }); + await act(async () => { + findRepeatButton(c).click(); + await Promise.resolve(); + await Promise.resolve(); + }); + const post = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === "POST"); + expect(post).toBeTruthy(); + const body = JSON.parse(String((post![1] as RequestInit).body)); + expect(body.params.metadata).toEqual({ role: "general" }); + expect(String((post![1] as RequestInit).body)).not.toContain("memoryHits"); + cleanup(); + }); + + it("treats a JSON-RPC error answered with HTTP 200 as a failure, never as a success toast", async () => { + // `/a2a` maps most JSON-RPC error codes to `status: 200` (src/app/a2a/route.ts), so + // `res.ok` alone would report a run that never happened as done. + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + jsonrpc: "2.0", + id: "a2a:1", + error: { code: -32602, message: "segredo interno que NAO pode vazar" }, + }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) }); + }); + vi.stubGlobal("fetch", fetchMock); + let done = false; + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} + onActionDone={() => { + done = true; + }} + /> + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + findRepeatButton(c).click(); + }); + await act(async () => { + findRepeatButton(c).click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(done).toBe(false); + expect(c.textContent).not.toContain("repeatDone"); + expect(c.textContent).toContain("actionFailed"); + expect(c.textContent).toContain("RPC -32602"); + expect(c.textContent).not.toContain("segredo interno"); + cleanup(); + }); + + it("surfaces the sanitized HTTP status when a secured deployment rejects the a2a repeat (HTTP 400)", async () => { + // With REQUIRE_API_KEY / OMNIROUTE_API_KEY set, `/a2a` answers -32600 => HTTP 400 to a + // dashboard-session caller. The drawer must say so instead of pretending success. + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === "POST") { + return Promise.resolve({ ok: false, status: 400, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) }); + }); + vi.stubGlobal("fetch", fetchMock); + const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" }; + const { c, cleanup } = render( + {}} onActionDone={() => {}} /> + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + findRepeatButton(c).click(); + }); + await act(async () => { + findRepeatButton(c).click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(c.textContent).toContain("actionFailed"); + expect(c.textContent).toContain("HTTP 400"); + expect(c.textContent).not.toContain("repeatDone"); + cleanup(); + }); +}); diff --git a/tests/unit/ui/orchestrationHistoryTab.test.tsx b/tests/unit/ui/orchestrationHistoryTab.test.tsx index c27f4a57c4..ceee2897ca 100644 --- a/tests/unit/ui/orchestrationHistoryTab.test.tsx +++ b/tests/unit/ui/orchestrationHistoryTab.test.tsx @@ -250,6 +250,36 @@ describe("HistoryTab", () => { cleanup(); }); + it("keeps the drawer open on onActionDone so its success toast is visible, and refetches", async () => { + // Review finding (Minor B): this tab used to pass `onActionDone={() => setSelected(null)}`, + // which unmounted the drawer BEFORE it rendered the repeat/cancel confirmation — the + // operator saw the action silently do nothing. The callback must keep the drawer mounted + // (and re-sample the range so the new run shows up). + const fetchMock = mockFetch({ a2aTasks: [a2aTask()] }); + vi.stubGlobal("fetch", fetchMock); + const { c, cleanup } = render(); + await flush(); + + const cell = c.querySelector('button[aria-label*="smart-routing"]') as HTMLButtonElement; + act(() => { + cell.click(); + }); + expect((drawerCalls.at(-1) as { node: unknown }).node).toBeTruthy(); + const callsBefore = fetchMock.mock.calls.length; + + await act(async () => { + (drawerCalls.at(-1) as { onActionDone: () => void }).onActionDone(); + }); + await flush(); + + // Still open on the same node … + const last = drawerCalls.at(-1) as { node: { id: string } | null }; + expect(last.node?.id).toBe("a2a:t1"); + // … and the history was refetched. + expect(fetchMock.mock.calls.length).toBeGreaterThan(callsBefore); + cleanup(); + }); + it("shows a source-failed warning for A2A while Cloud Agent rows still render", async () => { vi.stubGlobal("fetch", mockFetch({ a2aFail: true, cloudAgentTasks: [cloudAgentTask()] })); const { c, cleanup } = render();