* 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
8.6 KiB
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
hubTaskSchemaganhourequirements: z.object({ cli: z.string().nullish(), model: z.string().nullish() }).nullish().catch(null). O.catch(null)é deliberado: o MESMO schema parseia a lista emgetFleetSnapshot(z.array(hubTaskSchema).parse(rawTasks)) — sem ele, umrequirementsde formato inesperado numa única task faria oparseinteiro lançar e o snapshot cair para{offline: true}.ConductorTaskDetailganhoucli: string | nullemodel: string | null(com JSDoc);getConductorTaskDetailpreenche comt.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
repeatReqForConductoragora enviacli: d.cli ?? undefined/model: d.model ?? undefined.undefined, nuncanull— o Zod real da rota (src/app/api/conductor/tasks/route.ts) temcli: z.string().optional()/model: z.string().optional(), enulldaria 400.JSON.stringifydescarta as chavesundefined, 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.jsonRpcErrorCodedeixou de lerres.json()e passou a receber o corpo já parseado; novoreadJsonBody(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").performActionpassou a devolver{ ok, body }em vez deboolean.- Novo
export function newNodeIdFrom(node, body): string | null— extrai o id da task criada e devolve já no formato do canvas (com prefixo), porquesetParams({ node })casa contrasnapshot.nodes.find(n => n.id === nodeId)e os ids emmergeSnapshot.tssã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/a2amessage/sendv0.3) Leitura defensiva: id ausente/vazio/não-string ⇒null(nunca um prefixo pelado), corpo não-objeto ⇒null, nunca lança.
- conductor:
- No hook:
approve/cancelcontinuamPromise<boolean>(viarunBooleanAction) — 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órepeatdevolveRepeatOutcome { ok, newNodeId }(interface exportada).
3. drawer/OrchestrationDrawer.tsx
RepeatButtonconsomeRepeatOutcomee chamaonActionDone(newNodeId ?? undefined).onActionDonevirou(newNodeId?: string) => voidnoRepeatButton,DrawerActionse no componente exportado.DrawerActions.run()(approve/cancel) segue chamandoonActionDone()sem argumento.
4. OrchestrationPageClient.tsx
- Novo
onActionDonememoizado:refetch()e, se veio id,setParams({ node: newNodeId }). A aba History renderiza o próprio drawer (HistoryTab.tsx) e não foi tocada — seuonActionDone={() => …}ignora o argumento extra, então o comportamento lá continua idêntico (re-amostranowMs, não navega), como o plano pede.
5. Testes
tests/unit/conductor-routes-auth.test.ts:src/app/api/conductor/tasks/route.tsentrou no arrayROUTES; o regex do proxy ganhou a alternativacreateConductorTask\(.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/pathcontinuam 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:repeatReqForconductor comcli/model; com ambosnull(corpo idêntico ao atual e sem as stringscli/model); com sóclisetado.newNodeIdFromnas três fontes (prefixado), corpos malformados ⇒null, fonte sem contrato ⇒null.- drawer: repeat de conductor faz POST com
cli/modele chamaonActionDoneexatamente uma vez com"conductor:task:t_new", com o toastrepeatDone.
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
newNodeIdFromprefixado (não previsto explicitamente no plano). O plano diz "extrair o id da task nova (task_idno Conductor,data.idno Cloud Agent) e chamaronActionDonecom ele". O id CRU não serve parasetParams({ node }): o canvas indexa por id prefixado (conductor:task:<id>,cloud-agent:<id>,a2a:<id>—mergeSnapshot.ts/routeFor). Segui o código:newNodeIdFromjá devolve o id de canvas. O plano não citava o a2a; incluí porque orepeatdo drawer também atende a2a e o envelope (result.task.id) é verificável emsrc/app/a2a/route.ts:250.performAction"devolve o corpo parseado" — só orepeatpropaga o id.approve/cancelficaramPromise<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..catch(null)norequirements. O plano não pedia; adicionei porquehubTaskSchematambé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".- A mudança em
conductor-routes-auth.test.tsnão é um teste que falha antes (explicado acima). ConductorTaskDetailestendeFleetTask;cli/modelforam adicionados só no detalhe (não emFleetTask/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.