From fd0275827a8993491952f87ec22faeaf01d10fa0 Mon Sep 17 00:00:00 2001
From: Miro
Date: Thu, 27 Aug 2026 20:48:19 +0800
Subject: [PATCH 1/3] =?UTF-8?q?fix(evolver):=20=E6=8C=81=E4=B9=85=E5=8C=96?=
=?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=B9=82=E7=AD=89=E5=B9=B6=E5=85=BC=E5=AE=B9?=
=?UTF-8?q?=E6=BB=9A=E5=8A=A8=E5=8D=87=E7=BA=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
infra/migrations/tests/test_migration_0042.py | 108 ++++++++++++++++++
.../0042_evolution_approval_operations.py | 100 ++++++++++++++++
.../orchestration/src/hooks/with-hooks.ts | 2 +-
.../orchestration/src/permissions/pending.ts | 103 ++++++++++++++---
.../orchestration/src/permissions/repo.ts | 61 +++++++++-
.../tests/permissions-pending.test.ts | 66 ++++++++---
.../inalpha_evolver/storage/run_queries.py | 8 ++
.../src/inalpha_evolver/storage/runs.py | 5 +-
8 files changed, 419 insertions(+), 34 deletions(-)
create mode 100644 infra/migrations/tests/test_migration_0042.py
create mode 100644 infra/migrations/versions/0042_evolution_approval_operations.py
diff --git a/infra/migrations/tests/test_migration_0042.py b/infra/migrations/tests/test_migration_0042.py
new file mode 100644
index 00000000..65526c6e
--- /dev/null
+++ b/infra/migrations/tests/test_migration_0042.py
@@ -0,0 +1,108 @@
+"""0042 durable evolution approval operation ledger integration tests."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+import psycopg
+import pytest
+from migration_0038_support import alembic, db_url
+
+pytestmark = pytest.mark.integration
+
+
+def test_0042_persists_one_operation_per_owner_thread_request(
+ migration_db_url: str,
+) -> None:
+ alembic(migration_db_url, "upgrade", "0041")
+ old_grant = (
+ "11111111-1111-4111-8111-111111111111",
+ "user:alice",
+ "config-1",
+ "operation-old",
+ "a" * 64,
+ )
+ with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn:
+ conn.execute(
+ """INSERT INTO evolution_credential_grant_uses
+ (jti,owner_sub,config_id,operation_id,config_digest)
+ VALUES (%s,%s,%s,%s,%s)""",
+ old_grant,
+ )
+
+ alembic(migration_db_url, "upgrade", "0042")
+ identity = ("user:alice", "thread-1", "evolver.run_evolution", "a" * 64)
+ first = "50000000-0000-4000-8000-000000000001"
+ second = "50000000-0000-4000-8000-000000000002"
+ expires = datetime.now(UTC) + timedelta(hours=24)
+ with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn:
+ assert conn.execute(
+ """SELECT request_digest FROM evolution_credential_grant_uses
+ WHERE jti=%s""",
+ (old_grant[0],),
+ ).fetchone() == ("0" * 64,)
+ conn.execute(
+ """INSERT INTO evolution_credential_grant_uses
+ (jti,owner_sub,config_id,operation_id,config_digest)
+ VALUES ('11111111-1111-4111-8111-111111111112',
+ 'user:alice','config-1','operation-rolling',%s)""",
+ ("a" * 64,),
+ )
+ assert conn.execute(
+ """SELECT request_digest FROM evolution_credential_grant_uses
+ WHERE jti='11111111-1111-4111-8111-111111111112'"""
+ ).fetchone() == ("0" * 64,)
+ conn.execute(
+ """INSERT INTO strategy_evo_runs
+ (run_id,owner_account_id,requested_by_sub,seed_strategy_id,budget,config,
+ status,idempotency_key,request_hash,queued_at)
+ VALUES ('50000000-0000-4000-8000-000000000010',
+ '00000000-0000-0000-0000-000000000099','user:alice','seed',1,'{}',
+ 'queued','old-image-compatible','old-hash',NOW())"""
+ )
+ assert conn.execute(
+ """SELECT llm_snapshot_required,llm_credential_grant_required
+ FROM strategy_evo_runs
+ WHERE run_id='50000000-0000-4000-8000-000000000010'"""
+ ).fetchone() == (False, False)
+ with pytest.raises(psycopg.errors.CheckViolation):
+ conn.execute(
+ """INSERT INTO strategy_evo_runs
+ (run_id,owner_account_id,requested_by_sub,seed_strategy_id,budget,config,
+ status,idempotency_key,request_hash,queued_at,
+ llm_snapshot_required,llm_credential_grant_required)
+ VALUES ('50000000-0000-4000-8000-000000000011',
+ '00000000-0000-0000-0000-000000000099','user:alice','seed',1,'{}',
+ 'queued','new-image-invalid','new-hash',NOW(),TRUE,TRUE)"""
+ )
+ conn.execute(
+ """INSERT INTO evolution_approval_operations
+ (operation_id,auth_sub,session_id,tool_name,input_digest,expires_at)
+ VALUES (%s,%s,%s,%s,%s,%s)""",
+ (first, *identity, expires),
+ )
+ conn.execute(
+ """INSERT INTO evolution_approval_operations
+ (operation_id,auth_sub,session_id,tool_name,input_digest,expires_at)
+ VALUES (%s,%s,%s,%s,%s,%s)
+ ON CONFLICT (auth_sub,session_id,tool_name,input_digest) DO UPDATE SET
+ operation_id=EXCLUDED.operation_id,approved_at=NOW(),expires_at=EXCLUDED.expires_at""",
+ (second, *identity, expires),
+ )
+ assert conn.execute(
+ """SELECT operation_id::text FROM evolution_approval_operations
+ WHERE auth_sub=%s AND session_id=%s AND tool_name=%s AND input_digest=%s
+ AND expires_at>NOW()""",
+ identity,
+ ).fetchone() == (second,)
+
+ alembic(migration_db_url, "downgrade", "0041")
+ with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn:
+ assert conn.execute(
+ "SELECT to_regclass('evolution_approval_operations')"
+ ).fetchone() == (None,)
+ assert conn.execute(
+ """SELECT 1 FROM information_schema.columns
+ WHERE table_name='evolution_credential_grant_uses'
+ AND column_name='request_digest'"""
+ ).fetchone() is None
diff --git a/infra/migrations/versions/0042_evolution_approval_operations.py b/infra/migrations/versions/0042_evolution_approval_operations.py
new file mode 100644
index 00000000..c84ec28d
--- /dev/null
+++ b/infra/migrations/versions/0042_evolution_approval_operations.py
@@ -0,0 +1,100 @@
+"""Persist restart-stable evolution approval operation identities."""
+
+from __future__ import annotations
+
+from alembic import op
+
+revision: str = "0042"
+down_revision: str | None = "0041"
+branch_labels: str | tuple[str, ...] | None = None
+depends_on: str | tuple[str, ...] | None = None
+
+
+def upgrade() -> None:
+ """Add request scope, restore rolling compatibility, and create the operation ledger."""
+ op.execute(
+ """SET LOCAL lock_timeout = '10s';
+ALTER TABLE evolution_credential_grant_uses
+ADD COLUMN request_digest TEXT;
+UPDATE evolution_credential_grant_uses SET request_digest=repeat('0',64);
+ALTER TABLE evolution_credential_grant_uses
+ALTER COLUMN request_digest SET DEFAULT repeat('0',64),
+ALTER COLUMN request_digest SET NOT NULL,
+ADD CONSTRAINT evolution_credential_grant_request_digest_check
+CHECK (request_digest ~ '^[0-9a-f]{64}$');
+ALTER TABLE strategy_evo_runs
+ALTER COLUMN llm_snapshot_required SET DEFAULT FALSE,
+ALTER COLUMN llm_credential_grant_required SET DEFAULT FALSE,
+DROP CONSTRAINT evo_run_llm_snapshot_check;
+ALTER TABLE strategy_evo_runs ADD CONSTRAINT evo_run_llm_snapshot_check
+CHECK (
+ (NOT llm_credential_grant_required OR llm_snapshot_required)
+ AND (status <> 'queued' OR NOT llm_snapshot_required OR llm_credential_grant_required)
+ AND (
+ NOT llm_snapshot_required
+ OR (
+ llm_snapshot IS NOT NULL
+ AND llm_config_digest IS NOT NULL
+ AND (
+ NOT llm_credential_grant_required
+ OR length(COALESCE(llm_credential_grant,''))>=100
+ )
+ AND llm_config_digest ~ '^[0-9a-f]{64}$'
+ AND COALESCE(llm_snapshot->>'config_digest'=llm_config_digest,FALSE)
+ AND length(COALESCE(llm_snapshot->>'config_id',''))>0
+ AND length(COALESCE(llm_snapshot->>'provider',''))>0
+ AND length(COALESCE(llm_snapshot->>'model',''))>0
+ AND COALESCE(jsonb_typeof(llm_snapshot->'pricing')='object',FALSE)
+ )
+ )
+) NOT VALID;
+CREATE TABLE evolution_approval_operations (
+ operation_id UUID PRIMARY KEY,
+ auth_sub TEXT NOT NULL,
+ session_id TEXT NOT NULL,
+ tool_name TEXT NOT NULL CHECK (tool_name='evolver.run_evolution'),
+ input_digest TEXT NOT NULL CHECK (input_digest ~ '^[0-9a-f]{64}$'),
+ approved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ UNIQUE(auth_sub,session_id,tool_name,input_digest),
+ CHECK (expires_at>approved_at)
+);
+CREATE INDEX ix_evolution_approval_operations_expires_at
+ON evolution_approval_operations(expires_at);"""
+ )
+
+
+def downgrade() -> None:
+ """Restore the 0041 constraint/defaults and remove new ledgers/scopes."""
+ op.execute(
+ """SET LOCAL lock_timeout = '10s';
+DROP TABLE evolution_approval_operations;
+ALTER TABLE strategy_evo_runs
+ALTER COLUMN llm_snapshot_required SET DEFAULT TRUE,
+ALTER COLUMN llm_credential_grant_required SET DEFAULT TRUE,
+DROP CONSTRAINT evo_run_llm_snapshot_check;
+ALTER TABLE strategy_evo_runs ADD CONSTRAINT evo_run_llm_snapshot_check
+CHECK (
+ (status <> 'queued' OR (llm_snapshot_required AND llm_credential_grant_required))
+ AND (
+ NOT llm_snapshot_required
+ OR (
+ llm_snapshot IS NOT NULL
+ AND llm_config_digest IS NOT NULL
+ AND (
+ NOT llm_credential_grant_required
+ OR length(COALESCE(llm_credential_grant,''))>=100
+ )
+ AND llm_config_digest ~ '^[0-9a-f]{64}$'
+ AND COALESCE(llm_snapshot->>'config_digest'=llm_config_digest,FALSE)
+ AND length(COALESCE(llm_snapshot->>'config_id',''))>0
+ AND length(COALESCE(llm_snapshot->>'provider',''))>0
+ AND length(COALESCE(llm_snapshot->>'model',''))>0
+ AND COALESCE(jsonb_typeof(llm_snapshot->'pricing')='object',FALSE)
+ )
+ )
+) NOT VALID;
+ALTER TABLE evolution_credential_grant_uses
+DROP CONSTRAINT evolution_credential_grant_request_digest_check,
+DROP COLUMN request_digest;"""
+ )
diff --git a/packages/orchestration/src/hooks/with-hooks.ts b/packages/orchestration/src/hooks/with-hooks.ts
index 4d0920a7..e26d2e99 100644
--- a/packages/orchestration/src/hooks/with-hooks.ts
+++ b/packages/orchestration/src/hooks/with-hooks.ts
@@ -222,7 +222,7 @@ export function withHooks(tool: T, opts: WithHooksOptions
};
}
- const operationId = store.consumeApproved({
+ const operationId = await store.consumeApproved({
authSub,
sessionId,
toolName,
diff --git a/packages/orchestration/src/permissions/pending.ts b/packages/orchestration/src/permissions/pending.ts
index 9c33bc2a..02a0892b 100644
--- a/packages/orchestration/src/permissions/pending.ts
+++ b/packages/orchestration/src/permissions/pending.ts
@@ -1,6 +1,11 @@
import { createHash, randomUUID } from "node:crypto";
-import { insertPending, markResolved } from "./repo.js";
+import {
+ findEvolutionOperation,
+ insertPending,
+ markResolved,
+ rememberEvolutionOperation,
+} from "./repo.js";
export type PendingDecision = "allow" | "deny";
@@ -39,11 +44,12 @@ interface PendingApprovalRecord extends PendingApprovalView {
interface ConsumedApprovalRecord {
operationId: string;
- deadline: string;
+ expiresAt: string;
timer: ReturnType;
}
const DEFAULT_TIMEOUT_MS = 30_000;
+const EVOLUTION_OPERATION_RETENTION_MS = 24 * 60 * 60 * 1_000;
export type PendingTelemetrySink = (record: Record) => void;
@@ -58,6 +64,20 @@ export interface ApprovalPersistence {
decision: PendingDecision,
via: "user" | "timeout",
): Promise;
+ rememberEvolutionOperation(args: EvolutionOperationScope & { operationId: string }): Promise<{
+ expiresAt: string;
+ } | undefined>;
+ findEvolutionOperation(args: EvolutionOperationScope): Promise<{
+ operationId: string;
+ expiresAt: string;
+ } | undefined>;
+}
+
+export interface EvolutionOperationScope {
+ authSub: string;
+ sessionId: string;
+ toolName: string;
+ inputDigest: string;
}
/** Produces a deterministic SHA-256 digest for an approval-defining input. */
@@ -147,12 +167,12 @@ export class PendingApprovalsStore {
return true;
}
- /** Atomically consumes one approved decision and returns its stable operation ID. */
- consumeApproved(args: PendingConsumeArgs): string | undefined {
+ /** Atomically consumes one approved decision and returns its restart-stable operation ID. */
+ async consumeApproved(args: PendingConsumeArgs): Promise {
const identity = this.identityFor(args);
const consumed = args.reuseAfterConsume ? this.consumedByIdentity.get(identity) : undefined;
if (consumed) {
- if (Date.now() >= Date.parse(consumed.deadline)) {
+ if (Date.now() >= Date.parse(consumed.expiresAt)) {
this.removeConsumed(identity);
} else {
this.telemetry({
@@ -166,6 +186,22 @@ export class PendingApprovalsStore {
return consumed.operationId;
}
}
+ const scope = this.operationScope(args);
+ if (args.reuseAfterConsume && this.persistence) {
+ const persisted = await this.persistence.findEvolutionOperation(scope);
+ if (persisted && Date.now() < Date.parse(persisted.expiresAt)) {
+ this.cacheConsumed(identity, persisted);
+ this.telemetry({
+ event: "ask_approval_operation_recovered",
+ requestId: persisted.operationId,
+ toolName: args.toolName,
+ sessionId: args.sessionId,
+ authSub: args.authSub,
+ ts: new Date().toISOString(),
+ });
+ return persisted.operationId;
+ }
+ }
const requestId = this.identityIndex.get(identity);
const record = requestId ? this.records.get(requestId) : undefined;
if (!record || record.status !== "approved") return undefined;
@@ -173,17 +209,35 @@ export class PendingApprovalsStore {
this.expire(record.requestId);
return undefined;
}
- this.remove(record);
+ let reusable: { operationId: string; expiresAt: string } | undefined;
if (args.reuseAfterConsume) {
- this.consumedByIdentity.set(identity, {
+ const fallback = {
operationId: record.requestId,
- deadline: record.deadline,
- timer: setTimeout(
- () => this.removeConsumed(identity),
- Math.max(Date.parse(record.deadline) - Date.now(), 0),
- ),
- });
+ expiresAt: new Date(Date.now() + EVOLUTION_OPERATION_RETENTION_MS).toISOString(),
+ };
+ try {
+ const persisted = await this.persistence?.rememberEvolutionOperation({
+ ...scope,
+ operationId: record.requestId,
+ });
+ reusable = persisted
+ ? { operationId: record.requestId, expiresAt: persisted.expiresAt }
+ : fallback;
+ } catch (error) {
+ this.telemetry({
+ event: "ask_approval_operation_persist_failed",
+ requestId: record.requestId,
+ toolName: record.toolName,
+ sessionId: record.sessionId,
+ authSub: record.authSub,
+ error: error instanceof Error ? error.message : String(error),
+ ts: new Date().toISOString(),
+ });
+ return undefined;
+ }
}
+ this.remove(record);
+ if (reusable) this.cacheConsumed(identity, reusable);
this.telemetry({
event: "ask_approval_consumed",
requestId: record.requestId,
@@ -247,6 +301,27 @@ export class PendingApprovalsStore {
this.consumedByIdentity.delete(identity);
}
+ private cacheConsumed(
+ identity: string,
+ record: { operationId: string; expiresAt: string },
+ ): void {
+ const timer = setTimeout(
+ () => this.removeConsumed(identity),
+ Math.max(Date.parse(record.expiresAt) - Date.now(), 0),
+ );
+ timer.unref?.();
+ this.consumedByIdentity.set(identity, { ...record, timer });
+ }
+
+ private operationScope(args: PendingConsumeArgs): EvolutionOperationScope {
+ return {
+ authSub: args.authSub,
+ sessionId: args.sessionId,
+ toolName: args.toolName,
+ inputDigest: approvalInputDigest(args.approvalInput),
+ };
+ }
+
private identityFor(args: PendingConsumeArgs): string {
return this.identityKey(
args.authSub,
@@ -296,4 +371,6 @@ function stableStringify(value: unknown): string {
export const pendingApprovals = new PendingApprovalsStore(undefined, {
insertPending,
markResolved,
+ rememberEvolutionOperation,
+ findEvolutionOperation,
});
diff --git a/packages/orchestration/src/permissions/repo.ts b/packages/orchestration/src/permissions/repo.ts
index 47da24f5..53de7565 100644
--- a/packages/orchestration/src/permissions/repo.ts
+++ b/packages/orchestration/src/permissions/repo.ts
@@ -10,20 +10,24 @@
*
* 何时用:仅 `PendingApprovalsStore`(写)与 permissions HTTP API(读 history)。
*
- * 何时不用:审批**决策**永远走内存 Promise —— 本层是审计面不是闸门;
- * 单测用 setPool() 注 mock,不连真实库。
+ * 何时不用:普通 ask 审批的决策仍走内存;只有会产生成本的演化操作
+ * 额外使用本层恢复稳定 operation ID。单测用 setPool() 注 mock。
*
* 坑:
*
- * - **fail-open**:任何落库失败只 console.error,绝不阻断审批流(闸门语义
- * fail-closed 在 pending.ts,审计面挂了不能把审批一起拖死)
+ * - 审计写入仍 fail-open;演化 operation ledger 的 DB 错误会向上抛出,由
+ * pending.ts fail-closed,避免重启/超时后丢失幂等边界。
* - DATABASE_URL 未配置时本层整体降级为 no-op(dev 无 PG 也能跑)
*/
import { Pool, type PoolConfig } from "pg";
import { getSettings } from "../config.js";
import { maskSensitive } from "../redact.js";
-import type { PendingApprovalView, PendingDecision } from "./pending.js";
+import type {
+ EvolutionOperationScope,
+ PendingApprovalView,
+ PendingDecision,
+} from "./pending.js";
/** 终态:决策 / 超时 / 重启扫尾。 */
export type ApprovalStatus =
@@ -135,6 +139,53 @@ export async function markResolved(
}
}
+/** Persist one approved evolution identity before the costful tool is allowed to execute. */
+export async function rememberEvolutionOperation(
+ args: EvolutionOperationScope & { operationId: string },
+): Promise<{ expiresAt: string } | undefined> {
+ const pool = getPoolOrNull();
+ if (!pool) return undefined;
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1_000);
+ const result = await pool.query(
+ `INSERT INTO evolution_approval_operations
+ (operation_id,auth_sub,session_id,tool_name,input_digest,approved_at,expires_at)
+ VALUES ($1,$2,$3,$4,$5,NOW(),$6)
+ ON CONFLICT (auth_sub,session_id,tool_name,input_digest) DO UPDATE SET
+ operation_id=EXCLUDED.operation_id,
+ approved_at=EXCLUDED.approved_at,
+ expires_at=EXCLUDED.expires_at
+ RETURNING expires_at`,
+ [
+ args.operationId,
+ args.authSub,
+ args.sessionId,
+ args.toolName,
+ args.inputDigest,
+ expiresAt,
+ ],
+ );
+ const row = result.rows[0];
+ return row ? { expiresAt: toIso(row.expires_at) } : undefined;
+}
+
+/** Recover an unexpired evolution operation after orchestration restart or transport timeout. */
+export async function findEvolutionOperation(
+ args: EvolutionOperationScope,
+): Promise<{ operationId: string; expiresAt: string } | undefined> {
+ const pool = getPoolOrNull();
+ if (!pool) return undefined;
+ const result = await pool.query(
+ `SELECT operation_id,expires_at FROM evolution_approval_operations
+ WHERE auth_sub=$1 AND session_id=$2 AND tool_name=$3 AND input_digest=$4
+ AND expires_at>NOW()`,
+ [args.authSub, args.sessionId, args.toolName, args.inputDigest],
+ );
+ const row = result.rows[0];
+ return row
+ ? { operationId: String(row.operation_id), expiresAt: toIso(row.expires_at) }
+ : undefined;
+}
+
/**
* 启动扫尾:上一进程遗留的 pending 行批量置 expired_restart。
* 返回扫掉的行数(log / 测试用);DB 不可用返回 0。
diff --git a/packages/orchestration/tests/permissions-pending.test.ts b/packages/orchestration/tests/permissions-pending.test.ts
index cc25c3c4..46613fa1 100644
--- a/packages/orchestration/tests/permissions-pending.test.ts
+++ b/packages/orchestration/tests/permissions-pending.test.ts
@@ -46,7 +46,7 @@ afterEach(() => {
});
describe("PendingApprovalsStore", () => {
- it("binds approval to owner, thread, tool and deterministic input digest", () => {
+ it("binds approval to owner, thread, tool and deterministic input digest", async () => {
const store = new PendingApprovalsStore(() => {});
const view = request(store);
expect(view.inputDigest).toBe(approvalInputDigest({ candidateId: "c-42" }));
@@ -55,7 +55,7 @@ describe("PendingApprovalsStore", () => {
expect(store.respond(view.requestId, "allow", "user:bob")).toBe(false);
expect(store.respond(view.requestId, "allow", "user:alice")).toBe(true);
expect(
- store.consumeApproved({
+ await store.consumeApproved({
authSub: "user:alice",
sessionId: "thread-B",
toolName: "paper.promote_candidate",
@@ -63,7 +63,7 @@ describe("PendingApprovalsStore", () => {
}),
).toBeUndefined();
expect(
- store.consumeApproved({
+ await store.consumeApproved({
authSub: "user:alice",
sessionId: "thread-A",
toolName: "paper.promote_candidate",
@@ -71,7 +71,7 @@ describe("PendingApprovalsStore", () => {
}),
).toBe(view.requestId);
expect(
- store.consumeApproved({
+ await store.consumeApproved({
authSub: "user:alice",
sessionId: "thread-A",
toolName: "paper.promote_candidate",
@@ -105,7 +105,7 @@ describe("PendingApprovalsStore", () => {
store.clearAll();
});
- it("reuses one evolution operation ID for a same-scope transport retry", () => {
+ it("reuses one evolution operation ID for a same-scope transport retry", async () => {
const store = new PendingApprovalsStore(() => {});
const args = {
authSub: "user:alice",
@@ -125,10 +125,10 @@ describe("PendingApprovalsStore", () => {
approvalInput: args.approvalInput,
reuseAfterConsume: true,
};
- expect(store.consumeApproved(consume)).toBe(view.requestId);
- expect(store.consumeApproved(consume)).toBe(view.requestId);
+ expect(await store.consumeApproved(consume)).toBe(view.requestId);
+ expect(await store.consumeApproved(consume)).toBe(view.requestId);
expect(
- store.consumeApproved({
+ await store.consumeApproved({
...consume,
approvalInput: { request: { budget: 2 }, llm_snapshot: { config_digest: "digest" } },
}),
@@ -136,7 +136,7 @@ describe("PendingApprovalsStore", () => {
store.clearAll();
});
- it("does not reuse a consumed evolution operation after its deadline", () => {
+ it("does not reuse a consumed evolution operation after its retention deadline", async () => {
vi.useFakeTimers();
const store = new PendingApprovalsStore(() => {});
const args = {
@@ -156,13 +156,53 @@ describe("PendingApprovalsStore", () => {
approvalInput: args.approvalInput,
reuseAfterConsume: true,
};
- expect(store.consumeApproved(consume)).toBe(view.requestId);
+ expect(await store.consumeApproved(consume)).toBe(view.requestId);
- vi.advanceTimersByTime(51);
+ vi.advanceTimersByTime(24 * 60 * 60 * 1_000 + 1);
- expect(store.consumeApproved(consume)).toBeUndefined();
+ expect(await store.consumeApproved(consume)).toBeUndefined();
store.clearAll();
});
+
+ it("recovers a durable evolution operation in a fresh store", async () => {
+ const operations = new Map();
+ const persistence = {
+ insertPending: vi.fn(async () => {}),
+ markResolved: vi.fn(async () => {}),
+ rememberEvolutionOperation: vi.fn(async (scope: { inputDigest: string; operationId: string }) => {
+ const value = {
+ operationId: scope.operationId,
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
+ };
+ operations.set(scope.inputDigest, value);
+ return { expiresAt: value.expiresAt };
+ }),
+ findEvolutionOperation: vi.fn(async (scope: { inputDigest: string }) =>
+ operations.get(scope.inputDigest),
+ ),
+ };
+ const args = {
+ authSub: "user:alice",
+ sessionId: "thread-A",
+ toolName: "evolver.run_evolution",
+ toolInput: { budget: 1 },
+ approvalInput: { request: { budget: 1 }, llm_snapshot: { config_digest: "digest" } },
+ timeoutMs: 5_000,
+ };
+ const first = new PendingApprovalsStore(() => {}, persistence);
+ const view = first.request(args);
+ expect(first.respond(view.requestId, "allow", args.authSub)).toBe(true);
+ expect(
+ await first.consumeApproved({ ...args, reuseAfterConsume: true }),
+ ).toBe(view.requestId);
+
+ const recovered = new PendingApprovalsStore(() => {}, persistence);
+ expect(
+ await recovered.consumeApproved({ ...args, reuseAfterConsume: true }),
+ ).toBe(view.requestId);
+ first.clearAll();
+ recovered.clearAll();
+ });
});
describe("permissions approval HTTP API", () => {
@@ -202,7 +242,7 @@ describe("permissions approval HTTP API", () => {
await respondRoute.handler(matching.ctx as never, async () => {});
expect(matching.captured.status).toBe(200);
expect(
- pendingApprovals.consumeApproved({
+ await pendingApprovals.consumeApproved({
authSub: "user:alice",
sessionId: "thread-A",
toolName: "paper.promote_candidate",
diff --git a/services/evolver/src/inalpha_evolver/storage/run_queries.py b/services/evolver/src/inalpha_evolver/storage/run_queries.py
index d823afb6..e3a22b55 100644
--- a/services/evolver/src/inalpha_evolver/storage/run_queries.py
+++ b/services/evolver/src/inalpha_evolver/storage/run_queries.py
@@ -57,6 +57,7 @@ async def claim_next(
)
await cur.execute(
f"""WITH picked AS(SELECT run_id FROM strategy_evo_runs WHERE status='queued'
+AND llm_snapshot_required AND llm_credential_grant_required
ORDER BY queued_at FOR UPDATE SKIP LOCKED LIMIT 1)UPDATE strategy_evo_runs r SET
status='running',active_stage='loading_data',started_at=%s,updated_at=%s FROM picked
WHERE r.run_id=picked.run_id RETURNING {",".join(f"r.{name.strip()}" for name in _COLUMNS.split(","))}""",
@@ -97,6 +98,13 @@ async def reconcile_interrupted(conn: AsyncConnection) -> int:
"""服务重启时保留 queued,收口可能已计费的 active run。"""
now = datetime.now(UTC)
async with conn.cursor() as cur:
+ await cur.execute(
+ """UPDATE strategy_evo_runs SET status='aborted',active_stage='aborted',
+finished_at=%s,updated_at=%s,failure_code='EVOLUTION_UPGRADE_ABORTED',
+failure_message='run queued by an image without owner LLM authorization metadata'
+WHERE status='queued' AND (NOT llm_snapshot_required OR NOT llm_credential_grant_required)""",
+ (now, now),
+ )
await cur.execute(
"""UPDATE strategy_evo_candidates c SET stage='completed',outcome='cancelled',
error_code='EVOLUTION_SERVICE_RESTARTED',error_message='service restarted during execution',
diff --git a/services/evolver/src/inalpha_evolver/storage/runs.py b/services/evolver/src/inalpha_evolver/storage/runs.py
index e38f9eab..a88cf8fe 100644
--- a/services/evolver/src/inalpha_evolver/storage/runs.py
+++ b/services/evolver/src/inalpha_evolver/storage/runs.py
@@ -35,9 +35,10 @@ async def insert_run(
await cur.execute(
f"""INSERT INTO strategy_evo_runs(run_id,owner_account_id,requested_by_sub,
seed_strategy_id,budget,config,llm_snapshot,llm_config_digest,llm_credential_grant,status,idempotency_key,
-request_hash,queued_at,venue,symbol,request_timeframe,data_timeframe,engine_timeframe,
+request_hash,queued_at,llm_snapshot_required,llm_credential_grant_required,
+venue,symbol,request_timeframe,data_timeframe,engine_timeframe,
requested_as_of,seed_source_snapshot,seed_source_hash) VALUES
-(%s,%s,%s,%s,%s,%s,%s,%s,%s,'queued',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+(%s,%s,%s,%s,%s,%s,%s,%s,%s,'queued',%s,%s,%s,TRUE,TRUE,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(owner_account_id,idempotency_key) DO NOTHING RETURNING {_COLUMNS},request_hash""",
(
run_id,
From 9aa5004ca36f3da0bcb52ea37c6675a4cb00642f Mon Sep 17 00:00:00 2001
From: Miro
Date: Thu, 27 Aug 2026 20:48:41 +0800
Subject: [PATCH 2/3] =?UTF-8?q?fix(evolver):=20=E7=94=A8=E8=AF=B7=E6=B1=82?=
=?UTF-8?q?=E7=BB=91=E5=AE=9A=E6=8E=88=E6=9D=83=E7=BB=9F=E4=B8=80=E5=87=AD?=
=?UTF-8?q?=E6=8D=AE=E9=93=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../internal/llm-config/[id]/route.test.ts | 3 +
.../app/api/internal/llm-config/[id]/route.ts | 8 +-
apps/dashboard/src/proxy.test.ts | 43 ++++++++++
apps/dashboard/src/proxy.ts | 6 +-
infra/docker-compose.prod.yml | 1 +
packages/orchestration/src/clients/evolver.ts | 81 +++++++++++++++----
.../mastra/llm/evolution-credential-grant.ts | 4 +-
.../orchestration/src/tools/evolver-shared.ts | 26 +++---
packages/orchestration/src/tools/evolver.ts | 7 +-
.../tests/evolver-client.test.ts | 61 +++++++++-----
services/evolver/pyproject.toml | 1 +
.../src/inalpha_evolver/api/approval.py | 43 +++++++---
.../src/inalpha_evolver/api/request_hash.py | 40 ++++++++-
.../src/inalpha_evolver/api/run_routes.py | 13 ++-
.../evolver/src/inalpha_evolver/config.py | 7 +-
.../evolver/tests/llm_snapshot_fixtures.py | 27 +++++--
services/evolver/tests/test_api_contract.py | 9 ++-
services/evolver/tests/test_approval.py | 51 +++++++++---
services/evolver/tests/test_e2e.py | 62 +++++++++-----
services/evolver/uv.lock | 52 ++++++++++++
20 files changed, 434 insertions(+), 111 deletions(-)
create mode 100644 apps/dashboard/src/proxy.test.ts
diff --git a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts
index dcfd4746..70547db0 100644
--- a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts
+++ b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts
@@ -32,6 +32,7 @@ async function token(
provider: "deepseek",
operation_id: "operation-1",
llm_config_digest: "a".repeat(64),
+ request_digest: "b".repeat(64),
...overrides,
})
.setProtectedHeader({ alg: "EdDSA" })
@@ -76,6 +77,7 @@ describe("internal owner LLM credential route", () => {
const now = Math.floor(Date.now() / 1_000);
const requests = [
callRoute(`Bearer ${await token({ token_use: "session" })}`),
+ callRoute(`Bearer ${await token({ request_digest: "missing-scope" })}`),
callRoute(`Bearer ${await token({}, { issuedAt: null })}`),
callRoute(`Bearer ${await token({}, { issuedAt: now - 108_100, expiresAt: now + 1 })}`),
callRoute(`Bearer ${await token({}, { issuedAt: now + 60, expiresAt: now + 120 })}`),
@@ -84,6 +86,7 @@ describe("internal owner LLM credential route", () => {
];
expect((await Promise.all(requests)).map((response) => response.status)).toEqual([
+ 403,
403,
401,
403,
diff --git a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts
index 3ec62a40..e351310f 100644
--- a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts
+++ b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts
@@ -42,6 +42,8 @@ export async function GET(
payload.operation_id.length < 8 ||
typeof payload.llm_config_digest !== "string" ||
!/^[0-9a-f]{64}$/.test(payload.llm_config_digest) ||
+ typeof payload.request_digest !== "string" ||
+ !/^[0-9a-f]{64}$/.test(payload.request_digest) ||
typeof payload.jti !== "string" ||
!payload.jti ||
typeof payload.sub !== "string" ||
@@ -68,8 +70,8 @@ export async function GET(
try {
recorded = await getPool().query(
`INSERT INTO evolution_credential_grant_uses
- (jti,owner_sub,config_id,operation_id,config_digest,consumed_at)
- VALUES ($1,$2,$3,$4,$5,NOW())
+ (jti,owner_sub,config_id,operation_id,config_digest,request_digest,consumed_at)
+ VALUES ($1,$2,$3,$4,$5,$6,NOW())
ON CONFLICT (jti) DO UPDATE SET
redemption_count=evolution_credential_grant_uses.redemption_count+1,
last_redeemed_at=NOW()
@@ -77,6 +79,7 @@ export async function GET(
AND evolution_credential_grant_uses.config_id=EXCLUDED.config_id
AND evolution_credential_grant_uses.operation_id=EXCLUDED.operation_id
AND evolution_credential_grant_uses.config_digest=EXCLUDED.config_digest
+ AND evolution_credential_grant_uses.request_digest=EXCLUDED.request_digest
AND evolution_credential_grant_uses.redemption_count<2
AND evolution_credential_grant_uses.consumed_at>=NOW()-INTERVAL '2 minutes'
RETURNING jti`,
@@ -86,6 +89,7 @@ export async function GET(
configId,
payload.operation_id,
payload.llm_config_digest,
+ payload.request_digest,
],
);
} catch {
diff --git a/apps/dashboard/src/proxy.test.ts b/apps/dashboard/src/proxy.test.ts
new file mode 100644
index 00000000..af723d3f
--- /dev/null
+++ b/apps/dashboard/src/proxy.test.ts
@@ -0,0 +1,43 @@
+import { NextRequest } from "next/server";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next-intl/middleware", () => ({
+ default: () => vi.fn(() => new Response(null, { status: 200 })),
+}));
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+});
+
+/** Load middleware after setting production auth flags because they are module constants. */
+async function productionMiddleware() {
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("AUTH_ENABLED", "true");
+ return (await import("./proxy")).default;
+}
+
+describe("dashboard production proxy", () => {
+ it("lets the exact internal credential exchange reach its Ed25519 route verifier", async () => {
+ const middleware = await productionMiddleware();
+ const response = await middleware(
+ new NextRequest("http://dashboard.test/api/internal/llm-config/config-1", {
+ headers: { Authorization: "Bearer signed-grant" },
+ }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("x-middleware-next")).toBe("1");
+ });
+
+ it("keeps other cookie-less API paths behind the session gate", async () => {
+ const middleware = await productionMiddleware();
+ const response = await middleware(
+ new NextRequest("http://dashboard.test/api/evolution", {
+ headers: { Authorization: "Bearer signed-grant" },
+ }),
+ );
+
+ expect(response.status).toBe(401);
+ });
+});
diff --git a/apps/dashboard/src/proxy.ts b/apps/dashboard/src/proxy.ts
index bed66b32..91f56fa0 100644
--- a/apps/dashboard/src/proxy.ts
+++ b/apps/dashboard/src/proxy.ts
@@ -42,7 +42,11 @@ export default async function middleware(req: NextRequest): Promise {
const { pathname } = req.nextUrl;
const isApi = pathname.startsWith("/api");
// 公开:登录页 + 登录/登出/会话 API(否则登录前无从进入)。
- const isPublic = pathname === "/login" || pathname.startsWith("/api/auth/");
+ // Evolver 凭据兑换只接受路由内自行验签的 Ed25519 Bearer grant,不依赖浏览器 cookie。
+ const isCredentialExchange =
+ req.method === "GET" && /^\/api\/internal\/llm-config\/[^/]+$/.test(pathname);
+ const isPublic =
+ pathname === "/login" || pathname.startsWith("/api/auth/") || isCredentialExchange;
if (AUTH_ENABLED && !isPublic && !(await hasValidSession(req))) {
if (isApi) {
diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml
index c714a9f8..5afb6b59 100644
--- a/infra/docker-compose.prod.yml
+++ b/infra/docker-compose.prod.yml
@@ -172,6 +172,7 @@ services:
EVOLVER_LLM_TIMEOUT_S: ${EVOLVER_LLM_TIMEOUT_S:-120}
# 签名私钥只属于 orchestration;覆盖 svc-common env_file,防 Evolver 自签 owner grant。
EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64: ""
+ EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64: ${EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64:?required}
deploy:
replicas: 1
depends_on:
diff --git a/packages/orchestration/src/clients/evolver.ts b/packages/orchestration/src/clients/evolver.ts
index bc7c1229..9a8d68a3 100644
--- a/packages/orchestration/src/clients/evolver.ts
+++ b/packages/orchestration/src/clients/evolver.ts
@@ -1,4 +1,6 @@
/** services/evolver 的 owner-scoped API 客户端。 */
+import { createHash } from "node:crypto";
+
import { HttpClient, HttpClientError } from "./http.js";
import type { EvolutionLLMSnapshot } from "../mastra/llm/evolution-snapshot.js";
@@ -13,6 +15,66 @@ export type EvolutionConfig = {
validation_split?: number;
};
+export type EvolutionStartRequest = {
+ budget: number;
+ seed_strategy_id: string;
+ config: Required;
+ llm: EvolutionLLMSnapshot;
+};
+
+/** 将 tool 输入收口为签名与发送共用的唯一请求体。 */
+export function buildEvolutionStartRequest(options: {
+ budget?: number;
+ seedStrategyId?: string;
+ config: EvolutionConfig;
+ llmSnapshot: EvolutionLLMSnapshot;
+}): EvolutionStartRequest {
+ return {
+ budget: options.budget ?? 4,
+ seed_strategy_id: options.seedStrategyId ?? "sma_cross_v1",
+ config: {
+ ...options.config,
+ from_ts: new Date(options.config.from_ts).toISOString(),
+ as_of: new Date(options.config.as_of).toISOString(),
+ initial_cash: options.config.initial_cash ?? 10_000,
+ fee_rate: options.config.fee_rate ?? 0.001,
+ validation_split: options.config.validation_split ?? 0.3,
+ },
+ llm: options.llmSnapshot,
+ };
+}
+
+/** 生成与 Python 端一致的审批请求摘要,覆盖所有会影响成本或结果的字段。 */
+export function evolutionRequestDigest(request: EvolutionStartRequest): string {
+ const config = request.config;
+ const canonical = [
+ request.seed_strategy_id,
+ numberText(request.budget),
+ config.venue,
+ config.symbol,
+ config.timeframe,
+ numberText(Date.parse(config.from_ts)),
+ numberText(Date.parse(config.as_of)),
+ float64Hex(config.initial_cash),
+ float64Hex(config.fee_rate),
+ float64Hex(config.validation_split),
+ request.llm.config_digest,
+ ];
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
+}
+
+function numberText(value: number): string {
+ if (!Number.isFinite(value)) throw new Error("evolution request contains a non-finite number");
+ return String(value);
+}
+
+function float64Hex(value: number): string {
+ if (!Number.isFinite(value)) throw new Error("evolution request contains a non-finite number");
+ const buffer = Buffer.allocUnsafe(8);
+ buffer.writeDoubleBE(value);
+ return buffer.toString("hex");
+}
+
export type CandidateResult = {
candidate_id: string;
run_id: string;
@@ -41,6 +103,8 @@ export type RunStatusResult = {
seed_strategy_id: string;
budget: number;
config: Record;
+ llm_snapshot: EvolutionLLMSnapshot | null;
+ llm_config_digest: string | null;
status: "queued" | "running" | "cancelling" | "completed" | "failed" | "aborted";
active_stage: string | null;
llm_cost_usd: number;
@@ -68,30 +132,19 @@ export class EvolverClient {
}
async startRun(options: {
- budget?: number;
- seedStrategyId?: string;
- config: EvolutionConfig;
+ request: EvolutionStartRequest;
idempotencyKey: string;
- approvalToken: string;
credentialGrant: string;
- llmSnapshot: EvolutionLLMSnapshot;
}): Promise {
- const body = {
- budget: options.budget ?? 4,
- seed_strategy_id: options.seedStrategyId ?? "sma_cross_v1",
- config: options.config,
- llm: options.llmSnapshot,
- };
const headers = {
"Idempotency-Key": options.idempotencyKey,
- "X-Evolution-Approval": options.approvalToken,
"X-Evolution-Credential": options.credentialGrant,
};
try {
- return await this.http.post("/api/v1/runs", body, headers);
+ return await this.http.post("/api/v1/runs", options.request, headers);
} catch (error) {
if (!(error instanceof HttpClientError) || ![502, 504].includes(error.status)) throw error;
- return await this.http.post("/api/v1/runs", body, headers);
+ return await this.http.post("/api/v1/runs", options.request, headers);
}
}
diff --git a/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts b/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts
index b0374e41..97055905 100644
--- a/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts
+++ b/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts
@@ -16,6 +16,7 @@ const GRANT_TTL_SECONDS = 30 * 60 * 60;
export async function mintEvolutionCredentialGrant(args: {
authSub: string;
operationId: string;
+ requestDigest: string;
snapshot: EvolutionLLMSnapshot;
}): Promise {
const encoded = process.env.EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64?.trim();
@@ -38,11 +39,12 @@ export async function mintEvolutionCredentialGrant(args: {
config_id: args.snapshot.config_id,
provider: args.snapshot.provider,
operation_id: args.operationId,
+ request_digest: args.requestDigest,
llm_config_digest: args.snapshot.config_digest,
})
.setProtectedHeader({ alg: "EdDSA", typ: "JWT" })
.setSubject(args.authSub)
- .setAudience(GRANT_AUDIENCE)
+ .setAudience(["inalpha-evolver", GRANT_AUDIENCE])
.setJti(randomUUID())
.setIssuedAt(now)
.setExpirationTime(now + GRANT_TTL_SECONDS)
diff --git a/packages/orchestration/src/tools/evolver-shared.ts b/packages/orchestration/src/tools/evolver-shared.ts
index dd2218f2..14362a7f 100644
--- a/packages/orchestration/src/tools/evolver-shared.ts
+++ b/packages/orchestration/src/tools/evolver-shared.ts
@@ -1,8 +1,14 @@
/** Evolver Mastra tools 的共享 schema 与客户端解析。 */
import { z } from "zod";
-import { mintServiceToken, resolveRequestToken } from "../auth.js";
-import { EvolverClient } from "../clients/evolver.js";
+import { resolveRequestToken } from "../auth.js";
+import {
+ buildEvolutionStartRequest,
+ evolutionRequestDigest,
+ EvolverClient,
+ type EvolutionConfig,
+ type EvolutionStartRequest,
+} from "../clients/evolver.js";
import { getSettings } from "../config.js";
import { AUTH_SUB_KEY } from "../hooks/with-hooks.js";
import {
@@ -24,13 +30,14 @@ export async function getEvolverClient(ctx?: ToolRequestContext): Promise {
const operationId = getRequestContextValue(
{ requestContext: ctx },
@@ -44,26 +51,19 @@ export async function getApprovedEvolutionRunContext(
if (!operationId || !llmSnapshot || typeof authSub !== "string" || !authSub) {
throw new Error("explicit evolution approval context is missing");
}
- const approvalToken = await mintServiceToken(
- {
- sub: authSub,
- token_use: "evolution_approval",
- operation_id: operationId,
- llm_config_digest: llmSnapshot.config_digest,
- },
- 300,
- );
+ const request = buildEvolutionStartRequest({ ...input, llmSnapshot });
const credentialGrant = await mintEvolutionCredentialGrant({
authSub,
operationId,
+ requestDigest: evolutionRequestDigest(request),
snapshot: llmSnapshot,
});
return {
client: await getEvolverClient(ctx),
operationId,
- approvalToken,
credentialGrant,
llmSnapshot,
+ request,
};
}
diff --git a/packages/orchestration/src/tools/evolver.ts b/packages/orchestration/src/tools/evolver.ts
index 1405783a..04744ded 100644
--- a/packages/orchestration/src/tools/evolver.ts
+++ b/packages/orchestration/src/tools/evolver.ts
@@ -24,16 +24,13 @@ export const evolverRunEvolutionTool = createTool({
}),
execute: async (inputData, ctx) => {
const approved = await getApprovedEvolutionRunContext(
+ inputData,
ctx?.requestContext as ToolRequestContext | undefined,
);
return await approved.client.startRun({
- budget: inputData.budget,
- seedStrategyId: inputData.seedStrategyId,
- config: inputData.config,
+ request: approved.request,
idempotencyKey: approved.operationId,
- approvalToken: approved.approvalToken,
credentialGrant: approved.credentialGrant,
- llmSnapshot: approved.llmSnapshot,
});
},
});
diff --git a/packages/orchestration/tests/evolver-client.test.ts b/packages/orchestration/tests/evolver-client.test.ts
index f0825ee9..4f1d04a5 100644
--- a/packages/orchestration/tests/evolver-client.test.ts
+++ b/packages/orchestration/tests/evolver-client.test.ts
@@ -3,8 +3,11 @@ import { generateKeyPairSync } from "node:crypto";
import { jwtVerify } from "jose";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { verifyToken } from "../src/auth.js";
-import { EvolverClient } from "../src/clients/evolver.js";
+import {
+ buildEvolutionStartRequest,
+ evolutionRequestDigest,
+ EvolverClient,
+} from "../src/clients/evolver.js";
import { AUTH_SUB_KEY } from "../src/hooks/with-hooks.js";
import {
APPROVAL_OPERATION_ID_KEY,
@@ -32,9 +35,10 @@ function response(status: number): Response {
}
function options() {
- return {
+ const request = buildEvolutionStartRequest({
budget: 1,
seedStrategyId: "sma_cross_v1",
+ llmSnapshot: snapshot,
config: {
venue: "binance",
symbol: "BTCUSDT",
@@ -43,10 +47,11 @@ function options() {
as_of: "2026-08-02T00:00:00Z",
initial_cash: 10_000,
},
+ });
+ return {
+ request,
idempotencyKey: "approval-operation-1",
- approvalToken: "approval-token",
credentialGrant: "credential-grant",
- llmSnapshot: snapshot,
};
}
@@ -56,7 +61,7 @@ afterEach(() => {
});
describe("EvolverClient", () => {
- it("mints a short-lived approval JWT bound to owner, operation, and snapshot", async () => {
+ it("mints one Ed25519 grant bound to owner, operation, snapshot, and request", async () => {
const keys = generateKeyPairSync("ed25519");
vi.stubEnv(
"EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64",
@@ -68,27 +73,25 @@ describe("EvolverClient", () => {
[USER_LLM_SNAPSHOT_KEY, snapshot],
]);
- const approved = await getApprovedEvolutionRunContext(requestContext);
- const payload = await verifyToken(approved.approvalToken);
+ const input = {
+ budget: 1,
+ seedStrategyId: "sma_cross_v1",
+ config: options().request.config,
+ };
+ const approved = await getApprovedEvolutionRunContext(input, requestContext);
const { payload: credential } = await jwtVerify(
approved.credentialGrant,
keys.publicKey,
{ algorithms: ["EdDSA"], audience: "inalpha-dashboard-credential" },
);
- expect(payload).toMatchObject({
- sub: "user:alice",
- token_use: "evolution_approval",
- operation_id: "approval-operation-1",
- llm_config_digest: snapshot.config_digest,
- });
- expect(Number(payload.exp) - Number(payload.iat)).toBe(300);
expect(credential).toMatchObject({
sub: "user:alice",
token_use: "evolution_credential",
config_id: "config-1",
operation_id: "approval-operation-1",
llm_config_digest: snapshot.config_digest,
+ request_digest: evolutionRequestDigest(approved.request),
});
expect(Number(credential.exp) - Number(credential.iat)).toBe(108_000);
});
@@ -112,9 +115,7 @@ describe("EvolverClient", () => {
expect((init.headers as Record)["Idempotency-Key"]).toBe(
"approval-operation-1",
);
- expect((init.headers as Record)["X-Evolution-Approval"]).toBe(
- "approval-token",
- );
+ expect((init.headers as Record)["X-Evolution-Approval"]).toBeUndefined();
expect((init.headers as Record)["X-Evolution-Credential"]).toBe(
"credential-grant",
);
@@ -122,6 +123,30 @@ describe("EvolverClient", () => {
}
});
+ it("normalizes defaults and timezone offsets before computing the request digest", () => {
+ const request = buildEvolutionStartRequest({
+ config: {
+ venue: "binance",
+ symbol: "BTCUSDT",
+ timeframe: "1h",
+ from_ts: "2026-08-01T08:00:00+08:00",
+ as_of: "2026-08-02T08:00:00+08:00",
+ },
+ llmSnapshot: snapshot,
+ });
+
+ expect(request.config).toMatchObject({
+ from_ts: "2026-08-01T00:00:00.000Z",
+ as_of: "2026-08-02T00:00:00.000Z",
+ initial_cash: 10_000,
+ fee_rate: 0.001,
+ validation_split: 0.3,
+ });
+ expect(evolutionRequestDigest(request)).toBe(
+ "0dd54caf902703e4303f252066bd9ad3ec1525c67cdab5bf1353befaefb48a2e",
+ );
+ });
+
it("retries a 502 once but does not retry other client errors", async () => {
const retryable = vi
.fn()
diff --git a/services/evolver/pyproject.toml b/services/evolver/pyproject.toml
index 36c3ca08..ffeb859f 100644
--- a/services/evolver/pyproject.toml
+++ b/services/evolver/pyproject.toml
@@ -13,6 +13,7 @@ dependencies = [
"pydantic>=2.13.0",
"pydantic-settings>=2.14.0",
"psycopg[binary,pool]>=3.3.0",
+ "cryptography>=46.0.0",
"openai>=2.38.0",
"unidiff>=0.7.0",
]
diff --git a/services/evolver/src/inalpha_evolver/api/approval.py b/services/evolver/src/inalpha_evolver/api/approval.py
index c94185be..c00cb394 100644
--- a/services/evolver/src/inalpha_evolver/api/approval.py
+++ b/services/evolver/src/inalpha_evolver/api/approval.py
@@ -1,13 +1,18 @@
-"""Mastra 显式审批断言验证。"""
+"""Orchestration Ed25519 evolution capability verification."""
from __future__ import annotations
+import base64
+
import jwt
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+from cryptography.hazmat.primitives.serialization import load_der_public_key
from fastapi import HTTPException
from ..config import EvolverSettings
-_MAX_APPROVAL_TTL_SECONDS = 300
+_GRANT_AUDIENCE = "inalpha-evolver"
+_MAX_GRANT_TTL_SECONDS = 30 * 60 * 60
def verify_evolution_approval(
@@ -15,35 +20,53 @@ def verify_evolution_approval(
*,
owner_sub: str,
operation_id: str,
+ config_id: str,
+ provider: str,
llm_config_digest: str,
+ request_digest: str,
settings: EvolverSettings,
) -> None:
- """验证短效审批 JWT,并绑定 owner、幂等操作和 LLM 快照。"""
+ """Verify one owner/request-bound grant without giving Evolver signing authority."""
try:
+ encoded_key = settings.evolution_credential_public_key_b64.strip()
+ if not encoded_key:
+ raise HTTPException(status_code=503, detail="evolution grant verifier unavailable")
+ loaded_key = load_der_public_key(base64.b64decode(encoded_key, validate=True))
+ if not isinstance(loaded_key, Ed25519PublicKey):
+ raise ValueError("evolution grant key must be Ed25519")
+ public_key = loaded_key
payload = jwt.decode(
token,
- settings.jwt_secret,
- algorithms=[settings.jwt_algorithm],
- options={"require": ["sub", "exp", "iat"]},
+ public_key,
+ algorithms=["EdDSA"],
+ audience=_GRANT_AUDIENCE,
+ options={"require": ["sub", "jti", "aud", "exp", "iat"]},
)
+ except HTTPException:
+ raise
+ except (ValueError, TypeError) as exc:
+ raise HTTPException(status_code=503, detail="evolution grant verifier unavailable") from exc
except jwt.PyJWTError as exc:
- raise HTTPException(status_code=401, detail="invalid evolution approval") from exc
+ raise HTTPException(status_code=401, detail="invalid evolution grant") from exc
expected = {
- "token_use": "evolution_approval",
+ "token_use": "evolution_credential",
"sub": owner_sub,
"operation_id": operation_id,
+ "config_id": config_id,
+ "provider": provider,
"llm_config_digest": llm_config_digest,
+ "request_digest": request_digest,
}
issued_at = payload.get("iat")
expires_at = payload.get("exp")
invalid_ttl = (
not isinstance(issued_at, int)
or not isinstance(expires_at, int)
- or expires_at - issued_at > _MAX_APPROVAL_TTL_SECONDS
+ or expires_at - issued_at > _MAX_GRANT_TTL_SECONDS
or expires_at <= issued_at
)
if invalid_ttl or any(payload.get(key) != value for key, value in expected.items()):
- raise HTTPException(status_code=403, detail="evolution approval scope mismatch")
+ raise HTTPException(status_code=403, detail="evolution grant scope mismatch")
__all__ = ["verify_evolution_approval"]
diff --git a/services/evolver/src/inalpha_evolver/api/request_hash.py b/services/evolver/src/inalpha_evolver/api/request_hash.py
index 325e93b9..7bbe40f2 100644
--- a/services/evolver/src/inalpha_evolver/api/request_hash.py
+++ b/services/evolver/src/inalpha_evolver/api/request_hash.py
@@ -3,10 +3,14 @@
import hashlib
import json
+import struct
+from datetime import UTC, datetime
from typing import Any
from .schemas import StartRunRequest
+_UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
+
def normalized_request(request: StartRunRequest) -> tuple[dict[str, Any], str]:
payload = request.model_dump(mode="json")
@@ -17,4 +21,38 @@ def normalized_request(request: StartRunRequest) -> tuple[dict[str, Any], str]:
return config, digest
-__all__ = ["normalized_request"]
+def approval_request_digest(request: StartRunRequest) -> str:
+ """Compute the cross-language digest covered by the Ed25519 capability."""
+ config = request.config
+ canonical = [
+ request.seed_strategy_id,
+ _number_text(request.budget),
+ config.venue,
+ config.symbol,
+ config.timeframe,
+ _number_text(_epoch_milliseconds(config.from_ts)),
+ _number_text(_epoch_milliseconds(config.as_of)),
+ _float64_hex(config.initial_cash),
+ _float64_hex(config.fee_rate),
+ _float64_hex(config.validation_split),
+ request.llm.config_digest,
+ ]
+ return hashlib.sha256(
+ json.dumps(canonical, ensure_ascii=False, separators=(",", ":")).encode()
+ ).hexdigest()
+
+
+def _number_text(value: int) -> str:
+ return str(value)
+
+
+def _float64_hex(value: float) -> str:
+ return struct.pack(">d", value).hex()
+
+
+def _epoch_milliseconds(value: datetime) -> int:
+ delta = value - _UNIX_EPOCH
+ return delta.days * 86_400_000 + delta.seconds * 1_000 + delta.microseconds // 1_000
+
+
+__all__ = ["approval_request_digest", "normalized_request"]
diff --git a/services/evolver/src/inalpha_evolver/api/run_routes.py b/services/evolver/src/inalpha_evolver/api/run_routes.py
index 9b658889..76cda8b7 100644
--- a/services/evolver/src/inalpha_evolver/api/run_routes.py
+++ b/services/evolver/src/inalpha_evolver/api/run_routes.py
@@ -17,7 +17,7 @@
from .approval import verify_evolution_approval
from .cursor import decode_cursor, encode_cursor
from .presenters import run_response
-from .request_hash import normalized_request
+from .request_hash import approval_request_digest, normalized_request
from .schemas import RunListResponse, RunStatusResponse, StartRunRequest
router = APIRouter()
@@ -31,10 +31,6 @@ async def start_run(
db: DBConn,
user: Annotated[User, Depends(get_current_user)],
idempotency_key: Annotated[str, Header(alias="Idempotency-Key", min_length=8, max_length=128)],
- evolution_approval: Annotated[
- str,
- Header(alias="X-Evolution-Approval", min_length=20, max_length=4096),
- ],
evolution_credential: Annotated[
str,
Header(alias="X-Evolution-Credential", min_length=100, max_length=4096),
@@ -42,14 +38,17 @@ async def start_run(
) -> RunStatusResponse:
owner = account_id_from_user(user)
settings = get_evolver_settings()
+ config, request_hash = normalized_request(body)
verify_evolution_approval(
- evolution_approval,
+ evolution_credential,
owner_sub=user.user_id,
operation_id=idempotency_key,
+ config_id=body.llm.config_id,
+ provider=body.llm.provider,
llm_config_digest=body.llm.config_digest,
+ request_digest=approval_request_digest(body),
settings=settings,
)
- config, request_hash = normalized_request(body)
async with db.transaction():
await db.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (str(owner),))
active = await run_queries.count_active(db, owner)
diff --git a/services/evolver/src/inalpha_evolver/config.py b/services/evolver/src/inalpha_evolver/config.py
index 4e881f5f..5d052461 100644
--- a/services/evolver/src/inalpha_evolver/config.py
+++ b/services/evolver/src/inalpha_evolver/config.py
@@ -62,7 +62,7 @@ class EvolverSettings(BaseSettings):
alias="EVOLVER_QUEUE_TIMEOUT_S",
ge=60,
le=86400,
- description="queued run 最长等待时间;必须短于凭据 grant 的 48 小时有效期。",
+ description="queued run 最长等待时间;必须短于凭据 grant 的 30 小时有效期。",
)
evolver_account_active_limit: int = Field(
default=2,
@@ -87,6 +87,11 @@ class EvolverSettings(BaseSettings):
jwt_secret: str = Field(default="dev-secret", alias="JWT_SECRET")
jwt_algorithm: str = Field(default="HS256", alias="JWT_ALGORITHM")
service_token_ttl_s: int = Field(default=3600, ge=60, le=86400)
+ evolution_credential_public_key_b64: str = Field(
+ default="",
+ alias="EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64",
+ description="Orchestration Ed25519 签名公钥(SPKI DER base64)。",
+ )
# ---- LLM ----
llm_api_key: str = Field(
diff --git a/services/evolver/tests/llm_snapshot_fixtures.py b/services/evolver/tests/llm_snapshot_fixtures.py
index 6b46c5b4..b1bbb832 100644
--- a/services/evolver/tests/llm_snapshot_fixtures.py
+++ b/services/evolver/tests/llm_snapshot_fixtures.py
@@ -2,10 +2,13 @@
from __future__ import annotations
+import base64
import copy
import time
import jwt
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
VALID_LLM_SNAPSHOT = {
"config_id": "config-1",
@@ -24,6 +27,13 @@
"config_digest": "a4635b0c80f69b6054bdc2330b78cb98d9c81c849d476e7d01f1b8d626015c2c",
}
+EVOLUTION_GRANT_PRIVATE_KEY = Ed25519PrivateKey.generate()
+EVOLUTION_GRANT_PUBLIC_KEY_B64 = base64.b64encode(
+ EVOLUTION_GRANT_PRIVATE_KEY.public_key().public_bytes(
+ Encoding.DER, PublicFormat.SubjectPublicKeyInfo
+ )
+).decode()
+
def llm_snapshot() -> dict:
"""返回可安全修改的有效快照副本。"""
@@ -34,20 +44,25 @@ def approval_token(
*,
subject: str,
operation_id: str,
- secret: str,
+ request_digest: str,
digest: str = VALID_LLM_SNAPSHOT["config_digest"],
) -> str:
- """签发与 orchestration 相同 scope 的短效审批断言。"""
+ """签发与 orchestration 相同 scope 的 Ed25519 grant。"""
now = int(time.time())
return jwt.encode(
{
"sub": subject,
- "token_use": "evolution_approval",
+ "token_use": "evolution_credential",
+ "aud": ["inalpha-evolver", "inalpha-dashboard-credential"],
+ "jti": "11111111-1111-4111-8111-111111111111",
"operation_id": operation_id,
+ "config_id": VALID_LLM_SNAPSHOT["config_id"],
+ "provider": VALID_LLM_SNAPSHOT["provider"],
"llm_config_digest": digest,
+ "request_digest": request_digest,
"iat": now,
- "exp": now + 300,
+ "exp": now + 30 * 60 * 60,
},
- secret,
- algorithm="HS256",
+ EVOLUTION_GRANT_PRIVATE_KEY,
+ algorithm="EdDSA",
)
diff --git a/services/evolver/tests/test_api_contract.py b/services/evolver/tests/test_api_contract.py
index 396021ed..6ccb3e9f 100644
--- a/services/evolver/tests/test_api_contract.py
+++ b/services/evolver/tests/test_api_contract.py
@@ -9,7 +9,7 @@
from fastapi.testclient import TestClient
from inalpha_evolver.api.presenters import candidate_response, run_response
-from inalpha_evolver.api.request_hash import normalized_request
+from inalpha_evolver.api.request_hash import approval_request_digest, normalized_request
from inalpha_evolver.api.schemas import (
EvolutionConfig,
EvolutionLLMSnapshot,
@@ -45,6 +45,13 @@ def test_request_hash_is_stable_and_payload_sensitive() -> None:
assert hash_a != hash_c
+def test_approval_request_digest_matches_typescript_contract() -> None:
+ assert (
+ approval_request_digest(_request())
+ == "c3084e0f6daee93abc87dd3dc5804295e70649f33fcd2b3613cf6de71e876d88"
+ )
+
+
@pytest.mark.parametrize(
("path", "value"),
[
diff --git a/services/evolver/tests/test_approval.py b/services/evolver/tests/test_approval.py
index b15f3652..ca8ccb18 100644
--- a/services/evolver/tests/test_approval.py
+++ b/services/evolver/tests/test_approval.py
@@ -2,39 +2,51 @@
from __future__ import annotations
+import base64
import time
from types import SimpleNamespace
import jwt
import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from fastapi import HTTPException
from inalpha_evolver.api.approval import verify_evolution_approval
-_SECRET = "approval-unit-test-secret-at-least-32-bytes"
_DIGEST = "a" * 64
+_REQUEST_DIGEST = "b" * 64
+_PRIVATE_KEY = Ed25519PrivateKey.generate()
+_PUBLIC_KEY_B64 = base64.b64encode(
+ _PRIVATE_KEY.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
+).decode()
def _token(
ttl_seconds: int,
*,
overrides: dict[str, object] | None = None,
- secret: str = _SECRET,
+ private_key: Ed25519PrivateKey = _PRIVATE_KEY,
) -> str:
now = int(time.time())
payload: dict[str, object] = {
"sub": "user:alice",
- "token_use": "evolution_approval",
+ "token_use": "evolution_credential",
+ "aud": ["inalpha-evolver", "inalpha-dashboard-credential"],
+ "jti": "11111111-1111-4111-8111-111111111111",
"operation_id": "approval-operation-1",
+ "config_id": "config-1",
+ "provider": "deepseek",
"llm_config_digest": _DIGEST,
+ "request_digest": _REQUEST_DIGEST,
"iat": now,
"exp": now + ttl_seconds,
}
payload.update(overrides or {})
return jwt.encode(
payload,
- secret,
- algorithm="HS256",
+ private_key,
+ algorithm="EdDSA",
)
@@ -43,16 +55,21 @@ def _verify(token: str) -> None:
token,
owner_sub="user:alice",
operation_id="approval-operation-1",
+ config_id="config-1",
+ provider="deepseek",
llm_config_digest=_DIGEST,
- settings=SimpleNamespace(jwt_secret=_SECRET, jwt_algorithm="HS256"), # type: ignore[arg-type]
+ request_digest=_REQUEST_DIGEST,
+ settings=SimpleNamespace( # type: ignore[arg-type]
+ evolution_credential_public_key_b64=_PUBLIC_KEY_B64
+ ),
)
-def test_approval_accepts_only_short_lived_matching_scope() -> None:
- _verify(_token(300))
+def test_approval_accepts_only_bounded_matching_scope() -> None:
+ _verify(_token(30 * 60 * 60))
with pytest.raises(HTTPException) as error:
- _verify(_token(301))
+ _verify(_token(30 * 60 * 60 + 1))
assert error.value.status_code == 403
@@ -61,14 +78,19 @@ def test_approval_rejects_another_owner() -> None:
token = jwt.encode(
{
"sub": "user:bob",
- "token_use": "evolution_approval",
+ "token_use": "evolution_credential",
+ "aud": ["inalpha-evolver", "inalpha-dashboard-credential"],
+ "jti": "11111111-1111-4111-8111-111111111111",
"operation_id": "approval-operation-1",
+ "config_id": "config-1",
+ "provider": "deepseek",
"llm_config_digest": _DIGEST,
+ "request_digest": _REQUEST_DIGEST,
"iat": now,
"exp": now + 300,
},
- _SECRET,
- algorithm="HS256",
+ _PRIVATE_KEY,
+ algorithm="EdDSA",
)
with pytest.raises(HTTPException) as error:
@@ -81,7 +103,10 @@ def test_approval_rejects_another_owner() -> None:
[
{"token_use": "session"},
{"operation_id": "another-operation"},
+ {"config_id": "config-2"},
+ {"provider": "openai"},
{"llm_config_digest": "b" * 64},
+ {"request_digest": "c" * 64},
{"iat": None},
{"exp": None},
],
@@ -95,7 +120,7 @@ def test_approval_rejects_invalid_claims(overrides: dict[str, object]) -> None:
def test_approval_rejects_expired_bad_signature_and_non_positive_ttl() -> None:
tokens = [
_token(-1),
- _token(300, secret="different-secret-at-least-32-bytes"),
+ _token(300, private_key=Ed25519PrivateKey.generate()),
_token(300, overrides={"iat": int(time.time()) + 300}),
]
for token in tokens:
diff --git a/services/evolver/tests/test_e2e.py b/services/evolver/tests/test_e2e.py
index 13541e27..42a1102c 100644
--- a/services/evolver/tests/test_e2e.py
+++ b/services/evolver/tests/test_e2e.py
@@ -11,15 +11,26 @@
import pytest
from fastapi.testclient import TestClient
+from inalpha_evolver.api.request_hash import approval_request_digest
+from inalpha_evolver.api.schemas import StartRunRequest
from inalpha_evolver.config import get_evolver_settings
from inalpha_evolver.main import app
-from .llm_snapshot_fixtures import approval_token, llm_snapshot
+from .llm_snapshot_fixtures import (
+ EVOLUTION_GRANT_PUBLIC_KEY_B64,
+ approval_token,
+ llm_snapshot,
+)
_SECRET = "evolver-test-secret-at-least-32-bytes-long"
-def _headers(key: str | None = None, *, include_approval: bool = True) -> dict[str, str]:
+def _headers(
+ payload: dict | None = None,
+ key: str | None = None,
+ *,
+ include_grant: bool = True,
+) -> dict[str, str]:
subject = f"test:{uuid4()}"
token = jwt.encode(
{"sub": subject, "exp": int(time.time()) + 3600},
@@ -29,12 +40,12 @@ def _headers(key: str | None = None, *, include_approval: bool = True) -> dict[s
headers = {"Authorization": f"Bearer {token}"}
if key:
headers["Idempotency-Key"] = key
- headers["X-Evolution-Credential"] = "signed-grant-" + "x" * 120
- if include_approval:
- headers["X-Evolution-Approval"] = approval_token(
+ if include_grant and payload is not None:
+ request = StartRunRequest.model_validate(payload)
+ headers["X-Evolution-Credential"] = approval_token(
subject=subject,
operation_id=key,
- secret=_SECRET,
+ request_digest=approval_request_digest(request),
)
return headers
@@ -46,6 +57,7 @@ def client() -> TestClient:
"postgresql+psycopg://quant:devpass@localhost:5433/inalpha_evo_test",
)
os.environ["JWT_SECRET"] = _SECRET
+ os.environ["EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64"] = EVOLUTION_GRANT_PUBLIC_KEY_B64
get_evolver_settings.cache_clear()
with TestClient(app) as value:
yield value
@@ -74,38 +86,52 @@ def test_business_endpoints_require_auth(client: TestClient) -> None:
def test_start_requires_idempotency_key(client: TestClient) -> None:
- assert client.post("/api/v1/runs", json=_payload(), headers=_headers()).status_code == 400
+ payload = _payload()
+ assert client.post("/api/v1/runs", json=payload, headers=_headers(payload)).status_code == 400
def test_start_requires_explicit_approval_assertion(client: TestClient) -> None:
- headers = _headers(f"api-test-{uuid4()}", include_approval=False)
- assert client.post("/api/v1/runs", json=_payload(), headers=headers).status_code == 400
+ payload = _payload()
+ headers = _headers(payload, f"api-test-{uuid4()}", include_grant=False)
+ assert client.post("/api/v1/runs", json=payload, headers=headers).status_code == 400
def test_start_rejects_approval_for_another_operation(client: TestClient) -> None:
key = f"api-test-{uuid4()}"
- headers = _headers(key)
- token = headers["X-Evolution-Approval"]
- payload = jwt.decode(token, _SECRET, algorithms=["HS256"])
- headers["X-Evolution-Approval"] = approval_token(
- subject=payload["sub"],
+ body = _payload()
+ headers = _headers(body, key)
+ token_payload = jwt.decode(
+ headers["X-Evolution-Credential"], options={"verify_signature": False}
+ )
+ headers["X-Evolution-Credential"] = approval_token(
+ subject=token_payload["sub"],
operation_id=f"other-{uuid4()}",
- secret=_SECRET,
+ request_digest=approval_request_digest(StartRunRequest.model_validate(body)),
)
- assert client.post("/api/v1/runs", json=_payload(), headers=headers).status_code == 403
+ assert client.post("/api/v1/runs", json=body, headers=headers).status_code == 403
+
+
+def test_start_rejects_business_request_tampered_after_approval(client: TestClient) -> None:
+ key = f"api-test-{uuid4()}"
+ payload = _payload()
+ headers = _headers(payload, key)
+ payload["budget"] = 2
+
+ assert client.post("/api/v1/runs", json=payload, headers=headers).status_code == 403
def test_start_rejects_tampered_snapshot_before_approval(client: TestClient) -> None:
key = f"api-test-{uuid4()}"
payload = _payload()
+ headers = _headers(payload, key)
payload["llm"]["model"] = "tampered-model"
- assert client.post("/api/v1/runs", json=payload, headers=_headers(key)).status_code == 400
+ assert client.post("/api/v1/runs", json=payload, headers=headers).status_code == 400
def test_start_returns_queued_and_is_idempotent(client: TestClient) -> None:
key = f"api-test-{uuid4()}"
- headers = _headers(key)
payload = _payload()
+ headers = _headers(payload, key)
first = client.post("/api/v1/runs", json=payload, headers=headers)
second = client.post("/api/v1/runs", json=payload, headers=headers)
diff --git a/services/evolver/uv.lock b/services/evolver/uv.lock
index 8ef26925..04df96aa 100644
--- a/services/evolver/uv.lock
+++ b/services/evolver/uv.lock
@@ -329,6 +329,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" },
]
+[[package]]
+name = "cryptography"
+version = "50.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
+ { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
+ { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
+ { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
+ { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
+ { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
+ { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
+ { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
+ { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
+ { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
+ { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
+ { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
+ { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
+ { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
+ { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
+ { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
+]
+
[[package]]
name = "distro"
version = "1.9.0"
@@ -590,6 +640,7 @@ name = "inalpha-evolver"
version = "0.2.0"
source = { editable = "." }
dependencies = [
+ { name = "cryptography" },
{ name = "fastapi" },
{ name = "inalpha-paper" },
{ name = "inalpha-shared" },
@@ -613,6 +664,7 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "cryptography", specifier = ">=46.0.0" },
{ name = "fastapi", specifier = ">=0.136.0" },
{ name = "inalpha-paper", editable = "../paper" },
{ name = "inalpha-shared", editable = "../_shared" },
From 6f97890b7aa99c36752863250be8119c900f65f8 Mon Sep 17 00:00:00 2001
From: Miro
Date: Thu, 27 Aug 2026 20:49:01 +0800
Subject: [PATCH 3/3] =?UTF-8?q?fix(dashboard):=20=E5=B1=95=E7=A4=BA?=
=?UTF-8?q?=E6=BC=94=E5=8C=96=E5=86=BB=E7=BB=93=E6=A8=A1=E5=9E=8B=E4=B8=8E?=
=?UTF-8?q?=E5=AE=9A=E4=BB=B7=E5=BF=AB=E7=85=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/dashboard/messages/en.json | 1 +
apps/dashboard/messages/zh.json | 1 +
.../components/evolution/EvolutionRunData.tsx | 22 ++++++++++++++++++-
apps/dashboard/src/lib/types.ts | 19 ++++++++++++++++
4 files changed, 42 insertions(+), 1 deletion(-)
diff --git a/apps/dashboard/messages/en.json b/apps/dashboard/messages/en.json
index effd80f8..df752273 100644
--- a/apps/dashboard/messages/en.json
+++ b/apps/dashboard/messages/en.json
@@ -580,6 +580,7 @@
"cost": "Cost",
"failure": "Failure code",
"dataset": "Frozen dataset",
+ "llmSnapshot": "Frozen LLM & pricing",
"seedReport": "Seed report",
"baseline": "Market baseline",
"notAvailable": "Not available yet",
diff --git a/apps/dashboard/messages/zh.json b/apps/dashboard/messages/zh.json
index 1174327c..5014bdfc 100644
--- a/apps/dashboard/messages/zh.json
+++ b/apps/dashboard/messages/zh.json
@@ -580,6 +580,7 @@
"cost": "费用",
"failure": "失败码",
"dataset": "冻结数据集",
+ "llmSnapshot": "冻结 LLM 与定价",
"seedReport": "种子报告",
"baseline": "市场基准",
"notAvailable": "尚不可用",
diff --git a/apps/dashboard/src/components/evolution/EvolutionRunData.tsx b/apps/dashboard/src/components/evolution/EvolutionRunData.tsx
index 910b4e4d..5468245b 100644
--- a/apps/dashboard/src/components/evolution/EvolutionRunData.tsx
+++ b/apps/dashboard/src/components/evolution/EvolutionRunData.tsx
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import type { EvolutionRun } from "@/lib/types";
import { Panel } from "@/components/ui/Panel";
-/** 展示 seed、基准、冻结数据 manifest 与失败信息。 */
+/** 展示 seed、冻结 LLM/数据快照、基准与失败信息。 */
export function EvolutionRunData({ run }: { run: EvolutionRun }) {
const t = useTranslations("evolution.detail");
return (
@@ -25,6 +25,13 @@ export function EvolutionRunData({ run }: { run: EvolutionRun }) {
)}
+
+
+
| null {
+ const snapshot = run.llm_snapshot;
+ if (!snapshot) return null;
+ return {
+ provider: snapshot.provider,
+ model: snapshot.model,
+ config_id: snapshot.config_id,
+ pricing_version: snapshot.pricing.version,
+ estimated_max_usd_per_candidate: snapshot.pricing.estimated_max_usd_per_candidate,
+ config_digest: run.llm_config_digest ?? snapshot.config_digest,
+ };
+}
+
function Item({ label, value }: { label: string; value: string }) {
return {label}{value};
}
diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts
index 8395056a..2e8f5f54 100644
--- a/apps/dashboard/src/lib/types.ts
+++ b/apps/dashboard/src/lib/types.ts
@@ -540,6 +540,23 @@ export type EvolutionRunStatus =
| "failed"
| "aborted";
+export interface EvolutionLLMSnapshot {
+ config_id: string;
+ provider: "deepseek" | "openai" | "kimi" | "zhipu";
+ model: string;
+ base_url: string | null;
+ pricing: {
+ version: string;
+ currency: "USD";
+ input_usd_per_million: number;
+ output_usd_per_million: number;
+ assumed_input_tokens: number;
+ max_output_tokens: number;
+ estimated_max_usd_per_candidate: number;
+ };
+ config_digest: string;
+}
+
export interface EvolutionCandidateSummary {
candidate_id: string;
run_id: string;
@@ -568,6 +585,8 @@ export interface EvolutionRunSummary {
seed_strategy_id: string;
budget: number;
config: Record;
+ llm_snapshot: EvolutionLLMSnapshot | null;
+ llm_config_digest: string | null;
status: EvolutionRunStatus;
active_stage: string | null;
llm_cost_usd: number;