1065 lines
36 KiB
Python
Executable File
1065 lines
36 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Workflow versioning system with task session tracking.
|
|
Links workflow sessions with task sessions and individual operations.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
# Try to import yaml
|
|
try:
|
|
import yaml
|
|
HAS_YAML = True
|
|
except ImportError:
|
|
HAS_YAML = False
|
|
|
|
|
|
# ============================================================================
|
|
# YAML/JSON Helpers
|
|
# ============================================================================
|
|
|
|
def load_yaml(filepath: str) -> dict:
|
|
"""Load YAML file."""
|
|
if not os.path.exists(filepath):
|
|
return {}
|
|
with open(filepath, 'r') as f:
|
|
content = f.read()
|
|
if not content.strip():
|
|
return {}
|
|
if HAS_YAML:
|
|
return yaml.safe_load(content) or {}
|
|
# Simple YAML fallback parser for basic key: value structures
|
|
return parse_simple_yaml(content)
|
|
|
|
|
|
def parse_simple_yaml(content: str) -> dict:
|
|
"""Parse YAML with nested dict support without PyYAML dependency."""
|
|
def parse_value(val: str):
|
|
"""Parse a YAML value string into Python type."""
|
|
val = val.strip()
|
|
if val in ('null', '~', ''):
|
|
return None
|
|
if val == 'true':
|
|
return True
|
|
if val == 'false':
|
|
return False
|
|
if val == '[]':
|
|
return []
|
|
if val == '{}':
|
|
return {}
|
|
# Check for integer
|
|
if val.lstrip('-').isdigit():
|
|
return int(val)
|
|
# Check for float
|
|
try:
|
|
if '.' in val:
|
|
return float(val)
|
|
except ValueError:
|
|
pass
|
|
# Handle quoted strings
|
|
if len(val) >= 2 and val[0] in ('"', "'") and val[-1] == val[0]:
|
|
return val[1:-1]
|
|
return val
|
|
|
|
result = {}
|
|
# Stack: list of (dict_ref, indent_level) to track nesting
|
|
stack = [(result, -1)]
|
|
current_list = None
|
|
list_indent = -1
|
|
|
|
for line in content.split('\n'):
|
|
# Skip empty lines and comments
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith('#'):
|
|
continue
|
|
|
|
# Calculate indentation (number of leading spaces)
|
|
indent = len(line) - len(line.lstrip())
|
|
|
|
# Handle list items
|
|
if stripped.startswith('- '):
|
|
item_value = stripped[2:].strip()
|
|
# Find the appropriate container
|
|
while len(stack) > 1 and stack[-1][1] >= indent:
|
|
stack.pop()
|
|
current_dict = stack[-1][0]
|
|
|
|
# If last key was set to empty dict, convert to list
|
|
if current_list is not None and list_indent < indent:
|
|
current_list.append(parse_value(item_value))
|
|
continue
|
|
|
|
# Handle key: value pairs
|
|
if ':' in stripped:
|
|
# Pop stack to correct nesting level
|
|
while len(stack) > 1 and stack[-1][1] >= indent:
|
|
stack.pop()
|
|
|
|
key, _, value = stripped.partition(':')
|
|
key = key.strip()
|
|
value = value.strip()
|
|
|
|
current_dict = stack[-1][0]
|
|
|
|
if value:
|
|
# Key with immediate value
|
|
parsed = parse_value(value)
|
|
current_dict[key] = parsed
|
|
if isinstance(parsed, list):
|
|
current_list = parsed
|
|
list_indent = indent
|
|
else:
|
|
current_list = None
|
|
list_indent = -1
|
|
else:
|
|
# Key without value - could be nested dict or list
|
|
# Default to dict, will be converted if we see list items
|
|
current_dict[key] = {}
|
|
stack.append((current_dict[key], indent))
|
|
current_list = None
|
|
list_indent = -1
|
|
|
|
return result
|
|
|
|
|
|
def save_yaml(filepath: str, data: dict):
|
|
"""Save data to YAML file."""
|
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
if HAS_YAML:
|
|
with open(filepath, 'w') as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
else:
|
|
# Write proper YAML format without PyYAML
|
|
with open(filepath, 'w') as f:
|
|
f.write(dict_to_yaml(data))
|
|
|
|
|
|
def dict_to_yaml(data, indent=0) -> str:
|
|
"""Convert dict to YAML string without PyYAML dependency."""
|
|
lines = []
|
|
prefix = ' ' * indent
|
|
|
|
if isinstance(data, dict):
|
|
for key, value in data.items():
|
|
if value is None:
|
|
lines.append(f'{prefix}{key}: null')
|
|
elif isinstance(value, bool):
|
|
lines.append(f'{prefix}{key}: {str(value).lower()}')
|
|
elif isinstance(value, (int, float)):
|
|
lines.append(f'{prefix}{key}: {value}')
|
|
elif isinstance(value, str):
|
|
# Quote strings with special chars or that look like other types
|
|
if any(c in value for c in ':{}[]#,&*?|<>=!%@`') or \
|
|
value in ('true', 'false', 'null', '~') or \
|
|
value.lstrip('-').replace('.', '').isdigit():
|
|
lines.append(f'{prefix}{key}: "{value}"')
|
|
else:
|
|
lines.append(f'{prefix}{key}: {value}')
|
|
elif isinstance(value, list):
|
|
if not value:
|
|
lines.append(f'{prefix}{key}: []')
|
|
else:
|
|
lines.append(f'{prefix}{key}:')
|
|
for item in value:
|
|
if isinstance(item, dict):
|
|
# Complex list item
|
|
item_yaml = dict_to_yaml(item, indent + 2).strip()
|
|
first_line, *rest = item_yaml.split('\n')
|
|
lines.append(f'{prefix} - {first_line.strip()}')
|
|
for r in rest:
|
|
lines.append(f'{prefix} {r.strip()}')
|
|
else:
|
|
lines.append(f'{prefix} - {item}')
|
|
elif isinstance(value, dict):
|
|
if not value:
|
|
lines.append(f'{prefix}{key}: {{}}')
|
|
else:
|
|
lines.append(f'{prefix}{key}:')
|
|
lines.append(dict_to_yaml(value, indent + 1))
|
|
else:
|
|
lines.append(f'{prefix}{key}: {value}')
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def file_hash(filepath: str) -> str:
|
|
"""Get SHA256 hash of file content."""
|
|
if not os.path.exists(filepath):
|
|
return None
|
|
with open(filepath, 'rb') as f:
|
|
return hashlib.sha256(f.read()).hexdigest()[:16]
|
|
|
|
|
|
# ============================================================================
|
|
# Path Helpers
|
|
# ============================================================================
|
|
|
|
def get_workflow_dir() -> Path:
|
|
return Path('.workflow')
|
|
|
|
|
|
def get_versions_dir() -> Path:
|
|
return get_workflow_dir() / 'versions'
|
|
|
|
|
|
def get_index_path() -> Path:
|
|
return get_workflow_dir() / 'index.yml'
|
|
|
|
|
|
def get_operations_log_path() -> Path:
|
|
return get_workflow_dir() / 'operations.log'
|
|
|
|
|
|
def get_version_dir(version: str) -> Path:
|
|
return get_versions_dir() / version
|
|
|
|
|
|
def get_current_state_path() -> Path:
|
|
return get_workflow_dir() / 'current.yml'
|
|
|
|
|
|
def get_version_tasks_dir(version: str) -> Path:
|
|
"""Get the tasks directory for a specific version."""
|
|
return get_version_dir(version) / 'tasks'
|
|
|
|
|
|
def get_current_tasks_dir() -> Optional[Path]:
|
|
"""Get the tasks directory for the currently active version."""
|
|
current_path = get_current_state_path()
|
|
if not current_path.exists():
|
|
return None
|
|
current = load_yaml(str(current_path))
|
|
version = current.get('active_version')
|
|
if not version:
|
|
return None
|
|
tasks_dir = get_version_tasks_dir(version)
|
|
tasks_dir.mkdir(parents=True, exist_ok=True)
|
|
return tasks_dir
|
|
|
|
|
|
# ============================================================================
|
|
# Version Index Management
|
|
# ============================================================================
|
|
|
|
def load_index() -> dict:
|
|
"""Load or create version index."""
|
|
index_path = get_index_path()
|
|
if index_path.exists():
|
|
return load_yaml(str(index_path))
|
|
return {
|
|
'versions': [],
|
|
'latest_version': None,
|
|
'total_versions': 0
|
|
}
|
|
|
|
|
|
def save_index(index: dict):
|
|
"""Save version index."""
|
|
save_yaml(str(get_index_path()), index)
|
|
|
|
|
|
def get_next_version() -> str:
|
|
"""Get next version number."""
|
|
index = load_index()
|
|
return f"v{index['total_versions'] + 1:03d}"
|
|
|
|
|
|
# ============================================================================
|
|
# Workflow Session Management
|
|
# ============================================================================
|
|
|
|
def create_workflow_session(feature: str, parent_version: str = None) -> dict:
|
|
"""Create a new workflow session with version tracking."""
|
|
now = datetime.now()
|
|
version = get_next_version()
|
|
session_id = f"workflow_{now.strftime('%Y%m%d_%H%M%S')}"
|
|
|
|
# Create version directory and tasks subdirectory
|
|
version_dir = get_version_dir(version)
|
|
version_dir.mkdir(parents=True, exist_ok=True)
|
|
(version_dir / 'tasks').mkdir(exist_ok=True)
|
|
|
|
# Create workflow session
|
|
session = {
|
|
'version': version,
|
|
'feature': feature,
|
|
'session_id': session_id,
|
|
'parent_version': parent_version,
|
|
'status': 'pending',
|
|
'started_at': now.isoformat(),
|
|
'completed_at': None,
|
|
'current_phase': 'INITIALIZING',
|
|
'approvals': {
|
|
'design': {
|
|
'status': 'pending',
|
|
'approved_by': None,
|
|
'approved_at': None,
|
|
'rejection_reason': None
|
|
},
|
|
'implementation': {
|
|
'status': 'pending',
|
|
'approved_by': None,
|
|
'approved_at': None,
|
|
'rejection_reason': None
|
|
}
|
|
},
|
|
'task_sessions': [],
|
|
'summary': {
|
|
'total_tasks': 0,
|
|
'tasks_completed': 0,
|
|
'entities_created': 0,
|
|
'entities_updated': 0,
|
|
'entities_deleted': 0,
|
|
'files_created': 0,
|
|
'files_updated': 0,
|
|
'files_deleted': 0
|
|
}
|
|
}
|
|
|
|
# Save session to version directory
|
|
save_yaml(str(version_dir / 'session.yml'), session)
|
|
|
|
# Update current state pointer
|
|
get_workflow_dir().mkdir(exist_ok=True)
|
|
save_yaml(str(get_current_state_path()), {
|
|
'active_version': version,
|
|
'session_id': session_id
|
|
})
|
|
|
|
# Update index
|
|
index = load_index()
|
|
index['versions'].append({
|
|
'version': version,
|
|
'feature': feature,
|
|
'status': 'pending',
|
|
'started_at': now.isoformat(),
|
|
'completed_at': None,
|
|
'tasks_count': 0,
|
|
'operations_count': 0
|
|
})
|
|
index['latest_version'] = version
|
|
index['total_versions'] += 1
|
|
save_index(index)
|
|
|
|
# Take snapshot of current state (manifest, tasks)
|
|
take_snapshot(version, 'before')
|
|
|
|
return session
|
|
|
|
|
|
def load_current_session() -> Optional[dict]:
|
|
"""Load the current active workflow session."""
|
|
current_path = get_current_state_path()
|
|
if not current_path.exists():
|
|
return None
|
|
|
|
current = load_yaml(str(current_path))
|
|
version = current.get('active_version')
|
|
if not version:
|
|
return None
|
|
|
|
session_path = get_version_dir(version) / 'session.yml'
|
|
if not session_path.exists():
|
|
return None
|
|
|
|
return load_yaml(str(session_path))
|
|
|
|
|
|
def save_current_session(session: dict):
|
|
"""Save the current workflow session."""
|
|
version = session['version']
|
|
session['updated_at'] = datetime.now().isoformat()
|
|
save_yaml(str(get_version_dir(version) / 'session.yml'), session)
|
|
|
|
# Update index
|
|
index = load_index()
|
|
for v in index['versions']:
|
|
if v['version'] == version:
|
|
v['status'] = session['status']
|
|
v['tasks_count'] = session['summary']['total_tasks']
|
|
break
|
|
save_index(index)
|
|
|
|
|
|
def complete_workflow_session(session: dict):
|
|
"""Mark workflow session as completed."""
|
|
now = datetime.now()
|
|
session['status'] = 'completed'
|
|
session['completed_at'] = now.isoformat()
|
|
save_current_session(session)
|
|
|
|
# Take final snapshot
|
|
take_snapshot(session['version'], 'after')
|
|
|
|
# Update index
|
|
index = load_index()
|
|
for v in index['versions']:
|
|
if v['version'] == session['version']:
|
|
v['status'] = 'completed'
|
|
v['completed_at'] = now.isoformat()
|
|
break
|
|
save_index(index)
|
|
|
|
# Clear current pointer
|
|
current_path = get_current_state_path()
|
|
if current_path.exists():
|
|
current_path.unlink()
|
|
|
|
|
|
# ============================================================================
|
|
# Task Session Management
|
|
# ============================================================================
|
|
|
|
def create_task_session(workflow_session: dict, task_id: str, task_type: str, agent: str) -> dict:
|
|
"""Create a new task session with full directory structure."""
|
|
now = datetime.now()
|
|
session_id = f"tasksession_{task_id}_{now.strftime('%Y%m%d_%H%M%S')}"
|
|
|
|
# Create task session DIRECTORY (not file)
|
|
version_dir = get_version_dir(workflow_session['version'])
|
|
task_session_dir = version_dir / 'task_sessions' / task_id
|
|
task_session_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
task_session = {
|
|
'session_id': session_id,
|
|
'workflow_version': workflow_session['version'],
|
|
'task_id': task_id,
|
|
'task_type': task_type,
|
|
'agent': agent,
|
|
'started_at': now.isoformat(),
|
|
'completed_at': None,
|
|
'duration_ms': None,
|
|
'status': 'in_progress',
|
|
'operations': [],
|
|
'review_session': None,
|
|
'errors': [],
|
|
'attempt_number': 1,
|
|
'previous_attempts': []
|
|
}
|
|
|
|
# Save session.yml
|
|
save_yaml(str(task_session_dir / 'session.yml'), task_session)
|
|
|
|
# Snapshot task definition
|
|
snapshot_task_definition(task_id, task_session_dir)
|
|
|
|
# Initialize operations.log
|
|
init_operations_log(task_session_dir, task_id, now)
|
|
|
|
# Link to workflow
|
|
workflow_session['task_sessions'].append(session_id)
|
|
workflow_session['summary']['total_tasks'] += 1
|
|
save_current_session(workflow_session)
|
|
|
|
return task_session
|
|
|
|
|
|
def snapshot_task_definition(task_id: str, task_session_dir: Path):
|
|
"""Snapshot the task definition at execution time."""
|
|
task_file = Path('tasks') / f'{task_id}.yml'
|
|
|
|
if task_file.exists():
|
|
task_data = load_yaml(str(task_file))
|
|
task_data['snapshotted_at'] = datetime.now().isoformat()
|
|
task_data['source_path'] = str(task_file)
|
|
task_data['status_at_snapshot'] = task_data.get('status', 'unknown')
|
|
save_yaml(str(task_session_dir / 'task.yml'), task_data)
|
|
|
|
|
|
def init_operations_log(task_session_dir: Path, task_id: str, start_time: datetime):
|
|
"""Initialize the operations log file."""
|
|
log_path = task_session_dir / 'operations.log'
|
|
header = f"# Operations Log for {task_id}\n"
|
|
header += f"# Started: {start_time.isoformat()}\n"
|
|
header += "# Format: [timestamp] OPERATION target_type: target_id (path)\n"
|
|
header += "=" * 70 + "\n\n"
|
|
with open(log_path, 'w') as f:
|
|
f.write(header)
|
|
|
|
|
|
def log_to_task_operations_log(task_session: dict, operation: dict):
|
|
"""Append operation to task-specific operations log."""
|
|
version = task_session['workflow_version']
|
|
task_id = task_session['task_id']
|
|
log_path = get_version_dir(version) / 'task_sessions' / task_id / 'operations.log'
|
|
|
|
if not log_path.exists():
|
|
return
|
|
|
|
entry = (
|
|
f"[{operation['performed_at']}] "
|
|
f"{operation['type']} {operation['target_type']}: {operation['target_id']}"
|
|
)
|
|
if operation.get('target_path'):
|
|
entry += f" ({operation['target_path']})"
|
|
entry += f"\n Summary: {operation['changes']['diff_summary']}\n"
|
|
|
|
with open(log_path, 'a') as f:
|
|
f.write(entry + "\n")
|
|
|
|
|
|
def load_task_session(version: str, task_id: str) -> Optional[dict]:
|
|
"""Load a task session from directory or flat file (backwards compatible)."""
|
|
# Try new directory structure first
|
|
session_dir = get_version_dir(version) / 'task_sessions' / task_id
|
|
session_path = session_dir / 'session.yml'
|
|
|
|
if session_path.exists():
|
|
return load_yaml(str(session_path))
|
|
|
|
# Fallback to old flat file structure
|
|
old_path = get_version_dir(version) / 'task_sessions' / f'{task_id}.yml'
|
|
if old_path.exists():
|
|
return load_yaml(str(old_path))
|
|
|
|
return None
|
|
|
|
|
|
def save_task_session(task_session: dict):
|
|
"""Save a task session to directory structure."""
|
|
version = task_session['workflow_version']
|
|
task_id = task_session['task_id']
|
|
session_dir = get_version_dir(version) / 'task_sessions' / task_id
|
|
session_dir.mkdir(parents=True, exist_ok=True)
|
|
save_yaml(str(session_dir / 'session.yml'), task_session)
|
|
|
|
|
|
def complete_task_session(task_session: dict, status: str = 'completed'):
|
|
"""Mark task session as completed."""
|
|
now = datetime.now()
|
|
started = datetime.fromisoformat(task_session['started_at'])
|
|
task_session['completed_at'] = now.isoformat()
|
|
task_session['duration_ms'] = int((now - started).total_seconds() * 1000)
|
|
task_session['status'] = status
|
|
save_task_session(task_session)
|
|
|
|
# Update workflow summary
|
|
session = load_current_session()
|
|
if session and status == 'completed':
|
|
session['summary']['tasks_completed'] += 1
|
|
save_current_session(session)
|
|
|
|
|
|
# ============================================================================
|
|
# Operation Logging
|
|
# ============================================================================
|
|
|
|
def log_operation(
|
|
task_session: dict,
|
|
op_type: str, # CREATE, UPDATE, DELETE, RENAME, MOVE
|
|
target_type: str, # file, entity, task, manifest
|
|
target_id: str,
|
|
target_path: str = None,
|
|
before_state: str = None,
|
|
after_state: str = None,
|
|
diff_summary: str = None,
|
|
rollback_data: dict = None
|
|
) -> dict:
|
|
"""Log an operation within a task session."""
|
|
now = datetime.now()
|
|
seq = len(task_session['operations']) + 1
|
|
op_id = f"op_{now.strftime('%Y%m%d_%H%M%S')}_{seq:03d}"
|
|
|
|
operation = {
|
|
'id': op_id,
|
|
'type': op_type,
|
|
'target_type': target_type,
|
|
'target_id': target_id,
|
|
'target_path': target_path,
|
|
'changes': {
|
|
'before': before_state,
|
|
'after': after_state,
|
|
'diff_summary': diff_summary or f"{op_type} {target_type}: {target_id}"
|
|
},
|
|
'performed_at': now.isoformat(),
|
|
'reversible': rollback_data is not None,
|
|
'rollback_data': rollback_data
|
|
}
|
|
|
|
task_session['operations'].append(operation)
|
|
save_task_session(task_session)
|
|
|
|
# Update workflow summary
|
|
session = load_current_session()
|
|
if session:
|
|
if op_type == 'CREATE':
|
|
if target_type == 'file':
|
|
session['summary']['files_created'] += 1
|
|
elif target_type == 'entity':
|
|
session['summary']['entities_created'] += 1
|
|
elif op_type == 'UPDATE':
|
|
if target_type == 'file':
|
|
session['summary']['files_updated'] += 1
|
|
elif target_type == 'entity':
|
|
session['summary']['entities_updated'] += 1
|
|
elif op_type == 'DELETE':
|
|
if target_type == 'file':
|
|
session['summary']['files_deleted'] += 1
|
|
elif target_type == 'entity':
|
|
session['summary']['entities_deleted'] += 1
|
|
save_current_session(session)
|
|
|
|
# Also log to operations log
|
|
log_to_file(operation, task_session)
|
|
|
|
# Also log to task-specific operations log
|
|
log_to_task_operations_log(task_session, operation)
|
|
|
|
# Update index operations count
|
|
index = load_index()
|
|
for v in index['versions']:
|
|
if v['version'] == task_session['workflow_version']:
|
|
v['operations_count'] = v.get('operations_count', 0) + 1
|
|
break
|
|
save_index(index)
|
|
|
|
return operation
|
|
|
|
|
|
def log_to_file(operation: dict, task_session: dict):
|
|
"""Append operation to global operations log."""
|
|
log_path = get_operations_log_path()
|
|
log_entry = (
|
|
f"[{operation['performed_at']}] "
|
|
f"v{task_session['workflow_version']} | "
|
|
f"{task_session['task_id']} | "
|
|
f"{operation['type']} {operation['target_type']}: {operation['target_id']}"
|
|
)
|
|
if operation['target_path']:
|
|
log_entry += f" ({operation['target_path']})"
|
|
log_entry += "\n"
|
|
|
|
with open(log_path, 'a') as f:
|
|
f.write(log_entry)
|
|
|
|
|
|
# ============================================================================
|
|
# Review Session Management
|
|
# ============================================================================
|
|
|
|
def create_review_session(task_session: dict, reviewer: str = 'reviewer') -> dict:
|
|
"""Create a review session for a task."""
|
|
now = datetime.now()
|
|
session_id = f"review_{task_session['task_id']}_{now.strftime('%Y%m%d_%H%M%S')}"
|
|
|
|
review = {
|
|
'session_id': session_id,
|
|
'task_session_id': task_session['session_id'],
|
|
'workflow_version': task_session['workflow_version'],
|
|
'reviewer': reviewer,
|
|
'started_at': now.isoformat(),
|
|
'completed_at': None,
|
|
'decision': None,
|
|
'checks': {
|
|
'file_exists': None,
|
|
'manifest_compliance': None,
|
|
'code_quality': None,
|
|
'lint': None,
|
|
'build': None,
|
|
'tests': None
|
|
},
|
|
'notes': '',
|
|
'issues_found': [],
|
|
'suggestions': []
|
|
}
|
|
|
|
task_session['review_session'] = review
|
|
save_task_session(task_session)
|
|
|
|
return review
|
|
|
|
|
|
def complete_review_session(
|
|
task_session: dict,
|
|
decision: str,
|
|
checks: dict,
|
|
notes: str = '',
|
|
issues: list = None,
|
|
suggestions: list = None
|
|
):
|
|
"""Complete a review session."""
|
|
now = datetime.now()
|
|
review = task_session['review_session']
|
|
review['completed_at'] = now.isoformat()
|
|
review['decision'] = decision
|
|
review['checks'].update(checks)
|
|
review['notes'] = notes
|
|
review['issues_found'] = issues or []
|
|
review['suggestions'] = suggestions or []
|
|
|
|
save_task_session(task_session)
|
|
|
|
|
|
# ============================================================================
|
|
# Snapshots
|
|
# ============================================================================
|
|
|
|
def take_snapshot(version: str, snapshot_type: str):
|
|
"""Take a snapshot of current state (before/after)."""
|
|
snapshot_dir = get_version_dir(version) / f'snapshot_{snapshot_type}'
|
|
snapshot_dir.mkdir(exist_ok=True)
|
|
|
|
# Snapshot manifest
|
|
if os.path.exists('project_manifest.json'):
|
|
shutil.copy('project_manifest.json', snapshot_dir / 'manifest.json')
|
|
|
|
# Snapshot tasks directory
|
|
if os.path.exists('tasks'):
|
|
tasks_snapshot = snapshot_dir / 'tasks'
|
|
if tasks_snapshot.exists():
|
|
shutil.rmtree(tasks_snapshot)
|
|
shutil.copytree('tasks', tasks_snapshot)
|
|
|
|
|
|
# ============================================================================
|
|
# History & Diff
|
|
# ============================================================================
|
|
|
|
def list_versions() -> list:
|
|
"""List all workflow versions."""
|
|
index = load_index()
|
|
return index['versions']
|
|
|
|
|
|
def get_version_details(version: str) -> Optional[dict]:
|
|
"""Get detailed info about a version."""
|
|
session_path = get_version_dir(version) / 'session.yml'
|
|
if not session_path.exists():
|
|
return None
|
|
return load_yaml(str(session_path))
|
|
|
|
|
|
def get_changelog(version: str) -> dict:
|
|
"""Generate changelog for a version."""
|
|
session = get_version_details(version)
|
|
if not session:
|
|
return None
|
|
|
|
changelog = {
|
|
'version': version,
|
|
'feature': session['feature'],
|
|
'status': session['status'],
|
|
'started_at': session['started_at'],
|
|
'completed_at': session['completed_at'],
|
|
'operations': {
|
|
'created': [],
|
|
'updated': [],
|
|
'deleted': []
|
|
},
|
|
'summary': session['summary']
|
|
}
|
|
|
|
# Collect operations from all task sessions
|
|
tasks_dir = get_version_dir(version) / 'task_sessions'
|
|
if tasks_dir.exists():
|
|
for task_file in tasks_dir.glob('*.yml'):
|
|
task = load_yaml(str(task_file))
|
|
for op in task.get('operations', []):
|
|
entry = {
|
|
'type': op['target_type'],
|
|
'id': op['target_id'],
|
|
'path': op['target_path'],
|
|
'task': task['task_id'],
|
|
'agent': task['agent']
|
|
}
|
|
if op['type'] == 'CREATE':
|
|
changelog['operations']['created'].append(entry)
|
|
elif op['type'] == 'UPDATE':
|
|
changelog['operations']['updated'].append(entry)
|
|
elif op['type'] == 'DELETE':
|
|
changelog['operations']['deleted'].append(entry)
|
|
|
|
return changelog
|
|
|
|
|
|
def diff_versions(version1: str, version2: str) -> dict:
|
|
"""Compare two versions."""
|
|
v1 = get_version_details(version1)
|
|
v2 = get_version_details(version2)
|
|
|
|
if not v1 or not v2:
|
|
return None
|
|
|
|
return {
|
|
'from_version': version1,
|
|
'to_version': version2,
|
|
'from_feature': v1['feature'],
|
|
'to_feature': v2['feature'],
|
|
'summary_diff': {
|
|
'entities_created': v2['summary']['entities_created'] - v1['summary']['entities_created'],
|
|
'entities_updated': v2['summary']['entities_updated'] - v1['summary']['entities_updated'],
|
|
'files_created': v2['summary']['files_created'] - v1['summary']['files_created'],
|
|
'files_updated': v2['summary']['files_updated'] - v1['summary']['files_updated']
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Display Functions
|
|
# ============================================================================
|
|
|
|
def show_history():
|
|
"""Display version history."""
|
|
versions = list_versions()
|
|
|
|
print()
|
|
print("╔" + "═" * 70 + "╗")
|
|
print("║" + "WORKFLOW VERSION HISTORY".center(70) + "║")
|
|
print("╠" + "═" * 70 + "╣")
|
|
|
|
if not versions:
|
|
print("║" + " No workflow versions found.".ljust(70) + "║")
|
|
else:
|
|
for v in versions:
|
|
status_icon = "✅" if v['status'] == 'completed' else "🔄" if v['status'] == 'in_progress' else "⏳"
|
|
line1 = f" {status_icon} {v['version']}: {v['feature'][:45]}"
|
|
print("║" + line1.ljust(70) + "║")
|
|
line2 = f" Started: {v['started_at'][:19]} | Tasks: {v['tasks_count']} | Ops: {v.get('operations_count', 0)}"
|
|
print("║" + line2.ljust(70) + "║")
|
|
print("║" + "─" * 70 + "║")
|
|
|
|
print("╚" + "═" * 70 + "╝")
|
|
|
|
|
|
def show_changelog(version: str):
|
|
"""Display changelog for a version."""
|
|
changelog = get_changelog(version)
|
|
if not changelog:
|
|
print(f"Version {version} not found.")
|
|
return
|
|
|
|
print()
|
|
print("╔" + "═" * 70 + "╗")
|
|
print("║" + f"CHANGELOG: {version}".center(70) + "║")
|
|
print("╠" + "═" * 70 + "╣")
|
|
print("║" + f" Feature: {changelog['feature'][:55]}".ljust(70) + "║")
|
|
print("║" + f" Status: {changelog['status']}".ljust(70) + "║")
|
|
print("╠" + "═" * 70 + "╣")
|
|
|
|
ops = changelog['operations']
|
|
print("║" + " CREATED".ljust(70) + "║")
|
|
for item in ops['created']:
|
|
print("║" + f" + [{item['type']}] {item['id']}".ljust(70) + "║")
|
|
if item['path']:
|
|
print("║" + f" {item['path']}".ljust(70) + "║")
|
|
|
|
print("║" + " UPDATED".ljust(70) + "║")
|
|
for item in ops['updated']:
|
|
print("║" + f" ~ [{item['type']}] {item['id']}".ljust(70) + "║")
|
|
|
|
print("║" + " DELETED".ljust(70) + "║")
|
|
for item in ops['deleted']:
|
|
print("║" + f" - [{item['type']}] {item['id']}".ljust(70) + "║")
|
|
|
|
print("╠" + "═" * 70 + "╣")
|
|
s = changelog['summary']
|
|
print("║" + " SUMMARY".ljust(70) + "║")
|
|
print("║" + f" Entities: +{s['entities_created']} ~{s['entities_updated']} -{s['entities_deleted']}".ljust(70) + "║")
|
|
print("║" + f" Files: +{s['files_created']} ~{s['files_updated']} -{s['files_deleted']}".ljust(70) + "║")
|
|
print("╚" + "═" * 70 + "╝")
|
|
|
|
|
|
def show_current():
|
|
"""Show current active workflow."""
|
|
session = load_current_session()
|
|
if not session:
|
|
print("No active workflow.")
|
|
print("Start one with: /workflow:spawn 'feature name'")
|
|
return
|
|
|
|
print()
|
|
print("╔" + "═" * 70 + "╗")
|
|
print("║" + "CURRENT WORKFLOW SESSION".center(70) + "║")
|
|
print("╠" + "═" * 70 + "╣")
|
|
print("║" + f" Version: {session['version']}".ljust(70) + "║")
|
|
print("║" + f" Feature: {session['feature'][:55]}".ljust(70) + "║")
|
|
print("║" + f" Phase: {session['current_phase']}".ljust(70) + "║")
|
|
print("║" + f" Status: {session['status']}".ljust(70) + "║")
|
|
print("╠" + "═" * 70 + "╣")
|
|
print("║" + " APPROVALS".ljust(70) + "║")
|
|
d = session['approvals']['design']
|
|
i = session['approvals']['implementation']
|
|
d_icon = "✅" if d['status'] == 'approved' else "❌" if d['status'] == 'rejected' else "⏳"
|
|
i_icon = "✅" if i['status'] == 'approved' else "❌" if i['status'] == 'rejected' else "⏳"
|
|
print("║" + f" {d_icon} Design: {d['status']}".ljust(70) + "║")
|
|
print("║" + f" {i_icon} Implementation: {i['status']}".ljust(70) + "║")
|
|
print("╠" + "═" * 70 + "╣")
|
|
s = session['summary']
|
|
print("║" + " PROGRESS".ljust(70) + "║")
|
|
print("║" + f" Tasks: {s['tasks_completed']}/{s['total_tasks']} completed".ljust(70) + "║")
|
|
print("║" + f" Entities: +{s['entities_created']} ~{s['entities_updated']} -{s['entities_deleted']}".ljust(70) + "║")
|
|
print("║" + f" Files: +{s['files_created']} ~{s['files_updated']} -{s['files_deleted']}".ljust(70) + "║")
|
|
print("╚" + "═" * 70 + "╝")
|
|
|
|
|
|
# ============================================================================
|
|
# CLI Interface
|
|
# ============================================================================
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Workflow versioning system")
|
|
subparsers = parser.add_subparsers(dest='command', help='Commands')
|
|
|
|
# create command
|
|
create_parser = subparsers.add_parser('create', help='Create new workflow version')
|
|
create_parser.add_argument('feature', help='Feature description')
|
|
create_parser.add_argument('--parent', help='Parent version (for fixes)')
|
|
|
|
# current command
|
|
subparsers.add_parser('current', help='Show current workflow')
|
|
|
|
# history command
|
|
subparsers.add_parser('history', help='Show version history')
|
|
|
|
# changelog command
|
|
changelog_parser = subparsers.add_parser('changelog', help='Show version changelog')
|
|
changelog_parser.add_argument('version', help='Version to show')
|
|
|
|
# diff command
|
|
diff_parser = subparsers.add_parser('diff', help='Compare two versions')
|
|
diff_parser.add_argument('version1', help='First version')
|
|
diff_parser.add_argument('version2', help='Second version')
|
|
|
|
# task-start command
|
|
task_start = subparsers.add_parser('task-start', help='Start a task session')
|
|
task_start.add_argument('task_id', help='Task ID')
|
|
task_start.add_argument('--type', default='create', help='Task type')
|
|
task_start.add_argument('--agent', required=True, help='Agent performing task')
|
|
|
|
# task-complete command
|
|
task_complete = subparsers.add_parser('task-complete', help='Complete a task session')
|
|
task_complete.add_argument('task_id', help='Task ID')
|
|
task_complete.add_argument('--status', default='completed', help='Final status')
|
|
|
|
# log-op command
|
|
log_op = subparsers.add_parser('log-op', help='Log an operation')
|
|
log_op.add_argument('task_id', help='Task ID')
|
|
log_op.add_argument('op_type', choices=['CREATE', 'UPDATE', 'DELETE'])
|
|
log_op.add_argument('target_type', choices=['file', 'entity', 'task', 'manifest'])
|
|
log_op.add_argument('target_id', help='Target ID')
|
|
log_op.add_argument('--path', help='File path if applicable')
|
|
log_op.add_argument('--summary', help='Change summary')
|
|
|
|
# complete command
|
|
subparsers.add_parser('complete', help='Complete current workflow')
|
|
|
|
# update-phase command
|
|
phase_parser = subparsers.add_parser('update-phase', help='Update workflow phase')
|
|
phase_parser.add_argument('phase', help='New phase')
|
|
|
|
# tasks-dir command
|
|
tasks_dir_parser = subparsers.add_parser('tasks-dir', help='Get tasks directory for current or specific version')
|
|
tasks_dir_parser.add_argument('--version', help='Specific version (defaults to current)')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == 'create':
|
|
session = create_workflow_session(args.feature, args.parent)
|
|
print(f"Created workflow version: {session['version']}")
|
|
print(f"Feature: {args.feature}")
|
|
print(f"Session ID: {session['session_id']}")
|
|
|
|
elif args.command == 'current':
|
|
show_current()
|
|
|
|
elif args.command == 'history':
|
|
show_history()
|
|
|
|
elif args.command == 'changelog':
|
|
show_changelog(args.version)
|
|
|
|
elif args.command == 'diff':
|
|
result = diff_versions(args.version1, args.version2)
|
|
if result:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
print("Could not compare versions")
|
|
|
|
elif args.command == 'task-start':
|
|
session = load_current_session()
|
|
if not session:
|
|
print("Error: No active workflow")
|
|
sys.exit(1)
|
|
task = create_task_session(session, args.task_id, args.type, args.agent)
|
|
print(f"Started task session: {task['session_id']}")
|
|
|
|
elif args.command == 'task-complete':
|
|
session = load_current_session()
|
|
if not session:
|
|
print("Error: No active workflow")
|
|
sys.exit(1)
|
|
task = load_task_session(session['version'], args.task_id)
|
|
if task:
|
|
complete_task_session(task, args.status)
|
|
print(f"Completed task: {args.task_id}")
|
|
else:
|
|
print(f"Task session not found: {args.task_id}")
|
|
|
|
elif args.command == 'log-op':
|
|
session = load_current_session()
|
|
if not session:
|
|
print("Error: No active workflow")
|
|
sys.exit(1)
|
|
task = load_task_session(session['version'], args.task_id)
|
|
if task:
|
|
op = log_operation(
|
|
task,
|
|
args.op_type,
|
|
args.target_type,
|
|
args.target_id,
|
|
target_path=args.path,
|
|
diff_summary=args.summary
|
|
)
|
|
print(f"Logged operation: {op['id']}")
|
|
else:
|
|
print(f"Task session not found: {args.task_id}")
|
|
|
|
elif args.command == 'complete':
|
|
session = load_current_session()
|
|
if not session:
|
|
print("Error: No active workflow")
|
|
sys.exit(1)
|
|
complete_workflow_session(session)
|
|
print(f"Completed workflow: {session['version']}")
|
|
|
|
elif args.command == 'update-phase':
|
|
session = load_current_session()
|
|
if not session:
|
|
print("Error: No active workflow")
|
|
sys.exit(1)
|
|
session['current_phase'] = args.phase
|
|
save_current_session(session)
|
|
print(f"Updated phase to: {args.phase}")
|
|
|
|
elif args.command == 'tasks-dir':
|
|
if args.version:
|
|
# Specific version requested
|
|
tasks_dir = get_version_tasks_dir(args.version)
|
|
tasks_dir.mkdir(parents=True, exist_ok=True)
|
|
print(str(tasks_dir))
|
|
else:
|
|
# Use current version
|
|
tasks_dir = get_current_tasks_dir()
|
|
if tasks_dir:
|
|
print(str(tasks_dir))
|
|
else:
|
|
print("Error: No active workflow")
|
|
sys.exit(1)
|
|
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|