@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/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +20 -0
- package/dist/index.js +34 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +34 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/controllers/eval.ts +6 -2
- package/src/services/__tests__/eval-runner.test.ts +131 -0
- package/src/services/eval-runner.ts +44 -3
- package/src/services/local-a2a.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/gateway",
|
|
3
|
-
"version": "3.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.
|
|
45
|
-
"@axiom-lattice/core": "3.0.
|
|
46
|
-
"@axiom-lattice/pg-stores": "2.0.
|
|
47
|
-
"@axiom-lattice/protocols": "3.0.
|
|
48
|
-
"@axiom-lattice/queue-redis": "1.0.
|
|
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",
|
package/src/controllers/eval.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|