feat(awooop): Phase 1-8 完整實作 — AwoooP Agent Platform 六平面架構
## Phase 1-3: Control Plane + Contract System - awooop_phase1_control_plane_2026-05-04.sql: 12 張核心表 + RLS - awooop_phase1_batch1_rls_2026-05-04.sql: 全部 FORCE RLS + GRANT - packages/awooop-contracts/: 六合約 JSON Schema + golden fixtures - src/models/awooop_contracts.py: Pydantic v2 contract models(extra=forbid) - src/repositories/contract_repository.py: contract lifecycle(draft→published→active) - src/services/contract_service.py: HMAC publish sig + Redis multi-sig activate - src/services/schema_validator.py: LLM output validator(retry×3, E-SCHEMA-001) ## Phase 2: Tenant Isolation - awooop_phase2_budget_ledger_2026-05-04.sql: budget_ledger + RLS - src/services/budget_service.py: Token Budget Hard Kill 三層防線 - src/core/context.py: PROJECT_ID ContextVar(31 background loop 自動繼承) - src/db/base.py + models.py: project_id 欄位 + RLS set_config 注入 - src/hermes/nl_gateway.py: project_id Redis key 前綴(Phase A 雙寫) - src/services/anomaly_counter.py: per-project 改造(Phase A fallback) ## Phase 4: Platform Shell in Shadow Mode - awooop_phase4_run_state_2026-05-04.sql: run_state + step_journal + idempotency - src/services/run_state_machine.py: 8-state FSM + SKIP LOCKED + stale reaper - src/services/platform_runtime.py: UUID v7 + W3C trace_id + shadow_execute - src/services/audit_sink.py: PII/secret redaction 9 patterns - src/api/v1/platform/runs.py: POST/GET /v1/platform/runs(Router→Service 架構) - src/workers/platform_worker.py: SKIP LOCKED worker + heartbeat + reaper loop - src/main.py: platform router + lifespan worker start/stop ## Phase 5: MCP Gateway 五閘門 - awooop_phase5_mcp_gateway_2026-05-04.sql: 4 表 + RLS - src/plugins/mcp/gateway.py: McpGateway(Gate 1~5, E-MCP-GATE-001~009) - src/plugins/mcp/redaction_middleware.py: 雙層 redaction + 16K 截斷 - src/plugins/mcp/registry.py: __provider name mangling(ADR-116) - src/plugins/mcp/credential_resolver.py: k8s secret ref 解析 - tests/test_mcp_credential_isolation.py: 10 個迴歸測試(secret leak 防再現) ## Phase 6-8: EwoooC + Channel Hub + Approval Token - awooop_phase6_ewoooc_onboarding_2026-05-04.sql: ewoooc tenant + 4 read-only MCP tools - awooop_phase7_channel_hub_2026-05-04.sql: conversation_event + outbound_message - src/services/provider_proxy.py: ProviderProxy + PlatformEnvelope(ADR-115) - src/services/channel_hub.py: Telegram inbound mirror + Progressive Feedback(30s) - src/services/awooop_approval_token.py: HS256 + jti NX replay 防護 + suggest mode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -309,3 +309,383 @@ class AwoooPProjectMigrationState(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 4: Run State Machine(ADR-114/ADR-119)
|
||||
# 2026-05-04 ogt + Claude Sonnet 4.6
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AwoooPRunState(Base):
|
||||
"""Run FSM 主表(SKIP LOCKED worker lease,ADR-114)"""
|
||||
|
||||
__tablename__ = "awooop_run_state"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"state IN ("
|
||||
"'pending','running','waiting_tool',"
|
||||
"'waiting_approval','completed','failed','cancelled','timeout')",
|
||||
name="chk_run_state",
|
||||
),
|
||||
Index("idx_run_state_pending", "project_id", "created_at",
|
||||
postgresql_where=text("state = 'pending' AND lease_until IS NULL")),
|
||||
Index("idx_run_state_stale", "lease_until",
|
||||
postgresql_where=text("state = 'running' AND lease_until IS NOT NULL")),
|
||||
Index("idx_run_state_project_timeline", "project_id", "created_at"),
|
||||
Index("idx_run_state_trace_id", "trace_id",
|
||||
postgresql_where=text("trace_id IS NOT NULL")),
|
||||
)
|
||||
|
||||
run_id: Mapped[UUID] = mapped_column(primary_key=True)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("awooop_projects.project_id"), nullable=False
|
||||
)
|
||||
agent_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
state: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
|
||||
lease_until: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
heartbeat_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
worker_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
attempt_count: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=3)
|
||||
trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
trigger_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
trigger_ref: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
is_shadow: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
input_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
output_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
cost_usd: Mapped[Decimal] = mapped_column(
|
||||
Numeric(10, 4), nullable=False, default=Decimal("0.0000")
|
||||
)
|
||||
step_count: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
|
||||
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
error_detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
timeout_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
|
||||
|
||||
class AwoooPRunStepJournal(Base):
|
||||
"""SAGA step journal(ADR-119)— 每個 tool call 獨立記錄"""
|
||||
|
||||
__tablename__ = "awooop_run_step_journal"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("run_id", "step_seq", name="uix_run_step_seq"),
|
||||
CheckConstraint(
|
||||
"result_status IN ('pending','success','failed','compensated')",
|
||||
name="chk_step_result_status",
|
||||
),
|
||||
Index("idx_run_step_run_id", "run_id", "step_seq"),
|
||||
)
|
||||
|
||||
step_id: Mapped[UUID] = mapped_column(
|
||||
primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
run_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("awooop_run_state.run_id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
step_seq: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
||||
tool_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
mcp_gateway_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
input_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
output_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
compensation_json: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
result_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
was_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
block_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
|
||||
class AwoooPRunIdempotency(Base):
|
||||
"""Run 去重冪等表(ADR-114)— (project_id, channel_type, provider_event_id) → run_id"""
|
||||
|
||||
__tablename__ = "awooop_run_idempotency"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"project_id", "channel_type", "provider_event_id",
|
||||
name="uix_run_idempotency_key",
|
||||
),
|
||||
Index("idx_run_idempotency_run_id", "run_id"),
|
||||
)
|
||||
|
||||
idempotency_id: Mapped[UUID] = mapped_column(
|
||||
primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
channel_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
provider_event_id: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
run_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("awooop_run_state.run_id"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Phase 5: MCP Gateway 四表(ADR-116/ADR-118,2026-05-04)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AwoooPMcpToolRegistry(Base):
|
||||
"""MCP Tool 白名單(Gate 3: Tool)"""
|
||||
|
||||
__tablename__ = "awooop_mcp_tool_registry"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"tool_type IN ('builtin','mcp_server','custom')",
|
||||
name="chk_tool_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"jsonb_typeof(allowed_scopes) = 'array'",
|
||||
name="chk_allowed_scopes_array",
|
||||
),
|
||||
UniqueConstraint("project_id", "tool_name", name="uix_tool_registry_project_name"),
|
||||
Index("idx_mcp_tool_registry_project", "project_id", "is_active"),
|
||||
)
|
||||
|
||||
tool_id: Mapped[UUID] = mapped_column(
|
||||
primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("awooop_projects.project_id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
tool_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
tool_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
allowed_scopes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
environment_tags: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
|
||||
|
||||
class AwoooPMcpGrant(Base):
|
||||
"""Agent × Tool 授權記錄(Gate 2 + Gate 3)"""
|
||||
|
||||
__tablename__ = "awooop_mcp_grants"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"jsonb_typeof(granted_scopes) = 'array'",
|
||||
name="chk_grant_scopes_array",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(is_revoked = FALSE AND revoked_at IS NULL AND revoked_by IS NULL)"
|
||||
" OR (is_revoked = TRUE AND revoked_at IS NOT NULL)",
|
||||
name="chk_revoke_consistency",
|
||||
),
|
||||
UniqueConstraint("project_id", "agent_id", "tool_id", name="uix_mcp_grant_agent_tool"),
|
||||
Index(
|
||||
"idx_mcp_grants_lookup", "project_id", "agent_id", "tool_id",
|
||||
postgresql_where=text("is_revoked = FALSE"),
|
||||
),
|
||||
)
|
||||
|
||||
grant_id: Mapped[UUID] = mapped_column(
|
||||
primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("awooop_projects.project_id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
agent_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
tool_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("awooop_mcp_tool_registry.tool_id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
granted_by: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
granted_scopes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
is_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
revoked_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
|
||||
|
||||
class AwoooPMcpCredentialRef(Base):
|
||||
"""k8s Secret 參照(ADR-118 credential isolation)— 只存路徑,不存明文"""
|
||||
|
||||
__tablename__ = "awooop_mcp_credential_refs"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
r"k8s_secret_ref ~ '^[a-z0-9-]+/[a-z0-9-]+#[a-zA-Z0-9_-]+$'",
|
||||
name="chk_k8s_ref_format",
|
||||
),
|
||||
CheckConstraint(
|
||||
r"value_sha256 IS NULL OR value_sha256 ~ '^[0-9a-f]{64}$'",
|
||||
name="chk_value_sha256_hex",
|
||||
),
|
||||
UniqueConstraint("tool_id", "k8s_secret_ref", name="uix_credential_ref_tool"),
|
||||
Index("idx_mcp_cred_refs_tool", "tool_id", postgresql_where=text("is_active = TRUE")),
|
||||
)
|
||||
|
||||
ref_id: Mapped[UUID] = mapped_column(
|
||||
primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
tool_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("awooop_mcp_tool_registry.tool_id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("awooop_projects.project_id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
k8s_secret_ref: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
value_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
rotated_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
|
||||
|
||||
class AwoooPMcpGatewayAudit(Base):
|
||||
"""MCP Gateway call 稽核日誌(ADR-116 P1-09)"""
|
||||
|
||||
__tablename__ = "awooop_mcp_gateway_audit"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"result_status IN ('success','blocked','failed','timeout')",
|
||||
name="chk_gateway_result_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"block_gate IS NULL OR (block_gate >= 1 AND block_gate <= 5)",
|
||||
name="chk_block_gate_range",
|
||||
),
|
||||
Index("idx_mcp_audit_run", "project_id", "run_id", "created_at"),
|
||||
Index(
|
||||
"idx_mcp_audit_blocked", "project_id", "block_gate", "created_at",
|
||||
postgresql_where=text("result_status = 'blocked'"),
|
||||
),
|
||||
)
|
||||
|
||||
call_id: Mapped[UUID] = mapped_column(
|
||||
primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
run_id: Mapped[UUID | None] = mapped_column(nullable=True)
|
||||
trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
tool_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("awooop_mcp_tool_registry.tool_id"), nullable=False
|
||||
)
|
||||
tool_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
credential_ref: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
input_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
output_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
gate_result: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
result_status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
block_gate: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
|
||||
block_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Phase 7: Channel Hub 雙表(ADR-106 channel_event family,2026-05-04)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AwoooPConversationEvent(Base):
|
||||
"""入站 Channel Event 鏡像(Telegram/LINE inbound,不儲存明文)"""
|
||||
|
||||
__tablename__ = "awooop_conversation_event"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"channel_type IN ('telegram','line','slack','api','internal')",
|
||||
name="chk_conv_event_channel_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"content_type IN ('text','photo','document','command','callback_query')",
|
||||
name="chk_conv_event_content_type",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"project_id", "channel_type", "provider_event_id",
|
||||
name="uix_conv_event_dedup",
|
||||
),
|
||||
Index("idx_conv_event_run", "project_id", "run_id", "received_at"),
|
||||
Index("idx_conv_event_subject", "project_id", "platform_subject_id", "received_at"),
|
||||
)
|
||||
|
||||
event_id: Mapped[UUID] = mapped_column(
|
||||
primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("awooop_projects.project_id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
channel_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
provider_event_id: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
platform_subject_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
channel_user_id: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
channel_chat_id: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
run_id: Mapped[UUID | None] = mapped_column(nullable=True)
|
||||
content_type: Mapped[str] = mapped_column(String(32), nullable=False, default="text")
|
||||
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
content_preview: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
attachment_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
is_duplicate: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
provider_ts: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
|
||||
|
||||
class AwoooPOutboundMessage(Base):
|
||||
"""出站訊息記錄(interim/final/approval_request + shadow status)"""
|
||||
|
||||
__tablename__ = "awooop_outbound_message"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"channel_type IN ('telegram','line','slack','api','internal')",
|
||||
name="chk_outbound_channel_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"message_type IN ('interim','final','error','approval_request')",
|
||||
name="chk_outbound_message_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"send_status IN ('pending','sent','failed','shadow')",
|
||||
name="chk_outbound_send_status",
|
||||
),
|
||||
Index("idx_outbound_msg_run", "project_id", "run_id", "queued_at"),
|
||||
Index(
|
||||
"idx_outbound_msg_pending", "project_id", "channel_type", "queued_at",
|
||||
postgresql_where=text("send_status = 'pending'"),
|
||||
),
|
||||
)
|
||||
|
||||
message_id: Mapped[UUID] = mapped_column(
|
||||
primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("awooop_projects.project_id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
run_id: Mapped[UUID] = mapped_column(nullable=False)
|
||||
conversation_event_id: Mapped[UUID | None] = mapped_column(nullable=True)
|
||||
channel_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
channel_chat_id: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
message_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
content_preview: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
provider_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
send_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
send_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
queued_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
triggered_by_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
waiting_since: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
|
||||
@@ -106,6 +106,11 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
try:
|
||||
# AwoooP Phase 2.3 (2026-05-04 ogt): SET LOCAL app.project_id 讓 RLS Policy 生效
|
||||
# 預設 'awoooi',多租戶路由將在 middleware 注入實際 project_id
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.project_id', 'awoooi', TRUE)")
|
||||
)
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
@@ -114,17 +119,30 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_context() -> AsyncGenerator[AsyncSession, None]:
|
||||
async def get_db_context(project_id: str | None = None) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Context manager for database session (non-FastAPI usage)
|
||||
|
||||
AwoooP Phase 2.3/2.4: 優先序 — 明確參數 > contextvar > "awoooi"
|
||||
- Phase 2.3: 啟用 RLS tenant isolation(SET LOCAL app.project_id)
|
||||
- Phase 2.4: 從 asyncio contextvar 讀取 background loop 的 project_id
|
||||
|
||||
Usage:
|
||||
async with get_db_context() as db:
|
||||
async with get_db_context() as db: # 繼承 contextvar 或預設 awoooi
|
||||
...
|
||||
async with get_db_context("other-tenant") as db: # 明確指定 tenant
|
||||
...
|
||||
"""
|
||||
from src.core.context import get_current_project_id
|
||||
effective_pid = project_id if project_id is not None else get_current_project_id()
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
try:
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.project_id', :pid, TRUE)"),
|
||||
{"pid": effective_pid},
|
||||
)
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
@@ -299,6 +317,62 @@ async def init_db() -> None:
|
||||
"ON timeline_events(incident_id);"
|
||||
))
|
||||
|
||||
# AwoooP Phase 2.6 (2026-05-04 ogt): budget_ledger 建表(ADR-120 Token Budget Hard Kill)
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS budget_ledger (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
project_id VARCHAR(64) NOT NULL DEFAULT 'awoooi',
|
||||
agent_id VARCHAR(128),
|
||||
run_id UUID,
|
||||
model VARCHAR(64),
|
||||
provider VARCHAR(32),
|
||||
prompt_tokens INT,
|
||||
completion_tokens INT,
|
||||
cost_usd NUMERIC(10, 4) NOT NULL DEFAULT 0.0000,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
"""))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_budget_ledger_project_date "
|
||||
"ON budget_ledger(project_id, recorded_at DESC);"
|
||||
))
|
||||
|
||||
# AwoooP Phase 2.3 (2026-05-04 ogt): 四表加 project_id(RLS 多租戶隔離)
|
||||
# 防禦性 ALTER — 已存在欄位為 no-op,安全。
|
||||
# Batch 1 RLS migration 執行後,app.project_id 由 get_db_context() 自動設置。
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE incidents "
|
||||
"ADD COLUMN IF NOT EXISTS project_id VARCHAR(64) NOT NULL DEFAULT 'awoooi';"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_incidents_project_id "
|
||||
"ON incidents (project_id);"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE knowledge_entries "
|
||||
"ADD COLUMN IF NOT EXISTS project_id VARCHAR(64) NOT NULL DEFAULT 'awoooi';"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_knowledge_entries_project_id "
|
||||
"ON knowledge_entries (project_id);"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE playbooks "
|
||||
"ADD COLUMN IF NOT EXISTS project_id VARCHAR(64) NOT NULL DEFAULT 'awoooi';"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_playbooks_project_id "
|
||||
"ON playbooks (project_id);"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE audit_logs "
|
||||
"ADD COLUMN IF NOT EXISTS project_id VARCHAR(64) NOT NULL DEFAULT 'awoooi';"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audit_logs_project_id "
|
||||
"ON audit_logs (project_id);"
|
||||
))
|
||||
|
||||
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 6 自我治理閉環
|
||||
# ADR-087: ai_governance_events 不可變 Event Sourcing 表
|
||||
# asyncpg 不允許 prepared statement 內多條指令,必須分開 execute
|
||||
|
||||
@@ -11,8 +11,9 @@ Schema 設計原則:
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
@@ -25,6 +26,7 @@ from sqlalchemy import (
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
text,
|
||||
@@ -34,6 +36,7 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as pg_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.db.base import Base
|
||||
@@ -368,6 +371,13 @@ class AuditLog(Base):
|
||||
default="default",
|
||||
nullable=False,
|
||||
)
|
||||
# AwoooP Phase 2.3 (2026-05-04 ogt): 多租戶隔離欄位,配合 Batch 1 RLS migration
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
default="awoooi",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Execution Result
|
||||
success: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
@@ -671,6 +681,13 @@ class IncidentRecord(Base):
|
||||
primary_key=True,
|
||||
comment="事件唯一識別碼 (如 INC-20260322-A1B2C3)",
|
||||
)
|
||||
# AwoooP Phase 2.3 (2026-05-04 ogt): 多租戶隔離欄位,配合 Batch 1 RLS migration
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
default="awoooi",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# === 狀態與嚴重度 ===
|
||||
status: Mapped[str] = mapped_column(
|
||||
@@ -813,6 +830,13 @@ class KnowledgeEntryRecord(Base):
|
||||
primary_key=True,
|
||||
default=generate_uuid,
|
||||
)
|
||||
# AwoooP Phase 2.3 (2026-05-04 ogt): 多租戶隔離欄位,配合 Batch 1 RLS migration
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
default="awoooi",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Core Fields
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
@@ -1075,6 +1099,13 @@ class PlaybookRecord(Base):
|
||||
String(36), primary_key=True,
|
||||
comment="Playbook 唯一識別碼 (PB-YYYYMMDD-XXXXXX)",
|
||||
)
|
||||
# AwoooP Phase 2.3 (2026-05-04 ogt): 多租戶隔離欄位,配合 Batch 1 RLS migration
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
default="awoooi",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Core Fields
|
||||
name: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
@@ -1612,3 +1643,45 @@ class AIProviderVersionHistory(Base):
|
||||
__table_args__ = (
|
||||
Index("ix_provider_version_captured", "provider", "captured_at"),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BudgetLedgerRecord — ADR-120 Token Budget Hard Kill(Phase 2.6)
|
||||
# 2026-05-04 ogt + Claude Sonnet 4.6
|
||||
# =============================================================================
|
||||
|
||||
class BudgetLedgerRecord(Base):
|
||||
"""
|
||||
LLM call 費用記帳表(ADR-120 D5)
|
||||
|
||||
每次 LLM call 完成後插入一筆記錄,供:
|
||||
- Tenant Budget 累計計算(Redis 快取,每分鐘從此表同步)
|
||||
- 儀表板消費統計
|
||||
- 告警閾值觸發(80% / 95% / 100%)
|
||||
"""
|
||||
__tablename__ = "budget_ledger"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
pg_UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=text("gen_random_uuid()"),
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, default="awoooi", index=True
|
||||
)
|
||||
agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
run_id: Mapped[UUID | None] = mapped_column(pg_UUID(as_uuid=True), nullable=True)
|
||||
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
provider: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
prompt_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
completion_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cost_usd: Mapped[Decimal] = mapped_column(
|
||||
Numeric(10, 4), nullable=False, default=Decimal("0.0000")
|
||||
)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=text("NOW()")
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_budget_ledger_project_date", "project_id", "recorded_at"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user