mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 20:32:25 +03:00
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:
committed by
GitHub
parent
86e83d4138
commit
a628d28898
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
66
src/app/api/conductor/tasks/route.ts
Normal file
66
src/app/api/conductor/tasks/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user