@axiom-lattice/gateway 3.0.0 → 3.0.2

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": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -41,11 +41,11 @@
41
41
  "redis": "^5.0.1",
42
42
  "uuid": "^9.0.1",
43
43
  "zod": "3.25.76",
44
- "@axiom-lattice/cli-a2a": "0.1.6",
45
- "@axiom-lattice/core": "3.0.0",
46
- "@axiom-lattice/pg-stores": "2.0.0",
47
- "@axiom-lattice/protocols": "3.0.0",
48
- "@axiom-lattice/queue-redis": "1.0.54"
44
+ "@axiom-lattice/cli-a2a": "0.1.7",
45
+ "@axiom-lattice/core": "3.0.2",
46
+ "@axiom-lattice/pg-stores": "2.0.2",
47
+ "@axiom-lattice/protocols": "3.0.1",
48
+ "@axiom-lattice/queue-redis": "1.0.55"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/bcrypt": "^6.0.0",
@@ -394,11 +394,15 @@ export function registerEvalRoutes(app: FastifyInstance): void {
394
394
  try {
395
395
  const tenantId = getTenantId(request);
396
396
  const { pid } = request.params as { pid: string };
397
- const runId = await evalRunner.startRun(tenantId, pid);
397
+ const body = (request.body ?? {}) as { suiteIds?: string[] };
398
+ const runId = await evalRunner.startRun(tenantId, pid, body?.suiteIds);
398
399
  reply.status(202).send({ success: true, message: "Run started", data: { run_id: runId } });
399
400
  } catch (err) {
400
401
  const msg = (err as Error).message;
401
- const code = msg === "Project not found" ? 404 : msg.includes("already in progress") ? 409 : 500;
402
+ const code = msg === "Project not found" ? 404
403
+ : msg.includes("already in progress") ? 409
404
+ : msg.includes("No suites match") ? 400
405
+ : 500;
402
406
  reply.status(code).send({ success: false, message: msg });
403
407
  }
404
408
  });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * EvalRunner 孤儿 run 处理测试
