Files
zk-data-agent/src/workflow_runtime.py
T
Abdelrahman Abdallah c17c2768eb Implemented the next parity slice.
New runtime/code:

  - src/ask_user_runtime.py
  - src/team_runtime.py

  New real tools in src/agent_tools.py:

  - ask_user_question
  - team_create
  - team_delete
  - team_list
  - team_get
  - send_message
  - team_messages
  - notebook_edit
2026-04-07 03:48:20 +02:00

320 lines
12 KiB
Python

from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
DEFAULT_WORKFLOW_STATE_PATH = Path('.port_sessions') / 'workflow_runtime.json'
WORKFLOW_MANIFEST_FILES = ('.claw-workflows.json', '.claw-workflow.json')
@dataclass(frozen=True)
class WorkflowDefinition:
name: str
source_manifest: str
description: str | None = None
prompt: str | None = None
steps: tuple[dict[str, Any], ...] = ()
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class WorkflowRunRecord:
run_id: str
workflow_name: str
status: str
created_at: str
arguments: dict[str, Any] = field(default_factory=dict)
summary: str | None = None
@dataclass
class WorkflowRuntime:
cwd: Path
workflows: tuple[WorkflowDefinition, ...] = field(default_factory=tuple)
manifests: tuple[str, ...] = field(default_factory=tuple)
history: tuple[WorkflowRunRecord, ...] = field(default_factory=tuple)
state_path: Path = field(default_factory=lambda: DEFAULT_WORKFLOW_STATE_PATH.resolve())
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'WorkflowRuntime':
resolved_cwd = cwd.resolve()
manifest_paths = _discover_manifest_paths(resolved_cwd, additional_working_directories)
workflows: list[WorkflowDefinition] = []
for manifest_path in manifest_paths:
workflows.extend(_load_workflows_from_manifest(manifest_path))
state_path = resolved_cwd / DEFAULT_WORKFLOW_STATE_PATH
payload = _load_payload(state_path)
history_payload = payload.get('history')
history = tuple(
_run_record_from_payload(item)
for item in history_payload
if isinstance(item, dict) and _run_record_from_payload(item) is not None
) if isinstance(history_payload, list) else ()
return cls(
cwd=resolved_cwd,
workflows=tuple(workflows),
manifests=tuple(str(path) for path in manifest_paths),
history=history,
state_path=state_path,
)
def has_workflows(self) -> bool:
return bool(self.workflows or self.history)
def list_workflows(
self,
*,
query: str | None = None,
limit: int | None = None,
) -> tuple[WorkflowDefinition, ...]:
workflows = self.workflows
if query:
needle = query.lower()
workflows = tuple(
workflow
for workflow in workflows
if needle in workflow.name.lower()
or needle in (workflow.description or '').lower()
)
if limit is not None and limit >= 0:
workflows = workflows[:limit]
return workflows
def get_workflow(self, name: str) -> WorkflowDefinition | None:
needle = name.strip().lower()
if not needle:
return None
for workflow in self.workflows:
if workflow.name.lower() == needle:
return workflow
return None
def run_workflow(
self,
name: str,
*,
arguments: dict[str, Any] | None = None,
) -> WorkflowRunRecord:
workflow = self.get_workflow(name)
if workflow is None:
raise KeyError(name)
normalized_arguments = dict(arguments or {})
rendered_steps = _render_steps(workflow.steps, normalized_arguments)
summary = (
rendered_steps[0]
if rendered_steps
else workflow.description
or f'Workflow {workflow.name} recorded without steps.'
)
record = WorkflowRunRecord(
run_id=f'workflow_run_{uuid4().hex[:10]}',
workflow_name=workflow.name,
status='recorded',
created_at=_utc_now(),
arguments=normalized_arguments,
summary=summary,
)
self.history = (*self.history, record)
self._persist_state()
return record
def render_summary(self) -> str:
lines = [
f'Local workflow manifests: {len(self.manifests)}',
f'Configured workflows: {len(self.workflows)}',
f'Workflow run history: {len(self.history)}',
]
if self.workflows:
lines.append('- Latest workflows:')
for workflow in self.workflows[:5]:
description = workflow.description or 'No description.'
lines.append(f' - {workflow.name}: {description}')
return '\n'.join(lines)
def render_workflows_index(self, *, query: str | None = None) -> str:
workflows = self.list_workflows(query=query, limit=100)
lines = ['# Workflows', '']
if not workflows:
lines.append('No local workflows discovered.')
return '\n'.join(lines)
for workflow in workflows:
description = workflow.description or 'No description.'
lines.append(f'- {workflow.name} ; steps={len(workflow.steps)} ; {description}')
return '\n'.join(lines)
def render_workflow(self, name: str) -> str:
workflow = self.get_workflow(name)
if workflow is None:
raise KeyError(name)
lines = ['# Workflow', '', f'name={workflow.name}']
lines.append(f'source_manifest={workflow.source_manifest}')
lines.append(f'step_count={len(workflow.steps)}')
if workflow.description:
lines.extend(['', '## Description', workflow.description])
if workflow.prompt:
lines.extend(['', '## Prompt', workflow.prompt])
if workflow.steps:
lines.extend(['', '## Steps'])
for index, step in enumerate(workflow.steps, start=1):
title = step.get('title') or step.get('name') or f'Step {index}'
detail = step.get('detail') or step.get('command') or step.get('prompt') or ''
lines.append(f'{index}. {title}')
if detail:
lines.append(f' {detail}')
return '\n'.join(lines)
def render_run_report(
self,
name: str,
*,
arguments: dict[str, Any] | None = None,
) -> str:
workflow = self.get_workflow(name)
if workflow is None:
raise KeyError(name)
normalized_arguments = dict(arguments or {})
record = self.run_workflow(name, arguments=normalized_arguments)
lines = ['# Workflow Run', '', f'run_id={record.run_id}', f'workflow={record.workflow_name}']
lines.append(f'status={record.status}')
lines.append(f'created_at={record.created_at}')
if normalized_arguments:
lines.extend(['', '## Arguments', json.dumps(normalized_arguments, indent=2, ensure_ascii=True)])
rendered_steps = _render_steps(workflow.steps, normalized_arguments)
if rendered_steps:
lines.extend(['', '## Resolved Steps'])
lines.extend(f'- {step}' for step in rendered_steps)
if workflow.prompt:
lines.extend(['', '## Prompt', _safe_format(workflow.prompt, normalized_arguments)])
return '\n'.join(lines)
def _persist_state(self) -> None:
self.state_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
'history': [asdict(record) for record in self.history[-128:]],
}
self.state_path.write_text(
json.dumps(payload, indent=2, ensure_ascii=True),
encoding='utf-8',
)
def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]:
directories = [cwd, *(Path(path).resolve() for path in additional_working_directories)]
seen: set[Path] = set()
found: list[Path] = []
for directory in directories:
for filename in WORKFLOW_MANIFEST_FILES:
candidate = (directory / filename).resolve()
if candidate in seen or not candidate.exists():
continue
seen.add(candidate)
found.append(candidate)
return tuple(found)
def _load_workflows_from_manifest(manifest_path: Path) -> list[WorkflowDefinition]:
try:
payload = json.loads(manifest_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
items: list[dict[str, Any]] = []
if isinstance(payload, dict):
raw_workflows = payload.get('workflows')
if isinstance(raw_workflows, list):
items.extend(item for item in raw_workflows if isinstance(item, dict))
raw_workflow = payload.get('workflow')
if isinstance(raw_workflow, dict):
items.append(raw_workflow)
workflows: list[WorkflowDefinition] = []
for item in items:
name = item.get('name')
if not isinstance(name, str) or not name.strip():
continue
raw_steps = item.get('steps')
steps: list[dict[str, Any]] = []
if isinstance(raw_steps, list):
for entry in raw_steps:
if isinstance(entry, str):
steps.append({'title': entry})
elif isinstance(entry, dict):
steps.append(dict(entry))
workflows.append(
WorkflowDefinition(
name=name.strip(),
source_manifest=str(manifest_path),
description=(
item['description'].strip()
if isinstance(item.get('description'), str) and item['description'].strip()
else None
),
prompt=(
item['prompt'].strip()
if isinstance(item.get('prompt'), str) and item['prompt'].strip()
else None
),
steps=tuple(steps),
metadata=dict(item.get('metadata', {})) if isinstance(item.get('metadata'), dict) else {},
)
)
return workflows
def _load_payload(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def _run_record_from_payload(payload: dict[str, Any]) -> WorkflowRunRecord | None:
run_id = payload.get('run_id')
workflow_name = payload.get('workflow_name')
status = payload.get('status')
created_at = payload.get('created_at')
if not all(isinstance(value, str) and value for value in (run_id, workflow_name, status, created_at)):
return None
arguments = payload.get('arguments')
return WorkflowRunRecord(
run_id=run_id,
workflow_name=workflow_name,
status=status,
created_at=created_at,
arguments=dict(arguments) if isinstance(arguments, dict) else {},
summary=str(payload['summary']) if isinstance(payload.get('summary'), str) else None,
)
def _render_steps(steps: tuple[dict[str, Any], ...], arguments: dict[str, Any]) -> list[str]:
rendered: list[str] = []
for index, step in enumerate(steps, start=1):
title = step.get('title') or step.get('name') or f'Step {index}'
detail = step.get('detail') or step.get('command') or step.get('prompt')
text = str(title)
if isinstance(detail, str) and detail.strip():
text = f'{text}: {_safe_format(detail, arguments)}'
rendered.append(_safe_format(text, arguments))
return rendered
def _safe_format(template: str, arguments: dict[str, Any]) -> str:
text = template
for key, value in arguments.items():
text = text.replace('{' + str(key) + '}', str(value))
return text
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()