Compare commits

...

7 Commits

Author SHA1 Message Date
diegosouzapw
67631f6d0c Merge remote-tracking branch 'origin/release/v3.8.51' into feat/orch-fase3-c
# Conflicts:
#	src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx
#	src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts
2026-09-10 10:23:06 -03:00
diegosouzapw
41105101a2 Merge remote-tracking branch 'origin/release/v3.8.51' into feat/orch-fase3-c 2026-09-10 08:57:27 -03:00
diegosouzapw
722be56d3b Merge remote-tracking branch 'origin/release/v3.8.51' into feat/orch-fase3-c 2026-09-07 16:37:57 -03:00
diegosouzapw
a9a1b04e2f chore(a2a): follow-ups changelog
Changelog fragment for the five items PR-C delivers from #12639.

The sixth item on the issue — an authenticated panel path for A2A task
creation — stays deliberately out of scope and is recorded as such in a
comment on the issue rather than silently dropped: the JSON-RPC endpoint
accepts API keys only, and widening that endpoint's auth surface to serve a
UI convenience is the operator's call, not the implementation's.

Closes #12639
2026-09-07 16:37:45 -03:00
diegosouzapw
a7d430fe9e fix(dashboard): carry conductor requirements and focus the repeated task
The drawer's "Repeat" for a Conductor task dropped the runner/model pinning and
left the operator staring at the finished run:

- `hubTaskSchema` now parses the hub's `requirements` (`.catch(null)` so an odd
  shape never fails the whole task parse), and `ConductorTaskDetail` exposes
  `cli`/`model` (`null` when the hub sends none).
- `repeatReqForConductor` carries `cli`/`model` when present and OMITS them
  otherwise — the route's Zod takes both as optional strings, so a `null` would
  400. The two fields are independent.
- `performAction` reads the response body once and returns it, so the repeat can
  report the CANVAS id of the created task (`task_id` / `data.id` /
  `result.task.id`, each with its node prefix). `OrchestrationPageClient` then
  refetches and focuses it via `?node=`; History keeps its current behavior.
- `conductor-routes-auth.test.ts` covers the creation route through its `ROUTES`
  array; the duplicated source assertion left `conductor-create-route.test.ts`.

Refs #12639
2026-09-07 16:28:56 -03:00
diegosouzapw
3ef0aef15b fix(a2a): bound the memory recall with its own deadline
collectMemoryHits() runs BEFORE the skill handler and had no deadline at all,
so a slow memory backend delayed the start of every A2A task — the HTTP
genericBackend alone defaults to a 30s timeout.

The search now races a MEMORY_RECALL_TIMEOUT_MS (1500ms) deadline. Overshooting
degrades exactly like any other recall failure: empty hits, a warn log, and the
task proceeds normally (best-effort contract unchanged, nothing propagates).
The deadline timer is cleared in a finally on BOTH paths so no handle is left
holding the event loop open, and MemoryHitsDeps.timeoutMs makes it injectable
so the tests cost milliseconds instead of 1.5s of wall clock.

Refs #12639
2026-09-07 16:14:24 -03:00
diegosouzapw
da8014442d feat(api): hydrate memoryHits from the persisted history event
`GET /api/a2a/tasks/[id]` falls back to the persisted history row once a task
leaves the in-memory TTL window, and `reconstituteHistoricalTask` hard-coded
`metadata: {}` — so the drawer's "Memory used" section vanished for any
historical task, even though `executeA2ATaskWithState` had already written a
`memory_hits` event with the hits.

The fallback now reads that event: `data_json` is parsed and, when it yields at
least one well-formed hit, exposed as `metadata.memoryHits`. The event itself is
filtered out of `events` — it is observability, not a state transition, and
without the filter it leaked into the timeline as a duplicate of the row's
current state.

Reading is defensive throughout, mirroring `DrawerMemory`'s own validation: the
payload is caller-influenced and unvalidated end to end, so `JSON.parse` runs
inside `safeJsonParse`, non-arrays are rejected, and each entry must carry `id`,
`key`, `type` and `snippet` as strings (a non-string field would be rendered as
a React child and take the drawer down). Malformed input degrades to
`metadata: {}` and a 200 — never a 500.

Refs #12639
2026-09-07 16:12:59 -03:00
15 changed files with 965 additions and 71 deletions

View 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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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,

View File

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

View File

@@ -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
View 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.
37. 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
View 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
View 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.

View File

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

View File

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

View File

@@ -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({

View File

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

View File

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