feat(dashboard): orchestration canvas fase 2 — repeat action + A2A memory hits (2.6/2.7) (#12508)

* feat(api): conductor task creation route (repeat support)

* feat(a2a): record memoryHits consulted per task (observability, 2.7)

* feat(dashboard): repeat action in orchestration drawer (2.6 — repeat only)

* fix(dashboard): require every field each repeat contract needs before enabling the action

* feat(dashboard): memory-used drawer section + fase2 i18n/changelog (2.7)

* fix(dashboard): validate memoryHits shape before rendering + locale wording fixes

* fix(dashboard,a2a): stop memoryHits leaking into repeats, harden drawer guard + status clamp

Final whole-branch review fix wave for the Orchestration Canvas Fase 2 PR-C.

- a2a: `createTask` stores a COPY of `input.metadata` instead of aliasing it, so the
  observability `memoryHits` written by `executeA2ATaskWithState` no longer leak into
  `task.input.metadata`, into the persisted `a2a_tasks.input_json`, or into the drawer's
  "Repeat" body (a repeated task was born carrying the previous run's memory snippets,
  even with the `OMNIROUTE_A2A_MEMORY_HITS=0` kill-switch on).
- dashboard: `repeatReqFor` strips `memoryHits` from the a2a repeat metadata, so tasks
  persisted before the copy-fix do not propagate them either.
- dashboard: the "Memory used" section now requires all four rendered fields (id, key,
  type, snippet) to be strings — `{ id: "x", key: { a: 1 } }` used to throw "Objects are
  not valid as a React child" and take the whole drawer down.
- dashboard: an `/a2a` action answered with a JSON-RPC error under HTTP 200 is reported as
  a failure (`RPC <code>`, code only — never the upstream message) instead of a success
  toast; the secured-deployment rejection keeps surfacing the sanitized `HTTP 400`.
- api: the conductor task-creation route clamps a hub status outside 400-599 to 502, so an
  out-of-range status can no longer turn a hub refusal into a `RangeError`.
- dashboard: the History tab's `onActionDone` keeps the drawer mounted (and refreshes the
  range) instead of closing it, so the repeat/cancel confirmation is actually visible.
- a2a: documented the recall owner-id limitation — `task.owner` is a SHA-256 key prefix
  while memory rows are keyed by the DB api-key id, and no hash-to-id lookup exists today,
  so recall only resolves under the keyless posture.

* refactor(dashboard): split drawer repeat helpers and test file under the size/complexity gates

---------

Co-authored-by: Markus Hartung <diegosouzapw@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-02 20:37:02 -03:00
committed by GitHub
parent 86e83d4138
commit a628d28898
56 changed files with 2078 additions and 62 deletions

View File

