M1:镜生域后端闭环——灵胚/照心试演/启镜原子闸门/执镜关系/命台租约/魂籍签名导出/死亡冷静期(迁移 001,测试 7 用例)

This commit is contained in:
chenxin committed 2026-08-21 22:39:07 +08:00
1 parent c090893e63
commit 1cd0069d65
24 files changed
+1508 -9

No files matched your search

+4
View File
@@ -17,6 +17,8 @@ from app.api.v1.endpoints import (
goals,
health,
imports,
embryo_drafts,
jingsheng_characters,
kernel_runs,
lorebooks,
mcp,
@@ -40,6 +42,8 @@ api_router.include_router(character_import.router)
api_router.include_router(chat.router)
api_router.include_router(runs.router)
api_router.include_router(kernel_runs.router)
api_router.include_router(embryo_drafts.router)
api_router.include_router(jingsheng_characters.router)
api_router.include_router(memory.router)
api_router.include_router(lorebooks.router)
api_router.include_router(personality.router)
@@ -0,0 +1,141 @@
"""灵胚 API(镜生域,rewrite/1.0 M1)。对应 api-contracts.md §3.2。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains import jingsheng
from app.kernel import Kernel
from app.schemas.auth import CurrentUser
router = APIRouter(prefix="/embryo-drafts", tags=["jingsheng"])
class EmbryoCreate(BaseModel):
source: str = Field(default="original", description="original/adopted/imported/serendipity")
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=8000)
identity_baseline: dict = Field(default_factory=dict, description="道体锚点草稿")
class EmbryoPatch(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=8000)
identity_baseline: dict | None = None
class RehearseRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=8000)
def _embryo_view(embryo) -> dict: # noqa: ANN001 — ORM 行
return {
"id": embryo.id,
"status": embryo.status,
"source": embryo.source,
"name": embryo.name,
"description": embryo.description,
"identity_baseline": json.loads(embryo.identity_baseline_json or "{}"),
"rehearsals": json.loads(embryo.rehearsals_json or "[]"),
"created_at": embryo.created_at.isoformat(),
"updated_at": embryo.updated_at.isoformat(),
}
@router.get("")
async def list_embryo_drafts(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return [_embryo_view(e) for e in await jingsheng.list_embryos(user.owner_scope)]
@router.post("", status_code=201)
async def create_embryo_draft(
payload: EmbryoCreate, user: CurrentUser = Depends(get_current_user)
) -> dict:
embryo = await jingsheng.create_embryo(
owner_scope=user.owner_scope,
source=payload.source,
name=payload.name,
description=payload.description,
identity_baseline=payload.identity_baseline,
)
return _embryo_view(embryo)
@router.get("/{embryo_id}")
async def get_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return _embryo_view(await jingsheng.get_embryo(embryo_id, user.owner_scope))
@router.patch("/{embryo_id}")
async def patch_embryo_draft(
embryo_id: str, payload: EmbryoPatch, user: CurrentUser = Depends(get_current_user)
) -> dict:
embryo = await jingsheng.patch_embryo(
embryo_id,
user.owner_scope,
name=payload.name,
description=payload.description,
identity_baseline=payload.identity_baseline,
)
return _embryo_view(embryo)
@router.post("/{embryo_id}/rehearse")
async def rehearse_embryo(
embryo_id: str,
payload: RehearseRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> dict:
"""照心试演:幻境 Run,不产生人物经历;事件流经 /runs/{run_id}/stream。"""
embryo = await jingsheng.get_embryo(embryo_id, user.owner_scope)
if embryo.status == "activated":
from app.core.errors import AppError
raise AppError("HJ-1103", "灵胚已启镜,无需再试演。", status_code=409)
kernel: Kernel | None = getattr(request.app.state, "kernel", None)
if kernel is None:
from app.core.errors import AppError
raise AppError("service_unavailable", "Kernel 运行时未就绪。", status_code=503)
baseline = json.loads(embryo.identity_baseline_json or "{}")
system_contract = (
f"你是一位尚未降生的灵胚「{embryo.name}」,正在幻境中试演。"
f"身份锚点:{json.dumps(baseline, ensure_ascii=False)}"
)
run = await kernel.start_rehearsal_run(
embryo_id=embryo_id, prompt=payload.prompt, system_contract=system_contract
)
await jingsheng.record_rehearsal(
embryo_id, user.owner_scope, run_id=run.id, prompt=payload.prompt
)
return {"run_id": run.id, "kind": "rehearsal"}
@router.post("/{embryo_id}/activate")
async def activate_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""启镜:签发正式人物 ID、魂籍、执镜关系与命台。"""
character = await jingsheng.activate_embryo(embryo_id, user.owner_scope)
return {
"character_id": character.id,
"name": character.name,
"status": character.status,
"source": character.source,
"identity_record_version": 1,
}
@router.post("/{embryo_id}/abandon", status_code=204)
async def abandon_embryo_draft(
embryo_id: str, user: CurrentUser = Depends(get_current_user)
) -> None:
await jingsheng.abandon_embryo(embryo_id, user.owner_scope)
@@ -0,0 +1,220 @@
"""镜生域人物端点:魂籍导出与校验、死亡请求/撤销/落定、命台租约(api-contracts.md §3.2)。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from app.api.v1.deps import get_current_user
from app.core.config import settings
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.domains import jingsheng
from app.domains.jingsheng.identity import IdentityVerificationError, verify_identity_record
from app.domains.jingsheng.seat import SeatConflict, acquire_lease, emit_seat_event, release_lease
from app.models.character import Character
from app.models.jingsheng import IdentityRecord
from app.schemas.auth import CurrentUser
router = APIRouter(prefix="/characters", tags=["jingsheng"])
class DeathFinalizeRequest(BaseModel):
confirm_phrase: str = Field(description="须填入人物名以强确认")
class SeatLeaseRequest(BaseModel):
holder: str = Field(min_length=1, max_length=120, description="租约持有者标识(设备/实例)")
async def _owned_character(character_id: str, owner_scope: str) -> Character:
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character
# ── 魂籍 ──────────────────────────────────────────────
@router.get("/{character_id}/identity-records")
async def list_identity_records(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
result = await session.execute(
select(IdentityRecord)
.where(IdentityRecord.character_id == character_id)
.order_by(IdentityRecord.version.desc())
)
records = result.scalars().all()
return [
{
"id": r.id,
"version": r.version,
"digest": r.digest,
"created_at": r.created_at.isoformat(),
}
for r in records
]
@router.get("/{character_id}/identity-records/{record_id}")
async def get_identity_record(
character_id: str, record_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
record = await session.get(IdentityRecord, record_id)
if record is None or record.character_id != character_id:
raise AppError("not_found", "魂籍记录不存在。")
return {
"id": record.id,
"version": record.version,
"digest": record.digest,
"payload": json.loads(record.payload_json),
"created_at": record.created_at.isoformat(),
}
@router.post("/{character_id}/identity-records")
async def export_identity_record(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""导出新版本魂籍:身份/数据/运行状态三段分离。"""
character = await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
latest = await session.scalar(
select(IdentityRecord.version)
.where(IdentityRecord.character_id == character_id)
.order_by(IdentityRecord.version.desc())
.limit(1)
)
next_version = (latest or 0) + 1
record = jingsheng.build_identity_record(
character_id=character_id,
version=next_version,
identity={
"name": character.name,
"source": character.source,
"identity_baseline": json.loads(character.identity_baseline_json or "{}"),
"activated_at": character.activated_at.isoformat() if character.activated_at else None,
},
runtime_state={"status": character.status},
)
session.add(record)
await session.commit()
await session.refresh(record)
return {
"id": record.id,
"version": record.version,
"digest": record.digest,
"created_at": record.created_at.isoformat(),
}
@router.post("/{character_id}/identity-records/{record_id}/verify")
async def verify_record(
character_id: str, record_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
record = await session.get(IdentityRecord, record_id)
if record is None or record.character_id != character_id:
raise AppError("not_found", "魂籍记录不存在。")
try:
verify_identity_record(record)
except IdentityVerificationError as exc:
raise AppError("HJ-9000", f"魂籍校验失败:{exc.reason}", status_code=422) from exc
return {"valid": True, "version": record.version, "digest": record.digest}
# ── 死亡与冷静期 ──────────────────────────────────────────────
@router.post("/{character_id}/death-request")
async def request_death(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
character = await jingsheng.death_request(
character_id, user.owner_scope, calm_period_hours=settings.death_calm_period_hours
)
return {
"character_id": character.id,
"status": character.status,
"calm_period_until": character.calm_period_until.isoformat(),
}
@router.post("/{character_id}/death-cancel")
async def cancel_death(character_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
character = await jingsheng.death_cancel(character_id, user.owner_scope)
return {"character_id": character.id, "status": character.status}
@router.post("/{character_id}/death-finalize")
async def finalize_death(
character_id: str,
payload: DeathFinalizeRequest,
user: CurrentUser = Depends(get_current_user),
) -> dict:
character = await _owned_character(character_id, user.owner_scope)
if payload.confirm_phrase != character.name:
raise AppError("invalid_request", "确认短语与人物名不一致,拒绝落定。")
deceased = await jingsheng.death_finalize(character_id, user.owner_scope)
return {
"character_id": deceased.id,
"status": deceased.status,
"died_at": deceased.died_at.isoformat() if deceased.died_at else None,
}
# ── 命台租约 ──────────────────────────────────────────────
@router.post("/{character_id}/seat/acquire")
async def acquire_seat(
character_id: str, payload: SeatLeaseRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
try:
seat = await acquire_lease(
session,
character_id,
payload.holder,
ttl_seconds=settings.seat_lease_ttl_seconds,
)
except SeatConflict as exc:
current_holder = exc.seat.holder
await session.rollback()
# 留痕在事务回滚后发射(避免 SQLite 跨连接写锁冲突)
await emit_seat_event(
"seat.contested",
character_id,
current_holder,
None,
requester=payload.holder,
)
raise AppError("HJ-1101", f"命台被占用:{exc}", status_code=429) from exc
epoch, holder = seat.epoch, seat.holder
await session.commit()
await emit_seat_event("seat.acquired", character_id, holder, epoch)
return {"epoch": epoch, "holder": holder}
@router.post("/{character_id}/seat/release", status_code=204)
async def release_seat(
character_id: str, payload: SeatLeaseRequest, user: CurrentUser = Depends(get_current_user)
) -> None:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
released = await release_lease(session, character_id, payload.holder)
await session.commit()
if released:
await emit_seat_event("seat.released", character_id, payload.holder, None)
@@ -19,6 +19,11 @@ class RuntimeConfig(BaseSettings):
shenshi_base_url: str = "" # 神识服务地址;为空时 Kernel 使用进程内回声桩(M0 骨架)
shenshi_service_token: str = "" # 灵台→神识服务令牌(开发档可空)
shenshi_timeout_seconds: float = 120.0 # 神识流式调用超时(秒)
# ==================== 镜生域(rewrite/1.0 M1) ====================
identity_record_secret: str = "dev-identity-secret" # 魂籍签名密钥;正式部署必须改掉默认值
death_calm_period_hours: int = 72 # 死亡冷静期时长(小时),期内可撤销
seat_lease_ttl_seconds: int = 60 # 命台租约 TTL(秒)
# Agent 循环最大轮数:模型每轮仍带工具调用则继续,超限截断返回(见 agent.runtime.DEFAULT_MAX_ITERATIONS)
agent_max_iterations: int = 8
# 单次高风险工具确认挂起超时(秒):须小于 run_timeout_seconds,超时按拒绝处理;
+16
View File
@@ -115,6 +115,22 @@ def new_user_id() -> str:
return new_id("user_")
def new_embryo_id() -> str:
return new_id("embryo_")
def new_custodianship_id() -> str:
return new_id("cust_")
def new_seat_id() -> str:
return new_id("seat_")
def new_identity_record_id() -> str:
return new_id("identity_")
def new_role_id() -> str:
return new_id("rl_")
+5
View File
@@ -13,3 +13,8 @@ def to_iso(value: datetime | None) -> str | None:
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
def as_utc(value: datetime) -> datetime:
"""SQLite 读回 naive datetime 时补 UTC;PG(timezone=True) 读回 aware 则原样返回。"""
return value if value.tzinfo else value.replace(tzinfo=UTC)
+1
View File
@@ -0,0 +1 @@
"""六域领域层(rewrite/1.0)。镜生域先行,其余域随里程碑落地。"""
+37
View File
@@ -0,0 +1,37 @@
"""镜生域:灵胚、启镜、执镜关系、命台、魂籍、死亡与冷静期(data-model.md §1)。"""
from app.domains.jingsheng.identity import build_identity_record, verify_identity_record
from app.domains.jingsheng.seat import SeatConflict, acquire_lease, emit_seat_event, release_lease
from app.domains.jingsheng.service import (
abandon_embryo,
activate_embryo,
bind_event_store,
create_embryo,
death_cancel,
death_finalize,
death_request,
get_embryo,
list_embryos,
record_rehearsal,
)
from app.domains.jingsheng.service import patch_embryo
__all__ = [
"SeatConflict",
"abandon_embryo",
"acquire_lease",
"activate_embryo",
"bind_event_store",
"build_identity_record",
"create_embryo",
"death_cancel",
"death_finalize",
"death_request",
"emit_seat_event",
"get_embryo",
"list_embryos",
"patch_embryo",
"record_rehearsal",
"release_lease",
"verify_identity_record",
]
+82
View File
@@ -0,0 +1,82 @@
"""魂籍(portable_identity_record):可验证、版本化的身份与状态包。
完整性模型:
- payload 规范化序列化(排序键、紧凑分隔符)后取 SHA-256 摘要;
- 签名 = HMAC-SHA256(密钥, "{character_id}|{version}|{digest}"),绑定人物与版本;
- 校验:重算摘要与签名比对。篡改 payload 破坏摘要;把记录复制到别的人物/版本,
签名不匹配——文件相同或人物 ID 相同不足以冒充原主体(data-model.md §1)。
"""
from __future__ import annotations
import hashlib
import hmac
import json
from app.core.config import settings
from app.models.jingsheng import IdentityRecord
def _canonical(payload: dict) -> str:
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _digest(payload: dict) -> str:
return hashlib.sha256(_canonical(payload).encode("utf-8")).hexdigest()
def _signature(character_id: str, version: int, digest: str) -> str:
secret = settings.identity_record_secret
message = f"{character_id}|{version}|{digest}".encode("utf-8")
return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
def build_identity_record(
*,
character_id: str,
version: int,
identity: dict,
runtime_state: dict,
data_state: dict | None = None,
) -> IdentityRecord:
"""构建一条魂籍版本记录(调用方负责落库)。"""
payload = {
"spec": "hunjing.portable_identity_record.v1",
"character_id": character_id,
"version": version,
"identity": identity,
"data_state": data_state or {"note": "数据状态由各域自报(M1 占位)"},
"runtime_state": runtime_state,
}
digest = _digest(payload)
return IdentityRecord(
character_id=character_id,
version=version,
payload_json=json.dumps(payload, ensure_ascii=False),
digest=digest,
signature=_signature(character_id, version, digest),
)
class IdentityVerificationError(Exception):
"""校验失败:摘要或签名不匹配。"""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
def verify_identity_record(record: IdentityRecord) -> dict:
"""校验魂籍记录,返回规范化 payload;失败抛 IdentityVerificationError。"""
try:
payload = json.loads(record.payload_json)
except json.JSONDecodeError as exc:
raise IdentityVerificationError("payload 无法解析") from exc
if _digest(payload) != record.digest:
raise IdentityVerificationError("摘要不匹配(payload 已被篡改)")
expected = _signature(record.character_id, record.version, record.digest)
if not hmac.compare_digest(expected, record.signature):
raise IdentityVerificationError("签名不匹配(非本平台签发或被移植冒用)")
if payload.get("character_id") != record.character_id or payload.get("version") != record.version:
raise IdentityVerificationError("payload 声明的身份与记录不符")
return payload
+94
View File
@@ -0,0 +1,94 @@
"""命台租约(authoritative_runtime_seat):同一人物唯一权威运行位置。
- 活跃租约(未过期、非本人)存在时,其他持有者获取被拒(SeatConflict);
- 租约过期可接管,epoch 递增(旧持有者写入按 epoch 失效——M2 运行时接入);
- 事件(seat.acquired / released / contested)由 API 层在事务提交后发射,
避免事务内跨连接写冲突;M1 为 API 级租约,Kernel 前台串行仍为进程内锁,多进程一致在 M2 收口。
"""
from __future__ import annotations
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import as_utc, utc_now
from app.models.jingsheng import RuntimeSeat
class SeatConflict(Exception):
"""命台被其他持有者占用(HJ-1101 语义);调用方须留痕 seat.contested。"""
def __init__(self, seat: RuntimeSeat) -> None:
super().__init__(f"命台被 {seat.holder} 占用至 {seat.lease_expires_at}")
self.seat = seat
async def acquire_lease(
session: AsyncSession, character_id: str, holder: str, ttl_seconds: int = 60
) -> RuntimeSeat:
"""获取或续期命台租约;冲突抛 SeatConflict(不在此发事件,避免事务内跨连接写)。"""
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
if seat is None:
seat = RuntimeSeat(character_id=character_id, epoch=0)
session.add(seat)
now = utc_now()
if (
seat.holder is not None
and seat.holder != holder
and seat.lease_expires_at is not None
and as_utc(seat.lease_expires_at) > now
):
raise SeatConflict(seat)
# 仅"从其他持有者手中接管"递增 epoch;无人持有时首次获取沿用当前纪元
takeover = seat.holder is not None and seat.holder != holder
seat.holder = holder
seat.lease_expires_at = now + timedelta(seconds=ttl_seconds)
if takeover:
seat.epoch += 1
await session.flush()
return seat
async def release_lease(session: AsyncSession, character_id: str, holder: str) -> bool:
"""释放租约;返回是否实际释放(holder 不匹配时无操作)。"""
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
if seat is None or seat.holder != holder:
return False
seat.holder = None
seat.lease_expires_at = None
await session.flush()
return True
async def emit_seat_event(
event_type: str,
character_id: str,
holder: str | None,
epoch: int | None,
*,
requester: str | None = None,
) -> None:
"""事务外发射命台事件(一炁,visibility=internal)。"""
from app.modules.runtime.events import Event
from app.services.event_store import DbEventStore
from app.domains.jingsheng import service as jingsheng_service
store: DbEventStore | None = jingsheng_service._event_store # noqa: SLF001
if store is None:
return
payload: dict = {"holder": holder, "epoch": epoch}
if requester is not None:
payload["requester"] = requester
await store.append(
Event(
type=event_type,
source="jingsheng",
payload=payload,
visibility="internal",
run_id=None,
character_id=character_id,
)
)
+318
View File
@@ -0,0 +1,318 @@
"""镜生域服务:灵胚生命周期、启镜事务、死亡请求与冷静期。"""
from __future__ import annotations
import json
from datetime import datetime, UTC
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.core.time import as_utc, utc_now
from app.db.session import AsyncSessionLocal
from app.domains.jingsheng.identity import build_identity_record
from app.models.character import Character
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
from app.services.event_store import DbEventStore
# 事件由服务层经一炁落库(DbEventStore 由调用方注入;默认取进程级装配)
_event_store: DbEventStore | None = None
def bind_event_store(store: DbEventStore) -> None:
"""lifespan 装配时注入事件存储。"""
global _event_store
_event_store = store
async def _emit(
event_type: str,
*,
character_id: str | None,
owner_scope: str,
payload: dict,
visibility: str = "public",
) -> None:
from app.modules.runtime.events import Event
if _event_store is None:
return
await _event_store.append(
Event(
type=event_type,
source="jingsheng",
payload=payload,
visibility=visibility, # type: ignore[arg-type]
run_id=None,
character_id=character_id,
)
)
def _owned(embryo: EmbryoDraft, owner_scope: str) -> EmbryoDraft:
if embryo.owner_scope != owner_scope:
raise AppError("not_found", "灵胚不存在。")
return embryo
async def list_embryos(owner_scope: str) -> list[EmbryoDraft]:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(EmbryoDraft)
.where(EmbryoDraft.owner_scope == owner_scope, EmbryoDraft.status != "abandoned")
.order_by(EmbryoDraft.created_at.desc())
)
return list(result.scalars().all())
async def create_embryo(
*,
owner_scope: str,
source: str,
name: str,
description: str | None = None,
identity_baseline: dict | None = None,
) -> EmbryoDraft:
if source not in ("original", "adopted", "imported", "serendipity"):
raise AppError("invalid_request", "灵胚来源取值不合法。")
embryo = EmbryoDraft(
owner_scope=owner_scope,
source=source,
name=name,
description=description,
identity_baseline_json=json.dumps(identity_baseline or {}, ensure_ascii=False),
)
async with AsyncSessionLocal() as session:
session.add(embryo)
await session.commit()
await session.refresh(embryo)
await _emit(
"embryo.created",
character_id=None,
owner_scope=owner_scope,
payload={"embryo_id": embryo.id, "source": source},
)
return embryo
async def get_embryo(embryo_id: str, owner_scope: str) -> EmbryoDraft:
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
return _owned(embryo, owner_scope)
async def patch_embryo(
embryo_id: str,
owner_scope: str,
*,
name: str | None = None,
description: str | None = None,
identity_baseline: dict | None = None,
) -> EmbryoDraft:
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
if embryo.status == "activated":
raise AppError(
"HJ-1103", "灵胚已启镜,草稿不可再编辑。", status_code=409
)
if name is not None:
embryo.name = name
if description is not None:
embryo.description = description
if identity_baseline is not None:
embryo.identity_baseline_json = json.dumps(identity_baseline, ensure_ascii=False)
await session.commit()
await session.refresh(embryo)
return embryo
async def record_rehearsal(embryo_id: str, owner_scope: str, *, run_id: str, prompt: str) -> None:
"""照心试演引用落灵胚(内容在事件日志,不构成人物经历)。"""
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
rehearsals = json.loads(embryo.rehearsals_json)
rehearsals.append({"run_id": run_id, "prompt": prompt, "performed_at": utc_now().isoformat()})
embryo.rehearsals_json = json.dumps(rehearsals, ensure_ascii=False)
if embryo.status == "drafting":
embryo.status = "previewing"
await session.commit()
async def abandon_embryo(embryo_id: str, owner_scope: str) -> None:
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None:
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
if embryo.status == "activated":
raise AppError("HJ-1103", "灵胚已启镜,不能放弃;请走人物死亡流程。", status_code=409)
embryo.status = "abandoned"
await session.commit()
await _emit(
"embryo.abandoned",
character_id=None,
owner_scope=owner_scope,
payload={"embryo_id": embryo_id},
)
async def activate_embryo(embryo_id: str, owner_scope: str) -> Character:
"""启镜:单事务内签发人物、执镜关系、命台与魂籍 v1。
并发防护:以 ``UPDATE embryo_drafts SET status='confirming' WHERE status IN (可启镜态)``
的原子行数作为闸门——第二个并发启镜拿不到闸门即 409,不产生第二个人物。
"""
async with AsyncSessionLocal() as session:
embryo = await session.get(EmbryoDraft, embryo_id)
if embryo is None or embryo.status == "abandoned":
raise AppError("not_found", "灵胚不存在。")
_owned(embryo, owner_scope)
gate = await session.execute(
update(EmbryoDraft)
.where(EmbryoDraft.id == embryo_id, EmbryoDraft.status.in_(("drafting", "previewing", "confirming")))
.values(status="confirming")
)
if gate.rowcount != 1:
raise AppError(
"HJ-1103", "灵胚当前状态不允许启镜(可能已被并发启镜)。", status_code=409
)
baseline = json.loads(embryo.identity_baseline_json or "{}")
character = Character(
name=embryo.name,
owner_scope=owner_scope,
status="active",
source=embryo.source,
identity_baseline_json=embryo.identity_baseline_json,
profile_json=json.dumps({"description": embryo.description or ""}, ensure_ascii=False),
activated_at=utc_now(),
)
session.add(character)
await session.flush() # 取 character.id(commit 后属性过期,异步下不可再访问)
character_id = character.id
session.add(Custodianship(character_id=character.id, custodian_scope=owner_scope))
session.add(RuntimeSeat(character_id=character.id, epoch=1))
record = build_identity_record(
character_id=character.id,
version=1,
identity={
"name": character.name,
"source": character.source,
"identity_baseline": baseline,
"activated_at": character.activated_at.isoformat(),
},
runtime_state={"status": "active", "seat_epoch": 1},
)
session.add(record)
await session.flush() # 取魂籍 ID
record_id = record.id
source = embryo.source
embryo.status = "activated"
await session.commit()
await _emit(
"character.activated",
character_id=character_id,
owner_scope=owner_scope,
payload={"embryo_id": embryo_id, "source": source, "identity_record": record_id},
)
async with AsyncSessionLocal() as session:
created = await session.get(Character, character_id)
assert created is not None
return created
async def death_request(
character_id: str, owner_scope: str, *, calm_period_hours: int
) -> Character:
"""死亡请求:进入冷静期(death_pending),可撤销;冷静期内人物默认静修。"""
async with AsyncSessionLocal() as session:
character = await _owned_character(session, character_id, owner_scope)
if character.status == "deceased":
raise AppError("HJ-1103", "人物已死亡。", status_code=409)
now = utc_now()
character.status = "death_pending"
character.death_requested_at = now
character.calm_period_until = datetime.fromtimestamp(
now.timestamp() + calm_period_hours * 3600, tz=UTC
)
await session.commit()
await session.refresh(character)
await _emit(
"character.death_requested",
character_id=character_id,
owner_scope=owner_scope,
payload={"calm_period_until": character.calm_period_until.isoformat()},
)
return character
async def death_cancel(character_id: str, owner_scope: str) -> Character:
"""冷静期撤销:恢复 active。人物不得阻挠撤销(宪章)。"""
async with AsyncSessionLocal() as session:
character = await _owned_character(session, character_id, owner_scope)
if character.status != "death_pending":
raise AppError("HJ-1103", "人物不在死亡冷静期。", status_code=409)
character.status = "active"
character.death_requested_at = None
character.calm_period_until = None
await session.commit()
await session.refresh(character)
await _emit(
"character.death_cancelled",
character_id=character_id,
owner_scope=owner_scope,
payload={},
)
return character
async def death_finalize(character_id: str, owner_scope: str) -> Character:
"""死亡落定:身份时间线闭合(deceased),数据保留——数据清除是独立操作。"""
async with AsyncSessionLocal() as session:
character = await _owned_character(session, character_id, owner_scope)
if character.status != "death_pending":
raise AppError("HJ-1103", "人物不在死亡冷静期。", status_code=409)
assert character.calm_period_until is not None
if utc_now() < as_utc(character.calm_period_until):
raise AppError(
"HJ-1103",
f"冷静期未满(至 {character.calm_period_until.isoformat()}),不能落定死亡。",
status_code=422,
)
character.status = "deceased"
character.died_at = utc_now()
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
if seat is not None:
seat.holder = None
seat.lease_expires_at = None
await session.commit()
await session.refresh(character)
await _emit(
"character.deceased",
character_id=character_id,
owner_scope=owner_scope,
payload={"died_at": character.died_at.isoformat() if character.died_at else None},
)
return character
async def _owned_character(
session: AsyncSession, character_id: str, owner_scope: str
) -> Character:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character
+5
View File
@@ -98,6 +98,11 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
timeout_seconds=float(settings.run_timeout_seconds),
)
# 镜生域事件写入绑定一炁(灵胚/启镜/命台/死亡事件落 events 表)
from app.domains.jingsheng import bind_event_store
bind_event_store(_app.state.event_store)
# ── MCP 进程管理器(stdio 子进程生命周期管理) ──────────────
from app.integrations.mcp.process_manager import (
ProcessManager,
+1
View File
@@ -16,6 +16,7 @@ from app.models.governance import (
)
from app.models.idempotency import IdempotencyKey
from app.models.imports import CharacterImportJob, ImportItem
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
from app.models.lorebook import Lorebook, LorebookEntry
from app.models.mcp_servers import McpServer
from app.models.memory import Memory, MemoryLink
+10
View File
@@ -41,3 +41,13 @@ class Character(Base):
)
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# ── rewrite/1.0 镜生域增量列(data-model.md §1):来源、道体锚点、死亡与冷静期 ──
source: Mapped[str] = mapped_column(
String(40), nullable=False, default="original", server_default="original", index=True
)
identity_baseline_json: Mapped[str] = mapped_column(
Text, nullable=False, default="{}", server_default="{}"
)
death_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
calm_period_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
died_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+84
View File
@@ -0,0 +1,84 @@
"""镜生域模型:灵胚、执镜关系、命台、魂籍(data-model.md §1)。
- 灵胚只有预备身份,启镜(activate)才产生正式人物与执镜关系;
- 命台人物粒度唯一(UNIQUE(character_id)),租约到期可被接管,epoch 递增;
- 魂籍版本不可变(UNIQUE(character_id, version)),签名绑定人物与版本。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_custodianship_id, new_embryo_id, new_identity_record_id, new_seat_id
from app.core.time import utc_now
from app.db.base import Base
class EmbryoDraft(Base):
"""灵胚:启镜前的可审阅草稿。放弃灵胚是删除草稿,不是人物死亡。"""
__tablename__ = "embryo_drafts"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_embryo_id)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# drafting / previewing / confirming / activated / abandoned
status: Mapped[str] = mapped_column(String(40), nullable=False, default="drafting", index=True)
# original / adopted / imported / serendipity
source: Mapped[str] = mapped_column(String(40), nullable=False, default="original")
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
identity_baseline_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 照心试演引用:[{run_id, prompt, performed_at}],内容在事件日志,不入正式经历
rehearsals_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class Custodianship(Base):
"""执镜关系:治理责任(1.0 一对一),与缘契(covenants)严格分离。"""
__tablename__ = "custodianships"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_custodianship_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, unique=True, index=True
)
custodian_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class RuntimeSeat(Base):
"""命台:人物唯一权威运行位置;holder 持有租约,过期可接管,epoch 每次获取递增。"""
__tablename__ = "runtime_seats"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_seat_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, unique=True, index=True
)
epoch: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
holder: Mapped[str | None] = mapped_column(String(120))
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class IdentityRecord(Base):
"""魂籍:可验证、版本化的身份与状态包。版本不可变;复制不能凭相同 ID 冒充原人物。"""
__tablename__ = "identity_records"
__table_args__ = (UniqueConstraint("character_id", "version", name="uq_identity_char_version"),)
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_identity_record_id)
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, index=True
)
version: Mapped[int] = mapped_column(Integer, nullable=False)
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
digest: Mapped[str] = mapped_column(String(64), nullable=False)
signature: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
+8 -1
View File
@@ -14,11 +14,18 @@ class Run(Base):
__tablename__ = "runs"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_run_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
# rewrite/1.0:照心试演(kind=rehearsal)无正式人物,character_id 允许为空
character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), index=True, nullable=True
)
session_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("chat_sessions.id"), index=True
)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True)
# run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2)
kind: Mapped[str] = mapped_column(
String(40), nullable=False, default="foreground", server_default="foreground"
)
goal: Mapped[str | None] = mapped_column(String(300))
origin: Mapped[str] = mapped_column(String(40), nullable=False, default="chat")
model_hint: Mapped[str | None] = mapped_column(String(120))
+8 -2
View File
@@ -66,11 +66,17 @@ class RunResponse(BaseModel):
)
id: str = Field(description="运行 ID,前缀为 run_。")
character_id: str = Field(description="所属人物 ID。")
session_id: str | None = Field(description="关联会话 ID。")
character_id: str | None = Field(
default=None, description="所属人物 ID;照心试演(kind=rehearsal)无正式人物。"
)
session_id: str | None = Field(default=None, description="关联会话 ID。")
status: str = Field(
description="运行状态:queued / running / completed / failed / cancelled / interrupted。"
)
kind: str = Field(
default="foreground",
description="运行种类:foreground / background_task / awakening / rehearsal(契约词典 §8.2)。",
)
goal: str | None = Field(description="运行目标。")
origin: str = Field(description="运行来源,例如 chat。")
model_hint: str | None = Field(description="本次运行使用的逻辑模型名或能力槽位。")
+1
View File
@@ -187,6 +187,7 @@ def run_to_response(run: Run) -> RunResponse:
character_id=run.character_id,
session_id=run.session_id,
status=run.status,
kind=run.kind,
goal=run.goal,
origin=run.origin,
model_hint=run.model_hint,