@axiom-lattice/gateway 2.1.111 → 2.1.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/gateway",
3
- "version": "2.1.111",
3
+ "version": "2.1.112",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -41,11 +41,10 @@
41
41
  "redis": "^5.0.1",
42
42
  "uuid": "^9.0.1",
43
43
  "zod": "3.25.76",
44
- "@axiom-lattice/agent-eval": "2.1.92",
45
- "@axiom-lattice/core": "2.1.98",
46
- "@axiom-lattice/pg-stores": "1.0.89",
47
- "@axiom-lattice/protocols": "2.1.50",
48
- "@axiom-lattice/queue-redis": "1.0.49"
44
+ "@axiom-lattice/core": "2.1.99",
45
+ "@axiom-lattice/pg-stores": "1.0.90",
46
+ "@axiom-lattice/protocols": "2.1.51",
47
+ "@axiom-lattice/queue-redis": "1.0.50"
49
48
  },
50
49
  "devDependencies": {
51
50
  "@types/bcrypt": "^6.0.0",
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it, jest, beforeEach } from "@jest/globals";
2
+
3
+ const mockQueryWorkflowRuns = jest.fn();
4
+ const mockGetRunSteps = jest.fn();
5
+ const mockGetAllAssistants = jest.fn();
6
+ const mockGetAgent = jest.fn();
7
+
8
+ jest.mock("@axiom-lattice/core", () => ({
9
+ getStoreLattice: (_key: string, type: string) =>
10
+ type === "workflowTracking"
11
+ ? { store: { queryWorkflowRuns: mockQueryWorkflowRuns, getRunSteps: mockGetRunSteps } }
12
+ : { store: { getAllAssistants: mockGetAllAssistants } },
13
+ agentInstanceManager: { getAgent: mockGetAgent },
14
+ ThreadStatus: { IDLE: "IDLE", BUSY: "BUSY", INTERRUPTED: "INTERRUPTED" },
15
+ }));
16
+
17
+ function makeRun(id: string, startedAt: string) {
18
+ return {
19
+ id,
20
+ tenantId: "t1",
21
+ assistantId: "a1",
22
+ threadId: `th_${id}`,
23
+ status: "interrupted" as const,
24
+ topologyEdges: [],
25
+ totalEdges: 2,
26
+ completedEdges: 1,
27
+ startedAt: new Date(startedAt),
28
+ createdAt: new Date(startedAt),
29
+ updatedAt: new Date(startedAt),
30
+ };
31
+ }
32
+
33
+ function makeRequest(query: Record<string, string> = {}) {
34
+ return {
35
+ headers: { "x-tenant-id": "t1" },
36
+ query,
37
+ log: { error: jest.fn(), warn: jest.fn() },
38
+ } as never;
39
+ }
40
+
41
+ const mockReply = { status: jest.fn().mockReturnThis(), send: jest.fn() } as never;
42
+
43
+ describe("getInboxItems", () => {
44
+ let getInboxItems: (req: never, reply: never) => Promise<{ data?: Record<string, unknown> }>;
45
+
46
+ beforeEach(async () => {
47
+ jest.clearAllMocks();
48
+ mockGetAllAssistants.mockResolvedValue([{ id: "a1", name: "Assistant One" }] as never);
49
+ mockGetRunSteps.mockResolvedValue([] as never);
50
+ mockGetAgent.mockImplementation(() => { throw new Error("no agent"); }); // fallback path
51
+ const mod = await import("../../controllers/workflow-tracking");
52
+ getInboxItems = mod.getInboxItems as never;
53
+ });
54
+
55
+ it("legacy mode: no query params returns all items without pagination fields", async () => {
56
+ mockQueryWorkflowRuns.mockResolvedValue({
57
+ records: [makeRun("r1", "2026-01-01"), makeRun("r2", "2026-01-02")],
58
+ total: 2,
59
+ } as never);
60
+
61
+ const res = await getInboxItems(makeRequest(), mockReply);
62
+
63
+ expect(mockQueryWorkflowRuns).toHaveBeenCalledWith("t1", { status: "interrupted" });
64
+ expect(res.data).toBeDefined();
65
+ const records = res.data!.records as unknown[];
66
+ expect(records).toHaveLength(2);
67
+ expect(res.data!.total).toBeUndefined();
68
+ expect(res.data!.page).toBeUndefined();
69
+ });
70
+
71
+ it("paged mode: passes limit/offset to store and returns total/page/pageSize", async () => {
72
+ mockQueryWorkflowRuns.mockResolvedValue({
73
+ records: [makeRun("r3", "2026-01-03")],
74
+ total: 42,
75
+ } as never);
76
+
77
+ const res = await getInboxItems(makeRequest({ page: "2", pageSize: "20" }), mockReply);
78
+
79
+ expect(mockQueryWorkflowRuns).toHaveBeenCalledWith("t1", { status: "interrupted", limit: 20, offset: 20 });
80
+ expect(res.data!.total).toBe(42);
81
+ expect(res.data!.page).toBe(2);
82
+ expect(res.data!.pageSize).toBe(20);
83
+ expect(res.data!.records).toHaveLength(1);
84
+ });
85
+
86
+ it("paged mode triggers when only pageSize is passed", async () => {
87
+ mockQueryWorkflowRuns.mockResolvedValue({ records: [], total: 0 } as never);
88
+
89
+ const res = await getInboxItems(makeRequest({ pageSize: "10" }), mockReply);
90
+
91
+ expect(mockQueryWorkflowRuns).toHaveBeenCalledWith("t1", { status: "interrupted", limit: 10, offset: 0 });
92
+ expect(res.data!.page).toBe(1);
93
+ expect(res.data!.pageSize).toBe(10);
94
+ });
95
+
96
+ it("sanitizes invalid page and caps pageSize at 100", async () => {
97
+ mockQueryWorkflowRuns.mockResolvedValue({ records: [], total: 0 } as never);
98
+
99
+ const res = await getInboxItems(makeRequest({ page: "abc", pageSize: "500" }), mockReply);
100
+
101
+ expect(mockQueryWorkflowRuns).toHaveBeenCalledWith("t1", { status: "interrupted", limit: 100, offset: 0 });
102
+ expect(res.data!.page).toBe(1);
103
+ expect(res.data!.pageSize).toBe(100);
104
+ });
105
+
106
+ it("returns empty records with pagination fields in paged mode", async () => {
107
+ mockQueryWorkflowRuns.mockResolvedValue({ records: [], total: 0 } as never);
108
+
109
+ const res = await getInboxItems(makeRequest({ page: "3", pageSize: "20" }), mockReply);
110
+
111
+ expect(res.data).toEqual({ records: [], total: 0, page: 3, pageSize: 20 });
112
+ });
113
+ });
@@ -16,6 +16,13 @@ const testOrDiscoverBody = z.object({
16
16
  });
