Files

803 lines
33 KiB
Python

"""SQLAlchemy-backed task-analysis store.
Mirrors the memory implementation's normalisation: JSON payloads
(chain_snapshot / stages / seat_results / blocks / direction_notes) are stored
as LONGTEXT JSON strings; datetimes are ISO strings in dict form.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.task_analysis.base import TaskAnalysisStore
from deerflow.persistence.task_analysis.model import (
TaskAnalysisBindingRow,
TaskAnalysisContentVersionRow,
TaskAnalysisRunMessageRow,
TaskAnalysisRunRow,
TaskAnalysisSettingRow,
TaskAnalysisTaskRow,
TaskDataBlockDefRow,
TaskDataBlockRow,
TaskDataPeriodRow,
TaskDataSubmissionRow,
)
def _iso(value: datetime | str | None) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def _utcnow() -> datetime:
return datetime.now(UTC)
def _parse_dt(value: Any) -> datetime | None:
if value is None or isinstance(value, datetime):
return value
try:
return datetime.fromisoformat(str(value))
except ValueError:
return None
def _dump(value: Any) -> str | None:
return None if value is None else json.dumps(value, ensure_ascii=False)
def _load(value: str | None, default: Any) -> Any:
if value is None or value == "":
return default
try:
return json.loads(value)
except (TypeError, ValueError):
return default
def _task_dict(row: TaskAnalysisTaskRow) -> dict[str, Any]:
data = row.to_dict()
data["created_at"] = _iso(row.created_at)
data["updated_at"] = _iso(row.updated_at)
return data
def _binding_dict(row: TaskAnalysisBindingRow) -> dict[str, Any]:
data = row.to_dict()
data["chain_snapshot"] = _load(row.chain_snapshot, {})
data["direction_notes"] = _load(row.direction_notes, [])
for key in ("next_run_at", "last_run_at", "created_at", "updated_at"):
data[key] = _iso(getattr(row, key))
return data
def _run_dict(row: TaskAnalysisRunRow) -> dict[str, Any]:
data = row.to_dict()
data["scheduled_for"] = _iso(row.scheduled_for)
data["started_at"] = _iso(row.started_at)
data["finished_at"] = _iso(row.finished_at)
data["created_at"] = _iso(row.created_at)
data["updated_at"] = _iso(row.updated_at)
for key, default in (
("stages_snapshot", []),
("stage_goals_snapshot", []),
("seat_names_snapshot", {}),
("seat_results", {}),
):
data[key] = _load(getattr(row, key), default)
return data
def _message_dict(row: TaskAnalysisRunMessageRow) -> dict[str, Any]:
data = row.to_dict()
data["created_at"] = _iso(row.created_at)
return data
def _version_dict(row: TaskAnalysisContentVersionRow) -> dict[str, Any]:
data = row.to_dict()
data["blocks"] = _load(row.blocks, {})
data["created_at"] = _iso(row.created_at)
return data
def _block_def_dict(row: TaskDataBlockDefRow) -> dict[str, Any]:
data = row.to_dict()
data["created_at"] = _iso(row.created_at)
data["updated_at"] = _iso(row.updated_at)
return data
def _period_dict(row: TaskDataPeriodRow) -> dict[str, Any]:
data = row.to_dict()
data["opened_at"] = _iso(row.opened_at)
data["created_at"] = _iso(row.created_at)
return data
def _task_block_dict(row: TaskDataBlockRow) -> dict[str, Any]:
data = row.to_dict()
data["payload"] = _load(row.payload, {})
data["updated_at"] = _iso(row.updated_at)
data["created_at"] = _iso(row.created_at)
return data
def _submission_dict(row: TaskDataSubmissionRow) -> dict[str, Any]:
data = row.to_dict()
data["payload"] = _load(row.payload, {})
data["created_at"] = _iso(row.created_at)
return data
class TaskAnalysisRepository(TaskAnalysisStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
# ── tasks ───────────────────────────────────────────────────────────────
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
source = str(data["source_task_id"])
async with self._sf() as session:
existing = await session.execute(
select(TaskAnalysisTaskRow).where(TaskAnalysisTaskRow.source_task_id == source)
)
if existing.scalar_one_or_none() is not None:
raise ValueError(f"source_task_id already exists: {source}")
row = TaskAnalysisTaskRow(
id=str(data.get("id") or f"ta-{source}"),
source_task_id=source,
name=str(data.get("name") or ""),
description=data.get("description"),
)
session.add(row)
# 建任务即种 6 条数据块定义(块 key 定死,标题后续可改)。
from deerflow.persistence.task_analysis.data_blocks import default_block_defs
for definition in default_block_defs():
session.add(
TaskDataBlockDefRow(
id=f"tdbd-{row.id}-{definition['block_key']}",
task_id=row.id,
block_key=definition["block_key"],
title=definition["title"],
sort_order=definition["sort_order"],
)
)
await session.commit()
await session.refresh(row)
return _task_dict(row)
async def get_task(self, task_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(TaskAnalysisTaskRow, task_id)
return _task_dict(row) if row else None
async def get_task_by_source(self, source_task_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
result = await session.execute(
select(TaskAnalysisTaskRow).where(TaskAnalysisTaskRow.source_task_id == source_task_id)
)
row = result.scalar_one_or_none()
return _task_dict(row) if row else None
async def list_tasks(self) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(TaskAnalysisTaskRow).order_by(TaskAnalysisTaskRow.created_at.desc())
)
tasks = [_task_dict(row) for row in result.scalars()]
bindings = await session.execute(select(TaskAnalysisBindingRow))
by_task: dict[str, dict[str, Any]] = {}
for row in bindings.scalars():
by_task[row.external_task_id] = _binding_dict(row)
for task in tasks:
task["binding"] = by_task.get(task["id"])
return tasks
async def delete_task_cascade(self, task_id: str) -> bool:
async with self._sf() as session:
row = await session.get(TaskAnalysisTaskRow, task_id)
if row is None:
return False
run_ids = (
(
await session.execute(
select(TaskAnalysisRunRow.id).where(TaskAnalysisRunRow.external_task_id == task_id)
)
)
.scalars()
.all()
)
await session.execute(
delete(TaskAnalysisBindingRow).where(TaskAnalysisBindingRow.external_task_id == task_id)
)
await session.execute(
delete(TaskDataBlockDefRow).where(TaskDataBlockDefRow.task_id == task_id)
)
await session.execute(
delete(TaskDataPeriodRow).where(TaskDataPeriodRow.task_id == task_id)
)
await session.execute(
delete(TaskDataBlockRow).where(TaskDataBlockRow.task_id == task_id)
)
await session.execute(
delete(TaskDataSubmissionRow).where(TaskDataSubmissionRow.task_id == task_id)
)
if run_ids:
await session.execute(
delete(TaskAnalysisRunMessageRow).where(TaskAnalysisRunMessageRow.run_id.in_(run_ids))
)
await session.execute(
delete(TaskAnalysisContentVersionRow).where(
TaskAnalysisContentVersionRow.run_id.in_(run_ids)
)
)
await session.execute(
delete(TaskAnalysisRunRow).where(TaskAnalysisRunRow.external_task_id == task_id)
)
await session.delete(row)
await session.commit()
return True
# ── bindings ────────────────────────────────────────────────────────────
async def get_binding(self, external_task_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
result = await session.execute(
select(TaskAnalysisBindingRow).where(
TaskAnalysisBindingRow.external_task_id == external_task_id
)
)
row = result.scalar_one_or_none()
return _binding_dict(row) if row else None
async def get_binding_by_id(self, binding_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(TaskAnalysisBindingRow, binding_id)
return _binding_dict(row) if row else None
async def upsert_binding(self, external_task_id: str, data: dict[str, Any]) -> dict[str, Any]:
async with self._sf() as session:
result = await session.execute(
select(TaskAnalysisBindingRow).where(
TaskAnalysisBindingRow.external_task_id == external_task_id
)
)
row = result.scalar_one_or_none()
if row is None:
row = TaskAnalysisBindingRow(
id=str(data.get("id") or f"tab-{external_task_id}"),
external_task_id=external_task_id,
chain_id=str(data["chain_id"]),
created_by=data.get("created_by"),
)
session.add(row)
row.chain_id = str(data.get("chain_id") or row.chain_id)
row.chain_snapshot = _dump(data.get("chain_snapshot") or {})
row.cron_expr = str(data.get("cron_expr") or row.cron_expr or "")
row.timezone = str(data.get("timezone") or row.timezone or "Asia/Shanghai")
if "schedule_type" in data:
row.schedule_type = str(data.get("schedule_type") or "cron")
row.model = data.get("model")
if "enabled" in data:
row.enabled = bool(data["enabled"])
if "next_run_at" in data:
row.next_run_at = _parse_dt(data.get("next_run_at"))
row.coordinator_prompt = data.get("coordinator_prompt")
if "direction_notes" in data:
row.direction_notes = _dump(list(data["direction_notes"] or []))
await session.commit()
await session.refresh(row)
return _binding_dict(row)
async def delete_binding(self, external_task_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(
delete(TaskAnalysisBindingRow).where(
TaskAnalysisBindingRow.external_task_id == external_task_id
)
)
await session.commit()
return bool(result.rowcount)
async def due_bindings(self, now: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(TaskAnalysisBindingRow)
.where(
TaskAnalysisBindingRow.enabled.is_(True),
TaskAnalysisBindingRow.next_run_at.is_not(None),
TaskAnalysisBindingRow.next_run_at <= now,
)
.order_by(TaskAnalysisBindingRow.next_run_at.asc())
.limit(limit)
)
return [_binding_dict(row) for row in result.scalars()]
async def update_binding(self, binding_id: str, patch: dict[str, Any]) -> dict[str, Any] | None:
allowed = {
"chain_id", "cron_expr", "timezone", "schedule_type", "enabled", "next_run_at",
"last_run_at", "last_status", "coordinator_prompt", "model",
}
values: dict[str, Any] = {k: v for k, v in patch.items() if k in allowed}
if "next_run_at" in values:
values["next_run_at"] = _parse_dt(values["next_run_at"])
if "last_run_at" in values:
values["last_run_at"] = _parse_dt(values["last_run_at"])
if "direction_notes" in patch:
values["direction_notes"] = _dump(list(patch["direction_notes"] or []))
if not values:
row = await self._get_binding_row(binding_id)
return _binding_dict(row) if row else None
async with self._sf() as session:
await session.execute(
update(TaskAnalysisBindingRow).where(TaskAnalysisBindingRow.id == binding_id).values(**values)
)
await session.commit()
row = await session.get(TaskAnalysisBindingRow, binding_id)
return _binding_dict(row) if row else None
async def _get_binding_row(self, binding_id: str) -> TaskAnalysisBindingRow | None:
async with self._sf() as session:
return await session.get(TaskAnalysisBindingRow, binding_id)
async def last_bound_chain_id(self) -> str | None:
async with self._sf() as session:
result = await session.execute(
select(TaskAnalysisBindingRow.chain_id)
.order_by(TaskAnalysisBindingRow.updated_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
# ── runs ────────────────────────────────────────────────────────────────
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any]:
binding_id = str(data["binding_id"])
scheduled_for = _parse_dt(data["scheduled_for"]) or datetime.now(UTC)
import uuid as _uuid
async with self._sf() as session:
result = await session.execute(
select(TaskAnalysisRunRow).where(
TaskAnalysisRunRow.binding_id == binding_id,
TaskAnalysisRunRow.scheduled_for == scheduled_for,
)
)
existing = result.scalar_one_or_none()
if existing is not None:
return _run_dict(existing)
row = TaskAnalysisRunRow(
id=str(data.get("id") or f"tar-{_uuid.uuid4()}"),
binding_id=binding_id,
external_task_id=str(data["external_task_id"]),
trigger=str(data.get("trigger") or "schedule"),
scheduled_for=scheduled_for,
status=str(data.get("status") or "pending"),
stages_snapshot=_dump(data.get("stages_snapshot") or []),
stage_goals_snapshot=_dump(data.get("stage_goals_snapshot") or []),
seat_names_snapshot=_dump(data.get("seat_names_snapshot") or {}),
seat_results=_dump(data.get("seat_results") or {}),
)
session.add(row)
await session.commit()
await session.refresh(row)
return _run_dict(row)
async def get_run(self, run_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(TaskAnalysisRunRow, run_id)
return _run_dict(row) if row else None
async def list_runs(self, external_task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(TaskAnalysisRunRow)
.where(TaskAnalysisRunRow.external_task_id == external_task_id)
.order_by(TaskAnalysisRunRow.started_at.desc())
.limit(limit)
)
return [_run_dict(row) for row in result.scalars()]
async def update_run(self, run_id: str, patch: dict[str, Any]) -> dict[str, Any] | None:
allowed = {
"trigger", "status", "finished_at", "error", "retry_count",
"stages_snapshot", "stage_goals_snapshot", "seat_names_snapshot", "seat_results",
}
values: dict[str, Any] = {}
for key, value in patch.items():
if key not in allowed:
continue
if key == "finished_at":
values[key] = _parse_dt(value)
elif key in {"stages_snapshot", "stage_goals_snapshot", "seat_names_snapshot", "seat_results"}:
values[key] = _dump(value)
else:
values[key] = value
if not values:
row = await self._get_run_row(run_id)
return _run_dict(row) if row else None
async with self._sf() as session:
await session.execute(
update(TaskAnalysisRunRow).where(TaskAnalysisRunRow.id == run_id).values(**values)
)
await session.commit()
row = await session.get(TaskAnalysisRunRow, run_id)
return _run_dict(row) if row else None
async def _get_run_row(self, run_id: str) -> TaskAnalysisRunRow | None:
async with self._sf() as session:
return await session.get(TaskAnalysisRunRow, run_id)
async def stuck_runs(self, older_than: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(TaskAnalysisRunRow)
.where(
TaskAnalysisRunRow.status.in_(("pending", "running")),
TaskAnalysisRunRow.started_at < older_than,
)
.order_by(TaskAnalysisRunRow.started_at.asc())
.limit(limit)
)
return [_run_dict(row) for row in result.scalars()]
# ── messages ────────────────────────────────────────────────────────────
async def append_run_messages(self, run_id: str, messages: list[dict[str, Any]]) -> int:
import uuid as _uuid
async with self._sf() as session:
existing = (
(
await session.execute(
select(TaskAnalysisRunMessageRow.id).where(
TaskAnalysisRunMessageRow.run_id == run_id
)
)
)
.scalars()
.all()
)
base_seq = len(existing)
rows = [
TaskAnalysisRunMessageRow(
id=str(_uuid.uuid4()),
run_id=run_id,
seq=base_seq + index,
seat_agent_id=str(message.get("seat_agent_id") or ""),
stage_index=int(message.get("stage_index") or 0),
event_type=str(message.get("event_type") or "llm.ai"),
content=message.get("content"),
)
for index, message in enumerate(messages)
]
session.add_all(rows)
await session.commit()
return len(rows)
async def list_run_messages(
self, run_id: str, *, seat: str | None = None, limit: int = 500
) -> list[dict[str, Any]]:
async with self._sf() as session:
stmt = (
select(TaskAnalysisRunMessageRow)
.where(TaskAnalysisRunMessageRow.run_id == run_id)
.order_by(TaskAnalysisRunMessageRow.seq.asc())
.limit(limit)
)
if seat is not None:
stmt = stmt.where(TaskAnalysisRunMessageRow.seat_agent_id == seat)
result = await session.execute(stmt)
return [_message_dict(row) for row in result.scalars()]
# ── versions ────────────────────────────────────────────────────────────
async def create_version(self, data: dict[str, Any]) -> dict[str, Any]:
import uuid as _uuid
run_id = str(data["run_id"])
async with self._sf() as session:
await session.execute(
delete(TaskAnalysisContentVersionRow).where(
TaskAnalysisContentVersionRow.run_id == run_id
)
)
row = TaskAnalysisContentVersionRow(
id=str(_uuid.uuid4()),
external_task_id=str(data["external_task_id"]),
run_id=run_id,
blocks=_dump(data.get("blocks") or {}),
)
session.add(row)
await session.commit()
await session.refresh(row)
return _version_dict(row)
async def list_versions(
self, external_task_id: str, *, limit: int = 50, offset: int = 0
) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(TaskAnalysisContentVersionRow)
.where(TaskAnalysisContentVersionRow.external_task_id == external_task_id)
.order_by(TaskAnalysisContentVersionRow.created_at.desc())
.offset(offset)
.limit(limit)
)
return [_version_dict(row) for row in result.scalars()]
# ── data delivery: block defs / periods / submissions ────────────────────
async def ensure_block_defs(self, task_id: str) -> list[dict[str, Any]]:
from deerflow.persistence.task_analysis.data_blocks import default_block_defs
async with self._sf() as session:
result = await session.execute(
select(TaskDataBlockDefRow).where(TaskDataBlockDefRow.task_id == task_id)
)
existing_keys = {row.block_key for row in result.scalars()}
missing = [
definition
for definition in default_block_defs()
if definition["block_key"] not in existing_keys
]
if not missing:
return await self._ordered_defs(session, task_id)
for definition in missing:
session.add(
TaskDataBlockDefRow(
id=f"tdbd-{task_id}-{definition['block_key']}",
task_id=task_id,
block_key=definition["block_key"],
title=definition["title"],
sort_order=definition["sort_order"],
)
)
try:
await session.commit()
except IntegrityError:
# 并发补种:另一个请求先种好了,沿用其结果。
await session.rollback()
return await self._ordered_defs(session, task_id)
@staticmethod
async def _ordered_defs(session: AsyncSession, task_id: str) -> list[dict[str, Any]]:
result = await session.execute(
select(TaskDataBlockDefRow)
.where(TaskDataBlockDefRow.task_id == task_id)
.order_by(TaskDataBlockDefRow.sort_order.asc(), TaskDataBlockDefRow.block_key.asc())
)
return [_block_def_dict(row) for row in result.scalars()]
async def list_block_defs(self, task_id: str) -> list[dict[str, Any]]:
async with self._sf() as session:
return await self._ordered_defs(session, task_id)
async def update_block_defs(self, task_id: str, updates: list[dict[str, Any]]) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(TaskDataBlockDefRow).where(TaskDataBlockDefRow.task_id == task_id)
)
rows = {row.block_key: row for row in result.scalars()}
touched = False
for item in updates:
block_key = str(item.get("block_key") or "")
row = rows.get(block_key)
if row is None:
raise ValueError(f"未知数据块:{block_key}")
patch: dict[str, Any] = {}
if item.get("title") is not None:
patch["title"] = str(item["title"])
if item.get("sort_order") is not None:
patch["sort_order"] = int(item["sort_order"])
if patch:
await session.execute(
update(TaskDataBlockDefRow)
.where(TaskDataBlockDefRow.id == row.id)
.values(**patch)
)
touched = True
if touched:
await session.commit()
return await self._ordered_defs(session, task_id)
async def open_period(
self,
task_id: str,
*,
source: str,
run_id: str | None = None,
opened_at: datetime | None = None,
) -> dict[str, Any]:
import uuid as _uuid
for _attempt in range(3):
async with self._sf() as session:
if run_id:
existing = await session.execute(
select(TaskDataPeriodRow).where(TaskDataPeriodRow.run_id == run_id)
)
row = existing.scalar_one_or_none()
if row is not None:
return _period_dict(row)
max_index = (
await session.execute(
select(func.max(TaskDataPeriodRow.period_index)).where(
TaskDataPeriodRow.task_id == task_id
)
)
).scalar()
row = TaskDataPeriodRow(
id=f"tdp-{_uuid.uuid4()}",
task_id=task_id,
period_index=(max_index or 0) + 1,
opened_at=opened_at or _utcnow(),
trigger=str(source or "cron"),
run_id=run_id,
)
session.add(row)
try:
await session.commit()
except IntegrityError:
# 并发开期撞了期号唯一键(或 run_id 幂等锚点)——重查/重试。
await session.rollback()
continue
await session.refresh(row)
return _period_dict(row)
raise RuntimeError(f"open_period 连续冲突,task={task_id} source={source}")
async def get_period(self, task_id: str, period_index: int) -> dict[str, Any] | None:
async with self._sf() as session:
result = await session.execute(
select(TaskDataPeriodRow).where(
TaskDataPeriodRow.task_id == task_id,
TaskDataPeriodRow.period_index == period_index,
)
)
row = result.scalar_one_or_none()
return _period_dict(row) if row else None
async def current_period(self, task_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
result = await session.execute(
select(TaskDataPeriodRow)
.where(TaskDataPeriodRow.task_id == task_id)
.order_by(TaskDataPeriodRow.period_index.desc())
.limit(1)
)
row = result.scalar_one_or_none()
return _period_dict(row) if row else None
async def list_periods(self, task_id: str, *, limit: int = 500) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(TaskDataPeriodRow)
.where(TaskDataPeriodRow.task_id == task_id)
.order_by(TaskDataPeriodRow.period_index.desc())
.limit(limit)
)
rows = [_period_dict(row) for row in result.scalars()]
rows.reverse() # 时间轴语义:按期号升序返回
return rows
async def append_submission(
self,
task_id: str,
period_index: int,
block_key: str,
payload: dict[str, Any],
*,
submitted_by: str | None = None,
) -> dict[str, Any]:
import uuid as _uuid
async with self._sf() as session:
base_seq = (
(
await session.execute(
select(func.count(TaskDataSubmissionRow.id)).where(
TaskDataSubmissionRow.task_id == task_id,
TaskDataSubmissionRow.period_index == period_index,
TaskDataSubmissionRow.block_key == block_key,
)
)
)
.scalar_one()
)
row = TaskDataSubmissionRow(
id=f"tds-{_uuid.uuid4()}",
task_id=task_id,
period_index=period_index,
block_key=block_key,
payload=_dump(payload),
submitted_by=submitted_by,
seq=int(base_seq),
)
session.add(row)
await session.commit()
await session.refresh(row)
return _submission_dict(row)
async def list_submissions(
self, task_id: str, period_index: int, block_key: str, *, limit: int = 500
) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(TaskDataSubmissionRow)
.where(
TaskDataSubmissionRow.task_id == task_id,
TaskDataSubmissionRow.period_index == period_index,
TaskDataSubmissionRow.block_key == block_key,
)
.order_by(TaskDataSubmissionRow.seq.asc())
.limit(limit)
)
return [_submission_dict(row) for row in result.scalars()]
async def upsert_period_block(
self, task_id: str, period_index: int, block_key: str, payload: dict[str, Any]
) -> dict[str, Any]:
import uuid as _uuid
async with self._sf() as session:
result = await session.execute(
select(TaskDataBlockRow).where(
TaskDataBlockRow.task_id == task_id,
TaskDataBlockRow.period_index == period_index,
TaskDataBlockRow.block_key == block_key,
)
)
row = result.scalar_one_or_none()
if row is None:
row = TaskDataBlockRow(
id=f"tdb-{_uuid.uuid4()}",
task_id=task_id,
period_index=period_index,
block_key=block_key,
payload=_dump(payload),
)
session.add(row)
else:
row.payload = _dump(payload)
row.updated_at = _utcnow()
await session.commit()
await session.refresh(row)
return _task_block_dict(row)
async def list_task_blocks(self, task_id: str, *, limit: int = 2000) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(TaskDataBlockRow)
.where(TaskDataBlockRow.task_id == task_id)
.order_by(TaskDataBlockRow.period_index.asc(), TaskDataBlockRow.block_key.asc())
.limit(limit)
)
return [_task_block_dict(row) for row in result.scalars()]
# ── settings ────────────────────────────────────────────────────────────
async def get_setting(self, key: str) -> str | None:
async with self._sf() as session:
row = await session.get(TaskAnalysisSettingRow, key)
return row.value if row else None
async def set_setting(self, key: str, value: str) -> None:
async with self._sf() as session:
row = await session.get(TaskAnalysisSettingRow, key)
if row is None:
session.add(TaskAnalysisSettingRow(key=key, value=value))
else:
row.value = value
await session.commit()