diff --git a/CLAUDE.md b/CLAUDE.md index d19f2bfa..fc937870 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -275,6 +275,10 @@ navigate(`/trees/${newTree.id}/edit`) **25. Claude API may wrap JSON responses in markdown fences:** When parsing AI-generated JSON, always strip ` ```json ... ``` ` fences before parsing. See `_strip_markdown_fences()` in `ai_tree_generator_service.py`. +**26. `sessionsApi.list` supports `batch_id` filter (added Feb 2026):** Both backend `GET /sessions` and frontend `SessionListParams` accept `batch_id` for querying all sessions in a maintenance batch. Use `sessionsApi.list({ batch_id })` to fetch batch-scoped sessions. + +**27. Maintenance batch sessions are created all-at-once at launch:** All sessions in a batch exist immediately after `batchLaunchApi.launch()` with `batch_id` + `target_label` set. `started_at` is null until a user begins executing that target — there is no "pending session creation" state. + --- ## RBAC & Permissions diff --git a/backend/alembic/versions/1490781700bc_backfill_default_tree_author_id_to_.py b/backend/alembic/versions/1490781700bc_backfill_default_tree_author_id_to_.py new file mode 100644 index 00000000..8d42e44c --- /dev/null +++ b/backend/alembic/versions/1490781700bc_backfill_default_tree_author_id_to_.py @@ -0,0 +1,94 @@ +"""backfill_default_tree_author_id_to_service_account + +Revision ID: 1490781700bc +Revises: 4f4137ce79e5 +Create Date: 2026-02-25 21:26:00.000000 + +Backfill author_id on is_default trees to the ResolutionFlow service account +(noreply@resolutionflow.com). The service account is created here if it does +not yet exist (idempotent), so this migration is safe to run before or after +the app starts. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +import uuid + + +# revision identifiers, used by Alembic. +revision: str = '1490781700bc' +down_revision: Union[str, None] = '4f4137ce79e5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com" +SERVICE_ACCOUNT_NAME = "ResolutionFlow" + + +def upgrade() -> None: + conn = op.get_bind() + + # Ensure service account exists + row = conn.execute( + sa.text("SELECT id FROM users WHERE email = :email"), + {"email": SERVICE_ACCOUNT_EMAIL}, + ).fetchone() + + if row is None: + service_id = str(uuid.uuid4()) + conn.execute( + sa.text(""" + INSERT INTO users ( + id, email, name, password_hash, role, + is_super_admin, is_team_admin, is_active, + is_service_account, must_change_password, + account_role, created_at + ) VALUES ( + :id, :email, :name, :password_hash, 'engineer', + false, false, true, + true, false, + 'engineer', NOW() + ) + """), + { + "id": service_id, + "email": SERVICE_ACCOUNT_EMAIL, + "name": SERVICE_ACCOUNT_NAME, + "password_hash": "!service-account-no-login", + }, + ) + else: + service_id = str(row[0]) + + # Backfill is_default trees that have no author + result = conn.execute( + sa.text(""" + UPDATE trees + SET author_id = :service_id + WHERE author_id IS NULL AND is_default = true + """), + {"service_id": service_id}, + ) + print(f"[backfill] Set author_id to service account on {result.rowcount} default trees") + + +def downgrade() -> None: + # Restore NULL on trees that were authored by the service account and are default + conn = op.get_bind() + row = conn.execute( + sa.text("SELECT id FROM users WHERE email = :email"), + {"email": SERVICE_ACCOUNT_EMAIL}, + ).fetchone() + if row is None: + return + service_id = str(row[0]) + conn.execute( + sa.text(""" + UPDATE trees + SET author_id = NULL + WHERE author_id = :service_id AND is_default = true + """), + {"service_id": service_id}, + ) diff --git a/backend/alembic/versions/4f4137ce79e5_add_is_service_account_to_users.py b/backend/alembic/versions/4f4137ce79e5_add_is_service_account_to_users.py new file mode 100644 index 00000000..c2566619 --- /dev/null +++ b/backend/alembic/versions/4f4137ce79e5_add_is_service_account_to_users.py @@ -0,0 +1,34 @@ +"""add_is_service_account_to_users + +Revision ID: 4f4137ce79e5 +Revises: fb1481317ff6 +Create Date: 2026-02-25 20:28:46.075639 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4f4137ce79e5' +down_revision: Union[str, None] = 'fb1481317ff6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'users', + sa.Column( + 'is_service_account', + sa.Boolean(), + nullable=False, + server_default='false', + ) + ) + + +def downgrade() -> None: + op.drop_column('users', 'is_service_account') diff --git a/backend/alembic/versions/fb1481317ff6_add_step_library_sync_fields.py b/backend/alembic/versions/fb1481317ff6_add_step_library_sync_fields.py new file mode 100644 index 00000000..b71d39bc --- /dev/null +++ b/backend/alembic/versions/fb1481317ff6_add_step_library_sync_fields.py @@ -0,0 +1,47 @@ +"""add_step_library_sync_fields + +Revision ID: fb1481317ff6 +Revises: a1b2c3d4e5f6 +Create Date: 2026-02-25 03:19:52.600292 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fb1481317ff6' +down_revision: Union[str, None] = 'a1b2c3d4e5f6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('step_library', sa.Column('source_tree_id', sa.UUID(), nullable=True)) + op.add_column('step_library', sa.Column('source_node_id', sa.String(255), nullable=True)) + op.add_column('step_library', sa.Column('is_flow_synced', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('step_library', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True)) + op.create_foreign_key( + 'fk_step_library_source_tree', + 'step_library', 'trees', + ['source_tree_id'], ['id'], + ondelete='SET NULL' + ) + op.create_unique_constraint( + 'uq_step_library_source_node', + 'step_library', + ['source_tree_id', 'source_node_id'] + ) + op.create_index('ix_step_library_source_tree_id', 'step_library', ['source_tree_id']) + + +def downgrade() -> None: + op.drop_index('ix_step_library_source_tree_id', 'step_library') + op.drop_constraint('uq_step_library_source_node', 'step_library', type_='unique') + op.drop_constraint('fk_step_library_source_tree', 'step_library', type_='foreignkey') + op.drop_column('step_library', 'last_synced_at') + op.drop_column('step_library', 'is_flow_synced') + op.drop_column('step_library', 'source_node_id') + op.drop_column('step_library', 'source_tree_id') diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index e366cf38..4cdd8a94 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -155,6 +155,14 @@ async def require_account_owner( ) +def get_service_account_id(request: Request) -> Optional[UUID]: + """Return the cached ResolutionFlow service account UUID from app.state. + + Returns None in test environments where lifespan startup did not run. + """ + return getattr(request.app.state, "service_account_id", None) + + async def get_plan_limits_for_user( current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 12b3f67e..5e7b9c5f 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -38,6 +38,7 @@ async def list_sessions( client_name: Optional[str] = Query(None, description="Search by client name (partial match)"), tree_name: Optional[str] = Query(None, description="Filter by tree name from snapshot"), tree_id: Optional[UUID] = Query(None, description="Filter by tree ID"), + batch_id: Optional[UUID] = Query(None, description="Filter by batch ID (maintenance batch runs)"), started_after: Optional[datetime] = Query(None, description="Filter sessions started after this datetime"), started_before: Optional[datetime] = Query(None, description="Filter sessions started before this datetime"), completed_after: Optional[datetime] = Query(None, description="Filter sessions completed after this datetime"), @@ -73,6 +74,10 @@ async def list_sessions( if tree_id: query = query.where(Session.tree_id == tree_id) + # Batch ID filter + if batch_id: + query = query.where(Session.batch_id == batch_id) + # Date range filters if started_after: query = query.where(Session.started_at >= started_after) diff --git a/backend/app/api/endpoints/steps.py b/backend/app/api/endpoints/steps.py index b188cf8d..14bc5a7c 100644 --- a/backend/app/api/endpoints/steps.py +++ b/backend/app/api/endpoints/steps.py @@ -5,6 +5,7 @@ from decimal import Decimal from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, func, desc, Integer, case from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.core.database import get_db from app.api.deps import get_current_active_user, require_engineer_or_admin @@ -39,7 +40,7 @@ async def get_step_or_404( select(StepLibrary).where( StepLibrary.id == step_id, StepLibrary.is_active == True - ) + ).options(selectinload(StepLibrary.source_tree)) ) step = result.scalar_one_or_none() if not step: @@ -72,7 +73,7 @@ async def list_steps( query = select(StepLibrary).where( StepLibrary.is_active == True, build_step_visibility_filter(current_user) - ) + ).options(selectinload(StepLibrary.source_tree)) # Apply filters if visibility: @@ -117,6 +118,8 @@ async def list_steps( "is_featured": step.is_featured, "created_by": step.created_by, "created_at": step.created_at, + "is_flow_synced": step.is_flow_synced, + "source_tree_name": step.source_tree.name if step.source_tree else None, } # Get category name if exists @@ -154,7 +157,7 @@ async def search_steps( StepLibrary.is_active == True, build_step_visibility_filter(current_user), func.to_tsvector('english', StepLibrary.title).match(search_query) - ).order_by(desc(StepLibrary.rating_average)).limit(limit) + ).options(selectinload(StepLibrary.source_tree)).order_by(desc(StepLibrary.rating_average)).limit(limit) result = await db.execute(query) steps = result.scalars().all() @@ -174,6 +177,8 @@ async def search_steps( "is_featured": step.is_featured, "created_by": step.created_by, "created_at": step.created_at, + "is_flow_synced": step.is_flow_synced, + "source_tree_name": step.source_tree.name if step.source_tree else None, } if step.category_id: @@ -247,6 +252,8 @@ async def get_step( "is_active": step.is_active, "created_at": step.created_at, "updated_at": step.updated_at, + "is_flow_synced": step.is_flow_synced, + "source_tree_name": step.source_tree.name if step.source_tree else None, } # Get category name if exists @@ -346,6 +353,12 @@ async def update_step( """Update a step (owner or admin only).""" step = await get_step_or_404(step_id, db, current_user, check_edit=True) + if step.is_flow_synced: + raise HTTPException( + status_code=400, + detail="Flow-synced steps are read-only. Fork to customize." + ) + # Validate category if being updated if step_data.category_id: cat_result = await db.execute( diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index fb63f7e3..e0f10a6f 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -21,13 +21,14 @@ from app.schemas.tree import ( PinnedFlowResponse, PinnedFlowsListResponse, PinnedFlowReorderRequest ) from app.models.user_pinned_tree import UserPinnedTree -from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin +from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin, get_service_account_id from app.core.permissions import can_edit_tree, can_access_tree from app.core.filters import build_tree_access_filter from app.core.subscriptions import check_tree_limit from app.core.audit import log_audit from app.core.config import settings from app.core.tree_validation import can_publish_tree +from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree router = APIRouter(prefix="/trees", tags=["trees"]) @@ -399,7 +400,8 @@ async def get_tree( async def create_tree( tree_data: TreeCreate, db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(require_engineer_or_admin)] + current_user: Annotated[User, Depends(require_engineer_or_admin)], + service_account_id: Annotated[Optional[UUID], Depends(get_service_account_id)], ): """Create a new tree (engineers and admins only). @@ -464,7 +466,7 @@ async def create_tree( tree_type=tree_data.tree_type, tree_structure=tree_data.tree_structure, intake_form=intake_form_data, - author_id=None if is_default else current_user.id, # Default trees have no author + author_id=service_account_id if is_default else current_user.id, account_id=None if is_default else current_user.account_id, is_public=True if is_default else tree_data.is_public, # Default trees are always public is_default=is_default, @@ -548,7 +550,8 @@ async def update_tree( tree_id: UUID, tree_data: TreeUpdate, db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(require_engineer_or_admin)] + current_user: Annotated[User, Depends(require_engineer_or_admin)], + service_account_id: Annotated[Optional[UUID], Depends(get_service_account_id)], ): """Update an existing tree (engineers and admins only). @@ -640,6 +643,22 @@ async def update_tree( if "tree_structure" in update_data: tree.version += 1 + # Sync steps to step library on publish transition only + if update_data.get("status") == 'published': + _structure = update_data.get("tree_structure", tree.tree_structure) + _type = update_data.get("tree_type", tree.tree_type) + _is_public = update_data.get("is_public", tree.is_public) + await sync_steps_from_tree( + db=db, + tree_id=tree.id, + tree_type=_type, + tree_structure=_structure, + author_id=tree.author_id, + account_id=tree.account_id, + is_public=_is_public, + service_account_id=service_account_id, + ) + # Handle tags replacement if tags_data is not None: from app.models.tag import tree_tag_assignments @@ -753,6 +772,10 @@ async def delete_tree( tree_tag_assignments.delete().where(tree_tag_assignments.c.tree_id == tree.id) ) + # Deactivate any synced step library entries before deletion + # (must happen before db.delete/commit — FK SET NULL would lose the reference) + await deactivate_synced_steps_for_tree(db, tree.id) + await log_audit(db, current_user.id, "tree.delete", "tree", tree.id, {"tree_name": tree.name}) await db.commit() diff --git a/backend/app/core/service_account.py b/backend/app/core/service_account.py new file mode 100644 index 00000000..d16e91c8 --- /dev/null +++ b/backend/app/core/service_account.py @@ -0,0 +1,60 @@ +"""ResolutionFlow system service account. + +This module manages the platform-level service account used as the author +for system/default content (seeded trees, synced step library entries, etc.). + +The service account ID is resolved once at startup and cached on app.state +so that sync operations can use it without a DB query per request. +""" +from __future__ import annotations + +import uuid +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com" +SERVICE_ACCOUNT_NAME = "ResolutionFlow" + + +async def ensure_service_account(db: AsyncSession) -> uuid.UUID: + """Ensure the ResolutionFlow service account exists and return its ID. + + Idempotent — safe to call on every startup. Creates the account if it + does not exist. The account has no usable password and is_service_account=True + so it can never log in via normal auth flows. + """ + from app.models.user import User + + result = await db.execute( + select(User).where(User.email == SERVICE_ACCOUNT_EMAIL) + ) + user = result.scalar_one_or_none() + + if user is not None: + if not user.is_service_account: + user.is_service_account = True + await db.commit() + return user.id + + # Create the service account with a random, unusable password hash + new_user = User( + id=uuid.uuid4(), + email=SERVICE_ACCOUNT_EMAIL, + name=SERVICE_ACCOUNT_NAME, + password_hash="!service-account-no-login", # bcrypt can't produce this prefix + role="engineer", + is_super_admin=False, + is_team_admin=False, + is_active=True, + is_service_account=True, + must_change_password=False, + account_role="engineer", + ) + db.add(new_user) + await db.commit() + logger.info(f"[service_account] Created service account (id={new_user.id})") + return new_user.id diff --git a/backend/app/core/step_sync.py b/backend/app/core/step_sync.py new file mode 100644 index 00000000..b31f02fa --- /dev/null +++ b/backend/app/core/step_sync.py @@ -0,0 +1,222 @@ +"""Sync steps from published flows into the step library.""" +from __future__ import annotations +import json +from typing import Any, Generator, Literal, Optional +from uuid import UUID +from datetime import datetime, timezone + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +StepVisibility = Literal['private', 'team', 'public'] + + +def resolve_step_visibility( + is_public: bool, + account_id: Optional[UUID], + node_override: Optional[str], +) -> StepVisibility: + """Resolve the visibility for a synced step. + + Priority: node-level library_visibility overrides flow visibility. + Flow visibility: 'public' if is_public, otherwise 'team'. + """ + if node_override in ('team', 'public'): + return node_override # type: ignore[return-value] + return 'public' if is_public else 'team' + + +def _normalize_commands(raw: Any) -> list[dict]: + """Normalize the commands field to a list of StepCommand dicts.""" + if not raw: + return [] + if isinstance(raw, str): + return [{"label": "", "command": raw, "command_type": None}] + if isinstance(raw, list): + result = [] + for item in raw: + if isinstance(item, str): + result.append({"label": "", "command": item, "command_type": None}) + elif isinstance(item, dict): + result.append({ + "label": item.get("label", ""), + "command": item.get("code", item.get("command", "")), + "command_type": item.get("language", item.get("command_type")), + }) + return result + return [] + + +def _walk_troubleshooting(node: dict) -> Generator[dict, None, None]: + """Recursively yield action and solution nodes from a troubleshooting tree.""" + if node.get("type") in ("action", "solution"): + yield node + for child in node.get("children", []): + yield from _walk_troubleshooting(child) + + +def extract_steps_for_sync( + tree_structure: dict, + tree_type: str, +) -> Generator[dict, None, None]: + """Extract step dicts ready for upsert from a tree structure. + + Yields dicts with keys: + source_node_id, title, step_type, content (dict), node_visibility_override + """ + if tree_type in ("procedural", "maintenance"): + steps = tree_structure.get("steps", []) + current_section: Optional[str] = None + for node in steps: + node_type = node.get("type") + if node_type == "section_header": + current_section = node.get("title") or node.get("section_header") + continue + if node_type != "procedure_step": + continue + instructions = node.get("description") or node.get("title", "") + commands = _normalize_commands(node.get("commands")) or None + content: dict = {"instructions": instructions} + if node.get("expected_outcome"): + content["help_text"] = node["expected_outcome"] + if commands: + content["commands"] = commands + if current_section: + content["group_label"] = current_section + yield { + "source_node_id": node["id"], + "title": node.get("title", "Untitled step"), + "step_type": "action", + "content": content, + "node_visibility_override": node.get("library_visibility"), + } + + elif tree_type == "troubleshooting": + for node in _walk_troubleshooting(tree_structure): + instructions = node.get("description") or node.get("title", "") + yield { + "source_node_id": node["id"], + "title": node.get("title", "Untitled step"), + "step_type": "action" if node["type"] == "action" else "solution", + "content": {"instructions": instructions}, + "node_visibility_override": None, + } + + +async def sync_steps_from_tree( + db: AsyncSession, + tree_id: UUID, + tree_type: str, + tree_structure: dict, + author_id: Optional[UUID], + account_id: Optional[UUID], + is_public: bool, + service_account_id: Optional[UUID] = None, +) -> int: + """Upsert step library entries from a published tree. + + Returns the number of steps synced. + + For default/system trees that have no author_id, pass service_account_id + so that created_by is set to the ResolutionFlow service account. + """ + resolved_author_id = author_id or service_account_id + if not resolved_author_id: + return 0 + + now = datetime.now(timezone.utc) + extracted = list(extract_steps_for_sync(tree_structure, tree_type)) + + for step_data in extracted: + visibility = resolve_step_visibility( + is_public=is_public, + account_id=account_id, + node_override=step_data["node_visibility_override"], + ) + await db.execute( + text(""" + INSERT INTO step_library ( + id, title, step_type, content, created_by, account_id, + visibility, is_flow_synced, source_tree_id, source_node_id, + last_synced_at, tags, is_active, + usage_count, rating_average, rating_count, + helpful_yes, helpful_no, is_featured, is_verified, + created_at, updated_at + ) VALUES ( + gen_random_uuid(), :title, :step_type, CAST(:content AS jsonb), + :created_by, :account_id, :visibility, true, + :source_tree_id, :source_node_id, :last_synced_at, + '{}', true, + 0, 0, 0, 0, 0, false, false, + :now, :now + ) + ON CONFLICT (source_tree_id, source_node_id) + DO UPDATE SET + title = EXCLUDED.title, + step_type = EXCLUDED.step_type, + content = EXCLUDED.content, + visibility = EXCLUDED.visibility, + last_synced_at = EXCLUDED.last_synced_at, + updated_at = EXCLUDED.updated_at, + is_active = true + """), + { + "title": step_data["title"], + "step_type": step_data["step_type"], + "content": json.dumps(step_data["content"]), + "created_by": str(resolved_author_id), + "account_id": str(account_id) if account_id else None, + "visibility": visibility, + "source_tree_id": str(tree_id), + "source_node_id": step_data["source_node_id"], + "last_synced_at": now, + "now": now, + } + ) + + # Soft-delete previously synced steps that no longer exist in the tree + current_node_ids = [s["source_node_id"] for s in extracted] + if current_node_ids: + # Build NOT IN using explicit named placeholders — asyncpg does not + # auto-cast a Python list to a PostgreSQL array in text() queries. + placeholders = ", ".join(f":id_{i}" for i in range(len(current_node_ids))) + params = {f"id_{i}": nid for i, nid in enumerate(current_node_ids)} + params.update({"tree_id": str(tree_id), "now": now}) + await db.execute( + text(f""" + UPDATE step_library + SET is_active = false, updated_at = :now + WHERE source_tree_id = :tree_id + AND is_flow_synced = true + AND source_node_id NOT IN ({placeholders}) + """), + params + ) + else: + await db.execute( + text(""" + UPDATE step_library + SET is_active = false, updated_at = :now + WHERE source_tree_id = :tree_id AND is_flow_synced = true + """), + {"tree_id": str(tree_id), "now": now} + ) + + return len(extracted) + + +async def deactivate_synced_steps_for_tree(db: AsyncSession, tree_id: UUID) -> None: + """Soft-delete all synced library entries for a tree (on tree delete/deactivate). + + Must be called BEFORE deleting the tree row — after deletion the FK ondelete='SET NULL' + will null source_tree_id, making the WHERE clause match nothing. + """ + await db.execute( + text(""" + UPDATE step_library + SET is_active = false, updated_at = :now + WHERE source_tree_id = :tree_id AND is_flow_synced = true + """), + {"tree_id": str(tree_id), "now": datetime.now(timezone.utc)} + ) diff --git a/backend/app/main.py b/backend/app/main.py index 78462f76..a5db1b9f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware from app.core.rate_limit import limiter from app.api.router import api_router from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations +from app.core.service_account import ensure_service_account # Initialize logging configuration setup_logging() @@ -103,6 +104,12 @@ async def lifespan(app: FastAPI): # Note: In production, use Alembic migrations instead of init_db # await init_db() + # Ensure service account exists and cache its ID for sync operations + async with async_session_maker() as db: + service_account_id = await ensure_service_account(db) + app.state.service_account_id = service_account_id + logger.info(f"[service_account] Service account ready (id={service_account_id})") + # Start maintenance schedule runner + AI conversation cleanup scheduler.start() async with async_session_maker() as db: diff --git a/backend/app/models/step_library.py b/backend/app/models/step_library.py index a3f23488..e93c1f75 100644 --- a/backend/app/models/step_library.py +++ b/backend/app/models/step_library.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone from decimal import Decimal from typing import TYPE_CHECKING, Optional -from sqlalchemy import String, DateTime, Integer, Boolean, Text, Numeric, ForeignKey, CheckConstraint +from sqlalchemy import String, DateTime, Integer, Boolean, Text, Numeric, ForeignKey, CheckConstraint, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY from app.core.database import Base @@ -13,6 +13,7 @@ if TYPE_CHECKING: from app.models.account import Account from app.models.step_category import StepCategory from app.models.session import Session + from app.models.tree import Tree class StepLibrary(Base): @@ -22,6 +23,7 @@ class StepLibrary(Base): "step_type IN ('decision', 'action', 'solution')", name='ck_step_library_step_type' ), + UniqueConstraint('source_tree_id', 'source_node_id', name='uq_step_library_source_node'), ) id: Mapped[uuid.UUID] = mapped_column( @@ -95,10 +97,26 @@ class StepLibrary(Base): # Soft delete is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # Sync tracking (flow-sourced steps) + source_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey('trees.id', ondelete='SET NULL'), + nullable=True, + index=True + ) + source_node_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + is_flow_synced: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + last_synced_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + # Relationships creator: Mapped["User"] = relationship("User", foreign_keys=[created_by]) team: Mapped[Optional["Team"]] = relationship("Team") account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id], back_populates="step_library") + source_tree: Mapped[Optional["Tree"]] = relationship( + "Tree", + foreign_keys=[source_tree_id], + lazy="select" + ) category: Mapped[Optional["StepCategory"]] = relationship("StepCategory") ratings: Mapped[list["StepRating"]] = relationship("StepRating", back_populates="step", cascade="all, delete-orphan") usage_logs: Mapped[list["StepUsageLog"]] = relationship("StepUsageLog", back_populates="step", cascade="all, delete-orphan") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 275ed5ca..ba6ba2b1 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -39,6 +39,7 @@ class User(Base): is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_team_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + is_service_account: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") # Account-based multi-tenancy (new) diff --git a/backend/app/schemas/step_library.py b/backend/app/schemas/step_library.py index 93390c60..ebc1eae6 100644 --- a/backend/app/schemas/step_library.py +++ b/backend/app/schemas/step_library.py @@ -17,6 +17,7 @@ class StepContent(BaseModel): instructions: str = Field(..., min_length=1) help_text: Optional[str] = None commands: Optional[list[StepCommand]] = None + group_label: Optional[str] = None # Section header this step belongs to (for flow-synced steps) # Base schemas @@ -59,6 +60,8 @@ class StepLibraryResponse(StepLibraryBase): # Computed fields (populated by API) category_name: Optional[str] = None author_name: Optional[str] = None + is_flow_synced: bool = False + source_tree_name: Optional[str] = None class Config: from_attributes = True @@ -79,6 +82,8 @@ class StepLibraryListResponse(BaseModel): created_by: UUID author_name: Optional[str] = None created_at: datetime + is_flow_synced: bool = False + source_tree_name: Optional[str] = None class Config: from_attributes = True diff --git a/backend/tests/test_step_sync.py b/backend/tests/test_step_sync.py new file mode 100644 index 00000000..63308d64 --- /dev/null +++ b/backend/tests/test_step_sync.py @@ -0,0 +1,216 @@ +"""Tests for flow-to-library step sync.""" +import pytest +from uuid import uuid4 +from app.core.step_sync import extract_steps_for_sync, resolve_step_visibility + + +class TestResolveStepVisibility: + """Test visibility resolution logic.""" + + def test_public_flow_gives_public_steps(self): + result = resolve_step_visibility(is_public=True, account_id=None, node_override=None) + assert result == 'public' + + def test_team_flow_gives_team_steps(self): + result = resolve_step_visibility(is_public=False, account_id=uuid4(), node_override=None) + assert result == 'team' + + def test_private_flow_gives_team_steps(self): + result = resolve_step_visibility(is_public=False, account_id=None, node_override=None) + assert result == 'team' + + def test_node_override_takes_precedence(self): + result = resolve_step_visibility(is_public=True, account_id=None, node_override='team') + assert result == 'team' + + def test_public_override_on_team_flow(self): + result = resolve_step_visibility(is_public=False, account_id=uuid4(), node_override='public') + assert result == 'public' + + +class TestExtractStepsForSync: + """Test step extraction from tree structures.""" + + def test_extracts_procedure_steps_from_procedural_flow(self): + tree_structure = { + "steps": [ + {"id": "step_1", "type": "procedure_step", "title": "Verify prerequisites", + "description": "Check all prereqs", "content_type": "action"}, + {"id": "end_1", "type": "procedure_end", "title": "Done"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert len(results) == 1 + assert results[0]['source_node_id'] == 'step_1' + assert results[0]['title'] == 'Verify prerequisites' + assert results[0]['step_type'] == 'action' + assert results[0]['content']['instructions'] == 'Check all prereqs' + + def test_skips_section_header_nodes(self): + tree_structure = { + "steps": [ + {"id": "sec_1", "type": "section_header", "title": "Phase 1"}, + {"id": "step_1", "type": "procedure_step", "title": "First step", + "description": "Do this"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert len(results) == 1 + assert results[0]['source_node_id'] == 'step_1' + + def test_captures_section_header_as_group_label(self): + tree_structure = { + "steps": [ + {"id": "sec_1", "type": "section_header", "title": "Cable Checks"}, + {"id": "step_1", "type": "procedure_step", "title": "Check cable", + "description": "Verify cable is seated"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert results[0]['content']['group_label'] == 'Cable Checks' + + def test_normalizes_string_commands(self): + tree_structure = { + "steps": [ + {"id": "step_1", "type": "procedure_step", "title": "Run command", + "description": "Execute this", "commands": "ping 8.8.8.8"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert results[0]['content']['commands'] == [{"label": "", "command": "ping 8.8.8.8", "command_type": None}] + + def test_normalizes_commandblock_commands(self): + tree_structure = { + "steps": [ + {"id": "step_1", "type": "procedure_step", "title": "Run PS", + "description": "Run powershell", + "commands": [{"code": "Get-Service", "language": "powershell", "label": "Check services"}]}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + cmds = results[0]['content']['commands'] + assert len(cmds) == 1 + assert cmds[0]['command'] == 'Get-Service' + assert cmds[0]['command_type'] == 'powershell' + assert cmds[0]['label'] == 'Check services' + + def test_extracts_action_and_solution_from_troubleshooting(self): + tree_structure = { + "id": "root", + "type": "decision", + "question": "What is wrong?", + "options": [{"id": "o1", "label": "Thing A", "next_node_id": "act_1"}], + "children": [ + {"id": "act_1", "type": "action", "title": "Fix thing A", + "description": "Do the fix", "next_node_id": "sol_1", + "children": [{"id": "sol_1", "type": "solution", "title": "All fixed", + "description": "Problem resolved"}]}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='troubleshooting')) + node_ids = {r['source_node_id'] for r in results} + assert 'act_1' in node_ids + assert 'sol_1' in node_ids + types = {r['source_node_id']: r['step_type'] for r in results} + assert types['act_1'] == 'action' + assert types['sol_1'] == 'solution' + + def test_uses_title_as_instructions_fallback(self): + tree_structure = { + "steps": [ + {"id": "step_1", "type": "procedure_step", "title": "Do the thing"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert results[0]['content']['instructions'] == 'Do the thing' + + def test_empty_steps_list(self): + tree_structure = {"steps": []} + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert results == [] + + def test_maintenance_treated_same_as_procedural(self): + tree_structure = { + "steps": [ + {"id": "step_1", "type": "procedure_step", "title": "Maintenance step", + "description": "Do maintenance"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='maintenance')) + assert len(results) == 1 + + +class TestSyncOnPublish: + """Integration tests — sync triggered by publishing a tree.""" + + @pytest.mark.asyncio + async def test_publishing_procedural_tree_creates_library_steps( + self, client, auth_headers + ): + # Create a procedural tree with two steps + tree_resp = await client.post("/api/v1/trees", json={ + "name": "Test Procedure", + "tree_type": "procedural", + "status": "draft", + "tree_structure": { + "steps": [ + {"id": "step_1", "type": "procedure_step", + "title": "First step", "description": "Do this first"}, + {"id": "step_2", "type": "procedure_step", + "title": "Second step", "description": "Do this second"}, + {"id": "end_1", "type": "procedure_end", "title": "Done"}, + ] + } + }, headers=auth_headers) + assert tree_resp.status_code == 201 + tree_id = tree_resp.json()["id"] + + # Publish the tree + pub_resp = await client.put(f"/api/v1/trees/{tree_id}", json={"status": "published"}, headers=auth_headers) + assert pub_resp.status_code == 200 + + # Check library has synced entries + lib_resp = await client.get("/api/v1/steps", headers=auth_headers) + assert lib_resp.status_code == 200 + steps = lib_resp.json() + synced = [s for s in steps if s.get("is_flow_synced")] + assert len(synced) == 2 + titles = {s["title"] for s in synced} + assert "First step" in titles + assert "Second step" in titles + + @pytest.mark.asyncio + async def test_republishing_updates_existing_library_steps( + self, client, auth_headers + ): + # Create a draft tree first, then publish + tree_resp = await client.post("/api/v1/trees", json={ + "name": "Update Test", + "tree_type": "procedural", + "status": "draft", + "tree_structure": {"steps": [ + {"id": "step_1", "type": "procedure_step", + "title": "Original title", "description": "Original desc"}, + {"id": "end_1", "type": "procedure_end", "title": "Done"}, + ]} + }, headers=auth_headers) + tree_id = tree_resp.json()["id"] + first_pub = await client.put(f"/api/v1/trees/{tree_id}", json={"status": "published"}, headers=auth_headers) + assert first_pub.status_code == 200 + + # Republish with updated step title + second_pub = await client.put(f"/api/v1/trees/{tree_id}", json={ + "tree_structure": {"steps": [ + {"id": "step_1", "type": "procedure_step", + "title": "Updated title", "description": "Updated desc"}, + {"id": "end_1", "type": "procedure_end", "title": "Done"}, + ]}, + "status": "published" + }, headers=auth_headers) + assert second_pub.status_code == 200 + + # Check library shows updated title (not a duplicate) + lib_resp = await client.get("/api/v1/steps", headers=auth_headers) + synced = [s for s in lib_resp.json() if s.get("is_flow_synced")] + assert len(synced) == 1 + assert synced[0]["title"] == "Updated title" diff --git a/docs/plans/2026-02-24-tree-fork-ui-design.md b/docs/plans/2026-02-24-tree-fork-ui-design.md new file mode 100644 index 00000000..2313fcdb --- /dev/null +++ b/docs/plans/2026-02-24-tree-fork-ui-design.md @@ -0,0 +1,108 @@ +# Tree Forking UI Design + +> **Date:** 2026-02-24 +> **Feature:** Personal tree forking — explicit modal, reason capture, fork badge + +--- + +## Overview + +Add a proper fork UX to the flow library. The backend is fully complete (POST `/trees/:id/fork`, fork fields in API responses, tests passing). The frontend needs: a `ForkModal` component with a "Reason for Forking" field, updated fork handlers in `TreeLibraryPage` and `MyTreesPage`, fork field types on `Tree`, and a "Fork" chip on tree cards. + +--- + +## What's Being Built + +### 1. Types — `frontend/src/types/tree.ts` + +Add `ForkInfo` interface and fork fields to `Tree`: + +```ts +export interface ForkInfo { + parent_tree_id: string + parent_tree_name: string | null + fork_depth: number + fork_reason: string | null + has_parent_updates: boolean +} +``` + +Add to `Tree`: +```ts +fork_info?: ForkInfo | null +parent_tree_id?: string | null +fork_depth?: number +``` + +Add to `TreeCreate`: +```ts +fork_reason?: string +``` + +### 2. `ForkModal` Component — `frontend/src/components/library/ForkModal.tsx` + +A focused dialog with: +- **Name field** — pre-filled with `"Copy of "` +- **"Reason for Forking"** — optional textarea (placeholder: "e.g. customizing for a specific client…") +- **Cancel** (secondary) + **Fork** (gradient) buttons +- Calls `treesApi.fork(treeId, { name, fork_reason })` on submit +- Success: shows toast, navigates to `/my-trees` +- Error: shows inline error, stays open + +### 3. Update Fork Handlers + +In `TreeLibraryPage` and `MyTreesPage`, replace the current silent `handleForkTree` (which calls `treesApi.fork()` directly) with a handler that: +1. Sets the selected tree to fork +2. Opens `ForkModal` + +The modal handles the actual API call and navigation. + +### 4. "Fork" Badge on Tree Cards + +In `TreeGridView`, `TreeListView`, and `TreeTableView`, render a small chip when `fork_depth > 0` (or `parent_tree_id` is set on `TreeListItem`): + +```tsx +{tree.fork_depth > 0 && ( + + Fork + +)} +``` + +`fork_depth` needs to be added to `TreeListItem` (it comes from the backend list response). + +--- + +## What's NOT Being Built + +- Lineage tree view / "forked from" link — out of scope +- "Has updates available" notification — out of scope +- Fork management / ancestry tracking UI — out of scope + +--- + +## Data Flow + +``` +User clicks "Fork" on a card + → onForkTree(tree) called + → parent sets forkTarget state + opens ForkModal + → user fills Name + optional Reason + → ForkModal calls treesApi.fork(treeId, { name, fork_reason }) + → on success: toast "Flow forked!" + navigate('/my-trees') + → My Trees page loads, forked flow shows "Fork" badge +``` + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `frontend/src/types/tree.ts` | Add `ForkInfo`, fork fields on `Tree`, `fork_depth` on `TreeListItem` | +| `frontend/src/components/library/ForkModal.tsx` | New component | +| `frontend/src/pages/TreeLibraryPage.tsx` | Open modal instead of silent fork | +| `frontend/src/pages/MyTreesPage.tsx` | Open modal instead of silent fork | +| `frontend/src/components/library/TreeGridView.tsx` | Fork badge | +| `frontend/src/components/library/TreeListView.tsx` | Fork badge | +| `frontend/src/components/library/TreeTableView.tsx` | Fork badge | diff --git a/docs/plans/2026-02-25-flow-to-library-sync-design.md b/docs/plans/2026-02-25-flow-to-library-sync-design.md new file mode 100644 index 00000000..a1f77b42 --- /dev/null +++ b/docs/plans/2026-02-25-flow-to-library-sync-design.md @@ -0,0 +1,187 @@ +# Flow-to-Library Step Sync Design + +> **Date:** 2026-02-25 +> **Feature:** Automatically sync steps from published flows into the step library + +--- + +## Overview + +When a flow is published, its steps are extracted and written into the `step_library` table so engineers can discover and reuse them when building new flows or inserting ad-hoc steps during a live session. Library entries are flow-owned and read-only — forking creates a personal copy for customization. + +**What gets synced:** +- `procedural` / `maintenance` flows → each `procedure_step` node → `step_type: 'action'` +- `troubleshooting` flows → each `action` node → `step_type: 'action'`; each `solution` node → `step_type: 'solution'` +- `section_header` and `procedure_end` nodes are NOT synced as library entries + +**Sync trigger:** `PUT /trees/{tree_id}` when `status` transitions to `'published'` + +**Sync model:** Upsert keyed on `(source_tree_id, source_node_id)` — subsequent publishes update existing entries without losing usage counts or ratings. + +--- + +## Section 1: Data Model + +### New columns on `step_library` (one migration) + +| Column | Type | Default | Notes | +|--------|------|---------|-------| +| `source_tree_id` | UUID FK → `trees.id` | NULL | SET NULL on tree delete | +| `source_node_id` | String(255) | NULL | Node `id` within `tree_structure` JSONB | +| `is_flow_synced` | Boolean | `false` | Distinguishes synced from manually created entries | +| `last_synced_at` | DateTime(timezone=True) | NULL | Timestamp of last sync | + +### New optional field on `StepContent` schema + +Add `group_label: Optional[str] = None` to `StepContent` in `backend/app/schemas/step_library.py`. For procedural steps that belong to a section, this stores the section header title so steps are browsable/filterable by section in the library. + +### Per-step visibility override on procedural step nodes + +Add optional `library_visibility` field to individual step nodes in `tree_structure` JSONB: +- Type: `'team' | 'public'` (no `'private'` — synced steps are always at minimum team-visible) +- If absent: inherits visibility from the flow (default behavior) +- Stored directly on the step node in `tree_structure` — no schema migration needed (JSONB is flexible) + +### Visibility inheritance mapping + +| Flow state | Resolved step visibility | +|-----------|--------------------------| +| `is_public=True` | `'public'` | +| `is_public=False`, has `account_id` | `'team'` | +| `is_public=False`, no `account_id` | `'team'` | +| Step has `library_visibility` set | Use that value (overrides above) | + +### On flow deactivation / deletion + +When `is_active` is set to `False` on a tree, or the tree is deleted, soft-delete all synced library entries for that tree: `UPDATE step_library SET is_active=False WHERE source_tree_id=:tree_id AND is_flow_synced=True`. + +Forked copies (`is_flow_synced=False`, different `created_by`) are unaffected. + +--- + +## Section 2: Sync Logic (Backend) + +### Trigger location + +`backend/app/api/endpoints/trees.py` — `update_tree()` function, after the block at line ~587 where `status` is confirmed to be transitioning to `'published'`. + +### Extraction logic + +**For procedural/maintenance flows:** +``` +steps = tree_structure.get('steps', []) +for node in steps: + if node['type'] != 'procedure_step': + continue + # find the most recent section_header preceding this step + group_label = last_seen_section_header_title + yield StepLibraryUpsert( + title=node['title'], + step_type='action', + content=StepContent( + instructions=node.get('description') or node['title'], + help_text=node.get('expected_outcome'), + commands=[StepCommand(label=c.get('label',''), command=c['code'], command_type=c.get('language')) + for c in normalize_commands(node.get('commands'))], + group_label=group_label, + ), + visibility=node.get('library_visibility') or resolve_visibility(tree), + source_tree_id=tree.id, + source_node_id=node['id'], + ) +``` + +**For troubleshooting flows:** +``` +walk all nodes recursively +for node with type in ('action', 'solution'): + yield StepLibraryUpsert( + title=node['title'], + step_type='action' if node['type']=='action' else 'solution', + content=StepContent( + instructions=node.get('description') or node['title'], + ), + visibility=resolve_visibility(tree), + source_tree_id=tree.id, + source_node_id=node['id'], + ) +``` + +**Command normalization:** `node.commands` can be a plain string or an array of `{language, code, label}` objects. Normalize both into `StepCommand` list. + +### Upsert query + +```sql +INSERT INTO step_library (id, title, step_type, content, visibility, created_by, + account_id, is_flow_synced, source_tree_id, source_node_id, last_synced_at, ...) +VALUES (...) +ON CONFLICT (source_tree_id, source_node_id) +DO UPDATE SET + title = EXCLUDED.title, + content = EXCLUDED.content, + visibility = EXCLUDED.visibility, + last_synced_at = EXCLUDED.last_synced_at, + is_active = true -- re-activate if previously soft-deleted +``` + +Requires a unique constraint on `(source_tree_id, source_node_id)`. + +### `created_by` for synced entries + +Set to the tree's `author_id`. This gives the flow author "ownership" of the entry, consistent with the flow-owned model. Permissions in `core/permissions.py` already allow the creator to see their own private steps — no change needed. + +--- + +## Section 3: Per-Step Visibility Override (Editor) + +### Where + +`frontend/src/components/procedural-editor/StepEditor.tsx` — inside the existing "More Options" collapsible section. + +### What + +A **"Library Visibility"** select field, shown only for `procedure_step` nodes (not section headers, not end nodes): + +``` +Library Visibility +[ Inherit from flow ▼ ] (options: Inherit from flow / Team only / Public) +``` + +- Default (no `library_visibility` on node): renders as "Inherit from flow" +- Selecting "Team only" or "Public" writes `library_visibility: 'team'` or `library_visibility: 'public'` to the node +- Selecting "Inherit from flow" removes the `library_visibility` key from the node + +Only rendered when `tree_type` is `'procedural'` or `'maintenance'`. Troubleshooting flows have no per-node override (they inherit the flow visibility always). + +--- + +## Section 4: Frontend — Step Library Browser + +### Changes to `StepLibraryBrowser` / step list + +- **"From Flow" badge** on synced entries (`is_flow_synced: true`): small chip — same style as existing type badges. Shows source flow name. +- **Step detail/preview panel**: add "Sourced from: [Flow Name]" line with a link to the flow's navigate/edit page. +- **Read-only indicator**: for `is_flow_synced` entries, replace the Edit button with a lock icon + tooltip: "Managed by source flow — fork to customize." +- **Fork behavior**: existing "Save to Library" copy mechanism unchanged. Forked copy gets `is_flow_synced=false`, `source_tree_id=null`, `created_by=current_user`. + +### API response changes + +`StepLibraryResponse` needs two new fields: +- `is_flow_synced: bool` +- `source_tree_name: Optional[str]` — joined from `trees.name` at query time + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `backend/alembic/versions/030_add_step_library_sync_fields.py` | New migration — add 4 columns + unique constraint | +| `backend/app/models/step_library.py` | Add 4 new columns + FK relationship to Tree | +| `backend/app/schemas/step_library.py` | Add `group_label` to `StepContent`; add `is_flow_synced` + `source_tree_name` to response schema | +| `backend/app/api/endpoints/trees.py` | Add sync logic after publish transition | +| `backend/app/core/step_sync.py` | New module — extraction + upsert logic (keeps trees.py clean) | +| `backend/tests/test_step_sync.py` | New test file | +| `frontend/src/types/step.ts` | Add `is_flow_synced`, `source_tree_name` to `Step` type | +| `frontend/src/components/procedural-editor/StepEditor.tsx` | Add Library Visibility select in More Options | +| `frontend/src/components/step-library/StepLibraryBrowser.tsx` | From Flow badge, read-only indicator, source flow link | diff --git a/docs/plans/2026-02-25-flow-to-library-sync.md b/docs/plans/2026-02-25-flow-to-library-sync.md new file mode 100644 index 00000000..12fe8fb0 --- /dev/null +++ b/docs/plans/2026-02-25-flow-to-library-sync.md @@ -0,0 +1,1065 @@ +# Flow-to-Library Step Sync Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** When a flow is published, extract its steps (procedure_steps from procedural/maintenance flows; action/solution nodes from troubleshooting flows) and upsert them into the `step_library` table, keeping them in sync on subsequent publishes, and showing a "From Flow" read-only indicator in the library browser. + +**Architecture:** New migration adds 4 columns to `step_library` + a unique constraint on `(source_tree_id, source_node_id)`. A new `backend/app/core/step_sync.py` module handles all extraction and upsert logic. The `update_tree` endpoint calls `sync_steps_from_tree()` after a successful publish. Frontend adds `is_flow_synced` + `source_tree_name` to the Step type, a read-only lock in `StepCard`, and a "Library Visibility" select in `StepEditor`. + +**Tech Stack:** Python FastAPI, SQLAlchemy 2.0 async, PostgreSQL JSONB, Alembic, React 19 + TypeScript + Tailwind CSS. + +--- + +## Codebase Context + +- **Publish endpoint:** `backend/app/api/endpoints/trees.py:546` — `update_tree()`. Publish validation at line 587. Version increment at line 639. Step sync should be inserted after line 641 (after version increment, before tag replacement at line 643). +- **Step library model:** `backend/app/models/step_library.py` — `StepLibrary` table, 20 cols. No `source_tree_id` yet. +- **Step schemas:** `backend/app/schemas/step_library.py` — `StepContent` (lines 15–19), `StepLibraryResponse` (lines 45–64). No `is_flow_synced` yet. +- **Frontend Step type:** `frontend/src/types/step.ts:15` — `Step` interface. No `is_flow_synced` yet. +- **StepCard:** `frontend/src/components/step-library/StepCard.tsx` — ownership check via `isOwn = step.created_by === currentUserId`. +- **StepEditor More Options:** `frontend/src/components/procedural-editor/StepEditor.tsx:138` — `{showMore &&
}` block inside the More Options section. +- **Fixtures:** `client`, `test_db`, `test_user`, `auth_headers`, `test_tree`, `test_admin`, `admin_auth_headers` from `backend/tests/conftest.py`. +- **Next migration:** base off `e65b9f8fd458_add_feedback_table.py`. +- **Test runner:** `cd backend && backend/venv/bin/python -m pytest tests/test_step_sync.py -v --override-ini="addopts="` + +--- + +### Task 1: Database migration — add sync columns to `step_library` + +**Files:** +- Create: `backend/alembic/versions/030_add_step_library_sync_fields.py` + +**Step 1: Create the migration file manually (do NOT use autogenerate)** + +```bash +cd /home/michaelchihlas/dev/patherly/backend +alembic revision -m "add_step_library_sync_fields" +``` + +This creates a new file in `backend/alembic/versions/`. Open it and replace the `upgrade()` and `downgrade()` bodies with: + +```python +def upgrade() -> None: + op.add_column('step_library', sa.Column('source_tree_id', sa.UUID(), nullable=True)) + op.add_column('step_library', sa.Column('source_node_id', sa.String(255), nullable=True)) + op.add_column('step_library', sa.Column('is_flow_synced', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('step_library', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True)) + op.create_foreign_key( + 'fk_step_library_source_tree', + 'step_library', 'trees', + ['source_tree_id'], ['id'], + ondelete='SET NULL' + ) + op.create_unique_constraint( + 'uq_step_library_source_node', + 'step_library', + ['source_tree_id', 'source_node_id'] + ) + op.create_index('ix_step_library_source_tree_id', 'step_library', ['source_tree_id']) + +def downgrade() -> None: + op.drop_index('ix_step_library_source_tree_id', 'step_library') + op.drop_constraint('uq_step_library_source_node', 'step_library', type_='unique') + op.drop_constraint('fk_step_library_source_tree', 'step_library', type_='foreignkey') + op.drop_column('step_library', 'last_synced_at') + op.drop_column('step_library', 'is_flow_synced') + op.drop_column('step_library', 'source_node_id') + op.drop_column('step_library', 'source_tree_id') +``` + +**Step 2: Run the migration** + +```bash +cd /home/michaelchihlas/dev/patherly/backend +alembic upgrade head +``` + +Expected: `Running upgrade ... -> , add_step_library_sync_fields` + +**Step 3: Verify the columns exist** + +```bash +docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d step_library" | grep -E "source_|is_flow|last_sync" +``` + +Expected: 4 new columns listed. + +**Step 4: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add backend/alembic/versions/ +git commit -m "feat: add sync tracking columns to step_library + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 2: Update `StepLibrary` model with new columns + +**Files:** +- Modify: `backend/app/models/step_library.py` + +**Step 1: Read the file to find the exact insertion point** + +Read `backend/app/models/step_library.py` — find the `is_active` column (last column before relationships). Add the 4 new columns after `is_active`: + +```python +# Sync tracking (flow-sourced steps) +source_tree_id: Mapped[Optional[uuid_pkg.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey('trees.id', ondelete='SET NULL'), + nullable=True, + index=True +) +source_node_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) +is_flow_synced: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) +last_synced_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) +``` + +**Step 2: Add the `source_tree` relationship** + +In the relationships section of `StepLibrary`, add after the existing `account` relationship: + +```python +source_tree: Mapped[Optional["Tree"]] = relationship( + "Tree", + foreign_keys=[source_tree_id], + lazy="select" +) +``` + +**Step 3: Verify imports** + +The file already imports `Optional`, `UUID`, `Boolean`, `String`, `DateTime`, `ForeignKey`, `relationship`. Confirm `mapped_column` and `Mapped` are imported. No new imports should be needed. + +**Step 4: Run the backend to check for import errors** + +```bash +cd /home/michaelchihlas/dev/patherly/backend +backend/venv/bin/python -c "from app.models.step_library import StepLibrary; print('OK')" +``` + +Expected: `OK` + +**Step 5: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add backend/app/models/step_library.py +git commit -m "feat: add sync columns and source_tree relationship to StepLibrary model + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 3: Update schemas and add `group_label` to `StepContent` + +**Files:** +- Modify: `backend/app/schemas/step_library.py` + +**Step 1: Add `group_label` to `StepContent`** + +Find `StepContent` (lines 15–19) and add `group_label`: + +```python +class StepContent(BaseModel): + instructions: str = Field(..., min_length=1) + help_text: Optional[str] = None + commands: Optional[list[StepCommand]] = None + group_label: Optional[str] = None # Section header this step belongs to (for flow-synced steps) +``` + +**Step 2: Add `is_flow_synced` and `source_tree_name` to `StepLibraryResponse`** + +Find `StepLibraryResponse` (lines 45–64) and add two fields at the end: + +```python + is_flow_synced: bool = False + source_tree_name: Optional[str] = None +``` + +**Step 3: Verify import — `Optional` is already imported from typing** + +Read the top of the file to confirm. No new imports needed. + +**Step 4: Verify Python import** + +```bash +cd /home/michaelchihlas/dev/patherly/backend +backend/venv/bin/python -c "from app.schemas.step_library import StepLibraryResponse, StepContent; print('OK')" +``` + +Expected: `OK` + +**Step 5: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add backend/app/schemas/step_library.py +git commit -m "feat: add group_label to StepContent, is_flow_synced to StepLibraryResponse + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 4: Update step list endpoint to return `is_flow_synced` and `source_tree_name` + +**Files:** +- Modify: `backend/app/api/endpoints/steps.py` + +The list endpoint currently returns `StepLibraryResponse` objects built from ORM instances. We need to populate `is_flow_synced` and `source_tree_name`. + +**Step 1: Read `backend/app/api/endpoints/steps.py` lines 58–139** + +Find where the list query is executed and where response objects are built. Look for the SELECT statement and how it maps to `StepLibraryResponse`. + +**Step 2: Add `source_tree_name` to the list query via a join** + +The list query will need to LEFT JOIN trees to get the name. Find the existing query and add: + +```python +from app.models.tree import Tree as TreeModel + +# In the list query, add a join: +query = ( + select( + StepLibrary, + TreeModel.name.label('source_tree_name') + ) + .outerjoin(TreeModel, StepLibrary.source_tree_id == TreeModel.id) + # ... existing filters ... +) +``` + +Then when building the response, pass `source_tree_name` from the joined result. + +**Important:** Read the existing query structure carefully before modifying — match whatever pattern (scalar results, row mapping, etc.) is already used. + +**Step 3: Also update the single-step GET endpoint** (lines 221–265) to include `source_tree_name` in the same way. + +**Step 4: Verify Python import** + +```bash +cd /home/michaelchihlas/dev/patherly/backend +backend/venv/bin/python -c "from app.api.endpoints.steps import router; print('OK')" +``` + +Expected: `OK` + +**Step 5: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add backend/app/api/endpoints/steps.py +git commit -m "feat: include is_flow_synced and source_tree_name in step list/detail responses + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 5: Create `backend/app/core/step_sync.py` + +**Files:** +- Create: `backend/app/core/step_sync.py` +- Test: `backend/tests/test_step_sync.py` + +This is the core of the feature. Write the test first. + +**Step 1: Write the failing tests** + +Create `backend/tests/test_step_sync.py`: + +```python +"""Tests for flow-to-library step sync.""" +import pytest +from uuid import uuid4 +from app.core.step_sync import extract_steps_for_sync, resolve_step_visibility + + +class TestResolveStepVisibility: + """Test visibility resolution logic.""" + + def test_public_flow_gives_public_steps(self): + result = resolve_step_visibility(is_public=True, account_id=None, node_override=None) + assert result == 'public' + + def test_team_flow_gives_team_steps(self): + result = resolve_step_visibility(is_public=False, account_id=uuid4(), node_override=None) + assert result == 'team' + + def test_private_flow_gives_team_steps(self): + result = resolve_step_visibility(is_public=False, account_id=None, node_override=None) + assert result == 'team' + + def test_node_override_takes_precedence(self): + result = resolve_step_visibility(is_public=True, account_id=None, node_override='team') + assert result == 'team' + + def test_public_override_on_team_flow(self): + result = resolve_step_visibility(is_public=False, account_id=uuid4(), node_override='public') + assert result == 'public' + + +class TestExtractStepsForSync: + """Test step extraction from tree structures.""" + + def test_extracts_procedure_steps_from_procedural_flow(self): + tree_structure = { + "steps": [ + {"id": "step_1", "type": "procedure_step", "title": "Verify prerequisites", + "description": "Check all prereqs", "content_type": "action"}, + {"id": "end_1", "type": "procedure_end", "title": "Done"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert len(results) == 1 + assert results[0]['source_node_id'] == 'step_1' + assert results[0]['title'] == 'Verify prerequisites' + assert results[0]['step_type'] == 'action' + assert results[0]['content']['instructions'] == 'Check all prereqs' + + def test_skips_section_header_nodes(self): + tree_structure = { + "steps": [ + {"id": "sec_1", "type": "section_header", "title": "Phase 1"}, + {"id": "step_1", "type": "procedure_step", "title": "First step", + "description": "Do this"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert len(results) == 1 + assert results[0]['source_node_id'] == 'step_1' + + def test_captures_section_header_as_group_label(self): + tree_structure = { + "steps": [ + {"id": "sec_1", "type": "section_header", "title": "Cable Checks"}, + {"id": "step_1", "type": "procedure_step", "title": "Check cable", + "description": "Verify cable is seated"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert results[0]['content']['group_label'] == 'Cable Checks' + + def test_normalizes_string_commands(self): + tree_structure = { + "steps": [ + {"id": "step_1", "type": "procedure_step", "title": "Run command", + "description": "Execute this", "commands": "ping 8.8.8.8"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert results[0]['content']['commands'] == [{"label": "", "command": "ping 8.8.8.8", "command_type": None}] + + def test_normalizes_commandblock_commands(self): + tree_structure = { + "steps": [ + {"id": "step_1", "type": "procedure_step", "title": "Run PS", + "description": "Run powershell", + "commands": [{"code": "Get-Service", "language": "powershell", "label": "Check services"}]}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + cmds = results[0]['content']['commands'] + assert len(cmds) == 1 + assert cmds[0]['command'] == 'Get-Service' + assert cmds[0]['command_type'] == 'powershell' + assert cmds[0]['label'] == 'Check services' + + def test_extracts_action_and_solution_from_troubleshooting(self): + tree_structure = { + "id": "root", + "type": "decision", + "question": "What is wrong?", + "options": [{"id": "o1", "label": "Thing A", "next_node_id": "act_1"}], + "children": [ + {"id": "act_1", "type": "action", "title": "Fix thing A", + "description": "Do the fix", "next_node_id": "sol_1", + "children": [{"id": "sol_1", "type": "solution", "title": "All fixed", + "description": "Problem resolved"}]}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='troubleshooting')) + node_ids = {r['source_node_id'] for r in results} + assert 'act_1' in node_ids + assert 'sol_1' in node_ids + types = {r['source_node_id']: r['step_type'] for r in results} + assert types['act_1'] == 'action' + assert types['sol_1'] == 'solution' + + def test_uses_title_as_instructions_fallback(self): + tree_structure = { + "steps": [ + {"id": "step_1", "type": "procedure_step", "title": "Do the thing"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert results[0]['content']['instructions'] == 'Do the thing' + + def test_empty_steps_list(self): + tree_structure = {"steps": []} + results = list(extract_steps_for_sync(tree_structure, tree_type='procedural')) + assert results == [] + + def test_maintenance_treated_same_as_procedural(self): + tree_structure = { + "steps": [ + {"id": "step_1", "type": "procedure_step", "title": "Maintenance step", + "description": "Do maintenance"}, + ] + } + results = list(extract_steps_for_sync(tree_structure, tree_type='maintenance')) + assert len(results) == 1 +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd /home/michaelchihlas/dev/patherly/backend +backend/venv/bin/python -m pytest tests/test_step_sync.py -v --override-ini="addopts=" +``` + +Expected: FAIL — `ImportError: cannot import name 'extract_steps_for_sync'` + +**Step 3: Implement `backend/app/core/step_sync.py`** + +```python +"""Sync steps from published flows into the step library.""" +from __future__ import annotations +from typing import Any, Generator, Literal, Optional +from uuid import UUID, uuid4 +from datetime import datetime, timezone + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +StepVisibility = Literal['private', 'team', 'public'] + + +def resolve_step_visibility( + is_public: bool, + account_id: Optional[UUID], + node_override: Optional[str], +) -> StepVisibility: + """Resolve the visibility for a synced step. + + Priority: node-level library_visibility > flow visibility. + Flow visibility: public if is_public, otherwise 'team'. + """ + if node_override in ('team', 'public'): + return node_override # type: ignore[return-value] + return 'public' if is_public else 'team' + + +def _normalize_commands(raw: Any) -> list[dict]: + """Normalize command field to list of StepCommand dicts.""" + if not raw: + return [] + if isinstance(raw, str): + return [{"label": "", "command": raw, "command_type": None}] + if isinstance(raw, list): + result = [] + for item in raw: + if isinstance(item, str): + result.append({"label": "", "command": item, "command_type": None}) + elif isinstance(item, dict): + result.append({ + "label": item.get("label", ""), + "command": item.get("code", item.get("command", "")), + "command_type": item.get("language", item.get("command_type")), + }) + return result + return [] + + +def _walk_troubleshooting(node: dict) -> Generator[dict, None, None]: + """Recursively yield action and solution nodes from a troubleshooting tree.""" + node_type = node.get("type") + if node_type in ("action", "solution"): + yield node + for child in node.get("children", []): + yield from _walk_troubleshooting(child) + + +def extract_steps_for_sync( + tree_structure: dict, + tree_type: str, +) -> Generator[dict, None, None]: + """Extract step dicts ready for upsert from a tree structure. + + Yields dicts with keys: + source_node_id, title, step_type, content (dict), node_visibility_override + """ + if tree_type in ("procedural", "maintenance"): + steps = tree_structure.get("steps", []) + current_section: Optional[str] = None + for node in steps: + node_type = node.get("type") + if node_type == "section_header": + current_section = node.get("title") or node.get("section_header") + continue + if node_type != "procedure_step": + continue + description = node.get("description") or node.get("title", "") + content: dict = { + "instructions": description, + "help_text": node.get("expected_outcome"), + "commands": _normalize_commands(node.get("commands")) or None, + "group_label": current_section, + } + # Remove None values for cleanliness + content = {k: v for k, v in content.items() if v is not None} + # instructions is required — ensure it's present + content.setdefault("instructions", node.get("title", "")) + yield { + "source_node_id": node["id"], + "title": node.get("title", "Untitled step"), + "step_type": "action", + "content": content, + "node_visibility_override": node.get("library_visibility"), + } + + elif tree_type == "troubleshooting": + for node in _walk_troubleshooting(tree_structure): + description = node.get("description") or node.get("title", "") + content = { + "instructions": description, + } + yield { + "source_node_id": node["id"], + "title": node.get("title", "Untitled step"), + "step_type": "action" if node["type"] == "action" else "solution", + "content": content, + "node_visibility_override": None, + } + + +async def sync_steps_from_tree( + db: AsyncSession, + tree_id: UUID, + tree_type: str, + tree_structure: dict, + author_id: UUID, + account_id: Optional[UUID], + is_public: bool, +) -> int: + """Upsert step library entries from a published tree. + + Returns the number of steps synced. + """ + from app.models.step_library import StepLibrary # avoid circular import + + count = 0 + now = datetime.now(timezone.utc) + + for step_data in extract_steps_for_sync(tree_structure, tree_type): + visibility = resolve_step_visibility( + is_public=is_public, + account_id=account_id, + node_override=step_data["node_visibility_override"], + ) + + # Use raw SQL upsert keyed on (source_tree_id, source_node_id) + await db.execute( + text(""" + INSERT INTO step_library ( + id, title, step_type, content, created_by, account_id, + visibility, is_flow_synced, source_tree_id, source_node_id, + last_synced_at, tags, is_active, + usage_count, rating_average, rating_count, + helpful_yes, helpful_no, is_featured, is_verified, + created_at, updated_at + ) VALUES ( + gen_random_uuid(), :title, :step_type, :content::jsonb, + :created_by, :account_id, :visibility, true, + :source_tree_id, :source_node_id, :last_synced_at, + '{}', true, + 0, 0, 0, 0, 0, false, false, + :now, :now + ) + ON CONFLICT (source_tree_id, source_node_id) + DO UPDATE SET + title = EXCLUDED.title, + step_type = EXCLUDED.step_type, + content = EXCLUDED.content, + visibility = EXCLUDED.visibility, + last_synced_at = EXCLUDED.last_synced_at, + updated_at = EXCLUDED.updated_at, + is_active = true + """), + { + "title": step_data["title"], + "step_type": step_data["step_type"], + "content": __import__('json').dumps(step_data["content"]), + "created_by": str(author_id), + "account_id": str(account_id) if account_id else None, + "visibility": visibility, + "source_tree_id": str(tree_id), + "source_node_id": step_data["source_node_id"], + "last_synced_at": now, + "now": now, + } + ) + count += 1 + + # Soft-delete any previously synced steps for this tree that no longer exist + current_node_ids = [ + s["source_node_id"] + for s in extract_steps_for_sync(tree_structure, tree_type) + ] + if current_node_ids: + await db.execute( + text(""" + UPDATE step_library + SET is_active = false, updated_at = :now + WHERE source_tree_id = :tree_id + AND is_flow_synced = true + AND source_node_id NOT IN :node_ids + """), + {"tree_id": str(tree_id), "node_ids": tuple(current_node_ids), "now": now} + ) + else: + # No steps extracted — deactivate all synced entries for this tree + await db.execute( + text(""" + UPDATE step_library + SET is_active = false, updated_at = :now + WHERE source_tree_id = :tree_id AND is_flow_synced = true + """), + {"tree_id": str(tree_id), "now": now} + ) + + return count + + +async def deactivate_synced_steps_for_tree(db: AsyncSession, tree_id: UUID) -> None: + """Soft-delete all synced library entries for a tree (called on tree delete/deactivate).""" + await db.execute( + text(""" + UPDATE step_library + SET is_active = false, updated_at = :now + WHERE source_tree_id = :tree_id AND is_flow_synced = true + """), + {"tree_id": str(tree_id), "now": datetime.now(timezone.utc)} + ) +``` + +**Step 4: Run tests** + +```bash +cd /home/michaelchihlas/dev/patherly/backend +backend/venv/bin/python -m pytest tests/test_step_sync.py -v --override-ini="addopts=" +``` + +Expected: All tests PASS. + +**Step 5: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add backend/app/core/step_sync.py backend/tests/test_step_sync.py +git commit -m "feat: add step_sync module with extraction and upsert logic + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 6: Wire sync into the publish endpoint + +**Files:** +- Modify: `backend/app/api/endpoints/trees.py` + +**Step 1: Read lines 583–650 of the file to confirm exact positions** + +Confirm line numbers for: +- The `if "status" in update_data and update_data["status"] == 'published':` block +- The version increment at `tree.version += 1` +- The tag replacement block that follows + +**Step 2: Add the import at the top of the file** + +Near the other core imports (grep for `from app.core`), add: + +```python +from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree +``` + +**Step 3: Insert sync call after version increment** + +After `tree.version += 1` (line ~641) and before the tag replacement block, add: + +```python + # Sync steps to library when publishing + if update_data.get("status") == 'published' or tree.status == 'published': + final_structure = update_data.get("tree_structure", tree.tree_structure) + final_type = update_data.get("tree_type", tree.tree_type) + await sync_steps_from_tree( + db=db, + tree_id=tree.id, + tree_type=final_type, + tree_structure=final_structure, + author_id=tree.author_id, + account_id=tree.account_id, + is_public=update_data.get("is_public", tree.is_public), + ) +``` + +**Step 4: Add deactivation on tree delete** + +Find the delete tree endpoint (search for `@router.delete`). After confirming the tree is being deleted, add before commit: + +```python +await deactivate_synced_steps_for_tree(db, tree_id) +``` + +**Step 5: Verify Python import** + +```bash +cd /home/michaelchihlas/dev/patherly/backend +backend/venv/bin/python -c "from app.api.endpoints.trees import router; print('OK')" +``` + +Expected: `OK` + +**Step 6: Write integration test** + +Add to `backend/tests/test_step_sync.py` a new class: + +```python +class TestSyncOnPublish: + """Integration tests — sync triggered by publishing a tree.""" + + @pytest.mark.asyncio + async def test_publishing_procedural_tree_creates_library_steps( + self, client, auth_headers, test_db + ): + # Create a procedural tree + tree_resp = await client.post("/trees", json={ + "name": "Test Procedure", + "tree_type": "procedural", + "tree_structure": { + "steps": [ + {"id": "step_1", "type": "procedure_step", + "title": "First step", "description": "Do this first"}, + {"id": "step_2", "type": "procedure_step", + "title": "Second step", "description": "Do this second"}, + {"id": "end_1", "type": "procedure_end", "title": "Done"}, + ] + } + }, headers=auth_headers) + assert tree_resp.status_code == 201 + tree_id = tree_resp.json()["id"] + + # Publish the tree + pub_resp = await client.put(f"/trees/{tree_id}", json={"status": "published"}, headers=auth_headers) + assert pub_resp.status_code == 200 + + # Check library has entries + lib_resp = await client.get("/steps", headers=auth_headers) + assert lib_resp.status_code == 200 + steps = lib_resp.json() + synced = [s for s in steps if s.get("is_flow_synced")] + assert len(synced) == 2 + titles = {s["title"] for s in synced} + assert "First step" in titles + assert "Second step" in titles + + @pytest.mark.asyncio + async def test_republishing_updates_existing_library_steps( + self, client, auth_headers, test_db + ): + # Create and publish + tree_resp = await client.post("/trees", json={ + "name": "Update Test", + "tree_type": "procedural", + "tree_structure": {"steps": [ + {"id": "step_1", "type": "procedure_step", + "title": "Original title", "description": "Original desc"}, + ]} + }, headers=auth_headers) + tree_id = tree_resp.json()["id"] + await client.put(f"/trees/{tree_id}", json={"status": "published"}, headers=auth_headers) + + # Update step title and republish + await client.put(f"/trees/{tree_id}", json={ + "tree_structure": {"steps": [ + {"id": "step_1", "type": "procedure_step", + "title": "Updated title", "description": "Updated desc"}, + ]}, + "status": "published" + }, headers=auth_headers) + + # Check library entry was updated + lib_resp = await client.get("/steps", headers=auth_headers) + synced = [s for s in lib_resp.json() if s.get("is_flow_synced")] + assert len(synced) == 1 + assert synced[0]["title"] == "Updated title" +``` + +**Step 7: Run integration tests** + +```bash +cd /home/michaelchihlas/dev/patherly/backend +backend/venv/bin/python -m pytest tests/test_step_sync.py -v --override-ini="addopts=" +``` + +Expected: All tests pass. + +**Step 8: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add backend/app/api/endpoints/trees.py backend/tests/test_step_sync.py +git commit -m "feat: trigger step library sync on tree publish/delete + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 7: Frontend — update Step types + +**Files:** +- Modify: `frontend/src/types/step.ts` + +**Step 1: Read the file to find `Step` and `StepListItem` interfaces** + +**Step 2: Add `is_flow_synced` and `source_tree_name` to `Step` (after `is_verified`)** + +```typescript + is_flow_synced: boolean + source_tree_name: string | null +``` + +**Step 3: Add same fields to `StepListItem`** + +`StepListItem` is the subset used in list views — add there too: + +```typescript + is_flow_synced: boolean + source_tree_name: string | null +``` + +**Step 4: Verify build** + +```bash +cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 5: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add frontend/src/types/step.ts +git commit -m "feat: add is_flow_synced and source_tree_name to Step types + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 8: Frontend — read-only indicator in `StepCard` + +**Files:** +- Modify: `frontend/src/components/step-library/StepCard.tsx` + +**Step 1: Read the full `StepCard.tsx` to understand current structure** + +Find where the Edit button is rendered (look for `onEdit` prop usage and the edit button JSX). + +**Step 2: Add `Lock` to Lucide imports** + +```tsx +import { ..., Lock } from 'lucide-react' +``` + +**Step 3: Replace the edit button condition** + +Currently the edit button likely shows when `isOwn` is true. Change it so that for flow-synced steps, the edit button is replaced by a lock icon with a tooltip: + +```tsx +{isOwn && ( + step.is_flow_synced ? ( + + + + ) : ( + onEdit && ( + + ) + ) +)} +``` + +**Step 4: Add "From Flow" badge** + +In the badges/chips area of the card (near where `is_featured` or `is_verified` badges are shown), add: + +```tsx +{step.is_flow_synced && ( + + From Flow + +)} +``` + +**Step 5: Verify build** + +```bash +cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 6: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add frontend/src/components/step-library/StepCard.tsx +git commit -m "feat: show From Flow badge and lock icon on flow-synced StepCard + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 9: Frontend — source flow link in `StepDetailModal` + +**Files:** +- Modify: `frontend/src/components/step-library/StepDetailModal.tsx` + +**Step 1: Read `StepDetailModal.tsx` to find where step metadata is displayed** + +Look for where `author_name`, `usage_count`, `category_name` are rendered in the detail panel. + +**Step 2: Add source flow attribution** + +In the metadata section, add after the author line: + +```tsx +{step.is_flow_synced && step.source_tree_name && ( +
+ + Sourced from {step.source_tree_name} +
+)} +``` + +Add `GitBranch` to Lucide imports if not already present. + +**Step 3: Verify build** + +```bash +cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 4: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add frontend/src/components/step-library/StepDetailModal.tsx +git commit -m "feat: show source flow name in StepDetailModal for synced steps + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 10: Frontend — Library Visibility select in `StepEditor` + +**Files:** +- Modify: `frontend/src/components/procedural-editor/StepEditor.tsx` +- Modify: `frontend/src/types/tree.ts` (add `library_visibility` to `ProceduralStep`) + +**Step 1: Add `library_visibility` to `ProceduralStep` in `frontend/src/types/tree.ts`** + +Find `ProceduralStep` (line ~106) and add after `reference_url?`: + +```typescript + library_visibility?: 'team' | 'public' +``` + +**Step 2: Read `StepEditor.tsx` lines 130–200 to find the More Options block** + +The `{showMore &&
}` block starts around line 150. Find the last field inside it. + +**Step 3: Add Library Visibility select inside the More Options block** + +At the end of the `showMore` content block, before the closing `
`, add: + +```tsx +{/* Library Visibility — only for procedure_step nodes */} +{step.type === 'procedure_step' && ( +
+ + +

+ Controls visibility in the step library. Defaults to the flow's own visibility setting. +

+
+)} +``` + +**Step 4: Verify build** + +```bash +cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 5: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add frontend/src/types/tree.ts frontend/src/components/procedural-editor/StepEditor.tsx +git commit -m "feat: add Library Visibility select to procedural StepEditor + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +## Manual Verification Checklist + +1. **Publish sync:** Create a procedural flow with 2+ steps and a section header → publish → open Step Library → synced steps appear with "From Flow" badge, correct section in group_label +2. **Republish update:** Edit a step title in a published flow → republish → Step Library shows updated title (not a duplicate) +3. **Visibility inherit:** Public flow → synced steps have `visibility: 'public'`. Team flow → `visibility: 'team'` +4. **Per-step override:** Set Library Visibility to "Team only" on a step in a public flow → publish → that step appears as `team` in library while others are `public` +5. **Read-only in library:** Synced step shows lock icon instead of edit button, tooltip reads "Managed by source flow" +6. **Source flow name:** Click a synced step → detail panel shows "Sourced from: [Flow Name]" +7. **Fork works:** Click "Save to Library" on a synced step → creates a personal copy with `is_flow_synced: false`, editable normally +8. **Troubleshooting sync:** Publish a troubleshooting flow → action and solution nodes appear in library (decision nodes do not) diff --git a/docs/plans/2026-02-25-tree-fork-ui.md b/docs/plans/2026-02-25-tree-fork-ui.md new file mode 100644 index 00000000..42eb3f5f --- /dev/null +++ b/docs/plans/2026-02-25-tree-fork-ui.md @@ -0,0 +1,493 @@ +# Tree Fork UI Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an explicit `ForkModal` with a "Reason for Forking" field to replace the silent fork flow, and show a "Fork" chip badge on forked tree cards in the library and My Trees views. + +**Architecture:** The backend is fully complete (POST `/trees/:id/fork` accepts `{ name, fork_reason }`). The frontend `treesApi.fork()` already accepts these params. We need: (1) `ForkInfo` types added to `tree.ts`, (2) a new `ForkModal` component, (3) updated fork handlers in `TreeLibraryPage` and `MyTreesPage` to open the modal instead of forking silently, (4) a "Fork" chip in all three card views (grid, list, table). + +**Tech Stack:** React 19, TypeScript, Tailwind CSS, Lucide React, `treesApi.fork(id, { name, fork_reason })` already wired. + +--- + +## Context for the Implementer + +- `treesApi.fork(id, data?)` is at `frontend/src/api/trees.ts:42` — already accepts `{ fork_reason?, name? }` +- `onForkTree` prop exists on all three card views and currently passes only `treeId: string` +- `TreeLibraryPage` has `handleForkTree(treeId: string)` at line ~247 that calls `treesApi.fork(treeId)` silently +- `MyTreesPage` does NOT currently have a fork handler — the "Fork" UI there is an informational message (line ~215), not a button wired to `onForkTree` +- `TreeListItem` (used by all three views) does NOT yet have `fork_depth` or `parent_tree_id` — must add these +- `MyTreesPage` already uses `tree.parent_tree_id` at line ~283 for a "Forked from" display block — this field must be on the type for that to compile cleanly after our changes +- All three card views are in `frontend/src/components/library/` +- Design system: `bg-violet-400/15 text-violet-400` for the Fork chip; `bg-gradient-brand` for the Fork submit button; modal structure uses `bg-card border-border rounded-xl` + +--- + +### Task 1: Add `ForkInfo` type and fork fields to `TreeListItem` and `Tree` + +**Files:** +- Modify: `frontend/src/types/tree.ts:142-190` + +This is a pure type change — no runtime behavior changes. + +**Step 1: Add `ForkInfo` interface and fork fields** + +In `frontend/src/types/tree.ts`, after line 141 (the `ProceduralTreeStructure` closing brace), add `ForkInfo` then update `Tree` and `TreeListItem`: + +```typescript +export interface ForkInfo { + parent_tree_id: string + parent_tree_name: string | null + fork_depth: number + fork_reason: string | null + has_parent_updates: boolean +} +``` + +Add to `Tree` interface (after `usage_count: number`): +```typescript + fork_info?: ForkInfo | null + parent_tree_id?: string | null + fork_depth?: number +``` + +Add to `TreeListItem` interface (after `visibility` field): +```typescript + fork_depth?: number + parent_tree_id?: string | null +``` + +**Step 2: Verify TypeScript compiles cleanly** + +```bash +cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20 +``` + +Expected: Clean build, no errors. + +**Step 3: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly +git add frontend/src/types/tree.ts +git commit -m "feat: add ForkInfo type and fork fields to Tree/TreeListItem + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 2: Create `ForkModal` component + +**Files:** +- Create: `frontend/src/components/library/ForkModal.tsx` + +**Step 1: Create the component file** + +Create `frontend/src/components/library/ForkModal.tsx` with this exact content: + +```tsx +import { useState } from 'react' +import { GitBranch, X } from 'lucide-react' +import { treesApi } from '@/api/trees' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' +import { useNavigate } from 'react-router-dom' + +interface ForkModalProps { + treeId: string + treeName: string + onClose: () => void +} + +export function ForkModal({ treeId, treeName, onClose }: ForkModalProps) { + const navigate = useNavigate() + const [name, setName] = useState(`Copy of ${treeName}`) + const [forkReason, setForkReason] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) return + setIsSubmitting(true) + setError(null) + try { + await treesApi.fork(treeId, { + name: name.trim(), + fork_reason: forkReason.trim() || undefined, + }) + toast.success('Flow forked successfully') + onClose() + navigate('/my-trees') + } catch (err) { + console.error('Failed to fork flow:', err) + setError('Failed to fork flow. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ {/* Header */} +
+
+ +

Fork Flow

+
+ +
+ + {/* Body */} +
+
+ + setName(e.target.value)} + required + autoFocus + className={cn( + 'w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground', + 'placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20' + )} + /> +
+ +
+ +