feat(dashboard): orchestration canvas fase 2 — repeat action + A2A memory hits (2.6/2.7) (#12508)

* 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>`, 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 <diegosouzapw@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-02 20:37:02 -03:00
committed by GitHub
parent 86e83d4138
commit a628d28898
56 changed files with 2078 additions and 62 deletions

View File

@@ -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<string, unknown>)[f] === "string")
);
if (hits.length === 0) return null;
return (
<Section title={t("drawerMemory")}>
<ul className="text-xs flex flex-col gap-1.5">
{hits.map((h) => (
<li key={h.id}>
<code className="text-[9px] text-muted mr-1">{h.type}</code>
<span className="font-medium">{h.key}</span>
<div className="text-[10px] text-muted break-words">{h.snippet}</div>
</li>
))}
</ul>
</Section>
);
}
/**
* 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<ReturnType<typeof setTimeout> | 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<boolean>;
onActionDone: () => void;
onToast: (text: string) => void;
t: Translate;
}) {
const { confirming, onClick } = useTwoClickConfirm(() => {
void (async () => {
if (await repeat()) {
onActionDone();
onToast(t("repeatDone"));
}
})();
});
return (
<button
className="text-xs rounded border border-border px-2 py-1 disabled:opacity-50"
onClick={onClick}
disabled={!canRepeat}
title={canRepeat || busy ? undefined : t("repeatUnavailable")}
>
{confirming ? t("repeatConfirm") : t("actionRepeat")}
</button>
);
}
/** 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<boolean>;
cancel: () => Promise<boolean>;
repeat: () => Promise<boolean>;
onActionDone: () => void;
onToast: (text: string) => void;
t: Translate;
}) {
if (!canApprove && !canCancel) return null;
if (!canApprove && !canCancel && !showRepeat) return null;
const run = async (fn: () => Promise<boolean>) => {
if (await fn()) {
onActionDone();
@@ -304,6 +428,16 @@ function DrawerActions({
{t("actionCancel")}
</button>
)}
{showRepeat && (
<RepeatButton
canRepeat={canRepeat}
busy={busy}
repeat={repeat}
onActionDone={onActionDone}
onToast={onToast}
t={t}
/>
)}
</div>
</Section>
);
@@ -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({
</Section>
<DrawerMetrics node={node} ca={ca} t={t} />
<DrawerResult ca={ca} a2a={a2a} t={t} />
<DrawerMemory a2a={a2a} t={t} />
<DrawerActions
canApprove={canApprove}
canCancel={canCancel}
showRepeat={showRepeat}
canRepeat={canRepeat}
busy={busy}
approve={approve}
cancel={cancel}
repeat={repeat}
onActionDone={onActionDone}
onToast={showToast}
t={t}

View File

@@ -1,12 +1,16 @@
"use client";
/** Fetches per-source task detail on drawer open + approve/cancel actions. */
/** Fetches per-source task detail on drawer open + approve/cancel/repeat actions. */
import { useEffect, useState } from "react";
import type { OrchNode } from "../model/orchestrationTypes";
import type { CloudAgentTask } from "@/lib/cloudAgent/types";
import type { A2ATask } from "@/lib/a2a/taskManager";
import type { ConductorTaskDetail } from "@/lib/conductor/hubProxy";
// Client-safe stand-in for sanitizeErrorMessage (server-only, breaks the client bundle — #10692): only our own `HTTP <status>` 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 <status>` / `RPC <code>` errors and AbortError pass through verbatim, everything else collapses to a generic string. `RPC <code>` 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<string, unknown> | undefined {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
const rest = { ...(metadata as Record<string, unknown>) };
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 <code>`) — never the upstream `error.message`.
*/
async function jsonRpcErrorCode(res: {
json?: () => Promise<unknown>;
}): Promise<number | undefined> {
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<boolean> => {
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),
};
}

View File

@@ -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"
>
<span className="font-medium">{row.identity}</span>{" "}
<span className="text-[9px] uppercase text-muted">
{t(SOURCE_KEY[row.source])}
</span>
<span className="text-[9px] uppercase text-muted">{t(SOURCE_KEY[row.source])}</span>
</th>
{row.cells.map((cell, i) => (
<td key={i} className="p-0.5 align-top">
@@ -350,10 +346,22 @@ export function HistoryTab() {
<HistoryGridTable grid={grid} preset={preset} t={t} onSelectItem={onSelectItem} />
)}
{/* `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. */}
<OrchestrationDrawer
node={selected}
onClose={() => setSelected(null)}
onActionDone={() => setSelected(null)}
onActionDone={() => {
const sampled = Date.now();
setNowMs((prev) => (sampled > prev ? sampled : prev + 1));
}}
/>
</div>
);

View File

@@ -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 });
}