@@ -1,4 +1,6 @@
import type { A2ATask, TaskArtifact } from "./taskManager";
import { appendA2ATaskEvent } from "@/lib/db/a2aTasks";
import { memoryManager } from "@/lib/memory/manager";
type TaskManagerLike = {
updateTask: (
@@ -14,6 +16,90 @@ type StreamTaskResult = {
metadata: Record<string, unknown>;
};
/**
* Task D2 (Orchestration Canvas Fase 2, PR-C): a memory hit recorded for OBSERVABILITY ONLY.
* The retrieved memory is never injected into a skill's prompt or behavior — it is only
* mirrored into `task.metadata.memoryHits` and a `memory_hits` history event so the dashboard
* can show which memories were consulted for a given A2A task.
*
* Note on drift from the original spec: `Memory` (`src/lib/memory/types.ts`) does not expose a
* `score` field, so hits carry `key`/`type` instead of a relevance score.
*/
export interface MemoryHit {
id: string;
key: string;
type: string;
/** `content` truncated to 200 chars — never the full memory body. */
snippet: string;
}
/** DI seam for `collectMemoryHits` — tests need neither a real memory backend nor a database. */
export interface MemoryHitsDeps {
search?: (cfg: {
query: string;
apiKeyId: string;
limit?: number;
}) => Promise<Array<{ id: string; key: string; type: string; content: string }>>;
appendEvent?: (taskId: string, eventType: string, dataJson?: string) => void;
}
/**
* Collect the memories consulted for a task's last user message, as pure observability.
*
* - Kill-switch: `OMNIROUTE_A2A_MEMORY_HITS=0` returns `[]` without querying anything.
* - Query = the content of the LAST message with `role === "user"`; empty/absent ⇒ `[]`.
* - Owner id = `task.owner ?? "mcp"` — the same keyless fallback the MCP memory tools use
* (`open-sse/mcp-server/tools/memoryTools.ts::resolveMemoryOwnerId`).
* - Any failure in the recall path ⇒ `[]` — this must never fail the caller's task.
*
* KNOWN LIMITATION — recall only resolves under the KEYLESS posture. `task.owner` is a
* SHA-256 PREFIX of the raw API key (`src/lib/a2a/authenticate.ts::resolveA2AOwner`), while
* memory rows are keyed by the DB api-key **id** (`String(apiKeyInfo.id)`, the value
* `getApiKeyMetadata()` returns — see `open-sse/mcp-server/mcpCallerIdentity.ts`). The two
* live in different namespaces, so for a keyed caller the search below matches nothing and
* the hits list is always empty; only the keyless case (`owner === undefined` → `"mcp"`)
* lines up with the MCP-tool owner id. Bridging them needs a hash→api-key-id lookup that
* does NOT exist today: `src/lib/db/apiKeys.ts` only ever looks a key up by its RAW value
* (`WHERE key = ? OR key_hash = ?`, with the FULL sha256 hex), and the raw key is long gone
* by the time a task executes. Deliberately NOT worked around here — inventing a
* prefix-scan lookup over `api_keys` would be a new auth-adjacent surface. Follow-up:
* either persist the DB api-key id on the task alongside the hash, or add an explicit
* `getApiKeyIdByKeyHashPrefix()` in the db layer.
*/
export async function collectMemoryHits(
task: A2ATask,
deps?: MemoryHitsDeps
): Promise<MemoryHit[]> {
if (process.env.OMNIROUTE_A2A_MEMORY_HITS === "0") return [];
const messages = task.input?.messages ?? [];
let query: string | undefined;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") {
query = messages[i].content;
break;
}
}
if (!query || query.trim() === "") return [];
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 });
return results.map((m) => ({
id: m.id,
key: m.key,
type: m.type,
snippet: m.content.slice(0, 200),
}));
} catch {
return [];
}
}
export type A2ASkillHandler = (task: A2ATask) => Promise<StreamTaskResult>;
export const A2A_SKILL_HANDLERS: Record<string, A2ASkillHandler> = {
@@ -46,9 +132,20 @@ export const A2A_SKILL_HANDLERS: Record<string, A2ASkillHandler> = {
export async function executeA2ATaskWithState(
tm: TaskManagerLike,
task: A2ATask,
handler: (task: A2ATask) => Promise<StreamTaskResult>
handler: (task: A2ATask) => Promise<StreamTaskResult>,
deps?: MemoryHitsDeps
) {
try {
const hits = await collectMemoryHits(task, deps);
if (hits.length) {
task.metadata.memoryHits = hits;
try {
(deps?.appendEvent ?? appendA2ATaskEvent)(task.id, "memory_hits", JSON.stringify(hits));
} catch {
// best-effort — never break the task's write path
}
}
const result = await handler(task);
tm.updateTask(task.id, "completed", result.artifacts);
return result;

View File

@@ -14,11 +14,7 @@
import { randomUUID } from "crypto";
import { emit } from "@/lib/events/eventBus";
import {
upsertA2ATask,
appendA2ATaskEvent,
purgeA2AHistory,
} from "@/lib/db/a2aTasks";
import { upsertA2ATask, appendA2ATaskEvent, purgeA2AHistory } from "@/lib/db/a2aTasks";
import { logger } from "@omniroute/open-sse/utils/logger";
const log = logger("A2A_TASKS");
@@ -204,7 +200,14 @@ export class A2ATaskManager {
input,
artifacts: [],
events: [{ timestamp: now.toISOString(), state: "submitted" }],
metadata: input.metadata || {},
// COPY, never the caller's object: `metadata` is the task's own mutable
// runtime bag (`taskExecution.ts` writes `memoryHits` into it), while
// `input.metadata` is the immutable record of what the caller sent. Sharing
// one reference made every runtime write leak back into `input` — and from
// there into the persisted `a2a_tasks.input_json` and into the drawer's
// "Repeat" body, so a repeated task was born carrying the previous run's
// memory snippets even with `OMNIROUTE_A2A_MEMORY_HITS=0`.
metadata: { ...(input.metadata ?? {}) },
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + this.ttlMs).toISOString(),