mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 06:12:17 +03:00
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:
committed by
GitHub
parent
86e83d4138
commit
a628d28898
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user