Files
zk-data-agent/src/plan_runtime.py
T
Abdelrahman Abdallah 783145fe6a add mcp and online search
2026-04-05 02:35:49 +02:00

397 lines
14 KiB
Python

from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .task_runtime import TaskRuntime
DEFAULT_PLAN_RUNTIME_PATH = Path('.port_sessions') / 'plan_runtime.json'
VALID_PLAN_STATUSES = (
'pending',
'in_progress',
'completed',
'blocked',
'cancelled',
)
@dataclass(frozen=True)
class PlanStep:
step: str
status: str = 'pending'
task_id: str | None = None
description: str | None = None
priority: str | None = None
active_form: str | None = None
owner: str | None = None
depends_on: tuple[str, ...] = ()
def to_dict(self) -> dict[str, Any]:
return {
'step': self.step,
'status': self.status,
'task_id': self.task_id,
'description': self.description,
'priority': self.priority,
'active_form': self.active_form,
'owner': self.owner,
'depends_on': list(self.depends_on),
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> 'PlanStep':
return cls(
step=str(payload.get('step') or ''),
status=_normalize_plan_status(payload.get('status')),
task_id=(
str(payload.get('task_id'))
if isinstance(payload.get('task_id'), str) and payload.get('task_id')
else None
),
description=(
str(payload.get('description'))
if isinstance(payload.get('description'), str)
and payload.get('description').strip()
else None
),
priority=(
str(payload.get('priority'))
if isinstance(payload.get('priority'), str)
and payload.get('priority').strip()
else None
),
active_form=(
str(payload.get('active_form'))
if isinstance(payload.get('active_form'), str)
and payload.get('active_form').strip()
else None
),
owner=(
str(payload.get('owner'))
if isinstance(payload.get('owner'), str)
and payload.get('owner').strip()
else None
),
depends_on=_normalize_id_list(payload.get('depends_on')),
)
@dataclass(frozen=True)
class PlanMutation:
explanation: str | None
store_path: str
before_sha256: str | None
after_sha256: str
before_preview: str | None
after_preview: str
before_count: int
after_count: int
synced_tasks: int = 0
synced_task_store_path: str | None = None
synced_task_sha256: str | None = None
@dataclass
class PlanRuntime:
steps: tuple[PlanStep, ...] = field(default_factory=tuple)
explanation: str | None = None
updated_at: str | None = None
storage_path: Path = field(default_factory=lambda: DEFAULT_PLAN_RUNTIME_PATH.resolve())
@classmethod
def from_workspace(cls, cwd: Path) -> 'PlanRuntime':
storage_path = (cwd.resolve() / DEFAULT_PLAN_RUNTIME_PATH).resolve()
if not storage_path.exists():
return cls(storage_path=storage_path)
try:
payload = json.loads(storage_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return cls(storage_path=storage_path)
raw_steps = payload.get('steps')
if not isinstance(raw_steps, list):
return cls(storage_path=storage_path)
steps: list[PlanStep] = []
for item in raw_steps:
if not isinstance(item, dict):
continue
step = PlanStep.from_dict(item)
if step.step:
steps.append(step)
explanation = payload.get('explanation')
updated_at = payload.get('updated_at')
return cls(
steps=tuple(steps),
explanation=(
explanation.strip()
if isinstance(explanation, str) and explanation.strip()
else None
),
updated_at=str(updated_at) if isinstance(updated_at, str) and updated_at else None,
storage_path=storage_path,
)
def update_plan(
self,
items: list[dict[str, Any]],
*,
explanation: str | None = None,
task_runtime: TaskRuntime | None = None,
sync_tasks: bool = True,
) -> PlanMutation:
normalized_steps: list[PlanStep] = []
for index, item in enumerate(items, start=1):
if not isinstance(item, dict):
continue
step_text = item.get('step')
if not isinstance(step_text, str) or not step_text.strip():
continue
task_id = item.get('task_id')
normalized_steps.append(
PlanStep(
step=step_text.strip(),
status=_normalize_plan_status(item.get('status')),
task_id=(
task_id.strip()
if isinstance(task_id, str) and task_id.strip()
else f'plan_{index}'
),
description=(
item.get('description').strip()
if isinstance(item.get('description'), str)
and item.get('description').strip()
else None
),
priority=(
item.get('priority').strip()
if isinstance(item.get('priority'), str)
and item.get('priority').strip()
else None
),
active_form=(
item.get('active_form').strip()
if isinstance(item.get('active_form'), str)
and item.get('active_form').strip()
else None
),
owner=(
item.get('owner').strip()
if isinstance(item.get('owner'), str)
and item.get('owner').strip()
else None
),
depends_on=_normalize_id_list(item.get('depends_on')),
)
)
mutation = self._persist(
tuple(normalized_steps),
explanation=(
explanation.strip()
if isinstance(explanation, str) and explanation.strip()
else None
),
)
if sync_tasks and task_runtime is not None:
dependents: dict[str, list[str]] = {}
for step in self.steps:
for dependency in step.depends_on:
dependents.setdefault(dependency, []).append(step.task_id or '')
task_items = [
{
'task_id': step.task_id or f'plan_{index}',
'title': step.step,
'description': step.description,
'status': _plan_status_to_task_status(step.status),
'priority': step.priority,
'active_form': step.active_form,
'owner': step.owner,
'blocked_by': list(step.depends_on),
'blocks': sorted(
dependency
for dependency in dependents.get(step.task_id or f'plan_{index}', [])
if dependency
),
}
for index, step in enumerate(self.steps, start=1)
]
task_mutation = task_runtime.replace_tasks(task_items)
return PlanMutation(
explanation=mutation.explanation,
store_path=mutation.store_path,
before_sha256=mutation.before_sha256,
after_sha256=mutation.after_sha256,
before_preview=mutation.before_preview,
after_preview=mutation.after_preview,
before_count=mutation.before_count,
after_count=mutation.after_count,
synced_tasks=task_mutation.after_count,
synced_task_store_path=task_mutation.store_path,
synced_task_sha256=task_mutation.after_sha256,
)
return mutation
def clear_plan(self, *, task_runtime: TaskRuntime | None = None) -> PlanMutation:
mutation = self._persist((), explanation=None)
if task_runtime is not None:
task_mutation = task_runtime.replace_tasks([])
return PlanMutation(
explanation=None,
store_path=mutation.store_path,
before_sha256=mutation.before_sha256,
after_sha256=mutation.after_sha256,
before_preview=mutation.before_preview,
after_preview=mutation.after_preview,
before_count=mutation.before_count,
after_count=mutation.after_count,
synced_tasks=task_mutation.after_count,
synced_task_store_path=task_mutation.store_path,
synced_task_sha256=task_mutation.after_sha256,
)
return mutation
def render_summary(self) -> str:
lines = [
f'Local plan runtime file: {self.storage_path}',
f'Total plan steps: {len(self.steps)}',
]
if self.explanation:
lines.append(f'- Explanation: {self.explanation}')
counts: dict[str, int] = {}
for step in self.steps:
counts[step.status] = counts.get(step.status, 0) + 1
if counts:
lines.append(
'- Status counts: '
+ ', '.join(f'{name}={count}' for name, count in sorted(counts.items()))
)
if self.updated_at:
lines.append(f'- Updated: {self.updated_at}')
return '\n'.join(lines)
def render_plan(self) -> str:
if not self.steps:
return '# Plan\n\nNo stored plan is currently available.'
lines = ['# Plan', '']
if self.explanation:
lines.extend(['## Explanation', self.explanation, ''])
lines.append('## Steps')
for index, step in enumerate(self.steps, start=1):
details = [f'{index}. {step.step}', f'status={step.status}']
if step.task_id:
details.append(f'task_id={step.task_id}')
if step.priority:
details.append(f'priority={step.priority}')
lines.append('- ' + '; '.join(details))
if step.description:
lines.append(f' description: {step.description}')
if step.active_form:
lines.append(f' active_form: {step.active_form}')
if step.owner:
lines.append(f' owner: {step.owner}')
if step.depends_on:
lines.append(f" depends_on: {', '.join(step.depends_on)}")
return '\n'.join(lines)
def _persist(
self,
steps: tuple[PlanStep, ...],
*,
explanation: str | None,
) -> PlanMutation:
before_count = len(self.steps)
before_text = self._serialize_payload(self.steps, self.explanation, self.updated_at)
before_preview = _snapshot_text(before_text)
before_sha256 = (
hashlib.sha256(before_text.encode('utf-8')).hexdigest()
if self.storage_path.exists() or self.steps or self.explanation
else None
)
updated_at = datetime.now(timezone.utc).isoformat()
payload_text = self._serialize_payload(steps, explanation, updated_at)
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
self.storage_path.write_text(payload_text, encoding='utf-8')
self.steps = steps
self.explanation = explanation
self.updated_at = updated_at
after_sha256 = hashlib.sha256(payload_text.encode('utf-8')).hexdigest()
return PlanMutation(
explanation=explanation,
store_path=str(self.storage_path),
before_sha256=before_sha256,
after_sha256=after_sha256,
before_preview=before_preview if before_text.strip() else None,
after_preview=_snapshot_text(payload_text),
before_count=before_count if before_text.strip() else 0,
after_count=len(steps),
)
def _serialize_payload(
self,
steps: tuple[PlanStep, ...],
explanation: str | None,
updated_at: str | None,
) -> str:
payload = {
'explanation': explanation,
'updated_at': updated_at,
'steps': [step.to_dict() for step in steps],
}
return json.dumps(payload, ensure_ascii=True, indent=2)
def _normalize_plan_status(value: Any) -> str:
if isinstance(value, str):
lowered = value.strip().lower()
aliases = {
'todo': 'pending',
'open': 'pending',
'done': 'completed',
'complete': 'completed',
'in-progress': 'in_progress',
'in progress': 'in_progress',
'blocked_on': 'blocked',
}
lowered = aliases.get(lowered, lowered)
if lowered in VALID_PLAN_STATUSES:
return lowered
return 'pending'
def _plan_status_to_task_status(status: str) -> str:
if status == 'completed':
return 'completed'
if status == 'in_progress':
return 'in_progress'
if status == 'blocked':
return 'blocked'
if status == 'cancelled':
return 'cancelled'
return 'pending'
def _normalize_id_list(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
normalized: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
continue
text = item.strip()
if not text or text in seen:
continue
normalized.append(text)
seen.add(text)
return tuple(normalized)
def _snapshot_text(text: str, limit: int = 240) -> str:
normalized = ' '.join(text.split())
if len(normalized) <= limit:
return normalized
return normalized[: limit - 3] + '...'