17
17
 
18
18
  function getTenant(request: FastifyRequest): string {
19
+ // First try to get from authenticated user context
20
+ const userTenantId = (request as any).user?.tenantId;
21
+ if (userTenantId) {
22
+ return userTenantId;
23
+ }
24
+
25
+ // Fallback to request headers for backward compatibility
19
26
  return (request.headers["x-tenant-id"] as string) || "default";
20
27
  }
21
28
 
@@ -25,12 +25,27 @@ export async function createProject(
25
25
  const store = getEvalStore();
26
26
  const id = uuidv4();
27
27
  const data = request.body as any;
28
+ const serverCfg = (data.targetServerConfig || {}) as Record<string, unknown>;
29
+ const judgeCfg = (data.judgeModelConfig || {}) as Record<string, unknown>;
30
+ const modelKey = (judgeCfg.modelKey as string) || "";
31
+ if (modelKey) {
32
+ const { modelLatticeManager } = await import("@axiom-lattice/core");
33
+ if (!modelLatticeManager.hasLattice(modelKey)) {
34
+ return reply.status(400).send({ success: false, message: `Judge model "${modelKey}" is not registered` });
35
+ }
36
+ }
28
37
  const project = await store.createProject(tenantId, id, {
29
38
  name: data.name,
30
39
  description: data.description,
31
40
  version: data.version,
32
- judgeModelConfig: data.judgeModelConfig ?? {},
33
- targetServerConfig: data.targetServerConfig ?? {},
41
+ judgeModelConfig: {
42
+ modelKey,
43
+ displayName: (judgeCfg.displayName as string) || "",
44
+ },
45
+ targetServerConfig: {
46
+ base_url: (serverCfg.base_url as string) || "",
47
+ api_key: (serverCfg.api_key as string) || "",
48
+ },
34
49
  concurrency: data.concurrency ?? 1,
35
50
  reportConfig: data.reportConfig,
36
51
  });
@@ -62,7 +77,7 @@ export async function listProjects(
62
77
  const first = models[0];
63
78
  const judgeModel = first
64
79
  ? { modelKey: first.key }
65
- : { provider: "openai", model: "gpt-4" };
80
+ : {};
66
81
 
67
82
  const host = request.hostname || "localhost";
68
83
  const baseUrl = `http://${host}:${process.env.PORT || 4001}`;
@@ -125,7 +140,32 @@ export async function updateProject(
125
140
  if (!existing) {
126
141
  return reply.status(404).send({ success: false, message: "Project not found" });
127
142
  }
128
- const updated = await store.updateProject(tenantId, pid, request.body as any);
143
+ const body = request.body as Record<string, unknown>;
144
+ const updateData: Record<string, unknown> = { ...body };
145
+ // Strip in_process from user input — it is server-controlled
146
+ const serverCfg = (body.targetServerConfig || {}) as Record<string, unknown>;
147
+ if (body.targetServerConfig !== undefined) {
148
+ updateData.targetServerConfig = {
149
+ base_url: (serverCfg.base_url as string) || "",
150
+ api_key: (serverCfg.api_key as string) || "",
151
+ };
152
+ }
153
+ // Only allow modelKey/displayName — never raw provider/apiKey/baseURL
154
+ if (body.judgeModelConfig !== undefined) {
155
+ const judgeCfg = (body.judgeModelConfig || {}) as Record<string, unknown>;
156
+ const modelKey = (judgeCfg.modelKey as string) || "";
157
+ if (modelKey) {
158
+ const { modelLatticeManager } = await import("@axiom-lattice/core");
159
+ if (!modelLatticeManager.hasLattice(modelKey)) {
160
+ return reply.status(400).send({ success: false, message: `Judge model "${modelKey}" is not registered` });
161
+ }
162
+ }
163
+ updateData.judgeModelConfig = {
164
+ modelKey,
165
+ displayName: (judgeCfg.displayName as string) || "",
166
+ };
167
+ }
168
+ const updated = await store.updateProject(tenantId, pid, updateData);
129
169
  return {
130
170
  success: true,
131
171
  message: "Successfully updated project",
@@ -1,5 +1,5 @@
1
1
  import { FastifyRequest, FastifyReply } from "fastify";
2
- import { getStoreLattice, agentInstanceManager, ThreadStatus } from "@axiom-lattice/core";
2
+ import { getStoreLattice, agentInstanceManager, ThreadStatus, abortWorkflowRun as signalAbort } from "@axiom-lattice/core";
3
3
  import type { WorkflowTrackingStore, WorkflowRun, RunStep } from "@axiom-lattice/protocols";
4
4
  import { MessageChunkTypes } from "@axiom-lattice/protocols";
5
5
 
@@ -189,10 +189,18 @@ export async function getAllWorkflowRuns(
189
189
  }
190
190
 
191
191
  // GET /api/workflows/inbox
192
+ // Pagination semantics:
193
+ // - No page/pageSize → legacy mode: all interrupted items, data = { records } (unchanged shape).
194
+ // - page and/or pageSize → paged mode: store-level pagination over interrupted runs,
195
+ // data = { records, total, page, pageSize }. total = number of interrupted RUNS
196
+ // (each run expands to 0..N inbox items after the agent-state check).
197
+ const INBOX_DEFAULT_PAGE_SIZE = 20;
198
+ const INBOX_MAX_PAGE_SIZE = 100;
199
+
192
200
  export async function getInboxItems(
193
- request: FastifyRequest,
201
+ request: FastifyRequest<{ Querystring: { page?: string; pageSize?: string } }>,
194
202
  reply: FastifyReply
195
- ): Promise<ApiResponse<{ records: any[] }>> {
203
+ ): Promise<ApiResponse<{ records: any[]; total?: number; page?: number; pageSize?: number }>> {
196
204
  const tenantId = getTenantId(request);
197
205
 
198
206
  try {
@@ -214,10 +222,32 @@ export async function getInboxItems(
214
222
  // names unavailable, will fall back to ID
215
223
  }
216
224
 
217
- const runs = await store.getWorkflowRunsByTenantId(tenantId);
218
- const pendingRuns = runs.filter(r => r.status === "interrupted");
225
+ const { page: pageParam, pageSize: pageSizeParam } = request.query;
226
+ const paged = pageParam !== undefined || pageSizeParam !== undefined;
227
+ const page = Math.max(1, parseInt(pageParam ?? "", 10) || 1);
228
+ const pageSize = Math.min(INBOX_MAX_PAGE_SIZE, Math.max(1, parseInt(pageSizeParam ?? "", 10) || INBOX_DEFAULT_PAGE_SIZE));
229
+
230
+ let pendingRuns: WorkflowRun[];
231
+ let total: number | undefined;
232
+ if (paged) {
233
+ const result = await store.queryWorkflowRuns(tenantId, {
234
+ status: "interrupted",
235
+ limit: pageSize,
236
+ offset: (page - 1) * pageSize,
237
+ });
238
+ pendingRuns = result.records;
239
+ total = result.total;
240
+ } else {
241
+ const result = await store.queryWorkflowRuns(tenantId, { status: "interrupted" });
242
+ pendingRuns = result.records;
243
+ }
244
+
219
245
  if (pendingRuns.length === 0) {
220
- return { success: true, message: "No pending workflows", data: { records: [] } };
246
+ return {
247
+ success: true,
248
+ message: "No pending workflows",
249
+ data: paged ? { records: [], total, page, pageSize } : { records: [] },
250
+ };
221
251
  }
222
252
 
223
253
  // Parallelize agent checks across interrupted workflows
@@ -283,15 +313,13 @@ export async function getInboxItems(
283
313
 
284
314
  const results = await Promise.all(checkPromises);
285
315
  const inboxItems = results.flat();
286
-
287
- inboxItems.sort((a, b) =>
288
- new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
289
- );
316
+ // Runs are already sorted by updatedAt DESC, id DESC from queryWorkflowRuns.
317
+ // flatMap preserves run order within each run.
290
318
 
291
319
  return {
292
320
  success: true,
293
321
  message: "Successfully retrieved inbox items",
294
- data: { records: inboxItems },
322
+ data: paged ? { records: inboxItems, total, page, pageSize } : { records: inboxItems },
295
323
  };
296
324
  } catch (error) {
297
325
  request.log.error(error, "Failed to get inbox items");
@@ -597,3 +625,69 @@ export async function replyInboxTask(
597
625
  });
598
626
  }
599
627
  }
628
+
629
+ /**
630
+ * Abort a running workflow.
631
+ *
632
+ * Stops the main graph execution AND signals all in-progress
633
+ * sub-agent invokes to cancel.
634
+ */
635
+ export async function abortWorkflowRun(
636
+ request: FastifyRequest,
637
+ reply: FastifyReply
638
+ ): Promise<void> {
639
+ try {
640
+ const { runId } = request.params as { runId: string };
641
+ const tenantId = getTenantId(request);
642
+
643
+ const store = getTrackingStore();
644
+ if (!store) {
645
+ return reply.status(404).send({
646
+ success: false,
647
+ message: "No workflow tracking store configured",
648
+ });
649
+ }
650
+
651
+ const run = await store.getWorkflowRun(runId);
652
+ if (!run) {
653
+ return reply.status(404).send({
654
+ success: false,
655
+ message: "Workflow run not found",
656
+ });
657
+ }
658
+
659
+ // Persist abort intent FIRST — prevents restart after server crash
660
+ await store.updateWorkflowRun(runId, {
661
+ status: "cancelled",
662
+ errorMessage: "Aborted by user",
663
+ completedAt: new Date(),
664
+ }).catch(() => {});
665
+
666
+ // Stop the main graph execution
667
+ const workspace_id = request.headers["x-workspace-id"] as string;
668
+ const project_id = request.headers["x-project-id"] as string;
669
+ const agent = agentInstanceManager.getAgent({
670
+ assistant_id: run.assistantId,
671
+ thread_id: run.threadId,
672
+ tenant_id: tenantId,
673
+ workspace_id,
674
+ project_id,
675
+ });
676
+ await agent.abort();
677
+
678
+ // Signal sub-agents via AbortController registry
679
+ signalAbort(runId);
680
+
681
+ return reply.status(200).send({
682
+ success: true,
683
+ message: "Workflow aborted",
684
+ data: { runId, assistantId: run.assistantId, threadId: run.threadId },
685
+ });
686
+ } catch (error) {
687
+ request.log.error(error, "Failed to abort workflow run");
688
+ return reply.status(500).send({
689
+ success: false,
690
+ message: `Abort failed: ${(error as Error).message}`,
691
+ });
692
+ }
693
+ }
package/src/index.ts CHANGED
@@ -12,10 +12,11 @@ import type { ChannelInstallationStore, BindingRegistry } from "@axiom-lattice/p
12
12
  import { MessageRouter } from "./router/MessageRouter";
13
13
  import { ChannelAdapterRegistry } from "./channels/registry";
14
14
  import { createDeduplicationMiddleware, createRateLimitMiddleware, createAuditLoggerMiddleware } from "./router/middlewares";
15
- import { setBindingRegistry, setMenuRegistry } from "@axiom-lattice/core";
15
+ import { setBindingRegistry, setMenuRegistry, setEvalRunService } from "@axiom-lattice/core";
16
16
  import { larkChannelAdapter } from "./channels/lark/LarkChannelAdapter";
17
17
  import { extractUserFromAuthHeader } from "./controllers/auth";
18
18
  import { setChannelControllerDeps } from "./controllers/channel-installations";
19
+ import { evalRunner } from "./services/eval-runner";
19
20
  import { configureSwagger } from "./swagger";
20
21
  import {
21
22
  setQueueServiceType,
@@ -387,6 +388,8 @@ const start = async (config?: LatticeGatewayConfig) => {
387
388
  });
388
389
  }
389
390
 
391
+ setEvalRunService(evalRunner);
392
+
390
393
  // Initialize MenuRegistry for custom menu items
391
394
  try {
392
395
  const menuStore = getStoreLattice("default", "menu").store;
@@ -466,13 +469,16 @@ const start = async (config?: LatticeGatewayConfig) => {
466
469
  }
467
470
 
468
471
  // Restore agent instances with pending messages (fire-and-forget to avoid blocking startup)
469
- agentInstanceManager.restore()
470
- .then((stats) => {
471
- logger.info(`Agent recovery complete: ${stats.restored} threads restored, ${stats.errors} errors`);
472
- })
473
- .catch((error) => {
474
- logger.error("Agent recovery failed", { error });
475
- });
472
+ // Temporarily disabled: do not auto-consume pending messages on startup.
473
+ if (process.env.AXIOM_RESTORE_PENDING_ON_STARTUP === "true") {
474
+ agentInstanceManager.restore()
475
+ .then((stats) => {
476
+ logger.info(`Agent recovery complete: ${stats.restored} threads restored, ${stats.errors} errors`);
477
+ })
478
+ .catch((error) => {
479
+ logger.error("Agent recovery failed", { error });
480
+ });
481
+ }
476
482
 
477
483
  // Restore MCP server connections that were connected before shutdown
478
484
  restoreMcpConnections().catch((error) => {
@@ -510,7 +510,9 @@ export const registerLatticeRoutes = (app: FastifyInstance, channelDeps?: { rout
510
510
  workflowTrackingController.getAllWorkflowRuns
511
511
  );
512
512
 
513
- app.get(
513
+ app.get<{
514
+ Querystring: { page?: string; pageSize?: string };
515
+ }>(
514
516
  "/api/workflows/inbox",
515
517
  workflowTrackingController.getInboxItems
516
518
  );
@@ -546,6 +548,11 @@ export const registerLatticeRoutes = (app: FastifyInstance, channelDeps?: { rout
546
548
  Params: { runId: string };
547
549
  }>("/api/workflows/runs/:runId/tasks", workflowTrackingController.getRunTasks);
548
550
 
551
+ // Abort a running workflow — stops graph + all sub-agents
552
+ app.post<{
553
+ Params: { runId: string };
554
+ }>("/api/workflows/runs/:runId/abort", workflowTrackingController.abortWorkflowRun);
555
+
549
556
  // 简化的 Inbox 任务回复接口(后台 streaming 执行,立即返回)
550
557
  app.post<{
551
558
  Body: { runId: string; answers: Array<{ questionIndex: number; selectedOptions: string[] }> };
@@ -1,6 +1,9 @@
1
1
  import { EventEmitter } from "events";
2
- import { getStoreLattice, modelLatticeManager } from "@axiom-lattice/core";
3
- import { LatticeEvalProject } from "@axiom-lattice/agent-eval";
2
+ import {
3
+ getStoreLattice,
4
+ modelLatticeManager,
5
+ LatticeEvalProject,
6
+ } from "@axiom-lattice/core";
4
7
  import type { EvalStore } from "@axiom-lattice/protocols";
5
8
  import type {
6
9
  CaseRunResult,
@@ -8,8 +11,7 @@ import type {
8
11
  LatticeEvalProjectType,
9
12
  LatticeEvalSuiteType,
10
13
  LatticeEvalLogEvent,
11
- OutputType,
12
- } from "@axiom-lattice/agent-eval";
14
+ } from "@axiom-lattice/core";
13
15
  import { v4 as uuidv4 } from "uuid";
14
16
 
15
17
  export interface EvalStreamEvent {
@@ -48,6 +50,12 @@ class EvalRunner {
48
50
  const project = await store.getProjectById(tenantId, projectId);
49
51
  if (!project) throw new Error("Project not found");
50
52
 
53
+ for (const ctx of this.runs.values()) {
54
+ if (ctx.projectId === projectId) {
55
+ throw new Error("A run is already in progress for this project");
56
+ }
57
+ }
58
+
51
59
  const existingRuns = await store.getRunsByTenant(tenantId, { projectId, status: "running" });
52
60
  if (existingRuns.length > 0) {
53
61
  throw new Error("A run is already in progress for this project");
@@ -65,8 +73,8 @@ class EvalRunner {
65
73
  cases: cases.map((c) => ({
66
74
  caseId: c.id,
67
75
  input: { message: c.inputMessage, files: c.inputFiles },
68
- steps: c.steps as Array<{ agent_id: string; override_message?: string }>,
69
- output: { type: c.outputType } as OutputType,
76
+ steps: c.steps,
77
+ output: { type: "message_content" },
70
78
  eval: {
71
79
  content_assertion: c.contentAssertion,
72
80
  eval_rubrics: c.rubrics?.map((r) => ({
@@ -79,39 +87,35 @@ class EvalRunner {
79
87
  });
80
88
  }
81
89
 
82
- const runId = uuidv4();
83
- const concurrency = (project as unknown as Record<string, unknown>).concurrency as number || 3;
84
- await store.createRun(tenantId, projectId, runId, { totalCases, concurrency });
85
-
86
- const judgeCfg = project.judgeModelConfig as Record<string, unknown>;
87
- const hasModelKey = Boolean(judgeCfg.modelKey);
88
- const hasApiKey = Boolean(judgeCfg.apiKeyEnvName || judgeCfg.apiKey);
89
- const hasCredentials = hasApiKey;
90
-
91
- let judgeModelConfig: { modelKey?: string; model?: any } = {};
90
+ const judgeCfg = (project.judgeModelConfig ?? {}) as Record<string, unknown>;
91
+ const judgeModelKey = (judgeCfg.modelKey as string) || "";
92
+ let resolvedJudgeModelKey: string;
92
93
 
93
- if (hasModelKey) {
94
- judgeModelConfig = { modelKey: judgeCfg.modelKey as string };
95
- } else if (!hasCredentials) {
96
- const firstModel = modelLatticeManager.getAllLattices()[0];
97
- if (firstModel) {
98
- judgeModelConfig = { modelKey: firstModel.key };
99
- } else {
100
- judgeModelConfig = { model: judgeCfg as any };
94
+ if (judgeModelKey) {
95
+ if (!modelLatticeManager.hasLattice(judgeModelKey)) {
96
+ throw new Error(`Judge model "${judgeModelKey}" is not registered`);
101
97
  }
98
+ resolvedJudgeModelKey = judgeModelKey;
102
99
  } else {
103
- judgeModelConfig = { model: judgeCfg as any };
100
+ const firstModel = modelLatticeManager.getAllLattices()[0];
101
+ if (!firstModel) {
102
+ throw new Error("No model registered — cannot run eval without a judge model");
103
+ }
104
+ resolvedJudgeModelKey = firstModel.key;
105
+ console.warn(`[eval-runner] Project "${project.name}" has no judge modelKey — falling back to first registered model "${firstModel.key}"`);
104
106
  }
105
107
 
108
+ const runId = uuidv4();
109
+ const concurrency = Math.max(1, Math.floor((project as unknown as Record<string, unknown>).concurrency as number) || 3);
110
+ await store.createRun(tenantId, projectId, runId, { totalCases, concurrency });
111
+
106
112
  const projectConfig: LatticeEvalProjectType = {
107
113
  projectName: project.name,
108
114
  version: project.version,
109
115
  description: project.description,
110
116
  suites: evalSuites,
111
- judge_agent_config: judgeModelConfig,
117
+ judge_agent_config: { modelKey: resolvedJudgeModelKey },
112
118
  lattice_server_config: {
113
- base_url: (project.targetServerConfig as Record<string, string>).base_url || "",
114
- api_key: (project.targetServerConfig as Record<string, string>).api_key || "",
115
119
  tenant_id: tenantId,
116
120
  },
117
121
  concurrency,
@@ -163,37 +167,41 @@ class EvalRunner {
163
167
  const runPromise = (async () => {
164
168
  try {
165
169
  const evalProject = new LatticeEvalProject(projectConfig, onCaseComplete);
166
- const { report } = await evalProject.runAllSuitesBatch(concurrency);
167
-
168
- const completedCount = stats.passed + stats.failed;
169
- await store.updateRunStatus(tenantId, runId, {
170
- status: "completed",
171
- completedAt: new Date(),
172
- passedCases: stats.passed,
173
- failedCases: stats.failed,
174
- avgScore: completedCount > 0 ? stats.totalScore / completedCount : 0,
175
- });
176
-
177
- this.eventEmitter.emit(`run:${runId}`, {
178
- type: "completed",
179
- runId,
180
- data: { passed: stats.passed, failed: stats.failed, avgScore: completedCount > 0 ? stats.totalScore / completedCount : 0 },
181
- } satisfies EvalStreamEvent);
170
+ const { report } = await evalProject.runAllSuitesBatch(concurrency, abortController.signal);
171
+
172
+ if (!abortController.signal.aborted) {
173
+ const completedCount = stats.passed + stats.failed;
174
+ await store.updateRunStatus(tenantId, runId, {
175
+ status: "completed",
176
+ completedAt: new Date(),
177
+ passedCases: stats.passed,
178
+ failedCases: stats.failed,
179
+ avgScore: completedCount > 0 ? stats.totalScore / completedCount : 0,
180
+ });
181
+
182
+ this.eventEmitter.emit(`run:${runId}`, {
183
+ type: "completed",
184
+ runId,
185
+ data: { passed: stats.passed, failed: stats.failed, avgScore: completedCount > 0 ? stats.totalScore / completedCount : 0 },
186
+ } satisfies EvalStreamEvent);
187
+ }
182
188
 
183
189
  return { report };
184
190
  } catch (err) {
185
191
  const errorMsg = (err as Error).message;
186
- await store.updateRunStatus(tenantId, runId, {
187
- status: "failed",
188
- error: errorMsg,
189
- completedAt: new Date(),
190
- });
191
-
192
- this.eventEmitter.emit(`run:${runId}`, {
193
- type: "error",
194
- runId,
195
- data: { message: errorMsg },
196
- } satisfies EvalStreamEvent);
192
+ if (!abortController.signal.aborted) {
193
+ await store.updateRunStatus(tenantId, runId, {
194
+ status: "failed",
195
+ error: errorMsg,
196
+ completedAt: new Date(),
197
+ });
198
+
199
+ this.eventEmitter.emit(`run:${runId}`, {
200
+ type: "error",
201
+ runId,
202
+ data: { message: errorMsg },
203
+ } satisfies EvalStreamEvent);
204
+ }
197
205
 
198
206
  throw err;
199
207
  } finally {