3
+ *
4
+ * 网关重启后 DB 中 status=running 的 run 在内存中不存在(孤儿)。
5
+ * startRun 应先将孤儿标记为 failed,再允许新 run 启动。
6
+ */
7
+
8
+ import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
9
+
10
+ const mockStore = {
11
+ getProjectById: jest.fn(),
12
+ getRunsByTenant: jest.fn(),
13
+ getSuitesByProject: jest.fn(),
14
+ getCasesBySuite: jest.fn(),
15
+ updateRunStatus: jest.fn(),
16
+ createRun: jest.fn(),
17
+ createRunResult: jest.fn(),
18
+ getResultsByRun: jest.fn(),
19
+ };
20
+
21
+ jest.mock("@axiom-lattice/core", () => {
22
+ const actual = jest.requireActual("@axiom-lattice/core");
23
+ return {
24
+ ...actual,
25
+ getStoreLattice: jest.fn(() => ({ store: mockStore })),
26
+ modelLatticeManager: {
27
+ hasLattice: jest.fn(() => true),
28
+ getAllLattices: jest.fn(() => [{ key: "mock-model" }]),
29
+ },
30
+ LatticeEvalProject: jest.fn().mockImplementation(() => ({
31
+ calibrateJudge: jest.fn().mockResolvedValue({ ok: true }),
32
+ runAllSuitesBatch: jest.fn().mockResolvedValue({
33
+ report: { summary: { total_cases: 0, passed_cases: 0, failed_cases: 0, pass_rate: 0 } },
34
+ }),
35
+ })),
36
+ };
37
+ });
38
+
39
+ import { evalRunner } from "../eval-runner";
40
+
41
+ const runningRun = (id: string, createdAt = new Date()) => ({
42
+ id,
43
+ projectId: "p1",
44
+ tenantId: "t1",
45
+ status: "running" as const,
46
+ concurrency: 3,
47
+ totalCases: 0,
48
+ passedCases: 0,
49
+ failedCases: 0,
50
+ avgScore: 0,
51
+ createdAt,
52
+ });
53
+
54
+ describe("EvalRunner orphaned run handling", () => {
55
+ beforeEach(() => {
56
+ jest.clearAllMocks();
57
+ mockStore.getProjectById.mockResolvedValue({ id: "p1", name: "eval-test", tenantId: "t1" });
58
+ mockStore.getSuitesByProject.mockResolvedValue([]);
59
+ mockStore.getCasesBySuite.mockResolvedValue([]);
60
+ mockStore.updateRunStatus.mockResolvedValue(null);
61
+ mockStore.createRun.mockResolvedValue(runningRun("new-run-1"));
62
+ });
63
+
64
+ afterEach(() => {
65
+ jest.restoreAllMocks();
66
+ });
67
+
68
+ it("marks orphaned running runs as failed before starting a new run", async () => {
69
+ const orphan = runningRun("orphan-1", new Date(Date.now() - 60_000));
70
+ mockStore.getRunsByTenant
71
+ .mockResolvedValueOnce([orphan]) // first call: orphan found
72
+ .mockResolvedValueOnce([]); // second call: all cleaned
73
+
74
+ const runId = await evalRunner.startRun("t1", "p1");
75
+
76
+ expect(mockStore.updateRunStatus).toHaveBeenCalledWith(
77
+ "t1",
78
+ "orphan-1",
79
+ expect.objectContaining({ status: "failed" })
80
+ );
81
+ expect(mockStore.createRun).toHaveBeenCalled();
82
+ expect(runId).toBeTruthy();
83
+ });
84
+
85
+ it("does not clean a run that is alive in memory", async () => {
86
+ mockStore.getRunsByTenant.mockResolvedValue([]);
87
+
88
+ const runId = await evalRunner.startRun("t1", "p1");
89
+ expect(runId).toBeTruthy();
90
+ expect(mockStore.updateRunStatus).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it("does not clean a run created after process start (other instance)", async () => {
94
+ const live = runningRun("other-instance-1", new Date()); // created now → after process start
95
+ mockStore.getRunsByTenant
96
+ .mockResolvedValueOnce([live])
97
+ .mockResolvedValueOnce([live]); // still running after cleanup attempt
98
+
99
+ await expect(evalRunner.startRun("t1", "p1")).rejects.toThrow(/already in progress/);
100
+ expect(mockStore.updateRunStatus).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it("runs only the requested suites when suiteIds is provided", async () => {
104
+ mockStore.getSuitesByProject.mockResolvedValue([
105
+ { id: "s-dev", name: "po-extraction-user-sample", tenantId: "t1", projectId: "p1", createdAt: new Date(), updatedAt: new Date() },
106
+ { id: "s-val", name: "po-extraction-validation", tenantId: "t1", projectId: "p1", createdAt: new Date(), updatedAt: new Date() },
107
+ ]);
108
+ const { LatticeEvalProject } = jest.requireMock("@axiom-lattice/core");
109
+ const runProjectConfigs: any[] = [];
110
+ LatticeEvalProject.mockImplementation((config: any) => {
111
+ runProjectConfigs.push(config);
112
+ return {
113
+ calibrateJudge: jest.fn().mockResolvedValue({ ok: true }),
114
+ runAllSuitesBatch: jest.fn().mockResolvedValue({
115
+ report: { summary: { total_cases: 0, passed_cases: 0, failed_cases: 0, pass_rate: 0 } },
116
+ }),
117
+ };
118
+ });
119
+
120
+ const runId = await evalRunner.startRun("t1", "p1", ["s-val"]);
121
+ expect(runId).toBeTruthy();
122
+ expect(runProjectConfigs[0].suites.map((s: any) => s.suiteName)).toEqual(["po-extraction-validation"]);
123
+ });
124
+
125
+ it("throws when suiteIds filter matches nothing", async () => {
126
+ mockStore.getSuitesByProject.mockResolvedValue([
127
+ { id: "s-dev", name: "dev", tenantId: "t1", projectId: "p1", createdAt: new Date(), updatedAt: new Date() },
128
+ ]);
129
+ await expect(evalRunner.startRun("t1", "p1", ["s-val"])).rejects.toThrow(/No suites match/);
130
+ });
131
+ });
@@ -37,15 +37,27 @@ function mapLogs(logs: LatticeEvalLogEvent[]): Array<{ timestamp: string; level:
37
37
  }));
38
38
  }
39
39
 
40
+ /**
41
+ * EvalRunner — in-process evaluation run scheduler.
42
+ *
43
+ * @remarks
44
+ * Single-instance assumption: run state lives in an in-memory Map plus a
45
+ * per-instance EventEmitter. Deploying multiple gateway instances requires
46
+ * a distributed lease (runner instance id + heartbeat) before the orphan
47
+ * cleanup below is safe — without it, instance B could mark instance A's
48
+ * live run failed. The createdAt guard limits cleanup to runs created
49
+ * BEFORE this process started, which are true orphans of a restart.
50
+ */
40
51
  class EvalRunner {
41
52
  private runs = new Map<string, RunContext>();
42
53
  private eventEmitter = new EventEmitter();
54
+ private readonly processStartedAt = new Date();
43
55
 
44
56
  getEventEmitter(): EventEmitter {
45
57
  return this.eventEmitter;
46
58
  }
47
59
 
48
- async startRun(tenantId: string, projectId: string): Promise<string> {
60
+ async startRun(tenantId: string, projectId: string, suiteIds?: string[]): Promise<string> {
49
61
  const store = this.getEvalStore();
50
62
  const project = await store.getProjectById(tenantId, projectId);
51
63
  if (!project) throw new Error("Project not found");
@@ -57,11 +69,31 @@ class EvalRunner {
57
69
  }
58
70
 
59
71
  const existingRuns = await store.getRunsByTenant(tenantId, { projectId, status: "running" });
60
- if (existingRuns.length > 0) {
72
+ for (const run of existingRuns) {
73
+ // Only clean runs created before this process started: they are true
74
+ // orphans of a gateway restart. Runs created after startup but missing
75
+ // from memory belong to another live instance — leave them alone.
76
+ const orphaned = !this.runs.has(run.id) && run.createdAt < this.processStartedAt;
77
+ if (orphaned) {
78
+ await store.updateRunStatus(tenantId, run.id, {
79
+ status: "failed",
80
+ error: "Gateway restarted — run orphaned",
81
+ completedAt: new Date(),
82
+ });
83
+ }
84
+ }
85
+ const stillRunning = await store.getRunsByTenant(tenantId, { projectId, status: "running" });
86
+ if (stillRunning.length > 0) {
61
87
  throw new Error("A run is already in progress for this project");
62
88
  }
63
89
 
64
- const suites = await store.getSuitesByProject(tenantId, projectId);
90
+ let suites = await store.getSuitesByProject(tenantId, projectId);
91
+ if (suiteIds && suiteIds.length > 0) {
92
+ suites = suites.filter((s) => suiteIds.includes(s.id));
93
+ if (suites.length === 0) {
94
+ throw new Error("No suites match the requested suiteIds filter");
95
+ }
96
+ }
65
97
  const evalSuites: LatticeEvalSuiteType[] = [];
66
98
  let totalCases = 0;
67
99
 
@@ -173,6 +205,15 @@ class EvalRunner {
173
205
  const runPromise = (async () => {
174
206
  try {
175
207
  const evalProject = new LatticeEvalProject(projectConfig, onCaseComplete);
208
+ const calibration = await evalProject.calibrateJudge();
209
+ if (!calibration.ok) {
210
+ await store.updateRunStatus(tenantId, runId, {
211
+ status: "failed",
212
+ error: `Judge calibration failed: ${calibration.reason}`,
213
+ completedAt: new Date(),
214
+ });
215
+ throw new Error(`Judge calibration failed: ${calibration.reason}`);
216
+ }
176
217
  const { report } = await evalProject.runAllSuitesBatch(concurrency, abortController.signal);
177
218
 
178
219
  if (!abortController.signal.aborted) {
@@ -39,6 +39,8 @@ export function findWorkspaceRoot(startDir: string): string | null {
39
39
  if (parent === current) return null;
40
40
  current = parent;
41
41
  }
42
+
43
+ return null;
42
44
  }
43
45
 
44
46
  export function resolveLocalA2AWorkingDirectory(options: {