Files
OmniRoute/task-c3-report.md
Diego Rodrigues de Sa e Souza 590582711c fix(dashboard): orchestration canvas fase 3 — follow-ups da review final (#12639) (#12988)
* 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

* 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

* 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

* 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-10 10:23:52 -03:00

8.6 KiB
Raw Blame History

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.tsrequirements 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_idconductor:task:<id>
    • cloud-agent: body.data.idcloud-agent:<id>
    • a2a: body.result.task.ida2a:<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.