mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-12 18:02:11 +03:00
Compare commits
7 Commits
fix/12783-
...
feat/orch-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67631f6d0c | ||
|
|
41105101a2 | ||
|
|
722be56d3b | ||
|
|
a9a1b04e2f | ||
|
|
a7d430fe9e | ||
|
|
3ef0aef15b | ||
|
|
da8014442d |
15
changelog.d/fixes/orchestration-followups.md
Normal file
15
changelog.d/fixes/orchestration-followups.md
Normal file
@@ -0,0 +1,15 @@
|
||||
- **fix(dashboard):** five follow-ups from the Phase 2 Orchestration Canvas review. The History
|
||||
drawer now shows the memory section for runs that already left the live TTL window: the
|
||||
persisted `memory_hits` event is parsed into `metadata.memoryHits` with the same defensive
|
||||
validation the drawer applies, and — because that event is observability rather than a state
|
||||
transition — it no longer leaks into the timeline, where it had been inheriting the task's
|
||||
state and rendering as a duplicate transition. The A2A memory recall runs against its own 1.5s
|
||||
deadline instead of inheriting the memory backend's 30s one; overshooting degrades exactly like
|
||||
any other recall failure (no hits, task proceeds), and the timer is cleared on both paths.
|
||||
Repeating a Conductor run carries its `requirements.cli`/`requirements.model` forward, so the
|
||||
new run is pinned to the same runner profile and model rather than drifting to whatever the
|
||||
fleet picks. A successful repeat from the Agents tab now focuses the run it created, instead of
|
||||
leaving the operator on the finished one. And the auth test for `POST /api/conductor/tasks`
|
||||
moved into the shared `ROUTES` array rather than restating the pattern.
|
||||
|
||||
Closes #12639
|
||||
@@ -115,6 +115,13 @@ export default function OrchestrationPageClient() {
|
||||
[collapsed, setParams]
|
||||
);
|
||||
const closeDrawer = useCallback(() => setParams({ node: null }), [setParams]);
|
||||
const onActionDone = useCallback(
|
||||
(newNodeId?: string) => {
|
||||
refetch();
|
||||
if (newNodeId) setParams({ node: newNodeId });
|
||||
},
|
||||
[refetch, setParams]
|
||||
);
|
||||
const clearFilters = useCallback(
|
||||
() => setParams({ q: null, state: null, source: null, provider: null }),
|
||||
[setParams]
|
||||
@@ -176,8 +183,17 @@ export default function OrchestrationPageClient() {
|
||||
{tab === "history" && <HistoryTab />}
|
||||
</div>
|
||||
</div>
|
||||
{/* A successful repeat hands back the CANVAS id of the task it created: refetch, then
|
||||
focus it (`?node=`) so the operator lands on the new run instead of staring at the
|
||||
finished one. No id (approve/cancel, or a creation response without one) keeps the
|
||||
current selection. The History tab renders its own drawer and deliberately does NOT
|
||||
navigate (HistoryTab.tsx) — its runs are not addressable in the live snapshot. */}
|
||||
{tab !== "history" && (
|
||||
<OrchestrationDrawer node={selectedNode} onClose={closeDrawer} onActionDone={refetch} />
|
||||
<OrchestrationDrawer
|
||||
node={selectedNode}
|
||||
onClose={closeDrawer}
|
||||
onActionDone={onActionDone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslations } from "next-intl";
|
||||
import { StatusDot } from "@/shared/components/flow/StatusDot";
|
||||
import { orchStateColor, type OrchNode, type OrchState } from "../model/orchestrationTypes";
|
||||
import { useDrawerDetail } from "./useDrawerDetail";
|
||||
import type { DrawerError } from "./useDrawerDetail";
|
||||
import type { DrawerError, RepeatOutcome } from "./useDrawerDetail";
|
||||
import type { CloudAgentTask } from "@/lib/cloudAgent/types";
|
||||
import type { A2ATask } from "@/lib/a2a/taskManager";
|
||||
|
||||
@@ -349,15 +349,18 @@ function RepeatButton({
|
||||
}: {
|
||||
canRepeat: boolean;
|
||||
busy: boolean;
|
||||
repeat: () => Promise<boolean>;
|
||||
onActionDone: () => void;
|
||||
repeat: () => Promise<RepeatOutcome>;
|
||||
onActionDone: (newNodeId?: string) => void;
|
||||
onToast: (text: string) => void;
|
||||
t: Translate;
|
||||
}) {
|
||||
const { confirming, onClick } = useTwoClickConfirm(() => {
|
||||
void (async () => {
|
||||
if (await repeat()) {
|
||||
onActionDone();
|
||||
// The created task's CANVAS id is handed to `onActionDone` so the page can focus it —
|
||||
// `undefined` when the creation response carried no usable id (plain refetch, no jump).
|
||||
const { ok, newNodeId } = await repeat();
|
||||
if (ok) {
|
||||
onActionDone(newNodeId ?? undefined);
|
||||
onToast(t("repeatDone"));
|
||||
}
|
||||
})();
|
||||
@@ -395,8 +398,8 @@ function DrawerActions({
|
||||
busy: boolean;
|
||||
approve: () => Promise<boolean>;
|
||||
cancel: () => Promise<boolean>;
|
||||
repeat: () => Promise<boolean>;
|
||||
onActionDone: () => void;
|
||||
repeat: () => Promise<RepeatOutcome>;
|
||||
onActionDone: (newNodeId?: string) => void;
|
||||
onToast: (text: string) => void;
|
||||
t: Translate;
|
||||
}) {
|
||||
@@ -488,7 +491,7 @@ export function OrchestrationDrawer({
|
||||
}: {
|
||||
node: OrchNode | null;
|
||||
onClose: () => void;
|
||||
onActionDone: () => void;
|
||||
onActionDone: (newNodeId?: string) => void;
|
||||
}) {
|
||||
const t = useTranslations("orchestration");
|
||||
const {
|
||||
|
||||
@@ -143,7 +143,14 @@ function repeatReqForA2a(
|
||||
};
|
||||
}
|
||||
|
||||
/** conductor repeat builder — `POST /api/conductor/tasks` (D1 task-creation route). */
|
||||
/**
|
||||
* conductor repeat builder — `POST /api/conductor/tasks` (D1 task-creation route).
|
||||
* `cli`/`model` come from the hub's `requirements` (`ConductorTaskDetail`, hubProxy.ts) and are
|
||||
* carried over so the repeat lands on the SAME runner profile/model the original task was
|
||||
* pinned to. Both are `z.string().optional()` in the route's Zod: a `null` would 400, so a
|
||||
* missing requirement must OMIT the field (`undefined`) rather than send `null` — and the two
|
||||
* are independent (one may be set while the other is not).
|
||||
*/
|
||||
function repeatReqForConductor(detail: unknown): { url: string; init: RequestInit } | null {
|
||||
const d = detail as ConductorTaskDetail | null;
|
||||
if (!d?.repo || !d?.prompt) return null;
|
||||
@@ -154,6 +161,8 @@ function repeatReqForConductor(detail: unknown): { url: string; init: RequestIni
|
||||
prompt: d.prompt,
|
||||
baseRef: d.base_ref ?? undefined,
|
||||
mode: d.mode,
|
||||
cli: d.cli ?? undefined,
|
||||
model: d.model ?? undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -168,6 +177,36 @@ export function repeatReqFor(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `<prefix><id>` when `id` is a non-empty string, `null` otherwise (never a bare prefix). */
|
||||
function prefixedNodeId(prefix: string, id: unknown): string | null {
|
||||
return typeof id === "string" && id.length > 0 ? `${prefix}${id}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CANVAS node id of the task a successful repeat just created, from the creation response
|
||||
* body — `null` whenever the body does not carry a usable id (the caller then simply refetches
|
||||
* without focusing anything). The canvas addresses nodes by PREFIXED id
|
||||
* (`mergeSnapshot.ts`), so the raw upstream id is never returned on its own. Response
|
||||
* envelopes, verified against the live routes:
|
||||
* - conductor (`POST /api/conductor/tasks`): `{ task_id }`.
|
||||
* - cloud-agent (`POST /api/v1/agents/tasks`): `{ data: { id } }`.
|
||||
* - a2a (`POST /a2a`, JSON-RPC `message/send`): `{ result: { task: { id } } }`.
|
||||
*/
|
||||
export function newNodeIdFrom(node: OrchNode, body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const b = body as Record<string, unknown>;
|
||||
if (node.id.startsWith("conductor:task:")) return prefixedNodeId("conductor:task:", b.task_id);
|
||||
if (node.id.startsWith("cloud-agent:")) {
|
||||
const data = b.data as { id?: unknown } | undefined;
|
||||
return prefixedNodeId("cloud-agent:", data?.id);
|
||||
}
|
||||
if (node.id.startsWith("a2a:")) {
|
||||
const result = b.result as { task?: { id?: unknown } } | undefined;
|
||||
return prefixedNodeId("a2a:", result?.task?.id);
|
||||
}
|
||||
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:
|
||||
@@ -258,43 +297,62 @@ function useFetchDetail(
|
||||
* 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> {
|
||||
function jsonRpcErrorCode(body: unknown): number | undefined {
|
||||
const b = body as { error?: { code?: unknown } } | undefined | null;
|
||||
const code = b?.error?.code;
|
||||
return typeof code === "number" ? code : b?.error ? -32603 : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an action response body ONCE, tolerating a non-JSON/empty body. A body that cannot be
|
||||
* parsed is not evidence of failure — the status already stood — so it yields `null` and the
|
||||
* action stays successful (it just has no new-task id to focus).
|
||||
*/
|
||||
async function readJsonBody(res: { json?: () => Promise<unknown> }): Promise<unknown> {
|
||||
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;
|
||||
return (await res.json?.()) ?? null;
|
||||
} catch {
|
||||
// A non-JSON / already-consumed body is not evidence of failure — the status stands.
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of an action POST: whether it succeeded, plus the parsed body on success. */
|
||||
interface ActionOutcome {
|
||||
ok: boolean;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
async function performAction(
|
||||
req: { url: string; init: RequestInit } | null,
|
||||
setActionError: (text: string) => void,
|
||||
clearError: () => void
|
||||
): Promise<boolean> {
|
||||
if (!req) return false;
|
||||
): Promise<ActionOutcome> {
|
||||
if (!req) return { ok: false, body: null };
|
||||
try {
|
||||
const res = await fetch(req.url, req.init);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await readJsonBody(res);
|
||||
if (req.url === "/a2a") {
|
||||
const code = await jsonRpcErrorCode(res);
|
||||
const code = jsonRpcErrorCode(body);
|
||||
if (code !== undefined) throw new Error(`RPC ${code}`);
|
||||
}
|
||||
// The banner is not sticky: a retry (or any later action) that works clears whatever
|
||||
// detail/action error was on screen, so the drawer never shows a failure the operator
|
||||
// already recovered from.
|
||||
clearError();
|
||||
return true;
|
||||
return { ok: true, body };
|
||||
} catch (err) {
|
||||
setActionError(toSafeErrorText(err));
|
||||
return false;
|
||||
return { ok: false, body: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of the drawer's repeat action: success plus the canvas id of the created task. */
|
||||
export interface RepeatOutcome {
|
||||
ok: boolean;
|
||||
newNodeId: string | null;
|
||||
}
|
||||
|
||||
export function useDrawerDetail(node: OrchNode | null) {
|
||||
const [detail, setDetail] = useState<unknown | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -311,8 +369,10 @@ export function useDrawerDetail(node: OrchNode | null) {
|
||||
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;
|
||||
const runAction = async (
|
||||
req: { url: string; init: RequestInit } | null
|
||||
): Promise<ActionOutcome> => {
|
||||
if (busy) return { ok: false, body: null };
|
||||
setBusy(true);
|
||||
try {
|
||||
return await performAction(req, setActionError, () => setErrorState(null));
|
||||
@@ -320,6 +380,8 @@ export function useDrawerDetail(node: OrchNode | null) {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const runBooleanAction = async (req: { url: string; init: RequestInit } | null) =>
|
||||
(await runAction(req)).ok;
|
||||
|
||||
return {
|
||||
detail,
|
||||
@@ -330,8 +392,13 @@ export function useDrawerDetail(node: OrchNode | null) {
|
||||
canApprove,
|
||||
canCancel,
|
||||
canRepeat: !!repeatReq && !busy,
|
||||
approve: () => runAction(route?.approveReq ?? null),
|
||||
cancel: () => runAction(route?.cancelReq ?? null),
|
||||
repeat: () => runAction(repeatReq),
|
||||
approve: () => runBooleanAction(route?.approveReq ?? null),
|
||||
cancel: () => runBooleanAction(route?.cancelReq ?? null),
|
||||
// Only the repeat reports a new node id: approve/cancel act on the task already open, so
|
||||
// there is nothing new to focus (and their responses can echo the SAME task's id back).
|
||||
repeat: async (): Promise<RepeatOutcome> => {
|
||||
const { ok, body } = await runAction(repeatReq);
|
||||
return { ok, newNodeId: ok && node ? newNodeIdFrom(node, body) : null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,40 @@ function safeJsonParse<T>(json: string | null | undefined, fallback: T): T {
|
||||
}
|
||||
|
||||
const STATE_EVENT_PREFIX = "state:";
|
||||
/** Event type written by `collectMemoryHits` (src/lib/a2a/taskExecution.ts). */
|
||||
const MEMORY_HITS_EVENT_TYPE = "memory_hits";
|
||||
const MEMORY_HIT_FIELDS = ["id", "key", "type", "snippet"] as const;
|
||||
|
||||
interface MemoryHit {
|
||||
id: string;
|
||||
key: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted `memory_hits` event's `data_json` into the hits the drawer renders.
|
||||
* Mirrors `DrawerMemory`'s validation in
|
||||
* `src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx`: `metadata` is
|
||||
* caller-supplied and unvalidated end to end, so what got persisted can be anything —
|
||||
* a bare string (`"boom"`, whose `.length` is truthy), an object, or an array carrying entries
|
||||
* with a non-string `key`/`type`/`snippet` (rendered as React children, so an object there
|
||||
* would throw "Objects are not valid as a React child" and take the whole drawer down).
|
||||
* Never throws: a malformed payload degrades to an empty list, so the route answers 200
|
||||
* with `metadata: {}` instead of 500.
|
||||
*/
|
||||
function parseMemoryHits(dataJson: string | null | undefined): MemoryHit[] {
|
||||
const raw = safeJsonParse<unknown>(dataJson, null);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter(
|
||||
(hit): hit is MemoryHit =>
|
||||
!!hit &&
|
||||
typeof hit === "object" &&
|
||||
MEMORY_HIT_FIELDS.every(
|
||||
(field) => typeof (hit as Record<string, unknown>)[field] === "string"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute the in-memory `A2ATask` shape (src/lib/a2a/taskManager.ts) from a persisted
|
||||
@@ -29,22 +63,32 @@ const STATE_EVENT_PREFIX = "state:";
|
||||
* state each event represents is recovered by stripping that prefix.
|
||||
*/
|
||||
function reconstituteHistoricalTask(row: A2ATaskHistoryRow) {
|
||||
const input = safeJsonParse<{ skill: string; messages: Array<{ role: string; content: string }> }>(
|
||||
row.input_json,
|
||||
{ skill: row.skill_id ?? "", messages: [] }
|
||||
);
|
||||
const input = safeJsonParse<{
|
||||
skill: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
}>(row.input_json, { skill: row.skill_id ?? "", messages: [] });
|
||||
const artifacts = safeJsonParse<unknown[]>(row.output_json, []);
|
||||
const events = listA2ATaskEvents(row.id).map((event) => {
|
||||
const data = safeJsonParse<{ message?: string } | null>(event.data_json, null);
|
||||
const state = event.event_type.startsWith(STATE_EVENT_PREFIX)
|
||||
? event.event_type.slice(STATE_EVENT_PREFIX.length)
|
||||
: row.state;
|
||||
return {
|
||||
timestamp: event.created_at,
|
||||
state,
|
||||
...(data?.message !== undefined ? { message: data.message } : {}),
|
||||
};
|
||||
});
|
||||
const eventRows = listA2ATaskEvents(row.id);
|
||||
|
||||
// `memory_hits` is observability, not a state transition — it is hydrated into `metadata`
|
||||
// (Fase 3, Task C1) and kept out of the timeline the drawer renders.
|
||||
const memoryHits = eventRows
|
||||
.filter((event) => event.event_type === MEMORY_HITS_EVENT_TYPE)
|
||||
.flatMap((event) => parseMemoryHits(event.data_json));
|
||||
|
||||
const events = eventRows
|
||||
.filter((event) => event.event_type !== MEMORY_HITS_EVENT_TYPE)
|
||||
.map((event) => {
|
||||
const data = safeJsonParse<{ message?: string } | null>(event.data_json, null);
|
||||
const state = event.event_type.startsWith(STATE_EVENT_PREFIX)
|
||||
? event.event_type.slice(STATE_EVENT_PREFIX.length)
|
||||
: row.state;
|
||||
return {
|
||||
timestamp: event.created_at,
|
||||
state,
|
||||
...(data?.message !== undefined ? { message: data.message } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -53,7 +97,7 @@ function reconstituteHistoricalTask(row: A2ATaskHistoryRow) {
|
||||
input,
|
||||
artifacts,
|
||||
events,
|
||||
metadata: {},
|
||||
metadata: memoryHits.length > 0 ? { memoryHits } : {},
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
expiresAt: row.updated_at,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { A2ATask, TaskArtifact } from "./taskManager";
|
||||
import { appendA2ATaskEvent } from "@/lib/db/a2aTasks";
|
||||
import { memoryManager } from "@/lib/memory/manager";
|
||||
import { logger } from "@omniroute/open-sse/utils/logger";
|
||||
|
||||
const log = logger("A2A_TASKS");
|
||||
|
||||
type TaskManagerLike = {
|
||||
updateTask: (
|
||||
@@ -41,8 +44,22 @@ export interface MemoryHitsDeps {
|
||||
limit?: number;
|
||||
}) => Promise<Array<{ id: string; key: string; type: string; content: string }>>;
|
||||
appendEvent?: (taskId: string, eventType: string, dataJson?: string) => void;
|
||||
/** Recall deadline override — tests inject a few ms instead of waiting {@link MEMORY_RECALL_TIMEOUT_MS}. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task C2 (Orchestration Canvas Fase 3, PR-C): deadline for the observability-only memory
|
||||
* recall. The recall runs BEFORE the skill handler, so an unbounded one delays the task
|
||||
* itself — the HTTP memory backend (`genericBackend`) alone defaults to a 30s timeout.
|
||||
* Overshooting the deadline degrades exactly like any other recall failure: empty hits,
|
||||
* task proceeds.
|
||||
*/
|
||||
export const MEMORY_RECALL_TIMEOUT_MS = 1500;
|
||||
|
||||
/** Internal marker so the catch below can tell a deadline apart from a backend error. */
|
||||
class MemoryRecallTimeoutError extends Error {}
|
||||
|
||||
/**
|
||||
* Collect the memories consulted for a task's last user message, as pure observability.
|
||||
*
|
||||
@@ -82,21 +99,37 @@ export async function collectMemoryHits(
|
||||
}
|
||||
if (!query || query.trim() === "") return [];
|
||||
|
||||
const timeoutMs = deps?.timeoutMs ?? MEMORY_RECALL_TIMEOUT_MS;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
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 });
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new MemoryRecallTimeoutError("memory recall deadline exceeded")),
|
||||
timeoutMs
|
||||
);
|
||||
});
|
||||
const results = await Promise.race([search({ query, apiKeyId, limit: 5 }), deadline]);
|
||||
return results.map((m) => ({
|
||||
id: m.id,
|
||||
key: m.key,
|
||||
type: m.type,
|
||||
snippet: m.content.slice(0, 200),
|
||||
}));
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (err instanceof MemoryRecallTimeoutError) {
|
||||
log.warn(
|
||||
`Memory recall for task ${task.id} exceeded ${timeoutMs}ms — continuing without hits`
|
||||
);
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
// Cleared on BOTH paths: a surviving timer holds the event loop open.
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface ConductorTaskDetail extends FleetTask {
|
||||
tests: unknown;
|
||||
council: unknown;
|
||||
created_at: string | null;
|
||||
/** Runner profile the task was pinned to (hub `requirements.cli`), `null` when unconstrained. */
|
||||
cli: string | null;
|
||||
/** Model the task was pinned to (hub `requirements.model`), `null` when unconstrained. */
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
// ============ Untrusted hub shapes (parse only what we read) ============
|
||||
@@ -74,6 +78,13 @@ const hubTaskSchema = z.object({
|
||||
tests: z.unknown().optional(),
|
||||
})
|
||||
.nullish(),
|
||||
// The hub echoes back the `requirements` object `createConductorTask` sends on creation.
|
||||
// `.catch(null)` keeps an unexpected shape from failing the WHOLE task parse — a single odd
|
||||
// requirement must not blank the fleet snapshot (the list parses with this same schema).
|
||||
requirements: z
|
||||
.object({ cli: z.string().nullish(), model: z.string().nullish() })
|
||||
.nullish()
|
||||
.catch(null),
|
||||
council: z.unknown().optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
@@ -159,13 +170,16 @@ export async function getFleetSnapshot(opts: HubProxyOptions = {}): Promise<Flee
|
||||
hubGet("/v1/tasks", opts),
|
||||
]);
|
||||
if (rawRunners === null || rawTasks === null) return { offline: true, runners: [], tasks: [] };
|
||||
const runners = z.array(hubRunnerSchema).parse(rawRunners).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.capabilities.name ?? "?",
|
||||
clis: (r.capabilities.clis ?? []).map((c) => c.profile),
|
||||
online: r.online !== false,
|
||||
draining: r.draining === true,
|
||||
}));
|
||||
const runners = z
|
||||
.array(hubRunnerSchema)
|
||||
.parse(rawRunners)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.capabilities.name ?? "?",
|
||||
clis: (r.capabilities.clis ?? []).map((c) => c.profile),
|
||||
online: r.online !== false,
|
||||
draining: r.draining === true,
|
||||
}));
|
||||
const tasks = z.array(hubTaskSchema).parse(rawTasks).map(toFleetTask);
|
||||
emitFleetTransitions(tasks);
|
||||
return { offline: false, runners, tasks };
|
||||
@@ -190,6 +204,8 @@ export async function getConductorTaskDetail(
|
||||
tests: t.manifest?.tests ?? null,
|
||||
council: t.council ?? null,
|
||||
created_at: t.created_at ?? null,
|
||||
cli: t.requirements?.cli ?? null,
|
||||
model: t.requirements?.model ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
68
task-c1-report.md
Normal file
68
task-c1-report.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Task C1 — History hidrata `memoryHits` (PR-C, Refs #12639)
|
||||
|
||||
**Commit:** `da8014442debd2eca7a326472ab704aa86aefba1`
|
||||
**Branch:** `feat/orch-fase3-c` · 2 arquivos, +164/-16
|
||||
|
||||
## O que mudou
|
||||
|
||||
### `src/app/api/a2a/tasks/[id]/route.ts`
|
||||
|
||||
- Novas constantes `MEMORY_HITS_EVENT_TYPE = "memory_hits"` e `MEMORY_HIT_FIELDS = ["id","key","type","snippet"]`, interface `MemoryHit`.
|
||||
- Nova função `parseMemoryHits(dataJson)`: `safeJsonParse` (try/catch já existente) → `Array.isArray` → filtro por item exigindo os 4 campos como `string`. Nunca lança; payload ruim vira lista vazia.
|
||||
- `reconstituteHistoricalTask`: `listA2ATaskEvents(row.id)` agora é lido uma vez em `eventRows`; os eventos `memory_hits` são extraídos para `memoryHits` e **filtrados fora** de `events`; `metadata` passou de `{}` fixo para `memoryHits.length > 0 ? { memoryHits } : {}`.
|
||||
|
||||
### `tests/unit/a2a-history-route.test.ts` (+104 linhas, 365 no total — teto 1200 ok)
|
||||
|
||||
7 testes novos:
|
||||
|
||||
1. hidrata `metadata.memoryHits` com 2 hits e o evento de memória NÃO aparece em `events` (timeline fica `["submitted","completed"]`).
|
||||
2. descarta entradas malformadas (`sem snippet`, `key` objeto, `null`, string, número) e mantém só a válida.
|
||||
3–7. tabela de payloads ruins (`{not-json`, string JSON pura, objeto JSON, array 100% inválido, array vazio) ⇒ HTTP **200**, `metadata` `{}`, e o evento de memória continua fora da timeline.
|
||||
|
||||
## Gates (saída real)
|
||||
|
||||
```
|
||||
$ node --import tsx/esm --test tests/unit/a2a-history-route.test.ts # ANTES do fix (C1.2)
|
||||
✔ 10 pré-existentes ✖ 7 novos (falha típica: metadata {} esperado ≠ { memoryHits: [...] } / timeline extra 'completed')
|
||||
|
||||
$ node --import tsx/esm --test tests/unit/a2a-history-route.test.ts # DEPOIS (C1.4)
|
||||
ℹ tests 17
|
||||
ℹ pass 17
|
||||
ℹ fail 0
|
||||
|
||||
$ node --import tsx/esm --test tests/unit/db-a2a-tasks.test.ts
|
||||
ℹ tests 7
|
||||
ℹ pass 7
|
||||
ℹ fail 0
|
||||
|
||||
$ npm run typecheck:core
|
||||
npm notice run tsc --pretty false -p tsconfig.typecheck-core.json
|
||||
(sem erros, exit 0)
|
||||
|
||||
$ npx eslint 'src/app/api/a2a/tasks/[id]/route.ts' tests/unit/a2a-history-route.test.ts
|
||||
eslint exit=0
|
||||
|
||||
$ git commit (husky ativo, sem --no-verify)
|
||||
lint-staged: prettier + eslint --fix ✔
|
||||
[docs-sync] PASS
|
||||
[t11:any-budget] PASS
|
||||
[tracked-artifacts] OK
|
||||
```
|
||||
|
||||
## Divergências entre plano e código real
|
||||
|
||||
Nenhuma divergência material. Confirmado no código:
|
||||
|
||||
- `event_type` exato = `"memory_hits"` (`src/lib/a2a/taskExecution.ts:143`, `appendA2ATaskEvent(task.id, "memory_hits", JSON.stringify(hits))`).
|
||||
- Shape de `listA2ATaskEvents` = `{ event_type, data_json, created_at }` (`src/lib/db/a2aTasks.ts`, `A2ATaskHistoryEventRow`) — sem `id` exposto, ordenação `ORDER BY id ASC`.
|
||||
- `STATE_EVENT_PREFIX = "state:"` continua governando só os eventos de transição; o `memory_hits` caía no `else` e herdava `row.state`, o que o fazia aparecer como uma transição duplicada na timeline — daí o filtro ser obrigatório, não cosmético.
|
||||
|
||||
Observações (não são divergência):
|
||||
|
||||
- A validação implementada é espelho exata da que já existe no drawer (`DrawerMemory` / `MEMORY_HIT_FIELDS` em `OrchestrationDrawer.tsx:275`), incluindo o motivo dos 4 campos: `type`/`key`/`snippet` são renderizados como React children.
|
||||
- Zod não foi usado: esta rota não valida body (é GET sem query schema) e a leitura é de JSON persistido, coberto pelo `safeJsonParse` já existente no arquivo. Hard Rule #12 intocada — o catch da rota continua usando `sanitizeErrorMessage`.
|
||||
- Hard Rule #5 respeitada: nenhum SQL na rota; tudo por `src/lib/db/a2aTasks.ts`.
|
||||
|
||||
## Trabalho paralelo
|
||||
|
||||
`src/lib/a2a/taskExecution.ts` e `tests/unit/a2a-memory-hits.test.ts` (Task C2) aparecem como `M` no `git status` — **não foram tocados nem adicionados** por esta task. O commit contém exclusivamente os 2 arquivos da C1.
|
||||
104
task-c2-report.md
Normal file
104
task-c2-report.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Task C2 — timeout no recall de memória (PR-C, Fase 3, Refs #12639)
|
||||
|
||||
Commit: `3ef0aef15be50bf458d3f54078e4c0107533f4b2` — `fix(a2a): bound the memory recall with its own deadline`
|
||||
Branch: `feat/orch-fase3-c`. Arquivos commitados (só estes dois):
|
||||
|
||||
- `src/lib/a2a/taskExecution.ts`
|
||||
- `tests/unit/a2a-memory-hits.test.ts`
|
||||
|
||||
## O que mudou
|
||||
|
||||
`src/lib/a2a/taskExecution.ts`:
|
||||
|
||||
- `export const MEMORY_RECALL_TIMEOUT_MS = 1500;`
|
||||
- `MemoryHitsDeps` ganhou `timeoutMs?: number` (default `MEMORY_RECALL_TIMEOUT_MS`).
|
||||
- `class MemoryRecallTimeoutError extends Error` (marcador interno, não exportado) só para o
|
||||
`catch` distinguir deadline de erro de backend.
|
||||
- `collectMemoryHits` roda `Promise.race([search(...), deadline])`; estouro ⇒ `log.warn` +
|
||||
`[]`. O contrato best-effort é idêntico ao de hoje: nada propaga, a task segue.
|
||||
- `finally { if (timer) clearTimeout(timer); }` — limpo nos DOIS caminhos.
|
||||
- `logger("A2A_TASKS")` importado de `@omniroute/open-sse/utils/logger` (mesmo canal já usado
|
||||
por `src/lib/a2a/taskManager.ts`). O log não contém `err.message`/`err.stack` — só o id da
|
||||
task e o deadline (Hard Rule #12; nada disso vai para resposta nenhuma).
|
||||
|
||||
`tests/unit/a2a-memory-hits.test.ts` (+5 testes, node:test, sem SQLite/deps reais):
|
||||
|
||||
1. `MEMORY_RECALL_TIMEOUT_MS` é 1500.
|
||||
2. `search` que nunca resolve ⇒ `[]` (com asserção de que voltou em <1s, provando que o
|
||||
deadline injetado é o que valeu).
|
||||
3. `search` que resolve dentro do prazo ⇒ hits normais.
|
||||
4. Nenhum timer fica pendurado nos dois caminhos.
|
||||
5. `executeA2ATaskWithState` completa a task normalmente quando o recall estoura.
|
||||
|
||||
## Por que o teste é rápido
|
||||
|
||||
Deadline injetável via `MemoryHitsDeps.timeoutMs` (o arquivo de teste já é 100% construído
|
||||
sobre esse DI seam — `search`/`appendEvent` fakes, zero fake timers, zero mock de módulo), então
|
||||
o caminho de timeout usa `timeoutMs: 5`. Suíte inteira em ~0,9 s de relógio.
|
||||
|
||||
## Decisão de projeto que mudei no meio (importante)
|
||||
|
||||
A primeira implementação tinha `timer.unref?.()` além do `clearTimeout`. Mutation-check
|
||||
(remover o `clearTimeout` e rodar) mostrou que **o teste do timer pendurado passava mesmo
|
||||
assim**: `process.getActiveResourcesInfo()` não reporta timer `unref`ado, ou seja o `unref`
|
||||
mascarava exatamente o defeito que o teste existe para pegar. Tirei o `unref` — a garantia
|
||||
real é o `clearTimeout` no `finally` — e refiz o mutation-check:
|
||||
|
||||
```
|
||||
--- MUTATED (clearTimeout removed) ---
|
||||
✖ collectMemoryHits leaves no pending timer behind on either path (20.667659ms)
|
||||
ℹ pass 15
|
||||
ℹ fail 1
|
||||
--- RESTORED ---
|
||||
✔ collectMemoryHits leaves no pending timer behind on either path (9.073282ms)
|
||||
ℹ pass 16
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
## Gates (saída real)
|
||||
|
||||
C2.2 — vermelho antes da implementação:
|
||||
|
||||
```
|
||||
SyntaxError: The requested module '../../src/lib/a2a/taskExecution.ts' does not provide an
|
||||
export named 'MEMORY_RECALL_TIMEOUT_MS'
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
```
|
||||
|
||||
C2.4 — `node --import tsx/esm --test tests/unit/a2a-memory-hits.test.ts`:
|
||||
|
||||
```
|
||||
ℹ tests 16
|
||||
ℹ pass 16
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
Todas as suítes a2a (`node --import tsx/esm --test $(ls tests/unit/*a2a*.test.ts)`):
|
||||
|
||||
```
|
||||
ℹ tests 106
|
||||
ℹ suites 2
|
||||
ℹ pass 106
|
||||
ℹ fail 0
|
||||
ℹ duration_ms 12146.220038
|
||||
```
|
||||
|
||||
`npm run typecheck:core` — sem saída de erro (só as linhas `npm notice`), exit 0.
|
||||
`npx eslint src/lib/a2a/taskExecution.ts tests/unit/a2a-memory-hits.test.ts` — zero achados.
|
||||
`npx prettier --write` nos dois arquivos (o de teste foi reformatado; re-rodei a suíte depois:
|
||||
16/16). Hooks do husky rodaram no commit (`any-budget` PASS, `tracked-artifacts` OK) — sem
|
||||
`--no-verify`.
|
||||
|
||||
## Divergências plano × código
|
||||
|
||||
- **Nenhuma no contrato.** `MemoryHit`, `MemoryHitsDeps`, a assinatura de `collectMemoryHits` e
|
||||
o kill-switch `OMNIROUTE_A2A_MEMORY_HITS === "0"` estão exatamente como o plano copiou.
|
||||
- Detalhe não previsto: o plano dizia "estouro ⇒ `[]` + log" sem dizer qual logger. Usei o
|
||||
mesmo `logger("A2A_TASKS")` de `taskManager.ts` (o `taskExecution.ts` não tinha logger algum).
|
||||
- O `unref` sugerido pela minha leitura inicial de "sem handle pendurado segurando o processo do
|
||||
runner" foi descartado por mascarar o teste (ver acima); o requisito continua atendido pelo
|
||||
`clearTimeout` no `finally`, agora com asserção que tem dentes.
|
||||
- Trabalho paralelo (C1): `git status` mostrava apenas `task-c1-report.md` untracked; não toquei
|
||||
nele nem em `src/app/api/a2a/tasks/[id]/route.ts` / `tests/unit/a2a-history-route.test.ts`.
|
||||
`git add` foi feito nomeando só os meus dois arquivos.
|
||||
159
task-c3-report.md
Normal file
159
task-c3-report.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Task C3 — `requirements` no repeat do Conductor + foco na task nova + teste de auth
|
||||
|
||||
Branch `feat/orch-fase3-c` · commit **`a7d430fe9ee3ae1fd1d86ff0083318fc1e96005b`**
|
||||
(`fix(dashboard): carry conductor requirements and focus the repeated task`, corpo com `Refs #12639`).
|
||||
|
||||
## O que mudou, por item
|
||||
|
||||
### 1. `src/lib/conductor/hubProxy.ts` — `requirements` no schema de detalhe
|
||||
|
||||
- `hubTaskSchema` ganhou
|
||||
`requirements: z.object({ cli: z.string().nullish(), model: z.string().nullish() }).nullish().catch(null)`.
|
||||
O `.catch(null)` é deliberado: o MESMO schema parseia a **lista** em `getFleetSnapshot`
|
||||
(`z.array(hubTaskSchema).parse(rawTasks)`) — sem ele, um `requirements` de formato inesperado
|
||||
numa única task faria o `parse` inteiro lançar e o snapshot cair para `{offline: true}`.
|
||||
- `ConductorTaskDetail` ganhou `cli: string | null` e `model: string | null` (com JSDoc);
|
||||
`getConductorTaskDetail` preenche com `t.requirements?.cli ?? null` / `?? null`.
|
||||
- Origem do shape: é exatamente o objeto que `createConductorTask` (mesmo arquivo, linhas ~238-241)
|
||||
envia para o hub (`body.requirements = { cli?, model? }`) — não foi inventado.
|
||||
|
||||
### 2. `drawer/useDrawerDetail.ts`
|
||||
|
||||
- `repeatReqForConductor` agora envia `cli: d.cli ?? undefined` / `model: d.model ?? undefined`.
|
||||
**`undefined`, nunca `null`** — o Zod real da rota (`src/app/api/conductor/tasks/route.ts`) tem
|
||||
`cli: z.string().optional()` / `model: z.string().optional()`, e `null` daria 400. `JSON.stringify`
|
||||
descarta as chaves `undefined`, então o corpo sai idêntico ao de hoje quando não há requirement.
|
||||
Os dois campos são independentes (um pode existir sem o outro) — há teste para esse caso.
|
||||
- `jsonRpcErrorCode` deixou de ler `res.json()` e passou a receber o **corpo já parseado**; novo
|
||||
`readJsonBody(res)` lê o corpo UMA vez (um body só pode ser consumido uma vez, e agora o id da
|
||||
task nova sai do mesmo corpo). Body não-JSON/vazio ⇒ `null`, sem virar falha (mantém o contrato
|
||||
anterior: "corpo ilegível não é evidência de falha, o status já valeu").
|
||||
- `performAction` passou a devolver `{ ok, body }` em vez de `boolean`.
|
||||
- Novo `export function newNodeIdFrom(node, body): string | null` — extrai o id da task criada e
|
||||
devolve **já no formato do canvas** (com prefixo), porque `setParams({ node })` casa contra
|
||||
`snapshot.nodes.find(n => n.id === nodeId)` e os ids em `mergeSnapshot.ts` são prefixados:
|
||||
- conductor: `body.task_id` → `conductor:task:<id>`
|
||||
- cloud-agent: `body.data.id` → `cloud-agent:<id>`
|
||||
- a2a: `body.result.task.id` → `a2a:<id>` (envelope real de `/a2a` `message/send` v0.3)
|
||||
Leitura defensiva: id ausente/vazio/não-string ⇒ `null` (nunca um prefixo pelado), corpo não-objeto
|
||||
⇒ `null`, nunca lança.
|
||||
- No hook: `approve`/`cancel` continuam `Promise<boolean>` (via `runBooleanAction`) — de propósito:
|
||||
eles agem sobre a task já aberta e a resposta do cloud-agent pode ecoar o id da MESMA task, o que
|
||||
faria o painel "navegar" para onde já está. Só `repeat` devolve
|
||||
`RepeatOutcome { ok, newNodeId }` (interface exportada).
|
||||
|
||||
### 3. `drawer/OrchestrationDrawer.tsx`
|
||||
|
||||
- `RepeatButton` consome `RepeatOutcome` e chama `onActionDone(newNodeId ?? undefined)`.
|
||||
- `onActionDone` virou `(newNodeId?: string) => void` no `RepeatButton`, `DrawerActions` e no
|
||||
componente exportado. `DrawerActions.run()` (approve/cancel) segue chamando `onActionDone()` sem
|
||||
argumento.
|
||||
|
||||
### 4. `OrchestrationPageClient.tsx`
|
||||
|
||||
- Novo `onActionDone` memoizado: `refetch()` e, se veio id, `setParams({ node: newNodeId })`.
|
||||
A aba History renderiza o próprio drawer (`HistoryTab.tsx`) e **não** foi tocada — seu
|
||||
`onActionDone={() => …}` ignora o argumento extra, então o comportamento lá continua idêntico
|
||||
(re-amostra `nowMs`, não navega), como o plano pede.
|
||||
|
||||
### 5. Testes
|
||||
|
||||
- `tests/unit/conductor-routes-auth.test.ts`: `src/app/api/conductor/tasks/route.ts` entrou no array
|
||||
`ROUTES`; o regex do proxy ganhou a alternativa `createConductorTask\(`.
|
||||
- `tests/unit/conductor-create-route.test.ts`: removido o teste de fonte duplicado
|
||||
(`route: requireManagementAuth antes de criar a task no hub`), substituído por um comentário
|
||||
apontando para o array. `fs`/`path` continuam usados pelo resto do arquivo.
|
||||
- `tests/unit/ui/orchestrationDrawerRepeat.test.tsx` (786 → 971 linhas, cap 1200 — não precisou
|
||||
dividir): 3 describes novos, 6 testes:
|
||||
- `repeatReqFor` conductor com `cli`/`model`; com ambos `null` (corpo idêntico ao atual e sem as
|
||||
strings `cli`/`model`); com só `cli` setado.
|
||||
- `newNodeIdFrom` nas três fontes (prefixado), corpos malformados ⇒ `null`, fonte sem contrato ⇒ `null`.
|
||||
- drawer: repeat de conductor faz POST com `cli`/`model` e chama `onActionDone` exatamente uma vez
|
||||
com `"conductor:task:t_new"`, com o toast `repeatDone`.
|
||||
|
||||
## TDD — evidência
|
||||
|
||||
**C3.2 (vermelho)** `npx vitest run tests/unit/ui/orchestrationDrawerRepeat.test.tsx`:
|
||||
|
||||
```
|
||||
Test Files 1 failed (1)
|
||||
Tests 6 failed | 29 passed (35)
|
||||
```
|
||||
|
||||
(falhas: `newNodeIdFrom is not a function` ×3, corpo sem `cli`/`model` ×3 — a última:
|
||||
`expected { …(4) } to match object { cli: 'claude', model: 'sonnet' }`)
|
||||
|
||||
`node --import tsx/esm --test tests/unit/conductor-routes-auth.test.ts tests/unit/conductor-create-route.test.ts` já passou nesse ponto
|
||||
(`ℹ pass 11 / fail 0`) — **divergência menor com o plano**: a mudança do array `ROUTES` é ampliação
|
||||
de cobertura + de-duplicação, não a prova de um defeito, então ela não tinha como "falhar antes".
|
||||
A rota já chamava `requireManagementAuth` corretamente.
|
||||
|
||||
**C3.4 (verde)**:
|
||||
|
||||
```
|
||||
$ npx vitest run tests/unit/ui/orchestrationDrawerRepeat.test.tsx
|
||||
Test Files 1 passed (1)
|
||||
Tests 35 passed (35)
|
||||
|
||||
$ node --import tsx/esm --test tests/unit/conductor-routes-auth.test.ts tests/unit/conductor-create-route.test.ts tests/unit/client-bundle-no-server-only-10692.test.ts
|
||||
✔ src/app/api/conductor/tasks/route.ts: requireManagementAuth antes do proxy ao hub (4.175999ms)
|
||||
ℹ tests 12
|
||||
ℹ pass 12
|
||||
ℹ fail 0
|
||||
|
||||
$ npx vitest run tests/unit/ui/
|
||||
Test Files 210 passed (210)
|
||||
Tests 1200 passed (1200)
|
||||
Duration 179.85s
|
||||
|
||||
$ node --import tsx/esm --test tests/unit/conductor-hub-proxy.test.ts tests/unit/conductor-fleet-mirror.test.ts tests/unit/conductor-fleet-route.test.ts tests/unit/conductor-delegate.test.ts
|
||||
ℹ tests 17
|
||||
ℹ pass 17
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
$ npm run typecheck:core
|
||||
EXIT=0 (sem saída de erro)
|
||||
|
||||
$ npm run check:dashboard-typecheck
|
||||
[dashboard-typecheck] OK — 206 pre-existing error(s), all within frozen baseline.
|
||||
EXIT=0
|
||||
|
||||
$ npx eslint <os 7 arquivos alterados>
|
||||
EXIT=0 (zero erros, zero warnings)
|
||||
|
||||
$ npm run check:file-size
|
||||
[file-size] OK — 136 arquivos congelados, cap 1200 para novos (4588 arquivos verificados)
|
||||
[test-file-size] OK — 40 test files congelados, testCap 1200 para novos (5495 test files verificados)
|
||||
```
|
||||
|
||||
Hooks do husky rodaram no commit (lint-staged + docs-sync + any-budget + tracked-artifacts, todos PASS);
|
||||
nada de `--no-verify`.
|
||||
|
||||
## Divergências entre plano e código real
|
||||
|
||||
1. **`newNodeIdFrom` prefixado (não previsto explicitamente no plano).** O plano diz "extrair o id da
|
||||
task nova (`task_id` no Conductor, `data.id` no Cloud Agent) e chamar `onActionDone` com ele". O id
|
||||
CRU não serve para `setParams({ node })`: o canvas indexa por id prefixado
|
||||
(`conductor:task:<id>`, `cloud-agent:<id>`, `a2a:<id>` — `mergeSnapshot.ts` / `routeFor`). Segui o
|
||||
código: `newNodeIdFrom` já devolve o id de canvas. O plano não citava o a2a; incluí porque o
|
||||
`repeat` do drawer também atende a2a e o envelope (`result.task.id`) é verificável em
|
||||
`src/app/a2a/route.ts:250`.
|
||||
2. **`performAction` "devolve o corpo parseado" — só o `repeat` propaga o id.** `approve`/`cancel`
|
||||
ficaram `Promise<boolean>` de propósito (ver item 2 acima): o POST de approve/cancel do cloud-agent
|
||||
pode devolver o id da MESMA task, e navegar para ela seria ruído. Cumprimento do espírito do plano
|
||||
("o drawer extrai o id da task nova") sem o efeito colateral.
|
||||
3. **`.catch(null)` no `requirements`.** O plano não pedia; adicionei porque `hubTaskSchema` também
|
||||
parseia a LISTA de tasks, e um campo novo estrito ali seria um vetor de "snapshot vira offline por
|
||||
causa de um campo estranho de uma task".
|
||||
4. **A mudança em `conductor-routes-auth.test.ts` não é um teste que falha antes** (explicado acima).
|
||||
5. `ConductorTaskDetail` estende `FleetTask`; `cli`/`model` foram adicionados só no detalhe (não em
|
||||
`FleetTask`/`toFleetTask`), porque o repeat lê o DETALHE carregado, não o nó.
|
||||
|
||||
## Fora de escopo (não feito, é da Task C4)
|
||||
|
||||
`changelog.d/fixes/orchestration-followups.md`, o comentário na issue #12639 e a bateria completa
|
||||
(lint total, test:vitest, check:cycles, i18n) continuam pendentes na C4.
|
||||
@@ -259,3 +259,107 @@ test("GET /api/a2a/tasks/[id] still 404s when the task is absent from both memor
|
||||
});
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
/**
|
||||
* Task C1 (Orchestration Canvas Fase 3, PR-C): the history fallback hydrates
|
||||
* `metadata.memoryHits` from the persisted `memory_hits` event that `executeA2ATaskWithState`
|
||||
* writes (src/lib/a2a/taskExecution.ts), so the drawer's "Memory used" section survives a task
|
||||
* leaving the in-memory TTL window. `data_json` is persisted JSON — every read is defensive:
|
||||
* a malformed payload degrades to `metadata: {}` and NEVER a 500.
|
||||
*/
|
||||
test("GET /api/a2a/tasks/[id] hydrates metadata.memoryHits from the persisted memory_hits event", async () => {
|
||||
seedRow({ id: "history-memory" });
|
||||
a2aTasksDb.appendA2ATaskEvent("history-memory", "state:submitted");
|
||||
a2aTasksDb.appendA2ATaskEvent(
|
||||
"history-memory",
|
||||
"memory_hits",
|
||||
JSON.stringify([
|
||||
{ id: "m1", key: "user.name", type: "factual", snippet: "Diego" },
|
||||
{ id: "m2", key: "user.tz", type: "factual", snippet: "UTC-3" },
|
||||
])
|
||||
);
|
||||
a2aTasksDb.appendA2ATaskEvent("history-memory", "state:completed");
|
||||
|
||||
const res = await detailRoute.GET(
|
||||
new Request("http://localhost/api/a2a/tasks/history-memory", {
|
||||
headers: AUTH_HEADERS,
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: "history-memory" }) }
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as {
|
||||
task: {
|
||||
metadata: { memoryHits?: unknown };
|
||||
events: Array<{ state: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
assert.deepEqual(body.task.metadata.memoryHits, [
|
||||
{ id: "m1", key: "user.name", type: "factual", snippet: "Diego" },
|
||||
{ id: "m2", key: "user.tz", type: "factual", snippet: "UTC-3" },
|
||||
]);
|
||||
// The memory event is not a state transition — it must never reach the timeline.
|
||||
assert.equal(body.task.events.length, 2);
|
||||
assert.deepEqual(
|
||||
body.task.events.map((e) => e.state),
|
||||
["submitted", "completed"]
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/a2a/tasks/[id] drops malformed memoryHits entries and keeps the valid ones", async () => {
|
||||
seedRow({ id: "history-memory-partial" });
|
||||
a2aTasksDb.appendA2ATaskEvent(
|
||||
"history-memory-partial",
|
||||
"memory_hits",
|
||||
JSON.stringify([
|
||||
{ id: "ok", key: "k", type: "t", snippet: "s" },
|
||||
{ id: "no-snippet", key: "k", type: "t" },
|
||||
{ id: "object-key", key: { a: 1 }, type: "t", snippet: "s" },
|
||||
null,
|
||||
"boom",
|
||||
42,
|
||||
])
|
||||
);
|
||||
|
||||
const res = await detailRoute.GET(
|
||||
new Request("http://localhost/api/a2a/tasks/history-memory-partial", {
|
||||
headers: AUTH_HEADERS,
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: "history-memory-partial" }) }
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { task: { metadata: { memoryHits?: unknown } } };
|
||||
assert.deepEqual(body.task.metadata.memoryHits, [
|
||||
{ id: "ok", key: "k", type: "t", snippet: "s" },
|
||||
]);
|
||||
});
|
||||
|
||||
for (const [label, dataJson] of [
|
||||
["unparseable JSON", "{not-json"],
|
||||
["a bare JSON string", JSON.stringify("boom")],
|
||||
["a JSON object instead of an array", JSON.stringify({ id: "m1" })],
|
||||
["an array whose every entry is malformed", JSON.stringify([{ id: "m1" }, null, 7])],
|
||||
["an empty array", JSON.stringify([])],
|
||||
] as const) {
|
||||
test(`GET /api/a2a/tasks/[id] answers 200 with metadata {} when memory_hits carries ${label}`, async () => {
|
||||
const id = `history-memory-${label.replace(/\W+/g, "-")}`;
|
||||
seedRow({ id });
|
||||
a2aTasksDb.appendA2ATaskEvent(id, "state:submitted");
|
||||
a2aTasksDb.appendA2ATaskEvent(id, "memory_hits", dataJson);
|
||||
|
||||
const res = await detailRoute.GET(
|
||||
new Request(`http://localhost/api/a2a/tasks/${id}`, { headers: AUTH_HEADERS }) as never,
|
||||
{ params: Promise.resolve({ id }) }
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as {
|
||||
task: { metadata: Record<string, unknown>; events: Array<{ state: string }> };
|
||||
};
|
||||
assert.deepEqual(body.task.metadata, {});
|
||||
// Even a malformed memory event stays out of the timeline.
|
||||
assert.deepEqual(
|
||||
body.task.events.map((e) => e.state),
|
||||
["submitted"]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
collectMemoryHits,
|
||||
executeA2ATaskWithState,
|
||||
MEMORY_RECALL_TIMEOUT_MS,
|
||||
type MemoryHit,
|
||||
type MemoryHitsDeps,
|
||||
} from "../../src/lib/a2a/taskExecution.ts";
|
||||
@@ -329,3 +330,87 @@ test("executeA2ATaskWithState never leaks memoryHits into task.input.metadata or
|
||||
tm.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Task C2 (Orchestration Canvas Fase 3, PR-C): the recall is best-effort, so it must also be
|
||||
* BOUNDED. Without a deadline a slow memory backend (the HTTP `genericBackend` defaults to a
|
||||
* 30s timeout) delays the start of every A2A task. The deadline is injectable through
|
||||
* `MemoryHitsDeps.timeoutMs` so these tests cost milliseconds, not 1.5s of wall clock.
|
||||
*/
|
||||
test("MEMORY_RECALL_TIMEOUT_MS is the 1.5s default deadline", () => {
|
||||
assert.equal(MEMORY_RECALL_TIMEOUT_MS, 1500);
|
||||
});
|
||||
|
||||
test("collectMemoryHits returns [] when search never resolves (deadline hit)", async () => {
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: () => new Promise(() => {}), // never settles
|
||||
timeoutMs: 5,
|
||||
};
|
||||
|
||||
const task = makeTask();
|
||||
const started = Date.now();
|
||||
const hits = await collectMemoryHits(task, deps);
|
||||
|
||||
assert.deepEqual(hits, []);
|
||||
assert.ok(Date.now() - started < 1000, "gave up on the injected deadline, not the default");
|
||||
});
|
||||
|
||||
test("collectMemoryHits returns the hits when search resolves inside the deadline", async () => {
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
return [{ id: "m1", key: "k1", type: "factual", content: "hello" }];
|
||||
},
|
||||
timeoutMs: 1000,
|
||||
};
|
||||
|
||||
const hits = await collectMemoryHits(makeTask(), deps);
|
||||
|
||||
assert.deepEqual(hits, [{ id: "m1", key: "k1", type: "factual", snippet: "hello" }]);
|
||||
});
|
||||
|
||||
test("collectMemoryHits leaves no pending timer behind on either path", async () => {
|
||||
const countTimers = () => process.getActiveResourcesInfo().filter((r) => r === "Timeout").length;
|
||||
|
||||
const before = countTimers();
|
||||
|
||||
// Success path with a long deadline: the timer must be cleared, not left ticking.
|
||||
await collectMemoryHits(makeTask(), {
|
||||
search: async () => [{ id: "m1", key: "k1", type: "factual", content: "hello" }],
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
assert.equal(countTimers(), before, "success path cleared its deadline timer");
|
||||
|
||||
// Timeout path: the timer has fired, so nothing may stay registered either.
|
||||
await collectMemoryHits(makeTask(), {
|
||||
search: () => new Promise(() => {}),
|
||||
timeoutMs: 5,
|
||||
});
|
||||
assert.equal(countTimers(), before, "timeout path left no timer registered");
|
||||
});
|
||||
|
||||
test("executeA2ATaskWithState completes the task normally when memory recall times out", async () => {
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: () => new Promise(() => {}),
|
||||
timeoutMs: 5,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -57,21 +57,8 @@ test.after(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
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)"
|
||||
);
|
||||
});
|
||||
// A prova de fonte "requireManagementAuth antes do proxy ao hub" desta rota vive no array
|
||||
// `ROUTES` de `conductor-routes-auth.test.ts` (uma única implementação para as quatro rotas).
|
||||
|
||||
test("POST /api/conductor/tasks: body válido + hub ok → 201 {task_id}", async () => {
|
||||
process.env.CONDUCTOR_HUB_URL = await fakeHub({
|
||||
|
||||
@@ -8,6 +8,9 @@ const ROUTES = [
|
||||
"src/app/api/conductor/fleet/route.ts",
|
||||
"src/app/api/conductor/tasks/[id]/route.ts",
|
||||
"src/app/api/conductor/tasks/[id]/cancel/route.ts",
|
||||
// Rota de criação (repeat do drawer): coberta aqui pelo mesmo padrão, em vez de
|
||||
// duplicar o teste de fonte dentro de `conductor-create-route.test.ts`.
|
||||
"src/app/api/conductor/tasks/route.ts",
|
||||
];
|
||||
|
||||
for (const route of ROUTES) {
|
||||
@@ -16,8 +19,13 @@ for (const route of ROUTES) {
|
||||
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.search(/getFleetSnapshot\(|getConductorTaskDetail\(|cancelConductorTask\(/);
|
||||
const proxyAt = src.search(
|
||||
/getFleetSnapshot\(|getConductorTaskDetail\(|cancelConductorTask\(|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)");
|
||||
assert.ok(
|
||||
!src.includes("CONDUCTOR_HUB_TOKEN"),
|
||||
"token nunca manuseado na rota (vive no hubProxy)"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ vi.mock("next-intl", () => ({
|
||||
}));
|
||||
|
||||
import { OrchestrationDrawer } from "@/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer";
|
||||
import { repeatReqFor } from "@/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail";
|
||||
import {
|
||||
newNodeIdFrom,
|
||||
repeatReqFor,
|
||||
} from "@/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail";
|
||||
|
||||
function render(el: React.ReactElement) {
|
||||
const c = document.createElement("div");
|
||||
@@ -784,3 +787,185 @@ describe("OrchestrationDrawer repeat action (two-click confirm)", () => {
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("repeatReqFor conductor requirements (cli/model)", () => {
|
||||
const node = {
|
||||
id: "conductor:task:1",
|
||||
kind: "work",
|
||||
source: "conductor",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
|
||||
it("carries cli/model when the hub detail exposes them (a repeat must land on the same runner profile/model)", () => {
|
||||
const detail = {
|
||||
repo: "https://github.com/x/y",
|
||||
prompt: "fix the bug",
|
||||
base_ref: "main",
|
||||
mode: "auto",
|
||||
cli: "claude",
|
||||
model: "sonnet",
|
||||
};
|
||||
const req = repeatReqFor(node as never, detail);
|
||||
expect(JSON.parse(String(req?.init.body))).toEqual({
|
||||
repoUrl: "https://github.com/x/y",
|
||||
prompt: "fix the bug",
|
||||
baseRef: "main",
|
||||
mode: "auto",
|
||||
cli: "claude",
|
||||
model: "sonnet",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits cli/model entirely when the detail has them null (the route's Zod treats both as optional strings — `null` would 400)", () => {
|
||||
const detail = {
|
||||
repo: "https://github.com/x/y",
|
||||
prompt: "fix the bug",
|
||||
base_ref: "main",
|
||||
mode: "auto",
|
||||
cli: null,
|
||||
model: null,
|
||||
};
|
||||
const body = String(repeatReqFor(node as never, detail)?.init.body);
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
repoUrl: "https://github.com/x/y",
|
||||
prompt: "fix the bug",
|
||||
baseRef: "main",
|
||||
mode: "auto",
|
||||
});
|
||||
expect(body).not.toContain("cli");
|
||||
expect(body).not.toContain("model");
|
||||
});
|
||||
|
||||
it("carries only the field that is present (cli set, model null — they are independent)", () => {
|
||||
const detail = {
|
||||
repo: "https://github.com/x/y",
|
||||
prompt: "fix the bug",
|
||||
base_ref: null,
|
||||
mode: "solo",
|
||||
cli: "codex",
|
||||
model: null,
|
||||
};
|
||||
expect(JSON.parse(String(repeatReqFor(node as never, detail)?.init.body))).toEqual({
|
||||
repoUrl: "https://github.com/x/y",
|
||||
prompt: "fix the bug",
|
||||
mode: "solo",
|
||||
cli: "codex",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("newNodeIdFrom", () => {
|
||||
it("maps each source's creation response to the CANVAS node id (prefixed), never the raw id", () => {
|
||||
const cond = { id: "conductor:task:1", source: "conductor" };
|
||||
expect(newNodeIdFrom(cond as never, { task_id: "t2" })).toBe("conductor:task:t2");
|
||||
const ca = { id: "cloud-agent:1", source: "cloud-agent" };
|
||||
expect(newNodeIdFrom(ca as never, { data: { id: "c2" } })).toBe("cloud-agent:c2");
|
||||
const a2a = { id: "a2a:1", source: "a2a" };
|
||||
expect(newNodeIdFrom(a2a as never, { result: { task: { id: "n2" } } })).toBe("a2a:n2");
|
||||
});
|
||||
|
||||
it("returns null for a body that does not carry a usable id (never throws, never yields a bare prefix)", () => {
|
||||
const cond = { id: "conductor:task:1", source: "conductor" };
|
||||
expect(newNodeIdFrom(cond as never, null)).toBeNull();
|
||||
expect(newNodeIdFrom(cond as never, {})).toBeNull();
|
||||
expect(newNodeIdFrom(cond as never, { task_id: "" })).toBeNull();
|
||||
expect(newNodeIdFrom(cond as never, { task_id: 7 })).toBeNull();
|
||||
expect(newNodeIdFrom(cond as never, "nope")).toBeNull();
|
||||
const ca = { id: "cloud-agent:1", source: "cloud-agent" };
|
||||
expect(newNodeIdFrom(ca as never, { data: {} })).toBeNull();
|
||||
const a2a = { id: "a2a:1", source: "a2a" };
|
||||
expect(newNodeIdFrom(a2a as never, { result: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a node whose source has no repeat contract", () => {
|
||||
const overflow = { id: "overflow:1", kind: "overflow" };
|
||||
expect(newNodeIdFrom(overflow as never, { task_id: "t2" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrchestrationDrawer repeat focuses the newly created task", () => {
|
||||
const CONDUCTOR_DETAIL = {
|
||||
id: "1",
|
||||
status: "succeeded",
|
||||
mode: "solo",
|
||||
repo: "https://github.com/x/y",
|
||||
runner: null,
|
||||
summary: null,
|
||||
branch: null,
|
||||
error: null,
|
||||
updated_at: null,
|
||||
prompt: "fix the bug",
|
||||
base_ref: "main",
|
||||
tests: null,
|
||||
council: null,
|
||||
created_at: null,
|
||||
cli: "claude",
|
||||
model: "sonnet",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function repeatOnce(onActionDone: (id?: string) => void) {
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: () => Promise.resolve({ task_id: "t_new" }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(CONDUCTOR_DETAIL) });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const node = {
|
||||
id: "conductor:task:1",
|
||||
kind: "work",
|
||||
source: "conductor",
|
||||
state: "running",
|
||||
label: "x",
|
||||
};
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={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();
|
||||
await Promise.resolve();
|
||||
});
|
||||
return { c, cleanup, fetchMock };
|
||||
}
|
||||
|
||||
it("hands the canvas id of the created task to onActionDone, so the page can focus it", async () => {
|
||||
const seen: Array<string | undefined> = [];
|
||||
const { c, cleanup, fetchMock } = await repeatOnce((id) => seen.push(id));
|
||||
const post = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === "POST");
|
||||
expect(post![0]).toBe("/api/conductor/tasks");
|
||||
expect(JSON.parse(String((post![1] as RequestInit).body))).toMatchObject({
|
||||
cli: "claude",
|
||||
model: "sonnet",
|
||||
});
|
||||
expect(seen).toEqual(["conductor:task:t_new"]);
|
||||
expect(c.textContent).toContain("repeatDone");
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user